เพิ่มทางเข้าเอกสารสำหรับ 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>
219 lines
11 KiB
TypeScript
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();
|
|
}
|