{html.escape(title)} · Curator{body}"""
return document.encode("utf-8")
class CuratorServer(ThreadingHTTPServer):
def __init__(self, address: tuple[str, int], settings: Settings, database: Database):
super().__init__(address, CuratorHandler)
self.settings = settings
self.database = database
self.library = Library(settings, database)
self.covers = CoverStore(settings, database)
self.service = CuratorService(settings, database)
class CuratorHandler(BaseHTTPRequestHandler):
server: CuratorServer
TOKEN_COOKIE = "curator_token"
def log_message(self, format: str, *args: Any) -> None:
super().log_message(format, *args)
# -- authentication -------------------------------------------------
#
# Every route except /api/health and /login requires the access token. The
# health endpoint stays open so systemd and a load balancer can probe it
# without a credential, and it reveals nothing a probe may not see.
#
# The token is either a Bearer header (curl / programmatic) or a cookie
# (browser). The cookie is HttpOnly and SameSite=Strict: Strict means a
# cross-site POST cannot carry it, which is what makes the write endpoints
# resistant to CSRF without a second token -- the only same-origin way to
# authenticate is to actually hold the token. Writes are POST-only.
def _cookie_token(self) -> str:
for chunk in (self.headers.get("Cookie") or "").split(";"):
name, _, value = chunk.partition("=")
if name.strip() == self.TOKEN_COOKIE:
return value.strip()
return ""
def authorized(self) -> bool:
token = self.server.settings.web_token
if not token:
return True
auth = self.headers.get("Authorization") or ""
if auth.startswith("Bearer ") and hmac.compare_digest(auth[len("Bearer ") :], token):
return True
cookie = self._cookie_token()
return bool(cookie) and hmac.compare_digest(cookie, token)
def _auth_required(self) -> bool:
"""Whether the given path is exempt from authentication."""
path = urllib.parse.urlparse(self.path).path
return path not in ("/api/health", "/login")
def _send_login(self, message: str = "") -> None:
notice = f'
{html.escape(message)}
' if message else ""
body = f"""
登录
Curator 需要访问令牌
{notice}"""
self.send_bytes(page("登录", body), "text/html; charset=utf-8", 401)
def _form_fields(self) -> dict[str, str]:
"""Flat view of an application/x-www-form-urlencoded POST body."""
length = int(self.headers.get("Content-Length", "0"))
values = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
return {key: vals[0] if vals else "" for key, vals in values.items()}
def _do_login(self) -> None:
form = self._form_fields()
supplied = form.get("token", "")
token = self.server.settings.web_token
if not token or not hmac.compare_digest(supplied, token):
self._send_login("令牌不正确")
return
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header(
"Set-Cookie",
f"{self.TOKEN_COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=31536000",
)
self.send_header("Location", "/")
self.end_headers()
def send_bytes(self, data: bytes, content_type: str, status: int = 200, filename: str = "") -> None:
self.send_response(status)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("X-Content-Type-Options", "nosniff")
if filename:
quoted = urllib.parse.quote(filename)
self.send_header("Content-Disposition", f"attachment; filename*=UTF-8''{quoted}")
self.end_headers()
self.wfile.write(data)
def redirect(self, path: str) -> None:
self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", path)
self.end_headers()
@staticmethod
def media_label(media_type: str) -> str:
return {"book": "书籍", "movie": "电影", "tv": "剧集", "music": "音乐"}.get(media_type, media_type)
@staticmethod
def short_time(value: str) -> str:
return value[:16].replace("T", " ") if value else ""
@staticmethod
def json_object(value: Any) -> dict[str, Any]:
try:
parsed = json.loads(value or "{}") if isinstance(value, str) else value
except (json.JSONDecodeError, TypeError):
return {}
return parsed if isinstance(parsed, dict) else {}
@staticmethod
def json_list(value: Any) -> list[Any]:
try:
parsed = json.loads(value or "[]") if isinstance(value, str) else value
except (json.JSONDecodeError, TypeError):
return []
return parsed if isinstance(parsed, list) else []
def _sandboxed(self, data: bytes, content_type: str, filename: str = "") -> None:
"""Serve untrusted content in a unique opaque origin.
Used for uploaded EPUB chapters and inline PDFs. A sandboxed document
cannot read the Curator session cookie, touch the authenticated write
endpoints, or reflect the token -- this closes P2-6's stored-XSS hole at
the response rather than trusting the iframe attribute alone.
"""
self.send_response(HTTPStatus.OK)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(data)))
self.send_header("X-Content-Type-Options", "nosniff")
self.send_header(
"Content-Security-Policy",
"sandbox; default-src 'none'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; "
"font-src 'self' data:; media-src 'self'; base-uri 'none'; form-action 'none'",
)
if filename:
self.send_header("Content-Disposition", f"inline; filename*=UTF-8''{urllib.parse.quote(filename)}")
self.end_headers()
self.wfile.write(data)
def cover(self, kind: str, entity_id: int, media_type: str, title: str) -> str:
safe_type = media_type if media_type in {"book", "movie", "tv", "music"} else "book"
image = f''
return (f'
')
def candidate_card(self, item: Any, *, compact: bool = False, show_source: bool = True) -> str:
media_type = str(item["media_type"] or "unknown")
title = str(item["title"] or "未命名")
creator = str(item["creator"] or "").strip()
metadata = self.json_object(item["metadata_json"])
matches = self.json_list(item["library_matches_json"])
web_review = metadata.get("book_web_review") or {}
evidence = metadata.get("book_web_review_evidence") or []
verdicts = {"strong": "强烈推荐", "worth": "值得", "optional": "按兴趣", "skip": "不建议", "insufficient": "证据不足"}
recommendation = verdicts.get(str(web_review.get("verdict") or item["recommendation"]), "待判断")
summary = str(web_review.get("summary") or item["summary"] or "").strip()
byline = creator or str(item["original_title"] or "").strip()
tags = [f'{html.escape(self.media_label(media_type))}', self.status_tag(str(item["status"]))]
if recommendation != "待判断":
tags.append(f'{html.escape(recommendation)}')
for match in matches[:2]:
quality = str(match.get("quality") or "")
instance = str(match.get("instance") or "")
text = " · ".join(value for value in (quality.upper(), instance) if value)
if text:
tags.append(f'{html.escape(text)}')
rating_bits = []
rating_labels = {"douban": "豆瓣读书", "goodreads": "Goodreads"}
rating_scales = {"douban": 10, "goodreads": 5}
for review in metadata.get("book_reviews") or []:
if review.get("rating") is not None:
provider = str(review.get("provider") or "评分")
rating_bits.append(
f'{rating_labels.get(provider, provider)} {float(review["rating"]):.1f}/{rating_scales.get(provider, 5)}'
)
for source in evidence:
match = re.search(r"豆瓣评分[::]\s*(\d+(?:\.\d+)?)", str(source.get("snippet") or ""))
if match:
rating_bits.append(f"豆瓣 {match.group(1)}/10")
break
if rating_bits:
tags.append(f'{html.escape(";".join(rating_bits))}')
review_detail = ""
if media_type == "book" and not compact:
strengths = [str(value) for value in web_review.get("strengths") or []][:3]
caveats = [str(value) for value in web_review.get("caveats") or []][:3]
source_links = []
for ref in (web_review.get("evidence_refs") or list(range(min(3, len(evidence)))))[:3]:
if not isinstance(ref, int) or ref < 0 or ref >= len(evidence):
continue
source = evidence[ref]
url = str(source.get("url") or "")
label = str(source.get("domain") or source.get("title") or "评价来源")
if url.startswith(("http://", "https://")):
source_links.append(f'{html.escape(label)}')
columns = ""
if strengths:
columns += '
值得关注
' + "".join(f'
{html.escape(value)}
' for value in strengths) + '
'
if caveats:
columns += '
需要留意
' + "".join(f'
{html.escape(value)}
' for value in caveats) + '
'
if columns or source_links:
sources = ('
评价来源:' + " · ".join(source_links) + '
') if source_links else ""
review_detail = f'评价依据
{columns}
{sources}'
actions = []
if item["status"] == "pending":
next_target = "/candidates?status=pending" if show_source else f'/source/{item["inbox_item_id"]}'
actions.extend([
f'',
f'',
])
if media_type == "book" and item["library_state"] != "owned":
search_url = book_search_url(self.server.settings.zlib_search_url_template, title, creator)
if search_url:
actions.append(f'查找 EPUB')
upload_query = urllib.parse.urlencode({"title": title, "author": creator})
actions.append(f'导入文件')
if show_source and item["inbox_item_id"]:
actions.append(f'查看来源')
source_line = ""
if show_source and item["inbox_item_id"]:
source_line = f'
''')
for item in candidates:
if media_filter not in {"all", item["media_type"]}:
continue
matches = self.json_list(item["library_matches_json"])
match = matches[0] if matches else {}
quality = str(match.get("quality") or "")
instance = str(match.get("instance") or "")
details = " · ".join(value for value in (quality.upper(), instance, str(item["year"] or "")) if value)
items.append(f'''{self.cover("candidate", int(item["id"]), item['media_type'], str(item['title']))}
{html.escape(item['title'])}
{html.escape(item['original_title'] or item['creator'] or '')}
''')
for candidate in candidates:
if candidate["status"] != "selected" or int(candidate["id"]) in matched_candidate_ids:
continue
cards.append(self.candidate_card(candidate))
listing = "".join(cards) or '
暂无待获取作品
'
body = f"""
待获取
已决定收集、尚未确认入库的作品
{len(cards)} 项待处理
{listing}
"""
self.send_bytes(page("待获取", body), "text/html; charset=utf-8")
@staticmethod
def status_tag(status: str) -> str:
labels = {
"pending": "待决定", "owned": "已入库", "wanted": "待获取", "tracked": "已跟踪",
"selected": "已决定收集", "ignored": "已忽略", "not_recommended": "不建议收集",
"unknown": "未核对", "superseded": "已过期",
}
safe = html.escape(status)
return f'{html.escape(labels.get(status, status))}'
def activity(self) -> None:
# Reads workflow_jobs, which is joined to the control ledger, so a write
# shows the action and risk tier it was classified as. The old
# activity_jobs table was a parallel log with no link to the plan that
# caused the row.
jobs = self.server.database.recent_jobs(100)
rows = ""
for job in jobs:
action = str(job["plan_action"] or "")
risk = str(job["plan_risk"] or "")
badge = f' {html.escape(action)}·{html.escape(risk)}' if action else ""
error = str(job["error"] or "")
detail = html.escape(str(job["detail"] or ""))
if error:
detail += f' {html.escape(error)}'
rows += (
f'
#{job["id"]}'
f'{html.escape(str(job["status"]))}'
f'{html.escape(str(job["kind"]))}{badge} {detail}'
f'
"""
self.send_bytes(page(str(source["title"]), body), "text/html; charset=utf-8")
def candidates(self, query: str) -> None:
params = urllib.parse.parse_qs(query)
status = params.get("status", ["pending"])[0]
media_type = params.get("type", ["all"])[0]
all_items = self.server.database.all_media_candidates()
status_groups = {
"pending": {"pending"}, "selected": {"selected", "wanted"},
"owned": {"owned"}, "ignored": {"ignored", "not_recommended"},
"all": {str(item["status"]) for item in all_items},
}
items = [item for item in all_items if item["status"] in status_groups.get(status, status_groups["pending"])]
if media_type != "all":
items = [item for item in items if item["media_type"] == media_type]
status_labels = [("pending", "待决定"), ("selected", "已选择"), ("owned", "已入库"), ("ignored", "已忽略"), ("all", "全部")]
status_tabs = "".join(
f'{label}'
for key, label in status_labels
)
type_labels = [("all", "全部类型"), ("book", "书籍"), ("movie", "电影"), ("tv", "剧集"), ("music", "音乐")]
type_tabs = "".join(
f'{label}'
for key, label in type_labels
)
cards = "".join(self.candidate_card(item) for item in items) or '
没有符合条件的作品
'
body = f"""
发现
从来源中识别的作品与收集判断
{len(items)} 项
{status_tabs}
{type_tabs}
{cards}
"""
self.send_bytes(page("发现", body), "text/html; charset=utf-8")
def update_candidate(self, candidate_id: int) -> None:
item = self.server.database.media_candidate(candidate_id)
if not item:
self.send_error(HTTPStatus.NOT_FOUND)
return
length = int(self.headers.get("Content-Length", "0"))
values = urllib.parse.parse_qs(self.rfile.read(length).decode("utf-8"))
action = values.get("action", [""])[0]
next_path = values.get("next", [f"/source/{item['inbox_item_id']}"])[0]
if not next_path.startswith("/") or next_path.startswith("//"):
next_path = "/candidates"
if item["status"] != "pending":
self.redirect(next_path)
return
if action not in {"collect", "ignore"}:
raise ValueError("未知候选操作")
# Routed through the service so that a decision made in the browser
# produces the same ledger as the same decision made in Telegram. This
# branch previously wrote no ledger at all, and for a film or series it
# only marked the candidate "selected" without ever calling the adapter --
# so "collect" here and "collect" in the chat did different things.
try:
metadata = json.loads(item["metadata_json"] or "{}")
except (json.JSONDecodeError, TypeError):
metadata = {}
self.server.service.execute(WriteRequest(
action="collect" if action == "collect" else "ignore_candidate",
media_type=str(item["media_type"] or "unknown"),
title=str(item["title"] or ""),
creator=str(item["creator"] or ""),
channel="web",
year=int(item["year"]) if item["year"] else None,
identity={
str(key): str(value)
for key, value in (metadata.get("external_ids") or {}).items()
if value
},
candidate_id=candidate_id,
explicit=True,
source={
"original_title": str(item["original_title"] or ""),
"aliases": metadata.get("aliases") or [],
},
))
self.redirect(next_path)
def work(self, work_id: int) -> None:
work = self.server.database.work(work_id)
if not work:
self.send_error(HTTPStatus.NOT_FOUND)
return
rows = ""
for asset in self.server.database.work_assets(work_id):
metadata = json.loads(asset["metadata_json"] or "{}")
edition_title = str(metadata.get("display_title") or "")
title_note = f' {html.escape(edition_title)}' if edition_title and edition_title != work["title"] else ""
read = f'阅读 ' if asset["format"] in {"epub", "pdf"} else ""
rows += f"