| 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 |