feat(n11): add base64 document staging from e-Court

เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage
(x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging
(TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO
แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม:

- lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน
  pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc
- lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ
  (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid
- route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 +
  concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง
- registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว
  re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก
- ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath
  guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์)
- session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes
  ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี
- Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย)
- MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง)

Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E
browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้
fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) —
ผ่าน code review รอบ 7 แก้ครบทุก finding

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-11-05 12:53:56 +07:00
parent 2e951f99f4
commit a02e983ba8
34 changed files with 2396 additions and 62 deletions

View file

@ -0,0 +1,291 @@
// N11 (PLAN-STAGED.md D1) — POST /api/note/files/stage: ผู้เรียก backend e-Court
// (server-to-server ไม่มี CORS ไม่มี session ของแอป) ส่งเนื้อไฟล์ PDF/รูปเป็น
// base64 ใน JSON body — เก็บใน RAM staging (lib/staged-docs.ts) เท่านั้น ห้ามเขียน
// disk/DB/MinIO แม้แต่ไบต์เดียว (annotation/bookmark เก็บถาวรตามระบบเดิม — row
// files ที่ registerStagedFile สร้างคือ FK anchor)
// · auth ก่อนแตะอะไรทั้งสิ้น: requireDocverApiKey (env DOCVER_API_KEY — กลุ่มเดียว
// กับทางเข้า mint POST /api/note/auth/token)
// · อ่าน body แบบ stream นับไบต์ (Content-Length เป็น fast-path เท่านั้น — chunked
// ไม่มี header) + idle 60 วิ / total 10 นาที → 408 + concurrency จำกัด
// (STAGED_MAX_CONCURRENT default 1) → 429 — คืน slot/เคลียร์ timer ใน finally ทุกทางออก
// · error ทั้งหก 401/400/408/413/429/500 เป็น JSON {"error": <ไทย>} (pattern docverError)
// · ห้าม log bytes/key ดิบ — log ได้เฉพาะ fid + ขนาด
// · carve-out invariant #4: route handler เขียนตาราง files ผ่าน registerStagedFile —
// ไม่ publish SSE ไม่ revalidatePath (รายการไฟล์ไม่ sync ผ่าน SSE เหมือน N8/N4)
import { requireDocverApiKey } from "@/lib/docver-token";
import { registerStagedFile } from "@/lib/external-import";
import {
detectStagedExt,
stagedMaxBodyBytes,
stagedMaxFileBytes,
StagedCapError,
} from "@/lib/staged-docs";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
const IDLE_TIMEOUT_MS = 60_000;
const TOTAL_TIMEOUT_MS = 600_000;
const RETRY_AFTER_SECONDS = "5";
const SLOW_MSG = "อัปโหลดช้าเกินกำหนดหรือการเชื่อมต่อขาด — ลองส่งใหม่";
const TOO_BIG_MSG = "ไฟล์ใหญ่เกินขนาดที่รองรับ (เนื้อไฟล์เกิน 100MB / base64 เกิน ~140MB)";
/** concurrency counter บน globalThis (pattern เดียวกับ lib/annotate-events.ts —
* dev hot-reload จะ duplicate module-level state) — กัน peak RAM หลายสำเนาบวมเกิน
* cap ตอนยิงพร้อมกัน */
type StageGlobal = { __stagedInflight?: number };
const g = globalThis as typeof globalThis & StageGlobal;
function stageMaxConcurrent(): number {
const raw = process.env.STAGED_MAX_CONCURRENT?.trim();
if (raw === undefined || raw === "") return 1;
const n = Number(raw);
return Number.isInteger(n) && n >= 1 ? n : 1;
}
function stageError(
message: string,
status: number,
headers?: Record<string, string>,
): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "content-type": "application/json; charset=utf-8", ...headers },
});
}
/** Response 200 ของกลุ่มนี้ — ปัจจุบันมีแต่ POST (คืน {fileId}) */
function stageJson(body: unknown): Response {
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json; charset=utf-8" },
});
}
class StageReadError extends Error {
constructor(
message: string,
public status: 400 | 408 | 413,
) {
super(message);
}
}
/** อ่าน body แบบ stream สะสมนับไบต์ + idle/total timeout + ผูก req.signal (client
* ตัดการเชื่อมต่อ) — เกิน cap = 413 ก่อนแลกทั้งก้อน · timer ทั้งสองเคลียร์ใน
* finally เสมอ (ลืมแล้ว timer ค้างหลัง request จบ โดยเฉพาะ Next dev hot-reload) */
async function readBodyCapped(req: Request): Promise<Buffer> {
const cap = stagedMaxBodyBytes();
const declared = Number(req.headers.get("content-length"));
if (Number.isFinite(declared) && declared > cap) {
throw new StageReadError(TOO_BIG_MSG, 413);
}
if (req.body === null) {
throw new StageReadError("ต้องส่ง body เป็น JSON object", 400);
}
if (req.signal.aborted) {
throw new StageReadError(SLOW_MSG, 408);
}
const reader = req.body.getReader();
const chunks: Uint8Array[] = [];
let received = 0;
let idleTimer: ReturnType<typeof setTimeout> | null = null;
let totalTimer: ReturnType<typeof setTimeout> | null = null;
// timer ทั้งสอง + abort ส่งสัญญาณ rejecting ผ่าน promise ที่ race กับ read()
let timedOutReject: ((e: StageReadError) => void) | null = null;
const timedOut = new Promise<never>((_, reject) => {
timedOutReject = reject;
});
let abortReject: ((e: StageReadError) => void) | null = null;
const aborted = new Promise<never>((_, reject) => {
abortReject = reject;
});
const onAbort = () => abortReject?.(new StageReadError(SLOW_MSG, 408));
req.signal.addEventListener("abort", onAbort, { once: true });
totalTimer = setTimeout(
() => timedOutReject?.(new StageReadError(SLOW_MSG, 408)),
TOTAL_TIMEOUT_MS,
);
try {
for (;;) {
idleTimer = setTimeout(
() => timedOutReject?.(new StageReadError(SLOW_MSG, 408)),
IDLE_TIMEOUT_MS,
);
const { done, value } = await Promise.race([reader.read(), timedOut, aborted]);
clearTimeout(idleTimer);
idleTimer = null;
if (done) break;
received += value.byteLength;
if (received > cap) throw new StageReadError(TOO_BIG_MSG, 413);
chunks.push(value);
}
return Buffer.concat(chunks);
} finally {
if (idleTimer !== null) clearTimeout(idleTimer);
if (totalTimer !== null) clearTimeout(totalTimer);
req.signal.removeEventListener("abort", onAbort);
// ออกกลางทาง (timeout/abort/413/error) — ปล่อย stream ที่ยังไม่อ่าน (best-effort)
void reader.cancel().catch(() => {});
}
}
const STAGE_BODY_FIELDS = ["fid", "data", "typeId", "title"] as const;
type StageBody =
| { ok: true; fid: string; data: string; typeId?: string; title?: string }
| { ok: false; error: string };
/** body แบบ strict allowlist กัน typo เงียบ ๆ ตาม convention ของกลุ่ม (key อื่น =
* 400) · fid เกณฑ์เดียวกับ parseLoginTokenBody (cap 200 + ห้ามอักขระ & = + %) */
function parseStageBody(raw: unknown): StageBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(STAGE_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${STAGE_BODY_FIELDS.join(", ")})`,
};
}
}
const rawFid = record.fid;
if (typeof rawFid !== "string" || rawFid.trim() === "") {
return { ok: false, error: "ต้องระบุ fid (string)" };
}
const fid = rawFid.trim();
if (fid.length > 200) return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
if (/[&=+%]/.test(fid)) {
return { ok: false, error: "fid มีอักขระที่ไม่รับ (& = + %)" };
}
const data = record.data;
if (typeof data !== "string" || data === "") {
return { ok: false, error: "ต้องระบุ data (base64 ของเนื้อไฟล์)" };
}
const typeId = record.typeId;
if (typeId !== undefined && typeof typeId !== "string") {
return { ok: false, error: "typeId ต้องเป็น string" };
}
const title = record.title;
if (title !== undefined && typeof title !== "string") {
return { ok: false, error: "title ต้องเป็น string" };
}
return {
ok: true,
fid,
data,
...(typeId !== undefined ? { typeId } : {}),
...(title !== undefined ? { title } : {}),
};
}
/** base64 มาตรฐานต่อเนื่องเท่านั้น — ห้าม whitespace/line break และไม่รับ base64url
* (-/_) → scan บน string ต้นทาง (ไม่สร้างสำเนาตัด whitespace) + len % 4 === 0 +
* เทียบความยาวที่ควรได้จากสูตร len/4×3 − padding กับ Buffer.length หลัง decode
* (Node Buffer.from(b64) ยืดหยุ่นเกิน ได้ garbage เงียบ ๆ — การเทียบความยาวเป็นตัวตัดสิน)
* ⚠️ ห้ามกลับไปใช้ regex แบบ /^[A-Za-z0-9+/]+={0,2}$/ — quantifier ที่ต้องย้อน
* กลับบน string ~140MB ทำให้ V8 ล้น stack (RangeError ใน dev server ที่รันนาน
* 2026-11-04) — negated class scan ข้างล่างเดินเชิงเส้นไม่มี backtracking ปลอดภัย
* ทุกขนาด/ทุกระดับ engine */
function decodeBase64Strict(data: string): Buffer | { error: string } {
if (data.length % 4 !== 0) {
return { error: "data ต้องเป็น base64 ที่ความยาวลงตัวส่วน 4" };
}
const CONTIGUOUS_MSG =
"data ต้องเป็น base64 มาตรฐานต่อเนื่อง (ห้าม whitespace/line break และไม่รับ base64url)";
if (/[^A-Za-z0-9+/=]/.test(data)) {
return { error: CONTIGUOUS_MSG };
}
// '=' ได้เฉพาะท้ายชุด 0–2 ตัว — อยู่กลางชุด/เกิน 2 = ไม่ใช่ base64 มาตรฐาน
const firstEq = data.indexOf("=");
let padding = 0;
if (firstEq !== -1) {
padding = data.length - firstEq;
if (padding > 2 || data.slice(firstEq) !== "=".repeat(padding)) {
return { error: CONTIGUOUS_MSG };
}
}
const expected = (data.length / 4) * 3 - padding;
const bytes = Buffer.from(data, "base64");
if (bytes.length !== expected) {
return { error: "data ไม่ใช่ base64 ที่ถูกต้อง" };
}
return bytes;
}
export async function POST(req: Request): Promise<Response> {
const denied = requireDocverApiKey(req);
if (denied) return denied;
// จับกั้นตั้งแต่ก่อนอ่าน body จนจบ — คืน slot ใน finally ทุกทางออก (สำเร็จ/error/
// client ตัดการเชื่อมต่อ) ไม่งั้นไฟล์ใหญ่บนเน็ตช้ากิน slot นานเป็นนาที จน request
// ถัดไป 429 ตลอด
if ((g.__stagedInflight ?? 0) >= stageMaxConcurrent()) {
return stageError("ระบบกำลังจัดเอกสารอื่นอยู่ — ลองใหม่อีกครั้ง", 429, {
"retry-after": RETRY_AFTER_SECONDS,
});
}
g.__stagedInflight = (g.__stagedInflight ?? 0) + 1;
try {
let bodyText: string;
try {
bodyText = (await readBodyCapped(req)).toString("utf8");
} catch (err) {
if (err instanceof StageReadError) return stageError(err.message, err.status);
console.error("[stage] อ่าน body ไม่สำเร็จ", err);
return stageError("อ่าน request ไม่สำเร็จ — ลองส่งใหม่", 500);
}
let parsedJson: unknown;
try {
parsedJson = JSON.parse(bodyText);
} catch {
return stageError("body ต้องเป็น JSON object", 400);
}
const parsed = parseStageBody(parsedJson);
if (!parsed.ok) return stageError(parsed.error, 400);
const decoded = decodeBase64Strict(parsed.data);
if ("error" in decoded) return stageError(decoded.error, 400);
const bytes = decoded;
if (bytes.length > stagedMaxFileBytes()) {
return stageError(TOO_BIG_MSG, 413);
}
// ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ — ext ของ sentinel มาจาก
// ผลตรวจนี้
const ext = detectStagedExt(bytes);
if (ext === null) {
return stageError("ไฟล์ไม่ใช่ PDF หรือรูปภาพที่รองรับ", 400);
}
const title = parsed.title?.trim().slice(0, 200) || undefined;
const typeId = parsed.typeId?.trim() || undefined;
try {
const { fileId } = await registerStagedFile(parsed.fid, bytes, ext, {
title,
typeId,
});
// audit: fid + ขนาด + ext เท่านั้น (strip อักขระควบคุมกันฝัง \n ปลอมบรรทัด log —
// pattern เดียวกับ session route)
const auditFid = parsed.fid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
console.log(
`[stage] e-Court stage ok: fid=${auditFid} size=${bytes.length} ext=${ext}`,
);
return stageJson({ fileId });
} catch (err) {
if (err instanceof StagedCapError) return stageError(err.message, 413);
// ห้าม log bytes ดิบ — log เฉพาะ fid + ขนาด
console.error(
`[stage] stage ล้ม fid=${parsed.fid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")} size=${bytes.length}`,
err,
);
return stageError("จัดเอกสารไม่สำเร็จ — ลองอีกครั้ง", 500);
}
} finally {
g.__stagedInflight = Math.max(0, (g.__stagedInflight ?? 0) - 1);
}
}