diff --git a/api/webprojects/container/runtime.js b/api/webprojects/container/runtime.js index 38bda5c..24208a7 100644 --- a/api/webprojects/container/runtime.js +++ b/api/webprojects/container/runtime.js @@ -503,7 +503,7 @@ function renderReanchor() { const old = details.querySelector('[data-reanchor]') if (old) old.remove() - if (!activeThread || !selectedAnchor || !canManage(activeThread)) return + if (!activeThread || activeThread.status === 'deleted' || !selectedAnchor || !canManage(activeThread)) return const thread = activeThread, anchor = {...selectedAnchor} const control = button('用所选位置重新关联', () => mutate('/comment-threads/' + encodeURIComponent(thread.id) + '/reanchor', {anchor, release_id: settings.releaseId, page_key: page.key, page_path: page.path}, thread.revision, control, openThread), {'data-reanchor': ''}) @@ -519,18 +519,22 @@ if (mode !== 'comment' || sequence !== detailSequence) return activeThread = current details.replaceChildren(element('h3', {}, '讨论 ' + current.id + ' · 事件记录')) - const kinds = {comment: '评论', reply: '回复', resolve: '已解决', reopen: '已重开', reanchor: '重新关联'} + const kinds = {comment: '评论', reply: '回复', resolve: '已解决', delete: '已删除', reopen: '已重开', reanchor: '重新关联'} for (const event of events) { const item = element('div', {class: 'event'}) item.append(element('small', {}, (kinds[event.kind] || event.kind) + ' · ' + (event.created_at || '')), renderAuthor(event.actor_user_id, event.actor_application_id, event.actor_name_snapshot, event.actor_user), element('p', {class: 'body'}, event.body || '')) details.append(item) } - const input = element('textarea', {'aria-label': '回复内容', maxlength: '4000'}) - const reply = button('提交回复', () => mutate('/comment-threads/' + encodeURIComponent(current.id) + '/replies', {body: input.value.trim()}, null, reply, openThread)) - details.append(input, reply) + if (current.status !== 'deleted') { + const input = element('textarea', {'aria-label': '回复内容', maxlength: '4000'}) + const reply = button('提交回复', () => mutate('/comment-threads/' + encodeURIComponent(current.id) + '/replies', {body: input.value.trim()}, null, reply, openThread)) + details.append(input, reply) + } else { + details.append(element('p', {class: 'notice'}, '讨论已删除;保留事件记录,重新打开后可继续回复。')) + } if (canManage(current)) { - const action = current.status === 'resolved' ? 'reopen' : 'resolve' + const action = current.status === 'open' ? 'resolve' : 'reopen' const change = button(action === 'resolve' ? '标记已解决' : '重新打开', () => mutate('/comment-threads/' + encodeURIComponent(current.id) + '/' + action, {}, current.revision, change, openThread)) details.append(change) } diff --git a/api/webprojects/route.go b/api/webprojects/route.go index 9dcc396..ebcb101 100644 --- a/api/webprojects/route.go +++ b/api/webprojects/route.go @@ -18,7 +18,7 @@ func InitRouter() error { for _, suffix := range []string{"", "/:thread_id", "/:thread_id/events"} { data.AddRoute(http.MethodGet, "/api/web-share/:id/comment-threads"+suffix, requireModule, comments) } - for _, suffix := range []string{"", "/:thread_id/replies", "/:thread_id/resolve", "/:thread_id/reopen", "/:thread_id/reanchor"} { + for _, suffix := range []string{"", "/:thread_id/replies", "/:thread_id/resolve", "/:thread_id/delete", "/:thread_id/reopen", "/:thread_id/reanchor"} { data.AddRoute(http.MethodPost, "/api/web-share/:id/comment-threads"+suffix, requireModule, comments) } read := middleware.RequireIdentity("web-projects:read", false) diff --git a/docs/specs/web-container-comments-requirements.md b/docs/specs/web-container-comments-requirements.md index b539bef..9c7d632 100644 --- a/docs/specs/web-container-comments-requirements.md +++ b/docs/specs/web-container-comments-requirements.md @@ -1,6 +1,6 @@ # 网页托管增强容器与评论能力需求 -状态:已实现,已通过 Pi 隔离环境验收,待合并 +状态:基础容器已部署;Skill 上传检查与评论删除已实现,验证范围为静态检查和构建,未执行测试套件 更新时间:2026-09-16 @@ -14,7 +14,7 @@ 1. 访问者能确认当前登录态,并返回系统主页或网页托管页。 2. 已登录且有页面访问权的用户能对整页、文字、图片和组件发起讨论。 -3. 评论形成可追溯消息流,支持回复、解决、重开和重新关联。 +3. 评论形成可追溯消息流,支持回复、解决、删除、重开和重新关联。 4. 页面重新发布后,仍存在的内容继续显示评论;无法定位的未解决评论集中在页尾提示。 ## 2. 核心判断 @@ -38,7 +38,7 @@ ## 3. 用户与权限 -| 角色 | 页面可见条件 | 查看评论 | 创建/回复 | 解决、重开、重新关联 | +| 角色 | 页面可见条件 | 查看评论 | 创建/回复 | 解决、删除、重开、重新关联 | | --- | --- | --- | --- | --- | | 未登录用户 | 仅公开项目 | 不允许,且不显示入口 | 不允许 | 不允许 | | 已登录普通用户 | 满足项目 `private/members/authenticated/public` 规则 | 允许 | 允许 | 仅自己创建的主题 | @@ -96,6 +96,12 @@ - 交互模块不覆盖透明遮罩,评论从侧栏发起;拖拽、缩放、Marker、信息窗和原按钮继续由页面自身处理。 - 托管页面作为 iframe 子模块加载时不重复注入菜单。 +### 5.3 删除与恢复 + +删除针对整个讨论及回复,状态变为 `deleted` 并追加不可变 `delete` 事件,保留正文用于审计。作者或项目所有者在有项目访问权限时可执行删除、查询已删除历史和重新打开;其他用户对已删除主题详情及事件得到 404。所有操作继续校验用户有效状态、应用 scope、当前项目 ACL 与 revision。 + +普通列表、标记和页尾不显示已删除讨论;已删除主题不能回复、解决或重新关联。作者或所有者用重开操作恢复为 `open` 后可继续讨论。单条回复独立删除不在当前命令范围内。 + ## 6. 页面更新与定位 页面应遵循 [HTML 评论结构标准](../../skills/home-server-web-share/references/html-comment-contract.md)。核心标记为: @@ -119,13 +125,17 @@ ## 7. 应用与 Skill -`home-server-web-share` skill 提供: +`home-server-web-share` Skill 提供以下能力: -- 按状态或 `request_id` 查询评论。 -- 查看主题及完整事件分页。 -- 创建整页、文字、图片或模块评论。 -- 回复、解决、重开和重新关联。 -- 生成/修改 HTML 时遵循稳定锚点和交互保护标准。 +| 能力 | 行为 | +| --- | --- | +| 上传前检查 | `check-html` 离线检查 HTML/ZIP;`upload` 自动检查发送内容,并读取目标项目实际容器模式决定阻断项 | +| 评论查询 | 按状态或 request_id 查询主题,自动读取完整事件分页;默认 all 排除 deleted,显式 deleted 查询受作者/所有者限制 | +| 创建与回复 | 整页、文字、图片和模块评论,支持用户及 AK/SK 应用 | +| 评论处理 | 解决、删除、重新打开和重新关联;删除保留审计,重开可恢复已删除讨论 | +| HTML 生成约定 | 使用稳定锚点、语义边界和地图等原交互保护,不重写已有业务模块 | + +上传检查应报告文件、行号、问题类型、原因和建议,区分托管格式阻断项、增强模式阻断项与待核对警告;有适用阻断项时不发送上传。缺少标记的旧页面只提示保守定位,不自动重写。静态检查不执行 JavaScript,不证明动态 SPA、地图手势或布局兼容已经通过;详细范围见 [上传检查规则](../../skills/home-server-web-share/references/html-upload-check.md)。 每个写操作必须带调用方生成的 `request_id`。同一身份、项目和 `request_id` 的相同请求返回原结果;参数不同返回冲突。连接中断后先按 `request_id` 查询,不自动重试未知结果。 @@ -143,6 +153,7 @@ - 用户和 AK/SK 应用的回复都显示身份快照;应用代写同时保留用户 ID 与应用 ID。 - 评论、回复、解决、重开和重新关联事件记录调用方实际查看的 release;历史版本必须属于当前项目。 - 解决后可回复、可重新打开;状态操作使用 revision 防止覆盖并发更新。 +- 删除后普通列表、标记和页尾隐藏整个讨论;只有有项目访问权的作者/所有者可查询审计并重开恢复,其他用户不能读回正文或回复。 - 同一 `request_id` 的并发重复请求只产生一个事件/主题。 ### 8.3 更新与兼容 @@ -159,6 +170,13 @@ - 后端迁移、构建、API 生命周期、前端测试和真实 Chromium 测试全部通过。 - Pi 使用专用数据库、存储、配置、HTTPS 入口和回环端口完成真实服务验收:私有 404、匿名隐藏、默认浏览、原交互、文字/模块评论、应用回复、解决/重开、跨版本定位、失锚页尾与 Skill 命令链均通过。 +### 8.5 Skill 检查与删除验收标准 + +- 离线检查不读取配置/凭证、不联网、不执行脚本,并输出适用模式、摘要、问题和修正建议。 +- 上传检查与实际请求使用相同内容,托管阻断项适用于所有模式,增强阻断项依据目标项目实际模式判断。 +- 删除、审计查询和恢复遵循作者/所有者、scope、revision、request_id 规则;追加一个对应事件,不物理删除已有消息。 +- 基础容器的历史 Pi 验收结论不代表新增检查与删除生命周期已完成自动化验收;本扩展未运行测试套件。 + ## 9. 当前边界 - 首版不支持地图内部 POI、经纬度或屏幕坐标评论;评论绑定地图模块整体。 diff --git a/docs/specs/web-container-comments-technical-design.md b/docs/specs/web-container-comments-technical-design.md index 9704822..2f19103 100644 --- a/docs/specs/web-container-comments-technical-design.md +++ b/docs/specs/web-container-comments-technical-design.md @@ -1,6 +1,6 @@ # 网页托管增强容器与评论能力技术方案 -状态:实现版,已通过 Pi 隔离环境验收 +状态:实现版;基础容器已部署,新增 Skill 检查与删除仅完成静态检查和构建,未执行测试套件 更新时间:2026-09-16 @@ -45,7 +45,7 @@ flowchart LR | 浏览器运行时 | `api/webprojects/container/runtime.js` | 菜单、模式生命周期、DOM 定位、侧栏和页尾降级 | | 项目编辑页 | `front_vue/src/views/WebProjectEditor.vue` | 选择增强/原始容器 | | 应用权限 | `domain/service/applications`、`front_vue/src/views/Applications.vue` | `web-comments:read/write` scope | -| Agent 接口 | `skills/home-server-web-share` | 查询和处理评论、HTML 结构标准 | +| Agent 接口 | `skills/home-server-web-share` | 上传前兼容性检查、查询和处理评论、HTML 结构标准 | ## 3. 请求链路 @@ -146,12 +146,13 @@ stateDiagram-v2 | 方法 | 路径 | 说明 | | --- | --- | --- | | GET | `/api/web-share/{project_id}/view-context` | 页面身份、容器和评论能力;公开页允许匿名 | -| GET | `/api/web-share/{project_id}/comment-threads` | 主题列表;支持 `status=all/open/resolved`、`cursor`、`limit`、`request_id` | +| GET | `/api/web-share/{project_id}/comment-threads` | 主题列表;支持 `status=all/open/resolved/deleted`、`cursor`、`limit`、`request_id`;默认 all 不含 deleted | | POST | `/api/web-share/{project_id}/comment-threads` | 创建主题 | | GET | `/api/web-share/{project_id}/comment-threads/{thread_id}` | 主题当前状态 | | GET | `/api/web-share/{project_id}/comment-threads/{thread_id}/events` | 事件流;支持 `cursor`/`after_seq`、`limit`、`request_id` | | POST | `/api/web-share/{project_id}/comment-threads/{thread_id}/replies` | 回复,允许已解决主题 | | POST | `/api/web-share/{project_id}/comment-threads/{thread_id}/resolve` | 解决;要求 `If-Match` | +| POST | `/api/web-share/{project_id}/comment-threads/{thread_id}/delete` | 软删除整个讨论与回复、追加审计;要求 `If-Match` | | POST | `/api/web-share/{project_id}/comment-threads/{thread_id}/reopen` | 重开;要求 `If-Match` | | POST | `/api/web-share/{project_id}/comment-threads/{thread_id}/reanchor` | 重新关联;要求 `If-Match` 和当前 release | @@ -183,7 +184,7 @@ stateDiagram-v2 - `exact/prefix/suffix/label` 分别最多 4096/128/128/256 个 Unicode 字符。 - 页面路径必须是清理后的项目相对路径,不能包含反斜杠、NUL、查询或片段。 - 创建与重新关联使用的 `release_id` 必须等于当前发布版本;版本变化返回冲突。 -- 回复、解决和重开可携带调用方实际查看的 `release_id`,该版本必须属于当前项目;版本文件已淘汰时可由本项目持久化的主题或事件证明归属,旧客户端省略时兼容记录当前版本。 +- 回复、解决、删除和重开可携带调用方实际查看的 `release_id`,该版本必须属于当前项目;版本文件已淘汰时可由本项目持久化的主题或事件证明归属,旧客户端省略时兼容记录当前版本。 ## 6. 数据模型 @@ -222,11 +223,11 @@ erDiagram ### 6.1 主题表 -`web_comment_thread` 保存列表所需当前状态:当前 release/page/anchor、状态、revision 和作者快照;同时保留 `original_*` 字段用于审计原始位置。状态只有 `open/resolved`。 +`web_comment_thread` 保存列表所需当前状态:当前 release/page/anchor、状态、revision 和作者快照;同时保留 `original_*` 字段用于审计原始位置。状态为 `open/resolved/deleted`。`deleted` 隐藏整个讨论和回复,不物理清除数据;现有 VARCHAR 字段可容纳,无需增加 DDL。 ### 6.2 事件表 -`web_comment_event` 保存 `comment/reply/resolve/reopen/reanchor`。事件不可更新;`sequence` 与写入后的主题 revision 相同,唯一键 `(thread_id, sequence)` 保证顺序。`source_release_id` 记录执行动作时调用方实际查看的页面版本,而不是事务执行时的最新版本。 +`web_comment_event` 保存 `comment/reply/resolve/delete/reopen/reanchor`。事件不可更新;`sequence` 与写入后的主题 revision 相同,唯一键 `(thread_id, sequence)` 保证顺序。`source_release_id` 记录执行动作时调用方实际查看的页面版本,而不是事务执行时的最新版本。 幂等唯一键是 `(project_id, actor_user_id, actor_application_id, request_id)`。服务端保存标准输入的 SHA-256: @@ -243,16 +244,31 @@ stateDiagram-v2 open --> resolved: resolve + If-Match resolved --> resolved: reply,revision+1 resolved --> open: reopen + If-Match + open --> deleted: delete + If-Match + resolved --> deleted: delete + If-Match + deleted --> open: reopen + If-Match open --> open: reanchor + If-Match resolved --> resolved: reanchor + If-Match ``` 回复也递增 revision,防止项目所有者在未看到新回复时用旧 revision 解决主题。状态变更和重新关联要求 `If-Match`;不一致返回冲突。 +删除、已删除历史查询和恢复限于作者或项目所有者,仍须通过项目 ACL、有效身份与应用 scope。其他读者对 deleted 主题及事件得到 404。deleted 不接受回复、解决和重新关联;相同 request_id 与 payload 的删除重试仍返回幂等结果。按调用方 request_id 精确查询时可返回其有权读取的已删除主题,避免误判未知写入尚未发生。 + ## 7. HTML 定位协议 完整规范见 [HTML 评论结构标准](../../skills/home-server-web-share/references/html-comment-contract.md)。当前 schema 版本为 `1`。 +### 7.1 上传前静态检查 + +`skills/home-server-web-share/scripts/html_check.py` 使用标准库 HTMLParser 与 ZIP reader 进行有界离线检查,不提取归档、不执行脚本。`manage.py check-html` 可无配置调用;`upload` 使用 `build_multipart(..., include_content=True)` 返回的实际内容检查,避免检查与发送之间重新读取文件。 + +检查分为 `hosting` 与 `enhanced` 两类,覆盖 ZIP 入口/路径/白名单/CRC/容量、静态标记版本/唯一性/正文根/类型/保护区、meta CSP、资源地址与交互模块提示。上传在认证前阻断托管错误,认证后 GET 项目实际 `container_mode`,再决定增强错误是否阻断;通过后才发上传。dry-run 的 mode 为 unknown,不查询服务端。 + +报告含 SHA-256、文件、行号、错误/警告计数与建议;最多输出 200 条明细,计数仍覆盖全部问题。CSP 检查只针对静态 meta,动态脚本、响应头策略、外部 CSS/JS 和 HTML5 DOM 修复仍需运行环境核对。[检查规则](../../skills/home-server-web-share/references/html-upload-check.md) + +### 7.2 结构与定位 + ```html
@@ -318,7 +334,7 @@ stateDiagram-v2 | 运行时单元测试 | page identity、重复原文消歧、跨页面隔离、双身份显示 | | Chromium 测试 | 默认浏览、匿名隐藏、文字/图片/模块、地图式交互、页尾失锚、窄屏、iframe、生命周期清理 | | 前端测试与构建 | 容器模式表单、scope 选择、既有页面回归、生产构建 | -| Skill 测试 | 参数边界、自动分页、正文文件、request_id 读回、revision 和 dry-run 脱敏 | +| Skill 验证 | 上传前静态检查、实际内容摘要、参数边界、自动分页、正文文件、request_id 读回、删除/恢复、revision 和 dry-run 脱敏 | | Pi 隔离验收 | 专用数据库、存储、配置和端口;真实服务验证访问矩阵、发布更新、失锚与原交互 | 完成标准以 [需求文档第 8 节](web-container-comments-requirements.md#8-验收标准) 为准。 diff --git a/docs/specs/web-container-comments.html b/docs/specs/web-container-comments.html index df0bb51..8b50963 100644 --- a/docs/specs/web-container-comments.html +++ b/docs/specs/web-container-comments.html @@ -48,14 +48,14 @@
home_server · 技术规格 - 实现版 · Pi 隔离验收通过 · 2026-09-16 · 仅所有者可见 + 基础容器已部署 · Skill 扩展已实现 · 2026-09-16 · 仅所有者可见

网页托管增强容器与评论能力

让托管服务作为统一主容器提供导航、登录状态和可追踪评论,同时把原网页的按钮、表单、地图、图表与播放器交互完整保留下来。

- 默认浏览匿名隐藏评论稳定 HTML 锚点AK/SK 评论跨版本定位 + 默认浏览匿名隐藏评论稳定 HTML 锚点AK/SK 评论上传前检查评论删除与恢复
@@ -177,11 +177,12 @@

主题、事件与并发

- - + +
结构保存内容关键约束
web_comment_thread当前/原始 release、page、anchor,状态、revision、作者快照列表快速读取;状态为 open/resolved
web_comment_eventcomment、reply、resolve、reopen、reanchor 完整历史(thread_id, sequence) 唯一
web_comment_thread当前/原始 release、page、anchor,状态、revision、作者快照列表快速读取;状态为 open/resolved/deleted
web_comment_eventcomment、reply、resolve、delete、reopen、reanchor 完整历史(thread_id, sequence) 唯一
-

回复也递增 revision。解决、重开和重新关联必须携带最新 If-Match,避免覆盖未读回复。事件的 source_release_id 记录调用方实际查看的页面版本。每次写操作带 request_id;同一身份与项目的相同请求返回原结果,参数变化返回冲突。

+

回复也递增 revision。解决、删除、重开和重新关联必须携带最新 If-Match,避免覆盖未读回复。事件的 source_release_id 记录调用方实际查看的页面版本。每次写操作带 request_id;同一身份与项目的相同请求返回原结果,参数变化返回冲突。

+

删除整个讨论及回复时使用软删除并保留审计,普通列表、标记和页尾隐藏它。只有满足项目访问权限的作者或所有者可查询已删除历史、重新打开恢复;其他用户读取已删除详情和事件得到 404。无需新增 DDL。

@@ -189,14 +190,22 @@

API 与 Skill 能力

+ +
能力接口/命令
上传前检查check-html --file PATH --mode enhanced;upload 自动检查实际发送内容
上下文GET /api/web-share/{id}/view-context
主题列表/详情GET .../comment-threads、GET .../{thread_id}
事件历史GET .../{thread_id}/events,自动读取分页
创建/回复comment-create、comment-reply
状态与位置comment-resolve、comment-reopen、comment-reanchor
删除与恢复comment-delete、comments --status deleted、comment-reopen

正文从 UTF-8 普通文件读取,最多 4000 个 Unicode 字符。写结果未知时按原 request_id 查询,确认未执行后才能以完全相同参数重试。

+

上传兼容性检查

+

离线检查 HTML 标记、ZIP 入口/路径/白名单/容量/校验、meta CSP、资源地址和交互保护。结果包含文件、行号、问题、原因与建议;报告最多展示 200 条,阻断计数覆盖全部问题。上传读取目标实际容器模式,托管错误总是阻断,增强错误只对 enhanced 阻断。

+
python3 "$MANAGE" check-html --file /absolute/path/index.html --mode enhanced
+python3 "$MANAGE" --config "$CONFIG" --endpoint external comment-delete 123 9001 \
+  --revision 5 --request-id delete-UNIQUE-ID
+
静态检查不联网、不执行 JavaScript、不模拟浏览器 DOM 修复。动态 SPA、地图手势、外部 CSS/JS 与布局仍需浏览器核对;raw 模式保留增强问题提示,不自动改写页面或关闭 CSP。
@@ -207,7 +216,7 @@

上线与回滚

回滚时先将已切换项目改为 raw,再回滚二进制;保留评论表,避免丢失历史。
-

验收覆盖

+

基础容器验收覆盖

  • Go 领域与 HTTP 集成测试。
  • 前端 60 项回归与生产构建。
  • @@ -216,6 +225,7 @@

    验收覆盖

  • Pi 专用数据库、存储、配置、HTTPS 入口和回环端口的隔离部署已通过。
  • 真实服务完成私有 404、匿名隐藏、原交互、应用回复、跨版本定位、失锚页尾和 Skill 状态流转验收。
+

新增上传检查与删除扩展的验证范围为静态检查和构建,未执行测试套件;上述基础容器历史验收不代表新增生命周期已通过自动化验收。

diff --git a/domain/service/webcomments/service.go b/domain/service/webcomments/service.go index 47b0072..c1f7c11 100644 --- a/domain/service/webcomments/service.go +++ b/domain/service/webcomments/service.go @@ -71,22 +71,36 @@ func commentUsers(ctx context.Context, ids []int64) (map[int64]accounts.UserDisp } func List(ctx context.Context, projectID int64, p *utils.Principal, status string, cursor int64, limit int, request string) (*Page, error) { - if status != "" && status != "all" && status != "open" && status != "resolved" { + if p == nil { + return nil, apperrors.ErrUnauthorized + } + if status != "" && status != "all" && status != "open" && status != "resolved" && status != "deleted" { return nil, apperrors.ErrInvalid } if request != "" && !requestIDPattern.MatchString(request) { return nil, apperrors.ErrInvalid } tx := db.MasterDB().WithContext(ctx) - if _, err := Authorize(tx, projectID, p, false, true); err != nil { + project, err := Authorize(tx, projectID, p, false, true) + if err != nil { return nil, err } q := tx.Table("web_comment_thread").Select(threadColumns).Where("project_id = ?", projectID) if cursor > 0 { q = q.Where("id < ?", cursor) } - if status != "" && status != "all" { + if status == "deleted" { + q = q.Where("status = ?", status) + if p.UserID != project.OwnerUserID { + q = q.Where("author_user_id = ?", p.UserID) + } + } else if status != "" && status != "all" { q = q.Where("status = ?", status) + } else if request == "" { + q = q.Where("status IN ?", []string{"open", "resolved"}) + } else if p.UserID != project.OwnerUserID { + // Idempotency lookups include a deleted discussion only for its author. + q = q.Where("(status IN ? OR (status = ? AND author_user_id = ?))", []string{"open", "resolved"}, "deleted", p.UserID) } if request != "" { var event Event @@ -127,18 +141,25 @@ func List(ctx context.Context, projectID int64, p *utils.Principal, status strin } func Detail(ctx context.Context, projectID, threadID int64, p *utils.Principal) (*Thread, error) { + if p == nil { + return nil, apperrors.ErrUnauthorized + } tx := db.MasterDB().WithContext(ctx) - if _, err := Authorize(tx, projectID, p, false, true); err != nil { + project, err := Authorize(tx, projectID, p, false, true) + if err != nil { return nil, err } var thread Thread - err := tx.Table("web_comment_thread").Select(threadColumns).Where("project_id = ? AND id = ?", projectID, threadID).Take(&thread).Error + err = tx.Table("web_comment_thread").Select(threadColumns).Where("project_id = ? AND id = ?", projectID, threadID).Take(&thread).Error if errors.Is(err, gorm.ErrRecordNotFound) { return nil, apperrors.ErrNotFound } if err != nil { return nil, apperrors.ErrDependency } + if thread.Status == "deleted" && p.UserID != thread.AuthorUserID && p.UserID != project.OwnerUserID { + return nil, apperrors.ErrNotFound + } identities, e := commentUsers(ctx, []int64{thread.AuthorUserID}) if e != nil { return nil, e @@ -239,6 +260,9 @@ func Mutate(ctx context.Context, projectID, threadID, revision int64, p *utils.P if e != nil { return apperrors.ErrDependency } + if result.Status == "deleted" && p.UserID != result.AuthorUserID && p.UserID != project.OwnerUserID { + return apperrors.ErrNotFound + } return nil } if !errors.Is(e, gorm.ErrRecordNotFound) { @@ -273,6 +297,14 @@ func Mutate(ctx context.Context, projectID, threadID, revision int64, p *utils.P if e != nil { return apperrors.ErrDependency } + if result.Status == "deleted" { + if p.UserID != result.AuthorUserID && p.UserID != project.OwnerUserID { + return apperrors.ErrNotFound + } + if action != "reopen" { + return apperrors.ErrConflict + } + } if action != "reply" { if p.UserID != result.AuthorUserID && p.UserID != project.OwnerUserID { return apperrors.ErrForbidden @@ -289,10 +321,14 @@ func Mutate(ctx context.Context, projectID, threadID, revision int64, p *utils.P } result.Status = "resolved" case "reopen": - if result.Status != "resolved" { + if result.Status != "resolved" && result.Status != "deleted" { return apperrors.ErrConflict } result.Status = "open" + case "delete": + // Retain the immutable discussion history; deletion hides the whole + // thread and replies until its author or project owner reopens it. + result.Status = "deleted" case "reanchor": rid, e := strconv.ParseInt(input.ReleaseID, 10, 64) if e != nil || project.CurrentReleaseID == nil || rid != *project.CurrentReleaseID { @@ -308,7 +344,7 @@ func Mutate(ctx context.Context, projectID, threadID, revision int64, p *utils.P result.Revision++ result.UpdatedAt = now fields := map[string]interface{}{"revision": result.Revision, "updated_at": result.UpdatedAt} - if action == "resolve" || action == "reopen" { + if action == "resolve" || action == "reopen" || action == "delete" { fields["status"] = result.Status } if action == "reanchor" { diff --git a/script/home_server_api.py b/script/home_server_api.py index fbc7504..5b82284 100644 --- a/script/home_server_api.py +++ b/script/home_server_api.py @@ -318,7 +318,7 @@ def call(self, method: str, path: str, *, body: Optional[bytes] = None, headers= # 从已核对的普通 HTML/ZIP 文件构造有界 multipart 请求,验证入口字段并返回请求体和 Content-Type;不发送上传。 -def build_multipart(upload_file: Path, entry_file: Optional[str], *, boundary: Optional[str] = None): +def build_multipart(upload_file: Path, entry_file: Optional[str], *, boundary: Optional[str] = None, include_content: bool = False): suffix = upload_file.suffix.lower() if suffix not in {".html", ".htm", ".zip"}: raise ClientError("--upload-file 仅支持 HTML 或 ZIP") @@ -362,7 +362,10 @@ def build_multipart(upload_file: Path, entry_file: Optional[str], *, boundary: O content, f"\r\n--{selected_boundary}--\r\n".encode("ascii"), ]) - return b"".join(parts), f"multipart/form-data; boundary={selected_boundary}" + result = (b"".join(parts), f"multipart/form-data; boundary={selected_boundary}") + # Optional content is the exact immutable byte string used in the request, + # so a preflight checker never inspects a second, possibly replaced file. + return result + (content,) if include_content else result def read_json_body(path: Path) -> bytes: diff --git a/script/test_web_hosting_skill.py b/script/test_web_hosting_skill.py index 4a697d6..d994a24 100644 --- a/script/test_web_hosting_skill.py +++ b/script/test_web_hosting_skill.py @@ -14,6 +14,7 @@ import unittest from unittest import mock import urllib.parse +import zipfile from dataclasses import dataclass from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path @@ -274,12 +275,22 @@ def responder(request): self.assertEqual(base64.b64decode(encoded), f"{ACCESS_KEY}:{SECRET_KEY}".encode()) return token_response() self.assertEqual(request["headers"].get("authorization"), f"Bearer {ACCESS_TOKEN}") - return success({"operation": f"{request['method']} {parsed.path}"}, 201 if parsed.path.endswith("/releases") and request["method"] == "POST" else 200) + result = {"operation": f"{request['method']} {parsed.path}"} + if request["method"] == "GET" and parsed.path == "/api/web-share/12": + result["container_mode"] = "enhanced" + return success(result, 201 if parsed.path.endswith("/releases") and request["method"] == "POST" else 200) with LocalHTTPSServer(self.server_certificate, self.server_key, responder) as server: config = self.write_config(server.origin, endpoint="internal") upload = self.fixture_root / "site.zip" - upload.write_bytes(b"PK\x03\x04offline-site-marker") + with zipfile.ZipFile(upload, "w") as archive: + archive.writestr( + "public/index.html", + '' + 'Fixture
' + '
' + 'offline-site-marker
', + ) cases = [ (("--endpoint", "internal", "list", "--cursor", "11", "--limit", "7", "--status", "enabled"), "GET /api/web-share"), (("--endpoint", "internal", "show", "12"), "GET /api/web-share/12"), @@ -298,7 +309,21 @@ def responder(request): for arguments, operation in cases: with self.subTest(command=arguments[-1] if arguments else ""): completed = self.run_cli(config, *arguments) - self.assert_success(completed, "internal", server.origin, {"operation": operation}) + expected = {"operation": operation} + if operation == "GET /api/web-share/12": + expected["container_mode"] = "enhanced" + if operation != "POST /api/web-share/12/releases": + self.assert_success(completed, "internal", server.origin, expected) + continue + self.assertEqual(completed.returncode, 0, completed.stderr) + self.assertEqual(completed.stderr, "") + payload = json.loads(completed.stdout) + self.assertEqual(payload["endpoint"], "internal") + self.assertEqual(payload["origin"], server.origin) + self.assertEqual(payload["result"], expected) + self.assertEqual(payload["html_check"]["status"], "passed") + self.assertEqual(payload["html_check"]["mode"], "enhanced") + self.assertEqual(payload["html_check"]["errors"], 0) business = [call for call in server.calls if call["path"] != "/api/auth/token"] self.assertEqual(len([call for call in server.calls if call["path"] == "/api/auth/token"]), len(cases)) @@ -316,24 +341,26 @@ def responder(request): "member_user_ids": [], "client_request_id": "create-1", }) - self.assertEqual(business[5]["headers"].get("idempotency-key"), "upload-1") - self.assertIn(b'name="entry_file"\r\n\r\npublic/index.html', business[5]["body"]) - self.assertIn(b'name="file"; filename="site.zip"', business[5]["body"]) - self.assertIn(b"PK\x03\x04offline-site-marker", business[5]["body"]) - self.assertEqual(business[6]["headers"].get("if-match"), "2") - self.assertEqual(json.loads(business[6]["body"]), {"release_id": "31"}) - self.assertEqual(business[7]["headers"].get("if-match"), "3") - self.assertEqual(json.loads(business[7]["body"]), { + self.assertEqual(business[5]["method"], "GET") + self.assertEqual(business[5]["path"], "/api/web-share/12") + self.assertEqual(business[6]["headers"].get("idempotency-key"), "upload-1") + self.assertIn(b'name="entry_file"\r\n\r\npublic/index.html', business[6]["body"]) + self.assertIn(b'name="file"; filename="site.zip"', business[6]["body"]) + self.assertIn(b"offline-site-marker", business[6]["body"]) + self.assertEqual(business[7]["headers"].get("if-match"), "2") + self.assertEqual(json.loads(business[7]["body"]), {"release_id": "31"}) + self.assertEqual(business[8]["headers"].get("if-match"), "3") + self.assertEqual(json.loads(business[8]["body"]), { "access_mode": "members", "member_user_ids": ["201", "202"], }) - self.assertEqual(business[8]["headers"].get("if-match"), "4") - self.assertEqual(business[8]["body"], b"") + self.assertEqual(business[9]["headers"].get("if-match"), "4") + self.assertEqual(business[9]["body"], b"") for index, mode, revision in ( - (9, "owner", "5"), - (10, "authenticated", "6"), - (11, "public", "7"), - (12, "members", "8"), + (10, "owner", "5"), + (11, "authenticated", "6"), + (12, "public", "7"), + (13, "members", "8"), ): with self.subTest(visibility=mode): self.assertEqual(business[index]["method"], "PATCH") diff --git a/skills/home-server-web-share/SKILL.md b/skills/home-server-web-share/SKILL.md index 6ed8046..4eef738 100644 --- a/skills/home-server-web-share/SKILL.md +++ b/skills/home-server-web-share/SKILL.md @@ -1,6 +1,6 @@ --- name: home-server-web-share -description: Use when managing home_server hosted HTML or equipment manuals, publishing static pages, changing visibility, operating page comments, or generating HTML that needs stable comment anchors and protected interactive modules. +description: Use when checking HTML or ZIP compatibility before home_server upload; managing hosted pages, comments, visibility, or equipment manuals; or generating HTML with stable anchors and protected interactive modules. --- # Home Server 网页与说明书 @@ -26,6 +26,19 @@ description: Use when managing home_server hosted HTML or equipment manuals, pub 增强容器由服务端在响应时注入,页面本身不要实现评论接口、模拟登录态或透明事件遮罩。匿名用户和浏览模式不加载评论正文;已登录用户主动进入评论模式后才能选择文字或从面板选择图片、模块。原始页面模式不注入容器,也不接受新的评论写入。 +## 上传前兼容性检查 + +上传前先运行离线检查,默认按增强模式判断;这个命令不读取配置或凭证、不联网,也不执行 HTML 中的脚本。 + +```bash +python3 "$MANAGE" check-html --file /absolute/path/index.html --mode enhanced +python3 "$MANAGE" check-html --file /absolute/path/site.zip --entry-file index.html --mode enhanced +``` + +结果包含状态、SHA-256、文件与行号、问题代码、原因和修正建议。`errors>0` 时退出码为 1;只有警告时为 0,仍需逐项核对。详细规则见 [上传检查与处理](references/html-upload-check.md)。地图或动态 SPA 的警告不能仅凭静态扫描宣称已经通过浏览器兼容验证。 + +`upload` 必定检查实际 multipart 请求中的同一份内容,并在发送上传前读取项目实际 `container_mode`。托管格式错误始终阻断;结构或 CSP 的增强模式阻断项仅对 `enhanced` 项目阻断,对 `raw` 保留提示。`--dry-run upload` 不查询服务端,报告 `mode=unknown`,增强模式问题仍待核对,不能据此宣称增强上传已获准。不要通过修改可见范围或关闭 CSP 绕过问题。 + ## 选择目标和入口 ```bash @@ -114,7 +127,22 @@ python3 "$MANAGE" --config "$CONFIG" --endpoint internal comment-reanchor 123 90 --revision 4 --request-id reanchor-UNIQUE-ID ``` -回复允许发生在已解决主题;重新打开使用 `comment-reopen`。回复、解决和重开的 `--release` 建议填写调用方实际查看的页面版本,省略时服务端兼容记录当前版本。解决、重开、重新关联必须先读取最新主题并使用其 `revision`。同一写操作结果不确定时,只能在核对 `comments --request-id ORIGINAL-ID` 后,以完全相同的参数和原 `request_id` 明确重试。 +回复允许发生在已解决主题;重新打开使用 `comment-reopen`。回复、解决、删除和重开的 `--release` 建议填写调用方实际查看的页面版本,省略时服务端兼容记录当前版本。解决、删除、重开、重新关联必须先读取最新主题并使用其 `revision`。同一写操作结果不确定时,只能在核对 `comments --request-id ORIGINAL-ID` 后,以完全相同的参数和原 `request_id` 明确重试。 + +### 删除与恢复 + +删除作用于整个讨论及其回复,使用软删除并追加 `delete` 审计事件;不物理清除正文。仅主题作者或项目所有者可删除、查询已删除历史及恢复,AK/SK 应用遵循所属用户权限。单条回复独立删除不属于此命令。 + +```bash +python3 "$MANAGE" --config "$CONFIG" --endpoint internal comment-show 123 9001 +python3 "$MANAGE" --config "$CONFIG" --endpoint internal comment-delete 123 9001 \ + --revision 5 --release 457 --request-id delete-UNIQUE-ID +python3 "$MANAGE" --config "$CONFIG" --endpoint internal comments 123 --status deleted +python3 "$MANAGE" --config "$CONFIG" --endpoint internal comment-reopen 123 9001 \ + --revision 6 --release 457 --request-id restore-UNIQUE-ID +``` + +成功读回必须显示 `thread.status=deleted`、匹配 request_id 的 `event.kind=delete` 及新 revision。默认 `comments --status all` 只包含未删除的未解决/已解决讨论;已删除讨论不显示标记、页尾或普通列表,不接受回复、解决、重新关联。`comment-reopen` 可从 `resolved` 或 `deleted` 恢复为 `open`,保留历史。删除接口要求服务端包含本版本的 `/comment-threads/{id}/delete`;404 不能当作删除成功。 ## 失败与交付 diff --git a/skills/home-server-web-share/agents/openai.yaml b/skills/home-server-web-share/agents/openai.yaml index df44782..837eacd 100644 --- a/skills/home-server-web-share/agents/openai.yaml +++ b/skills/home-server-web-share/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Home Server 网页与说明书" - short_description: "管理托管网页、评论和设备说明书,支持私有内外网入口" + short_description: "上传前检查 HTML,管理托管网页、评论和设备说明书" default_prompt: "使用 $home-server-web-share 把这些资料整理成仅自己可见的设备说明书。" diff --git a/skills/home-server-web-share/references/configuration.md b/skills/home-server-web-share/references/configuration.md index eaef195..d4f709b 100644 --- a/skills/home-server-web-share/references/configuration.md +++ b/skills/home-server-web-share/references/configuration.md @@ -60,4 +60,6 @@ ln -s /absolute/path/home_server/skills/home-server-web-share \ "$HOME/.codex/skills/home-server-web-share" ``` -随后重新加载技能并使用 `$home-server-web-share`。不要覆盖已有同名安装。脚本会解析符号链接得到实际仓库路径,因此不依赖运行命令时的当前目录。此 skill 复用仓库中的 API 客户端,安装时保留整个仓库,不能只复制 skill 子目录;不要让长期安装指向之后会删除的临时 worktree。 +随后重新加载技能并使用 `$home-server-web-share`。已有同名安装时先核对实际链接目标;用户授权升级后保留旧版本用于回滚,再把链接更新到完整稳定版本,不盲目覆盖其他技能。脚本会解析符号链接得到实际仓库路径,因此不依赖运行命令时的当前目录。此 skill 复用仓库中的 API 客户端,安装时保留整个仓库,不能只复制 skill 子目录;不要让长期安装指向之后会删除的临时 worktree。 + +`check-html` 完全离线,不要求配置或凭证。评论查询/写入需要应用的 `web-comments:read/write`;仅有 `web-projects:write` 无法操作评论。删除评论还要求服务端支持 `/comment-threads/{id}/delete`,客户端更新不能代替服务端升级。 diff --git a/skills/home-server-web-share/references/html-comment-contract.md b/skills/home-server-web-share/references/html-comment-contract.md index 8a19b3c..03c7f29 100644 --- a/skills/home-server-web-share/references/html-comment-contract.md +++ b/skills/home-server-web-share/references/html-comment-contract.md @@ -25,7 +25,7 @@ page-id/comment-id 采用 1~96 位小写语义标识,正则 `^[a-z][a-z0-9]* 嵌套目标选择最接近的显式 comment-id。ignore 的优先级最高,任何父目标的文本/摘要都不得包含其内容。preserve 区域只暴露外层模块和 label,不读取内层动态文本;祖先 text 目标也须跳过该子树。父/子目标均参与时,不能将同一次选择重复绑定两个目标。 -页面若声明 Content Security Policy,`script-src 'self'` 可以加载同源容器脚本。只允许 nonce/hash 且不允许同源脚本的策略会阻止运行时注入;生成页面时应显式允许同源 `/api/web-share/container.js`,不能通过关闭 CSP 规避。无法调整的页面使用原始页面模式。 +页面若声明 Content Security Policy,应允许同源容器脚本、同源 API 请求与容器样式。`script-src 'self'` 可允许同源脚本,但其他更具体指令和 strict-dynamic 仍需核对;平台脚本没有 nonce/integrity,样式为 Shadow DOM 内的 style 元素。不能通过关闭 CSP 规避。无法调整的页面使用原始页面模式;上传前运行 [兼容性检查](html-upload-check.md)。 ## 3. 最小结构示例 diff --git a/skills/home-server-web-share/references/html-upload-check.md b/skills/home-server-web-share/references/html-upload-check.md new file mode 100644 index 0000000..bd21576 --- /dev/null +++ b/skills/home-server-web-share/references/html-upload-check.md @@ -0,0 +1,42 @@ +# HTML 上传检查与处理 + +`manage.py check-html` 是离线检查。`upload` 自动复用同一检查器,检查内容与发送内容使用同一个普通文件描述符读出的不可变字节串,报告 SHA-256 用于核对产物。检查不改写 HTML、ZIP 或当前线上页面。 + +## 检查范围 + +| 范围 | 阻断项 | 提示项 | +| --- | --- | --- | +| 静态托管,所有模式 | ZIP 非规范/重复路径、缺失或非 `.html` 入口、不支持的文件类型、保留路径、非普通文件、加密、压缩或 CRC 校验失败、大小/项数超限 | 站点根路径资源、ZIP 中找不到相对资源、base URL、其他字符编码、Service Worker 文本调用 | +| 增强评论结构 | 未知 schema、无效 page/target ID、重复 page/target ID、多正文根、目标类型无效、目标在显式根外、未知交互保护属性 | 缺少稳定标记、目标位于 ignore/preserve 子树、保护区缺少外层模块目标 | +| 增强容器加载 | 自行嵌入平台容器/脚本、静态 meta CSP 明确阻止脚本/请求/样式、HTML 无法以 UTF-8 检查 | CSP 显式来源或样式哈希待核对、高德 SDK、canvas/iframe/编辑器未声明外层保护 | + +ZIP 默认检查上限与服务端默认一致:上传 50 MiB、5000 项、目录 16 层、单文件 50 MiB、解压 200 MiB。服务端可配置更低限制;最终仍以服务端校验为准。单文件 `.html/.htm` 保存为 `index.html`;ZIP 内只允许 `.html` 网页,`.htm` 不在 ZIP 白名单。 + +报告最多展示前 200 条问题,计数和阻断判断覆盖全部问题,`issues_truncated=true` 表示需分批修正并重新检查。没有问题只表示静态规则通过,不能证明页面运行正确。 + +## 结果与容器模式 + +```bash +python3 /absolute/path/skills/home-server-web-share/scripts/manage.py \ + check-html --file /absolute/path/site.zip --entry-file index.html --mode enhanced +``` + +结果中的 `scope=hosting` 适用于所有模式;`scope=enhanced` 只影响增强容器。`severity=error` 配合实际 mode 判断是否阻断;raw 下的增强错误仍保留原级别与建议,但不计入 `errors`。默认 check mode 为 enhanced;已有 raw 项目可以显式用 `--mode raw`。 + +| 状态 | 退出码 | 动作 | +| --- | --- | --- | +| `blocked` | 1 | 根据文件、行号与建议修正,不发送上传 | +| `warnings` | 0 | 核对警告,明确需要浏览器验证的范围 | +| `passed` | 0 | 可继续上传,权限和服务端限制仍需核对 | + +实际 `upload` 先离线检查托管格式,再认证并读取项目实际模式;有适用阻断项时输出 `error=html_incompatible`、`mutation_attempted=false`,没有发送上传。无法核对模式时停止上传。没有适用阻断项才发送上传,成功输出额外 `html_check`,原有 release 结果结构保持不变。 + +`--dry-run upload` 仍不联网,报告模式为 `unknown`;增强模式错误列为待核对提示。项目模式在预检与实际上传之间若发生并发改变,服务端仍以实际配置处理,发布前应再次核对项目快照。 + +## 修正原则 + +原有语义对象保持稳定 ID;不同对象分配不同 ID。给地图等已有外层容器补充 `kind=module` 和 `interaction=preserve`,不要移动或替换已初始化业务节点。输入与敏感区域用 ignore,不索引表单值。 + +CSP 检查遵循指令回退顺序,meta 中的多条策略分别生效。服务端注入的脚本没有 nonce/integrity;只允许 nonce/hash 或对 parser-inserted 脚本使用 strict-dynamic 的策略可能阻断它。容器还创建 Shadow DOM 内联 style,需要策略明确允许其样式;样式哈希与当前平台样式一致才可用。应保留 CSP 并明确允许容器所需规则,无法维护时由所有者选择 raw。[W3C CSP 脚本检查规则](https://www.w3.org/TR/CSP3/#script-pre-request)、[CSP 样式规则](https://www.w3.org/TR/CSP3/#directive-style-src-elem) + +检查器使用标准库 HTMLParser,不执行 JavaScript、不加载远程资源、不模拟 HTML5 修复后的 DOM。运行时生成的标记、SPA 路由、外部 CSS/JS 中的资源地址、脚本动态改写页面、地图手势、窄屏布局均无法由静态结果证明正确。已由页面脚本设置的 CSP、服务端响应头策略也不在静态文件检查范围内。 diff --git a/skills/home-server-web-share/scripts/html_check.py b/skills/home-server-web-share/scripts/html_check.py new file mode 100644 index 0000000..f644ad9 --- /dev/null +++ b/skills/home-server-web-share/scripts/html_check.py @@ -0,0 +1,279 @@ +"""Bounded, offline checks for the static hosting and HTML comment contracts.""" + +from __future__ import annotations + +import hashlib +from html.parser import HTMLParser +import io +import posixpath +import re +import stat +from urllib.parse import unquote, urlsplit +import zipfile +import zlib + + +MAX_FILE = 50 * 1024 * 1024 +MAX_EXPANDED = 200 * 1024 * 1024 +MAX_ENTRIES = 5000 +EXTENSIONS = set("avif bmp css csv eot gif html ico jpeg jpg js json map md mjs otf pdf png svg txt wasm webmanifest webp woff woff2 xml".split()) +STABLE_ID = re.compile(r"[a-z][a-z0-9]*(?:-[a-z0-9]+)*\Z") +VOID = set("area base br col embed hr img input link meta param source track wbr".split()) +IGNORED = set("script style noscript template input textarea select button form iframe canvas svg".split()) + + +class Issues(list): + def __init__(self): + super().__init__() + self.counts = {"hosting": 0, "enhanced": 0, "warning": 0} + self.total = 0 + + def append(self, item): + self.total += 1 + self.counts[item["scope"] if item["severity"] == "error" else "warning"] += 1 + if len(self) < 200: + super().append(item) + + +def safe_path(name): + components = name.split("/") + return bool(name and len(name.encode("utf-8")) <= 2048 and not any(char in name for char in "\\\x00") + and not name.startswith("/") and posixpath.normpath(name) == name and name not in {".", ".."} + and not name.startswith("../") and len(components) <= 16 + and all(len(part.encode("utf-8")) <= 255 for part in components)) + + +def add_issue(issues, code, message, suggestion, *, file, line=0, scope="enhanced", severity="error"): + issues.append({"code": code, "severity": severity, "scope": scope, "file": file, + "line": line, "message": message, "suggestion": suggestion}) + + +class CommentHTML(HTMLParser): + """Inspect declarative metadata only; never run scripts or change the DOM.""" + + def __init__(self, file, issues, resources): + super().__init__(convert_charrefs=True) + self.file, self.issues, self.resources = file, issues, resources + self.stack, self.roots, self.targets = [], [], [] + self.ids, self.html_count, self.page_id = set(), 0, "" + + def issue(self, code, message, suggestion, severity="error", scope="enhanced"): + add_issue(self.issues, code, message, suggestion, file=self.file, line=self.getpos()[0], scope=scope, severity=severity) + + def check_csp(self, policy): + directives = {} + for part in policy.split(";"): + values = part.split() + if values: + directives.setdefault(values[0].lower(), values[1:]) + for name, fallback in (("script-src-elem", "script-src"), ("connect-src", "default-src"), ("style-src-elem", "style-src")): + sources = directives.get(name, directives.get(fallback, directives.get("default-src"))) + if sources is None: + continue + sources = [value.lower() if value.startswith("'") else value for value in sources] + signed = any(value.startswith(("'nonce-", "'sha256-", "'sha384-", "'sha512-")) for value in sources) + hashed = any(value.startswith(("'sha256-", "'sha384-", "'sha512-")) for value in sources) + blocked = not sources or sources == ["'none'"] or (name == "script-src-elem" and + ("'strict-dynamic'" in sources or all(value.startswith("'") for value in sources) and "'self'" not in sources)) + if name == "style-src-elem": + blocked = not hashed and (signed or "'unsafe-inline'" not in sources) + if blocked: + self.issue("csp-container-blocked", f"CSP {name} 会阻止容器脚本、评论请求或容器样式。", "保留 CSP 并明确允许容器的同源脚本、请求及样式;无法调整时使用 raw 模式。") + elif name == "style-src-elem" and hashed: + self.issue("csp-style-hash-review", "CSP 样式哈希需与平台注入的样式内容匹配。", "核对当前容器样式哈希;平台升级后需重新核对,无法维护时使用 raw。", "warning") + elif name == "style-src-elem": + continue + elif "'self'" not in sources and "*" not in sources: + self.issue("csp-origin-review", "CSP 的来源列表需要结合实际托管域名核对。", "核对 /api/web-share/container.js 与同源评论请求是否获准。", "warning") + + def handle_starttag(self, tag, attributes): + attrs = dict(attributes) + parent = self.stack[-1] if self.stack else {"ignored": False, "preserved": False, "root": False} + own_ignored = tag in IGNORED or "data-hs-comment-ignore" in attrs or "data-hs-container" in attrs or ( + "contenteditable" in attrs and attrs["contenteditable"] != "false") + ignored, preserved = parent["ignored"] or own_ignored, parent["preserved"] + root = parent["root"] or "data-hs-comment-root" in attrs + if tag == "html": + self.html_count += 1 + self.page_id = attrs.get("data-hs-page-id") or "" + schema = attrs.get("data-hs-comment-schema") + if schema is not None and schema not in {"", "1"}: + self.issue("unknown-schema", "评论结构版本无法识别,容器会降级整页评论。", '使用 data-hs-comment-schema="1"。') + if self.page_id and (len(self.page_id) > 96 or not STABLE_ID.fullmatch(self.page_id)): + self.issue("invalid-page-id", "页面 ID 不符合稳定标识格式。", "使用 1~96 位小写语义 ID,单词以连字符分隔。") + if tag == "meta": + if (attrs.get("http-equiv") or "").lower() == "content-security-policy": + self.check_csp(attrs.get("content") or "") + if (attrs.get("charset") or "utf-8").lower() not in {"utf-8", "utf8"}: + self.issue("encoding-review", "页面声明了其他字符编码。", "统一 UTF-8 产物与编码声明,避免原文锚点乱码。", "warning", "hosting") + if "data-hs-comment-root" in attrs and not ignored and not preserved: + self.roots.append(self.getpos()[0]) + target = attrs.get("data-hs-comment-id") or "" + if target: + if ignored or preserved: + self.issue("unreachable-target", "评论目标位于忽略或原交互保护子树内,运行时不会索引它。", "把模块标记放在已有外层容器,或移除不可达的评论目标标记。", "warning") + else: + if target in self.ids: + self.issue("duplicate-target-id", "同一页面存在重复评论目标 ID。", "为不同语义对象使用不同 ID;同一对象更新时保留原 ID。") + self.ids.add(target) + if len(target) > 96 or not STABLE_ID.fullmatch(target): + self.issue("invalid-target-id", "评论目标 ID 无法被运行时识别。", "使用 1~96 位小写语义 ID,单词以连字符分隔。") + if attrs.get("data-hs-comment-kind") not in {"text", "image", "module"}: + self.issue("invalid-target-kind", "评论目标缺少有效类型。", "在同一节点设置 data-hs-comment-kind=text/image/module。") + self.targets.append((root, self.getpos()[0])) + interaction = attrs.get("data-hs-comment-interaction") + if interaction is not None and interaction != "preserve": + self.issue("unknown-interaction", "原交互保护属性无法识别。", '使用 data-hs-comment-interaction="preserve"。') + if interaction == "preserve" and not ignored and (not target or attrs.get("data-hs-comment-kind") != "module"): + self.issue("preserve-module-target", "保护区没有有效外层模块目标,只能保留原交互。", "在现有外层节点补充稳定 ID 和 kind=module。", "warning") + if (tag in {"canvas", "iframe"} or "contenteditable" in attrs and attrs["contenteditable"] != "false") and not ( + parent["ignored"] or preserved or "data-hs-comment-ignore" in attrs or interaction == "preserve"): + self.issue("interactive-module-review", "交互组件内部默认跳过,无法直接关联组件评论。", "在已有外层节点使用 module + preserve,或声明 ignore。", "warning") + if "data-hs-container" in attrs or attrs.get("id") == "hs-web-container": + self.issue("embedded-container", "上传内容含平台容器标记,可能重复注入。", "去掉页面内的模拟容器;平台在响应时注入。") + for name in ("src", "href", "action", "poster"): + value = attrs.get(name) + if value and (tag != "a" or name != "href"): + self.check_resource(value) + source = attrs.get("src") or "" + if tag == "script" and "/api/web-share/container.js" in source: + self.issue("embedded-container", "页面自行引入了平台容器脚本。", "删除该引用,由增强托管响应注入。") + if tag == "script" and "amap.com" in source.lower(): + self.issue("amap-module-review", "检测到高德 SDK,静态扫描不能判断地图实例的交互边界。", "地图已有外层模块使用 module + preserve;核对原拖拽、缩放和 Marker 交互。", "warning") + if tag == "base": + self.issue("base-url-review", "base URL 会改变相对资源的解析位置。", "核对托管路径 /p/slug/ 下的资源地址与导航。", "warning", "hosting") + if tag not in VOID: + self.stack.append({"tag": tag, "ignored": ignored, "preserved": preserved or interaction == "preserve", "root": root}) + + def handle_startendtag(self, tag, attrs): + self.handle_starttag(tag, attrs) + if tag not in VOID: + self.handle_endtag(tag) + + def handle_endtag(self, tag): + for index in range(len(self.stack) - 1, -1, -1): + if self.stack[index]["tag"] == tag: + del self.stack[index:] + break + + def check_resource(self, value): + if value.startswith("/") and not value.startswith("//"): + self.issue("root-relative-resource", "资源使用站点根路径,托管项目路径下可能无法访问。", "使用项目内相对路径;业务 API 的站点根路径应单独核对。", "warning", "hosting") + return + try: + parsed = urlsplit(value) + except ValueError: + self.issue("invalid-resource-url", "资源地址无法按标准 URL 解析。", "核对资源地址格式。", "warning", "hosting") + return + if self.resources is not None and not parsed.scheme and not parsed.netloc and parsed.path: + target = posixpath.normpath(posixpath.join(posixpath.dirname(self.file), unquote(parsed.path))) + if target not in self.resources: + self.issue("missing-relative-resource", "相对资源未在 ZIP 普通文件中找到。", "检查构建产物和路径;动态地址需要在浏览器中核对。", "warning", "hosting") + + def handle_data(self, data): + if self.stack and self.stack[-1]["tag"] == "script" and re.search(r"serviceWorker\s*\.\s*register\s*\(", data): + self.issue("service-worker-review", "脚本文本含 Service Worker 注册调用,托管环境默认不支持该能力。", "移除注册依赖,或核对静态页面在注册失败时是否仍可用。", "warning", "hosting") + if self.stack and self.stack[-1]["tag"] == "style" and re.search(r"url\(\s*['\"]?/(?!/)", data): + self.issue("root-relative-css", "内联 CSS 使用站点根路径资源。", "改用项目内相对资源路径。", "warning", "hosting") + + def finish(self): + if self.html_count > 1: + self.issue("multiple-html-elements", "同一文档声明了多个 html 元素。", "保留一个文档根,避免元数据与浏览器修复后的 DOM 不一致。") + if len(self.roots) > 1: + self.issue("multiple-comment-roots", "可索引正文根超过一个,容器会降级整页评论。", "保留一个正文根,其余业务导航使用 ignore。") + if self.roots: + for in_root, line in self.targets: + if not in_root: + add_issue(self.issues, "target-outside-root", "评论目标在正文根外,不会被运行时索引。", "将标记放在正文根内的现有语义节点。", file=self.file, line=line) + if not self.page_id or not self.roots or not self.ids: + self.issue("legacy-anchors", "缺少稳定页面 ID、正文根或目标 ID,跨版本定位只能保守匹配。", "生成或修改页面时遵循 HTML 评论结构标准;现成页面无需自动重写。", "warning") + + +def inspect_html(content, file, issues, resources): + try: + text = content.decode("utf-8-sig") + except UnicodeDecodeError: + add_issue(issues, "non-utf8-html", "HTML 不是有效 UTF-8,无法可靠检查文字锚点。", "将产物转换为 UTF-8 后检查。", file=file, scope="enhanced") + return "" + parser = CommentHTML(file, issues, resources) + parser.feed(text) + parser.close() + parser.finish() + return parser.page_id + + +def inspect_upload(content, suffix, entry_file=None): + """Return mode-independent findings from exactly the bytes to be uploaded.""" + issues, html_files, page_ids = Issues(), 0, set() + entry = entry_file or "index.html" + if suffix.lower() != ".zip": + if entry_file and entry_file != "index.html": + add_issue(issues, "single-html-entry", "单 HTML 上传的入口固定为 index.html,指定入口不会改变存储路径。", "省略 --entry-file 或指定 index.html。", file="index.html", scope="hosting", severity="warning") + inspect_html(content, "index.html", issues, None) + html_files = 1 + else: + checking_file = entry + try: + with zipfile.ZipFile(io.BytesIO(content)) as archive: + entries, names, total = archive.infolist(), set(), 0 + if len(entries) > MAX_ENTRIES: + raise ValueError("zip-entry-limit") + for info in entries: + name = info.filename[:-1] if info.is_dir() else info.filename + checking_file = name + if not safe_path(name) or name in names: + raise ValueError("zip-unsafe-or-duplicate-path") + names.add(name) + if info.is_dir(): + continue + mode = info.external_attr >> 16 + if stat.S_IFMT(mode) not in {0, stat.S_IFREG} or info.flag_bits & 1: + raise ValueError("zip-nonregular-or-encrypted") + components = name.lower().split("/") + if posixpath.splitext(name)[1][1:].lower() not in EXTENSIONS or any( + part in {".git", ".env"} or part.startswith(".env.") for part in components): + raise ValueError("zip-unsupported-content") + total += info.file_size + if info.file_size > MAX_FILE or total > MAX_EXPANDED: + raise ValueError("zip-size-limit") + resources = {info.filename for info in entries if not info.is_dir()} + checking_file = entry + if not safe_path(entry) or not entry.lower().endswith(".html") or entry not in resources: + raise ValueError("zip-entry-missing-or-invalid") + for info in entries: + if info.is_dir(): + continue + checking_file = info.filename + # Read every member with a bound, including assets: validate + # CRC, compression support and actual size without extraction. + with archive.open(info) as member: + data = member.read(min(MAX_FILE, info.file_size) + 1) + if len(data) != info.file_size: + raise ValueError("zip-declared-size-mismatch") + if info.filename.lower().endswith(".html"): + html_files += 1 + page_id = inspect_html(data, info.filename, issues, resources) + if page_id and page_id in page_ids: + add_issue(issues, "duplicate-page-id", "同一 ZIP 中存在重复稳定页面 ID。", "不同页面分配不同 ID,同一页面更新时保留 ID。", file=info.filename) + page_ids.add(page_id) + except (ValueError, zipfile.BadZipFile, NotImplementedError, RuntimeError, EOFError, OSError, zlib.error) as error: + reasons = {"zip-entry-limit": "ZIP 项数超过限制。", "zip-unsafe-or-duplicate-path": "ZIP 路径不规范或重复。", + "zip-nonregular-or-encrypted": "ZIP 包含非普通文件或加密成员。", "zip-unsupported-content": "ZIP 文件类型不受支持或包含保留路径。", + "zip-size-limit": "ZIP 单文件或解压总大小超限。", "zip-entry-missing-or-invalid": "ZIP 入口缺失或不是规范 .html 路径。", + "zip-declared-size-mismatch": "ZIP 实际解压大小与声明值不符。"} + code = str(error) if isinstance(error, ValueError) and str(error) in reasons else "zip-invalid" + add_issue(issues, code, reasons.get(code, "ZIP 的压缩或校验不符合托管要求。"), + "使用规范相对路径与 .html 入口;仅打包构建产物,最多 5000 项、16 层、单文件 50 MiB、解压 200 MiB。", file=checking_file, scope="hosting") + return {"sha256": hashlib.sha256(content).hexdigest(), "entry_file": "index.html" if suffix.lower() != ".zip" else entry, + "html_files": html_files, "issues": list(issues), "counts": issues.counts, "issue_count": issues.total} + + +def report(findings, mode): + errors = findings["counts"]["hosting"] + (findings["counts"]["enhanced"] if mode == "enhanced" else 0) + warnings = findings["issue_count"] - errors + return {"status": "blocked" if errors else "warnings" if warnings else "passed", "mode": mode, + "sha256": findings["sha256"], "entry_file": findings["entry_file"], "html_files": findings["html_files"], + "errors": errors, "warnings": warnings, "enhanced_errors": findings["counts"]["enhanced"], + "issues": findings["issues"], "issues_truncated": findings["issue_count"] > len(findings["issues"]), + "limitations": ["仅检查静态产物;不执行 JavaScript、不请求外部资源,也不模拟浏览器修复后的 DOM。", + "动态 SPA 标记、脚本设置的 CSP、运行时地图与布局交互仍需浏览器核对;服务端可配置更低文件限制。"]} diff --git a/skills/home-server-web-share/scripts/manage.py b/skills/home-server-web-share/scripts/manage.py index 19e23db..b011876 100644 --- a/skills/home-server-web-share/scripts/manage.py +++ b/skills/home-server-web-share/scripts/manage.py @@ -23,7 +23,9 @@ print(json.dumps({"error": "missing_client", "message": "请保留完整 home_server 仓库,不能只复制 skill 目录"}, ensure_ascii=False), file=sys.stderr) sys.exit(2) sys.path.insert(0, str(REPOSITORY / "script")) +sys.path.insert(0, str(Path(__file__).resolve().parent)) import home_server_api as api +import html_check API_PATH = "/api/web-share" @@ -108,6 +110,12 @@ def __init__(self, probes): self.probes = probes +class HTMLIncompatible(api.ClientError): + def __init__(self, report): + super().__init__("上传前检查发现阻断项;修正产物或核对项目容器模式后再上传") + self.report = report + + def positive_id(value): if not re.fullmatch(r"[1-9][0-9]{0,18}", value) or int(value) > 2**63 - 1: raise argparse.ArgumentTypeError("需要正整数 ID 或 revision") @@ -135,15 +143,15 @@ def parse_args(argv=None): commands = parser.add_subparsers(dest="command", required=True) for name in ( "doctor", "users", "list", "show", "releases", "create", "upload", "publish", "disable", "visibility", - "comments", "comment-show", "comment-create", "comment-reply", "comment-resolve", "comment-reopen", "comment-reanchor", + "check-html", "comments", "comment-show", "comment-create", "comment-reply", "comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor", ): command = commands.add_parser(name) if name in { "show", "releases", "upload", "publish", "disable", "visibility", "comments", "comment-show", "comment-create", - "comment-reply", "comment-resolve", "comment-reopen", "comment-reanchor", + "comment-reply", "comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor", }: command.add_argument("id", type=positive_id) - if name in {"comment-show", "comment-reply", "comment-resolve", "comment-reopen", "comment-reanchor"}: + if name in {"comment-show", "comment-reply", "comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor"}: command.add_argument("thread_id", type=positive_id) if name in {"publish", "disable", "visibility"}: command.add_argument("--revision", type=positive_id, required=True) @@ -162,9 +170,11 @@ def parse_args(argv=None): command.add_argument("--visibility", choices=MODES, default="owner") if name in {"create", "upload"}: command.add_argument("--request-id", required=True, help="本次操作的唯一标识;人工核对后重试时保留原值") - if name == "upload": + if name in {"upload", "check-html"}: command.add_argument("--file", type=Path, required=True) command.add_argument("--entry-file") + if name == "check-html": + command.add_argument("--mode", choices=("enhanced", "raw"), default="enhanced") if name == "publish": command.add_argument("--release", type=positive_id, required=True) if name == "visibility": @@ -172,7 +182,7 @@ def parse_args(argv=None): if name == "comments": command.add_argument("--cursor", type=positive_id) command.add_argument("--limit", type=int, default=100) - command.add_argument("--status", choices=("all", "open", "resolved"), default="all") + command.add_argument("--status", choices=("all", "open", "resolved", "deleted"), default="all") command.add_argument("--request-id", type=request_id) if name == "comment-show": command.add_argument("--after-seq", type=nonnegative_id, default="0") @@ -181,13 +191,13 @@ def parse_args(argv=None): command.add_argument("--page", type=comment_page, required=True) command.add_argument("--anchor-file", type=Path) command.add_argument("--release", type=positive_id, required=True) - if name in {"comment-reply", "comment-resolve", "comment-reopen"}: + if name in {"comment-reply", "comment-resolve", "comment-delete", "comment-reopen"}: command.add_argument("--release", type=positive_id) if name in {"comment-create", "comment-reply"}: command.add_argument("--body-file", type=Path, required=True) - if name in {"comment-create", "comment-reply", "comment-resolve", "comment-reopen", "comment-reanchor"}: + if name in {"comment-create", "comment-reply", "comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor"}: command.add_argument("--request-id", type=request_id, required=True) - if name in {"comment-resolve", "comment-reopen", "comment-reanchor"}: + if name in {"comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor"}: command.add_argument("--revision", type=positive_id, required=True) return parser.parse_args(argv) @@ -278,7 +288,11 @@ def build_operation(args): if args.command == "upload": if api.CONTROL_CHARACTER.search(args.file.name): raise api.ClientError("上传文件名不能包含控制字符") - body, headers["Content-Type"] = api.build_multipart(args.file.expanduser(), args.entry_file) + body, headers["Content-Type"], content = api.build_multipart(args.file.expanduser(), args.entry_file, include_content=True) + args.html_findings = html_check.inspect_upload(content, args.file.suffix, args.entry_file) + args.html_report = html_check.report(args.html_findings, "unknown") + if args.html_report["errors"]: + raise HTMLIncompatible(args.html_report) headers["Idempotency-Key"] = args.request_id summary.update(file=str(args.file.expanduser().absolute()), entry_file=args.entry_file, request_id=args.request_id, request_bytes=len(body)) if args.command == "comments": @@ -296,13 +310,14 @@ def build_operation(args): raise api.ClientError("--limit 必须在 1 到 100 之间") path += "/comment-threads/" + args.thread_id summary.update(method="GET", path=path, after_seq=args.after_seq, limit=args.limit) - if args.command in {"comment-create", "comment-reply", "comment-resolve", "comment-reopen", "comment-reanchor"}: + if args.command in {"comment-create", "comment-reply", "comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor"}: method = "POST" path += "/comment-threads" if args.command != "comment-create": suffix = { "comment-reply": "replies", "comment-resolve": "resolve", + "comment-delete": "delete", "comment-reopen": "reopen", "comment-reanchor": "reanchor", }[args.command] @@ -321,7 +336,7 @@ def build_operation(args): comment_body = read_comment_body(args.body_file) payload["body"] = comment_body summary["body_characters"] = len(comment_body) - if args.command in {"comment-resolve", "comment-reopen", "comment-reanchor"}: + if args.command in {"comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor"}: headers["If-Match"] = args.revision summary["revision"] = args.revision headers["Content-Type"] = "application/json" @@ -423,7 +438,7 @@ def execute_operation(api_client, args, method, path, headers, body, sensitive): query = urlencode(query_values) events = fetch_all_pages(api_client, path + "/events?" + query, "after_seq", "next_seq", sensitive) return {"thread": thread, "events": events["items"], "pages": events["pages"]} - if args.command in {"comment-create", "comment-reply", "comment-resolve", "comment-reopen", "comment-reanchor"}: + if args.command in {"comment-create", "comment-reply", "comment-resolve", "comment-delete", "comment-reopen", "comment-reanchor"}: written = call_business(api_client, method, path, headers, body, sensitive) if not isinstance(written, dict) or not isinstance(written.get("id"), str): raise api.ClientError("评论写入响应格式无效") @@ -431,8 +446,12 @@ def execute_operation(api_client, args, method, path, headers, body, sensitive): thread = call_business(api_client, "GET", thread_path, {}, None, sensitive) event_query = urlencode({"limit": 100, "request_id": args.request_id}) events = fetch_all_pages(api_client, thread_path + "/events?" + event_query, "after_seq", "next_seq", sensitive) - if len(events["items"]) != 1: + expected_kind = args.command.removeprefix("comment-").replace("create", "comment") + if len(events["items"]) != 1 or not isinstance(events["items"][0], dict) or events["items"][0].get("request_id") != args.request_id or events["items"][0].get("kind") != expected_kind: raise api.ClientError("评论写入已返回,但按 request_id 读回事件失败;请停止重试并人工核对") + expected_status = {"comment-delete": "deleted", "comment-resolve": "resolved", "comment-reopen": "open"}.get(args.command) + if not isinstance(thread, dict) or thread.get("id") != written["id"] or expected_status and thread.get("status") != expected_status: + raise api.ClientError("评论事件已写入,但当前主题状态不一致;可能有并发更新,请停止重试并核对最新历史") return {"thread": thread, "event": events["items"][0]} return call_business(api_client, method, path, headers, body, sensitive) @@ -444,6 +463,13 @@ def main(argv=None): mutation_attempted = False try: args = parse_args(argv) + if args.command == "check-html": + if api.CONTROL_CHARACTER.search(args.file.name): + raise api.ClientError("上传文件名不能包含控制字符") + _, _, content = api.build_multipart(args.file.expanduser(), args.entry_file, include_content=True) + checked = html_check.report(html_check.inspect_upload(content, args.file.suffix, args.entry_file), args.mode) + print(json.dumps({"result": checked, "mutation_attempted": False}, ensure_ascii=False, indent=2)) + return 1 if checked["errors"] else 0 config = load_config(args.config, args.endpoint) method, path, headers, body, summary = build_operation(args) if args.dry_run: @@ -462,13 +488,26 @@ def main(argv=None): api_client = api.HomeServerAPI(transport, credentials) api_client.exchange_token() sensitive += (api_client.access_token,) + if args.command == "upload": + project = call_business(api_client, "GET", API_PATH + "/" + args.id, {}, None, sensitive) + if not isinstance(project, dict) or project.get("container_mode") not in {"raw", "enhanced"}: + raise api.ClientError("无法核对项目容器模式;上传尚未发送,请确认服务端已升级") + args.html_report = html_check.report(args.html_findings, project["container_mode"]) + if args.html_report["errors"]: + raise HTMLIncompatible(args.html_report) mutation_attempted = method != "GET" data = execute_operation(api_client, args, method, path, headers, body, sensitive) result = {"endpoint": selected, "origin": origin, "result": data} if isinstance(data, dict) and isinstance(data.get("url"), str) and re.fullmatch(r"/p/[a-z0-9][a-z0-9-]{1,254}[a-z0-9]/", data["url"]): result["urls"] = {name: config[name + "_url"] + data["url"] for name in ("internal", "external") if config.get(name + "_url")} + if hasattr(args, "html_report"): + result["html_check"] = args.html_report print(json.dumps(api.redact_output(result, sensitive), ensure_ascii=False, indent=2)) return 0 + except HTMLIncompatible as error: + print(json.dumps(api.redact_output({"error": "html_incompatible", "message": str(error), "html_check": error.report, + "mutation_attempted": False}, sensitive), ensure_ascii=False, indent=2), file=sys.stderr) + return 1 except EndpointUnavailable as error: print(json.dumps({"error": "endpoint_unavailable", "message": str(error), "probes": error.probes}, ensure_ascii=False), file=sys.stderr) return 3