ChatComment 0.1.0__py3-none-any.whl
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/__init__.py +6 -0
- chatcomment/cli.py +76 -0
- chatcomment/server.py +163 -0
- chatcomment/static/admin.html +27 -0
- chatcomment/static/board.html +19 -0
- chatcomment/static/widget.css +1 -0
- chatcomment/static/widget.js +96 -0
- chatcomment/store.py +226 -0
- chatcomment-0.1.0.dist-info/METADATA +46 -0
- chatcomment-0.1.0.dist-info/RECORD +14 -0
- chatcomment-0.1.0.dist-info/WHEEL +5 -0
- chatcomment-0.1.0.dist-info/entry_points.txt +2 -0
- chatcomment-0.1.0.dist-info/licenses/LICENSE +21 -0
- chatcomment-0.1.0.dist-info/top_level.txt +1 -0
chatcomment/__init__.py
ADDED
chatcomment/cli.py
ADDED
|
@@ -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()
|
chatcomment/server.py
ADDED
|
@@ -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
|
+
})();
|
chatcomment/store.py
ADDED
|
@@ -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,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,14 @@
|
|
|
1
|
+
chatcomment/__init__.py,sha256=MbOFJ7nQzZIzkxRBR5BU4R_FTyXQdlbI0Kme7h1jP_0,134
|
|
2
|
+
chatcomment/cli.py,sha256=SMMCs3_MeUngoHR_UQfVG62uJRwZgcOpPiRbluINFfU,2709
|
|
3
|
+
chatcomment/server.py,sha256=oxjzZjICISELGNE13X2sGH6r10K331NKGq7PPGVfIV0,8705
|
|
4
|
+
chatcomment/store.py,sha256=xS4DQDNkNkTSphjrobx_-VKMvbFYLjieCtvfeQoJoeQ,10345
|
|
5
|
+
chatcomment/static/admin.html,sha256=LSXul9GjlOxND-cSxIu5bXy6FsCl1oXh4exOdxzf5o4,8135
|
|
6
|
+
chatcomment/static/board.html,sha256=627jTMjtG8iweDfh5cTt1Y2iaQdOfG7PrPcSAU7TUYw,1550
|
|
7
|
+
chatcomment/static/widget.css,sha256=yzpCC44nZ5EnIK5YwDmG3heaTf3bNbQV3eYnlnicwaU,2274
|
|
8
|
+
chatcomment/static/widget.js,sha256=9rhYzYka4WvKYWGuwZ6544EqNKXCEXVpTw5TdLZkej0,6449
|
|
9
|
+
chatcomment-0.1.0.dist-info/licenses/LICENSE,sha256=G-fJLQ4pkXXISE3qeN8J5Cw5VRoNongA0hbsHA9TRe8,1065
|
|
10
|
+
chatcomment-0.1.0.dist-info/METADATA,sha256=9Cbm2pQaevFMfSzK4mtTOxtGm7wUtmjl7w7A7ABkeI0,2761
|
|
11
|
+
chatcomment-0.1.0.dist-info/WHEEL,sha256=a-udL2m4V_6Yn8b-uKzWb12RjRxwwMo6ohYFM24tpaY,91
|
|
12
|
+
chatcomment-0.1.0.dist-info/entry_points.txt,sha256=tx14xzt93MClYvsagzT6aVpdk6cGfEf6P0l9oExiTD0,53
|
|
13
|
+
chatcomment-0.1.0.dist-info/top_level.txt,sha256=uqpJtHBxUnvTUjab_go04JUegl9urb1ef3wiuT89kjg,12
|
|
14
|
+
chatcomment-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
chatcomment
|