# -*- coding: utf-8 -*- """只读看板 HTTP 服务。 只注册 GET(以及登录所需的 POST /login)。数据管理仍由 Telegram、CLI 或 数据库完成,Web 不提供任何业务写接口。 """ from __future__ import annotations import hashlib import hmac import json import mimetypes import time from dataclasses import dataclass from http import HTTPStatus from http.cookies import SimpleCookie from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path from typing import Any, Callable, Mapping from urllib.parse import parse_qs, quote, unquote, urlsplit from ..analytics import is_loopback from ..config import ConfigStore from ..database import Database from .queries import DashboardQueries STATIC_ROOT = Path(__file__).resolve().parent / "static" COOKIE_NAME = "rb_session" SESSION_TTL = 7 * 86400 PROTECTED_API = {"/api/candidates", "/api/system"} PROTECTED_PAGES = {"/candidates", "/system"} class ApiError(Exception): def __init__(self, status: int, code: str, message: str) -> None: super().__init__(message) self.status = status self.code = code self.message = message @dataclass class WebContext: queries: DashboardQueries config_store: ConfigStore preview: bool = False logger: Any = None clock: Callable[[], float] = time.time db_path: str | None = None schema_version: int | None = None @property def auth_token(self) -> str: return self.config_store.get().web.auth_token class DashboardHTTPServer(ThreadingHTTPServer): daemon_threads = True allow_reuse_address = True def __init__(self, address, context: WebContext) -> None: self.context = context super().__init__(address, DashboardHandler) class DashboardHandler(BaseHTTPRequestHandler): protocol_version = "HTTP/1.1" server_version = "ReactionBoard/0.1" # ------------------------------------------------------------ logging def log_message(self, fmt: str, *args: Any) -> None: # 请求级日志容易刷屏,只保留错误在 _send_error 里记录。 return @property def context(self) -> WebContext: return self.server.context # type: ignore[attr-defined] # ------------------------------------------------------------ HTTP def do_GET(self) -> None: self._dispatch("GET") def do_HEAD(self) -> None: self._dispatch("HEAD") def do_POST(self) -> None: self._dispatch("POST") def do_PUT(self) -> None: self._method_not_allowed() def do_PATCH(self) -> None: self._method_not_allowed() def do_DELETE(self) -> None: self._method_not_allowed() def do_OPTIONS(self) -> None: self._method_not_allowed() def _method_not_allowed(self) -> None: self._send_error(HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "不支持该操作") # ------------------------------------------------------------ dispatch def _dispatch(self, method: str) -> None: parsed = urlsplit(self.path) path = unquote(parsed.path) query = parse_qs(parsed.query, keep_blank_values=True) try: if path == "/api/health": self._send_json(HTTPStatus.OK, { "ok": True, "preview": self.context.preview, "time": int(self.context.clock()), }) return if path == "/api/session": self._send_json(HTTPStatus.OK, { "auth_enabled": bool(self.context.auth_token), "authenticated": self._authorized(), "preview": self.context.preview, }) return if path == "/login": if method == "POST": self._login_post(query) else: self._login_page(query) return if path == "/logout": self._logout() return if path.startswith("/api/"): if path in PROTECTED_API and not self._authorized(): self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "需要登录") return if method == "POST": self._send_error( HTTPStatus.METHOD_NOT_ALLOWED, "method_not_allowed", "不支持该操作", ) return self._api(path, query, method) return if path in PROTECTED_PAGES and not self._authorized(): self._redirect(f"/login?next={quote(path)}") return self._static(path, method) except ApiError as exc: self._send_error(exc.status, exc.code, exc.message) except ValueError as exc: self._send_error(HTTPStatus.BAD_REQUEST, "bad_request", str(exc)) except BrokenPipeError: return except Exception as exc: # noqa: BLE001 if self.context.logger is not None: self.context.logger.error("web.error", path=path, error=str(exc)) self._send_error( HTTPStatus.INTERNAL_SERVER_ERROR, "internal_error", "服务端错误" ) # ------------------------------------------------------------ auth def _session_value(self, expires_at: int) -> str: token = self.context.auth_token payload = f"rb:{int(expires_at)}" signature = hmac.new( token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256 ).hexdigest() return f"{int(expires_at)}.{signature}" def _valid_session(self, value: str) -> bool: token = self.context.auth_token if not token or "." not in value: return False raw_exp, signature = value.split(".", 1) try: expires_at = int(raw_exp) except ValueError: return False if expires_at < int(self.context.clock()): return False payload = f"rb:{expires_at}" expected = hmac.new( token.encode("utf-8"), payload.encode("utf-8"), hashlib.sha256 ).hexdigest() return hmac.compare_digest(signature, expected) def _authorized(self) -> bool: token = self.context.auth_token if not token: return True header = self.headers.get("Authorization", "") if header.startswith("Bearer "): return hmac.compare_digest(header[7:].strip(), token) cookie_header = self.headers.get("Cookie") if not cookie_header: return False cookie = SimpleCookie() try: cookie.load(cookie_header) except Exception: # noqa: BLE001 return False morsel = cookie.get(COOKIE_NAME) return morsel is not None and self._valid_session(morsel.value) def _login_page(self, query: Mapping[str, list[str]]) -> None: if not self.context.auth_token: self._redirect("/") return next_path = _safe_next(_param(query, "next", "")) html = """ 登录 · Reaction 看板

Reaction 看板

输入配置中的 web.auth_token 以继续。

""" html = html.replace( 'action="/login"', f'action="/login?next={quote(next_path)}"' ) self._send_html(HTTPStatus.OK, html) def _login_post(self, query: Mapping[str, list[str]]) -> None: if not self.context.auth_token: self._redirect("/") return try: length = int(self.headers.get("Content-Length", "0")) except ValueError: length = 0 if length <= 0 or length > 4096: self._send_error(HTTPStatus.BAD_REQUEST, "bad_request", "请求体过大") return body = self.rfile.read(length).decode("utf-8", errors="replace") token = (parse_qs(body).get("token") or [""])[0] if not hmac.compare_digest(token, self.context.auth_token): self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "令牌不正确") return expires_at = int(self.context.clock()) + SESSION_TTL value = self._session_value(expires_at) target = _safe_next(_param(query, "next", "")) self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", target) self.send_header("Set-Cookie", ( f"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Lax; " f"Max-Age={SESSION_TTL}" )) self.send_header("Content-Length", "0") self._security_headers() self.end_headers() def _logout(self) -> None: self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", "/login" if self.context.auth_token else "/") self.send_header("Set-Cookie", f"{COOKIE_NAME}=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax") self.send_header("Content-Length", "0") self._security_headers() self.end_headers() # ------------------------------------------------------------ API def _api(self, path: str, query: Mapping[str, list[str]], method: str) -> None: queries = self.context.queries if path == "/api/overview": self._send_json(HTTPStatus.OK, queries.overview()) return if path == "/api/videos": self._send_json(HTTPStatus.OK, queries.list_videos( sort=_param(query, "sort", "heat"), within_days=_optional_int(query, "within_days"), year=_year_param(query), growth_days=_int_param(query, "growth_days", 7, 1, 365), folder_id=_optional_int(query, "folder"), q=_optional_str(query, "q"), page=_int_param(query, "page", 1, 1, 1_000_000), page_size=_int_param( query, "page_size", _optional_int(query, "limit") or 20, 1, 200, ), )) return if path.startswith("/api/videos/"): parts = [part for part in path.split("/") if part] if len(parts) == 3: aid = _path_int(parts[2], "aid") data = queries.video_detail(aid) if data is None: raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "视频不存在") self._send_json(HTTPStatus.OK, data) return if len(parts) == 4 and parts[3] == "trend": aid = _path_int(parts[2], "aid") days = _range_days(query) self._send_json(HTTPStatus.OK, queries.video_trend(aid, days=days)) return raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在") if path == "/api/rankings": scope = _param(query, "scope", "video") if scope == "up": self._send_json(HTTPStatus.OK, queries.list_ups( sort=_param(query, "sort", "efficiency"), within_days=_optional_int(query, "within_days"), year=_year_param(query), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), page=_int_param(query, "page", 1, 1, 1_000_000), page_size=_int_param( query, "page_size", _optional_int(query, "limit") or 20, 1, 200, ), )) return if scope != "video": raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "scope 必须是 video 或 up") self._send_json(HTTPStatus.OK, queries.list_videos( sort=_param(query, "sort", "heat"), within_days=_optional_int(query, "within_days"), year=_year_param(query), growth_days=_int_param(query, "growth_days", 7, 1, 365), folder_id=_optional_int(query, "folder"), q=_optional_str(query, "q"), page=_int_param(query, "page", 1, 1, 1_000_000), page_size=_int_param( query, "page_size", _optional_int(query, "limit") or 20, 1, 200, ), )) return if path == "/api/ups": self._send_json(HTTPStatus.OK, queries.list_ups( sort=_param(query, "sort", "efficiency"), within_days=_optional_int(query, "within_days"), year=_year_param(query), q=_optional_str(query, "q"), min_videos=_int_param(query, "min_videos", 1, 1, 10000), page=_int_param(query, "page", 1, 1, 1_000_000), page_size=_int_param( query, "page_size", _optional_int(query, "limit") or 20, 1, 200, ), )) return if path.startswith("/api/ups/"): parts = [part for part in path.split("/") if part] if len(parts) != 3: raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在") mid = _path_int(parts[2], "mid") data = queries.up_detail(mid) if data is None: raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "UP 不存在") self._send_json(HTTPStatus.OK, data) return if path == "/api/fans": self._send_json(HTTPStatus.OK, queries.fans( _optional_int(query, "mid"), days=_int_param(query, "days", 90, 1, 3650), start=_optional_str(query, "start"), end=_optional_str(query, "end"), recent_days=_int_param(query, "recent_days", 14, 1, 3650), )) return if path == "/api/about": self._send_json(HTTPStatus.OK, queries.about()) return if path == "/api/candidates": self._send_json(HTTPStatus.OK, queries.candidates()) return if path == "/api/system": self._send_json(HTTPStatus.OK, queries.system()) return raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "接口不存在") # ------------------------------------------------------------ static def _static(self, path: str, method: str) -> None: if path in {"/", "/index.html"}: target = STATIC_ROOT / "index.html" elif path.startswith("/static/"): relative = path[len("/static/"):] target = (STATIC_ROOT / relative).resolve() try: target.relative_to(STATIC_ROOT.resolve()) except ValueError: raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "文件不存在") else: # 伪静态前端路由:/rank、/video/123、/up/123 等直接返回 index.html, # 由前端根据 location.pathname 渲染。带扩展名的路径仍按文件处理并返回 404。 last_segment = path.rstrip("/").rsplit("/", 1)[-1] if "." in last_segment: raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "文件不存在") target = STATIC_ROOT / "index.html" if not target.is_file(): raise ApiError(HTTPStatus.NOT_FOUND, "not_found", "页面不存在") content_type = mimetypes.guess_type(str(target))[0] or "application/octet-stream" if content_type.startswith("text/") or content_type in { "application/javascript", "application/json", "image/svg+xml", }: content_type += "; charset=utf-8" payload = target.read_bytes() vendor_root = (STATIC_ROOT / "vendor").resolve() try: target.relative_to(vendor_root) cache_control = "public, max-age=86400" except ValueError: # 应用自身的 JS/CSS/HTML 每次重新校验,保证升级后浏览器立刻拿到新版。 cache_control = "no-cache" self.send_response(HTTPStatus.OK) self.send_header("Content-Type", content_type) self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", cache_control) self._security_headers() self.end_headers() if method != "HEAD": self.wfile.write(payload) # ------------------------------------------------------------ responses def _security_headers(self) -> None: self.send_header( "Content-Security-Policy", "default-src 'self'; img-src 'self' https: data:; " "style-src 'self' 'unsafe-inline'; script-src 'self'; " "connect-src 'self'; base-uri 'none'; frame-ancestors 'none'", ) self.send_header("X-Content-Type-Options", "nosniff") self.send_header("Referrer-Policy", "no-referrer") self.send_header("X-Frame-Options", "DENY") def _send_json(self, status: int, data: Any) -> None: payload = json.dumps({"ok": True, "data": data}, ensure_ascii=False).encode("utf-8") self.send_response(int(status)) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", "no-store") self._security_headers() self.end_headers() if self.command != "HEAD": self.wfile.write(payload) def _send_error(self, status: int, code: str, message: str) -> None: payload = json.dumps( {"ok": False, "error": {"code": code, "message": message}}, ensure_ascii=False, ).encode("utf-8") self.send_response(int(status)) self.send_header("Content-Type", "application/json; charset=utf-8") self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", "no-store") self._security_headers() self.end_headers() if self.command != "HEAD": self.wfile.write(payload) def _send_html(self, status: int, html: str) -> None: payload = html.encode("utf-8") self.send_response(int(status)) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(payload))) self.send_header("Cache-Control", "no-store") self._security_headers() self.end_headers() if self.command != "HEAD": self.wfile.write(payload) def _redirect(self, location: str) -> None: self.send_response(HTTPStatus.SEE_OTHER) self.send_header("Location", location) self.send_header("Content-Length", "0") self._security_headers() self.end_headers() # ------------------------------------------------------------ helpers def _safe_next(value: str | None) -> str: text = (value or "").strip() if not text.startswith("/") or text.startswith("//") or text.startswith("/login"): return "/" return text def _param(query: Mapping[str, list[str]], name: str, default: str) -> str: values = query.get(name) return default if not values else str(values[0]) def _optional_str(query: Mapping[str, list[str]], name: str) -> str | None: value = _param(query, name, "").strip() return value or None def _optional_int(query: Mapping[str, list[str]], name: str) -> int | None: value = _optional_str(query, name) if value is None: return None try: return int(value) except ValueError as exc: raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc def _year_param(query: Mapping[str, list[str]]) -> int | None: value = _optional_int(query, "year") if value is None: return None if value < 2000 or value > 2100: raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "year 必须在 2000~2100 之间") return value def _int_param( query: Mapping[str, list[str]], name: str, default: int, minimum: int, maximum: int, ) -> int: value = _optional_int(query, name) if value is None: return int(default) if value < minimum or value > maximum: raise ApiError( HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须在 {minimum}~{maximum} 之间", ) return value def _path_int(value: str, name: str) -> int: try: return int(value) except ValueError as exc: raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", f"{name} 必须是整数") from exc def _range_days(query: Mapping[str, list[str]]) -> int | None: raw = _optional_str(query, "range") if raw in {None, "all", "0"}: return None if raw in {"7", "30", "90"}: return int(raw) days = _optional_int(query, "days") if days is None: return 30 if days < 1 or days > 3650: raise ApiError(HTTPStatus.BAD_REQUEST, "bad_request", "days 超出范围") return days def _preview_marker(db: Database) -> bool: def run(conn): row = conn.execute("SELECT v FROM kv WHERE k = 'preview:seeded'").fetchone() return row is not None and str(row["v"]) == "1" return bool(db.read(run)) def create_server( config_store: ConfigStore, db: Database, *, host: str | None = None, port: int | None = None, preview: bool = False, logger: Any = None, clock: Callable[[], float] = time.time, ) -> DashboardHTTPServer: cfg = config_store.get() bind_host = cfg.web.host if host is None else host bind_port = cfg.web.port if port is None else int(port) if not cfg.web.auth_token and not is_loopback(bind_host): raise ValueError( "非回环地址必须配置 web.auth_token,或只绑定 127.0.0.1" ) if preview and not _preview_marker(db): raise ValueError("目标数据库不是预览库(缺少 preview:seeded=1 标记)") context = WebContext( queries=DashboardQueries(db, config_store, clock=clock), config_store=config_store, preview=preview, logger=logger, clock=clock, db_path=str(db.path), schema_version=db.schema_version, ) return DashboardHTTPServer((bind_host, bind_port), context) def serve_forever(server: DashboardHTTPServer, *, logger: Any = None) -> None: host, port = server.server_address[:2] if logger is not None: logger.info( "web.start", url=f"http://{host}:{port}", preview=server.context.preview, auth=bool(server.context.auth_token), ) try: server.serve_forever() finally: server.server_close()