ChatComment 0.2.1__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.1 → chatcomment-0.2.2}/CHANGELOG.md +7 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/PKG-INFO +11 -2
- {chatcomment-0.2.1 → chatcomment-0.2.2}/README.md +10 -1
- {chatcomment-0.2.1 → chatcomment-0.2.2}/docs/api.md +6 -1
- {chatcomment-0.2.1 → chatcomment-0.2.2}/docs/deployment.md +1 -1
- {chatcomment-0.2.1 → chatcomment-0.2.2}/docs/platform.md +39 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/pyproject.toml +1 -1
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/ChatComment.egg-info/PKG-INFO +11 -2
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/__init__.py +1 -1
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/access.py +1 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/cli.py +14 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/server.py +16 -4
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/store.py +9 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/tests/cli-tests/test_cli.py +17 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/tests/code-tests/test_access.py +13 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/tests/code-tests/test_comments.py +18 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/tests/code-tests/test_platform_http.py +77 -2
- {chatcomment-0.2.1 → chatcomment-0.2.2}/AGENTS.md +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/LICENSE +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/MANIFEST.in +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/setup.cfg +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/ChatComment.egg-info/SOURCES.txt +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/ChatComment.egg-info/dependency_links.txt +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/ChatComment.egg-info/entry_points.txt +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/ChatComment.egg-info/requires.txt +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/ChatComment.egg-info/top_level.txt +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/static/admin.html +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/static/board.html +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/static/login.html +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/static/widget.css +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/static/widget.js +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/src/chatcomment/validation.py +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/tests/code-tests/test_chatlogin_adapter.py +0 -0
- {chatcomment-0.2.1 → chatcomment-0.2.2}/tests/code-tests/test_validation.py +0 -0
|
@@ -1,5 +1,12 @@
|
|
|
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
|
+
|
|
3
10
|
## 0.2.1
|
|
4
11
|
|
|
5
12
|
- `--require-existing-db` 在 Store 初始化之前验证既有 SQLite 完整性、必要表/字段和可读性,拒绝空文件、损坏或错误 schema。
|
|
@@ -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
|
|
|
@@ -70,6 +70,45 @@ or logged. Keep the backend path canonical and preserve raw body bytes.
|
|
|
70
70
|
Guest contexts must explicitly use `authenticated=False`; they cannot have
|
|
71
71
|
management eligibility and never read private sites, even if mis-scoped.
|
|
72
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
|
+
|
|
73
112
|
For a native in-process adapter, `make_handler(..., context_resolver=callable)`
|
|
74
113
|
accepts the same validated context. The callable receives headers, method,
|
|
75
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`,只用于入口提示。完整验证合约见部署文档。
|
|
@@ -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)
|
|
@@ -91,6 +91,20 @@ def allow_origin(ctx,site_id,origin):
|
|
|
91
91
|
click.echo(json.dumps(result,ensure_ascii=False,indent=2))
|
|
92
92
|
|
|
93
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
|
+
|
|
94
108
|
@main.command()
|
|
95
109
|
@click.argument('destination', type=click.Path(path_type=Path))
|
|
96
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)
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|