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.
- {chatcomment-0.2.0 → chatcomment-0.2.2}/CHANGELOG.md +12 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/PKG-INFO +11 -2
- {chatcomment-0.2.0 → chatcomment-0.2.2}/README.md +10 -1
- {chatcomment-0.2.0 → chatcomment-0.2.2}/docs/api.md +6 -1
- {chatcomment-0.2.0 → chatcomment-0.2.2}/docs/deployment.md +1 -1
- {chatcomment-0.2.0 → chatcomment-0.2.2}/docs/platform.md +46 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/pyproject.toml +1 -1
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/PKG-INFO +11 -2
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/SOURCES.txt +3 -1
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/__init__.py +3 -2
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/access.py +1 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/cli.py +21 -3
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/server.py +16 -4
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/store.py +9 -0
- chatcomment-0.2.2/src/chatcomment/validation.py +98 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/cli-tests/test_cli.py +17 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/code-tests/test_access.py +13 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/code-tests/test_comments.py +18 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/tests/code-tests/test_platform_http.py +77 -2
- chatcomment-0.2.2/tests/code-tests/test_validation.py +89 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/AGENTS.md +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/LICENSE +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/MANIFEST.in +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/setup.cfg +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/dependency_links.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/entry_points.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/requires.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/ChatComment.egg-info/top_level.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/admin.html +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/board.html +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/login.html +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/widget.css +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.2}/src/chatcomment/static/widget.js +0 -0
- {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.
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
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='
|
|
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
|
|
44
|
-
|
|
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
|
-
|
|
227
|
-
if not
|
|
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)
|
|
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
|
-
|
|
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':
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|