diff --git a/reaction_board/web/server.py b/reaction_board/web/server.py index 6b38463..49496a0 100644 --- a/reaction_board/web/server.py +++ b/reaction_board/web/server.py @@ -18,7 +18,7 @@ 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, unquote, urlsplit +from urllib.parse import parse_qs, quote, unquote, urlsplit from ..analytics import is_loopback from ..config import ConfigStore @@ -28,6 +28,8 @@ 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): @@ -117,18 +119,26 @@ class DashboardHandler(BaseHTTPRequestHandler): }) 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() + self._login_post(query) else: - self._login_page() + self._login_page(query) return if path == "/logout": self._logout() return if path.startswith("/api/"): - if not self._authorized(): + if path in PROTECTED_API and not self._authorized(): self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "需要登录") return if method == "POST": @@ -141,8 +151,8 @@ class DashboardHandler(BaseHTTPRequestHandler): self._api(path, query, method) return - if not self._authorized(): - self._redirect("/login") + 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: @@ -203,10 +213,11 @@ class DashboardHandler(BaseHTTPRequestHandler): morsel = cookie.get(COOKIE_NAME) return morsel is not None and self._valid_session(morsel.value) - def _login_page(self) -> None: + 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 = """ @@ -230,9 +241,12 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} """ + html = html.replace( + 'action="/login"', f'action="/login?next={quote(next_path)}"' + ) self._send_html(HTTPStatus.OK, html) - def _login_post(self) -> None: + def _login_post(self, query: Mapping[str, list[str]]) -> None: if not self.context.auth_token: self._redirect("/") return @@ -250,8 +264,9 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} 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", "/") + self.send_header("Location", target) self.send_header("Set-Cookie", ( f"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Lax; " f"Max-Age={SESSION_TTL}" @@ -481,6 +496,13 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}} # ------------------------------------------------------------ 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]) diff --git a/reaction_board/web/static/api.js b/reaction_board/web/static/api.js index 316652a..b25f170 100644 --- a/reaction_board/web/static/api.js +++ b/reaction_board/web/static/api.js @@ -50,3 +50,10 @@ export async function health() { const payload = await response.json(); return payload?.data ?? payload; } + +export async function session() { + const response = await fetch("/api/session", { credentials: "same-origin" }); + if (!response.ok) throw new ApiError("无法连接看板服务", response.status); + const payload = await response.json(); + return payload?.data ?? payload; +} diff --git a/reaction_board/web/static/app.js b/reaction_board/web/static/app.js index 3e1bf4e..88b72bf 100644 --- a/reaction_board/web/static/app.js +++ b/reaction_board/web/static/app.js @@ -1,7 +1,6 @@ -import { health } from "./api.js"; +import { session } from "./api.js"; import { disposeAll } from "./charts.js"; -import { escapeHtml } from "./format.js"; -import { onRoute, startRouter } from "./router.js"; +import { onRoute, parseLocation, startRouter } from "./router.js"; import { renderCandidates } from "./views/candidates.js"; import { renderFans } from "./views/fans.js"; import { renderHome } from "./views/home.js"; @@ -14,8 +13,8 @@ const NAV_ITEMS = [ { path: "/home", label: "首页", icon: "home" }, { path: "/rank", label: "榜单", icon: "rank" }, { path: "/fans", label: "粉丝", icon: "fans" }, - { path: "/candidates", label: "候选", icon: "candidate" }, - { path: "/system", label: "系统", icon: "system" }, + { path: "/candidates", label: "候选", icon: "candidate", protected: true }, + { path: "/system", label: "系统", icon: "system", protected: true }, ]; const ROUTES = [ @@ -24,10 +23,32 @@ const ROUTES = [ { pattern: /^\/video\/(\d+)$/, view: renderVideo, title: "视频详情" }, { pattern: /^\/up\/(\d+)$/, view: renderUp, title: "UP 详情" }, { pattern: /^\/fans$/, view: renderFans, title: "粉丝" }, - { pattern: /^\/candidates$/, view: renderCandidates, title: "候选池" }, - { pattern: /^\/system$/, view: renderSystem, title: "系统状态" }, + { + pattern: /^\/candidates$/, view: renderCandidates, title: "候选池", + beforeEnter: requireAuth, + }, + { + pattern: /^\/system$/, view: renderSystem, title: "系统状态", + beforeEnter: requireAuth, + }, ]; +let authState = { authEnabled: false, authenticated: true }; + +function canAccessProtected() { + return !authState.authEnabled || authState.authenticated; +} + +function requireAuth() { + if (canAccessProtected()) return true; + const { path, query } = parseLocation(); + const search = query.toString(); + window.location.assign( + `/login?next=${encodeURIComponent(`${path}${search ? `?${search}` : ""}`)}`, + ); + return false; +} + function icon(name) { const paths = { home: '', @@ -42,7 +63,10 @@ function icon(name) { function renderNav() { const nav = document.querySelector("#bottom-nav"); - nav.innerHTML = NAV_ITEMS.map((item) => ` + const items = NAV_ITEMS.filter( + (item) => !item.protected || canAccessProtected(), + ); + nav.innerHTML = items.map((item) => ` ${icon(item.icon)}${item.label} `).join(""); @@ -88,19 +112,26 @@ window.addEventListener("error", (event) => { }, true); async function boot() { - renderNav(); const banner = document.querySelector("#preview-banner"); const hint = document.querySelector("#topbar-hint"); + const authLink = document.querySelector("#topbar-auth"); try { - const payload = await health(); - const data = payload?.data || payload; + const data = await session(); + authState = { + authEnabled: Boolean(data?.auth_enabled), + authenticated: data?.authenticated !== false, + }; if (data?.preview) { banner.hidden = false; if (hint) hint.hidden = false; } + if (authLink && authState.authEnabled && !authState.authenticated) { + authLink.hidden = false; + } } catch { toast("无法连接看板服务", "error"); } + renderNav(); onRoute(({ path, route }) => { disposeAll(); setTitle(route, path); diff --git a/reaction_board/web/static/index.html b/reaction_board/web/static/index.html index 6847669..3d8c704 100644 --- a/reaction_board/web/static/index.html +++ b/reaction_board/web/static/index.html @@ -20,6 +20,7 @@ 最新动态 + diff --git a/reaction_board/web/static/router.js b/reaction_board/web/static/router.js index d58ad85..cc617ac 100644 --- a/reaction_board/web/static/router.js +++ b/reaction_board/web/static/router.js @@ -125,6 +125,10 @@ async function render(options = {}) { finishRender(); return; } + if (matched.route.beforeEnter && matched.route.beforeEnter() === false) { + finishRender(); + return; + } current = { path, query, route: matched.route }; for (const listener of listeners) { try { diff --git a/tests/test_web_server.py b/tests/test_web_server.py index 5c01728..1bc24c8 100644 --- a/tests/test_web_server.py +++ b/tests/test_web_server.py @@ -65,6 +65,15 @@ def test_readonly_routes_and_static(): assert status == 200 and health["ok"] is True assert health["data"]["preview"] is False + status, _, payload = running.json("/api/session") + assert status == 200 + assert payload["data"]["auth_enabled"] is False + assert payload["data"]["authenticated"] is True + status, _, payload = running.json("/api/candidates") + assert status == 200 and payload["ok"] is True + status, _, payload = running.json("/api/system") + assert status == 200 and payload["ok"] is True + status, _, overview = running.json("/api/overview") assert status == 200 and overview["ok"] is True assert overview["data"]["data_date"] == "2026-10-31" @@ -169,16 +178,38 @@ def test_readonly_routes_and_static(): def test_token_auth_and_login(): with tempfile.TemporaryDirectory() as tmp: - running = RunningServer(tmp, token="secret-token") + running = RunningServer(tmp, token="test-token") try: + # 公共页面与公共 API 访客可见 status, _, body = running.json("/api/overview") + assert status == 200 and body["ok"] is True + status, _, body = running.json("/api/videos") + assert status == 200 and body["ok"] is True + status, _, body = running.json("/api/fans?days=7") + assert status == 200 and body["ok"] is True + status, _, payload = running.request("/") + assert status == 200 and b"