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:
parent
2e951f99f4
commit
a02e983ba8
34 changed files with 2396 additions and 62 deletions
291
app/api/note/files/stage/route.ts
Normal file
291
app/api/note/files/stage/route.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue