ChatComment 0.1.1__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 (33) hide show
  1. {chatcomment-0.1.1 → chatcomment-0.2.0}/CHANGELOG.md +7 -0
  2. {chatcomment-0.1.1 → chatcomment-0.2.0}/PKG-INFO +4 -1
  3. {chatcomment-0.1.1 → chatcomment-0.2.0}/README.md +2 -0
  4. {chatcomment-0.1.1 → chatcomment-0.2.0}/docs/api.md +3 -1
  5. chatcomment-0.2.0/docs/platform.md +113 -0
  6. {chatcomment-0.1.1 → chatcomment-0.2.0}/pyproject.toml +2 -2
  7. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/ChatComment.egg-info/PKG-INFO +4 -1
  8. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/ChatComment.egg-info/SOURCES.txt +6 -1
  9. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/ChatComment.egg-info/requires.txt +1 -0
  10. chatcomment-0.2.0/src/chatcomment/__init__.py +7 -0
  11. chatcomment-0.2.0/src/chatcomment/access.py +150 -0
  12. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/cli.py +32 -6
  13. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/server.py +122 -29
  14. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/static/admin.html +6 -3
  15. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/static/board.html +6 -4
  16. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/static/login.html +7 -4
  17. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/static/widget.js +3 -2
  18. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/store.py +13 -0
  19. {chatcomment-0.1.1 → chatcomment-0.2.0}/tests/cli-tests/test_cli.py +7 -0
  20. chatcomment-0.2.0/tests/code-tests/test_access.py +46 -0
  21. chatcomment-0.2.0/tests/code-tests/test_chatlogin_adapter.py +24 -0
  22. {chatcomment-0.1.1 → chatcomment-0.2.0}/tests/code-tests/test_comments.py +10 -0
  23. chatcomment-0.2.0/tests/code-tests/test_platform_http.py +103 -0
  24. chatcomment-0.1.1/src/chatcomment/__init__.py +0 -6
  25. {chatcomment-0.1.1 → chatcomment-0.2.0}/AGENTS.md +0 -0
  26. {chatcomment-0.1.1 → chatcomment-0.2.0}/LICENSE +0 -0
  27. {chatcomment-0.1.1 → chatcomment-0.2.0}/MANIFEST.in +0 -0
  28. {chatcomment-0.1.1 → chatcomment-0.2.0}/docs/deployment.md +0 -0
  29. {chatcomment-0.1.1 → chatcomment-0.2.0}/setup.cfg +0 -0
  30. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/ChatComment.egg-info/dependency_links.txt +0 -0
  31. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/ChatComment.egg-info/entry_points.txt +0 -0
  32. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/ChatComment.egg-info/top_level.txt +0 -0
  33. {chatcomment-0.1.1 → chatcomment-0.2.0}/src/chatcomment/static/widget.css +0 -0
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
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
+
3
10
  ## 0.1.1
4
11
 
5
12
  - 留言区仅显示一个齿轮入口;本人填写管理员邮箱后由服务器确认显示,不按昵称判断、不下发管理员邮箱。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.1.1
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,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,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.1"
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.1
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,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,6 +14,7 @@ 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
@@ -22,4 +24,7 @@ src/chatcomment/static/login.html
22
24
  src/chatcomment/static/widget.css
23
25
  src/chatcomment/static/widget.js
24
26
  tests/cli-tests/test_cli.py
25
- 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
@@ -12,11 +12,28 @@ from urllib.parse import parse_qs, urlsplit
12
12
 
13
13
  from . import __version__
14
14
  from .store import Invalid, Store, origin, text
15
+ from .access import AccessContext, verify_context
15
16
 
16
17
 
17
- def make_handler(store: Store, admin_token: str):
18
- if len(admin_token) < 32:
18
+ def make_handler(store: Store, admin_token: str | None = None, *, gateway_key=None,
19
+ context_resolver=None, session_endpoint='/api/session',
20
+ reauth_endpoint=None, return_path='/'):
21
+ scoped = gateway_key is not None or context_resolver is not None
22
+ if gateway_key is not None and context_resolver is not None:
23
+ raise ValueError('Choose a gateway key or a native context resolver')
24
+ if context_resolver is not None and not callable(context_resolver):
25
+ raise ValueError('Native context resolver must be callable')
26
+ if gateway_key is not None and (not isinstance(gateway_key,(str,bytes)) or len(gateway_key)<32):
27
+ raise ValueError('Gateway signing key must be at least 32 characters')
28
+ if not scoped and (not isinstance(admin_token,str) or len(admin_token) < 32):
19
29
  raise ValueError('管理 token 至少需要 32 个字符')
30
+ if scoped and admin_token is not None:
31
+ raise ValueError('Scoped platform mode must not accept the legacy global token')
32
+ for value in [session_endpoint,reauth_endpoint,return_path]:
33
+ if value is not None and (not isinstance(value,str) or not value.startswith('/') or value.startswith('//') or any(ord(c)<33 for c in value) or '\\' in value):
34
+ raise ValueError('Authentication endpoints must be same-origin absolute paths')
35
+ auth_config={'session_endpoint':session_endpoint,'reauth_endpoint':reauth_endpoint,
36
+ 'return_path':return_path,'scoped':scoped}
20
37
  attempts = defaultdict(deque)
21
38
  lock = threading.Lock()
22
39
 
@@ -50,59 +67,125 @@ def make_handler(store: Store, admin_token: str):
50
67
  if self.headers.get('Transfer-Encoding') or self.headers.get_content_type() != 'application/json':
51
68
  raise Invalid('仅接受 JSON 请求')
52
69
  try:
53
- size = int(self.headers.get('Content-Length', 0))
54
- except ValueError as exc:
55
- raise Invalid('无效的请求长度') from exc
56
- if not 0 < size <= 32768:
57
- raise Invalid('请求内容需为 1–32768 字节')
58
- try:
59
- result = json.loads(self.rfile.read(size))
70
+ result = json.loads(self.raw_body())
60
71
  except (ValueError, UnicodeDecodeError) as exc:
61
72
  raise Invalid('JSON 格式不正确') from exc
62
73
  if not isinstance(result, dict):
63
74
  raise Invalid('JSON 必须是对象')
64
75
  return result
65
76
 
77
+ def raw_body(self):
78
+ if hasattr(self,'_body'):
79
+ return self._body
80
+ if self.headers.get('Transfer-Encoding'):
81
+ raise Invalid('不接受分块请求')
82
+ try:
83
+ size = int(self.headers.get('Content-Length', 0))
84
+ except ValueError as exc:
85
+ raise Invalid('无效的请求长度') from exc
86
+ if not 0 < size <= 32768:
87
+ raise Invalid('请求内容需为 1–32768 字节')
88
+ self._body = self.rfile.read(size)
89
+ if len(self._body)!=size:
90
+ raise Invalid('请求正文不完整')
91
+ return self._body
92
+
93
+ def asset(self,name,mime,**extra):
94
+ data=files('chatcomment').joinpath('static',name).read_text()
95
+ config=json.dumps({**auth_config,**extra},ensure_ascii=False).replace('<','\\u003c')
96
+ return self.send(200,data.replace('__CHATCOMMENT_AUTH__',config),mime)
97
+
98
+ def target_site(self,query,context):
99
+ selected=query.get('site',[None])[0]
100
+ if selected:
101
+ return selected
102
+ ids=list(context.sites) if context else [s['id'] for s in store.sites()]
103
+ if not scoped and ids:
104
+ return ids[0]
105
+ if len(ids)!=1:
106
+ raise Invalid('请指定站点 site')
107
+ return ids[0]
108
+
66
109
  def dispatch(self):
67
110
  self.cors = None
68
111
  p = urlsplit(self.path)
69
112
  q = parse_qs(p.query)
70
113
  token = self.headers.get('Authorization', '').removeprefix('Bearer ')
71
- trusted = secrets.compare_digest(token, admin_token)
72
- manager = trusted and self.headers.get('X-ChatComment-Role', 'admin') == 'admin'
114
+ trusted = not scoped and secrets.compare_digest(token, admin_token)
115
+ context=None
116
+ if scoped:
117
+ body=self.raw_body() if self.command=='POST' else b''
118
+ context=(context_resolver(self.headers,self.command,self.path,body) if context_resolver else
119
+ verify_context(self.headers,key=gateway_key,method=self.command,path=self.path,body=body))
120
+ if context is not None and not isinstance(context,AccessContext):
121
+ raise PermissionError('平台未提供已验证的主体')
122
+ trusted=context is not None
123
+ manager = not scoped and trusted and self.headers.get('X-ChatComment-Role', 'admin') == 'admin'
73
124
  parts = p.path.strip('/').split('/')
74
125
  if p.path == '/health' and self.command in {'GET','HEAD'}:
75
126
  return self.send(200, {'service':'chatcomment','version':__version__})
127
+ if scoped and context is None:
128
+ raise PermissionError('此独立服务需要平台签名上下文')
76
129
  assets = {'/widget.js':('widget.js','application/javascript; charset=utf-8'), '/widget.css':('widget.css','text/css; charset=utf-8')}
77
130
  if p.path in assets and self.command in {'GET','HEAD'}:
78
131
  name, mime = assets[p.path]
79
132
  return self.send(200, files('chatcomment').joinpath('static', name).read_bytes(), mime)
80
133
  if p.path == '/board' and self.command in {'GET','HEAD'}:
81
- return self.send(200, files('chatcomment').joinpath('static/board.html').read_bytes(), 'text/html; charset=utf-8')
134
+ selected=self.target_site(q,context)
135
+ if scoped:context.require(selected,'read')
136
+ site=store.site(selected)
137
+ if site['private'] and scoped:
138
+ if context is None or not context.authenticated:raise PermissionError('此站点需要平台登录')
139
+ context.require(selected,'read')
140
+ return self.asset('board.html','text/html; charset=utf-8',site=selected,page=q.get('page',site['pages'])[0])
82
141
  if p.path == '/admin' and self.command in {'GET','HEAD'}:
83
- if not manager:
142
+ if scoped:
143
+ if context is None:raise PermissionError('需要平台登录和站点管理权限')
144
+ selected=q.get('site',[None])[0]
145
+ if selected:context.require(selected,'manage')
146
+ elif not any(context.permits(s,'manage') for s in context.sites):raise PermissionError('需要站点管理再认证')
147
+ elif not manager:
84
148
  raise PermissionError('请通过已登录管理网关访问')
85
- return self.send(200, files('chatcomment').joinpath('static/admin.html').read_bytes(), 'text/html; charset=utf-8')
149
+ return self.asset('admin.html','text/html; charset=utf-8')
86
150
  if p.path == '/login' and self.command in {'GET','HEAD'}:
87
- if not manager:
151
+ selected=self.target_site(q,context)
152
+ if scoped:
153
+ if context is None:raise PermissionError('需要平台登录')
154
+ context.require(selected,'hint')
155
+ elif not manager:
88
156
  raise PermissionError('请通过已登录管理网关访问')
89
- return self.send(200, files('chatcomment').joinpath('static/login.html').read_bytes(), 'text/html; charset=utf-8')
157
+ return self.asset('login.html','text/html; charset=utf-8',site=selected)
90
158
  if p.path == '/api/admin/entry' and self.command == 'POST':
91
- if not manager:
159
+ payload=self.payload()
160
+ if scoped:
161
+ selected=payload.get('site')
162
+ if context is None:raise PermissionError('需要平台登录')
163
+ context.require(selected,'hint',write=True)
164
+ expected=context.email
165
+ elif not manager:
92
166
  raise PermissionError('需要本人账号')
93
- entered = text(self.payload().get('email',''),'邮箱',500)
94
- expected = self.headers.get('X-ChatComment-Admin-Email','')
167
+ else:
168
+ expected = self.headers.get('X-ChatComment-Admin-Email','')
169
+ entered = text(payload.get('email',''),'邮箱',500)
95
170
  return self.send(200, {'show_settings':bool(expected) and secrets.compare_digest(entered.casefold().encode(),expected.strip().casefold().encode())})
96
171
  if p.path == '/api/admin/sites':
97
- if not manager:
172
+ if scoped and context is None:
173
+ raise PermissionError('需要平台站点管理权限')
174
+ if not scoped and not manager:
98
175
  raise PermissionError('需要管理权限')
99
176
  if self.command == 'GET':
100
- return self.send(200, {'sites':store.sites()})
177
+ sites=store.sites()
178
+ if scoped:
179
+ sites=[s for s in sites if context.permits(s['id'],'manage')]
180
+ if not sites:raise PermissionError('需要站点管理再认证')
181
+ return self.send(200, {'sites':sites})
101
182
  if self.command == 'POST':
102
183
  v = self.payload()
184
+ if scoped:context.require(v.get('id'),'manage',write=True)
103
185
  return self.send(201, store.register(v.get('id'),title=v.get('title'),origins=v.get('origins'),pages=v.get('pages'),private=v.get('private',True)))
104
186
  if len(parts) >= 4 and parts[:2] == ['api','sites']:
105
187
  site_id, action = parts[2:4]
188
+ if scoped:context.require(site_id,'read')
106
189
  site = store.site(site_id)
107
190
  request_origin = self.headers.get('Origin')
108
191
  if request_origin:
@@ -110,16 +193,24 @@ def make_handler(store: Store, admin_token: str):
110
193
  if request_origin not in site['origins']:
111
194
  raise PermissionError('来源未获此站点允许')
112
195
  self.cors = request_origin
113
- if site['private'] and not trusted:
114
- raise PermissionError('此站点需要登录网关')
196
+ if site['private']:
197
+ if not trusted:raise PermissionError('此站点需要登录网关')
198
+ if scoped:
199
+ if not context.authenticated:raise PermissionError('私人站点需要平台账号')
200
+ context.require(site_id,'read')
115
201
  if self.command == 'OPTIONS':
116
202
  if not self.cors:
117
203
  raise PermissionError('缺少来源')
118
204
  return self.send(204, b'')
119
- if action in {'settings','admin','export','moderate'} and not manager:
120
- raise PermissionError('需要管理权限')
205
+ if action in {'settings','admin','export','moderate'}:
206
+ if scoped:
207
+ if context is None:raise PermissionError('需要平台站点管理权限')
208
+ context.require(site_id,'manage',write=self.command=='POST')
209
+ elif not manager:raise PermissionError('需要管理权限')
121
210
  if action == 'config' and self.command == 'GET':
122
- return self.send(200, {'id':site_id,'title':site['title'],'settings':site['settings'],'can_manage':manager})
211
+ return self.send(200, {'id':site_id,'title':site['title'],'settings':site['settings'],
212
+ 'can_manage':context.permits(site_id,'hint') if scoped and context else manager,
213
+ 'auth':auth_config})
123
214
  if action == 'settings' and self.command == 'POST':
124
215
  return self.send(200, store.configure(site_id, self.payload()))
125
216
  if action == 'moderate' and self.command == 'POST':
@@ -128,6 +219,8 @@ def make_handler(store: Store, admin_token: str):
128
219
  if action in {'comments','admin','export'} and self.command == 'GET':
129
220
  return self.send(200, store.comments(site_id,q.get('page',[''])[0],admin=action!='comments',before=q.get('before',[None])[0],limit=q.get('limit',[50])[0]))
130
221
  if action == 'comments' and self.command == 'POST':
222
+ if scoped and context:
223
+ context.require(site_id,'comment',write=True)
131
224
  if not trusted and (not request_origin or self.headers.get('X-ChatComment-Request') != '1'):
132
225
  raise PermissionError('留言需通过允许来源的组件提交')
133
226
  # A process-local bound keeps public submissions within 10/minute/IP.
@@ -145,7 +238,7 @@ def make_handler(store: Store, admin_token: str):
145
238
  if len(bucket) >= 10:
146
239
  return self.send(429, {'error':'留言太频繁,请稍后再试'})
147
240
  bucket.append(now)
148
- authenticated = trusted and bool(self.headers.get('X-ChatComment-Identity'))
241
+ authenticated = bool(context and context.authenticated) if scoped else trusted and bool(self.headers.get('X-ChatComment-Identity'))
149
242
  return self.send(201, store.create(site_id,self.payload(),authenticated=authenticated))
150
243
  return self.send(404, {'error':'接口不存在'})
151
244
 
@@ -164,8 +257,8 @@ def make_handler(store: Store, admin_token: str):
164
257
  return Handler
165
258
 
166
259
 
167
- def serve(store, *, token, host='127.0.0.1', port=10196):
168
- server = ThreadingHTTPServer((host, port), make_handler(store, token))
260
+ def serve(store, *, token=None, host='127.0.0.1', port=10196, **options):
261
+ server = ThreadingHTTPServer((host, port), make_handler(store, token,**options))
169
262
  server.daemon_threads = True
170
263
  try:
171
264
  server.serve_forever()
@@ -4,14 +4,17 @@
4
4
  <section><label>站点 <select id="site"></select></label><p id="site-info" class="muted"></p><form id="settings"><div class="grid"><label><input name="anonymous" type="checkbox"> 允许匿名留言</label><label><input name="moderation" type="checkbox"> 新留言先审核</label></div><div class="grid" id="fields"></div><button>保存站点设置</button></form><p class="muted">页面访问范围由登录网关控制;允许匿名仅决定留言者是否需要已验证身份。邮箱只对管理员显示。</p></section>
5
5
  <section><div class="grid"><label>页面 <select id="page"></select></label><label>显示 <select id="filter"><option value="all">全部状态</option><option value="pending">待审核</option><option value="approved">已显示</option><option value="deleted">已删除</option></select></label></div><button id="refresh">刷新</button><button id="export" class="secondary">导出此页全部留言 JSON</button><div id="comments"></div><button id="more" hidden>加载更早留言</button></section>
6
6
  <section><details><summary>注册另一个站点</summary><form id="register"><label>站点 ID<input name="id" type="text" required pattern="[a-z0-9][a-z0-9_-]{0,63}"></label><label>站点名称<input name="title" type="text" required maxlength="120"></label><label>允许来源(每行一个完整 origin)<textarea name="origins" required placeholder="https://blog.example.com"></textarea></label><label>允许页面路径(每行一个)<textarea name="pages" required placeholder="/article/one/"></textarea></label><label><input type="checkbox" name="public"> 此站点允许公开读取与匿名提交(默认私有)</label><button>注册站点</button></form></details></section></main>
7
+ <script type="application/json" id="chatcomment-auth">__CHATCOMMENT_AUTH__</script>
7
8
  <script>
8
9
  (() => {'use strict';
9
10
  const base=location.pathname.replace(/\/admin\/?$/,'');
11
+ const config=JSON.parse(document.getElementById('chatcomment-auth').textContent);
10
12
  const $=s=>document.querySelector(s), notice=$('#notice');
11
13
  const labels={nickname:'昵称',email:'邮箱',website:'网址'};let sites=[],cursor=null;
12
- $('#back').href=base.replace(/\/comments$/,'/') || '/';
14
+ $('#back').href=config.return_path;
15
+ if(config.scoped)$('#register').parentElement.hidden=true;
13
16
  for(const [name,label] of Object.entries(labels)){const el=document.createElement('label');el.textContent=label+' ';const select=document.createElement('select');select.name=name;for(const [v,t] of [['optional','可选'],['required','必填'],['hidden','隐藏']]){const o=document.createElement('option');o.value=v;o.textContent=t;select.append(o);}el.append(select);$('#fields').append(el);}
14
- async function api(path,payload){const headers={};if(payload){headers['Content-Type']='application/json';const session=await fetch('/api/session',{credentials:'same-origin'});if(session.ok)headers['X-CSRF-Token']=(await session.json()).csrf_token;}const r=await fetch(base+path,{method:payload?'POST':'GET',credentials:'same-origin',headers,body:payload?JSON.stringify(payload):undefined});const v=await r.json().catch(()=>({}));if(!r.ok)throw new Error(v.error||'操作失败,请确认登录状态');return v;}
17
+ async function api(path,payload){const headers={};if(payload){headers['Content-Type']='application/json';const session=await fetch(config.session_endpoint,{credentials:'same-origin'});if(session.ok)headers['X-CSRF-Token']=(await session.json()).csrf_token;}const r=await fetch(base+path,{method:payload?'POST':'GET',credentials:'same-origin',headers,body:payload?JSON.stringify(payload):undefined});const v=await r.json().catch(()=>({}));if(!r.ok)throw new Error(v.error||'操作失败,请确认登录状态');return v;}
15
18
  function site(){return sites.find(s=>s.id===$('#site').value);}
16
19
  function sitePath(action){return '/api/sites/'+encodeURIComponent(site().id)+'/'+action;}
17
20
  async function loadSites(selected){sites=(await api('/api/admin/sites')).sites;$('#site').replaceChildren();for(const s of sites){const o=document.createElement('option');o.value=s.id;o.textContent=s.title+' · '+s.id;$('#site').append(o);}if(selected)$('#site').value=selected;if(sites.length)await selectSite();}
@@ -22,6 +25,6 @@ $('#site').onchange=()=>run(selectSite);$('#page').onchange=$('#filter').onchang
22
25
  $('#settings').onsubmit=e=>{e.preventDefault();run(async()=>{const f=e.target.elements;const fields=Object.fromEntries(Object.keys(labels).map(n=>[n,f[n].value]));const result=await api(sitePath('settings'),{allow_anonymous:f.anonymous.checked,moderation:f.moderation.checked,fields});sites[sites.findIndex(s=>s.id===result.id)]=result;notice.textContent='设置已保存,留言组件刷新后使用新设置。';});};
23
26
  $('#register').onsubmit=e=>{e.preventDefault();run(async()=>{const f=e.target.elements;const result=await api('/api/admin/sites',{id:f.id.value,title:f.title.value,origins:f.origins.value.split('\n').map(v=>v.trim()).filter(Boolean),pages:f.pages.value.split('\n').map(v=>v.trim()).filter(Boolean),private:!f.public.checked});await loadSites(result.id);e.target.reset();notice.textContent='站点已注册。';});};
24
27
  $('#export').onclick=()=>run(async()=>{let all=[],before=null;do{const d=await api(sitePath('export')+'?limit=100&page='+encodeURIComponent($('#page').value)+(before?'&before='+encodeURIComponent(before):''));all.push(...d.comments);before=d.next_cursor;}while(before);const blob=new Blob([JSON.stringify({site:site().id,page:$('#page').value,comments:all},null,2)],{type:'application/json'}),url=URL.createObjectURL(blob),a=document.createElement('a');a.href=url;a.download='chatcomment-'+site().id+'.json';a.click();URL.revokeObjectURL(url);});
25
- run(()=>loadSites());
28
+ run(()=>loadSites(new URLSearchParams(location.search).get('site')));
26
29
  })();
27
30
  </script></html>
@@ -1,19 +1,21 @@
1
1
  <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ChatComment · 留言板</title>
2
2
  <style>body{margin:0;background:#edf2ee;color:#243c32;font:16px/1.7 system-ui,sans-serif}main{max-width:900px;padding:36px 20px;margin:auto}a{color:#2d6953}.intro{font-size:14px;color:#607367}h1{font-size:32px;margin:12px 0 6px}.brand{font-size:12px;letter-spacing:.1em}</style>
3
3
  <main><a id="back">← 返回页面</a><p class="brand">ChatComment</p><h1>想法,留在这里。</h1><p class="intro">这里与页面底部共用同一个留言流。页面访问权限由网站管理,匿名只是留言的署名方式。</p><div id="comments"></div></main>
4
+ <script type="application/json" id="chatcomment-auth">__CHATCOMMENT_AUTH__</script>
4
5
  <script>
5
6
  (async () => {
6
7
  const base=location.pathname.replace(/\/board\/?$/,'');
7
8
  const query=new URLSearchParams(location.search);
9
+ const config=JSON.parse(document.getElementById('chatcomment-auth').textContent);
8
10
  const script=document.createElement('script');
9
11
  script.src=base+'/widget.js';script.defer=true;script.dataset.chatcomment='';
10
12
  script.dataset.target='#comments';script.dataset.api=base;
11
- script.dataset.site=query.get('site')||'personal-ai';script.dataset.page=query.get('page')||'/personal-ai/';
13
+ script.dataset.site=config.site;script.dataset.page=config.page;
12
14
  try {
13
- const session=await fetch('/api/session',{credentials:'same-origin'});
14
- if(session.ok && (await session.json()).csrf_token)script.dataset.csrfEndpoint='/api/session';
15
+ const session=await fetch(config.session_endpoint,{credentials:'same-origin'});
16
+ if(session.ok && (await session.json()).csrf_token)script.dataset.csrfEndpoint=config.session_endpoint;
15
17
  } catch (_) { /* Public standalone servers have no native login contract. */ }
16
- document.getElementById('back').href=base.replace(/\/comments$/,'/')||'/';
18
+ document.getElementById('back').href=config.return_path;
17
19
  document.body.append(script);
18
20
  })();
19
21
  </script></html>
@@ -1,16 +1,19 @@
1
1
  <!doctype html><html lang="zh-CN"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>ChatComment · 验证管理身份</title>
2
2
  <style>body{margin:0;background:#f0f4f0;font:16px/1.6 system-ui,sans-serif;color:#243d30}main{max-width:420px;margin:10vh auto;padding:28px;background:white;border:1px solid #d7e0d9;border-radius:14px}label{display:block;margin:18px 0}input{display:block;box-sizing:border-box;width:100%;font:inherit;padding:10px;border:1px solid #c7d4ca;border-radius:7px}button{font:inherit;background:#2c6550;color:white;border:0;border-radius:7px;padding:10px 18px;cursor:pointer}a{color:#2d6953}p{font-size:14px;color:#617366}</style>
3
3
  <main><a id="back">← 返回页面</a><h1>验证管理身份</h1><p>使用既有站点账号的邮箱和密码。只有本人账号可管理此留言区。</p><form><label>邮箱<input name="email" type="email" autocomplete="username" required></label><label>密码<input name="password" type="password" autocomplete="current-password" required></label><button>验证并进入设置</button><p role="status" aria-live="polite"></p></form></main>
4
+ <script type="application/json" id="chatcomment-auth">__CHATCOMMENT_AUTH__</script>
4
5
  <script>
5
6
  (() => {
6
7
  const base=location.pathname.replace(/\/login\/?$/,'');
7
- document.getElementById('back').href=base.replace(/\/comments$/,'/')||'/';
8
+ const config=JSON.parse(document.getElementById('chatcomment-auth').textContent);
9
+ document.getElementById('back').href=config.return_path;
8
10
  const form=document.querySelector('form'),status=document.querySelector('[role=status]');
9
11
  form.addEventListener('submit',async e=>{e.preventDefault();const button=form.querySelector('button');button.disabled=true;status.textContent='正在验证…';
10
- try{const session=await fetch('/api/session',{credentials:'same-origin'});if(!session.ok)throw new Error('请先登录站点');const csrf=(await session.json()).csrf_token;
11
- const response=await fetch(base+'/api/admin/reauth',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(Object.fromEntries(new FormData(form)))});
12
+ try{const session=await fetch(config.session_endpoint,{credentials:'same-origin'});if(!session.ok)throw new Error('请先登录站点');const csrf=(await session.json()).csrf_token;
13
+ const payload=Object.fromEntries(new FormData(form));if(config.scoped)payload.site=config.site;
14
+ const response=await fetch(config.reauth_endpoint||base+'/api/admin/reauth',{method:'POST',credentials:'same-origin',headers:{'Content-Type':'application/json','X-CSRF-Token':csrf},body:JSON.stringify(payload)});
12
15
  const result=await response.json();if(!response.ok)throw new Error(result.error||'邮箱或密码不正确');
13
- form.elements.password.value='';location.assign(base+'/admin');
16
+ form.elements.password.value='';location.assign(base+'/admin?site='+encodeURIComponent(config.site));
14
17
  }catch(error){status.textContent=error.message;form.elements.password.value='';button.disabled=false;}
15
18
  });
16
19
  })();
@@ -18,7 +18,7 @@
18
18
  <div class="cc-actions"><button type="submit">提交留言</button><button type="button" class="cc-refresh">刷新留言</button><span class="cc-status" role="status" aria-live="polite"></span></div></form>
19
19
  <div class="cc-list" aria-live="polite"></div><button type="button" class="cc-more" hidden>更早的留言</button>`;
20
20
  // The API base comes from the deployer's script attribute; never from comments.
21
- box.querySelector('.cc-admin').href = `${api}/login`;
21
+ box.querySelector('.cc-admin').href = `${api}/login?site=${site}`;
22
22
  const status = box.querySelector('.cc-status');
23
23
  const list = box.querySelector('.cc-list');
24
24
  const form = box.querySelector('form');
@@ -43,6 +43,7 @@
43
43
  }
44
44
  async function config() {
45
45
  const data = await request(`/api/sites/${site}/config`);
46
+ if (data.auth?.scoped) options.csrfEndpoint=data.auth.session_endpoint;
46
47
  const gear = box.querySelector('.cc-admin'); gear.hidden = true;
47
48
  const fields = box.querySelector('.cc-fields'); fields.replaceChildren();
48
49
  const labels = {nickname:'昵称',email:'邮箱',website:'网址'};
@@ -63,7 +64,7 @@
63
64
  if (!value || !input.validity.valid) return;
64
65
  timer = setTimeout(async () => {
65
66
  try {
66
- const hint = await request('/api/admin/entry', {email:value});
67
+ const hint = await request('/api/admin/entry', {site:options.site,email:value});
67
68
  gear.hidden = input.value.trim() !== value || hint.show_settings !== true;
68
69
  } catch (_) { gear.hidden = true; }
69
70
  }, 250);
@@ -142,6 +142,19 @@ 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 allow_origin(self,site_id,value):
146
+ """Add an approved platform origin without touching fields or comments."""
147
+ approved=origin(value)
148
+ with self.connect() as db:
149
+ row=db.execute('SELECT origins FROM sites WHERE id=?',(site_id,)).fetchone()
150
+ if row is None:raise KeyError('站点不存在')
151
+ origins=json.loads(row[0])
152
+ if approved not in origins:
153
+ if len(origins)>=30:raise Invalid('站点来源数量已达上限')
154
+ origins.append(approved)
155
+ db.execute('UPDATE sites SET origins=? WHERE id=?',(json.dumps(sorted(origins)),site_id))
156
+ return self.site(site_id)
157
+
145
158
  def check_page(self, site_id, page_key):
146
159
  site = self.site(site_id)
147
160
  page_key = page(page_key)
@@ -14,3 +14,10 @@ def test_installed_surface_and_registration(tmp_path):
14
14
  assert r.invoke(main,args+['site','list']).exit_code==0
15
15
  result=r.invoke(main,args+['backup',str(tmp_path/'backup.sqlite3')])
16
16
  assert result.exit_code==0,result.output
17
+
18
+
19
+ def test_scoped_missing_database_fails_before_creating_file(tmp_path):
20
+ path=tmp_path/'missing'/'db.sqlite3'
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
+ assert result.exit_code!=0 and 'mount is missing' in result.output
23
+ assert not path.exists()
@@ -0,0 +1,46 @@
1
+ import json
2
+ import base64
3
+ import hmac
4
+ import hashlib
5
+ from types import SimpleNamespace
6
+
7
+ import pytest
8
+
9
+ from chatcomment.access import AccessContext,SiteAccess,sign_context,verify_context
10
+
11
+ KEY=b'test-key-with-at-least-thirty-two-bytes'
12
+
13
+
14
+ def context():
15
+ return AccessContext('user-a','session-digest','a@example.invalid',{
16
+ 'owned':SiteAccess(True,True,True,True,200),
17
+ 'viewed':SiteAccess(read=True),
18
+ 'other-managed':SiteAccess(True,True,True,False,200)},True)
19
+
20
+
21
+ def test_explicit_site_roles_and_reauth():
22
+ c=context()
23
+ assert c.permits('owned','manage',now=100)
24
+ assert not c.permits('owned','manage',now=200)
25
+ assert c.permits('viewed','read') and not c.permits('viewed','comment')
26
+ assert c.permits('other-managed','manage',now=100) and not c.permits('other-managed','hint')
27
+ assert not c.permits('ungranted','read')
28
+ with pytest.raises(PermissionError):c.require('viewed','manage',now=100)
29
+ with pytest.raises(PermissionError):AccessContext('u','s',sites={'owned':SiteAccess(True,True)},csrf_verified=False).require('owned','comment',write=True)
30
+
31
+
32
+ def test_chatlogin_global_admin_does_not_grant_sites():
33
+ principal=SimpleNamespace(user_id='global-admin',authenticated=True,role='admin')
34
+ c=AccessContext.from_chatlogin(SimpleNamespace(principal=principal),session_id='digest',email='admin@example.invalid',sites={})
35
+ assert not c.permits('any-site','manage',now=100)
36
+
37
+
38
+ def test_signed_body_method_path_and_ttl():
39
+ headers=sign_context(context(),key=KEY,method='POST',path='/api/sites/owned/settings',body=b'{"a":1}',now=100)
40
+ c=verify_context(headers,key=KEY,method='POST',path='/api/sites/owned/settings',body=b'{"a":1}',now=110)
41
+ assert c.subject=='user-a' and c.permits('owned','manage',now=110)
42
+ for changes in [{'method':'GET'},{'path':'/api/sites/ungranted/settings'},{'body':b'{"a":2}'},{'now':131},{'key':b'wrong-key-that-is-at-least-32-bytes'}]:
43
+ args={'key':KEY,'method':'POST','path':'/api/sites/owned/settings','body':b'{"a":1}','now':110};args.update(changes)
44
+ with pytest.raises(PermissionError):verify_context(headers,**args)
45
+ assert verify_context({},key=KEY,method='GET',path='/health') is None
46
+ with pytest.raises(PermissionError):verify_context({'X-ChatComment-Role':'admin','X-ChatComment-Signature':'f'*64},key=KEY,method='GET',path='/health')
@@ -0,0 +1,24 @@
1
+ from chatlogin.managed import ManagedUsers
2
+
3
+ from chatcomment import AccessContext,SiteAccess
4
+
5
+
6
+ def test_real_managed_sessions_preserve_site_scope_and_subject():
7
+ users=ManagedUsers.in_memory('comment-platform')
8
+ try:
9
+ users.bootstrap_owner('alice@example.invalid','test-password-alice-123!')
10
+ alice=users.authenticate('alice@example.invalid','test-password-alice-123!')
11
+ users.create_user(alice,'bob@example.invalid','test-password-bob-123!')
12
+ bob=users.authenticate('bob@example.invalid','test-password-bob-123!')
13
+ issued=users.sessions.issue(bob)
14
+ session=users.sessions.resolve(issued.token)
15
+ assert session is not None
16
+ context=AccessContext.from_chatlogin(session,session_id=users.sessions.digest(issued.token),
17
+ email='bob@example.invalid',sites={'bob-project':SiteAccess(True,True,True,True)},csrf_verified=True)
18
+ assert context.subject==bob.user_id and context.permits('bob-project','hint')
19
+ assert not context.permits('alice-project','read')
20
+ assert not context.permits('bob-project','manage') # Password grant still required.
21
+ users.sessions.revoke(issued.token)
22
+ assert users.sessions.resolve(issued.token) is None
23
+ finally:
24
+ users.close()
@@ -32,6 +32,16 @@ def test_isolation_privacy_persistence_backup(store,tmp_path):
32
32
  with pytest.raises(Invalid): store.backup(backup)
33
33
 
34
34
 
35
+ def test_allow_origin_preserves_all_other_site_fields_and_rows(store):
36
+ item=store.create('first',{'page':'/one/','content':'preserve migration row','email':'private@example.invalid'})
37
+ before=store.site('first');rows=store.comments('first','/one/',admin=True)
38
+ after=store.allow_origin('first','https://new-platform.example/')
39
+ assert after['origins']==['https://first.example','https://new-platform.example']
40
+ assert {k:v for k,v in before.items() if k!='origins'}=={k:v for k,v in after.items() if k!='origins'}
41
+ assert store.comments('first','/one/',admin=True)==rows
42
+ assert store.allow_origin('first','https://new-platform.example')==after
43
+
44
+
35
45
  def test_fields_and_anonymous_enforced(store):
36
46
  config=store.site('first')['settings']
37
47
  config['fields']['email']='required'
@@ -0,0 +1,103 @@
1
+ from http.server import ThreadingHTTPServer
2
+ import json
3
+ import threading
4
+ import time
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ import pytest
9
+
10
+ from chatcomment import Store
11
+ from chatcomment.access import AccessContext,SiteAccess,sign_context
12
+ from chatcomment.server import make_handler
13
+
14
+ KEY='test-platform-signing-key-with-more-than-32-characters'
15
+
16
+
17
+ @pytest.fixture
18
+ def platform(tmp_path):
19
+ store=Store(tmp_path/'comments.sqlite3')
20
+ for site in ['alice-site','bob-site']:
21
+ store.register(site,title=site,origins=['https://platform.example'],pages=['/kept/page/'],private=True)
22
+ store.create('bob-site',{'page':'/kept/page/','content':'Bob private row','email':'bob-private@example.invalid'})
23
+ server=ThreadingHTTPServer(('127.0.0.1',0),make_handler(store,gateway_key=KEY,
24
+ session_endpoint='/auth/api/session',reauth_endpoint='/extensions/comments/reauth',return_path='/sites/alice-site/'))
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):
27
+ raw=json.dumps(body).encode() if body is not None else b''
28
+ method='POST' if body is not None else 'GET'
29
+ headers={'Origin':'https://platform.example'}
30
+ if body is not None:headers['Content-Type']='application/json'
31
+ if context is not None:headers.update(sign_context(context,key=KEY,method=method,path=signature_path or path,body=raw))
32
+ if legacy:headers.update({'Authorization':'Bearer '+KEY,'X-ChatComment-Role':'admin','X-ChatComment-Identity':'alice'})
33
+ req=urllib.request.Request(f'http://127.0.0.1:{server.server_port}'+path,data=raw if body is not None else None,headers=headers)
34
+ try:r=urllib.request.urlopen(req,timeout=5)
35
+ except urllib.error.HTTPError as e:r=e
36
+ with r:
37
+ data=r.read()
38
+ return r.status,json.loads(data) if r.headers.get_content_type()=='application/json' else data.decode()
39
+ yield store,request
40
+ server.shutdown();server.server_close();thread.join()
41
+
42
+
43
+ def owner(*,verified=False,csrf=True):
44
+ return AccessContext('alice','current-session-hash','alice@example.invalid',{
45
+ 'alice-site':SiteAccess(True,True,True,True,time.time()+900 if verified else 0)},csrf)
46
+
47
+
48
+ def test_private_read_and_cross_site_management(platform):
49
+ store,http=platform
50
+ assert http('/api/sites/alice-site/config')[0]==403
51
+ assert http('/widget.js')[0]==403
52
+ assert http('/widget.css')[0]==403
53
+ assert http('/health')[0]==200
54
+ assert http('/api/sites/alice-site/config',legacy=True)[0]==403
55
+ assert http('/api/sites/alice-site/config',context=owner())[0]==200
56
+ assert http('/api/sites/bob-site/comments?page=/kept/page/',context=owner(verified=True))[0]==403
57
+ assert http('/api/sites/bob-site/admin?page=/kept/page/',context=owner(verified=True))[0]==403
58
+ assert http('/api/sites/bob-site/config',context=owner(),signature_path='/api/sites/alice-site/config')[0]==403
59
+ assert http('/api/sites/unknown-site/config',context=owner())[0]==403
60
+ code,data=http('/api/admin/sites',context=owner(verified=True))
61
+ assert code==200 and [s['id'] for s in data['sites']]==['alice-site']
62
+ assert 'bob-private@example.invalid' not in json.dumps(data)
63
+ assert http('/admin?site=bob-site',context=owner(verified=True))[0]==403
64
+
65
+
66
+ def test_email_hint_is_site_scoped_and_never_grants_management(platform):
67
+ _,http=platform
68
+ assert http('/api/admin/entry',context=owner(),body={'site':'alice-site','email':'alice@example.invalid'})==(200,{'show_settings':True})
69
+ assert http('/api/admin/entry',context=owner(),body={'site':'alice-site','email':'nickname-rexwzh@example.invalid'})==(200,{'show_settings':False})
70
+ assert http('/api/admin/entry',context=owner(),body={'site':'bob-site','email':'alice@example.invalid'})[0]==403
71
+ assert http('/api/admin/entry',context=owner(csrf=False),body={'site':'alice-site','email':'alice@example.invalid'})[0]==403
72
+ assert http('/api/sites/alice-site/admin?page=/kept/page/',context=owner())[0]==403
73
+ assert http('/api/admin/sites',context=owner())[0]==403
74
+ assert http('/api/sites/alice-site/admin?page=/kept/page/',context=owner(verified=True))[0]==200
75
+ _,config=http('/api/sites/alice-site/config',context=owner())
76
+ assert config['auth']['session_endpoint']=='/auth/api/session'
77
+ assert 'alice@example.invalid' not in json.dumps(config)
78
+
79
+
80
+ def test_viewer_no_writes_csrf_and_registered_page(platform):
81
+ store,http=platform
82
+ viewer=AccessContext('viewer','session','viewer@example.invalid',{'alice-site':SiteAccess(read=True)},True)
83
+ guest=AccessContext('guest','guest-session',sites={'alice-site':SiteAccess(read=True)},authenticated=False)
84
+ assert http('/api/sites/alice-site/config',context=guest)[0]==403
85
+ assert http('/api/sites/alice-site/config',context=viewer)[1]['can_manage'] is False
86
+ body={'page':'/kept/page/','content':'A real scoped comment'}
87
+ assert http('/api/sites/alice-site/comments',context=viewer,body=body)[0]==403
88
+ assert http('/api/sites/alice-site/comments',context=owner(csrf=False),body=body)[0]==403
89
+ assert http('/api/sites/alice-site/comments',context=owner(),body={**body,'page':'/unregistered/'})[0]==404
90
+ code,data=http('/api/sites/alice-site/comments',context=owner(),body=body)
91
+ assert code==201
92
+ assert http('/api/sites/alice-site/comments?page=/kept/page/',context=viewer)[1]['comments'][0]['id']==data['id']
93
+ assert store.comments('bob-site','/kept/page/')['comments'][0]['content']=='Bob private row'
94
+
95
+
96
+ def test_scoped_login_configuration_and_registration_denial(platform):
97
+ store,http=platform
98
+ code,html=http('/login?site=alice-site',context=owner())
99
+ assert code==200 and '/auth/api/session' in html and '/extensions/comments/reauth' in html
100
+ assert 'alice@example.invalid' not in html and '__CHATCOMMENT_AUTH__' not in html
101
+ assert http('/login?site=bob-site',context=owner())[0]==403
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
+ with pytest.raises(ValueError):make_handler(store,KEY,gateway_key=KEY)
@@ -1,6 +0,0 @@
1
- """Reusable comments storage and HTTP service."""
2
- __version__ = "0.1.1"
3
-
4
- from .store import Store
5
-
6
- __all__ = ["Store", "__version__"]
File without changes
File without changes
File without changes
File without changes