eCourt-note/lib/staged-docs.ts
Your Name a02e983ba8 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>
2026-11-05 12:53:56 +07:00

219 lines
11 KiB
TypeScript

// N11 (PLAN-STAGED.md D2) — RAM staging ของเอกสาร base64 จาก e-Court: "แหล่ง bytes
// ชั่วคราว" ทดแทน pdf_url ที่ fetch ได้ — annotation/bookmark เก็บถาวรใน DB ปกติ
// (row files คือ FK anchor, staging เป็นแค่เนื้อไฟล์)
// - เก็บใน Map บน globalThis (pattern เดียวกับ lib/annotate-events.ts/db/index.ts —
// Next dev แยก bundle "use server"/route handler แล้ว HMR recompile จะ duplicate
// module-level state) · ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียว (ความหมาย
// "ไม่เก็บไฟล์" ของแผน)
// - TTL sliding จาก lastAccessAt + LRU evict ตาม total cap + lazy sweep ตอน put
// (ไม่มี setInterval ค้างใน dev — บริบทเดียวกับ rule ของ annotate-events)
// - single-process (ข้อจำกัดเดียวกับ SSE pub/sub — invariant #11)
// - pure module: ไม่ import อะไรที่ลาก @/db มาด้วย — unit test รันได้ไม่ต้องตั้ง DATABASE_URL
import { isPdfBytes } from "@/lib/document-versioning";
/** ext ของ sentinel = ผลตรวจ magic bytes ที่ stage route สร้างเอง (pdf/jpg/png/tif) */
export const STAGED_EXTS = ["pdf", "jpg", "png", "tif"] as const;
export type StagedExt = (typeof STAGED_EXTS)[number];
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
type StagedEntry = {
bytes: Buffer;
ext: StagedExt;
createdAt: number;
lastAccessAt: number;
};
type StagedGlobal = { __stagedDocs?: Map<string, StagedEntry> };
const g = globalThis as typeof globalThis & StagedGlobal;
const stagedDocs: Map<string, StagedEntry> =
g.__stagedDocs ?? (g.__stagedDocs = new Map());
/** env จำนวนเต็มบวก — ค่าเสีย/ว่าง = ใช้ default (ปรับได้ทุก environment) */
function positiveIntEnv(name: string, fallback: number): number {
const raw = process.env[name];
if (raw === undefined || raw.trim() === "") return fallback;
const n = Number(raw);
return Number.isInteger(n) && n > 0 ? n : fallback;
}
/** TTL แบบ sliding — default 8 ชม. เท่า NOTE_SESSION_TTL ให้ครอบ session ทั้งก้อน
* (ระหว่าง annotate ไม่มีการอ่าน bytes การเปิดดูครั้งเดียวจึงต้องไม่หมดกลาง session) */
export function stagedDocTtlMs(): number {
return positiveIntEnv("STAGED_DOC_TTL_SECONDS", 28_800) * 1000;
}
/** โควตารวมทุกไฟล์ใน RAM — เกิน = LRU evict รายการ lastAccessAt เก่าสุด */
export function stagedMaxTotalBytes(): number {
return positiveIntEnv("STAGED_MAX_TOTAL_BYTES", 256 * 1024 * 1024);
}
/** ขนาดเนื้อไฟล์ต่อไฟล์ (คู่กับ base64 cap ที่ stage route ใช้ตัดกลาง stream) */
export function stagedMaxFileBytes(): number {
return positiveIntEnv("STAGED_MAX_FILE_BYTES", 100 * 1024 * 1024);
}
/** ความยาว body (JSON) สูงสุดที่ stage route รับ = base64 ยาวสุดที่ decode แล้ว
* ได้ ≤ stagedMaxFileBytes บวก headroom ของ envelope (fid/title/typeId + วงเล็บ) */
export function stagedMaxBodyBytes(): number {
return 4 * Math.ceil(stagedMaxFileBytes() / 3) + 8192;
}
// config guard: total cap ต่ำกว่าไฟล์เดี่ยวสูงสุด = ไฟล์ใหญ่สุดโดน 413 ตลอด — เตือน
// หนึ่งครั้งตอนบูต (ปรับ env หรือตั้งใจใช้กับไฟล์เล็กก็ได้)
if (stagedMaxTotalBytes() < stagedMaxFileBytes()) {
console.warn(
"[staged] STAGED_MAX_TOTAL_BYTES ต่ำกว่า STAGED_MAX_FILE_BYTES — ไฟล์เดี่ยวขนาดใหญ่สุดจะโดนปฏิเสธ (413) เสมอ",
);
}
/** sentinel URL ใน files.pdf_url — `staged:<uuid>.<ext>` (ext ติดไปเสมอเพื่อให้
* fileKindFromUrl/imageFormatFromUrl ทำงานทุกจุดโดยไม่แก้ — D2/การตัดสินใจข้อ 7) */
export function stagedSourceUrl(fileId: string, ext: StagedExt): string {
return `staged:${fileId}.${ext}`;
}
/** แกะ sentinel — validate uuid + ext ซ้ำ (ทั้งคู่ server generate เอง การ validate
* เป็น defense กัน row ถูกเขียนค้างระหว่างอัปเดตไม่สำเร็จ) */
export function parseStagedSource(
url: string,
): { fileId: string; ext: StagedExt } | null {
if (!url.startsWith("staged:")) return null;
const rest = url.slice("staged:".length);
const dot = rest.lastIndexOf(".");
if (dot <= 0) return null;
const fileId = rest.slice(0, dot);
const ext = rest.slice(dot + 1).toLowerCase();
if (!UUID_RE.test(fileId)) return null;
if (!(STAGED_EXTS as readonly string[]).includes(ext)) return null;
return { fileId, ext: ext as StagedExt };
}
export function isStagedSource(url: string): boolean {
return parseStagedSource(url) !== null;
}
/** ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ (D1) — ext ของ sentinel
* มาจากผลตรวจนี้ · ไม่รู้จัก = null → route ตอบ 400 */
export function detectStagedExt(bytes: Buffer): StagedExt | null {
if (isPdfBytes(bytes)) return "pdf";
const b = bytes;
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
return "jpg";
}
if (
b.length >= 4 &&
b[0] === 0x89 &&
b[1] === 0x50 &&
b[2] === 0x4e &&
b[3] === 0x47
) {
return "png";
}
if (
b.length >= 4 &&
((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a && b[3] === 0x00) || // II*\0 little-endian
(b[0] === 0x4d && b[1] === 0x4d && b[2] === 0x00 && b[3] === 0x2a)) // MM\0* big-endian
) {
return "tif";
}
return null;
}
/** ไฟล์เดี่ยวใหญ่กว่าโควตารวม — stage route แปลงเป็น 413 (ไม่ไล่ evict เพื่อนแล้ว
* ก็ยังไม่พอ) */
export class StagedCapError extends Error {}
function totalStagedBytes(): number {
let sum = 0;
stagedDocs.forEach((e) => {
sum += e.bytes.length;
});
return sum;
}
/** เก็บกวาด entry หมดอายุตอน put — lazy sweep ไม่มี timer (กัน setInterval ค้างใน dev) */
function sweepExpiredStagedDocs(now: number): void {
const ttl = stagedDocTtlMs();
// Array.from แทน for...of บน Map โดยตรง — tsconfig ไม่ได้ตั้ง target ≥ es2015
// (pattern เดียวกับ parseVersionUploadForm ของ lib/document-versioning.ts) ·
// เก็บเป็น array ก่อนเพราะลบระหว่างเดิน Map ไม่ได้
const expired: string[] = [];
stagedDocs.forEach((e, id) => {
if (now - e.lastAccessAt > ttl) expired.push(id);
});
for (const id of expired) stagedDocs.delete(id);
}
/** เก็บ/แทนที่ bytes ใต้ fileId — re-POST fid เดิม = replace ช่องเดิม (ไม่งอกใหม่ต่อครั้ง)
* · แทรกแล้วเกิน total cap = ไล่ evict รายการ lastAccessAt เก่าสุดจนคืนใต้ cap
* (รายการที่เพิ่งใส่ lastAccessAt = now จึงไม่ถูกไล่เอง) */
export function putStagedDoc(
fileId: string,
bytes: Buffer,
ext: StagedExt,
): void {
const now = Date.now();
sweepExpiredStagedDocs(now);
if (bytes.length > stagedMaxTotalBytes()) {
throw new StagedCapError(
"ไฟล์ใหญ่เกินโควตารวมของพื้นที่จัดเตรียมเอกสาร (STAGED_MAX_TOTAL_BYTES)",
);
}
stagedDocs.set(fileId, { bytes, ext, createdAt: now, lastAccessAt: now });
while (totalStagedBytes() > stagedMaxTotalBytes()) {
let oldestId: string | null = null;
let oldestAt = Infinity;
stagedDocs.forEach((e, id) => {
if (e.lastAccessAt < oldestAt) {
oldestAt = e.lastAccessAt;
oldestId = id;
}
});
if (oldestId === null) break;
stagedDocs.delete(oldestId);
}
}
/** ผู้อ่าน bytes จริง (pdf route / export) — สำเร็จ = touch ยืด TTL (sliding) แล้วคืน
* Buffer · miss ทุกกรณี (ไม่ใช่ sentinel / ไม่มี entry / หมดอายุ / ext ไม่ตรงกับ
* entry) = null ให้ caller ตอบ 410 gate · Buffer คืนเป็น reference ของ entry —
* ผู้อ่านต้องไม่ mutate (fixPdfActualText/pdf-lib สร้างชุด bytes ใหม่เองตรวจแล้ว) */
export function getStagedDoc(url: string): Buffer | null {
const parsed = parseStagedSource(url);
if (parsed === null) return null;
const entry = stagedDocs.get(parsed.fileId);
if (entry === undefined) return null;
if (Date.now() - entry.lastAccessAt > stagedDocTtlMs()) {
stagedDocs.delete(parsed.fileId);
return null;
}
// ext ของ sentinel ไม่ตรง entry (row เขียนค้างระหว่างอัปเดตไม่สำเร็จ) = miss เหมือน
// หมดอายุ — ไม่มี sentinel โกหก และไม่ touch entry ของ ext จริง
if (entry.ext !== parsed.ext) return null;
entry.lastAccessAt = Date.now();
return entry.bytes;
}
/** เช็ค "มีอยู่ไหม" อย่างเดียว (expire + ext เทียบชุดเดียวกับ getStagedDoc) โดยไม่
* copy ไม่ touch TTL — สำหรับผู้ที่แค่ตัดสินใจไม่ได้อ่าน bytes จริง อย่าง session
* route ตอนแลก token (ไม่ยืดอายุ/ถือ Buffer เปล่า — การอ่านจริงเกิดที่ viewer) */
export function hasStagedDoc(url: string): boolean {
const parsed = parseStagedSource(url);
if (parsed === null) return false;
const entry = stagedDocs.get(parsed.fileId);
if (entry === undefined) return false;
if (Date.now() - entry.lastAccessAt > stagedDocTtlMs()) return false;
return entry.ext === parsed.ext;
}
/** ลบ entry ตาม fileId — ใช้คืน RAM ของ "ผู้แพ้" ตอน insert ชน 23505 (D3) */
export function deleteStagedDoc(fileId: string): void {
stagedDocs.delete(fileId);
}
/** เฉพาะ test — ล้าง store ทั้งก้อนระหว่างเคส (Map บน globalThis ชื่นชมข้ามเคส) */
export function __clearStagedDocsForTest(): void {
stagedDocs.clear();
}