返回 CodeWhale
route.ts
根目录 / web / app / api / admin / post / route.ts
1 import { NextResponse } from "next/server";
2 import {
3 deleteDraft,
4 getAgentEnv,
5 getDraft,
6 parseDraftKey,
7 validateSession,
8 type CommunityAgentEnv,
9 } from "@/lib/community-agent";
10
11 export const dynamic = "force-dynamic";
12
13 async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: boolean; status?: number; error?: string }> {
14 if (!env.MAINTAINER_TOKEN) {
15 return { ok: false, status: 503, error: "MAINTAINER_TOKEN not configured" };
16 }
17
18 const cookieHeader = req.headers.get("cookie") ?? "";
19 let sid: string | undefined;
20 for (const c of cookieHeader.split(";")) {
21 const [name, ...rest] = c.trim().split("=");
22 if (name === "mt_sid") {
23 sid = rest.join("=");
24 break;
25 }
26 }
27
28 if (!sid || !(await validateSession(env.CURATED_KV, sid))) {
29 return { ok: false, status: 401, error: "unauthorized" };
30 }
31 return { ok: true };
32 }
33
34 const ALLOWED_ACTIONS = new Set(["post", "discard"]);
35 const ALLOWED_ORIGINS = new Set(["https://codewhale.net", "https://www.codewhale.net"]);
36 const MAX_BODY_BYTES = 65_536;
37
38 export async function POST(req: Request) {
39 const env = await getAgentEnv();
40
41 const origin = req.headers.get("origin");
42 if (origin && !ALLOWED_ORIGINS.has(origin)) {
43 return NextResponse.json({ error: "forbidden origin" }, { status: 403 });
44 }
45
46 const auth = await checkAuth(req, env);
47 if (!auth.ok) {
48 return NextResponse.json(
49 { error: auth.error ?? "unauthorized" },
50 { status: auth.status ?? 401, headers: { "Cache-Control": "no-store" } }
51 );
52 }
53
54 const contentLength = Number(req.headers.get("content-length") ?? "0");
55 if (contentLength > MAX_BODY_BYTES) {
56 return NextResponse.json({ error: "payload too large" }, { status: 413 });
57 }
58
59 const body = await req.json() as { action: string; draftKey: string; editedBody?: string; lang?: "en" | "zh" };
60 const { action, draftKey, editedBody, lang } = body;
61
62 if (!ALLOWED_ACTIONS.has(action)) {
63 return NextResponse.json({ error: "unknown action" }, { status: 400 });
64 }
65 if (typeof draftKey !== "string" || !draftKey || draftKey.length > 256) {
66 return NextResponse.json({ error: "missing or invalid draftKey" }, { status: 400 });
67 }
68 if (!parseDraftKey(draftKey)) {
69 return NextResponse.json({ error: "invalid draftKey namespace" }, { status: 400 });
70 }
71 if (editedBody !== undefined && (typeof editedBody !== "string" || editedBody.length > MAX_BODY_BYTES)) {
72 return NextResponse.json({ error: "editedBody too long" }, { status: 413 });
73 }
74 if (lang !== undefined && lang !== "en" && lang !== "zh") {
75 return NextResponse.json({ error: "invalid lang" }, { status: 400 });
76 }
77
78 const draft = await getDraft(env.CURATED_KV, draftKey);
79 if (!draft) {
80 return NextResponse.json({ error: "draft not found" }, { status: 404 });
81 }
82
83 if (action === "discard") {
84 await deleteDraft(env.CURATED_KV, draftKey);
85 return NextResponse.json({ ok: true, action: "discarded" });
86 }
87
88 if (action === "post") {
89 if (!env.MAINTAINER_GITHUB_PAT) {
90 return NextResponse.json({ error: "MAINTAINER_GITHUB_PAT not configured" }, { status: 500 });
91 }
92
93 const commentBody = editedBody ?? (lang === "zh" ? draft.bodyZh : draft.bodyEn);
94
95 if (draft.type === "digest") {
96 const digestBody = commentBody;
97 const firstLine = digestBody.split("\n")[0].replace(/^#+\s*/, "").trim();
98 const title = firstLine || `Weekly Digest ${draft.id}`;
99
100 const digestRepo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
101 const issuesUrl = `https://api.github.com/repos/${digestRepo}/issues`;
102
103 const digestRes = await fetch(issuesUrl, {
104 method: "POST",
105 headers: {
106 Accept: "application/vnd.github+json",
107 Authorization: `token ${env.MAINTAINER_GITHUB_PAT}`,
108 "X-GitHub-Api-Version": "2022-11-28",
109 "Content-Type": "application/json",
110 },
111 body: JSON.stringify({ title, body: digestBody, labels: ["digest"] }),
112 });
113
114 if (!digestRes.ok) {
115 const text = await digestRes.text();
116 return NextResponse.json({ error: `GitHub ${digestRes.status}: ${text}` }, { status: 502 });
117 }
118
119 const issue = await digestRes.json() as { number: number; html_url: string };
120
121 draft.posted = true;
122 draft.targetNumber = issue.number;
123 draft.targetUrl = issue.html_url;
124 await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 });
125
126 return NextResponse.json({ ok: true, action: "posted", number: issue.number, url: issue.html_url });
127 }
128
129 if (!draft.targetNumber) {
130 return NextResponse.json({ error: "no target number" }, { status: 400 });
131 }
132
133 const repo = env.GITHUB_REPO ?? "Hmbown/CodeWhale";
134 const commentUrl = `https://api.github.com/repos/${repo}/issues/${draft.targetNumber}/comments`;
135
136 const ghRes = await fetch(commentUrl, {
137 method: "POST",
138 headers: {
139 Accept: "application/vnd.github+json",
140 Authorization: `Bearer ${env.MAINTAINER_GITHUB_PAT}`,
141 "X-GitHub-Api-Version": "2022-11-28",
142 "Content-Type": "application/json",
143 },
144 body: JSON.stringify({ body: commentBody }),
145 });
146
147 if (!ghRes.ok) {
148 const text = await ghRes.text();
149 return NextResponse.json({ error: `GitHub ${ghRes.status}: ${text}` }, { status: 502 });
150 }
151
152 // Mark as posted
153 draft.posted = true;
154 await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 });
155
156 return NextResponse.json({ ok: true, action: "posted" });
157 }
158
159 // ALLOWED_ACTIONS guard above means this is unreachable.
160 return NextResponse.json({ error: "unknown action" }, { status: 400 });
161 }
162
162 lines TYPESCRIPT