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: '