feat(web): require login only for candidates and system

This commit is contained in:
kairee committed 2026-11-01 03:09:21 +08:00
1 parent 55c40db047
commit 6aefe9ce87
6 files changed
+130 -32

No files matched your search

+31 -9
View File
@@ -18,7 +18,7 @@ from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path from pathlib import Path
from typing import Any, Callable, Mapping 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 ..analytics import is_loopback
from ..config import ConfigStore from ..config import ConfigStore
@@ -28,6 +28,8 @@ from .queries import DashboardQueries
STATIC_ROOT = Path(__file__).resolve().parent / "static" STATIC_ROOT = Path(__file__).resolve().parent / "static"
COOKIE_NAME = "rb_session" COOKIE_NAME = "rb_session"
SESSION_TTL = 7 * 86400 SESSION_TTL = 7 * 86400
PROTECTED_API = {"/api/candidates", "/api/system"}
PROTECTED_PAGES = {"/candidates", "/system"}
class ApiError(Exception): class ApiError(Exception):
@@ -117,18 +119,26 @@ class DashboardHandler(BaseHTTPRequestHandler):
}) })
return 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 path == "/login":
if method == "POST": if method == "POST":
self._login_post() self._login_post(query)
else: else:
self._login_page() self._login_page(query)
return return
if path == "/logout": if path == "/logout":
self._logout() self._logout()
return return
if path.startswith("/api/"): if path.startswith("/api/"):
if not self._authorized(): if path in PROTECTED_API and not self._authorized():
self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "需要登录") self._send_error(HTTPStatus.UNAUTHORIZED, "unauthorized", "需要登录")
return return
if method == "POST": if method == "POST":
@@ -141,8 +151,8 @@ class DashboardHandler(BaseHTTPRequestHandler):
self._api(path, query, method) self._api(path, query, method)
return return
if not self._authorized(): if path in PROTECTED_PAGES and not self._authorized():
self._redirect("/login") self._redirect(f"/login?next={quote(path)}")
return return
self._static(path, method) self._static(path, method)
except ApiError as exc: except ApiError as exc:
@@ -203,10 +213,11 @@ class DashboardHandler(BaseHTTPRequestHandler):
morsel = cookie.get(COOKIE_NAME) morsel = cookie.get(COOKIE_NAME)
return morsel is not None and self._valid_session(morsel.value) 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: if not self.context.auth_token:
self._redirect("/") self._redirect("/")
return return
next_path = _safe_next(_param(query, "next", ""))
html = """<!doctype html> html = """<!doctype html>
<html lang="zh-CN"><head><meta charset="utf-8"> <html lang="zh-CN"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1"> <meta name="viewport" content="width=device-width,initial-scale=1">
@@ -230,9 +241,12 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
<input type="password" name="token" autocomplete="current-password" placeholder="访问令牌" required autofocus> <input type="password" name="token" autocomplete="current-password" placeholder="访问令牌" required autofocus>
<button type="submit">进入看板</button> <button type="submit">进入看板</button>
</form></main></body></html>""" </form></main></body></html>"""
html = html.replace(
'action="/login"', f'action="/login?next={quote(next_path)}"'
)
self._send_html(HTTPStatus.OK, html) 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: if not self.context.auth_token:
self._redirect("/") self._redirect("/")
return return
@@ -250,8 +264,9 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
return return
expires_at = int(self.context.clock()) + SESSION_TTL expires_at = int(self.context.clock()) + SESSION_TTL
value = self._session_value(expires_at) value = self._session_value(expires_at)
target = _safe_next(_param(query, "next", ""))
self.send_response(HTTPStatus.SEE_OTHER) self.send_response(HTTPStatus.SEE_OTHER)
self.send_header("Location", "/") self.send_header("Location", target)
self.send_header("Set-Cookie", ( self.send_header("Set-Cookie", (
f"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Lax; " f"{COOKIE_NAME}={value}; Path=/; HttpOnly; SameSite=Lax; "
f"Max-Age={SESSION_TTL}" f"Max-Age={SESSION_TTL}"
@@ -481,6 +496,13 @@ border-color:#3a2c22;color:#f3e9dc}button{background:#e9b97c;color:#2a1a10}}
# ------------------------------------------------------------ helpers # ------------------------------------------------------------ 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: def _param(query: Mapping[str, list[str]], name: str, default: str) -> str:
values = query.get(name) values = query.get(name)
return default if not values else str(values[0]) return default if not values else str(values[0])
+7
View File
@@ -50,3 +50,10 @@ export async function health() {
const payload = await response.json(); const payload = await response.json();
return payload?.data ?? payload; 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;
}
+42 -11
View File
@@ -1,7 +1,6 @@
import { health } from "./api.js"; import { session } from "./api.js";
import { disposeAll } from "./charts.js"; import { disposeAll } from "./charts.js";
import { escapeHtml } from "./format.js"; import { onRoute, parseLocation, startRouter } from "./router.js";
import { onRoute, startRouter } from "./router.js";
import { renderCandidates } from "./views/candidates.js"; import { renderCandidates } from "./views/candidates.js";
import { renderFans } from "./views/fans.js"; import { renderFans } from "./views/fans.js";
import { renderHome } from "./views/home.js"; import { renderHome } from "./views/home.js";
@@ -14,8 +13,8 @@ const NAV_ITEMS = [
{ path: "/home", label: "首页", icon: "home" }, { path: "/home", label: "首页", icon: "home" },
{ path: "/rank", label: "榜单", icon: "rank" }, { path: "/rank", label: "榜单", icon: "rank" },
{ path: "/fans", label: "粉丝", icon: "fans" }, { path: "/fans", label: "粉丝", icon: "fans" },
{ path: "/candidates", label: "候选", icon: "candidate" }, { path: "/candidates", label: "候选", icon: "candidate", protected: true },
{ path: "/system", label: "系统", icon: "system" }, { path: "/system", label: "系统", icon: "system", protected: true },
]; ];
const ROUTES = [ const ROUTES = [
@@ -24,10 +23,32 @@ const ROUTES = [
{ pattern: /^\/video\/(\d+)$/, view: renderVideo, title: "视频详情" }, { pattern: /^\/video\/(\d+)$/, view: renderVideo, title: "视频详情" },
{ pattern: /^\/up\/(\d+)$/, view: renderUp, title: "UP 详情" }, { pattern: /^\/up\/(\d+)$/, view: renderUp, title: "UP 详情" },
{ pattern: /^\/fans$/, view: renderFans, title: "粉丝" }, { 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) { function icon(name) {
const paths = { const paths = {
home: '<path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z"/>', home: '<path d="M4 10.5 12 4l8 6.5V20a1 1 0 0 1-1 1h-5v-6H10v6H5a1 1 0 0 1-1-1z"/>',
@@ -42,7 +63,10 @@ function icon(name) {
function renderNav() { function renderNav() {
const nav = document.querySelector("#bottom-nav"); 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) => `
<a class="nav-item" href="${item.path}" data-nav="${item.path}"> <a class="nav-item" href="${item.path}" data-nav="${item.path}">
${icon(item.icon)}<span class="nav-label">${item.label}</span> ${icon(item.icon)}<span class="nav-label">${item.label}</span>
</a>`).join(""); </a>`).join("");
@@ -88,19 +112,26 @@ window.addEventListener("error", (event) => {
}, true); }, true);
async function boot() { async function boot() {
renderNav();
const banner = document.querySelector("#preview-banner"); const banner = document.querySelector("#preview-banner");
const hint = document.querySelector("#topbar-hint"); const hint = document.querySelector("#topbar-hint");
const authLink = document.querySelector("#topbar-auth");
try { try {
const payload = await health(); const data = await session();
const data = payload?.data || payload; authState = {
authEnabled: Boolean(data?.auth_enabled),
authenticated: data?.authenticated !== false,
};
if (data?.preview) { if (data?.preview) {
banner.hidden = false; banner.hidden = false;
if (hint) hint.hidden = false; if (hint) hint.hidden = false;
} }
if (authLink && authState.authEnabled && !authState.authenticated) {
authLink.hidden = false;
}
} catch { } catch {
toast("无法连接看板服务", "error"); toast("无法连接看板服务", "error");
} }
renderNav();
onRoute(({ path, route }) => { onRoute(({ path, route }) => {
disposeAll(); disposeAll();
setTitle(route, path); setTitle(route, path);
+1
View File
@@ -20,6 +20,7 @@
<small id="topbar-title">最新动态</small> <small id="topbar-title">最新动态</small>
</span> </span>
</a> </a>
<a class="topbar-hint" id="topbar-auth" href="/login" hidden>登录</a>
<span class="topbar-hint" id="topbar-hint" hidden>预览</span> <span class="topbar-hint" id="topbar-hint" hidden>预览</span>
</header> </header>
<div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div> <div class="preview-banner" id="preview-banner" hidden>预览数据 · 不影响真实数据库</div>
+4
View File
@@ -125,6 +125,10 @@ async function render(options = {}) {
finishRender(); finishRender();
return; return;
} }
if (matched.route.beforeEnter && matched.route.beforeEnter() === false) {
finishRender();
return;
}
current = { path, query, route: matched.route }; current = { path, query, route: matched.route };
for (const listener of listeners) { for (const listener of listeners) {
try { try {
+45 -12
View File
@@ -65,6 +65,15 @@ def test_readonly_routes_and_static():
assert status == 200 and health["ok"] is True assert status == 200 and health["ok"] is True
assert health["data"]["preview"] is False 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") status, _, overview = running.json("/api/overview")
assert status == 200 and overview["ok"] is True assert status == 200 and overview["ok"] is True
assert overview["data"]["data_date"] == "2026-10-31" assert overview["data"]["data_date"] == "2026-10-31"
@@ -169,16 +178,38 @@ def test_readonly_routes_and_static():
def test_token_auth_and_login(): def test_token_auth_and_login():
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
running = RunningServer(tmp, token="secret-token") running = RunningServer(tmp, token="test-token")
try: try:
# 公共页面与公共 API 访客可见
status, _, body = running.json("/api/overview") 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"<html" in payload.lower()
status, _, payload = running.request("/rank")
assert status == 200 and b"<html" in payload.lower()
# 候选与系统需要登录
status, _, body = running.json("/api/candidates")
assert status == 401 and body["error"]["code"] == "unauthorized" assert status == 401 and body["error"]["code"] == "unauthorized"
status, _, body = running.json("/api/system")
assert status == 401
status, headers, _ = running.request("/candidates")
assert status == 303 and headers["Location"] == "/login?next=/candidates"
status, headers, _ = running.request("/system")
assert status == 303 and headers["Location"] == "/login?next=/system"
status, headers, _ = running.request("/") status, _, body = running.json("/api/session")
assert status == 303 and headers["Location"] == "/login" assert status == 200
assert body["data"]["auth_enabled"] is True
assert body["data"]["authenticated"] is False
status, _, payload = running.request("/login") status, _, payload = running.request("/login?next=/candidates")
assert status == 200 and b"web.auth_token" in payload assert status == 200 and b"web.auth_token" in payload
assert b"action=\"/login?next=/candidates\"" in payload
status, _, _ = running.request( status, _, _ = running.request(
"/login", "/login",
@@ -189,28 +220,30 @@ def test_token_auth_and_login():
assert status == 401 assert status == 401
status, headers, _ = running.request( status, headers, _ = running.request(
"/login", "/login?next=/candidates",
method="POST", method="POST",
headers={"Content-Type": "application/x-www-form-urlencoded"}, headers={"Content-Type": "application/x-www-form-urlencoded"},
body="token=secret-token", body="token=test-token",
) )
assert status == 303 assert status == 303 and headers["Location"] == "/candidates"
cookie = headers["Set-Cookie"].split(";", 1)[0] cookie = headers["Set-Cookie"].split(";", 1)[0]
assert cookie.startswith("rb_session=") assert cookie.startswith("rb_session=")
status, _, body = running.json("/api/overview", headers={"Cookie": cookie}) status, _, body = running.json("/api/candidates", headers={"Cookie": cookie})
assert status == 200 and body["ok"] is True
status, _, body = running.json("/api/system", headers={"Cookie": cookie})
assert status == 200 and body["ok"] is True assert status == 200 and body["ok"] is True
status, _, body = running.json( status, _, body = running.json(
"/api/overview", headers={"Authorization": "Bearer secret-token"} "/api/candidates", headers={"Authorization": "Bearer test-token"}
) )
assert status == 200 assert status == 200
tampered = cookie[:-1] + ("0" if cookie[-1] != "0" else "1") tampered = cookie[:-1] + ("0" if cookie[-1] != "0" else "1")
status, _, _ = running.json("/api/overview", headers={"Cookie": tampered}) status, _, _ = running.json("/api/candidates", headers={"Cookie": tampered})
assert status == 401 assert status == 401
status, _, body = running.json("/api/overview", headers={"Cookie": tampered})
assert status == 200
# 健康检查始终匿名
status, _, _ = running.json("/api/health") status, _, _ = running.json("/api/health")
assert status == 200 assert status == 200
finally: finally: