Commit b8c474b837370d3be9911fba0863024cb108c248

Authored by yibo
1 parent 8ffb7e2b

Add minimal admin auth gate for /api/admin/* (T6)

Single shared password (.env ADMIN_PASSWORD) + in-memory HttpOnly
session cookie, no accounts/roles — MVCP M.1/M.10's real account
system supersedes this in period 3. Covers the existing
/api/admin/library/* routes (previously unauthenticated, see
architecture.md 1.2's tech-debt note) plus the new upload endpoint.

admin.html gets a fetch() interceptor that redirects to the new
admin-login.html on any 401 from /api/admin/*, rather than patching
every call site.
admin-auth.test.js 0 → 100644
  1 +// Integration test for the minimal admin auth gate on /api/admin/*
  2 +// (dev-plan.md T6). Spawns server.js on a throwaway port, same isolation
  3 +// approach as uploads-route.test.js.
  4 +const test = require("node:test");
  5 +const assert = require("node:assert/strict");
  6 +const { spawn } = require("node:child_process");
  7 +
  8 +const PORT = 19934 + Math.floor(Math.random() * 1000);
  9 +const BASE = `http://localhost:${PORT}`;
  10 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  11 +
  12 +function waitForServer(url, timeoutMs = 10000) {
  13 + const deadline = Date.now() + timeoutMs;
  14 + return (async function poll() {
  15 + while (Date.now() < deadline) {
  16 + try {
  17 + await fetch(url);
  18 + return;
  19 + } catch {
  20 + await new Promise((r) => setTimeout(r, 100));
  21 + }
  22 + }
  23 + throw new Error("server did not come up in time");
  24 + })();
  25 +}
  26 +
  27 +function sessionCookie(loginRes) {
  28 + const setCookie = loginRes.headers.get("set-cookie") || "";
  29 + return setCookie.split(";")[0];
  30 +}
  31 +
  32 +let child;
  33 +test.before(async () => {
  34 + child = spawn(process.execPath, ["server.js"], {
  35 + cwd: __dirname,
  36 + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD },
  37 + stdio: "pipe",
  38 + });
  39 + await waitForServer(BASE);
  40 +});
  41 +
  42 +test.after(() => child.kill());
  43 +
  44 +test("any /api/admin/* route returns 401 without a session cookie", async () => {
  45 + const res = await fetch(`${BASE}/api/admin/library`, {
  46 + method: "POST",
  47 + headers: { "Content-Type": "application/json" },
  48 + body: JSON.stringify({ prompt: "x", videoUrl: "http://example.com/x.mp4" }),
  49 + });
  50 + assert.equal(res.status, 401);
  51 +});
  52 +
  53 +test("logging in with the wrong password is rejected and sets no cookie", async () => {
  54 + const res = await fetch(`${BASE}/api/admin/login`, {
  55 + method: "POST",
  56 + headers: { "Content-Type": "application/json" },
  57 + body: JSON.stringify({ password: "definitely-wrong" }),
  58 + });
  59 + assert.equal(res.status, 401);
  60 + assert.equal(res.headers.get("set-cookie"), null);
  61 +});
  62 +
  63 +test("logging in with the right password grants a session that unlocks /api/admin/*, and logout revokes it", async () => {
  64 + const loginRes = await fetch(`${BASE}/api/admin/login`, {
  65 + method: "POST",
  66 + headers: { "Content-Type": "application/json" },
  67 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  68 + });
  69 + assert.equal(loginRes.status, 200);
  70 + const cookie = sessionCookie(loginRes);
  71 + assert.match(cookie, /^admin_session=.+/);
  72 +
  73 + const authedRes = await fetch(`${BASE}/api/admin/library`, {
  74 + method: "POST",
  75 + headers: { "Content-Type": "application/json", Cookie: cookie },
  76 + body: JSON.stringify({ prompt: "authed test entry", videoUrl: "http://example.com/x.mp4" }),
  77 + });
  78 + assert.equal(authedRes.status, 200);
  79 + const created = await authedRes.json();
  80 +
  81 + // clean up the entry this test created in the real library.json
  82 + await fetch(`${BASE}/api/admin/library/${created.id}`, { method: "DELETE", headers: { Cookie: cookie } });
  83 +
  84 + const logoutRes = await fetch(`${BASE}/api/admin/logout`, { method: "POST", headers: { Cookie: cookie } });
  85 + assert.equal(logoutRes.status, 200);
  86 +
  87 + const afterLogout = await fetch(`${BASE}/api/admin/library`, {
  88 + method: "POST",
  89 + headers: { "Content-Type": "application/json", Cookie: cookie },
  90 + body: JSON.stringify({ prompt: "should be rejected", videoUrl: "http://example.com/x.mp4" }),
  91 + });
  92 + assert.equal(afterLogout.status, 401);
  93 +});
admin-login.html 0 → 100644
  1 +<!DOCTYPE html>
  2 +<html lang="zh-CN">
  3 +<head>
  4 +<meta charset="UTF-8" />
  5 +<title>管理员登录</title>
  6 +<link rel="stylesheet" href="style.css" />
  7 +<style>
  8 + .login-wrap { max-width: 360px; margin: 15vh auto; padding: 24px; }
  9 + .login-wrap h1 { font-size: 18px; margin-bottom: 16px; }
  10 + .login-wrap input { width: 100%; margin-bottom: 12px; }
  11 + .login-wrap .err { color: #c0392b; font-size: 13px; min-height: 18px; margin-bottom: 8px; }
  12 +</style>
  13 +</head>
  14 +<body>
  15 +<div class="login-wrap">
  16 + <h1>管理员登录</h1>
  17 + <input type="password" id="password" placeholder="管理员口令" autofocus />
  18 + <div class="err" id="err"></div>
  19 + <button id="loginBtn">登录</button>
  20 +</div>
  21 +<script>
  22 + const params = new URLSearchParams(location.search);
  23 + const next = params.get("next") || "/admin.html";
  24 +
  25 + async function login() {
  26 + const password = document.getElementById("password").value;
  27 + const errBox = document.getElementById("err");
  28 + errBox.textContent = "";
  29 + try {
  30 + const res = await fetch("/api/admin/login", {
  31 + method: "POST",
  32 + headers: { "Content-Type": "application/json" },
  33 + body: JSON.stringify({ password }),
  34 + });
  35 + if (!res.ok) {
  36 + errBox.textContent = "口令错误";
  37 + return;
  38 + }
  39 + location.href = next;
  40 + } catch (e) {
  41 + errBox.textContent = "登录请求失败: " + e.message;
  42 + }
  43 + }
  44 +
  45 + document.getElementById("loginBtn").onclick = login;
  46 + document.getElementById("password").addEventListener("keydown", (e) => {
  47 + if (e.key === "Enter") login();
  48 + });
  49 +</script>
  50 +</body>
  51 +</html>
admin.html
@@ -80,6 +80,17 @@ @@ -80,6 +80,17 @@
80 </main> 80 </main>
81 81
82 <script> 82 <script>
  83 +// Redirect to login whenever any /api/admin/* call comes back 401 (T6 auth
  84 +// gate) — a single interceptor instead of touching every fetch call site.
  85 +const _fetch = window.fetch;
  86 +window.fetch = async (...args) => {
  87 + const res = await _fetch(...args);
  88 + if (res.status === 401 && String(args[0]).startsWith("/api/admin/") && !String(args[0]).includes("/login")) {
  89 + location.href = "/admin-login.html?next=" + encodeURIComponent(location.pathname);
  90 + }
  91 + return res;
  92 +};
  93 +
83 let library = []; 94 let library = [];
84 let editingId = null; 95 let editingId = null;
85 let sortKey = "createdAt"; 96 let sortKey = "createdAt";
server.js
@@ -6,6 +6,7 @@ const http = require(&quot;http&quot;); @@ -6,6 +6,7 @@ const http = require(&quot;http&quot;);
6 const https = require("https"); 6 const https = require("https");
7 const fs = require("fs"); 7 const fs = require("fs");
8 const path = require("path"); 8 const path = require("path");
  9 +const crypto = require("crypto");
9 10
10 try { 11 try {
11 process.loadEnvFile(path.join(__dirname, ".env")); 12 process.loadEnvFile(path.join(__dirname, ".env"));
@@ -90,6 +91,37 @@ function readJsonBody(req) { @@ -90,6 +91,37 @@ function readJsonBody(req) {
90 }); 91 });
91 } 92 }
92 93
  94 +// Minimal admin auth (dev-plan.md T6): one shared password from .env, an
  95 +// HttpOnly session cookie, no accounts/roles/DB — the account system in
  96 +// MVCP M.1/M.10 supersedes this in period 3. Sessions are in-memory only,
  97 +// so a process restart logs everyone out (acceptable for this stopgap).
  98 +const ADMIN_PASSWORD = process.env.ADMIN_PASSWORD || "";
  99 +const ADMIN_SESSION_COOKIE = "admin_session";
  100 +const ADMIN_SESSION_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30 days, matches MVCP M.10's session TTL
  101 +const adminSessions = new Map(); // token -> expiresAt
  102 +
  103 +function parseCookies(req) {
  104 + const header = req.headers.cookie || "";
  105 + const cookies = {};
  106 + for (const pair of header.split(";")) {
  107 + const idx = pair.indexOf("=");
  108 + if (idx === -1) continue;
  109 + cookies[pair.slice(0, idx).trim()] = decodeURIComponent(pair.slice(idx + 1).trim());
  110 + }
  111 + return cookies;
  112 +}
  113 +
  114 +function isAdminAuthed(req) {
  115 + const token = parseCookies(req)[ADMIN_SESSION_COOKIE];
  116 + if (!token) return false;
  117 + const expiresAt = adminSessions.get(token);
  118 + if (!expiresAt || expiresAt < Date.now()) {
  119 + adminSessions.delete(token);
  120 + return false;
  121 + }
  122 + return true;
  123 +}
  124 +
93 function h3ConsoleFetch(pathAndQuery, options) { 125 function h3ConsoleFetch(pathAndQuery, options) {
94 return new Promise((resolve, reject) => { 126 return new Promise((resolve, reject) => {
95 const proxyReq = http.request( 127 const proxyReq = http.request(
@@ -469,6 +501,46 @@ http @@ -469,6 +501,46 @@ http
469 501
470 const pathname = req.url.split("?")[0]; 502 const pathname = req.url.split("?")[0];
471 503
  504 + if (pathname === "/api/admin/login" && req.method === "POST") {
  505 + readJsonBody(req)
  506 + .then((body) => {
  507 + if (!ADMIN_PASSWORD || body.password !== ADMIN_PASSWORD) {
  508 + res.writeHead(401, { "Content-Type": "application/json" });
  509 + res.end(JSON.stringify({ error: "invalid password" }));
  510 + return;
  511 + }
  512 + const token = crypto.randomUUID();
  513 + adminSessions.set(token, Date.now() + ADMIN_SESSION_TTL_MS);
  514 + res.writeHead(200, {
  515 + "Content-Type": "application/json",
  516 + "Set-Cookie": `${ADMIN_SESSION_COOKIE}=${token}; HttpOnly; Path=/; Max-Age=${Math.floor(ADMIN_SESSION_TTL_MS / 1000)}; SameSite=Lax`,
  517 + });
  518 + res.end(JSON.stringify({ ok: true }));
  519 + })
  520 + .catch(() => {
  521 + res.writeHead(400, { "Content-Type": "application/json" });
  522 + res.end(JSON.stringify({ error: "invalid JSON body" }));
  523 + });
  524 + return;
  525 + }
  526 +
  527 + if (pathname === "/api/admin/logout" && req.method === "POST") {
  528 + const token = parseCookies(req)[ADMIN_SESSION_COOKIE];
  529 + if (token) adminSessions.delete(token);
  530 + res.writeHead(200, {
  531 + "Content-Type": "application/json",
  532 + "Set-Cookie": `${ADMIN_SESSION_COOKIE}=; HttpOnly; Path=/; Max-Age=0`,
  533 + });
  534 + res.end(JSON.stringify({ ok: true }));
  535 + return;
  536 + }
  537 +
  538 + if (pathname.startsWith("/api/admin/") && !isAdminAuthed(req)) {
  539 + res.writeHead(401, { "Content-Type": "application/json" });
  540 + res.end(JSON.stringify({ error: "unauthorized" }));
  541 + return;
  542 + }
  543 +
472 if (pathname === "/api/library") { 544 if (pathname === "/api/library") {
473 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest"; 545 const sort = new URL(req.url, "http://x").searchParams.get("sort") || "newest";
474 const lib = getLibrary().slice(); 546 const lib = getLibrary().slice();
uploads-route.test.js
@@ -18,6 +18,8 @@ const TINY_VALID_PNG = Buffer.from( @@ -18,6 +18,8 @@ const TINY_VALID_PNG = Buffer.from(
18 18
19 const PORT = 18934 + Math.floor(Math.random() * 1000); 19 const PORT = 18934 + Math.floor(Math.random() * 1000);
20 const BASE = `http://localhost:${PORT}`; 20 const BASE = `http://localhost:${PORT}`;
  21 +const ADMIN_PASSWORD = "test-password-only-for-this-run";
  22 +let cookie;
21 23
22 function waitForServer(url, timeoutMs = 10000) { 24 function waitForServer(url, timeoutMs = 10000) {
23 const deadline = Date.now() + timeoutMs; 25 const deadline = Date.now() + timeoutMs;
@@ -41,10 +43,17 @@ test.before(async () =&gt; { @@ -41,10 +43,17 @@ test.before(async () =&gt; {
41 // H3_CONSOLE_BASE cleared: this test only exercises the upload route, 43 // H3_CONSOLE_BASE cleared: this test only exercises the upload route,
42 // no need for the spawned copy to also hit the real H3 Console / 44 // no need for the spawned copy to also hit the real H3 Console /
43 // race the live pm2 instance on library.json's periodic sync. 45 // race the live pm2 instance on library.json's periodic sync.
44 - env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "" }, 46 + env: { ...process.env, PORT: String(PORT), H3_CONSOLE_BASE: "", H3_CONSOLE_TOKEN: "", ADMIN_PASSWORD },
45 stdio: "pipe", 47 stdio: "pipe",
46 }); 48 });
47 await waitForServer(BASE); 49 await waitForServer(BASE);
  50 +
  51 + const loginRes = await fetch(`${BASE}/api/admin/login`, {
  52 + method: "POST",
  53 + headers: { "Content-Type": "application/json" },
  54 + body: JSON.stringify({ password: ADMIN_PASSWORD }),
  55 + });
  56 + cookie = (loginRes.headers.get("set-cookie") || "").split(";")[0];
48 }); 57 });
49 58
50 test.after(() => { 59 test.after(() => {
@@ -59,7 +68,7 @@ test(&quot;uploading a real PNG returns a URL that GET /uploads/* serves back with th @@ -59,7 +68,7 @@ test(&quot;uploading a real PNG returns a URL that GET /uploads/* serves back with th
59 const fd = new FormData(); 68 const fd = new FormData();
60 fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png"); 69 fd.append("file", new Blob([TINY_VALID_PNG], { type: "image/png" }), "photo.png");
61 70
62 - const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }); 71 + const uploadRes = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd });
63 assert.equal(uploadRes.status, 200); 72 assert.equal(uploadRes.status, 200);
64 const { url } = await uploadRes.json(); 73 const { url } = await uploadRes.json();
65 assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.png$/); 74 assert.match(url, /^\/uploads\/\d{6}\/[0-9a-f-]+\.png$/);
@@ -74,7 +83,7 @@ test(&quot;a zip disguised as .jpg is rejected regardless of filename/content-type cl @@ -74,7 +83,7 @@ test(&quot;a zip disguised as .jpg is rejected regardless of filename/content-type cl
74 const fd = new FormData(); 83 const fd = new FormData();
75 fd.append("file", new Blob([zipMagic], { type: "image/jpeg" }), "totally-a-photo.jpg"); 84 fd.append("file", new Blob([zipMagic], { type: "image/jpeg" }), "totally-a-photo.jpg");
76 85
77 - const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }); 86 + const res = await fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd });
78 assert.equal(res.status, 400); 87 assert.equal(res.status, 400);
79 }); 88 });
80 89
@@ -89,7 +98,7 @@ test(&quot;a file over 8MB is rejected&quot;, async () =&gt; { @@ -89,7 +98,7 @@ test(&quot;a file over 8MB is rejected&quot;, async () =&gt; {
89 fd.append("file", new Blob([oversized], { type: "image/jpeg" }), "huge.jpg"); 98 fd.append("file", new Blob([oversized], { type: "image/jpeg" }), "huge.jpg");
90 99
91 await assert.rejects( 100 await assert.rejects(
92 - () => fetch(`${BASE}/api/admin/upload`, { method: "POST", body: fd }), 101 + () => fetch(`${BASE}/api/admin/upload`, { method: "POST", headers: { Cookie: cookie }, body: fd }),
93 /fetch failed|ECONNRESET|terminated/i 102 /fetch failed|ECONNRESET|terminated/i
94 ); 103 );
95 }); 104 });