返回 CodeWhale
route.ts
根目录 / web / app / api / admin / login / route.ts
1 import { NextResponse } from "next/server";
2 import { getAgentEnv, safeEqual, createSession } from "@/lib/community-agent";
3 import { FormBodyError, readBoundedUrlEncodedForm } from "@/lib/bounded-form";
4
5 export const dynamic = "force-dynamic";
6
7 const ALLOWED_LOCALES = new Set(["en", "zh"]);
8 const MAX_LOGIN_BODY_BYTES = 4_096;
9 const MAX_TOKEN_CHARS = 512;
10
11 function pickLocale(value: string | null | undefined): string {
12 if (!value) return "en";
13 return ALLOWED_LOCALES.has(value) ? value : "en";
14 }
15
16 export async function POST(req: Request) {
17 const env = await getAgentEnv();
18 const url = new URL(req.url);
19 const localeFromQuery = pickLocale(url.searchParams.get("locale"));
20
21 if (!env.MAINTAINER_TOKEN) {
22 return new NextResponse("Not configured", {
23 status: 503,
24 headers: { "Cache-Control": "no-store" },
25 });
26 }
27
28 // One maintainer principal: key by that account, never by attacker-controlled
29 // headers or submitted tokens. The binding shares counters across isolates
30 // in a Cloudflare location. An unavailable limiter must not disable the gate.
31 try {
32 if (!env.ADMIN_LOGIN_LIMITER) throw new Error("Missing login limiter");
33 const { success } = await env.ADMIN_LOGIN_LIMITER.limit({ key: "codewhale-web:admin-login" });
34 if (!success) {
35 return new NextResponse("Too many login attempts", {
36 status: 429,
37 headers: { "Cache-Control": "no-store", "Retry-After": "60" },
38 });
39 }
40 } catch {
41 return new NextResponse("Login temporarily unavailable", {
42 status: 503,
43 headers: { "Cache-Control": "no-store", "Retry-After": "60" },
44 });
45 }
46
47 let form: URLSearchParams;
48 try {
49 form = await readBoundedUrlEncodedForm(req, MAX_LOGIN_BODY_BYTES);
50 } catch (error) {
51 if (error instanceof FormBodyError) {
52 return new NextResponse(error.message, {
53 status: error.status,
54 headers: { "Cache-Control": "no-store" },
55 });
56 }
57 throw error;
58 }
59 const submitted = form.get("token") ?? "";
60 const locale = pickLocale(form.get("locale") ?? localeFromQuery);
61 if (submitted.length > MAX_TOKEN_CHARS) {
62 return new NextResponse("Token too long", {
63 status: 413,
64 headers: { "Cache-Control": "no-store" },
65 });
66 }
67
68 const valid = await safeEqual(submitted, env.MAINTAINER_TOKEN);
69 if (!valid) {
70 return NextResponse.redirect(new URL(`/${locale}/admin?err=1`, req.url), {
71 status: 303,
72 headers: { "Cache-Control": "no-store" },
73 });
74 }
75
76 const sid = await createSession(env.CURATED_KV);
77 if (!sid) {
78 return new NextResponse("Session storage unavailable", {
79 status: 503,
80 headers: { "Cache-Control": "no-store" },
81 });
82 }
83
84 const res = NextResponse.redirect(new URL(`/${locale}/admin`, req.url), {
85 status: 303,
86 headers: { "Cache-Control": "no-store" },
87 });
88 res.cookies.set("mt_sid", sid, {
89 path: "/",
90 httpOnly: true,
91 secure: true,
92 sameSite: "strict",
93 maxAge: 60 * 60 * 24,
94 });
95 return res;
96 }
97
97 lines TYPESCRIPT