Add server-side login fallback

This commit is contained in:
Codex 2026-05-14 20:09:55 +08:00
parent b42059f4c8
commit da5533b5cb
4 changed files with 36 additions and 4 deletions

View File

@ -9,10 +9,10 @@
</head>
<body>
<section id="auth-gate" class="auth-gate" hidden>
<form id="auth-form" class="auth-card">
<form id="auth-form" class="auth-card" action="/auth/login" method="post">
<div class="auth-title">输入访问密码</div>
<p>云端部署时需要先验证,验证后这台设备会记住登录状态。</p>
<input id="auth-password" type="password" autocomplete="current-password" placeholder="访问密码" required>
<input id="auth-password" name="password" type="password" autocomplete="current-password" placeholder="访问密码" required>
<button id="auth-submit" type="submit">进入系统</button>
<div id="auth-message" class="auth-message" aria-live="polite"></div>
</form>

View File

@ -10,10 +10,10 @@
</head>
<body>
<section id="auth-gate" class="auth-gate" hidden>
<form id="auth-form" class="auth-card">
<form id="auth-form" class="auth-card" action="/auth/login" method="post">
<div class="auth-title">输入访问密码</div>
<p>云端使用时需要先验证,验证后这台手机会记住登录状态。</p>
<input id="auth-password" type="password" autocomplete="current-password" placeholder="访问密码" required>
<input id="auth-password" name="password" type="password" autocomplete="current-password" placeholder="访问密码" required>
<button id="auth-submit" type="submit">进入手机版</button>
<div id="auth-message" class="auth-message" aria-live="polite"></div>
</form>

View File

@ -76,6 +76,15 @@ const server = http.createServer(async (request, response) => {
return sendJson(response, 200, { enabled: true, token: ACCESS_TOKEN });
}
if (url.pathname === "/auth/login" && request.method === "POST") {
const body = await readFormBody(request);
if (!ACCESS_PASSWORD || timingSafeEqualText(String(body.password || ""), ACCESS_PASSWORD)) {
if (ACCESS_PASSWORD) response.setHeader("set-cookie", cookieHeader(ACCESS_TOKEN));
return sendRedirect(response, body.next || "/");
}
return sendRedirect(response, "/?auth_error=1");
}
if (url.pathname.startsWith("/api/") && !isAuthorizedRequest(request, url)) {
return sendAuthRequired(response);
}
@ -841,6 +850,14 @@ async function readJsonBody(request) {
return JSON.parse(content);
}
async function readFormBody(request) {
const chunks = [];
for await (const chunk of request) chunks.push(chunk);
const content = Buffer.concat(chunks).toString("utf8");
const params = new URLSearchParams(content);
return Object.fromEntries(params.entries());
}
async function readImageUploadBody(request) {
const body = await readJsonBody(request);
const type = String(body.type || "").toLowerCase();
@ -916,6 +933,12 @@ function sendText(response, status, text, type) {
response.end(text);
}
function sendRedirect(response, location) {
const safeLocation = String(location || "/").startsWith("/") ? String(location || "/") : "/";
response.writeHead(303, { location: safeLocation });
response.end();
}
function contentType(filePath) {
const ext = path.extname(filePath).toLowerCase();
return {

View File

@ -15,6 +15,9 @@ test("server supports optional shared access password authentication", () => {
assert.match(server, /HOTNESS_ACCESS_PASSWORD/);
assert.match(server, /\/api\/auth\/status/);
assert.match(server, /\/api\/auth\/login/);
assert.match(server, /\/auth\/login/);
assert.match(server, /readFormBody/);
assert.match(server, /sendRedirect/);
assert.match(server, /isAuthorizedRequest/);
assert.match(server, /sendAuthRequired/);
assert.match(server, /x-hotness-auth-token/i);
@ -22,6 +25,9 @@ test("server supports optional shared access password authentication", () => {
test("desktop page has a password gate and sends auth token with API calls", () => {
assert.match(desktopHtml, /id="auth-gate"/);
assert.match(desktopHtml, /action="\/auth\/login"/);
assert.match(desktopHtml, /method="post"/);
assert.match(desktopHtml, /name="password"/);
assert.match(desktopHtml, /id="auth-password"/);
assert.match(desktopJs, /HOTNESS_AUTH_TOKEN_KEY/);
assert.match(desktopJs, /ensureAccessAuth/);
@ -43,6 +49,9 @@ test("desktop login submit is bound before the rest of the app can fail", () =>
test("mobile page has the same password gate for cloud use", () => {
assert.match(mobileHtml, /id="auth-gate"/);
assert.match(mobileHtml, /action="\/auth\/login"/);
assert.match(mobileHtml, /method="post"/);
assert.match(mobileHtml, /name="password"/);
assert.match(mobileHtml, /id="auth-password"/);
assert.match(mobileJs, /HOTNESS_AUTH_TOKEN_KEY/);
assert.match(mobileJs, /ensureAccessAuth/);