ChatComment 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. {chatcomment-0.2.0 → chatcomment-0.2.2}/CHANGELOG.md +12 -0
  2. {chatcomment-0.2.0 → chatcomment-0.2.2}/PKG-INFO +11 -2
  3. {chatcomment-0.2.0 → chatcomment-0.2.2}/README.md +10 -1
  4. {chatcomment-0.2.0 → chatcomment-0.2.2}/docs/api.md +6 -1
  5. {chatcomment-0.2.0 → chatcomment-0.2.2}/docs/deployment.md +1 -1
  6. {chatcomment-0.2.0 → chatcomment-0.2.2}/docs/platform.md +46 -0
  7. {chatcomment-0.2.0 → chatcomment-0.2.2}/pyproject.toml +1 -1
  8. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/PKG-INFO +11 -2
  9. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/SOURCES.txt +3 -1
  10. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/__init__.py +3 -2
  11. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/access.py +1 -0
  12. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/cli.py +21 -3
  13. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/server.py +16 -4
  14. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/store.py +9 -0
  15. chatcomment-0.2.2/src/chatcomment/validation.py +98 -0
  16. {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/cli-tests/test_cli.py +17 -0
  17. {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/code-tests/test_access.py +13 -0
  18. {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/code-tests/test_comments.py +18 -0
  19. {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/code-tests/test_platform_http.py +77 -2
  20. chatcomment-0.2.2/tests/code-tests/test_validation.py +89 -0
  21. {chatcomment-0.2.0 → chatcomment-0.2.2}/AGENTS.md +0 -0
  22. {chatcomment-0.2.0 → chatcomment-0.2.2}/LICENSE +0 -0
  23. {chatcomment-0.2.0 → chatcomment-0.2.2}/MANIFEST.in +0 -0
  24. {chatcomment-0.2.0 → chatcomment-0.2.2}/setup.cfg +0 -0
  25. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/dependency_links.txt +0 -0
  26. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/entry_points.txt +0 -0
  27. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/requires.txt +0 -0
  28. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/top_level.txt +0 -0
  29. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/admin.html +0 -0
  30. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/board.html +0 -0
  31. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/login.html +0 -0
  32. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/widget.css +0 -0
  33. {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/widget.js +0 -0
  34. {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/code-tests/test_chatlogin_adapter.py +0 -0
@@ -1,5 +1,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.2
4
+
5
+ - `Store.set_visibility`、`site visibility --public/--private` 和站点 visibility API 可切换既有站点公开/私有,只改 private,不重建站点或覆盖留言/设置。
6
+ - 签名模式的 visibility API 要求真实已认证的站点 owner 与写 CSRF;字段设置、审核和导出仍须密码再认证。公开访客只获 read/comment,不获邮箱齿轮或管理权限。
7
+ - 签名匿名访客也受每站点/可信会话 10 次每分钟的提交预算限制;旧公开模式保留每来源 IP 预算,内存桶数量有界。
8
+ - 公开访客无需账号即可提交,但仍受来源、页面、匿名开关、字段必填及审核规则约束;注册默认私有保持兼容。
9
+
10
+ ## 0.2.1
11
+
12
+ - `--require-existing-db` 在 Store 初始化之前验证既有 SQLite 完整性、必要表/字段和可读性,拒绝空文件、损坏或错误 schema。
13
+ - 只读源文件并在私有稳定快照上校验,保留活跃 WAL 支持,不在挂载目录创建 SQLite 辅助文件;允许完整 schema 的零留言数据库。
14
+
3
15
  ## 0.2.0
4
16
 
5
17
  - 平台中立的 `AccessContext` / `SiteAccess` API,复用 ChatLogin 主体并显式检查每站 read/comment/manage/owner;管理 grant 按 session 和站点区分。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Embeddable comments with a Python server, SQLite storage and a small JavaScript widget.
5
5
  Author: ChatArch
6
6
  License-Expression: MIT
@@ -42,10 +42,19 @@ chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
42
42
 
43
43
  组件自动读取设置并只显示允许的字段;必填校验由服务器执行。默认不记住访客信息、不加载第三方头像、不渲染留言 HTML。公开模式的匿名留言不代表拥有可信实名;关闭匿名时要求可信网关提供已验证的作者身份。
44
44
 
45
+ 既有站点可以明确切换公开或私有,不需要重新注册,也不会覆盖留言、字段设置或来源:
46
+
47
+ ```sh
48
+ chatcomment site visibility personal-blog --public
49
+ chatcomment site visibility personal-blog --private
50
+ ```
51
+
52
+ Python 对应 `Store.set_visibility(site_id, private=False)`。这是本地部署者命令;HTTP 对应 `POST /api/sites/{id}/visibility`、正文 `{"private":false}`,签名平台模式要求当前已认证的站点 owner 与写 CSRF。此项属于所有者发布策略;字段设置、审核和导出等留言管理仍须密码再认证。注册默认仍为私有;公开托管平台应显式使用 `--public`,或在已有站点上使用上述切换。
53
+
45
54
  API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
46
55
 
47
56
  多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
48
57
 
49
- 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
58
+ 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。未登录的公开访客没有该提示。
50
59
 
51
60
  网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称、邮箱或伪造角色 header 获得权限,因为缺少服务器 Bearer token。服务器可为受信 owner 请求附加 `X-ChatComment-Admin-Email`,只用于入口提示。完整验证合约见部署文档。
@@ -22,10 +22,19 @@ chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
22
22
 
23
23
  组件自动读取设置并只显示允许的字段;必填校验由服务器执行。默认不记住访客信息、不加载第三方头像、不渲染留言 HTML。公开模式的匿名留言不代表拥有可信实名;关闭匿名时要求可信网关提供已验证的作者身份。
24
24
 
25
+ 既有站点可以明确切换公开或私有,不需要重新注册,也不会覆盖留言、字段设置或来源:
26
+
27
+ ```sh
28
+ chatcomment site visibility personal-blog --public
29
+ chatcomment site visibility personal-blog --private
30
+ ```
31
+
32
+ Python 对应 `Store.set_visibility(site_id, private=False)`。这是本地部署者命令;HTTP 对应 `POST /api/sites/{id}/visibility`、正文 `{"private":false}`,签名平台模式要求当前已认证的站点 owner 与写 CSRF。此项属于所有者发布策略;字段设置、审核和导出等留言管理仍须密码再认证。注册默认仍为私有;公开托管平台应显式使用 `--public`,或在已有站点上使用上述切换。
33
+
25
34
  API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
26
35
 
27
36
  多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
28
37
 
29
- 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
38
+ 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。未登录的公开访客没有该提示。
30
39
 
31
40
  网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称、邮箱或伪造角色 header 获得权限,因为缺少服务器 Bearer token。服务器可为受信 owner 请求附加 `X-ChatComment-Admin-Email`,只用于入口提示。完整验证合约见部署文档。
@@ -1,6 +1,6 @@
1
1
  # Commands and APIs
2
2
 
3
- `chatcomment --help`, `--version`, `--tree`, `--tree-brief` describe the installed CLI. Commands: `serve`, `site register`, `site list`, `site allow-origin`, `backup`. Python API: `Store.register`, `site`, `sites`, `configure`, `allow_origin`, `create`, `comments`, `moderate`, `backup`; `server.make_handler` and `server.serve` expose the same HTTP service. Platform APIs `AccessContext`, `SiteAccess`, `sign_context`, `verify_context` are described in [platform.md](platform.md).
3
+ `chatcomment --help`, `--version`, `--tree`, `--tree-brief` describe the installed CLI. Commands: `serve`, `site register`, `site list`, `site allow-origin`, `site visibility`, `backup`. Python API: `Store.register`, `site`, `sites`, `configure`, `set_visibility`, `allow_origin`, `create`, `comments`, `moderate`, `backup`; `server.make_handler` and `server.serve` expose the same HTTP service. Platform APIs `AccessContext`, `SiteAccess`, `sign_context`, `verify_context` are described in [platform.md](platform.md).
4
4
 
5
5
  All request bodies are JSON (maximum 32 KiB). Site IDs are explicit, origins are exact and page paths must be pre-registered; query strings and hashes do not create new comment streams.
6
6
 
@@ -19,6 +19,7 @@ Platform deployments use `serve --gateway-key-env NAME`, optionally `--session-e
19
19
  | GET | `/board?site={id}&page=/path/` | Standalone widget host; apply the private-site gateway to this HTML too |
20
20
  | GET/POST | `/api/admin/sites` | List/register sites |
21
21
  | POST | `/api/sites/{id}/settings` | Complete settings object |
22
+ | POST | `/api/sites/{id}/visibility` | `{"private":false}` (or true); authenticated site owner plus write CSRF in platform mode |
22
23
  | GET | `/api/sites/{id}/admin?page=/path/` | All states with private fields |
23
24
  | POST | `/api/sites/{id}/moderate` | `{id,status:approved|pending|deleted}` |
24
25
  | GET | `/api/sites/{id}/export?page=/path/` | Admin data, paginated |
@@ -38,3 +39,7 @@ Settings example:
38
39
  ```
39
40
 
40
41
  Hidden fields are discarded. Email/URL formats, required fields and 1–5000 character plain text content are checked server-side. Browser output uses `textContent`; no comment HTML or third-party avatars are executed.
42
+
43
+ Visibility changes update only the existing `sites.private` value. They preserve every comment column/state, origin, page and field setting; unknown sites and non-boolean values are rejected. Registration remains private by default. The local operator must explicitly choose `site visibility SITE --public` or `--private`. The platform API requires a live authenticated site owner and verified write CSRF; a delegated manager, viewer or anonymous visitor cannot change this owner policy. Unlike field settings/moderation/export, this publishing-policy action does not require a temporary password management grant. Public site visibility and `allow_anonymous` are separate: both public visibility and anonymous submission permission are needed for a guest post.
44
+
45
+ Signed public guest submissions are limited to ten attempts/minute/site/verified guest-session identifier per process. The gateway supplies that opaque identifier in its signed context and must enforce its own visitor/abuse limits; client identity headers cannot choose it. Raw legacy public traffic retains the per-IP limit. Limiter state stays in bounded process memory and is not written to the comments database.
@@ -4,7 +4,7 @@ Install the built wheel into a versioned virtual environment. Run the service as
4
4
 
5
5
  For a private site, apply the existing site login to **every** API and asset request under the integration prefix. For writes, validate the existing session's CSRF token before proxying. The widget supports `data-csrf-endpoint="/api/session"`, which fetches a native `csrf_token` just before a write; it does not store credentials. The management UI also uses this native endpoint. Other session systems can provide this minimal same-origin contract or host a custom admin client.
6
6
 
7
- The gateway maps its prefix to backend `/`, injects the Bearer token and a verified identity marker, and relays only required response headers. Block unexpected methods and cap body size. A separate public site may explicitly use `site register --public`, but the private site setting must remain private. Site IDs are not secrets and never replace access control.
7
+ The gateway maps its prefix to backend `/`, injects the Bearer token and a verified identity marker, and relays only required response headers. Block unexpected methods and cap body size. A public site may explicitly use `site register --public`. Change an existing site's policy with `site visibility SITE --public` or `--private`; take a consistent backup and verify that only the private flag changed. Do not re-register an existing ID or restore an old database to change visibility. Site IDs are not secrets and never replace access control.
8
8
 
9
9
  For the single-gear workflow, inject the configured owner email only as the server-side `X-ChatComment-Admin-Email` header. Implement `/api/admin/reauth` at the gateway: check current session and CSRF, verify the supplied email/password through the existing account backend, verify its owner role, rotate the native session cookie and create a short-lived grant bound to that session. Protect `/admin`, `/api/admin/sites` and site settings/admin/export/moderate routes with this grant. Reject readers even if they type the owner email or forge role headers. Keep email hints, private read access and management grants distinct. The included login HTML stores no credentials and delegates password verification entirely to this gateway contract.
10
10
 
@@ -21,6 +21,13 @@ on the direct native application domain, require a signed context. `/health`
21
21
  is the only unsigned route. This mode explicitly rejects legacy token mode;
22
22
  browser `Role`, nickname, email and Bearer headers do not grant access.
23
23
 
24
+ `--require-existing-db` validates the existing SQLite before `Store` can initialize
25
+ anything: header, integrity, required tables/columns and readable schema. Empty,
26
+ corrupt, unrelated or incompatible files fail closed without source changes.
27
+ It reads a stable private snapshot including WAL rather than creating sidecars
28
+ beside the mounted source; valid schemas with zero comments are accepted.
29
+ The same preflight is available as `validate_existing_database(path)`.
30
+
24
31
  The legacy `--token-file` mode remains for existing deployments during staged
25
32
  migration. It must not be used as a multi-user platform authorization shortcut.
26
33
 
@@ -63,6 +70,45 @@ or logged. Keep the backend path canonical and preserve raw body bytes.
63
70
  Guest contexts must explicitly use `authenticated=False`; they cannot have
64
71
  management eligibility and never read private sites, even if mis-scoped.
65
72
 
73
+ For a public site, the platform can authorize a visitor without creating an
74
+ account. Verify the current app's public visibility, exact request Origin and a
75
+ server-issued guest CSRF token before signing a write:
76
+
77
+ ```python
78
+ guest = AccessContext(
79
+ subject='public-visitor', session=verified_guest_session_digest,
80
+ authenticated=False, csrf_verified=actual_guest_csrf_was_verified,
81
+ sites={site_id: SiteAccess(read=True, comment=True)},
82
+ )
83
+ ```
84
+
85
+ Never set guest `manage` or `owner`, infer identity from the email form, or mark a
86
+ guest authenticated to bypass private-site checks. The site itself must be
87
+ public and its settings must allow anonymous submissions. Required fields,
88
+ moderation, origin and page registration still apply. Its raw backend routes
89
+ remain signature-only even when the site is public.
90
+
91
+ The widget's configured session endpoint must return HTTP 200 and `csrf_token`
92
+ for authorized public guests as well as signed-in readers. A host-page loader
93
+ must allow `read=true` instead of requiring `authenticated=true`. Guests receive
94
+ `can_manage=false` and no settings gear. Signed guest posts have a ten/minute
95
+ budget per verified session and site in bounded process memory; gateways should
96
+ also enforce visitor limits before signing to prevent session cycling.
97
+
98
+ An existing site's policy can be changed with
99
+ `Store.set_visibility(site_id, private=False)` or the local operator command
100
+ `chatcomment --db /data/comments.sqlite3 site visibility SITE --public` (use
101
+ `--private` to reverse it). For delegated management use
102
+ `POST /api/sites/{id}/visibility` with `{"private":false}`: this requires a live
103
+ authenticated owner of the selected site and verified write CSRF. The
104
+ `visibility` permission deliberately uses the owner scope without a temporary
105
+ management grant, so the platform can synchronize an owner's explicit project
106
+ publishing policy without fabricating password reauthentication. Field settings,
107
+ moderation, export and the management UI continue requiring the password grant.
108
+ It changes only the private flag, not origins, pages, settings or comment rows.
109
+ Initial registration remains private unless the integration explicitly opts
110
+ into public access; existing sites never change silently on package upgrade.
111
+
66
112
  For a native in-process adapter, `make_handler(..., context_resolver=callable)`
67
113
  accepts the same validated context. The callable receives headers, method,
68
114
  downstream path and body bytes. Do not combine it with a gateway key or legacy
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ChatComment"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Embeddable comments with a Python server, SQLite storage and a small JavaScript widget."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Embeddable comments with a Python server, SQLite storage and a small JavaScript widget.
5
5
  Author: ChatArch
6
6
  License-Expression: MIT
@@ -42,10 +42,19 @@ chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
42
42
 
43
43
  组件自动读取设置并只显示允许的字段;必填校验由服务器执行。默认不记住访客信息、不加载第三方头像、不渲染留言 HTML。公开模式的匿名留言不代表拥有可信实名;关闭匿名时要求可信网关提供已验证的作者身份。
44
44
 
45
+ 既有站点可以明确切换公开或私有,不需要重新注册,也不会覆盖留言、字段设置或来源:
46
+
47
+ ```sh
48
+ chatcomment site visibility personal-blog --public
49
+ chatcomment site visibility personal-blog --private
50
+ ```
51
+
52
+ Python 对应 `Store.set_visibility(site_id, private=False)`。这是本地部署者命令;HTTP 对应 `POST /api/sites/{id}/visibility`、正文 `{"private":false}`,签名平台模式要求当前已认证的站点 owner 与写 CSRF。此项属于所有者发布策略;字段设置、审核和导出等留言管理仍须密码再认证。注册默认仍为私有;公开托管平台应显式使用 `--public`,或在已有站点上使用上述切换。
53
+
45
54
  API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
46
55
 
47
56
  多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
48
57
 
49
- 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
58
+ 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。未登录的公开访客没有该提示。
50
59
 
51
60
  网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称、邮箱或伪造角色 header 获得权限,因为缺少服务器 Bearer token。服务器可为受信 owner 请求附加 `X-ChatComment-Admin-Email`,只用于入口提示。完整验证合约见部署文档。
@@ -18,6 +18,7 @@ src/chatcomment/access.py
18
18
  src/chatcomment/cli.py
19
19
  src/chatcomment/server.py
20
20
  src/chatcomment/store.py
21
+ src/chatcomment/validation.py
21
22
  src/chatcomment/static/admin.html
22
23
  src/chatcomment/static/board.html
23
24
  src/chatcomment/static/login.html
@@ -27,4 +28,5 @@ tests/cli-tests/test_cli.py
27
28
  tests/code-tests/test_access.py
28
29
  tests/code-tests/test_chatlogin_adapter.py
29
30
  tests/code-tests/test_comments.py
30
- tests/code-tests/test_platform_http.py
31
+ tests/code-tests/test_platform_http.py
32
+ tests/code-tests/test_validation.py
@@ -1,7 +1,8 @@
1
1
  """Reusable comments storage and HTTP service."""
2
- __version__ = "0.2.0"
2
+ __version__ = "0.2.2"
3
3
 
4
4
  from .store import Store
5
5
  from .access import AccessContext,SiteAccess,sign_context,verify_context
6
+ from .validation import validate_existing_database
6
7
 
7
- __all__ = ["Store", "AccessContext", "SiteAccess", "sign_context", "verify_context", "__version__"]
8
+ __all__ = ["Store", "AccessContext", "SiteAccess", "sign_context", "verify_context", "validate_existing_database", "__version__"]
@@ -69,6 +69,7 @@ class AccessContext:
69
69
  scope=self.scope(site_id)
70
70
  if action=='read':return scope.read
71
71
  if action=='comment':return scope.read and scope.comment
72
+ if action=='visibility':return self.authenticated and scope.read and scope.owner
72
73
  if action=='hint':return self.authenticated and scope.read and scope.owner
73
74
  if action=='manage':
74
75
  return self.authenticated and scope.read and scope.manage and scope.management_until>(time.time() if now is None else now)
@@ -7,6 +7,7 @@ import click
7
7
  from chatstyle import add_tree_option
8
8
 
9
9
  from . import Store, __version__
10
+ from .validation import validate_existing_database
10
11
 
11
12
 
12
13
  @click.group(name='chatcomment')
@@ -29,7 +30,7 @@ def main(ctx, db):
29
30
  @click.option('--session-endpoint', default='/api/session',show_default=True)
30
31
  @click.option('--reauth-endpoint', default=None,help='Platform same-origin management reauthentication endpoint.')
31
32
  @click.option('--return-path', default='/',show_default=True)
32
- @click.option('--require-existing-db',is_flag=True,help='Fail closed if the persistent database mount is missing.')
33
+ @click.option('--require-existing-db',is_flag=True,help='Validate a readable existing ChatComment SQLite database before initialization.')
33
34
  @click.pass_context
34
35
  def serve(ctx, host, port, token_file,gateway_key_file,gateway_key_env,session_endpoint,reauth_endpoint,return_path,require_existing_db):
35
36
  """Serve API, widget assets and the gateway-protected admin page."""
@@ -40,8 +41,11 @@ def serve(ctx, host, port, token_file,gateway_key_file,gateway_key_env,session_e
40
41
  key=gateway_key_file.read_text().strip() if gateway_key_file else os.environ.get(gateway_key_env,'') if gateway_key_env else None
41
42
  if len(token if token is not None else key or '')<32:
42
43
  raise click.ClickException('Server credential must contain at least 32 characters.')
43
- if require_existing_db and not ctx.obj['db'].expanduser().is_file():
44
- raise click.ClickException('Persistent database mount is missing; refusing to create an empty database.')
44
+ if require_existing_db:
45
+ try:
46
+ validate_existing_database(ctx.obj['db'])
47
+ except ValueError as exc:
48
+ raise click.ClickException(str(exc)) from exc
45
49
  run(Store(ctx.obj['db']), token=token,gateway_key=key, host=host, port=port,
46
50
  session_endpoint=session_endpoint,reauth_endpoint=reauth_endpoint,return_path=return_path)
47
51
 
@@ -87,6 +91,20 @@ def allow_origin(ctx,site_id,origin):
87
91
  click.echo(json.dumps(result,ensure_ascii=False,indent=2))
88
92
 
89
93
 
94
+ @site.command(name='visibility')
95
+ @click.argument('site_id')
96
+ @click.option('--public/--private','public',default=None,help='Choose explicitly: public visitors or authenticated private access.')
97
+ @click.pass_context
98
+ def visibility(ctx,site_id,public):
99
+ """Change an existing site's visibility, preserving comments and settings."""
100
+ if public is None:raise click.UsageError('Choose --public or --private explicitly.')
101
+ try:
102
+ result=Store(ctx.obj['db']).set_visibility(site_id,private=not public)
103
+ except (ValueError,KeyError) as exc:
104
+ raise click.ClickException(str(exc)) from exc
105
+ click.echo(json.dumps(result,ensure_ascii=False,indent=2))
106
+
107
+
90
108
  @main.command()
91
109
  @click.argument('destination', type=click.Path(path_type=Path))
92
110
  @click.pass_context
@@ -213,6 +213,11 @@ def make_handler(store: Store, admin_token: str | None = None, *, gateway_key=No
213
213
  'auth':auth_config})
214
214
  if action == 'settings' and self.command == 'POST':
215
215
  return self.send(200, store.configure(site_id, self.payload()))
216
+ if action == 'visibility' and self.command == 'POST':
217
+ if scoped:
218
+ context.require(site_id,'visibility',write=True)
219
+ elif not manager:raise PermissionError('需要站点所有者权限')
220
+ return self.send(200,store.set_visibility(site_id,private=self.payload().get('private')))
216
221
  if action == 'moderate' and self.command == 'POST':
217
222
  v = self.payload()
218
223
  return self.send(200, store.moderate(site_id, v.get('id'), v.get('status')))
@@ -223,15 +228,22 @@ def make_handler(store: Store, admin_token: str | None = None, *, gateway_key=No
223
228
  context.require(site_id,'comment',write=True)
224
229
  if not trusted and (not request_origin or self.headers.get('X-ChatComment-Request') != '1'):
225
230
  raise PermissionError('留言需通过允许来源的组件提交')
226
- # A process-local bound keeps public submissions within 10/minute/IP.
227
- if not trusted:
231
+ signed_guest=bool(scoped and context and not context.authenticated)
232
+ if signed_guest and not request_origin:
233
+ raise PermissionError('匿名留言需通过允许来源的组件提交')
234
+ # Gateways share a source IP. Their verified guest session
235
+ # scopes the budget; raw public mode keeps its per-IP budget.
236
+ if not trusted or signed_guest:
228
237
  now = time.monotonic()
229
238
  with lock:
230
- if len(attempts) > 10000:
239
+ if len(attempts) >= 10000:
231
240
  for k in list(attempts):
232
241
  if not attempts[k] or now - attempts[k][-1] > 60:
233
242
  del attempts[k]
234
- key = (self.client_address[0],site_id)
243
+ identity=('gateway-session',context.session) if signed_guest else ('ip',self.client_address[0])
244
+ key = (identity,site_id)
245
+ if key not in attempts and len(attempts)>=10000:
246
+ return self.send(429,{'error':'留言太频繁,请稍后再试'})
235
247
  bucket = attempts[key]
236
248
  while bucket and now - bucket[0] > 60:
237
249
  bucket.popleft()
@@ -142,6 +142,15 @@ class Store:
142
142
  db.execute('UPDATE sites SET settings=? WHERE id=?', (json.dumps(config), site_id))
143
143
  return self.site(site_id)
144
144
 
145
+ def set_visibility(self,site_id,*,private):
146
+ """Change one existing site's visibility without recreating its data."""
147
+ if type(private) is not bool:
148
+ raise Invalid('private 必须是布尔值')
149
+ with self.connect() as db:
150
+ result=db.execute('UPDATE sites SET private=? WHERE id=?',(int(private),site_id))
151
+ if result.rowcount!=1:raise KeyError('站点不存在')
152
+ return self.site(site_id)
153
+
145
154
  def allow_origin(self,site_id,value):
146
155
  """Add an approved platform origin without touching fields or comments."""
147
156
  approved=origin(value)
@@ -0,0 +1,98 @@
1
+ """Read-only preflight for an existing persistent ChatComment database.
2
+
3
+ SQLite mode=ro may still create WAL sidecars, and immutable=1 ignores a live
4
+ WAL. Validate a stable private copy of the main file and journals instead; never
5
+ open the source with SQLite, change its permissions or initialize its schema.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from contextlib import closing
10
+ import os
11
+ from pathlib import Path
12
+ import shutil
13
+ import sqlite3
14
+ import stat
15
+ import tempfile
16
+
17
+ from .store import Invalid
18
+
19
+ _COLUMNS = {
20
+ 'sites': {'id':'TEXT','title':'TEXT','origins':'TEXT','pages':'TEXT','private':'INTEGER','settings':'TEXT'},
21
+ 'comments': {'id':'TEXT','site_id':'TEXT','page_key':'TEXT','nickname':'TEXT','email':'TEXT',
22
+ 'website':'TEXT','content':'TEXT','status':'TEXT','created_at':'TEXT','authenticated':'INTEGER'},
23
+ }
24
+
25
+
26
+ def _files_state(path):
27
+ state={}
28
+ for suffix in ['', '-wal', '-journal']:
29
+ candidate=Path(str(path)+suffix)
30
+ try:
31
+ info=candidate.stat()
32
+ except FileNotFoundError:
33
+ state[suffix]=None
34
+ continue
35
+ if not stat.S_ISREG(info.st_mode):
36
+ raise Invalid('Persistent database validation failed: database and journals must be regular files.')
37
+ state[suffix]=(info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns,info.st_ctime_ns)
38
+ return state
39
+
40
+
41
+ def _validate_snapshot(path):
42
+ with closing(sqlite3.connect(path.as_uri()+'?mode=ro',uri=True,timeout=5)) as db:
43
+ db.execute('PRAGMA query_only=ON')
44
+ if db.execute('PRAGMA quick_check').fetchall()!=[('ok',)]:
45
+ raise Invalid('Persistent database validation failed: SQLite integrity check failed.')
46
+ tables={row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
47
+ for table,required in _COLUMNS.items():
48
+ if table not in tables:
49
+ raise Invalid(f'Persistent database validation failed: missing ChatComment table {table}.')
50
+ columns={row[1]:row for row in db.execute(f'PRAGMA table_info({table})')}
51
+ if ([name for name,row in columns.items() if row[5]]!=['id'] or
52
+ any(name not in columns or columns[name][2].strip().upper()!=kind or
53
+ (name!='id' and columns[name][3]!=1) for name,kind in required.items())):
54
+ raise Invalid(f'Persistent database validation failed: incompatible ChatComment schema for {table}.')
55
+ db.execute('SELECT '+','.join(required)+' FROM '+table+' LIMIT 1').fetchall()
56
+ foreign_keys={(row[2],row[3],row[4]) for row in db.execute('PRAGMA foreign_key_list(comments)')}
57
+ if ('sites','site_id','id') not in foreign_keys:
58
+ raise Invalid('Persistent database validation failed: missing comment site relationship.')
59
+
60
+
61
+ def validate_existing_database(value):
62
+ """Validate a readable, structurally intact existing database without writes.
63
+
64
+ Empty files, unrelated SQLite databases, corrupt/incomplete schema and
65
+ unreadable files are refused before Store initialization. A valid database
66
+ may contain zero comments or even zero sites. Normal WAL writes may require
67
+ a retry to obtain a stable snapshot; persistent changes fail closed.
68
+ """
69
+ try:
70
+ path=Path(value).expanduser().resolve()
71
+ if not path.is_file():
72
+ raise Invalid('Persistent database mount is missing; refusing to create an empty database.')
73
+ with path.open('rb') as source:
74
+ if source.read(16)!=b'SQLite format 3\x00':
75
+ raise Invalid('Persistent database validation failed: empty or invalid SQLite file.')
76
+ for _ in range(3):
77
+ before=_files_state(path)
78
+ if before[''] is None:
79
+ continue
80
+ with tempfile.TemporaryDirectory(prefix='chatcomment-validate-') as directory:
81
+ snapshot=Path(directory)/'database.sqlite3'
82
+ try:
83
+ for suffix,info in before.items():
84
+ if info is None:
85
+ continue
86
+ target=Path(str(snapshot)+suffix)
87
+ descriptor=os.open(target,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
88
+ with os.fdopen(descriptor,'wb') as destination,Path(str(path)+suffix).open('rb') as source:
89
+ shutil.copyfileobj(source,destination)
90
+ if _files_state(path)!=before:
91
+ continue
92
+ _validate_snapshot(snapshot)
93
+ return path
94
+ except FileNotFoundError:
95
+ continue
96
+ raise Invalid('Persistent database validation failed: database changed during the read-only check; retry startup.')
97
+ except (OSError,sqlite3.Error) as exc:
98
+ raise Invalid('Persistent database validation failed: database or schema is unreadable or corrupt.') from exc
@@ -21,3 +21,20 @@ def test_scoped_missing_database_fails_before_creating_file(tmp_path):
21
21
  result=CliRunner().invoke(main,['--db',str(path),'serve','--gateway-key-env','TEST_GATEWAY_KEY','--require-existing-db'],env={'TEST_GATEWAY_KEY':'x'*48})
22
22
  assert result.exit_code!=0 and 'mount is missing' in result.output
23
23
  assert not path.exists()
24
+
25
+
26
+ def test_operator_visibility_requires_explicit_choice_and_preserves_site(tmp_path):
27
+ import json
28
+ from chatcomment import Store
29
+ path=tmp_path/'db.sqlite3';store=Store(path)
30
+ before=store.register('demo',title='Demo',origins=['https://example.com'],pages=['/post/'])
31
+ row=store.create('demo',{'page':'/post/','content':'keep row'},authenticated=True)
32
+ r=CliRunner();args=['--db',str(path),'site','visibility','demo']
33
+ missing=r.invoke(main,args)
34
+ assert missing.exit_code!=0 and 'Choose --public or --private' in missing.output
35
+ for option,private in [('--public',False),('--private',True)]:
36
+ result=r.invoke(main,args+[option]);assert result.exit_code==0,result.output
37
+ actual=json.loads(result.output)
38
+ assert actual['private'] is private
39
+ assert {k:v for k,v in actual.items() if k!='private'}=={k:v for k,v in before.items() if k!='private'}
40
+ assert store.comments('demo','/post/',admin=True)['comments'][0]['id']==row['id']
@@ -35,6 +35,19 @@ def test_chatlogin_global_admin_does_not_grant_sites():
35
35
  assert not c.permits('any-site','manage',now=100)
36
36
 
37
37
 
38
+ def test_visibility_is_live_site_owner_policy_with_csrf_not_management_reauth():
39
+ c=context()
40
+ assert c.permits('owned','visibility',now=500)
41
+ assert not c.permits('owned','manage',now=500)
42
+ assert not c.permits('viewed','visibility')
43
+ assert not c.permits('other-managed','visibility')
44
+ assert not c.permits('ungranted','visibility')
45
+ guest=AccessContext('guest','guest-digest',sites={'owned':SiteAccess(read=True,comment=True)},authenticated=False)
46
+ assert not guest.permits('owned','visibility')
47
+ no_csrf=AccessContext('owner','owner-digest',sites={'owned':SiteAccess(True,True,True,True)})
48
+ with pytest.raises(PermissionError):no_csrf.require('owned','visibility',write=True)
49
+
50
+
38
51
  def test_signed_body_method_path_and_ttl():
39
52
  headers=sign_context(context(),key=KEY,method='POST',path='/api/sites/owned/settings',body=b'{"a":1}',now=100)
40
53
  c=verify_context(headers,key=KEY,method='POST',path='/api/sites/owned/settings',body=b'{"a":1}',now=110)
@@ -42,6 +42,24 @@ def test_allow_origin_preserves_all_other_site_fields_and_rows(store):
42
42
  assert store.allow_origin('first','https://new-platform.example')==after
43
43
 
44
44
 
45
+ def test_visibility_changes_only_existing_site_private_flag(store):
46
+ item=store.create('second',{'page':'/one/','content':'Keep every column','email':'private@example.invalid'},authenticated=True)
47
+ store.moderate('second',item['id'],'deleted')
48
+ before=store.site('second');comments=store.comments('second','/one/',admin=True)
49
+ other=store.site('first');physical=store.path.stat()
50
+ for private in [False,False,True]:
51
+ after=store.set_visibility('second',private=private)
52
+ assert after['private'] is private
53
+ assert {k:v for k,v in after.items() if k!='private'}=={k:v for k,v in before.items() if k!='private'}
54
+ assert store.comments('second','/one/',admin=True)==comments
55
+ assert store.site('first')==other
56
+ assert store.path.stat().st_ino==physical.st_ino
57
+ for invalid in [None,0,1,'public',{},[]]:
58
+ with pytest.raises(Invalid):store.set_visibility('second',private=invalid)
59
+ with pytest.raises(KeyError):store.set_visibility('missing-site',private=False)
60
+ assert store.site('second')==before
61
+
62
+
45
63
  def test_fields_and_anonymous_enforced(store):
46
64
  config=store.site('first')['settings']
47
65
  config['fields']['email']='required'
@@ -23,10 +23,10 @@ def platform(tmp_path):
23
23
  server=ThreadingHTTPServer(('127.0.0.1',0),make_handler(store,gateway_key=KEY,
24
24
  session_endpoint='/auth/api/session',reauth_endpoint='/extensions/comments/reauth',return_path='/sites/alice-site/'))
25
25
  thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start()
26
- def request(path,*,context=None,body=None,signature_path=None,legacy=False):
26
+ def request(path,*,context=None,body=None,signature_path=None,legacy=False,origin='https://platform.example'):
27
27
  raw=json.dumps(body).encode() if body is not None else b''
28
28
  method='POST' if body is not None else 'GET'
29
- headers={'Origin':'https://platform.example'}
29
+ headers={'Origin':origin} if origin else {}
30
30
  if body is not None:headers['Content-Type']='application/json'
31
31
  if context is not None:headers.update(sign_context(context,key=KEY,method=method,path=signature_path or path,body=raw))
32
32
  if legacy:headers.update({'Authorization':'Bearer '+KEY,'X-ChatComment-Role':'admin','X-ChatComment-Identity':'alice'})
@@ -101,3 +101,78 @@ def test_scoped_login_configuration_and_registration_denial(platform):
101
101
  assert http('/login?site=bob-site',context=owner())[0]==403
102
102
  assert http('/api/admin/sites',context=owner(verified=True),body={'id':'bob-site','title':'overwrite','origins':['https://platform.example'],'pages':['/kept/page/']})[0]==403
103
103
  with pytest.raises(ValueError):make_handler(store,KEY,gateway_key=KEY)
104
+
105
+
106
+ def guest(*,session='guest-session-digest',csrf=True):
107
+ return AccessContext('public-visitor',session,sites={
108
+ 'alice-site':SiteAccess(read=True,comment=True),'bob-site':SiteAccess(read=True,comment=True)},
109
+ authenticated=False,csrf_verified=csrf)
110
+
111
+
112
+ def test_owner_visibility_transition_public_guest_and_management_boundaries(platform):
113
+ store,http=platform
114
+ original=store.site('alice-site')
115
+ kept=store.create('alice-site',{'page':'/kept/page/','content':'Existing owner comment','email':'kept@example.invalid'},authenticated=True)
116
+ before=store.comments('alice-site','/kept/page/',admin=True)
117
+ path='/api/sites/alice-site/visibility'
118
+ viewer=AccessContext('viewer','viewer-session',sites={'alice-site':SiteAccess(read=True)},csrf_verified=True)
119
+ deployer=AccessContext('deployer','deployer-session',sites={'alice-site':SiteAccess(read=True,comment=True)},csrf_verified=True)
120
+ manager=AccessContext('manager','manager-session',sites={
121
+ 'alice-site':SiteAccess(True,True,True,False,time.time()+900)},csrf_verified=True)
122
+ for context in [owner(verified=True,csrf=False),viewer,deployer,manager]:
123
+ assert http(path,context=context,body={'private':False})[0]==403
124
+ assert http(path,context=owner(verified=True),origin='https://malicious.example',body={'private':False})[0]==403
125
+ assert http(path,context=owner(verified=True),body={'private':'false'})[0]==400
126
+ code,site=http(path,context=owner(),body={'private':False})
127
+ assert code==200 and site['private'] is False
128
+ assert http('/api/sites/alice-site/settings',context=owner(),body=original['settings'])[0]==403
129
+ assert http('/api/admin/sites',context=owner())[0]==403
130
+ assert {k:v for k,v in site.items() if k!='private'}=={k:v for k,v in original.items() if k!='private'}
131
+ assert store.comments('alice-site','/kept/page/',admin=True)==before
132
+ code,config=http('/api/sites/alice-site/config',context=guest())
133
+ assert code==200 and config['can_manage'] is False
134
+ assert http('/api/sites/alice-site/config')[0]==403
135
+ assert http('/widget.js')[0]==403
136
+ body={'page':'/kept/page/','content':'Anonymous guest comment'}
137
+ code,row=http('/api/sites/alice-site/comments',context=guest(),body=body)
138
+ assert code==201
139
+ with store.connect() as db:
140
+ assert db.execute('SELECT authenticated FROM comments WHERE id=?',(row['id'],)).fetchone()[0]==0
141
+ rows=http('/api/sites/alice-site/comments?page=/kept/page/',context=guest())[1]['comments']
142
+ assert {r['id'] for r in rows}=={kept['id'],row['id']} and all('email' not in r for r in rows)
143
+ assert http('/api/sites/alice-site/comments',context=guest(csrf=False),body=body)[0]==403
144
+ assert http('/api/sites/alice-site/comments',context=guest(),origin='https://malicious.example',body=body)[0]==403
145
+ assert http('/api/sites/alice-site/comments',context=guest(),origin=None,body=body)[0]==403
146
+ assert http('/api/admin/entry',context=guest(),body={'site':'alice-site','email':'alice@example.invalid'})[0]==403
147
+ assert http('/api/admin/sites',context=guest())[0]==403
148
+ assert http('/login?site=alice-site',context=guest())[0]==403
149
+ assert http(path,context=guest(),body={'private':True})[0]==403
150
+ assert http('/api/sites/alice-site/settings',context=guest(),body=original['settings'])[0]==403
151
+ assert http('/api/sites/bob-site/visibility',context=owner(verified=True),body={'private':False})[0]==403
152
+ assert http(path,context=owner(),body={'private':True})[0]==200
153
+ assert store.site('alice-site')==original
154
+ assert http('/api/sites/alice-site/config',context=guest())[0]==403
155
+ assert http('/api/sites/alice-site/comments',context=guest(),body=body)[0]==403
156
+
157
+
158
+ def test_signed_guest_rate_budget_is_by_verified_session_and_site(platform):
159
+ store,http=platform
160
+ for site in ['alice-site','bob-site']:store.set_visibility(site,private=False)
161
+ body={'page':'/kept/page/','content':'Isolated guest rate-limit test'}
162
+ for _ in range(10):assert http('/api/sites/alice-site/comments',context=guest(),body=body)[0]==201
163
+ assert http('/api/sites/alice-site/comments',context=guest(),body=body)[0]==429
164
+ assert http('/api/sites/alice-site/comments',context=guest(session='different-verified-session'),body=body)[0]==201
165
+ assert http('/api/sites/bob-site/comments',context=guest(),body=body)[0]==201
166
+ assert http('/api/sites/alice-site/comments',context=owner(),body=body)[0]==201
167
+
168
+
169
+ def test_public_guest_still_obeys_anonymous_and_field_settings(platform):
170
+ store,http=platform;store.set_visibility('alice-site',private=False)
171
+ body={'page':'/kept/page/','content':'Anonymous rules'}
172
+ config=store.site('alice-site')['settings'];config['allow_anonymous']=False
173
+ store.configure('alice-site',config)
174
+ assert http('/api/sites/alice-site/comments',context=guest(),body=body)[0]==403
175
+ config['allow_anonymous']=True;config['fields']['nickname']='required'
176
+ store.configure('alice-site',config)
177
+ assert http('/api/sites/alice-site/comments',context=guest(),body=body)[0]==400
178
+ assert http('/api/sites/alice-site/comments',context=guest(),body={**body,'nickname':'Guest'})[0]==201
@@ -0,0 +1,89 @@
1
+ import os
2
+ from pathlib import Path
3
+ import sqlite3
4
+ from unittest.mock import patch
5
+
6
+ from click.testing import CliRunner
7
+ import pytest
8
+
9
+ from chatcomment import Store,validate_existing_database
10
+ from chatcomment.cli import main
11
+ from chatcomment.store import Invalid
12
+
13
+
14
+ def source_state(directory):
15
+ return {p.name:(p.read_bytes(),p.stat().st_mtime_ns,p.stat().st_mode) for p in directory.iterdir() if p.is_file()}
16
+
17
+
18
+ @pytest.mark.parametrize('kind',['empty','garbage','corrupt','unrelated','missing-table','bad-columns','views'])
19
+ def test_invalid_existing_database_never_initializes_or_writes(tmp_path,kind):
20
+ path=tmp_path/'comments.sqlite3'
21
+ if kind=='empty':path.touch()
22
+ elif kind=='garbage':path.write_bytes(b'not a sqlite database')
23
+ elif kind=='corrupt':
24
+ Store(path)
25
+ path.write_bytes(path.read_bytes()[:100])
26
+ else:
27
+ with sqlite3.connect(path) as db:
28
+ if kind=='unrelated':db.execute('CREATE TABLE unrelated(id INTEGER)')
29
+ elif kind=='missing-table':db.execute('CREATE TABLE sites(id TEXT PRIMARY KEY)')
30
+ elif kind=='bad-columns':
31
+ db.execute('CREATE TABLE sites(id INTEGER,title TEXT)')
32
+ db.execute('CREATE TABLE comments(id TEXT)')
33
+ else:
34
+ db.execute('CREATE TABLE data(id TEXT)')
35
+ db.execute('CREATE VIEW sites AS SELECT * FROM data')
36
+ db.execute('CREATE VIEW comments AS SELECT * FROM data')
37
+ before=source_state(tmp_path)
38
+ with patch('chatcomment.cli.Store') as constructor,patch('chatcomment.server.serve') as run:
39
+ result=CliRunner().invoke(main,['--db',str(path),'serve','--gateway-key-env','TEST_GATEWAY_KEY','--require-existing-db'],env={'TEST_GATEWAY_KEY':'x'*48})
40
+ assert result.exit_code!=0 and 'Persistent database validation failed' in result.output
41
+ constructor.assert_not_called();run.assert_not_called()
42
+ assert source_state(tmp_path)==before
43
+
44
+
45
+ def test_complete_zero_comment_schema_is_accepted_without_source_sidecars(tmp_path):
46
+ path=tmp_path/'comments.sqlite3';Store(path)
47
+ before=source_state(tmp_path)
48
+ assert validate_existing_database(path)==path.resolve()
49
+ assert source_state(tmp_path)==before
50
+ with patch('chatcomment.server.serve') as run:
51
+ result=CliRunner().invoke(main,['--db',str(path),'serve','--gateway-key-env','TEST_GATEWAY_KEY','--require-existing-db'],env={'TEST_GATEWAY_KEY':'x'*48})
52
+ assert result.exit_code==0,result.output
53
+ run.assert_called_once()
54
+
55
+
56
+ def test_live_wal_schema_is_read_and_source_files_are_untouched(tmp_path):
57
+ path=tmp_path/'comments.sqlite3'
58
+ # Keep a writer open so the current schema lives in WAL, not the base file.
59
+ template=Store(tmp_path/'template.sqlite3')
60
+ with template.connect() as db:
61
+ schema=[r[0] for r in db.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY rowid")]
62
+ writer=sqlite3.connect(path)
63
+ writer.execute('PRAGMA journal_mode=WAL');writer.execute('PRAGMA wal_autocheckpoint=0')
64
+ writer.executescript(';\n'.join(schema)+';')
65
+ with sqlite3.connect(path.as_uri()+'?mode=ro&immutable=1',uri=True) as base_only:
66
+ assert 'comments' not in {r[0] for r in base_only.execute('SELECT name FROM sqlite_master')}
67
+ before=source_state(tmp_path)
68
+ assert validate_existing_database(path)==path.resolve()
69
+ assert source_state(tmp_path)==before
70
+ writer.close()
71
+
72
+
73
+ def test_wrong_wal_schema_does_not_create_source_sidecars(tmp_path):
74
+ path=tmp_path/'comments.sqlite3'
75
+ writer=sqlite3.connect(path);writer.execute('PRAGMA journal_mode=WAL')
76
+ writer.execute('CREATE TABLE unrelated(id TEXT)');writer.commit()
77
+ before=source_state(tmp_path)
78
+ with pytest.raises(Invalid):validate_existing_database(path)
79
+ assert source_state(tmp_path)==before
80
+ writer.close()
81
+
82
+
83
+ @pytest.mark.skipif(hasattr(os,'geteuid') and os.geteuid()==0,reason='Root can read mode000 files')
84
+ def test_unreadable_file_is_refused_without_changing_permissions(tmp_path):
85
+ path=tmp_path/'comments.sqlite3';Store(path);path.chmod(0)
86
+ try:
87
+ with pytest.raises(Invalid,match='unreadable'):validate_existing_database(path)
88
+ assert path.stat().st_mode & 0o777==0
89
+ finally:path.chmod(0o600)
File without changes
File without changes
File without changes
File without changes