From 39cac75fbbdc861e93ec8c402f0d9d100ee2bdca Mon Sep 17 00:00:00 2001 From: npmrun <1549469775@qq.com> Date: Tue, 21 Apr 2026 11:42:22 +0800 Subject: [PATCH] docs: EJS custom pages as multi-tenant /@slug/p/:pageKey with DB table Made-with: Cursor --- .../specs/2026-04-21-personal-home-ejs-design.md | 110 +++++++++++---------- 1 file changed, 59 insertions(+), 51 deletions(-) diff --git a/docs/superpowers/specs/2026-04-21-personal-home-ejs-design.md b/docs/superpowers/specs/2026-04-21-personal-home-ejs-design.md index 8201f5b..ef0fd91 100644 --- a/docs/superpowers/specs/2026-04-21-personal-home-ejs-design.md +++ b/docs/superpowers/specs/2026-04-21-personal-home-ejs-design.md @@ -1,9 +1,13 @@ -# 设计:个人主页可选 EJS 整页渲染 +# 设计:个人主页可选 EJS 整页渲染(多页可扩展) **日期**:2026-04-21 -**状态**:已定稿(修订:与根路径解耦) +**状态**:已定稿(修订:根路径解耦 + 多页自建) -**修订说明**:不再在 **`/@:publicSlug`** 上根据设置切换整页;根路径 **始终** 为现有 Vue 主页。**个性化整站(EJS)** 使用 **单独公开路由**,避免与 Nuxt 根路由抢请求、也避免访客/站长对「同一 URL 两种形态」困惑。 +**修订说明** + +1. **`/@:publicSlug`** 不被 EJS 覆盖;**始终** Vue 默认主页。 +2. 个性化整页使用 **独立路由族**,**不**与 Nuxt 根路由抢请求。 +3. **不止一条**:支持 **多条** EJS 页面;主页所有者在控制台 **自行添加 / 编辑 / 禁用 / 删除**(具体交互由实现计划细化)。**不再**假设全局仅有一个 `custom` 字符串模板字段。 ## 1. 背景与目标 @@ -11,88 +15,92 @@ **目标**: -- **`/@:publicSlug`**:**不覆盖、不劫持**,行为与现网一致,**始终** 渲染 Vue 默认主页。 -- 新增 **独立公开路由**,在满足设置时由服务端输出 **完整 HTML**(EJS),实现「不限于本站壳」的个性化整页;模板内可链到 `/@slug/posts/...`、`timeline`、`about` 等现有 Vue 子路由。 -- **路由形态(已选)**:在 `@` 命名空间下增加 **固定子路径**,规范路径为: - - **`/@:publicSlug/custom`** - - 实现层使用常量(如 `PUBLIC_EJS_HOME_SEGMENT = 'custom'`),若需改名仅改一处;**不得**与已有动态段(如 `posts`、`about`)冲突。 -- **模板能力(已确认)**:采用 **完整 EJS**(含任意 `<% %>` 服务端逻辑);等价于在服务器上以进程身份执行 **用户可控代码**,**信任模型与滥用责任由运营侧承担**;本版 **不做** 沙箱化必选(若日后对公众开放,需另立项「受限模板 / 沙箱进程」)。 +- **`/@:publicSlug`**:**不覆盖、不劫持**,**始终** Vue。 +- 在 **`@` 命名空间**下增加 **固定前缀 + 动态页键**,由服务端在用户启用时输出 **完整 HTML**(EJS),条数 **多条**,键由用户 **添加时指定**(在约束内)。 +- 模板内可链到 `/@slug/posts/...`、`timeline`、`about` 等现有 Vue 子路由。 +- **模板能力(已确认)**:**完整 EJS**(含任意 `<% %>`);等价于在服务器上以进程身份执行 **用户可控代码**,**信任模型与滥用责任由运营侧承担**;本版 **不做** 沙箱化必选。 ## 2. 产品规则(已确认) | 维度 | 规则 | |------|------| -| 根路径 | **`/@:publicSlug`** **仅** Vue;**不**存在「根路径切 EJS」模式。 | -| 个性化页 | 仅 **`/@:publicSlug/custom`**(及同义尾部 `/`)在**启用**时由 **EJS → `text/html`** 响应;**未启用**时对访客返回 **404**(不推荐 301 到根路径,以免外站链到 `custom` 时期望稳定语义时产生循环或歧义;若产品坚持跳根,可另议)。 | -| 子路由 | `/@:publicSlug/posts/...`、`timeline`、`about` 等 **始终** Vue;`custom` 与上述 segment **并列**,**不** 接管子树。 | -| 启用与编辑 | 每用户 **`customHomeEnabled`**(命名实现可微调)与 **`homeEjsSource`**;**仅** 所有者在控制台开启并编辑模板;关闭后 **`/custom` 404**。 | -| 数据真相 | 注入 EJS 的公开数据与 **`GET /api/public/profile/:publicSlug`** **语义一致**(同一可见性、同一字段含义),通过 **共享 server 层** 组装,避免两套逻辑漂移。 | +| 根路径 | **`/@:publicSlug`** **仅** Vue。 | +| 个性化页 URL | **`/@:publicSlug//`**(及同义尾部 `/`)。`` 为 **全站常量**(建议 `p`,短且易记;实现层 `PUBLIC_EJS_PAGES_PREFIX`),**不得**与用户内容的第一段自由混淆——访客能一眼看出是「自定义页族」。`` 为 **单段** 路径(见下条)。 | +| `pageKey` | 由用户在添加页面时设定;**约束**:长度、字符集(建议 `[a-z0-9-]` 小写 storage、展示可折叠)、**保留字** 拒绝(含与站内 **一级** 路径同名者,如 `posts`、`timeline`、`about`、`reading`、**以及前缀自身** `p` 若会产生歧义时由实现明确——至少禁止 `pageKey` 为空、`.`、`..`)。**同一 `publicSlug` 下 `pageKey` 唯一**。 | +| 条数上限 | **每用户可配置页面数上限**(具体数字实现定,如 20~50),写入控制台与错误提示;本 spec 不定死数字。 | +| 命中条件 | 用户 **`publicSlug` 有效**;存在一行 **`pageKey` 匹配** 且 **启用** 且 **`ejsSource` 非空**;否则对该 URL **404**。 | +| 子路由 | `/@:publicSlug/posts/...`、`timeline`、`about` 等 **始终** Vue;EJS 仅命中 **`//:pageKey`** 双段结构,**不** 向下吞更深路径(若未来要「子路径」,另开设计)。 | +| 编辑权限 | **仅** 对应用户可在控制台 **增删改** 各页的 `pageKey`(或仅创建时可写、创建后只读——产品二选一,默认 **创建后可改 key** 易破坏外链,建议 **创建后 key 只读** 或 **改 key 视为新页 + 旧 URL 301**,实现计划定夺)。 | +| 数据真相 | 注入 EJS 的公开数据与 **`GET /api/public/profile/:publicSlug`** **语义一致**,通过 **共享 server 层** 组装;并额外注入 **当前页** 元信息(见 4.2)。 | ## 3. 非目标(本版明确不做) -- **不** 根据设置 **覆盖** **`/@:publicSlug`** 的响应体或路由解析顺序。 -- **不** 用 EJS 接管 `/@slug/**` 除 **`custom`** 之外的整棵子树;**不** 将 **`custom`** 改为通配符子站。 -- **不** 将「完整 EJS」与「沙箱必选」同时作为本版交付条件;沙箱 / Liquid 等作为 **后续** 产品阶段评估。 -- **不** 以「仅客户端 `v-html` 注入整站」冒充 **整页 SSR HTML** 的主方案(可与方案对比说明,但不替代主路径)。 +- **不** 覆盖 **`/@:publicSlug`**。 +- **不** 用 EJS 接管除 **`//:pageKey`** 以外的 `/@slug/**`。 +- **不** 在本版要求 **沙箱**;不将「受限模板」作为交付前提。 +- **不** 用仅客户端 `v-html` 冒充 **整页 SSR HTML** 主路径。 ## 4. 架构(推荐实现) -### 4.1 方案选定:Nitro 对 **`/@[slug]/custom`** 短路 +### 4.1 Nitro 短路:匹配 **`/@…/p/:pageKey`** -在 **Nitro** 侧(`server/middleware` 或等价、且顺序可控的钩子): +在 **Nitro** 侧(`server/middleware` 或顺序明确的钩子 / 专用 route): -1. 仅当请求路径 **匹配**「个性化页」:`/^\/@(?[^/]+)\/custom\/?$/`(与 **`PUBLIC_EJS_HOME_SEGMENT`** 一致;大小写策略与 **`publicSlug` 查库** 约定对齐)。 -2. 解析 `publicSlug`,查库:**用户存在** 且 **`customHomeEnabled`** 为真 且 **`homeEjsSource`** 非空(具体条件实现可收紧)。 -3. **满足**:拉取 **与公开 API 等价** 的 payload,执行 **`ejs.render`**,**`Content-Type: text/html;charset=utf-8`**,**结束响应**。 -4. **不满足**:**不写 body**、**不** 替代 404——由后续 Nitro/Nuxt 处理;若工程上尚无对应 Vue 占位页,应 **显式** `404`(避免落到无关页面)。推荐:**专用 nitro 路由** 或 **中间件内** 对「匹配 `/custom` 但未启用」直接 **`404`**。 +1. 路径匹配 **`^\/@(?[^/]+)\/\/(?[^/]+)\/?$`**(`` 与常量一致)。 +2. `publicSlug` 解析用户;按 **`(userId, pageKey)`** 查 **自定义页表**。 +3. 命中且启用:组装 `locals`,**`ejs.render`**,**`text/html`**,结束响应。 +4. **未命中**:**显式 404**(不要落到无关 Vue 页)。 -**与根路径的关系**:访问 **`/@slug`** **永不进入** 上述 EJS 分支,仅进入现有 `index.vue`。 +**与根路径**:**`/@slug`** 永不进入本分支。 ### 4.2 数据与注入 -- **单一来源**:抽取 **`getPublicProfilePayloadForHome(publicSlug)`**(名称可调整),供 **`GET /api/public/profile/:publicSlug`** 与 EJS 渲染 **共用**。 -- **`ejs.render(template, locals)`**:`locals` 至少包含: - - **`profile`**:与 API 响应体(成功时的业务 `data`)**结构一致** 的对象; - - **`site`**:站名、站点公开根 URL 等(来源与现有全局配置一致); - - **URL 辅助**:如 **`profileRootPath`** = `/@{slug}`、**`customHomePath`** = `/@{slug}/custom`,便于模板内链到站内子路径; - - **安全向 helper**:如 **`escapeHtml`**、日期展示格式化等(**鼓励** 在模板里用 helper,但 **不** 通过模板消灭 EJS 的任意代码能力——与产品选项 A 一致)。 -- **禁止项(工程习惯)**:不向 `locals` 注入开放 **`require`**、未约束文件路径等;**`ejs` 选项** 需审阅官方文档,避免 **`root` / `filename`** 等导致意外包含路径遍历;模板存储于 **DB 文本字段**,不设「从磁盘任意路径读模板」。 +- **profile 单一来源**:**`getPublicProfilePayloadForHome(publicSlug)`**(名称可调整)与 **`GET /api/public/profile/:publicSlug`** 共用。 +- **`ejs.render(template, locals)`** 建议包含: + - **`profile`**:与公开 API **`data`** 同构; + - **`site`**:站名、公开根 URL; + - **`page`**:`{ key, path }`,`path` 为当前页规范路径(含前缀),便于模板内自链; + - **URL 辅助**:**`profileRootPath`**、`buildSitePath('posts')`** 等(与现有路由约定一致); + - **安全向 helper**:`escapeHtml`、日期格式化等(与选项 A 一致,不消灭任意 EJS 能力)。 +- **禁止项**:不向 `locals` 注入无界 **`require`**;**`ejs` 选项** 避免引入磁盘任意读;模板仅存 **DB**,文件名不传真实 FS **`root`**。 ### 4.3 存储 -- 用户表或等价扩展:保存 **`customHomeEnabled`**、**`homeEjsSource`**(或拆表);**最大长度**、**版本号(可选)** 在实现层约束。 -- 保存时:**可选** 先做 **语法与编译试跑**(仅对所有者预览接口或保存路径),语法错误返回 **400**,错误信息 **不** 对匿名访客暴露内部栈。 +- **独立表**(推荐):**`user_ejs_pages`**(名可微调):`user_id`、`page_key`、`ejs_source`、`enabled`、`created_at`、`updated_at`;**唯一约束 `(user_id, page_key)`**。 +- **不** 再依赖用户表上单字段 **`homeEjsSource`** 作为唯一真相(若历史上存在,迁移为表或弃用)。 +- **单页模板体积**、**全站每用户页数** 限流在服务层强制。 ### 4.4 可选:站内发现 -- 若产品需要在 Vue 根主页展示「个性化站点」入口:仅在 **`customHomeEnabled`** 时显示链到 **`/@slug/custom`**;**本 spec 不强制**(YAGNI,实现计划可单列为小任务)。 - -## 5. 安全与滥用(在选项 A 下的最低工程线) +- Vue 根主页可列出「自定义页」入口(只展示 `enabled` 的页);**本 spec 不强制定 UI**。 -以下内容 **不能** 替代「仅可信用户 / 条款 + 封禁」的产品决策,但作为 **最低限度**: +## 5. 安全与滥用(选项 A 下的最低工程线) -- **单请求**:渲染 **超时**(防止死循环 / 极重计算拖死进程)。 -- **模板体积**:**上限**。 -- **错误响应**:访客侧 **通用错误页或简短 500 文案**;调试信息仅在 **已鉴权预览** 中展示。 -- **可选**:运营 **关闭 `customHomeEnabled`**(管理端或 CLI 文档化),使 **`/custom` 404**。 +- **单请求** 渲染 **超时**、**单模板体积** 上限、**每用户页数** 上限。 +- 访客 **500/错误页** 不泄露栈;**预览/保存** 可对主人返回语法错误。 +- 运营可 **禁用某页** 或 **封禁用户** 使对应 URL **404**。 ## 6. 错误与兼容性 -- **运行时错误**:记录服务端日志;对匿名访客返回 **安全** 的 HTML 错误页或 500。 -- **自动降级**(连续失败自动关闭 `customHomeEnabled`):**本版默认不做**;若日后做,须在设置中明文提示并可关闭。 +- 运行时错误:**日志** + 访客安全页。 +- **不** 做「自动删除页」类降级,除非产品后续明确要求并文案提示。 ## 7. 测试 -- **单元测试**:固定 `locals` 与短模板片段,断言 **`ejs.render`** 输出与 **逃逸** 行为。 -- **集成或手动清单**: - - **`/@slug`**:始终为 Vue; - - **`/@slug/custom`**:启用 → HTML;关闭 → **404**; - - **`/@slug/posts/...`** 等仍为 Vue。 +- **单元测试**:`ejs.render` + 固定 `locals`;`pageKey` 与保留字校验。 +- **集成 / 手测**: + - 同一用户两条不同 `pageKey` 均可 200; + - 禁用或删除后 **404**; + - **`/@slug`**、**`/@slug/posts/...`** 仍为 Vue。 ## 8. 依赖 -- 新增 **`ejs`** 运行时依赖(版本以 lockfile 与审计为准);类型如有 **`@types/ejs`** 则开发依赖补齐。 +- **`ejs`**;如有 **`@types/ejs`** 则 dev。 + +## 9. 与早期「仅 `/custom`」草案的关系 + +- 早期单一路径 **`/@slug/custom`** 视为 **仅 `pageKey = 'custom'` 的特殊情况**;现以 **通用多页模型** 为准。若产品仍需 **固定别名**(例如默认生成一页 `welcome`),可在实现时 **种子一行**,**不**再单独写死单一路由处理器(除非兼容迁移需要)。 --- -**下一实现阶段**:在用户确认本 spec 无异议后,另见 `docs/superpowers/plans/` 下 **implementation plan**(`writing-plans` 产出)。 +**下一实现阶段**:见 `docs/superpowers/plans/` 下 **implementation plan**(`writing-plans` 产出)。