ChatComment 0.1.0__tar.gz → 0.2.0__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 (35) hide show
  1. chatcomment-0.2.0/CHANGELOG.md +20 -0
  2. {chatcomment-0.1.0 → chatcomment-0.2.0}/PKG-INFO +7 -2
  3. {chatcomment-0.1.0 → chatcomment-0.2.0}/README.md +5 -1
  4. {chatcomment-0.1.0 → chatcomment-0.2.0}/docs/api.md +7 -1
  5. {chatcomment-0.1.0 → chatcomment-0.2.0}/docs/deployment.md +2 -0
  6. chatcomment-0.2.0/docs/platform.md +113 -0
  7. {chatcomment-0.1.0 → chatcomment-0.2.0}/pyproject.toml +2 -2
  8. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/ChatComment.egg-info/PKG-INFO +7 -2
  9. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/ChatComment.egg-info/SOURCES.txt +7 -1
  10. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/ChatComment.egg-info/requires.txt +1 -0
  11. chatcomment-0.2.0/src/chatcomment/__init__.py +7 -0
  12. chatcomment-0.2.0/src/chatcomment/access.py +150 -0
  13. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/chatcomment/cli.py +32 -6
  14. chatcomment-0.2.0/src/chatcomment/server.py +266 -0
  15. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/chatcomment/static/admin.html +6 -3
  16. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/chatcomment/static/board.html +6 -4
  17. chatcomment-0.2.0/src/chatcomment/static/login.html +20 -0
  18. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/chatcomment/static/widget.js +18 -3
  19. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/chatcomment/store.py +13 -0
  20. {chatcomment-0.1.0 → chatcomment-0.2.0}/tests/cli-tests/test_cli.py +7 -0
  21. chatcomment-0.2.0/tests/code-tests/test_access.py +46 -0
  22. chatcomment-0.2.0/tests/code-tests/test_chatlogin_adapter.py +24 -0
  23. {chatcomment-0.1.0 → chatcomment-0.2.0}/tests/code-tests/test_comments.py +14 -0
  24. chatcomment-0.2.0/tests/code-tests/test_platform_http.py +103 -0
  25. chatcomment-0.1.0/CHANGELOG.md +0 -8
  26. chatcomment-0.1.0/src/chatcomment/__init__.py +0 -6
  27. chatcomment-0.1.0/src/chatcomment/server.py +0 -163
  28. {chatcomment-0.1.0 → chatcomment-0.2.0}/AGENTS.md +0 -0
  29. {chatcomment-0.1.0 → chatcomment-0.2.0}/LICENSE +0 -0
  30. {chatcomment-0.1.0 → chatcomment-0.2.0}/MANIFEST.in +0 -0
  31. {chatcomment-0.1.0 → chatcomment-0.2.0}/setup.cfg +0 -0
  32. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/ChatComment.egg-info/dependency_links.txt +0 -0
  33. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/ChatComment.egg-info/entry_points.txt +0 -0
  34. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/ChatComment.egg-info/top_level.txt +0 -0
  35. {chatcomment-0.1.0 → chatcomment-0.2.0}/src/chatcomment/static/widget.css +0 -0
@@ -0,0 +1,20 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ - 平台中立的 `AccessContext` / `SiteAccess` API,复用 ChatLogin 主体并显式检查每站 read/comment/manage/owner;管理 grant 按 session 和站点区分。
6
+ - 独立服务签名网关模式,验证短时 method/path/query/body 绑定上下文;所有非 health 路由要求签名,不接受旧全局 token 作为替代。
7
+ - 邮箱齿轮提示、管理列表、再认证和前端 CSRF 端点按站点及平台配置;不向前端暴露期望邮箱或服务器密钥。
8
+ - 迁移所需的 `site allow-origin` 只更新允许来源,保留字段设置和全部留言;`--require-existing-db` 防止缺少持久卷时创建空库。
9
+
10
+ ## 0.1.1
11
+
12
+ - 留言区仅显示一个齿轮入口;本人填写管理员邮箱后由服务器确认显示,不按昵称判断、不下发管理员邮箱。
13
+ - 提供邮箱/密码验证页,网关可在现有账号校验后授予短时管理权限;管理 API 仍由服务器角色授权。
14
+
15
+ ## 0.1.0
16
+
17
+ - Python/SQLite 留言服务、无依赖 JS 组件及同源管理界面。
18
+ - 站点来源和页面白名单,匿名与字段设置,服务器端校验、审核、软删除、JSON 导出与 SQLite 在线备份。
19
+ - 私有站点可信网关模式;管理 token 留在服务器。
20
+ - 独立留言板与嵌入组件共用数据;服务端 owner / reader 权限与设置入口显示。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -15,6 +15,7 @@ Provides-Extra: dev
15
15
  Requires-Dist: build; extra == "dev"
16
16
  Requires-Dist: pytest>=8; extra == "dev"
17
17
  Requires-Dist: twine; extra == "dev"
18
+ Requires-Dist: chatlogin<0.3.0,>=0.2.1; extra == "dev"
18
19
  Dynamic: license-file
19
20
 
20
21
  # ChatComment
@@ -43,4 +44,8 @@ chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
43
44
 
44
45
  API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
45
46
 
46
- 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。设置按钮只在服务器返回 `can_manage=true` 时显示。网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称或伪造角色 header 获得权限,因为缺少服务器 Bearer token。
47
+ 多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
48
+
49
+ 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
50
+
51
+ 网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称、邮箱或伪造角色 header 获得权限,因为缺少服务器 Bearer token。服务器可为受信 owner 请求附加 `X-ChatComment-Admin-Email`,只用于入口提示。完整验证合约见部署文档。
@@ -24,4 +24,8 @@ chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
24
24
 
25
25
  API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
26
26
 
27
- 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。设置按钮只在服务器返回 `can_manage=true` 时显示。网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称或伪造角色 header 获得权限,因为缺少服务器 Bearer token。
27
+ 多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
28
+
29
+ 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
30
+
31
+ 网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称、邮箱或伪造角色 header 获得权限,因为缺少服务器 Bearer token。服务器可为受信 owner 请求附加 `X-ChatComment-Admin-Email`,只用于入口提示。完整验证合约见部署文档。
@@ -1,9 +1,11 @@
1
1
  # Commands and APIs
2
2
 
3
- `chatcomment --help`, `--version`, `--tree`, `--tree-brief` describe the installed CLI. Commands: `serve`, `site register`, `site list`, `backup`. Python API: `Store.register`, `site`, `sites`, `configure`, `create`, `comments`, `moderate`, `backup`; `server.make_handler` and `server.serve` expose the same HTTP service.
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).
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
 
7
+ Platform deployments use `serve --gateway-key-env NAME`, optionally `--session-endpoint`, `--reauth-endpoint`, `--return-path`, and `--require-existing-db`. Every non-health request needs a valid platform context, including direct backend assets; each site operation independently checks its scope. The bearer-token descriptions below refer to the retained legacy single-gateway mode, not platform authorization.
8
+
7
9
  | Method | Path | Behavior |
8
10
  | --- | --- | --- |
9
11
  | GET | `/health` | Version and service name |
@@ -12,6 +14,8 @@ All request bodies are JSON (maximum 32 KiB). Site IDs are explicit, origins are
12
14
  | GET | `/api/sites/{id}/comments?page=/path/` | Approved comments, never emails |
13
15
  | POST | `/api/sites/{id}/comments` | `{page,content,nickname?,email?,website?}` |
14
16
  | GET | `/admin` | Gateway-protected management UI |
17
+ | GET | `/login` | Owner reauthentication form; gateway owns password verification |
18
+ | POST | `/api/admin/entry` | `{email}` → `{show_settings}`; trusted owner only, hint never authorizes management |
15
19
  | GET | `/board?site={id}&page=/path/` | Standalone widget host; apply the private-site gateway to this HTML too |
16
20
  | GET/POST | `/api/admin/sites` | List/register sites |
17
21
  | POST | `/api/sites/{id}/settings` | Complete settings object |
@@ -25,6 +29,8 @@ Private sites and all admin calls require `Authorization: Bearer <server token>`
25
29
 
26
30
  Gateway members use `X-ChatComment-Role: reader`; they can read a private site and submit with a verified identity but cannot administer. Only owner requests receive `admin`. A direct bearer API client defaults to admin for compatibility. `/config` returns `can_manage` from this trusted role, and the widget shows the settings link accordingly. Private HTML, site read permissions and site-specific authorization remain the gateway's responsibility.
27
31
 
32
+ In 0.1.1 the widget additionally requires a successful `/api/admin/entry` hint for the entered email before displaying its single gear. The expected email comes only from `X-ChatComment-Admin-Email` on a trusted server request. Nicknames have no role. The `/login` form posts `{email,password}` to gateway-owned `/api/admin/reauth` with native session CSRF. The gateway must validate the existing account password and owner role, issue its native cookie, and enforce a short-lived management grant on `/admin` and all management API routes. A hint response alone must never grant that access.
33
+
28
34
  Settings example:
29
35
 
30
36
  ```json
@@ -6,6 +6,8 @@ For a private site, apply the existing site login to **every** API and asset req
6
6
 
7
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.
8
8
 
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
+
9
11
  Consistent live backup:
10
12
 
11
13
  ```sh
@@ -0,0 +1,113 @@
1
+ # Platform service integration
2
+
3
+ ChatComment can run as an independent Coolify application. The platform owns
4
+ ChatLogin accounts/sessions, project ACLs, CSRF, password reauthentication and
5
+ native resource/deployment IDs. ChatComment owns comments, field settings,
6
+ moderation and its persistent SQLite data. It does not schedule containers or
7
+ duplicate account tables.
8
+
9
+ ## Start a scoped backend
10
+
11
+ ```sh
12
+ chatcomment --db /data/comments.sqlite3 serve --host 0.0.0.0 --port 8000 \
13
+ --gateway-key-env CHATCOMMENT_GATEWAY_KEY \
14
+ --session-endpoint /__platform/session \
15
+ --reauth-endpoint /__platform/reauth --return-path /
16
+ ```
17
+
18
+ Use a server-only randomly generated key of at least 32 characters, stored in
19
+ the platform's secret configuration. All non-health routes, including assets
20
+ on the direct native application domain, require a signed context. `/health`
21
+ is the only unsigned route. This mode explicitly rejects legacy token mode;
22
+ browser `Role`, nickname, email and Bearer headers do not grant access.
23
+
24
+ The legacy `--token-file` mode remains for existing deployments during staged
25
+ migration. It must not be used as a multi-user platform authorization shortcut.
26
+
27
+ ## Reusable Python contract
28
+
29
+ ```python
30
+ from chatcomment import AccessContext, SiteAccess, sign_context
31
+
32
+ # Obtain a current Session from ChatLogin ManagedUsers.sessions.resolve().
33
+ # Read current project grants; do not derive these from Principal.role.
34
+ context = AccessContext.from_chatlogin(
35
+ session,
36
+ session_id=stable_session_digest,
37
+ email=current_verified_account_email,
38
+ sites={site_id: SiteAccess(
39
+ read=may_read, comment=may_comment, manage=may_manage,
40
+ owner=is_site_owner, management_until=site_bound_reauth_expiry,
41
+ )},
42
+ csrf_verified=actual_request_csrf_was_verified,
43
+ )
44
+ headers = sign_context(
45
+ context, key=server_key, method=method,
46
+ path=backend_path_with_raw_query, body=raw_request_body,
47
+ ttl=30, audience='chatcomment',
48
+ )
49
+ ```
50
+
51
+ The gateway strips all incoming `X-ChatComment-*` identity headers, checks the
52
+ current app session and site/project permission on every request, verifies
53
+ Origin and CSRF for writes, signs the **downstream** method/path/query/raw body,
54
+ and forwards only its generated context headers to the controlled internal
55
+ upstream. The backend calls `verify_context` and again checks the selected site's
56
+ permission. A context cannot be reused for a different body, verb, path or site.
57
+ TTL is at most 60 seconds. Signed claims are server-to-server, never sent to JS
58
+ or logged. Keep the backend path canonical and preserve raw body bytes.
59
+
60
+ `AccessContext` contains `subject`, a session identifier/digest, current email,
61
+ `sites`, `csrf_verified`, and `authenticated`. `SiteAccess` contains explicit
62
+ `read`, `comment`, `manage`, `owner`, and absolute `management_until` time.
63
+ Guest contexts must explicitly use `authenticated=False`; they cannot have
64
+ management eligibility and never read private sites, even if mis-scoped.
65
+
66
+ For a native in-process adapter, `make_handler(..., context_resolver=callable)`
67
+ accepts the same validated context. The callable receives headers, method,
68
+ downstream path and body bytes. Do not combine it with a gateway key or legacy
69
+ global token. The independent-service CLI uses the signed transport.
70
+
71
+ ## Management and browser contract
72
+
73
+ `/api/sites/{id}/config` returns field settings, owner hint eligibility and public
74
+ same-origin auth endpoint paths. It never returns expected administrator email
75
+ or a signing key. The widget sends `{site,email}` to `/api/admin/entry` only for
76
+ an eligible owner. A matching email only reveals its single settings gear.
77
+
78
+ The gear opens `/login?site={id}`. The included form posts `{site,email,password}`
79
+ to the configured **platform** reauthentication endpoint with native session
80
+ CSRF. The platform verifies the existing password and current owner eligibility,
81
+ then records a 900-second grant bound to subject, reader session, project and
82
+ site. Subsequent contexts may include that site's `management_until`.
83
+ An email hint, global admin role, another site's grant or expired grant never
84
+ authorizes management. `GET /api/admin/sites` lists only currently authorized,
85
+ reauthenticated sites; site paths independently enforce the scope.
86
+
87
+ Session responses supply `csrf_token`. The platform owns logout, reader session
88
+ audiences, account disable/revision checks and current ACL revocation. A raw
89
+ backend without platform context rejects requests. Central and reader cookies
90
+ must not be shared across wildcard application domains.
91
+
92
+ ## Persistent migration
93
+
94
+ Keep the existing `site_id` and `page_key` explicit when frontend domains or URI
95
+ change. Mount the existing local data directory at `/data` using its real UID
96
+ and restrictive permissions. SQLite WAL permits a staged period with the old
97
+ service using the same authoritative file; do not create diverging copies.
98
+ Coolify's native storage/resource configuration owns this mount and lifecycle.
99
+
100
+ Before deployment, take an online SQLite backup and generate per-row hashes of
101
+ all columns in `sites` and `comments`, including private email and soft-deleted
102
+ states. Compare the original IDs/rows after migration and permit only recorded
103
+ new rows. If the new app origin differs, add it explicitly:
104
+
105
+ ```sh
106
+ chatcomment --db /data/comments.sqlite3 site allow-origin personal-ai https://new-app.example
107
+ ```
108
+
109
+ This changes only the registered origin list. Field/anonymous/moderation settings
110
+ and every comment row remain unchanged. Record that intentional site metadata
111
+ change separately. Preserve the old service/route until the new same-page
112
+ browser flow, independent readback, permissions and native restart/redeploy
113
+ persistence pass. Retiring legacy routing is a separate coordinated last step.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ChatComment"
7
- version = "0.1.0"
7
+ version = "0.2.0"
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"
@@ -19,7 +19,7 @@ Repository = "https://github.com/ChatArch/ChatComment"
19
19
  chatcomment = "chatcomment.cli:main"
20
20
 
21
21
  [project.optional-dependencies]
22
- dev = ["build", "pytest>=8", "twine"]
22
+ dev = ["build", "pytest>=8", "twine", "chatlogin>=0.2.1,<0.3.0"]
23
23
 
24
24
  [tool.setuptools.packages.find]
25
25
  where = ["src"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.1.0
3
+ Version: 0.2.0
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
@@ -15,6 +15,7 @@ Provides-Extra: dev
15
15
  Requires-Dist: build; extra == "dev"
16
16
  Requires-Dist: pytest>=8; extra == "dev"
17
17
  Requires-Dist: twine; extra == "dev"
18
+ Requires-Dist: chatlogin<0.3.0,>=0.2.1; extra == "dev"
18
19
  Dynamic: license-file
19
20
 
20
21
  # ChatComment
@@ -43,4 +44,8 @@ chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
43
44
 
44
45
  API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
45
46
 
46
- 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。设置按钮只在服务器返回 `can_manage=true` 时显示。网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称或伪造角色 header 获得权限,因为缺少服务器 Bearer token。
47
+ 多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
48
+
49
+ 独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
50
+
51
+ 网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称、邮箱或伪造角色 header 获得权限,因为缺少服务器 Bearer token。服务器可为受信 owner 请求附加 `X-ChatComment-Admin-Email`,只用于入口提示。完整验证合约见部署文档。
@@ -6,6 +6,7 @@ README.md
6
6
  pyproject.toml
7
7
  docs/api.md
8
8
  docs/deployment.md
9
+ docs/platform.md
9
10
  src/ChatComment.egg-info/PKG-INFO
10
11
  src/ChatComment.egg-info/SOURCES.txt
11
12
  src/ChatComment.egg-info/dependency_links.txt
@@ -13,12 +14,17 @@ src/ChatComment.egg-info/entry_points.txt
13
14
  src/ChatComment.egg-info/requires.txt
14
15
  src/ChatComment.egg-info/top_level.txt
15
16
  src/chatcomment/__init__.py
17
+ src/chatcomment/access.py
16
18
  src/chatcomment/cli.py
17
19
  src/chatcomment/server.py
18
20
  src/chatcomment/store.py
19
21
  src/chatcomment/static/admin.html
20
22
  src/chatcomment/static/board.html
23
+ src/chatcomment/static/login.html
21
24
  src/chatcomment/static/widget.css
22
25
  src/chatcomment/static/widget.js
23
26
  tests/cli-tests/test_cli.py
24
- tests/code-tests/test_comments.py
27
+ tests/code-tests/test_access.py
28
+ tests/code-tests/test_chatlogin_adapter.py
29
+ tests/code-tests/test_comments.py
30
+ tests/code-tests/test_platform_http.py
@@ -6,3 +6,4 @@ chatenv<0.3.0,>=0.2.11
6
6
  build
7
7
  pytest>=8
8
8
  twine
9
+ chatlogin<0.3.0,>=0.2.1
@@ -0,0 +1,7 @@
1
+ """Reusable comments storage and HTTP service."""
2
+ __version__ = "0.2.0"
3
+
4
+ from .store import Store
5
+ from .access import AccessContext,SiteAccess,sign_context,verify_context
6
+
7
+ __all__ = ["Store", "AccessContext", "SiteAccess", "sign_context", "verify_context", "__version__"]
@@ -0,0 +1,150 @@
1
+ """Platform-neutral, per-site permissions and a signed gateway transport.
2
+
3
+ The platform resolves the real ChatLogin session on EVERY request, evaluates its
4
+ site ACL and CSRF, then signs this context. No global account role implies site
5
+ access. Browser-supplied identity headers are not trusted by this module.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import base64
10
+ from dataclasses import asdict, dataclass, field
11
+ import hashlib
12
+ import hmac
13
+ import json
14
+ import math
15
+ import secrets
16
+ import time
17
+ from typing import Mapping
18
+ from types import MappingProxyType
19
+
20
+
21
+ @dataclass(frozen=True)
22
+ class SiteAccess:
23
+ read: bool = False
24
+ comment: bool = False
25
+ manage: bool = False
26
+ owner: bool = False
27
+ management_until: float = 0
28
+
29
+ def __post_init__(self):
30
+ if any(type(getattr(self,k)) is not bool for k in ['read','comment','manage','owner']):
31
+ raise ValueError('Site permissions must be booleans')
32
+ if (self.comment or self.manage or self.owner) and not self.read:
33
+ raise ValueError('Site operations require explicit site read permission')
34
+ if self.owner and not self.manage:
35
+ raise ValueError('Site owner must have explicit management eligibility')
36
+ if type(self.management_until) not in (int,float) or not math.isfinite(self.management_until):
37
+ raise ValueError('Invalid management grant expiry')
38
+
39
+
40
+ @dataclass(frozen=True)
41
+ class AccessContext:
42
+ subject: str
43
+ session: str # A stable session digest/identifier, never the raw cookie.
44
+ email: str = field(default='',repr=False)
45
+ sites: Mapping[str, SiteAccess] = field(default_factory=dict)
46
+ csrf_verified: bool = False
47
+ authenticated: bool = True
48
+
49
+ def __post_init__(self):
50
+ for name in ['subject','session']:
51
+ value=getattr(self,name)
52
+ if not isinstance(value,str) or not 1<=len(value)<=256:
53
+ raise ValueError('Authenticated subject and session identifier required')
54
+ if not isinstance(self.email,str) or len(self.email)>500 or type(self.csrf_verified) is not bool or type(self.authenticated) is not bool:
55
+ raise ValueError('Invalid identity or CSRF context')
56
+ if not isinstance(self.sites,Mapping) or len(self.sites)>100:
57
+ raise ValueError('Bounded per-site permissions required')
58
+ for site,scope in self.sites.items():
59
+ if not isinstance(site,str) or not 1<=len(site)<=64 or not isinstance(scope,SiteAccess):
60
+ raise ValueError('Invalid site permission')
61
+ if not self.authenticated and (scope.manage or scope.owner):
62
+ raise ValueError('Anonymous context cannot have management eligibility')
63
+ object.__setattr__(self,'sites',MappingProxyType(dict(self.sites)))
64
+
65
+ def scope(self,site_id):
66
+ return self.sites.get(site_id,SiteAccess())
67
+
68
+ def permits(self,site_id,action,*,now=None):
69
+ scope=self.scope(site_id)
70
+ if action=='read':return scope.read
71
+ if action=='comment':return scope.read and scope.comment
72
+ if action=='hint':return self.authenticated and scope.read and scope.owner
73
+ if action=='manage':
74
+ return self.authenticated and scope.read and scope.manage and scope.management_until>(time.time() if now is None else now)
75
+ return False
76
+
77
+ def require(self,site_id,action,*,write=False,now=None):
78
+ if not self.permits(site_id,action,now=now):
79
+ raise PermissionError('此账号未获该站点权限或需要重新验证密码')
80
+ if write and not self.csrf_verified:
81
+ raise PermissionError('平台未验证此请求的 CSRF')
82
+
83
+ @classmethod
84
+ def from_chatlogin(cls,session,*,session_id,email,sites,csrf_verified=False):
85
+ """Adapt an already-resolved ChatLogin Session, never infer ACL from Role."""
86
+ principal=session.principal
87
+ if not principal.authenticated:
88
+ raise PermissionError('需要已验证的 ChatLogin 主体')
89
+ return cls(principal.user_id,session_id,email,sites,csrf_verified)
90
+
91
+
92
+ def _key(value):
93
+ if isinstance(value,str):value=value.encode()
94
+ if not isinstance(value,bytes) or len(value)<32:
95
+ raise ValueError('Gateway signing key must be at least 32 bytes')
96
+ return value
97
+
98
+
99
+ def _binding(method,path,body):
100
+ if not isinstance(body,bytes) or not isinstance(path,str) or not path.startswith('/'):
101
+ raise ValueError('Canonical request path and raw body bytes required')
102
+ return {'method':method.upper(),'path':path,'body_sha256':hashlib.sha256(body).hexdigest()}
103
+
104
+
105
+ def sign_context(context:AccessContext,*,key,method,path,body=b'',now=None,ttl=30,audience='chatcomment'):
106
+ """Return server-to-server headers bound to a single HTTP request.
107
+
108
+ Gateway MUST strip incoming X-ChatComment-* headers first. Password checks,
109
+ current account/session revocation, site ACL and CSRF happen in the platform.
110
+ """
111
+ if not isinstance(context,AccessContext):raise TypeError('Verified AccessContext required')
112
+ if type(ttl) is not int or not 1<=ttl<=60:raise ValueError('Context TTL must be 1..60 seconds')
113
+ stamp=time.time() if now is None else now
114
+ if type(stamp) not in (int,float) or not math.isfinite(stamp):raise ValueError('Invalid signing clock')
115
+ value={'v':1,'aud':audience,'iat':stamp,'exp':stamp+ttl,'nonce':secrets.token_urlsafe(16),
116
+ 'subject':context.subject,'session':context.session,'email':context.email,
117
+ 'sites':{site:asdict(scope) for site,scope in context.sites.items()},
118
+ 'csrf_verified':context.csrf_verified,'authenticated':context.authenticated,**_binding(method,path,body)}
119
+ raw=json.dumps(value,ensure_ascii=False,separators=(',',':'),sort_keys=True).encode()
120
+ encoded=base64.urlsafe_b64encode(raw).decode().rstrip('=')
121
+ if len(encoded)>16000:raise ValueError('Gateway context is too large; scope it to this request')
122
+ signature=hmac.new(_key(key),encoded.encode(),hashlib.sha256).hexdigest()
123
+ return {'X-ChatComment-Context':encoded,'X-ChatComment-Signature':signature}
124
+
125
+
126
+ def verify_context(headers,*,key,method,path,body=b'',now=None,audience='chatcomment'):
127
+ """Validate context before using any identity or site permission claim."""
128
+ encoded=headers.get('X-ChatComment-Context')
129
+ signature=headers.get('X-ChatComment-Signature')
130
+ if encoded is None and signature is None:return None
131
+ if not isinstance(encoded,str) or not 1<=len(encoded)<=16000 or not isinstance(signature,str) or len(signature)!=64 or not signature.isascii():
132
+ raise PermissionError('无效的平台认证上下文')
133
+ try:encoded_bytes=encoded.encode('ascii','strict')
134
+ except UnicodeError as exc:raise PermissionError('无效的平台认证上下文') from exc
135
+ expected=hmac.new(_key(key),encoded_bytes,hashlib.sha256).hexdigest()
136
+ if not hmac.compare_digest(expected,signature):raise PermissionError('平台认证签名不正确')
137
+ try:
138
+ raw=base64.b64decode(encoded+'='*((-len(encoded))%4),altchars=b'-_',validate=True)
139
+ data=json.loads(raw)
140
+ stamp=time.time() if now is None else now
141
+ if data['v']!=1 or data['aud']!=audience:raise ValueError('Wrong audience')
142
+ issued,expires=data['iat'],data['exp']
143
+ if any(type(v) not in (int,float) or not math.isfinite(v) for v in [issued,expires,stamp]):raise ValueError('Invalid timestamp')
144
+ if not issued-5<=stamp<expires or not 0<expires-issued<=60:raise ValueError('Expired context')
145
+ for name,value in _binding(method,path,body).items():
146
+ if data[name]!=value:raise ValueError('Request binding mismatch')
147
+ scopes={site:SiteAccess(**scope) for site,scope in data['sites'].items()}
148
+ return AccessContext(data['subject'],data['session'],data['email'],scopes,data['csrf_verified'],data['authenticated'])
149
+ except (ValueError,TypeError,KeyError,AttributeError) as exc:
150
+ raise PermissionError('平台认证上下文无效、过期或请求不匹配') from exc
@@ -1,6 +1,7 @@
1
1
  """Thin CLI around the Python API."""
2
2
  from pathlib import Path
3
3
  import json
4
+ import os
4
5
 
5
6
  import click
6
7
  from chatstyle import add_tree_option
@@ -22,15 +23,27 @@ def main(ctx, db):
22
23
  @main.command()
23
24
  @click.option('--host', default='127.0.0.1', show_default=True)
24
25
  @click.option('--port', default=10196, type=click.IntRange(1,65535), show_default=True)
25
- @click.option('--token-file', required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
26
+ @click.option('--token-file', type=click.Path(exists=True, dir_okay=False, path_type=Path), help='Legacy single-gateway token mode.')
27
+ @click.option('--gateway-key-file', type=click.Path(exists=True,dir_okay=False,path_type=Path), help='Server-only signing key; enables per-site platform authorization.')
28
+ @click.option('--gateway-key-env', help='Name of environment variable holding the server-only signing key.')
29
+ @click.option('--session-endpoint', default='/api/session',show_default=True)
30
+ @click.option('--reauth-endpoint', default=None,help='Platform same-origin management reauthentication endpoint.')
31
+ @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.')
26
33
  @click.pass_context
27
- def serve(ctx, host, port, token_file):
34
+ def serve(ctx, host, port, token_file,gateway_key_file,gateway_key_env,session_endpoint,reauth_endpoint,return_path,require_existing_db):
28
35
  """Serve API, widget assets and the gateway-protected admin page."""
29
36
  from .server import serve as run
30
- token = token_file.read_text().strip()
31
- if len(token) < 32:
32
- raise click.ClickException('Token file must contain at least 32 characters.')
33
- run(Store(ctx.obj['db']), token=token, host=host, port=port)
37
+ if sum(bool(v) for v in [token_file,gateway_key_file,gateway_key_env])!=1:
38
+ raise click.UsageError('Choose exactly one of --token-file, --gateway-key-file or --gateway-key-env.')
39
+ token=token_file.read_text().strip() if token_file else None
40
+ 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
+ if len(token if token is not None else key or '')<32:
42
+ 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.')
45
+ run(Store(ctx.obj['db']), token=token,gateway_key=key, host=host, port=port,
46
+ session_endpoint=session_endpoint,reauth_endpoint=reauth_endpoint,return_path=return_path)
34
47
 
35
48
 
36
49
  @main.group()
@@ -61,6 +74,19 @@ def list_sites(ctx):
61
74
  click.echo(json.dumps(Store(ctx.obj['db']).sites(),ensure_ascii=False,indent=2))
62
75
 
63
76
 
77
+ @site.command(name='allow-origin')
78
+ @click.argument('site_id')
79
+ @click.argument('origin')
80
+ @click.pass_context
81
+ def allow_origin(ctx,site_id,origin):
82
+ """Add an approved platform origin, preserving comments and field settings."""
83
+ try:
84
+ result=Store(ctx.obj['db']).allow_origin(site_id,origin)
85
+ except (ValueError,KeyError) as exc:
86
+ raise click.ClickException(str(exc)) from exc
87
+ click.echo(json.dumps(result,ensure_ascii=False,indent=2))
88
+
89
+
64
90
  @main.command()
65
91
  @click.argument('destination', type=click.Path(path_type=Path))
66
92
  @click.pass_context