121 lines
7.3 KiB
TypeScript
121 lines
7.3 KiB
TypeScript
import { resolve } from "node:path";
|
|
import type { AppConfig } from "./config";
|
|
import type { ApplicationDispatcher } from "./dispatcher";
|
|
import {
|
|
apiKeyAuthorized,
|
|
clearSessionCookie,
|
|
createSessionCookie,
|
|
sessionAuthorized,
|
|
validLogin,
|
|
type WebAuthSecrets,
|
|
} from "./web-auth";
|
|
|
|
const staticRoot = resolve(import.meta.dir, "../static");
|
|
|
|
function json(data: unknown, status = 200, headers: HeadersInit = {}): Response {
|
|
return Response.json(data, { status, headers: { "cache-control": "no-store", ...headers } });
|
|
}
|
|
|
|
function redirect(location: string): Response {
|
|
return new Response(null, { status: 302, headers: { location, "cache-control": "no-store" } });
|
|
}
|
|
|
|
async function body(request: Request): Promise<Record<string, unknown>> {
|
|
return await request.json().catch(() => ({})) as Record<string, unknown>;
|
|
}
|
|
|
|
function errorResponse(error: unknown): Response {
|
|
const message = error instanceof Error ? error.message : String(error);
|
|
const status = /does not exist|no draw chance|no eligible/u.test(message) ? 409 : 500;
|
|
if (status >= 500) console.error(JSON.stringify({ ok: false, component: "http", error: message }));
|
|
return json({ ok: false, error: status >= 500 ? "服务暂时不可用,请稍后重试" : message }, status);
|
|
}
|
|
|
|
async function staticFile(name: string, contentType: string): Promise<Response> {
|
|
const file = Bun.file(resolve(staticRoot, name));
|
|
if (!(await file.exists())) return json({ ok: false, error: "not found" }, 404);
|
|
return new Response(file, {
|
|
headers: {
|
|
"content-type": contentType,
|
|
"cache-control": name.endsWith(".html") ? "no-cache" : "public, max-age=300",
|
|
},
|
|
});
|
|
}
|
|
|
|
export function createHandler(
|
|
dispatcher: ApplicationDispatcher,
|
|
config: AppConfig,
|
|
auth: WebAuthSecrets,
|
|
legacyAdminToken: string,
|
|
): (request: Request) => Promise<Response> {
|
|
return async (request) => {
|
|
const url = new URL(request.url);
|
|
const session = sessionAuthorized(request, config, auth);
|
|
const apiKey = apiKeyAuthorized(request, auth) || request.headers.get("authorization") === `Bearer ${legacyAdminToken}`;
|
|
try {
|
|
if (request.method === "GET" && url.pathname === "/health") {
|
|
const scores = await dispatcher.dispatch({ kind: "scores.get" }) as Record<string, unknown>;
|
|
return json({ ok: true, service: "apistate-api", scoreStatus: scores.status, refreshedAt: scores.refreshedAt });
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/login") {
|
|
const input = await body(request);
|
|
if (!validLogin(input.username, input.password, config, auth)) return json({ ok: false, error: "用户名或密码错误" }, 401);
|
|
return json({ ok: true, username: config.webAuth.username }, 200, { "set-cookie": createSessionCookie(config, auth) });
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/logout") {
|
|
return json({ ok: true }, 200, { "set-cookie": clearSessionCookie(config) });
|
|
}
|
|
if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/login") {
|
|
return session ? redirect("/scores") : await staticFile("login.html", "text/html; charset=utf-8");
|
|
}
|
|
if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/styles.css") return await staticFile("styles.css", "text/css; charset=utf-8");
|
|
if ((request.method === "GET" || request.method === "HEAD") && url.pathname === "/app.js") return await staticFile("app.js", "text/javascript; charset=utf-8");
|
|
if (request.method === "GET" && url.pathname === "/") return redirect(session ? "/scores" : "/login");
|
|
const page = ({ "/scores": "scores.html", "/ranking": "ranking.html", "/lottery": "lottery.html" } as Record<string, string>)[url.pathname];
|
|
if (page) return session ? await staticFile(page, "text/html; charset=utf-8") : redirect("/login");
|
|
|
|
if (url.pathname.startsWith("/api/") && !session && !apiKey) return json({ ok: false, error: "unauthorized" }, 401);
|
|
if (request.method === "GET" && url.pathname === "/api/status") {
|
|
const scores = await dispatcher.dispatch({ kind: "scores.get" }) as Record<string, unknown>;
|
|
return json({ ok: true, service: "apistate", scoreStatus: scores.status, refreshedAt: scores.refreshedAt, nextRefreshAt: scores.nextRefreshAt });
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/api/scores") {
|
|
const state = await dispatcher.dispatch({ kind: "scores.get" }) as Record<string, unknown>;
|
|
return json({ ...state, snapshotOk: state.ok, ok: true });
|
|
}
|
|
if (request.method === "POST" && url.pathname === "/api/scores/refresh") {
|
|
const state = await dispatcher.dispatch({ kind: "scores.refresh" }) as Record<string, unknown>;
|
|
return json({ ...state, snapshotOk: state.ok, ok: true });
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/api/ranking") return json({ ok: true, ranking: await dispatcher.dispatch({ kind: "ranking.get" }) });
|
|
if (request.method === "GET" && url.pathname === "/api/lottery") return json(await dispatcher.dispatch({ kind: "lottery.publicState" }));
|
|
if (request.method === "POST" && url.pathname === "/api/lottery/draw") return json({ ok: true, record: await dispatcher.dispatch({ kind: "lottery.publicDraw" }) });
|
|
|
|
if (!url.pathname.startsWith("/api/admin/")) return json({ ok: false, error: "not found" }, 404);
|
|
if (!apiKey) return json({ ok: false, error: "unauthorized" }, 401);
|
|
if (request.method === "GET" && url.pathname === "/api/admin/status") return json(await dispatcher.dispatch({ kind: "lottery.status" }));
|
|
if (request.method === "GET" && url.pathname === "/api/admin/backend-check") return json(await dispatcher.dispatch({ kind: "backend.check" }));
|
|
if (request.method === "POST" && url.pathname === "/api/admin/draw") return json(await dispatcher.dispatch({ kind: "lottery.draw" }));
|
|
if (request.method === "POST" && url.pathname === "/api/admin/reset") {
|
|
const input = await body(request);
|
|
if (!Number.isInteger(input.draws) || Number(input.draws) < 0 || typeof input.includeRecords !== "boolean") return json({ ok: false, error: "draws must be a non-negative integer and includeRecords must be boolean" }, 400);
|
|
return json(await dispatcher.dispatch({ kind: "lottery.reset", draws: Number(input.draws), includeRecords: input.includeRecords }));
|
|
}
|
|
if (request.method === "GET" && url.pathname === "/api/admin/records") {
|
|
const limit = Number(url.searchParams.get("limit"));
|
|
if (!Number.isInteger(limit) || limit < 1) return json({ ok: false, error: "limit must be a positive integer" }, 400);
|
|
return json(await dispatcher.dispatch({ kind: "records.list", limit }));
|
|
}
|
|
if (request.method === "DELETE" && url.pathname.startsWith("/api/admin/records/")) return json(await dispatcher.dispatch({ kind: "records.delete", id: decodeURIComponent(url.pathname.slice("/api/admin/records/".length)) }));
|
|
if (request.method === "POST" && url.pathname === "/api/admin/credit-test") {
|
|
const input = await body(request);
|
|
if (typeof input.execute !== "boolean") return json({ ok: false, error: "execute must be boolean" }, 400);
|
|
return json(await dispatcher.dispatch({ kind: "credit.test", execute: input.execute }));
|
|
}
|
|
return json({ ok: false, error: "not found" }, 404);
|
|
} catch (error) {
|
|
return errorResponse(error);
|
|
}
|
|
};
|
|
}
|