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
219
lib/staged-docs.ts
Normal file
219
lib/staged-docs.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// 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();
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue