返回 CodeWhale
route.ts
根目录 / web / app / api / admin / post / route.ts
1 import { revalidatePath } from "next/cache";
2 import { NextResponse } from "next/server";
3 import { BodyReadError, readBoundedBody } from "@/lib/bounded-body";
4 import {
5 approveDigestRecord,
6 claimDraft,
7 clearDraftResolution,
8 clearPostOutcomeUnknown,
9 getPostOutcomeUnknown,
10 markPostOutcomeUnknown,
11 deleteDigestRecord,
12 deleteDraft,
13 getAgentEnv,
14 getDraft,
15 getDraftResolution,
16 markDraftResolved,
17 parseDraftKey,
18 releaseDraftClaim,
19 reviewedBodyHash,
20 validateSession,
21 type CommunityAgentEnv,
22 type DraftClaimResult,
23 } from "@/lib/community-agent";
24
25 export const dynamic = "force-dynamic";
26
27 async function checkAuth(req: Request, env: CommunityAgentEnv): Promise<{ ok: boolean; status?: number; error?: string }> {
28 if (!env.MAINTAINER_TOKEN) {
29 return { ok: false, status: 503, error: "MAINTAINER_TOKEN not configured" };
30 }
31
32 const cookieHeader = req.headers.get("cookie") ?? "";
33 let sid: string | undefined;
34 for (const c of cookieHeader.split(";")) {
35 const [name, ...rest] = c.trim().split("=");
36 if (name === "mt_sid") {
37 sid = rest.join("=");
38 break;
39 }
40 }
41
42 if (!sid || !(await validateSession(env.CURATED_KV, sid))) {
43 return { ok: false, status: 401, error: "unauthorized" };
44 }
45 return { ok: true };
46 }
47
48 const ALLOWED_ACTIONS = new Set(["post", "discard"]);
49 const ALLOWED_ORIGINS = new Set(["https://codewhale.net", "https://www.codewhale.net"]);
50 const MAX_BODY_BYTES = 65_536;
51
52 /** Refresh the ISR copy of /digest after its published set changes. */
53 function revalidateDigestPage() {
54 try {
55 revalidatePath("/[locale]/digest", "page");
56 } catch (e) {
57 // The page still refreshes on its hourly revalidate.
58 console.error("digest revalidation failed", e);
59 }
60 }
61
62 /**
63 * A GitHub 4xx other than 408/429 means the post was not created, so the
64 * claim can be released. A 5xx, 408 or 429 can come back after GitHub already
65 * created the comment or issue, so its outcome is unknown.
66 */
67 function githubDefinitelyRejected(status: number): boolean {
68 return status >= 400 && status < 500 && status !== 408 && status !== 429;
69 }
70
71 /** The answer for a claim that was not taken. */
72 function claimRefused(result: Exclude<DraftClaimResult, { ok: true }>) {
73 if (result.reason === "unconfirmed") {
74 return NextResponse.json({ error: "could not confirm the draft claim; nothing was done, retry" }, { status: 503 });
75 }
76 if (result.reason === "resolved") {
77 return NextResponse.json({ error: `draft already ${result.resolution.state}` }, { status: 409 });
78 }
79 return NextResponse.json(
80 { error: "another request is acting on this draft; check GitHub, then retry in a minute" },
81 { status: 409 }
82 );
83 }
84
85 const discarded = () => NextResponse.json({ ok: true, action: "discarded" });
86
87 export async function POST(req: Request) {
88 const env = await getAgentEnv();
89
90 const origin = req.headers.get("origin");
91 if (origin && !ALLOWED_ORIGINS.has(origin)) {
92 return NextResponse.json({ error: "forbidden origin" }, { status: 403 });
93 }
94
95 const auth = await checkAuth(req, env);
96 if (!auth.ok) {
97 return NextResponse.json(
98 { error: auth.error ?? "unauthorized" },
99 { status: auth.status ?? 401, headers: { "Cache-Control": "no-store" } }
100 );
101 }
102
103 // Count the real body bytes (Content-Length is only an early rejection)
104 // and answer malformed JSON with a 400 instead of an unhandled 500.
105 let body: unknown;
106 try {
107 const bytes = await readBoundedBody(req, MAX_BODY_BYTES);
108 body = JSON.parse(new TextDecoder().decode(bytes));
109 } catch (e) {
110 if (e instanceof BodyReadError) {
111 return NextResponse.json({ error: e.message }, { status: e.status });
112 }
113 return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
114 }
115 if (!body || typeof body !== "object" || Array.isArray(body)) {
116 return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
117 }
118 const { action, draftKey, editedBody, lang, reviewedSha256 } = body as {
119 action?: unknown;
120 draftKey?: unknown;
121 editedBody?: unknown;
122 lang?: unknown;
123 reviewedSha256?: unknown;
124 };
125
126 if (typeof action !== "string" || !ALLOWED_ACTIONS.has(action)) {
127 return NextResponse.json({ error: "unknown action" }, { status: 400 });
128 }
129 if (typeof draftKey !== "string" || !draftKey || draftKey.length > 256) {
130 return NextResponse.json({ error: "missing or invalid draftKey" }, { status: 400 });
131 }
132 const parsedKey = parseDraftKey(draftKey);
133 if (!parsedKey) {
134 return NextResponse.json({ error: "invalid draftKey namespace" }, { status: 400 });
135 }
136 if (editedBody !== undefined && typeof editedBody !== "string") {
137 return NextResponse.json({ error: "invalid editedBody" }, { status: 400 });
138 }
139 if (editedBody !== undefined && editedBody.length > MAX_BODY_BYTES) {
140 return NextResponse.json({ error: "editedBody too long" }, { status: 413 });
141 }
142 if (lang !== undefined && lang !== "en" && lang !== "zh") {
143 return NextResponse.json({ error: "invalid lang" }, { status: 400 });
144 }
145 if (typeof reviewedSha256 !== "string" || !/^[0-9a-f]{64}$/.test(reviewedSha256)) {
146 return NextResponse.json({ error: "missing or invalid reviewedSha256" }, { status: 400 });
147 }
148 const reviewLang = lang === "zh" ? "zh" : "en";
149
150 const draft = await getDraft(env.CURATED_KV, draftKey);
151 if (!draft) {
152 return NextResponse.json({ error: "draft not found" }, { status: 404 });
153 }
154 // Act only on the exact text the maintainer was shown. A draft regenerated
155 // after the admin page loaded must be reviewed again, not posted unseen.
156 const originalBody = reviewLang === "zh" ? draft.bodyZh : draft.bodyEn;
157 if ((await reviewedBodyHash(originalBody)) !== reviewedSha256) {
158 return NextResponse.json(
159 { error: "draft changed since it was loaded; reload and review it again" },
160 { status: 409 }
161 );
162 }
163
164 if (action === "discard") {
165 // A posted draft is already public on GitHub (and, for a digest, on
166 // /digest); discarding it would silently unpublish or relabel it.
167 const resolution = await getDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id);
168 if (draft.posted || resolution?.state === "posted" || resolution?.state === "posting") {
169 return NextResponse.json({ error: "draft already posted" }, { status: 409 });
170 }
171 // A repeated discard (double click) is a no-op, not an error: whether the
172 // first one already finished or is still running.
173 if (resolution?.state === "discarded") return discarded();
174 // Hold the same claim a post takes, so a discard and a post of one draft
175 // do not both run.
176 let result: DraftClaimResult;
177 try {
178 result = await claimDraft(env, parsedKey.type, parsedKey.id, "discard");
179 } catch (e) {
180 return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 });
181 }
182 if (!result.ok) {
183 if (result.reason === "resolved" && result.resolution.state === "discarded") return discarded();
184 if (result.reason === "held" && result.holder === "discard") return discarded();
185 return claimRefused(result);
186 }
187 const claim = result.claim;
188 let recorded = false;
189 try {
190 // The marker stops the next cron run from regenerating this draft.
191 await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "discarded");
192 await deleteDraft(env.CURATED_KV, draftKey);
193 if (draft.type === "digest") {
194 await deleteDigestRecord(env.CURATED_KV, draft.id);
195 }
196 recorded = true;
197 } catch (e) {
198 return NextResponse.json({ error: `discard failed: ${String(e)}` }, { status: 500 });
199 } finally {
200 // The marker (or, if it failed, the draft) now carries the decision.
201 await releaseDraftClaim(env.CURATED_KV, claim, { recorded }).catch(() => undefined);
202 }
203 if (draft.type === "digest") revalidateDigestPage();
204 return discarded();
205 }
206
207 if (action === "post") {
208 if (!env.MAINTAINER_GITHUB_PAT) {
209 return NextResponse.json({ error: "MAINTAINER_GITHUB_PAT not configured" }, { status: 500 });
210 }
211 if (draft.type !== "digest" && !draft.targetNumber) {
212 return NextResponse.json({ error: "no target number" }, { status: 400 });
213 }
214
215 // Posting is not idempotent on GitHub: refuse a draft that is already
216 // posted or has a post in flight (second tab, retry after a partial
217 // failure), then claim it before the GitHub call.
218 if (draft.posted) {
219 return NextResponse.json({ error: "draft already posted" }, { status: 409 });
220 }
221 const resolution = await getDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id);
222 if (resolution) {
223 return NextResponse.json({ error: `draft already ${resolution.state}` }, { status: 409 });
224 }
225 let result: DraftClaimResult;
226 try {
227 result = await claimDraft(env, parsedKey.type, parsedKey.id, "post");
228 } catch (e) {
229 return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 });
230 }
231 if (!result.ok) return claimRefused(result);
232 const heldClaim = result.claim;
233 try {
234 // Keeps the cron from regenerating the draft while the post is in flight.
235 await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "posting");
236 } catch (e) {
237 await releaseDraftClaim(env.CURATED_KV, heldClaim).catch(() => undefined);
238 return NextResponse.json({ error: `could not claim draft: ${String(e)}` }, { status: 500 });
239 }
240
241 const commentBody = editedBody ?? originalBody;
242
243 // GitHub has no idempotency key. If an earlier attempt's outcome was
244 // unknown, the post it may have created is looked for (from shortly
245 // before that attempt) instead of posting blind a second time.
246 const identity = await reviewedBodyHash(JSON.stringify({
247 repo: env.GITHUB_REPO ?? "codewhale-hq/CodeWhale", type: draft.type,
248 target: draft.targetNumber, body: commentBody,
249 }));
250 let unknownAt: string | null;
251 try {
252 unknownAt = await getPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, identity);
253 } catch (error) {
254 await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id).catch(() => undefined);
255 await releaseDraftClaim(env.CURATED_KV, heldClaim).catch(() => undefined);
256 const changed = error instanceof Error && error.message === "unresolved post has different text or target";
257 return NextResponse.json({ error: changed
258 ? "an earlier post with different text or target is unresolved; check GitHub before retrying; nothing was posted"
259 : "could not read the earlier post receipt; nothing was posted" }, { status: changed ? 409 : 503 });
260 }
261 const lookSince = unknownAt ? new Date(Date.parse(unknownAt) - 10 * 60 * 1000).toISOString() : null;
262 const githubFind = async (url: string, authScheme: "token" | "Bearer", matches: (item: Record<string, unknown>) => boolean): Promise<Record<string, unknown> | undefined> => {
263 const signal = AbortSignal.timeout(30_000);
264 for (let page = 1; page <= 10; page++) {
265 const pageUrl = new URL(url);
266 pageUrl.searchParams.set("page", String(page));
267 const res = await fetch(pageUrl.toString(), {
268 headers: {
269 Accept: "application/vnd.github+json",
270 Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`,
271 "X-GitHub-Api-Version": "2022-11-28",
272 }, signal,
273 });
274 if (!res.ok) throw new Error(`GitHub ${res.status}`);
275 const list: unknown = JSON.parse(new TextDecoder().decode(await readBoundedBody(res, 2 * 1024 * 1024)));
276 if (!Array.isArray(list) || list.some(item => !item || typeof item !== "object")) throw new Error("GitHub returned no valid list");
277 const found = list.find(matches);
278 if (found) return found;
279 if (list.length < 100) return undefined;
280 }
281 // A capped search is unknown, never proof that GitHub lacks the post.
282 throw new Error("GitHub reconciliation page limit exceeded");
283 };
284 // This request posted nothing, so the draft is free again.
285 const lookupFailed = async () => {
286 try {
287 await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id);
288 await releaseDraftClaim(env.CURATED_KV, heldClaim);
289 } catch { /* the claim expires on its own */ }
290 return NextResponse.json(
291 { error: "could not check GitHub for the post an earlier attempt may have created; nothing was posted, retry" },
292 { status: 502 }
293 );
294 };
295 const alreadyPosted = "An earlier attempt whose outcome was unknown had already posted this; it was not posted again.";
296
297 // After GitHub accepted the post, bookkeeping failures must not turn into
298 // an error the maintainer would "fix" by posting again.
299 const recordPosted = async (): Promise<string | undefined> => {
300 try {
301 await markDraftResolved(env.CURATED_KV, parsedKey.type, parsedKey.id, "posted");
302 // The marker now carries the decision, so a later claim sees it; a
303 // reopened draft (new activity clears the marker) is postable again.
304 await env.CURATED_KV?.put(draftKey, JSON.stringify(draft), { expirationTtl: 60 * 60 * 24 * 7 });
305 await clearPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim);
306 await releaseDraftClaim(env.CURATED_KV, heldClaim, { recorded: true }).catch(() => undefined);
307 return undefined;
308 } catch (e) {
309 return `Posted to GitHub, but saving the draft state failed (${String(e)}). Do not post it again; it may reappear as pending.`;
310 }
311 };
312
313 const postToGitHub = async (url: string, payload: unknown, authScheme: "token" | "Bearer") => {
314 await markPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim, identity);
315 try {
316 return await fetch(url, {
317 method: "POST",
318 headers: {
319 Accept: "application/vnd.github+json",
320 Authorization: `${authScheme} ${env.MAINTAINER_GITHUB_PAT}`,
321 "X-GitHub-Api-Version": "2022-11-28",
322 "Content-Type": "application/json",
323 },
324 signal: AbortSignal.timeout(30_000),
325 body: JSON.stringify(payload),
326 });
327 } catch {
328 // Outcome unknown: keep the short-lived claim so an immediate retry
329 // cannot double-post.
330 return null;
331 }
332 };
333 const unknownOutcome = async (detail: string) => {
334 // Remembered past the claim, so a later retry looks before posting.
335 // The durable receipt was persisted before the outbound POST.
336 return NextResponse.json(
337 { error: `${detail}; check GitHub before retrying (retry unlocks in 15 minutes)` },
338 { status: 502 }
339 );
340 };
341 const githubFailed = async (res: Response) => {
342 const text = await res.text().catch(() => "");
343 if (!githubDefinitelyRejected(res.status)) {
344 // Keep the claim: GitHub may have created the post anyway.
345 return unknownOutcome(`GitHub ${res.status}: ${text}`);
346 }
347 try {
348 await clearPostOutcomeUnknown(env, parsedKey.type, parsedKey.id, heldClaim);
349 await clearDraftResolution(env.CURATED_KV, parsedKey.type, parsedKey.id);
350 await releaseDraftClaim(env.CURATED_KV, heldClaim);
351 } catch { /* receipt remains; the next retry must reconcile */ }
352 return NextResponse.json({ error: `GitHub ${res.status}: ${text}` }, { status: 502 });
353 };
354
355 if (draft.type === "digest") {
356 const digestBody = commentBody;
357 const firstLine = digestBody.split("\n")[0].replace(/^#+\s*/, "").trim();
358 const title = firstLine || `Weekly Digest ${draft.id}`;
359
360 const digestRepo = env.GITHUB_REPO ?? "codewhale-hq/CodeWhale";
361 const issuesUrl = `https://api.github.com/repos/${digestRepo}/issues`;
362
363 let issue: { number?: number; html_url?: string } | undefined;
364 if (lookSince) {
365 try {
366 const listUrl = `${issuesUrl}?labels=digest&state=all&since=${encodeURIComponent(lookSince)}&per_page=100`;
367 const found = await githubFind(listUrl, "token", (item) => !item.pull_request && item.title === title && item.body === digestBody);
368 if (found) {
369 issue = {
370 number: typeof found.number === "number" ? found.number : undefined,
371 html_url: typeof found.html_url === "string" ? found.html_url : undefined,
372 };
373 }
374 } catch {
375 return lookupFailed();
376 }
377 }
378 const reconciled = issue !== undefined;
379 if (!issue) {
380 let digestRes: Response | null;
381 try { digestRes = await postToGitHub(issuesUrl, { title, body: digestBody, labels: ["digest"] }, "token"); }
382 catch { return lookupFailed(); }
383 if (!digestRes) return unknownOutcome("GitHub request failed");
384 if (!digestRes.ok) return githubFailed(digestRes);
385 issue = await digestRes.json().catch(() => ({})) as { number?: number; html_url?: string };
386 }
387
388 draft.posted = true;
389 if (typeof issue.number === "number") draft.targetNumber = issue.number;
390 if (typeof issue.html_url === "string") draft.targetUrl = issue.html_url;
391 let warning = await recordPosted();
392 if (reconciled) warning ??= alreadyPosted;
393
394 // Publishing to /digest is the approval, for the language shown to the
395 // maintainer only, and only of the record that renders to exactly the
396 // text they reviewed. An edited digest is posted to GitHub but not
397 // published there, since the record holds the unedited text.
398 let published = false;
399 if (editedBody === undefined || editedBody === originalBody) {
400 try {
401 const approval = await approveDigestRecord(env.CURATED_KV, draft, reviewLang);
402 published = approval === "published";
403 if (published) revalidateDigestPage();
404 else if (approval === "missing") {
405 warning ??= "Posted to GitHub, but the weekly record is gone, so /digest does not show it.";
406 } else {
407 warning ??= "Posted to GitHub, but the stored weekly record does not match the reviewed text, so /digest does not show it.";
408 }
409 } catch (e) {
410 warning ??= `Posted to GitHub, but publishing the digest page failed (${String(e)}).`;
411 }
412 } else {
413 warning ??= "Posted to GitHub. The text was edited, so /digest does not show this week.";
414 }
415
416 return NextResponse.json({
417 ok: true,
418 action: "posted",
419 number: issue.number,
420 url: issue.html_url,
421 published,
422 ...(warning ? { warning } : {}),
423 });
424 }
425
426 const repo = env.GITHUB_REPO ?? "codewhale-hq/CodeWhale";
427 const commentUrl = `https://api.github.com/repos/${repo}/issues/${draft.targetNumber}/comments`;
428
429 let reconciled = false;
430 if (lookSince) {
431 try {
432 const listUrl = `${commentUrl}?since=${encodeURIComponent(lookSince)}&per_page=100`;
433 reconciled = (await githubFind(listUrl, "Bearer", (comment) => comment.body === commentBody)) !== undefined;
434 } catch {
435 return lookupFailed();
436 }
437 }
438 if (!reconciled) {
439 let ghRes: Response | null;
440 try { ghRes = await postToGitHub(commentUrl, { body: commentBody }, "Bearer"); }
441 catch { return lookupFailed(); }
442 if (!ghRes) return unknownOutcome("GitHub request failed");
443 if (!ghRes.ok) return githubFailed(ghRes);
444 }
445
446 // Mark as posted
447 draft.posted = true;
448 const warning = (await recordPosted()) ?? (reconciled ? alreadyPosted : undefined);
449
450 return NextResponse.json({ ok: true, action: "posted", ...(warning ? { warning } : {}) });
451 }
452
453 // ALLOWED_ACTIONS guard above means this is unreachable.
454 return NextResponse.json({ error: "unknown action" }, { status: 400 });
455 }
456
456 lines TYPESCRIPT