ChatComment 0.2.0__tar.gz → 0.2.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {chatcomment-0.2.0 → chatcomment-0.2.1}/CHANGELOG.md +5 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/PKG-INFO +1 -1
- {chatcomment-0.2.0 → chatcomment-0.2.1}/docs/platform.md +7 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/pyproject.toml +1 -1
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/PKG-INFO +1 -1
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/SOURCES.txt +3 -1
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/__init__.py +3 -2
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/cli.py +7 -3
- chatcomment-0.2.1/src/chatcomment/validation.py +98 -0
- chatcomment-0.2.1/tests/code-tests/test_validation.py +89 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/AGENTS.md +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/LICENSE +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/MANIFEST.in +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/README.md +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/docs/api.md +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/docs/deployment.md +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/setup.cfg +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/dependency_links.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/entry_points.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/requires.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/top_level.txt +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/access.py +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/server.py +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/admin.html +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/board.html +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/login.html +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/widget.css +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/widget.js +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/store.py +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/cli-tests/test_cli.py +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/code-tests/test_access.py +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/code-tests/test_chatlogin_adapter.py +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/code-tests/test_comments.py +0 -0
- {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/code-tests/test_platform_http.py +0 -0
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.1
|
|
4
|
+
|
|
5
|
+
- `--require-existing-db` 在 Store 初始化之前验证既有 SQLite 完整性、必要表/字段和可读性,拒绝空文件、损坏或错误 schema。
|
|
6
|
+
- 只读源文件并在私有稳定快照上校验,保留活跃 WAL 支持,不在挂载目录创建 SQLite 辅助文件;允许完整 schema 的零留言数据库。
|
|
7
|
+
|
|
3
8
|
## 0.2.0
|
|
4
9
|
|
|
5
10
|
- 平台中立的 `AccessContext` / `SiteAccess` API,复用 ChatLogin 主体并显式检查每站 read/comment/manage/owner;管理 grant 按 session 和站点区分。
|
|
@@ -21,6 +21,13 @@ on the direct native application domain, require a signed context. `/health`
|
|
|
21
21
|
is the only unsigned route. This mode explicitly rejects legacy token mode;
|
|
22
22
|
browser `Role`, nickname, email and Bearer headers do not grant access.
|
|
23
23
|
|
|
24
|
+
`--require-existing-db` validates the existing SQLite before `Store` can initialize
|
|
25
|
+
anything: header, integrity, required tables/columns and readable schema. Empty,
|
|
26
|
+
corrupt, unrelated or incompatible files fail closed without source changes.
|
|
27
|
+
It reads a stable private snapshot including WAL rather than creating sidecars
|
|
28
|
+
beside the mounted source; valid schemas with zero comments are accepted.
|
|
29
|
+
The same preflight is available as `validate_existing_database(path)`.
|
|
30
|
+
|
|
24
31
|
The legacy `--token-file` mode remains for existing deployments during staged
|
|
25
32
|
migration. It must not be used as a multi-user platform authorization shortcut.
|
|
26
33
|
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "ChatComment"
|
|
7
|
-
version = "0.2.
|
|
7
|
+
version = "0.2.1"
|
|
8
8
|
description = "Embeddable comments with a Python server, SQLite storage and a small JavaScript widget."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -18,6 +18,7 @@ src/chatcomment/access.py
|
|
|
18
18
|
src/chatcomment/cli.py
|
|
19
19
|
src/chatcomment/server.py
|
|
20
20
|
src/chatcomment/store.py
|
|
21
|
+
src/chatcomment/validation.py
|
|
21
22
|
src/chatcomment/static/admin.html
|
|
22
23
|
src/chatcomment/static/board.html
|
|
23
24
|
src/chatcomment/static/login.html
|
|
@@ -27,4 +28,5 @@ tests/cli-tests/test_cli.py
|
|
|
27
28
|
tests/code-tests/test_access.py
|
|
28
29
|
tests/code-tests/test_chatlogin_adapter.py
|
|
29
30
|
tests/code-tests/test_comments.py
|
|
30
|
-
tests/code-tests/test_platform_http.py
|
|
31
|
+
tests/code-tests/test_platform_http.py
|
|
32
|
+
tests/code-tests/test_validation.py
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
"""Reusable comments storage and HTTP service."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.1"
|
|
3
3
|
|
|
4
4
|
from .store import Store
|
|
5
5
|
from .access import AccessContext,SiteAccess,sign_context,verify_context
|
|
6
|
+
from .validation import validate_existing_database
|
|
6
7
|
|
|
7
|
-
__all__ = ["Store", "AccessContext", "SiteAccess", "sign_context", "verify_context", "__version__"]
|
|
8
|
+
__all__ = ["Store", "AccessContext", "SiteAccess", "sign_context", "verify_context", "validate_existing_database", "__version__"]
|
|
@@ -7,6 +7,7 @@ import click
|
|
|
7
7
|
from chatstyle import add_tree_option
|
|
8
8
|
|
|
9
9
|
from . import Store, __version__
|
|
10
|
+
from .validation import validate_existing_database
|
|
10
11
|
|
|
11
12
|
|
|
12
13
|
@click.group(name='chatcomment')
|
|
@@ -29,7 +30,7 @@ def main(ctx, db):
|
|
|
29
30
|
@click.option('--session-endpoint', default='/api/session',show_default=True)
|
|
30
31
|
@click.option('--reauth-endpoint', default=None,help='Platform same-origin management reauthentication endpoint.')
|
|
31
32
|
@click.option('--return-path', default='/',show_default=True)
|
|
32
|
-
@click.option('--require-existing-db',is_flag=True,help='
|
|
33
|
+
@click.option('--require-existing-db',is_flag=True,help='Validate a readable existing ChatComment SQLite database before initialization.')
|
|
33
34
|
@click.pass_context
|
|
34
35
|
def serve(ctx, host, port, token_file,gateway_key_file,gateway_key_env,session_endpoint,reauth_endpoint,return_path,require_existing_db):
|
|
35
36
|
"""Serve API, widget assets and the gateway-protected admin page."""
|
|
@@ -40,8 +41,11 @@ def serve(ctx, host, port, token_file,gateway_key_file,gateway_key_env,session_e
|
|
|
40
41
|
key=gateway_key_file.read_text().strip() if gateway_key_file else os.environ.get(gateway_key_env,'') if gateway_key_env else None
|
|
41
42
|
if len(token if token is not None else key or '')<32:
|
|
42
43
|
raise click.ClickException('Server credential must contain at least 32 characters.')
|
|
43
|
-
if require_existing_db
|
|
44
|
-
|
|
44
|
+
if require_existing_db:
|
|
45
|
+
try:
|
|
46
|
+
validate_existing_database(ctx.obj['db'])
|
|
47
|
+
except ValueError as exc:
|
|
48
|
+
raise click.ClickException(str(exc)) from exc
|
|
45
49
|
run(Store(ctx.obj['db']), token=token,gateway_key=key, host=host, port=port,
|
|
46
50
|
session_endpoint=session_endpoint,reauth_endpoint=reauth_endpoint,return_path=return_path)
|
|
47
51
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Read-only preflight for an existing persistent ChatComment database.
|
|
2
|
+
|
|
3
|
+
SQLite mode=ro may still create WAL sidecars, and immutable=1 ignores a live
|
|
4
|
+
WAL. Validate a stable private copy of the main file and journals instead; never
|
|
5
|
+
open the source with SQLite, change its permissions or initialize its schema.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from contextlib import closing
|
|
10
|
+
import os
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
import shutil
|
|
13
|
+
import sqlite3
|
|
14
|
+
import stat
|
|
15
|
+
import tempfile
|
|
16
|
+
|
|
17
|
+
from .store import Invalid
|
|
18
|
+
|
|
19
|
+
_COLUMNS = {
|
|
20
|
+
'sites': {'id':'TEXT','title':'TEXT','origins':'TEXT','pages':'TEXT','private':'INTEGER','settings':'TEXT'},
|
|
21
|
+
'comments': {'id':'TEXT','site_id':'TEXT','page_key':'TEXT','nickname':'TEXT','email':'TEXT',
|
|
22
|
+
'website':'TEXT','content':'TEXT','status':'TEXT','created_at':'TEXT','authenticated':'INTEGER'},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _files_state(path):
|
|
27
|
+
state={}
|
|
28
|
+
for suffix in ['', '-wal', '-journal']:
|
|
29
|
+
candidate=Path(str(path)+suffix)
|
|
30
|
+
try:
|
|
31
|
+
info=candidate.stat()
|
|
32
|
+
except FileNotFoundError:
|
|
33
|
+
state[suffix]=None
|
|
34
|
+
continue
|
|
35
|
+
if not stat.S_ISREG(info.st_mode):
|
|
36
|
+
raise Invalid('Persistent database validation failed: database and journals must be regular files.')
|
|
37
|
+
state[suffix]=(info.st_dev,info.st_ino,info.st_size,info.st_mtime_ns,info.st_ctime_ns)
|
|
38
|
+
return state
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _validate_snapshot(path):
|
|
42
|
+
with closing(sqlite3.connect(path.as_uri()+'?mode=ro',uri=True,timeout=5)) as db:
|
|
43
|
+
db.execute('PRAGMA query_only=ON')
|
|
44
|
+
if db.execute('PRAGMA quick_check').fetchall()!=[('ok',)]:
|
|
45
|
+
raise Invalid('Persistent database validation failed: SQLite integrity check failed.')
|
|
46
|
+
tables={row[0] for row in db.execute("SELECT name FROM sqlite_master WHERE type='table'")}
|
|
47
|
+
for table,required in _COLUMNS.items():
|
|
48
|
+
if table not in tables:
|
|
49
|
+
raise Invalid(f'Persistent database validation failed: missing ChatComment table {table}.')
|
|
50
|
+
columns={row[1]:row for row in db.execute(f'PRAGMA table_info({table})')}
|
|
51
|
+
if ([name for name,row in columns.items() if row[5]]!=['id'] or
|
|
52
|
+
any(name not in columns or columns[name][2].strip().upper()!=kind or
|
|
53
|
+
(name!='id' and columns[name][3]!=1) for name,kind in required.items())):
|
|
54
|
+
raise Invalid(f'Persistent database validation failed: incompatible ChatComment schema for {table}.')
|
|
55
|
+
db.execute('SELECT '+','.join(required)+' FROM '+table+' LIMIT 1').fetchall()
|
|
56
|
+
foreign_keys={(row[2],row[3],row[4]) for row in db.execute('PRAGMA foreign_key_list(comments)')}
|
|
57
|
+
if ('sites','site_id','id') not in foreign_keys:
|
|
58
|
+
raise Invalid('Persistent database validation failed: missing comment site relationship.')
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def validate_existing_database(value):
|
|
62
|
+
"""Validate a readable, structurally intact existing database without writes.
|
|
63
|
+
|
|
64
|
+
Empty files, unrelated SQLite databases, corrupt/incomplete schema and
|
|
65
|
+
unreadable files are refused before Store initialization. A valid database
|
|
66
|
+
may contain zero comments or even zero sites. Normal WAL writes may require
|
|
67
|
+
a retry to obtain a stable snapshot; persistent changes fail closed.
|
|
68
|
+
"""
|
|
69
|
+
try:
|
|
70
|
+
path=Path(value).expanduser().resolve()
|
|
71
|
+
if not path.is_file():
|
|
72
|
+
raise Invalid('Persistent database mount is missing; refusing to create an empty database.')
|
|
73
|
+
with path.open('rb') as source:
|
|
74
|
+
if source.read(16)!=b'SQLite format 3\x00':
|
|
75
|
+
raise Invalid('Persistent database validation failed: empty or invalid SQLite file.')
|
|
76
|
+
for _ in range(3):
|
|
77
|
+
before=_files_state(path)
|
|
78
|
+
if before[''] is None:
|
|
79
|
+
continue
|
|
80
|
+
with tempfile.TemporaryDirectory(prefix='chatcomment-validate-') as directory:
|
|
81
|
+
snapshot=Path(directory)/'database.sqlite3'
|
|
82
|
+
try:
|
|
83
|
+
for suffix,info in before.items():
|
|
84
|
+
if info is None:
|
|
85
|
+
continue
|
|
86
|
+
target=Path(str(snapshot)+suffix)
|
|
87
|
+
descriptor=os.open(target,os.O_CREAT|os.O_EXCL|os.O_WRONLY,0o600)
|
|
88
|
+
with os.fdopen(descriptor,'wb') as destination,Path(str(path)+suffix).open('rb') as source:
|
|
89
|
+
shutil.copyfileobj(source,destination)
|
|
90
|
+
if _files_state(path)!=before:
|
|
91
|
+
continue
|
|
92
|
+
_validate_snapshot(snapshot)
|
|
93
|
+
return path
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
continue
|
|
96
|
+
raise Invalid('Persistent database validation failed: database changed during the read-only check; retry startup.')
|
|
97
|
+
except (OSError,sqlite3.Error) as exc:
|
|
98
|
+
raise Invalid('Persistent database validation failed: database or schema is unreadable or corrupt.') from exc
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import sqlite3
|
|
4
|
+
from unittest.mock import patch
|
|
5
|
+
|
|
6
|
+
from click.testing import CliRunner
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from chatcomment import Store,validate_existing_database
|
|
10
|
+
from chatcomment.cli import main
|
|
11
|
+
from chatcomment.store import Invalid
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def source_state(directory):
|
|
15
|
+
return {p.name:(p.read_bytes(),p.stat().st_mtime_ns,p.stat().st_mode) for p in directory.iterdir() if p.is_file()}
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@pytest.mark.parametrize('kind',['empty','garbage','corrupt','unrelated','missing-table','bad-columns','views'])
|
|
19
|
+
def test_invalid_existing_database_never_initializes_or_writes(tmp_path,kind):
|
|
20
|
+
path=tmp_path/'comments.sqlite3'
|
|
21
|
+
if kind=='empty':path.touch()
|
|
22
|
+
elif kind=='garbage':path.write_bytes(b'not a sqlite database')
|
|
23
|
+
elif kind=='corrupt':
|
|
24
|
+
Store(path)
|
|
25
|
+
path.write_bytes(path.read_bytes()[:100])
|
|
26
|
+
else:
|
|
27
|
+
with sqlite3.connect(path) as db:
|
|
28
|
+
if kind=='unrelated':db.execute('CREATE TABLE unrelated(id INTEGER)')
|
|
29
|
+
elif kind=='missing-table':db.execute('CREATE TABLE sites(id TEXT PRIMARY KEY)')
|
|
30
|
+
elif kind=='bad-columns':
|
|
31
|
+
db.execute('CREATE TABLE sites(id INTEGER,title TEXT)')
|
|
32
|
+
db.execute('CREATE TABLE comments(id TEXT)')
|
|
33
|
+
else:
|
|
34
|
+
db.execute('CREATE TABLE data(id TEXT)')
|
|
35
|
+
db.execute('CREATE VIEW sites AS SELECT * FROM data')
|
|
36
|
+
db.execute('CREATE VIEW comments AS SELECT * FROM data')
|
|
37
|
+
before=source_state(tmp_path)
|
|
38
|
+
with patch('chatcomment.cli.Store') as constructor,patch('chatcomment.server.serve') as run:
|
|
39
|
+
result=CliRunner().invoke(main,['--db',str(path),'serve','--gateway-key-env','TEST_GATEWAY_KEY','--require-existing-db'],env={'TEST_GATEWAY_KEY':'x'*48})
|
|
40
|
+
assert result.exit_code!=0 and 'Persistent database validation failed' in result.output
|
|
41
|
+
constructor.assert_not_called();run.assert_not_called()
|
|
42
|
+
assert source_state(tmp_path)==before
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_complete_zero_comment_schema_is_accepted_without_source_sidecars(tmp_path):
|
|
46
|
+
path=tmp_path/'comments.sqlite3';Store(path)
|
|
47
|
+
before=source_state(tmp_path)
|
|
48
|
+
assert validate_existing_database(path)==path.resolve()
|
|
49
|
+
assert source_state(tmp_path)==before
|
|
50
|
+
with patch('chatcomment.server.serve') as run:
|
|
51
|
+
result=CliRunner().invoke(main,['--db',str(path),'serve','--gateway-key-env','TEST_GATEWAY_KEY','--require-existing-db'],env={'TEST_GATEWAY_KEY':'x'*48})
|
|
52
|
+
assert result.exit_code==0,result.output
|
|
53
|
+
run.assert_called_once()
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_live_wal_schema_is_read_and_source_files_are_untouched(tmp_path):
|
|
57
|
+
path=tmp_path/'comments.sqlite3'
|
|
58
|
+
# Keep a writer open so the current schema lives in WAL, not the base file.
|
|
59
|
+
template=Store(tmp_path/'template.sqlite3')
|
|
60
|
+
with template.connect() as db:
|
|
61
|
+
schema=[r[0] for r in db.execute("SELECT sql FROM sqlite_master WHERE sql IS NOT NULL ORDER BY rowid")]
|
|
62
|
+
writer=sqlite3.connect(path)
|
|
63
|
+
writer.execute('PRAGMA journal_mode=WAL');writer.execute('PRAGMA wal_autocheckpoint=0')
|
|
64
|
+
writer.executescript(';\n'.join(schema)+';')
|
|
65
|
+
with sqlite3.connect(path.as_uri()+'?mode=ro&immutable=1',uri=True) as base_only:
|
|
66
|
+
assert 'comments' not in {r[0] for r in base_only.execute('SELECT name FROM sqlite_master')}
|
|
67
|
+
before=source_state(tmp_path)
|
|
68
|
+
assert validate_existing_database(path)==path.resolve()
|
|
69
|
+
assert source_state(tmp_path)==before
|
|
70
|
+
writer.close()
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_wrong_wal_schema_does_not_create_source_sidecars(tmp_path):
|
|
74
|
+
path=tmp_path/'comments.sqlite3'
|
|
75
|
+
writer=sqlite3.connect(path);writer.execute('PRAGMA journal_mode=WAL')
|
|
76
|
+
writer.execute('CREATE TABLE unrelated(id TEXT)');writer.commit()
|
|
77
|
+
before=source_state(tmp_path)
|
|
78
|
+
with pytest.raises(Invalid):validate_existing_database(path)
|
|
79
|
+
assert source_state(tmp_path)==before
|
|
80
|
+
writer.close()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@pytest.mark.skipif(hasattr(os,'geteuid') and os.geteuid()==0,reason='Root can read mode000 files')
|
|
84
|
+
def test_unreadable_file_is_refused_without_changing_permissions(tmp_path):
|
|
85
|
+
path=tmp_path/'comments.sqlite3';Store(path);path.chmod(0)
|
|
86
|
+
try:
|
|
87
|
+
with pytest.raises(Invalid,match='unreadable'):validate_existing_database(path)
|
|
88
|
+
assert path.stat().st_mode & 0o777==0
|
|
89
|
+
finally:path.chmod(0o600)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|