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

38
lib/ecourt-fid.ts Normal file
View file

@ -0,0 +1,38 @@
// dedupe key ของเอกสาร e-Court แบบ hash (ตามคำขอผู้ใช้ 2026-11-04) —
// files.external_id เก็บ "ecourt:" + HMAC-SHA256(pepper, fid) แทน fid ดิบ
// ไม่ให้ fid ของ e-Court หลุดลง DB (fid บางรูปสั้น/เรียงง่าย sha256 เปล่าจะ
// brute force กลับได้ จึงต้องมี pepper)
//
// หลักการ: hash เกิดฝั่งเราตอนรับ request ทุกครั้ง (stage/D5/N10 ถามด้วย fid ดิบ
// แล้วเรา hash ตอน query) — deterministic + pepper เดียวกัน = digest เดิมเสมอ
// contract ฝั่ง e-Court ไม่เปลี่ยน · one-way: กู้ fid คืนจาก DB ไม่ได้ (โดยตั้งใจ —
// field externalFileId ของ N10 จึงคืนค่า hash)
//
// ⚠️ env FID_HASH_PEPPER เป็นค่าคงที่ตลอดชีพของข้อมูล — **ห้ามเปลี่ยนหลังมีแถว**
// (เปลี่ยน = แถวเก่าค้นไม่เจอ + re-INSERT เกิดแถวคู่เงียบ ๆ) · ห้าม commit ลง repo
//
// pure module ไม่ import อะไรที่ลาก @/db มาด้วย — unit test (tests/lib/) รันได้
// โดยไม่ต้องตั้ง DATABASE_URL (pattern เดียวกับ lib/docver-token.ts)
import { createHmac } from "node:crypto";
export const ECOURT_ID_PREFIX = "ecourt:";
/** external_id ของเอกสาร e-Court — "ecourt:" + HMAC-SHA256 hex 64 ตัวของ fid
* (trim ก่อน hash — caller หลายทางส่ง fid ที่ trim แล้ว จุดเดียวที่ normalize)
* · env FID_HASH_PEPPER ไม่ตั้ง/ว่าง = throw (fail-closed: ยอม hash แบบไม่มี
* pepper แล้วเติมทีหลัง = fid เดิมได้ 2 key แถวแตก) */
export function ecourtExternalId(fid: string): string {
const pepper = process.env.FID_HASH_PEPPER?.trim();
if (pepper === undefined || pepper === "") {
throw new Error(
"ตั้งค่า FID_HASH_PEPPER ก่อน (ค่าคงที่ตลอดชีพ — ห้ามเปลี่ยนหลังมีข้อมูล)",
);
}
const digest = createHmac("sha256", pepper).update(fid.trim()).digest("hex");
return `${ECOURT_ID_PREFIX}${digest}`;
}
/** แถวนี้เป็นเอกสารของ e-Court ไหม (แยกจากแถว `url:` fallback ของ N8) */
export function isEcourtExternalId(externalId: string): boolean {
return externalId.startsWith(ECOURT_ID_PREFIX);
}

View file

@ -6,6 +6,7 @@
import { and, asc, inArray } from "drizzle-orm";
import { db } from "@/db";
import { ECOURT_ID_PREFIX } from "@/lib/ecourt-fid";
import {
annotationReplies,
annotationType,
@ -65,14 +66,15 @@ export type IncludeParts = {
annotationTypes: AnnotationType[];
};
const ECOURT_PREFIX = "ecourt:";
// external_id ของเอกสาร e-Court เก็บ "ecourt:" + hash ของ fid (lib/ecourt-fid —
// 2026-11-04 ไม่มี fid ดิบใน DB) → externalFileId ที่คืนให้ผู้เรียกจึงเป็นค่า hash
// (one-way — ผู้เรียกถือ fid จริงอยู่แล้วเพราะเป็นคนส่งมา ค่านี้ใช้เทียบความเหมือนได้อย่างเดียว)
function externalDocumentDTO(row: FileRow): ExternalDocumentDTO {
return {
id: row.id,
externalFileId:
row.externalId && row.externalId.startsWith(ECOURT_PREFIX)
? row.externalId.slice(ECOURT_PREFIX.length)
row.externalId && row.externalId.startsWith(ECOURT_ID_PREFIX)
? row.externalId.slice(ECOURT_ID_PREFIX.length)
: null,
title: row.title,
typeId: row.typeId,

View file

@ -8,9 +8,16 @@ import { eq } from "drizzle-orm";
import { db } from "@/db";
import { files } from "@/db/schema";
import { ecourtExternalId, isEcourtExternalId } from "@/lib/ecourt-fid";
import {
deleteStagedDoc,
putStagedDoc,
stagedSourceUrl,
type StagedExt,
} from "@/lib/staged-docs";
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import {
externalIdFor,
ECOURT_DEFAULT_TITLE,
isHttpUrl,
normDocumentGroup,
titleFromExternalUrl,
@ -84,8 +91,9 @@ export async function registerExternalFile(
const checked = validateExternalFileUrl(trimmed);
if (!checked.ok) return { ok: false, error: URL_ERROR_TEXT[checked.error] };
sourceUrl = checked.url.href;
title = titleFromExternalUrl(checked.url, rawFileId);
externalId = externalIdFor(rawFileId, checked.url);
title = titleFromExternalUrl(checked.url);
const id = rawFileId?.trim();
externalId = id ? ecourtExternalId(id) : `url:${checked.url.href}`;
} else if (trimmed.startsWith("/")) {
const local = validateLocalPath(trimmed);
if (!local.ok) return local;
@ -94,7 +102,7 @@ export async function registerExternalFile(
title =
base.slice(0, base.length - local.ext.length).trim().slice(0, 200) || "เอกสาร";
const id = rawFileId?.trim();
externalId = id ? `ecourt:${id}` : `url:${local.path}`;
externalId = id ? ecourtExternalId(id) : `url:${local.path}`;
} else {
return {
ok: false,
@ -102,12 +110,13 @@ export async function registerExternalFile(
};
}
// แถว ecourt:<fileId> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO route
// (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
// แถว ecourt:<hash ของ fid> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO
// route (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
// พิมพ์เองโดยผู้มี session ใด ๆ เคยใช้เขียน/refresh pdf_url ของเอกสารคนอื่นให้ชี้
// PDF ที่ตัวเองเลือก — ตอนนี้เปิดดูของที่รู้จักแล้วได้อย่างเดียว (แถว url:… ของ
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง)
const ecourtRow = externalId.startsWith("ecourt:");
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง) · external_id เก็บ hash ของ fid
// ผ่าน lib/ecourt-fid.ts (ไม่มี fid ดิบใน DB — 2026-11-04)
const ecourtRow = isEcourtExternalId(externalId);
const trusted = opts?.trusted === true;
const [existing] = await db
@ -158,3 +167,91 @@ export async function registerExternalFile(
}
return { ok: true, fileId: id };
}
/** N11 (PLAN-STAGED.md D3) — ลงทะเบียนเอกสาร base64 จาก e-Court: bytes เก็บใน RAM
* staging (lib/staged-docs.ts) ใต้ id ของ row สุดท้ายที่คืน จึงไม่มีทางเกิด
* "row ชี้ sentinel ที่ไม่มี bytes" · ผู้เรียกต้องผ่าน requireDocverApiKey +
* magic-byte check (ext มาจากผลตรวจ) มาก่อนแล้ว — trusted by construction
* · ลำดับ: resolve id ก่อน → stage ใต้ id นั้น → เขียน DB (ตรวจ cap เกิดก่อนแตะ DB —
* DB พังหลัง stage = entry กำพร้าใน RAM ที่ TTL กินเอง ไม่มีอะไรต้องเก็บกวาด)
* · insert ชน 23505 (คู่ขนาน fid เดียวกัน) = re-stage ใต้ id ผู้ชนะ + ลบของผู้แพ้
* (bytes สุดท้าย = request ที่เสร็จทีหลัง — last-write-wins ตาม semantics
* "re-POST = เนื้อล่าสุด") · โยน error ของ DB ต่อให้ route ตอบ 500 · ไม่ publish
* SSE / ไม่ revalidatePath (รายการไฟล์ไม่ sync ผ่าน SSE — carve-out invariant #4) */
export async function registerStagedFile(
fid: string,
bytes: Buffer,
ext: StagedExt,
opts?: { title?: string; typeId?: string },
): Promise<{ fileId: string }> {
const externalId = ecourtExternalId(fid);
// typeId: ส่งมา = refresh ย้ายกลุ่ม, ไม่ส่ง = คงเดิมห้ามเคลียร์ (เกณฑ์เดียวกับ N8)
const typeId = normDocumentGroup(opts?.typeId);
const title = opts?.title ?? null;
const [existing] = await db
.select()
.from(files)
.where(eq(files.externalId, externalId))
.limit(1);
let winnerId: string;
// row ปัจจุบันของผู้ชนะ — มีค่าเฉพาะเมื่อเป็น row เดิม (insert ใหม่ครบทุกค่าแล้ว
// ไม่ต้อง patch)
let current: typeof files.$inferSelect | undefined;
if (existing) {
winnerId = existing.id;
current = existing;
putStagedDoc(winnerId, bytes, ext); // replace ช่องเดิม — re-POST = เนื้อล่าสุด
} else {
const candidateId = randomUUID();
putStagedDoc(candidateId, bytes, ext);
try {
await db.insert(files).values({
id: candidateId,
title: title ?? ECOURT_DEFAULT_TITLE,
pdfUrl: stagedSourceUrl(candidateId, ext),
externalId,
typeId,
});
winnerId = candidateId;
} catch (err) {
if ((err as { code?: string })?.code !== "23505") throw err;
// คู่ขนาน fid เดียวกันชนะไปก่อน — หา row ผู้ชนะ แล้ว staging ทับด้วย bytes
// ของเรา (last-write-wins) + คืน RAM ของผู้แพ้ — finally ครอบทุกทางออกของ
// branch นี้ (select/put รอบสองโยนก็ห้ามทิ้ง candidate ไว้กิน total cap จน
// TTL หมด — code review 2026-11-05)
try {
const [row] = await db
.select()
.from(files)
.where(eq(files.externalId, externalId))
.limit(1);
if (!row) {
// ผู้ชนะหายไปกลางคัน (แทบเป็นไปไม่ได้) — โยน error 23505 ต่อ (review ข้อ 3)
throw err;
}
putStagedDoc(row.id, bytes, ext);
winnerId = row.id;
current = row;
} finally {
deleteStagedDoc(candidateId);
}
}
}
if (current) {
const sentinel = stagedSourceUrl(winnerId, ext);
const patch: { pdfUrl?: string; typeId?: string; title?: string } = {};
if (current.pdfUrl !== sentinel) patch.pdfUrl = sentinel;
if (typeId !== null && current.typeId !== typeId) patch.typeId = typeId;
// title ปลดล็อกเฉพาะค่าปัจจุบันยังเป็น default (ECOURT_DEFAULT_TITLE — ไม่ฝัง
// fid) — ครั้งแรกที่ส่ง title จริงชนะแล้วล็อก (กันค้างชื่อ default ตลอดชีพเมื่อ
// ครั้งแรกลืมส่ง)
if (title !== null && current.title === ECOURT_DEFAULT_TITLE) patch.title = title;
if (Object.keys(patch).length > 0) {
await db.update(files).set(patch).where(eq(files.id, winnerId));
}
}
return { fileId: winnerId };
}

View file

@ -687,6 +687,146 @@ export const openApiSpec = {
},
},
},
"/api/note/files/stage": {
post: {
tags: ["Judge's Note"],
summary: "ส่งเนื้อไฟล์เอกสาร (PDF/รูป) เข้า RAM staging ด้วย base64 (N11)",
description:
"ทางเข้าเอกสารที่ e-Court **ไม่มี URL ให้ดึง** (เอกสารภายใน/สร้างสด — ต่างจาก " +
"N8 ที่ส่ง `fileUrl`) — ส่งเนื้อไฟล์มาเป็น base64 ระบบเก็บใน **RAM ชั่วคราว** " +
"(TTL 8 ชม. นับจากการเข้าใช้ล่าสุด — ห้ามเก็บไฟล์ถาวรทุกรูปแบบตามโจทย์) " +
"annotation/bookmark เก็บถาวรใน DB ตามระบบเดิม\n\n" +
"ได้ `{fileId}` แล้วเอา `fid` **เดิม** ไป mint login token ที่ " +
"`POST /api/note/auth/token` → ลิงก์ `/note?t=<access_token>` (ไม่ต้องมี " +
"`fileUrl`) ให้ผู้ใช้คลิกตามเส้นทางเดิม\n\n" +
"- auth: header `x-api-key` (env `DOCVER_API_KEY` — ตัวเดียวกับทางเข้า mint)\n" +
"- `data`: **base64 มาตรฐานต่อเนื่อง** — ห้าม whitespace/line break และไม่รับ " +
"base64url (`-`/`_`)\n" +
"- ขนาด: เนื้อไฟล์ ≤ 100MB (base64 ยาว ~134MB) — ระบบตรวจ magic bytes เอง " +
"(PDF/JPEG/PNG/TIFF) และใช้ผลตรวจเป็นชนิดไฟล์ ไม่เชื่อค่าที่ประกาศ\n" +
"- การ stage ด้วย `fid` เดิมซ้ำ = **replace เนื้อล่าสุด** (row เดิม " +
"annotation ครบ — พิกัดอาจคลาดถ้าเลย์เอาต์ขยับ เช่นเดียวกับ N8)\n\n" +
"**คู่มือ retry:** `408`/`429`/`5xx` = **ยิงซ้ำได้** (429 ตาม header " +
"`Retry-After`, ที่เหลือ backoff) และการ stage ซ้ำ **ปลอดภัย** — `fid` เดิม " +
"แทนที่ของเดิม ไม่เกิดรายการซ้ำ ไม่เสียอะไร · `400`/`413` = แก้ request " +
"ก่อนยิงใหม่ · อัปโหลดช้าถูกตัดที่ 60 วิว่างเปล่า/10 นาทีรวม (408) — " +
"ลองส่งใหม่บนเครือข่ายที่ดีกว่า",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["fid", "data"],
properties: {
fid: {
type: "string",
description:
"รหัสเอกสารของ e-Court (1–200 ตัวอักษร, ห้ามอักขระ & = + %) — ใช้ mint login token " +
"ต่อด้วย fid เดิม · ฝั่งระบบนี้เก็บเป็น hash (ไม่มี fid ดิบใน DB) แต่ผู้เรียกยังส่ง " +
"fid ดิบมาก่อน hash ทุกเส้น — contract ไม่เปลี่ยน",
},
data: {
type: "string",
description:
"เนื้อไฟล์ (.pdf/.jpg/.png/.tif) เข้ารหัส base64 มาตรฐานต่อเนื่อง — เนื้อไฟล์ ≤ 100MB",
},
typeId: {
type: "string",
description:
"รหัสกลุ่มเอกสาร N9 (ทางเลือก) — ส่งมา = ย้ายกลุ่มตามค่าล่าสุด ไม่ส่ง = คงเดิม · " +
"⚠️ ของเอกสาร staging รับทางเส้นนี้**ทางเดียว** — typeId ที่แนบมากับ body ของ " +
"`POST /api/note/auth/session` (ตอนแลกลิงก์ fid-only) ไม่ถูกใช้กับกลุ่มของไฟล์",
},
title: {
type: "string",
description:
"ชื่อแสดง (ทางเลือก, ≤ 200 ตัวอักษร) — อัปเดตได้เฉพาะเมื่อชื่อปัจจุบันยังเป็นค่า default " +
'"เอกสารจาก e-Court" (ครั้งแรกที่ส่งชื่อจริงชนะแล้วล็อก)',
},
},
},
examples: {
"stage คำสั่งศาล PDF": {
value: {
fid: "F123",
data: "JVBERi0xLjQK…",
title: "คำสั่งศาลที่ 2501/2569",
typeId: "GROUP-01",
},
},
},
},
},
},
responses: {
200: {
description:
"ลงทะเบียนสำเร็จ — `fileId` คือ id ของ row เอกสาร (ผู้ใช้เข้าผ่านลิงก์ fid ตามปกติ)",
content: {
"application/json": {
schema: {
type: "object",
properties: {
fileId: {
type: "string",
description: "uuid ของ row เอกสาร (sentinel staged:<uuid>.<ext> ใน pdf_url)",
},
},
},
},
},
},
400: {
description:
"body ผิด (field ที่ไม่รู้จัก / base64 เสีย / magic bytes ไม่ใช่ PDF หรือรูปที่รองรับ / fid มีอักขระ & = + %)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
401: {
description:
"ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY — ข้อความเดียวกันทุกสาเหตุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
408: {
description:
"อ่าน body ช้าเกินกำหนด (ไม่มี chunk 60 วิ / เกิน 10 นาทีรวม) หรือการเชื่อมต่อขาด — ยิงซ้ำได้",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
413: {
description:
"ไฟล์ใหญ่เกินขนาดที่รองรับ (เนื้อไฟล์เกิน 100MB / base64 เกิน ~140MB) หรือใหญ่เกินโควตารวม — แก้ไฟล์ก่อนส่งใหม่",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
429: {
description:
"มีการ stage เอกสารอื่นกำลังดำเนินการ (จำกัด concurrent) — ยิงซ้ำตาม header Retry-After",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/token": {
post: {
tags: ["documents"],
@ -1033,9 +1173,10 @@ export const openApiSpec = {
name: "x-api-key",
description:
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
"request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " +
"(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
"DOCVER_API_KEY · ของกลุ่ม Judge's Note (mint login-token + stage เอกสาร base64) " +
"= env DOCVER_API_KEY เช่นกัน (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียว " +
"แล้วใช้ได้ทุก request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: " +
"รับได้ทั้งสองค่า (DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
},
DocverBearer: {
type: "http",
@ -1241,7 +1382,10 @@ export const openApiSpec = {
externalFileId: {
type: "string",
nullable: true,
description: "id ของ e-Court (null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)",
description:
"ค่า hash ของ fileId (HMAC-SHA256 ทางเดียว — แปลงกลับเป็น fid เดิมไม่ได้ " +
"2026-11-04: ระบบเก็บ hash แทน fid ดิบ; ค่านี้คงเดิมทุกครั้งที่ถามด้วย fid เดียวกัน " +
"จึงใช้เทียบความเหมือนได้อย่างเดียว · null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)",
},
title: { type: "string" },
typeId: { type: "string", nullable: true },

View file

@ -8,6 +8,12 @@ import path from "node:path";
export class PdfPathError extends Error {}
export function resolvePublicPdfPath(pdfUrl: string): string {
// N11: เอกสาร staging (sentinel staged:) ไม่มีไฟล์บนดิสก์ — ปฏิเสธชัด ๆ กัน caller
// ในอนาคตลืมแยกทาง (วันนี้ "staged:x.pdf" ผ่าน resolve ตกใน publicDir แล้วตายด้วย
// ENOENT เป็น 500 ดิบ — Windows อาจ EINVAL เพราะ colon ในชื่อ)
if (pdfUrl.startsWith("staged:")) {
throw new PdfPathError("เอกสารนี้ไม่มีไฟล์บนดิสก์ (staging พ้นช่วงจัดเตรียม)");
}
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… ของ Next เสิร์ฟจากที่นี่ —
// pdf_url ใน DB เป็น logical path ไม่รวม /note เติมตรงนี้ฝั่ง disk คู่กับ withBasePath ฝั่ง browser)
const publicDir = path.resolve(process.cwd(), "public", "note");

219
lib/staged-docs.ts Normal file
View 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();
}