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.
Files changed (34) hide show
  1. {chatcomment-0.2.0 → chatcomment-0.2.1}/CHANGELOG.md +5 -0
  2. {chatcomment-0.2.0 → chatcomment-0.2.1}/PKG-INFO +1 -1
  3. {chatcomment-0.2.0 → chatcomment-0.2.1}/docs/platform.md +7 -0
  4. {chatcomment-0.2.0 → chatcomment-0.2.1}/pyproject.toml +1 -1
  5. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/PKG-INFO +1 -1
  6. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/SOURCES.txt +3 -1
  7. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/__init__.py +3 -2
  8. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/cli.py +7 -3
  9. chatcomment-0.2.1/src/chatcomment/validation.py +98 -0
  10. chatcomment-0.2.1/tests/code-tests/test_validation.py +89 -0
  11. {chatcomment-0.2.0 → chatcomment-0.2.1}/AGENTS.md +0 -0
  12. {chatcomment-0.2.0 → chatcomment-0.2.1}/LICENSE +0 -0
  13. {chatcomment-0.2.0 → chatcomment-0.2.1}/MANIFEST.in +0 -0
  14. {chatcomment-0.2.0 → chatcomment-0.2.1}/README.md +0 -0
  15. {chatcomment-0.2.0 → chatcomment-0.2.1}/docs/api.md +0 -0
  16. {chatcomment-0.2.0 → chatcomment-0.2.1}/docs/deployment.md +0 -0
  17. {chatcomment-0.2.0 → chatcomment-0.2.1}/setup.cfg +0 -0
  18. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/dependency_links.txt +0 -0
  19. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/entry_points.txt +0 -0
  20. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/requires.txt +0 -0
  21. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/ChatComment.egg-info/top_level.txt +0 -0
  22. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/access.py +0 -0
  23. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/server.py +0 -0
  24. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/admin.html +0 -0
  25. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/board.html +0 -0
  26. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/login.html +0 -0
  27. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/widget.css +0 -0
  28. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/static/widget.js +0 -0
  29. {chatcomment-0.2.0 → chatcomment-0.2.1}/src/chatcomment/store.py +0 -0
  30. {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/cli-tests/test_cli.py +0 -0
  31. {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/code-tests/test_access.py +0 -0
  32. {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/code-tests/test_chatlogin_adapter.py +0 -0
  33. {chatcomment-0.2.0 → chatcomment-0.2.1}/tests/code-tests/test_comments.py +0 -0
  34. {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 和站点区分。
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Embeddable comments with a Python server, SQLite storage and a small JavaScript widget.
5
5
  Author: ChatArch
6
6
  License-Expression: MIT
@@ -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.0"
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"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ChatComment
3
- Version: 0.2.0
3
+ Version: 0.2.1
4
4
  Summary: Embeddable comments with a Python server, SQLite storage and a small JavaScript widget.
5
5
  Author: ChatArch
6
6
  License-Expression: MIT
@@ -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.0"
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='Fail closed if the persistent database mount is missing.')
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 and not ctx.obj['db'].expanduser().is_file():
44
- raise click.ClickException('Persistent database mount is missing; refusing to create an empty database.')
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