ChatComment 0.1.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.
- chatcomment-0.1.0/AGENTS.md +7 -0
- chatcomment-0.1.0/CHANGELOG.md +8 -0
- chatcomment-0.1.0/LICENSE +21 -0
- chatcomment-0.1.0/MANIFEST.in +4 -0
- chatcomment-0.1.0/PKG-INFO +46 -0
- chatcomment-0.1.0/README.md +27 -0
- chatcomment-0.1.0/docs/api.md +34 -0
- chatcomment-0.1.0/docs/deployment.md +15 -0
- chatcomment-0.1.0/pyproject.toml +31 -0
- chatcomment-0.1.0/setup.cfg +4 -0
- chatcomment-0.1.0/src/ChatComment.egg-info/PKG-INFO +46 -0
- chatcomment-0.1.0/src/ChatComment.egg-info/SOURCES.txt +24 -0
- chatcomment-0.1.0/src/ChatComment.egg-info/dependency_links.txt +1 -0
- chatcomment-0.1.0/src/ChatComment.egg-info/entry_points.txt +2 -0
- chatcomment-0.1.0/src/ChatComment.egg-info/requires.txt +8 -0
- chatcomment-0.1.0/src/ChatComment.egg-info/top_level.txt +1 -0
- chatcomment-0.1.0/src/chatcomment/__init__.py +6 -0
- chatcomment-0.1.0/src/chatcomment/cli.py +76 -0
- chatcomment-0.1.0/src/chatcomment/server.py +163 -0
- chatcomment-0.1.0/src/chatcomment/static/admin.html +27 -0
- chatcomment-0.1.0/src/chatcomment/static/board.html +19 -0
- chatcomment-0.1.0/src/chatcomment/static/widget.css +1 -0
- chatcomment-0.1.0/src/chatcomment/static/widget.js +96 -0
- chatcomment-0.1.0/src/chatcomment/store.py +226 -0
- chatcomment-0.1.0/tests/cli-tests/test_cli.py +16 -0
- chatcomment-0.1.0/tests/code-tests/test_comments.py +125 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
# Agent Notes
|
|
2
|
+
|
|
3
|
+
- Preserve unrelated changes. Keep Python capabilities importable and CLI thin.
|
|
4
|
+
- Use ChatStyle 0.2 / ChatEnv 0.2 as the CLI baseline; expose version and both tree options.
|
|
5
|
+
- Keep README, CHANGELOG, docs and tests consistent with shipped behavior.
|
|
6
|
+
- Release by PR, successful checks on the exact commit, merge, tag the merged main commit, then Trusted Publisher/OIDC. Never use long-lived PyPI tokens or publish a placeholder.
|
|
7
|
+
- Never commit credentials, sessions, SQLite data or machine-specific private configuration.
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ChatArch
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ChatComment
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embeddable comments with a Python server, SQLite storage and a small JavaScript widget.
|
|
5
|
+
Author: ChatArch
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ChatArch/ChatComment
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: click<9,>=8
|
|
12
|
+
Requires-Dist: chatstyle<0.3.0,>=0.2.0
|
|
13
|
+
Requires-Dist: chatenv<0.3.0,>=0.2.11
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: build; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: twine; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# ChatComment
|
|
21
|
+
|
|
22
|
+
Python 服务、SQLite 数据和一个无依赖的 JavaScript 留言组件。支持多站点、逐页隔离、匿名开关、昵称/邮箱/网址必填设置、审核与软删除。邮箱只在管理界面出现。默认私有站点由可信网关保护;可显式注册公开站点。
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pip install .
|
|
26
|
+
chatcomment --version
|
|
27
|
+
chatcomment --tree
|
|
28
|
+
chatcomment site register personal-blog --origin https://blog.example.com --page /post/one --public
|
|
29
|
+
chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--token-file` 必须指向仅部署者可读的现有文件(建议随机 32 字节以上);命令不生成或打印密钥。默认数据库 `~/.chatarch/chatcomment/comments.sqlite3`,监听 `127.0.0.1:10196`。公网接入必须使用 HTTPS 反向代理;私有站点的 API 要由网关验证登录后附加 Bearer token,不能把该 token 交给浏览器。管理入口也必须经这种登录网关,或使用支持 Bearer token 的管理客户端。
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<div id="comments"></div>
|
|
36
|
+
<script src="https://comments.example.com/widget.js" defer
|
|
37
|
+
data-chatcomment data-target="#comments"
|
|
38
|
+
data-api="https://comments.example.com" data-site="personal-blog"
|
|
39
|
+
data-page="/post/one"></script>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
组件自动读取设置并只显示允许的字段;必填校验由服务器执行。默认不记住访客信息、不加载第三方头像、不渲染留言 HTML。公开模式的匿名留言不代表拥有可信实名;关闭匿名时要求可信网关提供已验证的作者身份。
|
|
43
|
+
|
|
44
|
+
API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
|
|
45
|
+
|
|
46
|
+
独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。设置按钮只在服务器返回 `can_manage=true` 时显示。网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称或伪造角色 header 获得权限,因为缺少服务器 Bearer token。
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# ChatComment
|
|
2
|
+
|
|
3
|
+
Python 服务、SQLite 数据和一个无依赖的 JavaScript 留言组件。支持多站点、逐页隔离、匿名开关、昵称/邮箱/网址必填设置、审核与软删除。邮箱只在管理界面出现。默认私有站点由可信网关保护;可显式注册公开站点。
|
|
4
|
+
|
|
5
|
+
```sh
|
|
6
|
+
python -m pip install .
|
|
7
|
+
chatcomment --version
|
|
8
|
+
chatcomment --tree
|
|
9
|
+
chatcomment site register personal-blog --origin https://blog.example.com --page /post/one --public
|
|
10
|
+
chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
`--token-file` 必须指向仅部署者可读的现有文件(建议随机 32 字节以上);命令不生成或打印密钥。默认数据库 `~/.chatarch/chatcomment/comments.sqlite3`,监听 `127.0.0.1:10196`。公网接入必须使用 HTTPS 反向代理;私有站点的 API 要由网关验证登录后附加 Bearer token,不能把该 token 交给浏览器。管理入口也必须经这种登录网关,或使用支持 Bearer token 的管理客户端。
|
|
14
|
+
|
|
15
|
+
```html
|
|
16
|
+
<div id="comments"></div>
|
|
17
|
+
<script src="https://comments.example.com/widget.js" defer
|
|
18
|
+
data-chatcomment data-target="#comments"
|
|
19
|
+
data-api="https://comments.example.com" data-site="personal-blog"
|
|
20
|
+
data-page="/post/one"></script>
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
组件自动读取设置并只显示允许的字段;必填校验由服务器执行。默认不记住访客信息、不加载第三方头像、不渲染留言 HTML。公开模式的匿名留言不代表拥有可信实名;关闭匿名时要求可信网关提供已验证的作者身份。
|
|
24
|
+
|
|
25
|
+
API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
|
|
26
|
+
|
|
27
|
+
独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。设置按钮只在服务器返回 `can_manage=true` 时显示。网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称或伪造角色 header 获得权限,因为缺少服务器 Bearer token。
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# Commands and APIs
|
|
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.
|
|
4
|
+
|
|
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
|
+
|
|
7
|
+
| Method | Path | Behavior |
|
|
8
|
+
| --- | --- | --- |
|
|
9
|
+
| GET | `/health` | Version and service name |
|
|
10
|
+
| GET | `/widget.js`, `/widget.css` | Bundle-local widget assets |
|
|
11
|
+
| GET | `/api/sites/{id}/config` | Public field settings |
|
|
12
|
+
| GET | `/api/sites/{id}/comments?page=/path/` | Approved comments, never emails |
|
|
13
|
+
| POST | `/api/sites/{id}/comments` | `{page,content,nickname?,email?,website?}` |
|
|
14
|
+
| GET | `/admin` | Gateway-protected management UI |
|
|
15
|
+
| GET | `/board?site={id}&page=/path/` | Standalone widget host; apply the private-site gateway to this HTML too |
|
|
16
|
+
| GET/POST | `/api/admin/sites` | List/register sites |
|
|
17
|
+
| POST | `/api/sites/{id}/settings` | Complete settings object |
|
|
18
|
+
| GET | `/api/sites/{id}/admin?page=/path/` | All states with private fields |
|
|
19
|
+
| POST | `/api/sites/{id}/moderate` | `{id,status:approved|pending|deleted}` |
|
|
20
|
+
| GET | `/api/sites/{id}/export?page=/path/` | Admin data, paginated |
|
|
21
|
+
|
|
22
|
+
List responses contain `comments` and `next_cursor`. Pass `before=next_cursor` to continue; limit defaults to 50 and is capped at 100. Export UI retrieves every page. Deletion is a reversible state change. Anonymous identity is unverified; no IP addresses, cookies or user-agent fingerprints are stored in the database.
|
|
23
|
+
|
|
24
|
+
Private sites and all admin calls require `Authorization: Bearer <server token>`. A trusted gateway must remove untrusted copies of that header and `X-ChatComment-Identity`, check its own session and write CSRF, and inject them server-side. `X-ChatComment-Identity` only enables authenticated submission when the Bearer token is valid. It is never included in public records. Public submissions require an allowed `Origin` plus `X-ChatComment-Request: 1`; ten requests/minute/client address per process. CORS reflects only registered origins and does not allow browser credentials. A proxy should additionally apply a deployment-appropriate limit and pass the real client safely if public sites need independent limits.
|
|
25
|
+
|
|
26
|
+
Gateway members use `X-ChatComment-Role: reader`; they can read a private site and submit with a verified identity but cannot administer. Only owner requests receive `admin`. A direct bearer API client defaults to admin for compatibility. `/config` returns `can_manage` from this trusted role, and the widget shows the settings link accordingly. Private HTML, site read permissions and site-specific authorization remain the gateway's responsibility.
|
|
27
|
+
|
|
28
|
+
Settings example:
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{"allow_anonymous":true,"moderation":false,"fields":{"nickname":"optional","email":"optional","website":"hidden"}}
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
Hidden fields are discarded. Email/URL formats, required fields and 1–5000 character plain text content are checked server-side. Browser output uses `textContent`; no comment HTML or third-party avatars are executed.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# Deployment, backup and rollback
|
|
2
|
+
|
|
3
|
+
Install the built wheel into a versioned virtual environment. Run the service as an unprivileged systemd user with `UMask=0077`, automatic restart, a dedicated persistent database directory, and a token file outside source control. Bind to loopback and expose it through an HTTPS gateway. Never put the token in the widget, a URL, static HTML or browser storage.
|
|
4
|
+
|
|
5
|
+
For a private site, apply the existing site login to **every** API and asset request under the integration prefix. For writes, validate the existing session's CSRF token before proxying. The widget supports `data-csrf-endpoint="/api/session"`, which fetches a native `csrf_token` just before a write; it does not store credentials. The management UI also uses this native endpoint. Other session systems can provide this minimal same-origin contract or host a custom admin client.
|
|
6
|
+
|
|
7
|
+
The gateway maps its prefix to backend `/`, injects the Bearer token and a verified identity marker, and relays only required response headers. Block unexpected methods and cap body size. A separate public site may explicitly use `site register --public`, but the private site setting must remain private. Site IDs are not secrets and never replace access control.
|
|
8
|
+
|
|
9
|
+
Consistent live backup:
|
|
10
|
+
|
|
11
|
+
```sh
|
|
12
|
+
chatcomment --db ~/.chatarch/chatcomment/comments.sqlite3 backup ~/backups/chatcomment-2026-10-20.sqlite3
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
Do not copy just the main SQLite file while WAL writes are active. Store backups with mode 0600 and copy them to an approved protected backup location. Restore: stop the service, retain the old database and its WAL/SHM files together, put the verified backup at the configured path, then restart and read a known comment. A software rollback points the unit at the previous virtual environment and retains the database; 0.1.0 performs only additive schema creation. Do not restore an old database merely to roll back code.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "ChatComment"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Embeddable comments with a Python server, SQLite storage and a small JavaScript widget."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = "MIT"
|
|
12
|
+
authors = [{name = "ChatArch"}]
|
|
13
|
+
dependencies = ["click>=8,<9", "chatstyle>=0.2.0,<0.3.0", "chatenv>=0.2.11,<0.3.0"]
|
|
14
|
+
|
|
15
|
+
[project.urls]
|
|
16
|
+
Repository = "https://github.com/ChatArch/ChatComment"
|
|
17
|
+
|
|
18
|
+
[project.scripts]
|
|
19
|
+
chatcomment = "chatcomment.cli:main"
|
|
20
|
+
|
|
21
|
+
[project.optional-dependencies]
|
|
22
|
+
dev = ["build", "pytest>=8", "twine"]
|
|
23
|
+
|
|
24
|
+
[tool.setuptools.packages.find]
|
|
25
|
+
where = ["src"]
|
|
26
|
+
|
|
27
|
+
[tool.setuptools.package-data]
|
|
28
|
+
chatcomment = ["static/*"]
|
|
29
|
+
|
|
30
|
+
[tool.pytest.ini_options]
|
|
31
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ChatComment
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Embeddable comments with a Python server, SQLite storage and a small JavaScript widget.
|
|
5
|
+
Author: ChatArch
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://github.com/ChatArch/ChatComment
|
|
8
|
+
Requires-Python: >=3.10
|
|
9
|
+
Description-Content-Type: text/markdown
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Requires-Dist: click<9,>=8
|
|
12
|
+
Requires-Dist: chatstyle<0.3.0,>=0.2.0
|
|
13
|
+
Requires-Dist: chatenv<0.3.0,>=0.2.11
|
|
14
|
+
Provides-Extra: dev
|
|
15
|
+
Requires-Dist: build; extra == "dev"
|
|
16
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
17
|
+
Requires-Dist: twine; extra == "dev"
|
|
18
|
+
Dynamic: license-file
|
|
19
|
+
|
|
20
|
+
# ChatComment
|
|
21
|
+
|
|
22
|
+
Python 服务、SQLite 数据和一个无依赖的 JavaScript 留言组件。支持多站点、逐页隔离、匿名开关、昵称/邮箱/网址必填设置、审核与软删除。邮箱只在管理界面出现。默认私有站点由可信网关保护;可显式注册公开站点。
|
|
23
|
+
|
|
24
|
+
```sh
|
|
25
|
+
python -m pip install .
|
|
26
|
+
chatcomment --version
|
|
27
|
+
chatcomment --tree
|
|
28
|
+
chatcomment site register personal-blog --origin https://blog.example.com --page /post/one --public
|
|
29
|
+
chatcomment serve --token-file ~/.chatarch/chatcomment/admin-token
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`--token-file` 必须指向仅部署者可读的现有文件(建议随机 32 字节以上);命令不生成或打印密钥。默认数据库 `~/.chatarch/chatcomment/comments.sqlite3`,监听 `127.0.0.1:10196`。公网接入必须使用 HTTPS 反向代理;私有站点的 API 要由网关验证登录后附加 Bearer token,不能把该 token 交给浏览器。管理入口也必须经这种登录网关,或使用支持 Bearer token 的管理客户端。
|
|
33
|
+
|
|
34
|
+
```html
|
|
35
|
+
<div id="comments"></div>
|
|
36
|
+
<script src="https://comments.example.com/widget.js" defer
|
|
37
|
+
data-chatcomment data-target="#comments"
|
|
38
|
+
data-api="https://comments.example.com" data-site="personal-blog"
|
|
39
|
+
data-page="/post/one"></script>
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
组件自动读取设置并只显示允许的字段;必填校验由服务器执行。默认不记住访客信息、不加载第三方头像、不渲染留言 HTML。公开模式的匿名留言不代表拥有可信实名;关闭匿名时要求可信网关提供已验证的作者身份。
|
|
43
|
+
|
|
44
|
+
API、网关接入、备份与恢复见 [docs/api.md](docs/api.md)、[docs/deployment.md](docs/deployment.md)。本包不自动开放防火墙或注册 DNS,也不包含云服务凭据。
|
|
45
|
+
|
|
46
|
+
独立留言板为 `/board?site=personal-blog&page=%2Fpost%2Fone`,与嵌入表单读写同一留言流。设置按钮只在服务器返回 `can_manage=true` 时显示。网关为普通成员设置 `X-ChatComment-Role: reader`,仅经服务端验证的 owner 使用 `admin`;管理接口另行验证权限。公开浏览器不能靠昵称或伪造角色 header 获得权限,因为缺少服务器 Bearer token。
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
AGENTS.md
|
|
2
|
+
CHANGELOG.md
|
|
3
|
+
LICENSE
|
|
4
|
+
MANIFEST.in
|
|
5
|
+
README.md
|
|
6
|
+
pyproject.toml
|
|
7
|
+
docs/api.md
|
|
8
|
+
docs/deployment.md
|
|
9
|
+
src/ChatComment.egg-info/PKG-INFO
|
|
10
|
+
src/ChatComment.egg-info/SOURCES.txt
|
|
11
|
+
src/ChatComment.egg-info/dependency_links.txt
|
|
12
|
+
src/ChatComment.egg-info/entry_points.txt
|
|
13
|
+
src/ChatComment.egg-info/requires.txt
|
|
14
|
+
src/ChatComment.egg-info/top_level.txt
|
|
15
|
+
src/chatcomment/__init__.py
|
|
16
|
+
src/chatcomment/cli.py
|
|
17
|
+
src/chatcomment/server.py
|
|
18
|
+
src/chatcomment/store.py
|
|
19
|
+
src/chatcomment/static/admin.html
|
|
20
|
+
src/chatcomment/static/board.html
|
|
21
|
+
src/chatcomment/static/widget.css
|
|
22
|
+
src/chatcomment/static/widget.js
|
|
23
|
+
tests/cli-tests/test_cli.py
|
|
24
|
+
tests/code-tests/test_comments.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
chatcomment
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Thin CLI around the Python API."""
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
import click
|
|
6
|
+
from chatstyle import add_tree_option
|
|
7
|
+
|
|
8
|
+
from . import Store, __version__
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@click.group(name='chatcomment')
|
|
12
|
+
@click.version_option(__version__, prog_name='chatcomment')
|
|
13
|
+
@add_tree_option()
|
|
14
|
+
@click.option('--db', type=click.Path(path_type=Path), default=Path('~/.chatarch/chatcomment/comments.sqlite3'), show_default=True)
|
|
15
|
+
@click.pass_context
|
|
16
|
+
def main(ctx, db):
|
|
17
|
+
"""Embeddable comments, site settings and persistent storage."""
|
|
18
|
+
ctx.ensure_object(dict)
|
|
19
|
+
ctx.obj['db'] = db
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@main.command()
|
|
23
|
+
@click.option('--host', default='127.0.0.1', show_default=True)
|
|
24
|
+
@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.pass_context
|
|
27
|
+
def serve(ctx, host, port, token_file):
|
|
28
|
+
"""Serve API, widget assets and the gateway-protected admin page."""
|
|
29
|
+
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)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@main.group()
|
|
37
|
+
def site():
|
|
38
|
+
"""Register sites and inspect settings."""
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@site.command(name='register')
|
|
42
|
+
@click.argument('site_id')
|
|
43
|
+
@click.option('--title', default='留言')
|
|
44
|
+
@click.option('--origin', 'origins', required=True, multiple=True)
|
|
45
|
+
@click.option('--page', 'pages', required=True, multiple=True)
|
|
46
|
+
@click.option('--public', is_flag=True, help='Explicitly allow public comment reads and anonymous writes.')
|
|
47
|
+
@click.pass_context
|
|
48
|
+
def register(ctx, site_id, title, origins, pages, public):
|
|
49
|
+
"""Register an exact site ID, origin allowlist and allowed page paths."""
|
|
50
|
+
try:
|
|
51
|
+
result = Store(ctx.obj['db']).register(site_id,title=title,origins=list(origins),pages=list(pages),private=not public)
|
|
52
|
+
except ValueError as exc:
|
|
53
|
+
raise click.ClickException(str(exc)) from exc
|
|
54
|
+
click.echo(json.dumps(result,ensure_ascii=False,indent=2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@site.command(name='list')
|
|
58
|
+
@click.pass_context
|
|
59
|
+
def list_sites(ctx):
|
|
60
|
+
"""List registered sites and their effective settings."""
|
|
61
|
+
click.echo(json.dumps(Store(ctx.obj['db']).sites(),ensure_ascii=False,indent=2))
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@main.command()
|
|
65
|
+
@click.argument('destination', type=click.Path(path_type=Path))
|
|
66
|
+
@click.pass_context
|
|
67
|
+
def backup(ctx, destination):
|
|
68
|
+
"""Create a consistent SQLite online backup without overwriting a file."""
|
|
69
|
+
try:
|
|
70
|
+
click.echo(Store(ctx.obj['db']).backup(destination))
|
|
71
|
+
except ValueError as exc:
|
|
72
|
+
raise click.ClickException(str(exc)) from exc
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == '__main__':
|
|
76
|
+
main()
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""Small JSON HTTP server. Put authenticated private traffic behind a gateway."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict, deque
|
|
5
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
6
|
+
from importlib.resources import files
|
|
7
|
+
import json
|
|
8
|
+
import secrets
|
|
9
|
+
import threading
|
|
10
|
+
import time
|
|
11
|
+
from urllib.parse import parse_qs, urlsplit
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .store import Invalid, Store, origin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def make_handler(store: Store, admin_token: str):
|
|
18
|
+
if len(admin_token) < 32:
|
|
19
|
+
raise ValueError('管理 token 至少需要 32 个字符')
|
|
20
|
+
attempts = defaultdict(deque)
|
|
21
|
+
lock = threading.Lock()
|
|
22
|
+
|
|
23
|
+
class Handler(BaseHTTPRequestHandler):
|
|
24
|
+
server_version = 'ChatComment/' + __version__
|
|
25
|
+
|
|
26
|
+
def log_message(self, fmt, *args):
|
|
27
|
+
pass # Do not log private comment paths, identities or query values.
|
|
28
|
+
|
|
29
|
+
def send(self, code, data, content_type='application/json; charset=utf-8'):
|
|
30
|
+
if isinstance(data, (dict, list)):
|
|
31
|
+
data = json.dumps(data, ensure_ascii=False).encode()
|
|
32
|
+
if isinstance(data, str):
|
|
33
|
+
data = data.encode()
|
|
34
|
+
self.send_response(code)
|
|
35
|
+
self.send_header('Content-Type', content_type)
|
|
36
|
+
self.send_header('Content-Length', str(len(data)))
|
|
37
|
+
self.send_header('Cache-Control', 'private, no-store')
|
|
38
|
+
self.send_header('X-Content-Type-Options', 'nosniff')
|
|
39
|
+
self.send_header('Referrer-Policy', 'same-origin')
|
|
40
|
+
if getattr(self, 'cors', None):
|
|
41
|
+
self.send_header('Access-Control-Allow-Origin', self.cors)
|
|
42
|
+
self.send_header('Vary', 'Origin')
|
|
43
|
+
self.send_header('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
|
44
|
+
self.send_header('Access-Control-Allow-Headers', 'Content-Type, X-ChatComment-Request')
|
|
45
|
+
self.end_headers()
|
|
46
|
+
if self.command != 'HEAD':
|
|
47
|
+
self.wfile.write(data)
|
|
48
|
+
|
|
49
|
+
def payload(self):
|
|
50
|
+
if self.headers.get('Transfer-Encoding') or self.headers.get_content_type() != 'application/json':
|
|
51
|
+
raise Invalid('仅接受 JSON 请求')
|
|
52
|
+
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))
|
|
60
|
+
except (ValueError, UnicodeDecodeError) as exc:
|
|
61
|
+
raise Invalid('JSON 格式不正确') from exc
|
|
62
|
+
if not isinstance(result, dict):
|
|
63
|
+
raise Invalid('JSON 必须是对象')
|
|
64
|
+
return result
|
|
65
|
+
|
|
66
|
+
def dispatch(self):
|
|
67
|
+
self.cors = None
|
|
68
|
+
p = urlsplit(self.path)
|
|
69
|
+
q = parse_qs(p.query)
|
|
70
|
+
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'
|
|
73
|
+
parts = p.path.strip('/').split('/')
|
|
74
|
+
if p.path == '/health' and self.command in {'GET','HEAD'}:
|
|
75
|
+
return self.send(200, {'service':'chatcomment','version':__version__})
|
|
76
|
+
assets = {'/widget.js':('widget.js','application/javascript; charset=utf-8'), '/widget.css':('widget.css','text/css; charset=utf-8')}
|
|
77
|
+
if p.path in assets and self.command in {'GET','HEAD'}:
|
|
78
|
+
name, mime = assets[p.path]
|
|
79
|
+
return self.send(200, files('chatcomment').joinpath('static', name).read_bytes(), mime)
|
|
80
|
+
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')
|
|
82
|
+
if p.path == '/admin' and self.command in {'GET','HEAD'}:
|
|
83
|
+
if not manager:
|
|
84
|
+
raise PermissionError('请通过已登录管理网关访问')
|
|
85
|
+
return self.send(200, files('chatcomment').joinpath('static/admin.html').read_bytes(), 'text/html; charset=utf-8')
|
|
86
|
+
if p.path == '/api/admin/sites':
|
|
87
|
+
if not manager:
|
|
88
|
+
raise PermissionError('需要管理权限')
|
|
89
|
+
if self.command == 'GET':
|
|
90
|
+
return self.send(200, {'sites':store.sites()})
|
|
91
|
+
if self.command == 'POST':
|
|
92
|
+
v = self.payload()
|
|
93
|
+
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)))
|
|
94
|
+
if len(parts) >= 4 and parts[:2] == ['api','sites']:
|
|
95
|
+
site_id, action = parts[2:4]
|
|
96
|
+
site = store.site(site_id)
|
|
97
|
+
request_origin = self.headers.get('Origin')
|
|
98
|
+
if request_origin:
|
|
99
|
+
request_origin = origin(request_origin)
|
|
100
|
+
if request_origin not in site['origins']:
|
|
101
|
+
raise PermissionError('来源未获此站点允许')
|
|
102
|
+
self.cors = request_origin
|
|
103
|
+
if site['private'] and not trusted:
|
|
104
|
+
raise PermissionError('此站点需要登录网关')
|
|
105
|
+
if self.command == 'OPTIONS':
|
|
106
|
+
if not self.cors:
|
|
107
|
+
raise PermissionError('缺少来源')
|
|
108
|
+
return self.send(204, b'')
|
|
109
|
+
if action in {'settings','admin','export','moderate'} and not manager:
|
|
110
|
+
raise PermissionError('需要管理权限')
|
|
111
|
+
if action == 'config' and self.command == 'GET':
|
|
112
|
+
return self.send(200, {'id':site_id,'title':site['title'],'settings':site['settings'],'can_manage':manager})
|
|
113
|
+
if action == 'settings' and self.command == 'POST':
|
|
114
|
+
return self.send(200, store.configure(site_id, self.payload()))
|
|
115
|
+
if action == 'moderate' and self.command == 'POST':
|
|
116
|
+
v = self.payload()
|
|
117
|
+
return self.send(200, store.moderate(site_id, v.get('id'), v.get('status')))
|
|
118
|
+
if action in {'comments','admin','export'} and self.command == 'GET':
|
|
119
|
+
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]))
|
|
120
|
+
if action == 'comments' and self.command == 'POST':
|
|
121
|
+
if not trusted and (not request_origin or self.headers.get('X-ChatComment-Request') != '1'):
|
|
122
|
+
raise PermissionError('留言需通过允许来源的组件提交')
|
|
123
|
+
# A process-local bound keeps public submissions within 10/minute/IP.
|
|
124
|
+
if not trusted:
|
|
125
|
+
now = time.monotonic()
|
|
126
|
+
with lock:
|
|
127
|
+
if len(attempts) > 10000:
|
|
128
|
+
for k in list(attempts):
|
|
129
|
+
if not attempts[k] or now - attempts[k][-1] > 60:
|
|
130
|
+
del attempts[k]
|
|
131
|
+
key = (self.client_address[0],site_id)
|
|
132
|
+
bucket = attempts[key]
|
|
133
|
+
while bucket and now - bucket[0] > 60:
|
|
134
|
+
bucket.popleft()
|
|
135
|
+
if len(bucket) >= 10:
|
|
136
|
+
return self.send(429, {'error':'留言太频繁,请稍后再试'})
|
|
137
|
+
bucket.append(now)
|
|
138
|
+
authenticated = trusted and bool(self.headers.get('X-ChatComment-Identity'))
|
|
139
|
+
return self.send(201, store.create(site_id,self.payload(),authenticated=authenticated))
|
|
140
|
+
return self.send(404, {'error':'接口不存在'})
|
|
141
|
+
|
|
142
|
+
def handle_request(self):
|
|
143
|
+
try:
|
|
144
|
+
return self.dispatch()
|
|
145
|
+
except KeyError:
|
|
146
|
+
return self.send(404, {'error':'站点、页面或留言不存在'})
|
|
147
|
+
except PermissionError as exc:
|
|
148
|
+
return self.send(403, {'error':str(exc)})
|
|
149
|
+
except (Invalid, ValueError, TypeError) as exc:
|
|
150
|
+
return self.send(400, {'error':str(exc)})
|
|
151
|
+
|
|
152
|
+
do_GET = do_POST = do_HEAD = do_OPTIONS = handle_request
|
|
153
|
+
|
|
154
|
+
return Handler
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def serve(store, *, token, host='127.0.0.1', port=10196):
|
|
158
|
+
server = ThreadingHTTPServer((host, port), make_handler(store, token))
|
|
159
|
+
server.daemon_threads = True
|
|
160
|
+
try:
|
|
161
|
+
server.serve_forever()
|
|
162
|
+
finally:
|
|
163
|
+
server.server_close()
|
|
@@ -0,0 +1,27 @@
|
|
|
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
|
+
<style>body{margin:0;background:#f3f6f3;color:#243c31;font:15px/1.65 system-ui,sans-serif}main{max-width:1000px;margin:auto;padding:36px 20px}h1{margin:0;font-size:30px}a{color:#2a6b54}section{background:white;border:1px solid #d7e0d9;border-radius:14px;padding:24px;margin:24px 0}label{display:block;margin:12px 0}select,input,textarea,button{font:inherit;border:1px solid #c6d4ca;border-radius:7px;padding:8px 12px;max-width:100%}input[type=text],textarea{width:100%;box-sizing:border-box}button{background:#2d6550;color:white;cursor:pointer}button.secondary{background:#e6ede7;color:#315c42}article{border-top:1px solid #dce4dd;padding:18px 0}article p{white-space:pre-wrap;overflow-wrap:anywhere}.muted{font-size:13px;color:#617366}.grid{display:flex;gap:20px;flex-wrap:wrap}.notice{white-space:pre-wrap}button{margin-right:8px;margin-top:6px}[hidden]{display:none!important}</style>
|
|
3
|
+
<main><p class="muted">ChatComment / 服务器管理</p><h1>留言与站点设置</h1><a id="back">返回页面</a><p role="status" id="notice" class="notice"></p>
|
|
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
|
+
<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
|
+
<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>
|
|
8
|
+
(() => {'use strict';
|
|
9
|
+
const base=location.pathname.replace(/\/admin\/?$/,'');
|
|
10
|
+
const $=s=>document.querySelector(s), notice=$('#notice');
|
|
11
|
+
const labels={nickname:'昵称',email:'邮箱',website:'网址'};let sites=[],cursor=null;
|
|
12
|
+
$('#back').href=base.replace(/\/comments$/,'/') || '/';
|
|
13
|
+
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;}
|
|
15
|
+
function site(){return sites.find(s=>s.id===$('#site').value);}
|
|
16
|
+
function sitePath(action){return '/api/sites/'+encodeURIComponent(site().id)+'/'+action;}
|
|
17
|
+
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();}
|
|
18
|
+
async function selectSite(){const s=site();$('#site-info').textContent=(s.private?'私有站点 · ':'公开站点 · ')+s.origins.join(', ');$('#settings').elements.anonymous.checked=s.settings.allow_anonymous;$('#settings').elements.moderation.checked=s.settings.moderation;for(const [n,m] of Object.entries(s.settings.fields))$('#settings').elements[n].value=m;$('#page').replaceChildren();for(const p of s.pages){const o=document.createElement('option');o.value=p;o.textContent=p;$('#page').append(o);}await load();}
|
|
19
|
+
async function load(more=false){const data=await api(sitePath('admin')+'?page='+encodeURIComponent($('#page').value)+(more&&cursor?'&before='+encodeURIComponent(cursor):''));if(!more)$('#comments').replaceChildren();for(const c of data.comments){if($('#filter').value!=='all'&&c.status!==$('#filter').value)continue;const a=document.createElement('article'),meta=document.createElement('div'),p=document.createElement('p');meta.textContent=(c.nickname||'匿名')+' · '+new Date(c.created_at).toLocaleString()+' · '+({approved:'已显示',pending:'待审核',deleted:'已删除'}[c.status]);a.append(meta);if(c.email){const email=document.createElement('div');email.className='muted';email.textContent=c.email+'(仅管理可见)';a.append(email);}p.textContent=c.content;a.append(p);for(const [status,label] of [['approved','显示'],['pending','待审核'],['deleted','删除']]){const b=document.createElement('button');b.className='secondary';b.textContent=label;b.disabled=c.status===status;b.onclick=()=>run(async()=>{await api(sitePath('moderate'),{id:c.id,status});await load();notice.textContent='状态已保存。';});a.append(b);}$('#comments').append(a);}cursor=data.next_cursor;$('#more').hidden=!cursor;if(!$('#comments').children.length)$('#comments').textContent='此页暂无符合条件的留言。';}
|
|
20
|
+
async function run(fn){try{await fn();}catch(e){notice.textContent=e.message;}}
|
|
21
|
+
$('#site').onchange=()=>run(selectSite);$('#page').onchange=$('#filter').onchange=$('#refresh').onclick=()=>run(()=>load());$('#more').onclick=()=>run(()=>load(true));
|
|
22
|
+
$('#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
|
+
$('#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
|
+
$('#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());
|
|
26
|
+
})();
|
|
27
|
+
</script></html>
|
|
@@ -0,0 +1,19 @@
|
|
|
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
|
+
<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
|
+
<main><a id="back">← 返回页面</a><p class="brand">ChatComment</p><h1>想法,留在这里。</h1><p class="intro">这里与页面底部共用同一个留言流。页面访问权限由网站管理,匿名只是留言的署名方式。</p><div id="comments"></div></main>
|
|
4
|
+
<script>
|
|
5
|
+
(async () => {
|
|
6
|
+
const base=location.pathname.replace(/\/board\/?$/,'');
|
|
7
|
+
const query=new URLSearchParams(location.search);
|
|
8
|
+
const script=document.createElement('script');
|
|
9
|
+
script.src=base+'/widget.js';script.defer=true;script.dataset.chatcomment='';
|
|
10
|
+
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/';
|
|
12
|
+
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
|
+
} catch (_) { /* Public standalone servers have no native login contract. */ }
|
|
16
|
+
document.getElementById('back').href=base.replace(/\/comments$/,'/')||'/';
|
|
17
|
+
document.body.append(script);
|
|
18
|
+
})();
|
|
19
|
+
</script></html>
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.chatcomment{font:16px/1.65 system-ui,-apple-system,sans-serif;color:#243436;background:#f6f8f6;border:1px solid #d7e0dc;border-radius:18px;padding:clamp(20px,4vw,36px);margin:40px auto 0;max-width:100%;text-align:left;box-sizing:border-box}.chatcomment *{box-sizing:border-box}.chatcomment header{display:flex;align-items:center;justify-content:space-between;gap:16px}.chatcomment h2{font-size:25px;margin:0;color:inherit}.chatcomment .cc-eyebrow{font-size:11px;letter-spacing:.12em;text-transform:uppercase;color:#55716a;margin:0 0 4px}.chatcomment a{color:#326d5e}.chatcomment .cc-admin{font-size:13px}.chatcomment .cc-note,.chatcomment .cc-empty{color:#617570;font-size:13px}.chatcomment label{display:block;font-size:13px;color:#4b615b}.chatcomment label span{font-size:11px;color:#71817c}.chatcomment input,.chatcomment textarea{display:block;width:100%;background:white;color:#253b33;border:1px solid #c6d4cd;border-radius:8px;padding:10px 12px;font:inherit;margin-top:6px;min-width:0}.chatcomment textarea{resize:vertical;min-height:110px;font-size:15px}.chatcomment input:focus,.chatcomment textarea:focus{outline:2px solid #54917d;outline-offset:2px}.chatcomment .cc-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr));gap:14px;margin:20px 0 14px}.chatcomment .cc-actions{display:flex;gap:12px;align-items:center;flex-wrap:wrap;margin:16px 0}.chatcomment button{border:0;background:#275e4f;color:white;padding:10px 18px;border-radius:8px;font:inherit;font-size:14px;cursor:pointer}.chatcomment button:disabled{opacity:.55;cursor:wait}.chatcomment button.cc-refresh,.chatcomment button.cc-more{background:#e0e9e3;color:#345f50}.chatcomment .cc-status{font-size:13px;color:#526d62;overflow-wrap:anywhere}.chatcomment article{border-top:1px solid #d5dfd9;padding:18px 0}.chatcomment article p{white-space:pre-wrap;overflow-wrap:anywhere;margin:10px 0 0}.chatcomment .cc-meta{display:flex;justify-content:space-between;gap:16px;align-items:baseline;font-size:14px}.chatcomment time{font-size:12px;color:#71807a}.chatcomment [hidden]{display:none!important}@media(max-width:480px){.chatcomment .cc-fields{grid-template-columns:1fr}.chatcomment .cc-meta{display:block}.chatcomment time{display:block}.chatcomment header{align-items:flex-start}}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/* ChatComment: plain text, no third-party scripts or local storage. */
|
|
2
|
+
(() => {
|
|
3
|
+
'use strict';
|
|
4
|
+
const source = document.currentScript;
|
|
5
|
+
const current = source ? {...source.dataset} : {};
|
|
6
|
+
async function mount(options) {
|
|
7
|
+
const root = typeof options.target === 'string' ? document.querySelector(options.target) : options.target;
|
|
8
|
+
if (!root) throw new Error('ChatComment container not found');
|
|
9
|
+
const api = options.api.replace(/\/$/, '');
|
|
10
|
+
const site = encodeURIComponent(options.site);
|
|
11
|
+
const page = options.page || location.pathname;
|
|
12
|
+
const link = document.createElement('link');
|
|
13
|
+
link.rel = 'stylesheet'; link.href = `${api}/widget.css`; root.append(link);
|
|
14
|
+
const box = document.createElement('section'); box.className = 'chatcomment'; root.append(box);
|
|
15
|
+
box.innerHTML = `<header><div><p class="cc-eyebrow">ChatComment</p><h2>留下想法</h2></div><a class="cc-admin" hidden>设置与管理</a></header>
|
|
16
|
+
<p class="cc-note">留言保存在服务器,刷新后仍可查看。邮箱仅管理员可见。</p>
|
|
17
|
+
<form><div class="cc-fields"></div><label>留言 <span>必填</span><textarea name="content" required maxlength="5000" rows="4" placeholder="有什么想法,或者值得接着做的事?"></textarea></label>
|
|
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
|
+
<div class="cc-list" aria-live="polite"></div><button type="button" class="cc-more" hidden>更早的留言</button>`;
|
|
20
|
+
// The API base comes from the deployer's script attribute; never from comments.
|
|
21
|
+
box.querySelector('.cc-admin').href = `${api}/admin`;
|
|
22
|
+
const status = box.querySelector('.cc-status');
|
|
23
|
+
const list = box.querySelector('.cc-list');
|
|
24
|
+
const form = box.querySelector('form');
|
|
25
|
+
const submit = form.querySelector('[type=submit]');
|
|
26
|
+
let cursor = null;
|
|
27
|
+
async function request(path, payload) {
|
|
28
|
+
const headers = {'X-ChatComment-Request':'1'};
|
|
29
|
+
if (payload) {
|
|
30
|
+
headers['Content-Type'] = 'application/json';
|
|
31
|
+
if (options.csrfEndpoint) {
|
|
32
|
+
const session = await fetch(options.csrfEndpoint, {credentials:'same-origin'});
|
|
33
|
+
if (!session.ok) throw new Error('请先登录后再留言');
|
|
34
|
+
headers['X-CSRF-Token'] = (await session.json()).csrf_token;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
const response = await fetch(api + path, {method:payload?'POST':'GET',headers,credentials:'same-origin',body:payload?JSON.stringify(payload):undefined});
|
|
38
|
+
if (!response.ok) {
|
|
39
|
+
const error = await response.json().catch(() => ({}));
|
|
40
|
+
throw new Error(error.error || `读取失败(${response.status}),请确认登录状态`);
|
|
41
|
+
}
|
|
42
|
+
return response.json();
|
|
43
|
+
}
|
|
44
|
+
async function config() {
|
|
45
|
+
const data = await request(`/api/sites/${site}/config`);
|
|
46
|
+
box.querySelector('.cc-admin').hidden = data.can_manage !== true;
|
|
47
|
+
const fields = box.querySelector('.cc-fields'); fields.replaceChildren();
|
|
48
|
+
const labels = {nickname:'昵称',email:'邮箱',website:'网址'};
|
|
49
|
+
for (const [name, mode] of Object.entries(data.settings.fields)) {
|
|
50
|
+
if (mode === 'hidden') continue;
|
|
51
|
+
const label = document.createElement('label');
|
|
52
|
+
label.textContent = `${labels[name]}(${mode === 'required' ? '必填' : '可选'})`;
|
|
53
|
+
const input = document.createElement('input');
|
|
54
|
+
input.name = name; input.required = mode === 'required';
|
|
55
|
+
input.type = name === 'email' ? 'email' : name === 'website' ? 'url' : 'text';
|
|
56
|
+
input.maxLength = name === 'nickname' ? 120 : 500;
|
|
57
|
+
label.append(input); fields.append(label);
|
|
58
|
+
}
|
|
59
|
+
box.querySelector('.cc-note').textContent = `${data.settings.allow_anonymous?'无需留言账号,可匿名提交。':'需要已验证的站点身份。'}${data.settings.moderation?'留言经审核后显示。':''}邮箱仅管理员可见。`;
|
|
60
|
+
}
|
|
61
|
+
async function load(more=false) {
|
|
62
|
+
const data = await request(`/api/sites/${site}/comments?page=${encodeURIComponent(page)}${more&&cursor?'&before='+encodeURIComponent(cursor):''}`);
|
|
63
|
+
if (!more) list.replaceChildren();
|
|
64
|
+
for (const item of data.comments) {
|
|
65
|
+
const article = document.createElement('article');
|
|
66
|
+
const heading = document.createElement('div'); heading.className = 'cc-meta';
|
|
67
|
+
const author = document.createElement('strong'); author.textContent = item.nickname || '匿名';
|
|
68
|
+
const time = document.createElement('time'); time.dateTime = item.created_at; time.textContent = new Date(item.created_at).toLocaleString();
|
|
69
|
+
heading.append(author,time);
|
|
70
|
+
const content = document.createElement('p'); content.textContent = item.content;
|
|
71
|
+
article.append(heading,content); list.append(article);
|
|
72
|
+
}
|
|
73
|
+
if (!list.children.length) {const empty=document.createElement('p');empty.className='cc-empty';empty.textContent='还没有留言,欢迎留下第一条。';list.append(empty);}
|
|
74
|
+
cursor = data.next_cursor; box.querySelector('.cc-more').hidden = !cursor;
|
|
75
|
+
}
|
|
76
|
+
form.addEventListener('submit', async event => {
|
|
77
|
+
event.preventDefault(); submit.disabled=true; status.textContent='正在提交…';
|
|
78
|
+
try {
|
|
79
|
+
const result = await request(`/api/sites/${site}/comments`, {page,...Object.fromEntries(new FormData(form))});
|
|
80
|
+
form.elements.content.value='';
|
|
81
|
+
status.textContent=result.status==='pending'?'已提交,等待审核。':'已保存到服务器。';
|
|
82
|
+
await load();
|
|
83
|
+
} catch (error) {status.textContent=error.message;}
|
|
84
|
+
finally {submit.disabled=false;}
|
|
85
|
+
});
|
|
86
|
+
box.querySelector('.cc-refresh').onclick=async()=>{try{await config();await load();status.textContent='已从服务器刷新。';}catch(e){status.textContent=e.message;}};
|
|
87
|
+
box.querySelector('.cc-more').onclick=()=>load(true).catch(e=>{status.textContent=e.message;});
|
|
88
|
+
try {await config();await load();} catch(error){status.textContent=error.message;submit.disabled=true;}
|
|
89
|
+
return {refresh:load};
|
|
90
|
+
}
|
|
91
|
+
window.ChatComment = {mount};
|
|
92
|
+
if (source?.hasAttribute('data-chatcomment')) {
|
|
93
|
+
const start=()=>mount({target:current.target,api:current.api,site:current.site,page:current.page,csrfEndpoint:current.csrfEndpoint});
|
|
94
|
+
if (document.readyState==='loading') document.addEventListener('DOMContentLoaded',start,{once:true}); else start();
|
|
95
|
+
}
|
|
96
|
+
})();
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""SQLite persistence and validation, independent of the web transport."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from contextlib import contextmanager
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
import re
|
|
10
|
+
import sqlite3
|
|
11
|
+
from urllib.parse import urlsplit
|
|
12
|
+
import uuid
|
|
13
|
+
|
|
14
|
+
DEFAULT_SETTINGS = {
|
|
15
|
+
"allow_anonymous": True, "moderation": False,
|
|
16
|
+
"fields": {"nickname": "optional", "email": "optional", "website": "hidden"},
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Invalid(ValueError):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def text(value, name, limit, *, required=False):
|
|
25
|
+
if not isinstance(value, str):
|
|
26
|
+
raise Invalid(f"{name} 必须是文字")
|
|
27
|
+
value = value.strip()
|
|
28
|
+
if len(value) > limit or (required and not value):
|
|
29
|
+
raise Invalid(f"{name} {'不能为空,且' if required else ''}最多 {limit} 字")
|
|
30
|
+
if any(ord(c) < 32 and c not in '\n\r\t' for c in value):
|
|
31
|
+
raise Invalid(f"{name} 含无效字符")
|
|
32
|
+
return value
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def origin(value):
|
|
36
|
+
value = text(value, "来源", 500, required=True)
|
|
37
|
+
p = urlsplit(value)
|
|
38
|
+
try:
|
|
39
|
+
port = p.port
|
|
40
|
+
except ValueError as exc:
|
|
41
|
+
raise Invalid("来源端口无效") from exc
|
|
42
|
+
if p.scheme not in {"http", "https"} or not p.hostname or p.username or p.password or p.query or p.fragment or p.path not in {"", "/"}:
|
|
43
|
+
raise Invalid("来源需为完整 HTTP(S) origin,不含路径")
|
|
44
|
+
host = p.hostname.lower()
|
|
45
|
+
if ':' in host:
|
|
46
|
+
host = '[' + host + ']'
|
|
47
|
+
return f"{p.scheme.lower()}://{host}" + (f":{port}" if port and port != {"http":80,"https":443}[p.scheme] else "")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def page(value):
|
|
51
|
+
value = text(value, "页面路径", 500, required=True)
|
|
52
|
+
p = urlsplit(value)
|
|
53
|
+
if not value.startswith('/') or value.startswith('//') or p.query or p.fragment or '\\' in value or '%' in value or any(s in {'.', '..'} for s in value.split('/')):
|
|
54
|
+
raise Invalid("页面必须是规范的绝对路径,不含查询、锚点或转义")
|
|
55
|
+
return value
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def settings(value):
|
|
59
|
+
if not isinstance(value, dict) or set(value) != {"allow_anonymous", "moderation", "fields"}:
|
|
60
|
+
raise Invalid("设置必须包含匿名、审核和字段配置")
|
|
61
|
+
if type(value["allow_anonymous"]) is not bool or type(value["moderation"]) is not bool:
|
|
62
|
+
raise Invalid("匿名和审核设置必须是布尔值")
|
|
63
|
+
fields = value['fields']
|
|
64
|
+
if not isinstance(fields, dict) or set(fields) != {"nickname", "email", "website"} or any(v not in {"required", "optional", "hidden"} for v in fields.values()):
|
|
65
|
+
raise Invalid("字段状态必须为 required / optional / hidden")
|
|
66
|
+
return value
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class Store:
|
|
70
|
+
def __init__(self, path):
|
|
71
|
+
self.path = Path(path).expanduser()
|
|
72
|
+
self.path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
73
|
+
try:
|
|
74
|
+
fd = os.open(self.path, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
75
|
+
except FileExistsError:
|
|
76
|
+
pass
|
|
77
|
+
else:
|
|
78
|
+
os.close(fd)
|
|
79
|
+
self.path.chmod(0o600)
|
|
80
|
+
with self.connect() as db:
|
|
81
|
+
db.executescript('''
|
|
82
|
+
PRAGMA journal_mode=WAL;
|
|
83
|
+
CREATE TABLE IF NOT EXISTS sites (
|
|
84
|
+
id TEXT PRIMARY KEY, title TEXT NOT NULL, origins TEXT NOT NULL,
|
|
85
|
+
pages TEXT NOT NULL, private INTEGER NOT NULL, settings TEXT NOT NULL);
|
|
86
|
+
CREATE TABLE IF NOT EXISTS comments (
|
|
87
|
+
id TEXT PRIMARY KEY, site_id TEXT NOT NULL REFERENCES sites(id),
|
|
88
|
+
page_key TEXT NOT NULL, nickname TEXT NOT NULL, email TEXT NOT NULL,
|
|
89
|
+
website TEXT NOT NULL, content TEXT NOT NULL, status TEXT NOT NULL,
|
|
90
|
+
created_at TEXT NOT NULL, authenticated INTEGER NOT NULL);
|
|
91
|
+
CREATE INDEX IF NOT EXISTS comments_page ON comments(site_id,page_key,created_at);
|
|
92
|
+
''')
|
|
93
|
+
|
|
94
|
+
@contextmanager
|
|
95
|
+
def connect(self):
|
|
96
|
+
db = sqlite3.connect(self.path, timeout=15)
|
|
97
|
+
db.row_factory = sqlite3.Row
|
|
98
|
+
db.execute('PRAGMA foreign_keys=ON')
|
|
99
|
+
try:
|
|
100
|
+
with db:
|
|
101
|
+
yield db
|
|
102
|
+
finally:
|
|
103
|
+
db.close()
|
|
104
|
+
|
|
105
|
+
def register(self, site_id, *, title, origins, pages, private=True, config=None):
|
|
106
|
+
if not isinstance(site_id, str) or not re.fullmatch(r'[a-z0-9][a-z0-9_-]{0,63}', site_id):
|
|
107
|
+
raise Invalid("站点 ID 需为 1–64 位小写字母、数字、下划线或连字符")
|
|
108
|
+
title = text(title, "站点名称", 120, required=True)
|
|
109
|
+
if type(private) is not bool:
|
|
110
|
+
raise Invalid("private 必须是布尔值")
|
|
111
|
+
if not isinstance(origins, list) or not 1 <= len(origins) <= 30 or not isinstance(pages, list) or not 1 <= len(pages) <= 500:
|
|
112
|
+
raise Invalid("需提供 1–30 个来源和 1–500 个允许页面")
|
|
113
|
+
origins, pages = sorted(set(map(origin, origins))), sorted(set(map(page, pages)))
|
|
114
|
+
config = settings(config if config is not None else json.loads(json.dumps(DEFAULT_SETTINGS)))
|
|
115
|
+
try:
|
|
116
|
+
with self.connect() as db:
|
|
117
|
+
db.execute('INSERT INTO sites VALUES (?,?,?,?,?,?)', (site_id, title, json.dumps(origins), json.dumps(pages), int(private), json.dumps(config)))
|
|
118
|
+
except sqlite3.IntegrityError as exc:
|
|
119
|
+
raise Invalid("站点 ID 已存在,请修改设置而非重复注册") from exc
|
|
120
|
+
return self.site(site_id)
|
|
121
|
+
|
|
122
|
+
def site(self, site_id):
|
|
123
|
+
with self.connect() as db:
|
|
124
|
+
row = db.execute('SELECT * FROM sites WHERE id=?', (site_id,)).fetchone()
|
|
125
|
+
if not row:
|
|
126
|
+
raise KeyError("站点不存在")
|
|
127
|
+
result = dict(row)
|
|
128
|
+
for k in ['origins', 'pages', 'settings']:
|
|
129
|
+
result[k] = json.loads(result[k])
|
|
130
|
+
result['private'] = bool(result['private'])
|
|
131
|
+
return result
|
|
132
|
+
|
|
133
|
+
def sites(self):
|
|
134
|
+
with self.connect() as db:
|
|
135
|
+
ids = [r[0] for r in db.execute('SELECT id FROM sites ORDER BY id')]
|
|
136
|
+
return [self.site(i) for i in ids]
|
|
137
|
+
|
|
138
|
+
def configure(self, site_id, config):
|
|
139
|
+
self.site(site_id)
|
|
140
|
+
config = settings(config)
|
|
141
|
+
with self.connect() as db:
|
|
142
|
+
db.execute('UPDATE sites SET settings=? WHERE id=?', (json.dumps(config), site_id))
|
|
143
|
+
return self.site(site_id)
|
|
144
|
+
|
|
145
|
+
def check_page(self, site_id, page_key):
|
|
146
|
+
site = self.site(site_id)
|
|
147
|
+
page_key = page(page_key)
|
|
148
|
+
if page_key not in site['pages']:
|
|
149
|
+
raise KeyError("页面未注册")
|
|
150
|
+
return site, page_key
|
|
151
|
+
|
|
152
|
+
def create(self, site_id, payload, *, authenticated=False):
|
|
153
|
+
if not isinstance(payload, dict):
|
|
154
|
+
raise Invalid("留言需为 JSON 对象")
|
|
155
|
+
site, page_key = self.check_page(site_id, payload.get('page', ''))
|
|
156
|
+
config = site['settings']
|
|
157
|
+
if not config['allow_anonymous'] and not authenticated:
|
|
158
|
+
raise PermissionError("此站点需要已验证身份才能留言")
|
|
159
|
+
values = {}
|
|
160
|
+
labels = {'nickname':'昵称', 'email':'邮箱', 'website':'网址'}
|
|
161
|
+
for field, mode in config['fields'].items():
|
|
162
|
+
value = text(payload.get(field, ''), labels[field], 120 if field == 'nickname' else 500)
|
|
163
|
+
if mode == 'hidden':
|
|
164
|
+
value = ''
|
|
165
|
+
if mode == 'required' and not value:
|
|
166
|
+
raise Invalid(f"{labels[field]}为必填项")
|
|
167
|
+
values[field] = value
|
|
168
|
+
if values['email'] and not re.fullmatch(r'[^\s@]+@[^\s@]+\.[^\s@]+', values['email']):
|
|
169
|
+
raise Invalid("邮箱格式不正确")
|
|
170
|
+
if values['website']:
|
|
171
|
+
p = urlsplit(values['website'])
|
|
172
|
+
if p.scheme not in {'http','https'} or not p.hostname or p.username or p.password:
|
|
173
|
+
raise Invalid("网址必须是 HTTP(S) 链接")
|
|
174
|
+
comment = dict(id=uuid.uuid4().hex, site_id=site_id, page_key=page_key,
|
|
175
|
+
**values, content=text(payload.get('content',''), '留言', 5000, required=True),
|
|
176
|
+
status='pending' if config['moderation'] else 'approved',
|
|
177
|
+
created_at=datetime.now(timezone.utc).isoformat(), authenticated=int(authenticated))
|
|
178
|
+
with self.connect() as db:
|
|
179
|
+
db.execute('INSERT INTO comments VALUES (:id,:site_id,:page_key,:nickname,:email,:website,:content,:status,:created_at,:authenticated)', comment)
|
|
180
|
+
return self.public(comment)
|
|
181
|
+
|
|
182
|
+
@staticmethod
|
|
183
|
+
def public(row):
|
|
184
|
+
return {k: row[k] for k in ['id','site_id','page_key','nickname','website','content','status','created_at']}
|
|
185
|
+
|
|
186
|
+
def comments(self, site_id, page_key, *, admin=False, before=None, limit=50):
|
|
187
|
+
self.check_page(site_id, page_key)
|
|
188
|
+
limit = max(1, min(int(limit), 100))
|
|
189
|
+
sql = 'SELECT * FROM comments WHERE site_id=? AND page_key=?'
|
|
190
|
+
args = [site_id, page_key]
|
|
191
|
+
if not admin:
|
|
192
|
+
sql += " AND status='approved'"
|
|
193
|
+
if before:
|
|
194
|
+
sql += ' AND (created_at || id) < ?'
|
|
195
|
+
args.append(before)
|
|
196
|
+
sql += ' ORDER BY created_at DESC, id DESC LIMIT ?'
|
|
197
|
+
args.append(limit + 1)
|
|
198
|
+
with self.connect() as db:
|
|
199
|
+
rows = [dict(r) for r in db.execute(sql, args)]
|
|
200
|
+
has_more = len(rows) > limit
|
|
201
|
+
rows = rows[:limit]
|
|
202
|
+
cursor = rows[-1]['created_at'] + rows[-1]['id'] if has_more else None
|
|
203
|
+
return {'comments': rows if admin else [self.public(r) for r in rows], 'next_cursor': cursor}
|
|
204
|
+
|
|
205
|
+
def moderate(self, site_id, comment_id, status):
|
|
206
|
+
if status not in {'approved','pending','deleted'}:
|
|
207
|
+
raise Invalid("状态需为 approved / pending / deleted")
|
|
208
|
+
with self.connect() as db:
|
|
209
|
+
result = db.execute('UPDATE comments SET status=? WHERE id=? AND site_id=?', (status, comment_id, site_id))
|
|
210
|
+
if result.rowcount != 1:
|
|
211
|
+
raise KeyError("留言不存在")
|
|
212
|
+
return {'id':comment_id, 'status':status}
|
|
213
|
+
|
|
214
|
+
def backup(self, destination):
|
|
215
|
+
destination = Path(destination).expanduser()
|
|
216
|
+
if destination.exists():
|
|
217
|
+
raise Invalid("备份目标已存在")
|
|
218
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
219
|
+
try:
|
|
220
|
+
fd = os.open(destination, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
|
221
|
+
except FileExistsError as exc:
|
|
222
|
+
raise Invalid("备份目标已存在") from exc
|
|
223
|
+
os.close(fd)
|
|
224
|
+
with self.connect() as source, sqlite3.connect(destination) as target:
|
|
225
|
+
source.backup(target)
|
|
226
|
+
return destination
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from click.testing import CliRunner
|
|
2
|
+
from chatcomment.cli import main
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def test_installed_surface_and_registration(tmp_path):
|
|
6
|
+
r=CliRunner()
|
|
7
|
+
for option in ['--version','--tree','--tree-brief']:
|
|
8
|
+
result=r.invoke(main,[option]);assert result.exit_code==0,result.output
|
|
9
|
+
assert 'chatcomment' in result.output.lower()
|
|
10
|
+
args=['--db',str(tmp_path/'db.sqlite3')]
|
|
11
|
+
result=r.invoke(main,args+['site','register','demo','--origin','https://example.com','--page','/post/'])
|
|
12
|
+
assert result.exit_code==0,result.output
|
|
13
|
+
assert '"private": true' in result.output
|
|
14
|
+
assert r.invoke(main,args+['site','list']).exit_code==0
|
|
15
|
+
result=r.invoke(main,args+['backup',str(tmp_path/'backup.sqlite3')])
|
|
16
|
+
assert result.exit_code==0,result.output
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import threading
|
|
3
|
+
import urllib.error
|
|
4
|
+
import urllib.request
|
|
5
|
+
from http.server import ThreadingHTTPServer
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from chatcomment import Store
|
|
10
|
+
from chatcomment.server import make_handler
|
|
11
|
+
from chatcomment.store import Invalid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@pytest.fixture
|
|
15
|
+
def store(tmp_path):
|
|
16
|
+
s = Store(tmp_path / 'comments.sqlite3')
|
|
17
|
+
s.register('first',title='First',origins=['https://first.example'],pages=['/one/','/two/'],private=False)
|
|
18
|
+
s.register('second',title='Second',origins=['https://second.example'],pages=['/one/'],private=True)
|
|
19
|
+
return s
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_isolation_privacy_persistence_backup(store,tmp_path):
|
|
23
|
+
item = store.create('first',{'page':'/one/','content':'plain <script>alert(1)</script>','email':'private@example.com'})
|
|
24
|
+
assert 'email' not in item
|
|
25
|
+
assert store.comments('first','/two/')['comments'] == []
|
|
26
|
+
assert store.comments('second','/one/')['comments'] == []
|
|
27
|
+
reloaded = Store(store.path)
|
|
28
|
+
assert reloaded.comments('first','/one/')['comments'][0]['id'] == item['id']
|
|
29
|
+
assert reloaded.comments('first','/one/',admin=True)['comments'][0]['email']=='private@example.com'
|
|
30
|
+
backup = store.backup(tmp_path/'backup.sqlite3')
|
|
31
|
+
assert Store(backup).comments('first','/one/')['comments'][0]['id']==item['id']
|
|
32
|
+
with pytest.raises(Invalid): store.backup(backup)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_fields_and_anonymous_enforced(store):
|
|
36
|
+
config=store.site('first')['settings']
|
|
37
|
+
config['fields']['email']='required'
|
|
38
|
+
store.configure('first',config)
|
|
39
|
+
with pytest.raises(Invalid,match='邮箱为必填'):store.create('first',{'page':'/one/','content':'hello'})
|
|
40
|
+
config['allow_anonymous']=False
|
|
41
|
+
store.configure('first',config)
|
|
42
|
+
with pytest.raises(PermissionError):store.create('first',{'page':'/one/','content':'hello','email':'a@b.cn'})
|
|
43
|
+
item=store.create('first',{'page':'/one/','content':'hello','email':'a@b.cn','website':'https://discard.example'},authenticated=True)
|
|
44
|
+
assert item['website']==''
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_moderation_and_pagination(store):
|
|
48
|
+
config=store.site('first')['settings'];config['moderation']=True;store.configure('first',config)
|
|
49
|
+
ids=[store.create('first',{'page':'/one/','content':str(i)})['id'] for i in range(4)]
|
|
50
|
+
assert store.comments('first','/one/')['comments']==[]
|
|
51
|
+
for i in ids:store.moderate('first',i,'approved')
|
|
52
|
+
page1=store.comments('first','/one/',limit=2)
|
|
53
|
+
page2=store.comments('first','/one/',limit=2,before=page1['next_cursor'])
|
|
54
|
+
assert len({x['id'] for x in page1['comments']+page2['comments']})==4
|
|
55
|
+
assert page2['next_cursor'] is None
|
|
56
|
+
with pytest.raises(KeyError):store.moderate('second',ids[0],'deleted')
|
|
57
|
+
store.moderate('first',ids[0],'deleted')
|
|
58
|
+
assert len(store.comments('first','/one/')['comments'])==3
|
|
59
|
+
store.moderate('first',ids[0],'approved')
|
|
60
|
+
assert len(store.comments('first','/one/')['comments'])==4
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.mark.parametrize('payload',[{}, {'page':'/missing/','content':'x'}, {'page':'/one/?x=1','content':'x'}, {'page':'/one/','content':''}, {'page':'/one/','content':'x'*5001}, {'page':'/one/','content':'x','email':'bad'}, {'page':'/one/','content':None}])
|
|
64
|
+
def test_invalid_comments(store,payload):
|
|
65
|
+
with pytest.raises((ValueError,KeyError)):store.create('first',payload)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@pytest.fixture
|
|
69
|
+
def http(store):
|
|
70
|
+
token='t'*48
|
|
71
|
+
server=ThreadingHTTPServer(('127.0.0.1',0),make_handler(store,token))
|
|
72
|
+
worker=threading.Thread(target=server.serve_forever,daemon=True);worker.start()
|
|
73
|
+
def request(path,body=None,*,trusted=False,identity=False,origin=None,extra=None):
|
|
74
|
+
headers=extra or {}
|
|
75
|
+
if trusted:headers['Authorization']='Bearer '+token
|
|
76
|
+
if identity:headers['X-ChatComment-Identity']='verified'
|
|
77
|
+
if origin:headers['Origin']=origin
|
|
78
|
+
if body is not None:headers['Content-Type']='application/json'
|
|
79
|
+
req=urllib.request.Request(f'http://127.0.0.1:{server.server_port}'+path,data=json.dumps(body).encode() if body is not None else None,headers=headers)
|
|
80
|
+
try:r=urllib.request.urlopen(req,timeout=5)
|
|
81
|
+
except urllib.error.HTTPError as e:r=e
|
|
82
|
+
with r:
|
|
83
|
+
data=r.read()
|
|
84
|
+
return r.status,r.headers,json.loads(data) if r.headers.get_content_type()=='application/json' else data
|
|
85
|
+
yield request
|
|
86
|
+
server.shutdown();server.server_close();worker.join()
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_http_private_admin_origins_and_no_header_spoof(http):
|
|
90
|
+
assert http('/health')[0]==200
|
|
91
|
+
assert http('/widget.js')[0]==200
|
|
92
|
+
assert http('/admin')[0]==403
|
|
93
|
+
assert http('/admin',trusted=True)[0]==200
|
|
94
|
+
assert http('/board')[0]==200
|
|
95
|
+
assert http('/admin',trusted=True,extra={'X-ChatComment-Role':'reader'})[0]==403
|
|
96
|
+
assert http('/api/admin/sites',trusted=True,extra={'X-ChatComment-Role':'reader'})[0]==403
|
|
97
|
+
assert http('/api/sites/second/config',trusted=True,extra={'X-ChatComment-Role':'reader'})[2]['can_manage'] is False
|
|
98
|
+
assert http('/api/sites/first/config')[2]['can_manage'] is False
|
|
99
|
+
assert http('/api/sites/first/config',trusted=True)[2]['can_manage'] is True
|
|
100
|
+
assert http('/api/sites/second/settings',{},trusted=True,extra={'X-ChatComment-Role':'reader'})[0]==403
|
|
101
|
+
assert http('/api/admin/sites')[0]==403
|
|
102
|
+
assert http('/api/sites/second/config',identity=True)[0]==403
|
|
103
|
+
assert http('/api/sites/second/config',trusted=True)[0]==200
|
|
104
|
+
assert http('/api/sites/first/config',origin='https://evil.example')[0]==403
|
|
105
|
+
code,headers,_=http('/api/sites/first/config',origin='https://first.example')
|
|
106
|
+
assert code==200 and headers['Access-Control-Allow-Origin']=='https://first.example'
|
|
107
|
+
body={'page':'/one/','content':'real http','email':'secret@example.com'}
|
|
108
|
+
assert http('/api/sites/first/comments',body,origin='https://first.example')[0]==403
|
|
109
|
+
assert http('/api/sites/first/comments',body,origin='https://evil.example',extra={'X-ChatComment-Request':'1'})[0]==403
|
|
110
|
+
assert http('/api/sites/first/comments',body,origin='https://first.example',extra={'X-ChatComment-Request':'1'})[0]==201
|
|
111
|
+
assert 'email' not in http('/api/sites/first/comments?page=/one/')[2]['comments'][0]
|
|
112
|
+
assert http('/api/sites/first/comments?page=/two/')[2]['comments']==[]
|
|
113
|
+
assert http('/api/sites/first/settings',{},origin='https://first.example')[0]==403
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_http_required_fields_auth_and_rate_limit(store,http):
|
|
117
|
+
config=store.site('first')['settings'];config['allow_anonymous']=False;store.configure('first',config)
|
|
118
|
+
body={'page':'/one/','content':'test'}
|
|
119
|
+
assert http('/api/sites/first/comments',body,trusted=True)[0]==403
|
|
120
|
+
assert http('/api/sites/first/comments',body,trusted=True,identity=True)[0]==201
|
|
121
|
+
config['allow_anonymous']=True;config['fields']['nickname']='required';store.configure('first',config)
|
|
122
|
+
assert http('/api/sites/first/comments',body,trusted=True)[0]==400
|
|
123
|
+
for _ in range(10):
|
|
124
|
+
http('/api/sites/first/comments',{**body,'nickname':'tester'},origin='https://first.example',extra={'X-ChatComment-Request':'1'})
|
|
125
|
+
assert http('/api/sites/first/comments',body,origin='https://first.example',extra={'X-ChatComment-Request':'1'})[0]==429
|