ChatComment 0.1.1__tar.gz → 0.2.1__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 (37) hide show
  1. chatcomment-0.2.1/CHANGELOG.md +25 -0
  2. {chatcomment-0.1.1 → chatcomment-0.2.1}/PKG-INFO +4 -1
  3. {chatcomment-0.1.1 → chatcomment-0.2.1}/README.md +2 -0
  4. {chatcomment-0.1.1 → chatcomment-0.2.1}/docs/api.md +3 -1
  5. chatcomment-0.2.1/docs/platform.md +120 -0
  6. {chatcomment-0.1.1 → chatcomment-0.2.1}/pyproject.toml +2 -2
  7. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/ChatComment.egg-info/PKG-INFO +4 -1
  8. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/ChatComment.egg-info/SOURCES.txt +8 -1
  9. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/ChatComment.egg-info/requires.txt +1 -0
  10. chatcomment-0.2.1/src/chatcomment/__init__.py +8 -0
  11. chatcomment-0.2.1/src/chatcomment/access.py +150 -0
  12. chatcomment-0.2.1/src/chatcomment/cli.py +106 -0
  13. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/chatcomment/server.py +122 -29
  14. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/chatcomment/static/admin.html +6 -3
  15. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/chatcomment/static/board.html +6 -4
  16. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/chatcomment/static/login.html +7 -4
  17. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/chatcomment/static/widget.js +3 -2
  18. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/chatcomment/store.py +13 -0
  19. chatcomment-0.2.1/src/chatcomment/validation.py +98 -0
  20. {chatcomment-0.1.1 → chatcomment-0.2.1}/tests/cli-tests/test_cli.py +7 -0
  21. chatcomment-0.2.1/tests/code-tests/test_access.py +46 -0
  22. chatcomment-0.2.1/tests/code-tests/test_chatlogin_adapter.py +24 -0
  23. {chatcomment-0.1.1 → chatcomment-0.2.1}/tests/code-tests/test_comments.py +10 -0
  24. chatcomment-0.2.1/tests/code-tests/test_platform_http.py +103 -0
  25. chatcomment-0.2.1/tests/code-tests/test_validation.py +89 -0
  26. chatcomment-0.1.1/CHANGELOG.md +0 -13
  27. chatcomment-0.1.1/src/chatcomment/__init__.py +0 -6
  28. chatcomment-0.1.1/src/chatcomment/cli.py +0 -76
  29. {chatcomment-0.1.1 → chatcomment-0.2.1}/AGENTS.md +0 -0
  30. {chatcomment-0.1.1 → chatcomment-0.2.1}/LICENSE +0 -0
  31. {chatcomment-0.1.1 → chatcomment-0.2.1}/MANIFEST.in +0 -0
  32. {chatcomment-0.1.1 → chatcomment-0.2.1}/docs/deployment.md +0 -0
  33. {chatcomment-0.1.1 → chatcomment-0.2.1}/setup.cfg +0 -0
  34. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/ChatComment.egg-info/dependency_links.txt +0 -0
  35. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/ChatComment.egg-info/entry_points.txt +0 -0
  36. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/ChatComment.egg-info/top_level.txt +0 -0
  37. {chatcomment-0.1.1 → chatcomment-0.2.1}/src/chatcomment/static/widget.css +0 -0
@@ -0,0 +1,25 @@
1
+ # Changelog
2
+
3
+ ## 0.2.1
4
+
5
+ - `--require-existing-db` 在 Store 初始化之前验证既有 SQLite 完整性、必要表/字段和可读性,拒绝空文件、损坏或错误 schema。
6
+ - 只读源文件并在私有稳定快照上校验,保留活跃 WAL 支持,不在挂载目录创建 SQLite 辅助文件;允许完整 schema 的零留言数据库。
7
+
8
+ ## 0.2.0
9
+
10
+ - 平台中立的 `AccessContext` / `SiteAccess` API,复用 ChatLogin 主体并显式检查每站 read/comment/manage/owner;管理 grant 按 session 和站点区分。
11
+ - 独立服务签名网关模式,验证短时 method/path/query/body 绑定上下文;所有非 health 路由要求签名,不接受旧全局 token 作为替代。
12
+ - 邮箱齿轮提示、管理列表、再认证和前端 CSRF 端点按站点及平台配置;不向前端暴露期望邮箱或服务器密钥。
13
+ - 迁移所需的 `site allow-origin` 只更新允许来源,保留字段设置和全部留言;`--require-existing-db` 防止缺少持久卷时创建空库。
14
+
15
+ ## 0.1.1
16
+
17
+ - 留言区仅显示一个齿轮入口;本人填写管理员邮箱后由服务器确认显示,不按昵称判断、不下发管理员邮箱。
18
+ - 提供邮箱/密码验证页,网关可在现有账号校验后授予短时管理权限;管理 API 仍由服务器角色授权。
19
+
20
+ ## 0.1.0
21
+
22
+ - Python/SQLite 留言服务、无依赖 JS 组件及同源管理界面。
23
+ - 站点来源和页面白名单,匿名与字段设置,服务器端校验、审核、软删除、JSON 导出与 SQLite 在线备份。
24
+ - 私有站点可信网关模式;管理 token 留在服务器。
25
+ - 独立留言板与嵌入组件共用数据;服务端 owner / reader 权限与设置入口显示。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.1.1
3
+ Version: 0.2.1
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,6 +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
 
47
+ 多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
48
+
46
49
  独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
47
50
 
48
51
  网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称、邮箱或伪造角色 header 获得权限,因为缺少服务器 Bearer token。服务器可为受信 owner 请求附加 `X-ChatComment-Admin-Email`,只用于入口提示。完整验证合约见部署文档。
@@ -24,6 +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
+ 多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
28
+
27
29
  独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
28
30
 
29
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 |
@@ -0,0 +1,120 @@
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
+ `--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
+
31
+ The legacy `--token-file` mode remains for existing deployments during staged
32
+ migration. It must not be used as a multi-user platform authorization shortcut.
33
+
34
+ ## Reusable Python contract
35
+
36
+ ```python
37
+ from chatcomment import AccessContext, SiteAccess, sign_context
38
+
39
+ # Obtain a current Session from ChatLogin ManagedUsers.sessions.resolve().
40
+ # Read current project grants; do not derive these from Principal.role.
41
+ context = AccessContext.from_chatlogin(
42
+ session,
43
+ session_id=stable_session_digest,
44
+ email=current_verified_account_email,
45
+ sites={site_id: SiteAccess(
46
+ read=may_read, comment=may_comment, manage=may_manage,
47
+ owner=is_site_owner, management_until=site_bound_reauth_expiry,
48
+ )},
49
+ csrf_verified=actual_request_csrf_was_verified,
50
+ )
51
+ headers = sign_context(
52
+ context, key=server_key, method=method,
53
+ path=backend_path_with_raw_query, body=raw_request_body,
54
+ ttl=30, audience='chatcomment',
55
+ )
56
+ ```
57
+
58
+ The gateway strips all incoming `X-ChatComment-*` identity headers, checks the
59
+ current app session and site/project permission on every request, verifies
60
+ Origin and CSRF for writes, signs the **downstream** method/path/query/raw body,
61
+ and forwards only its generated context headers to the controlled internal
62
+ upstream. The backend calls `verify_context` and again checks the selected site's
63
+ permission. A context cannot be reused for a different body, verb, path or site.
64
+ TTL is at most 60 seconds. Signed claims are server-to-server, never sent to JS
65
+ or logged. Keep the backend path canonical and preserve raw body bytes.
66
+
67
+ `AccessContext` contains `subject`, a session identifier/digest, current email,
68
+ `sites`, `csrf_verified`, and `authenticated`. `SiteAccess` contains explicit
69
+ `read`, `comment`, `manage`, `owner`, and absolute `management_until` time.
70
+ Guest contexts must explicitly use `authenticated=False`; they cannot have
71
+ management eligibility and never read private sites, even if mis-scoped.
72
+
73
+ For a native in-process adapter, `make_handler(..., context_resolver=callable)`
74
+ accepts the same validated context. The callable receives headers, method,
75
+ downstream path and body bytes. Do not combine it with a gateway key or legacy
76
+ global token. The independent-service CLI uses the signed transport.
77
+
78
+ ## Management and browser contract
79
+
80
+ `/api/sites/{id}/config` returns field settings, owner hint eligibility and public
81
+ same-origin auth endpoint paths. It never returns expected administrator email
82
+ or a signing key. The widget sends `{site,email}` to `/api/admin/entry` only for
83
+ an eligible owner. A matching email only reveals its single settings gear.
84
+
85
+ The gear opens `/login?site={id}`. The included form posts `{site,email,password}`
86
+ to the configured **platform** reauthentication endpoint with native session
87
+ CSRF. The platform verifies the existing password and current owner eligibility,
88
+ then records a 900-second grant bound to subject, reader session, project and
89
+ site. Subsequent contexts may include that site's `management_until`.
90
+ An email hint, global admin role, another site's grant or expired grant never
91
+ authorizes management. `GET /api/admin/sites` lists only currently authorized,
92
+ reauthenticated sites; site paths independently enforce the scope.
93
+
94
+ Session responses supply `csrf_token`. The platform owns logout, reader session
95
+ audiences, account disable/revision checks and current ACL revocation. A raw
96
+ backend without platform context rejects requests. Central and reader cookies
97
+ must not be shared across wildcard application domains.
98
+
99
+ ## Persistent migration
100
+
101
+ Keep the existing `site_id` and `page_key` explicit when frontend domains or URI
102
+ change. Mount the existing local data directory at `/data` using its real UID
103
+ and restrictive permissions. SQLite WAL permits a staged period with the old
104
+ service using the same authoritative file; do not create diverging copies.
105
+ Coolify's native storage/resource configuration owns this mount and lifecycle.
106
+
107
+ Before deployment, take an online SQLite backup and generate per-row hashes of
108
+ all columns in `sites` and `comments`, including private email and soft-deleted
109
+ states. Compare the original IDs/rows after migration and permit only recorded
110
+ new rows. If the new app origin differs, add it explicitly:
111
+
112
+ ```sh
113
+ chatcomment --db /data/comments.sqlite3 site allow-origin personal-ai https://new-app.example
114
+ ```
115
+
116
+ This changes only the registered origin list. Field/anonymous/moderation settings
117
+ and every comment row remain unchanged. Record that intentional site metadata
118
+ change separately. Preserve the old service/route until the new same-page
119
+ browser flow, independent readback, permissions and native restart/redeploy
120
+ 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.1"
7
+ version = "0.2.1"
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.1
3
+ Version: 0.2.1
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,6 +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
 
47
+ 多用户平台接入使用 [平台服务协议](docs/platform.md):`--gateway-key-env` 启用按主体/session/站点校验的签名上下文,所有非 health 路由均拒绝未签名访问,兼容独立 Coolify 资源。`AccessContext.from_chatlogin()` 复用已验证的 ChatLogin Session;项目 ACL 与密码二次验证由平台拥有,账户全局 admin 不能自动获得站点权限。旧 `--token-file` 模式仅保留给已有单网关部署迁移使用。
48
+
46
49
  独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。组件只提供一个齿轮入口:已登录 owner 在邮箱栏填写匹配邮箱后,服务器返回显示提示;昵称不参与判断,邮箱不下发到 JS 配置。齿轮指向邮箱/密码验证页;网关须实现 `/api/admin/reauth` 并在校验现有账号后授予短时管理权限。公开站点没有该提示。
47
50
 
48
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,13 +14,19 @@ 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
21
+ src/chatcomment/validation.py
19
22
  src/chatcomment/static/admin.html
20
23
  src/chatcomment/static/board.html
21
24
  src/chatcomment/static/login.html
22
25
  src/chatcomment/static/widget.css
23
26
  src/chatcomment/static/widget.js
24
27
  tests/cli-tests/test_cli.py
25
- tests/code-tests/test_comments.py
28
+ tests/code-tests/test_access.py
29
+ tests/code-tests/test_chatlogin_adapter.py
30
+ tests/code-tests/test_comments.py
31
+ tests/code-tests/test_platform_http.py
32
+ tests/code-tests/test_validation.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,8 @@
1
+ """Reusable comments storage and HTTP service."""
2
+ __version__ = "0.2.1"
3
+
4
+ from .store import Store
5
+ from .access import AccessContext,SiteAccess,sign_context,verify_context
6
+ from .validation import validate_existing_database
7
+
8
+ __all__ = ["Store", "AccessContext", "SiteAccess", "sign_context", "verify_context", "validate_existing_database", "__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
@@ -0,0 +1,106 @@
1
+ """Thin CLI around the Python API."""
2
+ from pathlib import Path
3
+ import json
4
+ import os
5
+
6
+ import click
7
+ from chatstyle import add_tree_option
8
+
9
+ from . import Store, __version__
10
+ from .validation import validate_existing_database
11
+
12
+
13
+ @click.group(name='chatcomment')
14
+ @click.version_option(__version__, prog_name='chatcomment')
15
+ @add_tree_option()
16
+ @click.option('--db', type=click.Path(path_type=Path), default=Path('~/.chatarch/chatcomment/comments.sqlite3'), show_default=True)
17
+ @click.pass_context
18
+ def main(ctx, db):
19
+ """Embeddable comments, site settings and persistent storage."""
20
+ ctx.ensure_object(dict)
21
+ ctx.obj['db'] = db
22
+
23
+
24
+ @main.command()
25
+ @click.option('--host', default='127.0.0.1', show_default=True)
26
+ @click.option('--port', default=10196, type=click.IntRange(1,65535), show_default=True)
27
+ @click.option('--token-file', type=click.Path(exists=True, dir_okay=False, path_type=Path), help='Legacy single-gateway token mode.')
28
+ @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.')
29
+ @click.option('--gateway-key-env', help='Name of environment variable holding the server-only signing key.')
30
+ @click.option('--session-endpoint', default='/api/session',show_default=True)
31
+ @click.option('--reauth-endpoint', default=None,help='Platform same-origin management reauthentication endpoint.')
32
+ @click.option('--return-path', default='/',show_default=True)
33
+ @click.option('--require-existing-db',is_flag=True,help='Validate a readable existing ChatComment SQLite database before initialization.')
34
+ @click.pass_context
35
+ def serve(ctx, host, port, token_file,gateway_key_file,gateway_key_env,session_endpoint,reauth_endpoint,return_path,require_existing_db):
36
+ """Serve API, widget assets and the gateway-protected admin page."""
37
+ from .server import serve as run
38
+ if sum(bool(v) for v in [token_file,gateway_key_file,gateway_key_env])!=1:
39
+ raise click.UsageError('Choose exactly one of --token-file, --gateway-key-file or --gateway-key-env.')
40
+ token=token_file.read_text().strip() if token_file else None
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
42
+ if len(token if token is not None else key or '')<32:
43
+ raise click.ClickException('Server credential must contain at least 32 characters.')
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
49
+ run(Store(ctx.obj['db']), token=token,gateway_key=key, host=host, port=port,
50
+ session_endpoint=session_endpoint,reauth_endpoint=reauth_endpoint,return_path=return_path)
51
+
52
+
53
+ @main.group()
54
+ def site():
55
+ """Register sites and inspect settings."""
56
+
57
+
58
+ @site.command(name='register')
59
+ @click.argument('site_id')
60
+ @click.option('--title', default='留言')
61
+ @click.option('--origin', 'origins', required=True, multiple=True)
62
+ @click.option('--page', 'pages', required=True, multiple=True)
63
+ @click.option('--public', is_flag=True, help='Explicitly allow public comment reads and anonymous writes.')
64
+ @click.pass_context
65
+ def register(ctx, site_id, title, origins, pages, public):
66
+ """Register an exact site ID, origin allowlist and allowed page paths."""
67
+ try:
68
+ result = Store(ctx.obj['db']).register(site_id,title=title,origins=list(origins),pages=list(pages),private=not public)
69
+ except ValueError as exc:
70
+ raise click.ClickException(str(exc)) from exc
71
+ click.echo(json.dumps(result,ensure_ascii=False,indent=2))
72
+
73
+
74
+ @site.command(name='list')
75
+ @click.pass_context
76
+ def list_sites(ctx):
77
+ """List registered sites and their effective settings."""
78
+ click.echo(json.dumps(Store(ctx.obj['db']).sites(),ensure_ascii=False,indent=2))
79
+
80
+
81
+ @site.command(name='allow-origin')
82
+ @click.argument('site_id')
83
+ @click.argument('origin')
84
+ @click.pass_context
85
+ def allow_origin(ctx,site_id,origin):
86
+ """Add an approved platform origin, preserving comments and field settings."""
87
+ try:
88
+ result=Store(ctx.obj['db']).allow_origin(site_id,origin)
89
+ except (ValueError,KeyError) as exc:
90
+ raise click.ClickException(str(exc)) from exc
91
+ click.echo(json.dumps(result,ensure_ascii=False,indent=2))
92
+
93
+
94
+ @main.command()
95
+ @click.argument('destination', type=click.Path(path_type=Path))
96
+ @click.pass_context
97
+ def backup(ctx, destination):
98
+ """Create a consistent SQLite online backup without overwriting a file."""
99
+ try:
100
+ click.echo(Store(ctx.obj['db']).backup(destination))
101
+ except ValueError as exc:
102
+ raise click.ClickException(str(exc)) from exc
103
+
104
+
105
+ if __name__ == '__main__':
106
+ main()