diff --git a/.env.example b/.env.example index d8250bd..3f5b356 100644 --- a/.env.example +++ b/.env.example @@ -59,6 +59,28 @@ MINIO_SECRET_KEY=minioadmin # บน LAN เปิดไว้ = คนในวงแชร์เครือข่าย mint ลิงก์เข้าระบบได้ จึงควรเปิดเฉพาะตอนทดสอบ # NOTE_SSO_DEV_MINT=1 +# N11 RAM staging ของเอกสาร base64 จาก e-Court (2026-11-04, docs/PLAN-STAGED.md) +# POST /api/note/files/stage ใช้ DOCVER_API_KEY ด้านบน — env ด้านล่างเป็นตัวปรับล้วน +# ไม่ตั้งทั้งหมด = ใช้ default ทำงานได้เลย · ⚠️ staging อยู่ใน RAM ของ process เดียว +# (เหมือน SSE) — ห้ามรันหลาย instance/cluster · deploy หลัง IIS ต้องขยับ +# maxAllowedContentLength ≥ 140MB (ดู PLAN-STAGED.md D7) +# TTL sliding จากการเข้าใช้ล่าสุด (วินาที) — default 28800 = 8 ชม. เท่า session +# STAGED_DOC_TTL_SECONDS=28800 +# โควตารวมทุกไฟล์ใน RAM (ไบต์) — เกิน = LRU evict รายการเก่าสุด +# STAGED_MAX_TOTAL_BYTES=268435456 +# ขนาดเนื้อไฟล์ต่อไฟล์ (ไบต์) — เกิน = 413 +# STAGED_MAX_FILE_BYTES=104857600 +# จำนวน stage request พร้อมกันสูงสุด — เกิน = 429 Retry-After: 5 +# STAGED_MAX_CONCURRENT=1 + +# hash fid ของ e-Court ก่อนเก็บลง files.external_id (2026-11-04, lib/ecourt-fid.ts) +# = HMAC-SHA256(pepper, fid) — ไม่มี fid ดิบใน DB +# ⚠️ ค่าคงที่ตลอดชีพ: ห้ามเปลี่ยนหลังมีข้อมูล (เปลี่ยน = แถวเก่าค้นไม่เจอ + เกิดแถว +# คู่เงียบ ๆ) · ตั้งค่ายาวและสุ่ม 32+ ตัวอักษร · ห้าม commit ค่าจริงลง repo +# · row เก่าที่เก็บ plaintext แปลงครั้งเดียวด้วย npm run migrate:ecourt-fid +# (ต้องใช้ pepper ค่าเดียวกัน — ระหว่างรันต้องหยุด process ของแอปก่อน) +FID_HASH_PEPPER= + # add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token # ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp # มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง diff --git a/AGENTS.md b/AGENTS.md index ba98285..91b80da 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,7 +80,7 @@ app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/note/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (N4) app/api/note/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) -app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) +app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" + hash ของ fileId (lib/ecourt-fid 2026-11-04 — ผู้เรียกยังส่ง fid ดิบ เรา hash ตอน query) ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath diff --git a/CLAUDE.md b/CLAUDE.md index 5f39139..251b5f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,21 +14,21 @@ - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) - **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ใน custom properties — add-in ตรวจ HMAC เองฝั่ง client แล้วขอ token ที่ `/api/documents/token` แบบ body ticket เก็บใน localStorage `wordaddin-auth` ตาม fid, `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิง `/api/documents/*` ผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo`) (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) - Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) -- **SSO จากลิงก์ e-Court — ทางเข้า session ทางเดียว (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ทั้งระบบ, `docs/PLAN-SSO.md` §0.5)**: ลิงก์ 2 รูปแบบ **(A) JWT** — e-Court mint ผ่าน `POST /api/note/auth/token` (ทางเข้า `x-api-key` ตัวเดิม `DOCVER_API_KEY`) ฝังเป็น param `t=` และ **(B) ticket** — `ticket_data`+`HmacSha256` ชุดเดียวกับตัว stamp .docx ของ add-in → ทั้งคู่ชี้ตรงมา **`/note`** ที่ `NoteHome` (client bootstrap) แลก credential เป็น **access_token (purpose=login อายุ 8 ชม. = `NOTE_SESSION_TTL`) ที่ `POST /api/note/auth/session`** → เก็บใน localStorage `note-auth` (แบบ `wordaddin/services/authService`) + ตัด credential ออกจาก URL ทันที + ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก fid ที่เซ็น) · identity ทุก action/API แกะจาก token ผ่าน `lib/note-token.ts` (env `DOCVER_TOKEN_SECRET` ตัวเดียว — cookie คู่ oacCase/`NOTE_SESSION_SECRET`/middleware/link proof ของรอบ 2026-10-29 **ถอดหมด**) · หน้า protected เป็น shell public + client gate (`AnnotateView` — ไม่มี token = หน้า "กลับ e-Court กดลิงก์ใหม่") ดึงข้อมูลผ่าน `GET /api/note/files/[id]/bootstrap` (Bearer) · เส้นที่ browser เปิดแบบไม่ผ่าน fetch() (SSE/pdf/`` N3/export) คงไม่มี auth ตาม status quo · dev/ทดสอบ mint ลิงก์เองด้วย `scripts/make-login-link.mjs` (roles ยัง out of scope) +- **SSO จากลิงก์ e-Court — ทางเข้า session ทางเดียว (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ทั้งระบบ, `docs/PLAN-SSO.md` §0.5)**: ลิงก์ 2 รูปแบบ **(A) JWT** — e-Court mint ผ่าน `POST /api/note/auth/token` (ทางเข้า `x-api-key` ตัวเดิม `DOCVER_API_KEY`) ฝังเป็น param `t=` และ **(B) ticket** — `ticket_data`+`HmacSha256` ชุดเดียวกับตัว stamp .docx ของ add-in → ทั้งคู่ชี้ตรงมา **`/note`** ที่ `NoteHome` (client bootstrap) แลก credential เป็น **access_token (purpose=login อายุ 8 ชม. = `NOTE_SESSION_TTL`) ที่ `POST /api/note/auth/session`** → เก็บใน localStorage `note-auth` (แบบ `wordaddin/services/authService`) + ตัด credential ออกจาก URL ทันที + ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก fid ที่เซ็น — ลิงก์มี `fileUrl`; **ลิงก์ fid-only ไม่มี `fileUrl` = เอกสาร staging ของ N11**: e-Court POST เนื้อ base64 ที่ `POST /api/note/files/stage` ก่อนแล้ว mint token ด้วย fid เดิม — session route เช็ค staging ตั้งแต่แลก token, `docs/PLAN-STAGED.md`) · identity ทุก action/API แกะจาก token ผ่าน `lib/note-token.ts` (env `DOCVER_TOKEN_SECRET` ตัวเดียว — cookie คู่ oacCase/`NOTE_SESSION_SECRET`/middleware/link proof ของรอบ 2026-10-29 **ถอดหมด**) · หน้า protected เป็น shell public + client gate (`AnnotateView` — ไม่มี token = หน้า "กลับ e-Court กดลิงก์ใหม่") ดึงข้อมูลผ่าน `GET /api/note/files/[id]/bootstrap` (Bearer) · เส้นที่ browser เปิดแบบไม่ผ่าน fetch() (SSE/pdf/`` N3/export) คงไม่มี auth ตาม status quo · dev/ทดสอบ mint ลิงก์เองด้วย `scripts/make-login-link.mjs` (roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) 1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม) 2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด 3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน -4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ทุก action รับ `token` (access_token ของ session) เป็น param แรกและแกะ identity เองผ่าน `requireUser`/`lib/note-token.ts` — ห้ามรับ userId/userName จาก client (2026-10-30)** — **ยกเว้น `registerExternalFile` (N8)** ที่เรียกจาก route handler ไม่ใช่ Server Action: `POST /api/note/auth/session` (trusted — ตอนแลก credential ของลิงก์ e-Court, fileId จาก fid ที่เซ็น) กับ `GET /api/note/files/open` (path ในเครื่อง `/samples/…` เท่านั้น + trusted:false — ลิงก์ A Link Get(new) ของหน้า /note/dev; ไม่รับ fileId แถว `ecourt:` เกิดไม่ได้) (รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName, templateId และ docId (จาก claim fid) แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath` +4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ทุก action รับ `token` (access_token ของ session) เป็น param แรกและแกะ identity เองผ่าน `requireUser`/`lib/note-token.ts` — ห้ามรับ userId/userName จาก client (2026-10-30)** — **ยกเว้น `registerExternalFile` (N8)** ที่เรียกจาก route handler ไม่ใช่ Server Action: `POST /api/note/auth/session` (trusted — ตอนแลก credential ของลิงก์ e-Court, fileId จาก fid ที่เซ็น) กับ `GET /api/note/files/open` (path ในเครื่อง `/samples/…` เท่านั้น + trusted:false — ลิงก์ A Link Get(new) ของหน้า /note/dev; ไม่รับ fileId แถว `ecourt:` เกิดไม่ได้) และ **`registerStagedFile` (N11)** ของ `POST /api/note/files/stage` (เนื้อไฟล์ base64 จาก e-Court — trusted by construction: ผู้เรียกผ่าน `requireDocverApiKey` + magic-byte check ที่ route แล้ว, `docs/PLAN-STAGED.md`) (รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName, templateId และ docId (จาก claim fid) แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath` 5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ 6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)` 7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว) 8. **JSONB `data` เป็น strict discriminated union** (`AnnotationData` แยกตาม enum `type`) — ห้าม loosen type เป็น `any`/generic object 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) 10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน -11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) +11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE **และ RAM staging ของเอกสาร base64 (N11 `lib/staged-docs.ts`)** เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) 12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/annotate"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export) ## โครงสร้างไฟล์ (ตามแผน) @@ -46,19 +46,21 @@ db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) middleware.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — เดิมบังคับ session ทุกหน้าผ่าน cookie คู่; ตอนนี้ทุกหน้าเป็น shell public ส่วน session ตรวจฝั่ง client (token ใน localStorage) และ API/actions ตรวจ Bearer เอง lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) -lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้) +lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้; N11 2026-11-04: `resolvePublicPdfPath` ปฏิเสธ sentinel `staged:` ด้วย `PdfPathError` ชัด ๆ ก่อน resolve — defense in depth ให้ caller ในอนาคตที่ลืมแยกทาง) lib/note-auth.ts ← SSO ของแอป note ส่วนกลาง — pure (PLAN-SSO.md §4 ฉบับ token 2026-10-30: ไม่ import @/db/node:crypto ให้ client component ใช้ร่วมได้): parseSsoLinkParams (bootstrap ของ NoteHome — allowlist t/ticket_data/HmacSha256/fileUrl/typeId + cap ยาว, สองชุดพร้อมกัน = invalid ตาม D12) + parseSessionExchangeBody (body ของ POST /api/note/auth/session — เกณฑ์เดียวกัน) + parseLoginTokenBody (body ของเส้น mint: allowlist userid/username/fid/ttl_seconds 60–600 default 300 + ห้ามอักขระ & = + % ใน identity) + devMintEnabled/parseDevLinkBody (dev-link) + ssoBanner (แบนเนอร์ 4 ค่า) — unit test tests/lib/note-auth.test.ts lib/note-token.ts ← session ฝั่ง server ของแอป note (2026-10-30): NOTE_SESSION_TTL (8 ชม.) + noteUserFromToken/noteUserFromRequest (verifyDocverLoginToken + env DOCVER_TOKEN_SECRET — ใช้ใน requireUser ของ actions และ API ของแอป; import lib/docver-token ซึ่งใช้ node:crypto จึง**ห้ามให้ client แตะ**) lib/note-session.ts ← session ฝั่ง client (2026-10-30 แบบ add-in): localStorage "note-auth" {token, userid, username, expMs} — readNoteSession/saveNoteSession/clearNoteSession (หมดอายุ = ล้างคืน null, localStorage โยน = try/catch ทุกจุด) + noteAuthFetch แนบ Authorization: Bearer อัตโนมัติ -lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId **เฉพาะแถว `url:…` เสมอ / แถว `ecourt:` เฉพาะเมื่อ opts.trusted — link proof จาก SSO route, review 2026-10-30: ผู้มี session พิมพ์ `?fileUrl&fileId` เองเปิดดูของที่รู้จักแล้วได้อย่างเดียว สร้างแถวใหม่/refresh ไม่ได้ (เคยใช้ repoint pdf_url ของเอกสารคนอื่น)** — เรียกระหว่าง render หน้า /note และ /note/annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) +lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId **เฉพาะแถว `url:…` เสมอ / แถว `ecourt:` เฉพาะเมื่อ opts.trusted — link proof จาก SSO route, review 2026-10-30: ผู้มี session พิมพ์ `?fileUrl&fileId` เองเปิดดูของที่รู้จักแล้วได้อย่างเดียว สร้างแถวใหม่/refresh ไม่ได้ (เคยใช้ repoint pdf_url ของเอกสารคนอื่น)** — เรียกระหว่าง render หน้า /note และ /note/annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) + **N11 registerStagedFile** (2026-11-04 — ลำดับ resolve id → `putStagedDoc` ใต้ id นั้น (ตรวจ cap ก่อนแตะ DB) → insert; ชน `23505` = re-stage ใต้ id ผู้ชนะ + `deleteStagedDoc` ของผู้แพ้ = last-write-wins, **ไม่มีทางเกิด row ชี้ sentinel ที่ไม่มี bytes**; patch เดียว: pdfUrl sentinel ใหม่เมื่อ ext ต่าง / typeId เมื่อส่งมา / title เฉพาะค่าปัจจุบันยังเป็นค่า default `ECOURT_DEFAULT_TITLE`; เรียกจาก stage route เท่านั้น — carve-out invariant #4 เหมือน N8) · external_id ของ e-Court สร้างผ่าน lib/ecourt-fid.ts ทั้งสองฟังก์ชัน lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม) +lib/staged-docs.ts ← **N11 RAM staging ของเอกสาร base64 จาก e-Court** (`docs/PLAN-STAGED.md` 2026-11-04 — "แหล่ง bytes ชั่วคราว" ทดแทน pdf_url ที่ fetch ได้, **ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียว**): Map บน globalThis (pattern annotate-events) key = fileId, ค่า = {bytes, ext, createdAt, lastAccessAt} · sentinel ใน `files.pdf_url` = `staged:.` (stagedSourceUrl/parseStagedSource/isStagedSource — ext จาก magic bytes ที่ `detectStagedExt` เดียวกัน → `fileKindFromUrl` ทำงานทุกจุดไม่ต้องแก้) · TTL sliding จาก lastAccessAt (`STAGED_DOC_TTL_SECONDS` default 28800 = 8 ชม. เท่า NOTE_SESSION_TTL) · LRU evict ตาม total cap (`STAGED_MAX_TOTAL_BYTES` default 256MB) + ไฟล์เดี่ยว `STAGED_MAX_FILE_BYTES` default 100MB (เกิน = `StagedCapError` → route ตอบ 413) · `getStagedDoc` = ผู้อ่านจริง (touch+คืน Buffer — ต้องไม่ mutate ต้นทาง) แต่ `hasStagedDoc` = เช็คอย่างเดียว**ไม่ touch ไม่ copy** (session route ใช้ตอนแลก token) · lazy sweep ตอน put ไม่มี timer · **single-process เหมือน SSE (invariant #11)** · pure module (unit test tests/lib/staged-docs.test.ts รันได้ไม่ต้องตั้ง DATABASE_URL) +lib/ecourt-fid.ts ← **dedupe key ของเอกสาร e-Court แบบ hash (2026-11-04 ตามคำขอผู้ใช้ — ไม่มี fid ดิบใน DB)**: `ecourtExternalId(fid)` = `"ecourt:" + HMAC-SHA256(pepper, fid.trim())` hex 64 (pepper จาก env `FID_HASH_PEPPER` อ่านตอน call **ไม่ตั้ง/ว่าง = throw fail-closed** · ⚠️ ค่าคงที่ตลอดชีพห้ามเปลี่ยน — เปลี่ยน = แถวเก่าค้นไม่เจอ+เกิดแถวคู่เงียบ ๆ) + `isEcourtExternalId` — **จุดเดียว**ของการสร้าง/เทียบ external_id ทุกทาง (N8 registerExternalFile, N11 registerStagedFile, session D5, N10 query — e-Court ยังส่ง fid ดิบมาทุกเส้น เรา hash ตอนรับ/ถาม: deterministic + pepper เดียวกัน = digest เดิม, contract ฝั่ง e-Court ไม่เปลี่ยน) · ผลข้างเคียงที่รับไว้: `externalFileId` ของ N10 response จึงเป็นค่า hash one-way · pure (node:crypto อย่างเดียว — unit test tests/lib/ecourt-fid.test.ts) · row เก่า plaintext แปลงครั้งเดียวด้วย `npm run migrate:ecourt-fid` (scripts/migrate-ecourt-fid-hash.mjs — Node script ไม่ใช่ SQL เพราะ pepper ห้าม commit ลง drizzle/ · idempotent + pre-check ชน · ต้องหยุด process แอปก่อนรัน) lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts · **field `fields` ก็ถอดออก 2026-10-30** server แกะจากไฟล์เองที่ route ผ่าน lib/lib-tmpl.ts + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) -lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) — 2026-10-29 (PLAN-SSO.md D4): claims มี `purpose?: "docver"|"login"` (undefined = docver — token เก่าตอน roll ไม่ตาย), `issueDocverToken` รับ purpose ทาง param (default "docver"), `requireDocverBearer` ปฏิเสธ login-token (กัน scope escalation), **`verifyDocverLoginToken`** แยก reason expired/invalid + บังคับ purpose=login สำหรับ SSO route -lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + **requireDocverApiKey** (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ · ผู้ใช้: กลุ่ม docver + mint login-token + stage N11) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) — 2026-10-29 (PLAN-SSO.md D4): claims มี `purpose?: "docver"|"login"` (undefined = docver — token เก่าตอน roll ไม่ตาย), `issueDocverToken` รับ purpose ทาง param (default "docver"), `requireDocverBearer` ปฏิเสธ login-token (กัน scope escalation), **`verifyDocverLoginToken`** แยก reason expired/invalid + บังคับ purpose=login สำหรับ SSO route +lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) @@ -70,7 +72,7 @@ utils/annotation-display.ts ← label/ข้อความย่อต่อช utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) utils/user.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — เดิม parse/build cookie oacCase + OAC_SIG_COOKIE/SESSION_MAX_AGE ของรอบ cookie -utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ที่เดียวคือ N8 `registerExternalFile`, pure; ~~sanitizeNextPath~~ ถอดแล้วพร้อมหน้า login 2026-10-29) +utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl (fallback = **ECOURT_DEFAULT_TITLE** — ตัดพารามิเตอร์ fallbackId ออก 2026-11-04 กัน fid ดิบหลุดลง title) + isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ที่เดียวคือ N8 `registerExternalFile`) + ECOURT_DEFAULT_TITLE (ค่า default title กลางของเอกสาร e-Court "เอกสารจาก e-Court" — N8 fallback + N11 insert/เงื่อนไขปลดล็อก title ของ registerStagedFile, ไม่ฝัง fid) · pure (~~externalIdFor~~ ถอดแล้ว 2026-11-04 — external_id ของ e-Court สร้างผ่าน lib/ecourt-fid.ts แบบ hash แทน) utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย ให้สองฝั่งฉีด crypto ของตัวเอง: lib/addin-ticket.ts = node:crypto / wordaddin/services/ticketService.ts = Web Crypto): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint + ห้ามอักขระ & = + % ใน uid/uname — เกณฑ์เดียวกับ parseLoginTokenBody: อักขระที่ตีความต่างกันตาม context ง่าย, 2026-10-30) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — unit test tests/utils/docver-ticket.test.ts utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) @@ -82,19 +84,20 @@ hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event app/actions/auth.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — logout เป็นปุ่ม client ล้าง localStorage (components/LogoutButton) ไม่มี server action ให้เรียก app/api/note/auth/token/route.ts ← **SSO เส้น mint login-token** (PLAN-SSO.md §3.1 — x-api-key ผ่าน requireDocverApiKey ตัวเดิม + body allowlist userid/username/fid/ttl_seconds (60–600 default 300) → issueDocverToken purpose="login"; ไม่ import @/db) -app/api/note/auth/session/route.ts ← **แลก credential ของลิงก์ e-Court เป็น access_token** (PLAN-SSO.md §3.2 ฉบับ token 2026-10-30 — แทน GET e-court ที่ set cookie ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?, typeId?} → verify (JWT: verifyDocverLoginToken แยก expired/invalid / ticket: verifyAddinTicket + **ปฏิเสธค่า default "super_secret" เฉพาะ production** — ค่ารู้กันทั้ง repo = ปลอม session ได้ (dev ยอมรับ default ให้ทดสอบได้; กลุ่ม docver/add-in ใช้ default ได้ตาม requirement) → registerExternalFile trusted (fid จาก credential) → คืน {access_token (8 ชม.), userid, username, fileId?, fileError?} · audit log เฉพาะ userid (strip อักขระควบคุม) ห้าม log token ดิบ · ไม่ publish SSE +app/api/note/auth/session/route.ts ← **แลก credential ของลิงก์ e-Court เป็น access_token** (PLAN-SSO.md §3.2 ฉบับ token 2026-10-30 — แทน GET e-court ที่ set cookie ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?, typeId?} → verify (JWT: verifyDocverLoginToken แยก expired/invalid / ticket: verifyAddinTicket + **ปฏิเสธค่า default "super_secret" เฉพาะ production** — ค่ารู้กันทั้ง repo = ปลอม session ได้ (dev ยอมรับ default ให้ทดสอบได้; กลุ่ม docver/add-in ใช้ default ได้ตาม requirement) → มี fileUrl = registerExternalFile trusted (fid จาก credential) · **ไม่มี fileUrl (ลิงก์ fid-only — N11 2026-11-04, PLAN-STAGED.md D5): select `ecourt:` (lib/ecourt-fid) แล้วตัดสินตั้งแต่แลก token — staged + `hasStagedDoc` (ไม่ touch TTL) = คืน fileId / staged ไม่มี bytes = fileError "พ้นช่วงจัดเตรียม" / URL ปกติ (N8) = คืน fileId / ไม่เจอ = fileError "ยังไม่ถูกส่งเข้าระบบ"** → คืน {access_token (8 ชม.), userid, username, fileId?, fileError?} · audit log เฉพาะ userid (strip อักขระควบคุม) ห้าม log token ดิบ · ไม่ publish SSE app/api/note/files/[id]/bootstrap/route.ts ← ข้อมูลเริ่มต้นของ viewer (file + annotations + bookmarks + replies + user เป็น DTO) ให้ AnnotateView ดึงหลังมี token — Bearer บังคับ (401), query ชุดเดียวกับหน้า annotate สมัย server component +app/api/note/files/stage/route.ts ← **N11 POST stage เนื้อไฟล์ base64 จาก e-Court** (`docs/PLAN-STAGED.md` D1 2026-11-04 — เอกสารที่ไม่มี URL ให้ดึง): auth `requireDocverApiKey` (env `DOCVER_API_KEY`) ก่อนแตะอะไรทั้งสิ้น → อ่าน body แบบ stream นับไบต์ (Content-Length fast-path + chunked นับจริง, cap base64 ~140MB → 413) + idle 60 วิ/total 10 นาที/`req.signal` abort → 408 + concurrency `STAGED_MAX_CONCURRENT` default 1 → 429 `Retry-After: 5` (**คืน slot+เคลียร์ timer ใน finally ทุกทางออก**) → JSON allowlist fid/data/typeId/title (fid เกณฑ์ parseLoginTokenBody) → base64 มาตรฐานต่อเนื่อง strict (regex + len%4 + เทียบความยาวหลัง decode — Node Buffer.from ยืดหยุ่นเกิน) → magic bytes (`detectStagedExt`) เป็นตัวกำหนด ext → registerStagedFile → `{fileId}` · error ทั้งหก 401/400/408/413/429/500 เป็น JSON ไทย · ห้าม log bytes/key ดิบ (เฉพาะ fid+ขนาด) · contract อยู่ Swagger UI (`lib/openapi.ts` — มีคู่มือ retry: 408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) · ไม่ publish SSE/`revalidatePath` (carve-out invariant #4) app/api/note/files/open/route.ts ← **GET** /api/note/files/open?fileUrl=/samples/… — ทางเปิดเอกสารตัวอย่างของหน้า /note/dev แบบ "A Link Get(new)" ของ /tmpl (2026-10-30): ลิงก์เปล่ายิง GET ไม่มี Bearer (navigation ใส่ header ไม่ได้) จึง**รับเฉพาะ path ในเครื่องขึ้นต้น "/" เท่านั้น** (https เด็ดขาด + ห้าม ..) → registerExternalFile trusted:false (แถว url:… เท่านั้น — ไม่รับ fileId แถว ecourt: เกิดไม่ได้) → 302 เข้า viewer · ผิด = 302 /note?fileError=… ให้หน้าแรกแสดงสาเหตุ app/api/note/auth/dev-link/route.ts ← **mint ลิงก์ทดสอบจากหน้า /note** (คำขอผู้ใช้ 2026-10-29 — **2026-11-02 เกณฑ์เหลือ env `NOTE_SSO_DEV_MINT=1` อย่างเดียว** ตาม `devMintEnabled` ของ lib/note-auth ใช้ได้ทุก environment รวม production ที่ตั้ง env ไว้ (เครื่องทดสอบ .74) — เดิม gate ด้วย NODE_ENV≠production ด้วย; ปิด = 404 + ฟอร์มหายจากหน้า): **POST** parseDevLinkBody (delegate เกณฑ์ identity/ttl ให้ parseLoginTokenBody) → jwt ใช้ DOCVER_TOKEN_SECRET / ticket ใช้ addinTicketSecret + ticket_data เรียง key ตามตัว stamp → คืน `{url}` เต็มจาก origin ของ request — secret ไม่ผ่านหน้าเบราว์เซอร์ · **GET ?file=<ไฟล์ตัวอย่าง>&mode=jwt|ticket (2026-10-30 รอบสอง)** — ลิงก์ "A Link Get(new)" ของหน้า /note/dev: mint credential (identity dev คงที่ oom/สรวิชญ์ + `fid=dev-<ไฟล์>` ให้แต่ละตัวอย่างมี row ecourt: ของตัวเอง) แล้ว 302 เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก token ต่อ — flow เดียวกับลิงก์จริงของ e-Court ทุกขั้น · ไม่ใช่ทางเข้าจริง (แผน D7) · เครื่องที่ตั้ง env นี้ค้าง = ใครรู้ URL ก็ mint ได้ (ความเสี่ยงตกที่ผู้ตั้ง env) app/api/note/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) -app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) +app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" + **hash ของ fileId** (lib/ecourt-fid 2026-11-04 — ผู้เรียกยังส่ง fid ดิบ เรา hash ตอน query) ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode · externalFileId ใน bundle = ค่า hash one-way; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/note/page.tsx ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/note/auth/session → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public -app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` เมื่อ mint ปิด / dev-link เมื่อ mint เปิด + section Flow ของแอป — 2026-10-30 รอบสอง) + **ฟอร์ม `MockEcourtForm` "จำลอง e-Court — ขอ token ด้วย API key" (2026-11-02, แสดงทุก environment)** — ใส่ `DOCVER_API_KEY` + uid/uname + เลือกไฟล์ → ยิง `POST /api/note/auth/token` (ทางเข้าจริงเหมือน e-Court) → redirect เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก session ต่อ · **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` · **ทางเข้าหน้าคุมด้วย env `NOTE_SSO_DEV_MINT=1` อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment รวม production ที่ตั้ง env ไว้) · 0/ไม่ตั้ง = `notFound()` 404 ทั้งหน้า ไม่ใช่หน้า public อีกต่อไป** · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract) +app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` เมื่อ mint ปิด / dev-link เมื่อ mint เปิด + section Flow ของแอป — 2026-10-30 รอบสอง) + **ฟอร์ม `MockEcourtForm` "จำลอง e-Court — ขอ token ด้วย API key" (2026-11-02, แสดงทุก environment)** — ใส่ `DOCVER_API_KEY` + uid/uname + เลือกไฟล์ → ยิง `POST /api/note/auth/token` (ทางเข้าจริงเหมือน e-Court) → redirect เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก session ต่อ · **ฟอร์ม `MockStageForm` "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)" (N11 2026-11-04, PLAN-STAGED.md D8)** — เลือกไฟล์ (FileReader→base64, ตัด >100MB ฝั่ง client) → ยิง stage + mint ด้วย fid เดิม → ลิงก์ fid-only + ปุ่มเปิด · ต้องถือ `DOCVER_API_KEY` จริงจึงไม่ใช่ dropzone/ประตูหลัง · **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` · **ทางเข้าหน้าคุมด้วย env `NOTE_SSO_DEV_MINT=1` อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment รวม production ที่ตั้ง env ไว้) · 0/ไม่ตั้ง = `notFound()` 404 ทั้งหน้า ไม่ใช่หน้า public อีกต่อไป** · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract) app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) app/note/annotate/[id]/page.tsx ← shell บาง ๆ (ตรวจ UUID → `components/pdf/AnnotateView`) — ฉบับ token 2026-10-30: client gate ตรวจ token ใน localStorage แล้วดึง initial data ผ่าน GET /api/note/files/[id]/bootstrap (Bearer) → PDFViewer (prop token); ไม่มี token/401 = หน้า "กลับ e-Court กดลิงก์ใหม่" app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) diff --git a/app/api/note/annotations/export/route.ts b/app/api/note/annotations/export/route.ts index 92f50c7..5de1133 100644 --- a/app/api/note/annotations/export/route.ts +++ b/app/api/note/annotations/export/route.ts @@ -18,6 +18,7 @@ import { import { and, asc, eq, inArray } from "drizzle-orm"; import { db } from "@/db"; +import { getStagedDoc, isStagedSource } from "@/lib/staged-docs"; import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file"; import { @@ -223,8 +224,22 @@ export async function GET(req: Request) { // Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5) // N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์ + // N11: pdf_url เป็น sentinel staged: → bytes จาก RAM — หมดอายุ/ถูก evict = 410 gate + // ไทยเหมือน /api/note/pdf (ไม่ปล่อยเป็น 500 ดิบจาก readPublicPdf ที่หาไฟล์ไม่เจอ) let srcBytes: Buffer; - if (isRemoteSource(file.pdfUrl)) { + if (isStagedSource(file.pdfUrl)) { + const staged = getStagedDoc(file.pdfUrl); + if (staged === null) { + return new Response( + JSON.stringify({ error: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" }), + { + status: 410, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); + } + srcBytes = staged; + } else if (isRemoteSource(file.pdfUrl)) { try { srcBytes = await fetchSourceBytes(file.pdfUrl); } catch (err) { diff --git a/app/api/note/auth/session/route.ts b/app/api/note/auth/session/route.ts index 6e9a822..0309761 100644 --- a/app/api/note/auth/session/route.ts +++ b/app/api/note/auth/session/route.ts @@ -7,12 +7,17 @@ // · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง // · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ import { NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; +import { db } from "@/db"; +import { files } from "@/db/schema"; import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket"; import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token"; +import { ecourtExternalId } from "@/lib/ecourt-fid"; import { registerExternalFile } from "@/lib/external-import"; import { parseSessionExchangeBody } from "@/lib/note-auth"; import { NOTE_SESSION_TTL } from "@/lib/note-token"; +import { hasStagedDoc, isStagedSource } from "@/lib/staged-docs"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; @@ -81,6 +86,30 @@ export async function POST(request: Request): Promise { }); if (result.ok) fileId = result.fileId; else fileError = result.error; + } else { + // N11 (PLAN-STAGED.md D5): ลิงก์ fid-only (เอกสาร staging — เนื้อส่งมาทาง POST + // /api/note/files/stage แล้ว ลิงก์มีแค่ t=) — เช็คตั้งแต่ตอนแลก token เลย ไม่ปล่อย + // เข้า viewer ไปเจอ 410 เอง · ใช้ hasStagedDoc (ไม่ยืด TTL ไม่ถือ Buffer — การอ่าน + // จริงเกิดที่ viewer) · เอกสาร N8 ที่รู้จักแล้ว (pdf_url เป็น URL/path ปกติ) เข้าได้ + // ทางเดียวกัน · ผู้ใช้ได้ token ตามปกติทุกกรณี + try { + const [row] = await db + .select() + .from(files) + .where(eq(files.externalId, ecourtExternalId(identity.fid))) + .limit(1); + if (!row) { + fileError = "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"; + } else if (isStagedSource(row.pdfUrl)) { + if (hasStagedDoc(row.pdfUrl)) fileId = row.id; + else fileError = "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"; + } else { + fileId = row.id; + } + } catch (err) { + console.error("[sso] ค้นเอกสารของ fid ล้ม", err); + fileError = "ค้นเอกสารไม่สำเร็จ — ลองอีกครั้ง"; + } } const access_token = issueDocverToken( diff --git a/app/api/note/external/documents/route.ts b/app/api/note/external/documents/route.ts index 8a4faff..febfb49 100644 --- a/app/api/note/external/documents/route.ts +++ b/app/api/note/external/documents/route.ts @@ -2,6 +2,7 @@ import { and, asc, eq } from "drizzle-orm"; import { db } from "@/db"; import { files } from "@/db/schema"; +import { ecourtExternalId } from "@/lib/ecourt-fid"; import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth"; import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api"; import { requestOrigin } from "@/utils/request-origin"; @@ -47,17 +48,19 @@ export async function POST(req: Request) { if (query.mode === "file") { // ส่งทั้งคู่ = AND กรองทั้ง externalId และกลุ่มใน query เดียว เหลือเอกสารเดียว // (external_id unique — match ได้มากสุด 1 ฉบับ) ไม่ตรง = 404 โดยแยกสาเหตุ: - // ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ + // ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ · external_id เก็บ hash + // ของ fid (lib/ecourt-fid) — ผู้เรียกส่ง fid ดิบมา เรา hash ตอน query เหมือนเดิม + const externalId = ecourtExternalId(query.fileId); const [file] = await db .select() .from(files) .where( query.typeId ? and( - eq(files.externalId, `ecourt:${query.fileId}`), + eq(files.externalId, externalId), eq(files.typeId, query.typeId), ) - : eq(files.externalId, `ecourt:${query.fileId}`), + : eq(files.externalId, externalId), ) .limit(1); if (!file) { diff --git a/app/api/note/files/stage/route.ts b/app/api/note/files/stage/route.ts new file mode 100644 index 0000000..3b4c5d0 --- /dev/null +++ b/app/api/note/files/stage/route.ts @@ -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, +): 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 { + 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 | null = null; + let totalTimer: ReturnType | null = null; + // timer ทั้งสอง + abort ส่งสัญญาณ rejecting ผ่าน promise ที่ race กับ read() + let timedOutReject: ((e: StageReadError) => void) | null = null; + const timedOut = new Promise((_, reject) => { + timedOutReject = reject; + }); + let abortReject: ((e: StageReadError) => void) | null = null; + const aborted = new Promise((_, 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; + 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 { + 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); + } +} diff --git a/app/api/note/pdf/route.ts b/app/api/note/pdf/route.ts index ed7acef..45530a2 100644 --- a/app/api/note/pdf/route.ts +++ b/app/api/note/pdf/route.ts @@ -4,6 +4,7 @@ import { db } from "@/db"; import { files } from "@/db/schema"; import { fixPdfActualText } from "@/lib/pdf-actual-text"; import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file"; +import { getStagedDoc, isStagedSource } from "@/lib/staged-docs"; import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; import { fileKindFromUrl, imageContentTypeFromUrl } from "@/utils/file-kind"; @@ -26,9 +27,25 @@ export async function GET(req: Request) { if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 }); // Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5) - // N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์ + // N11: pdf_url เป็น sentinel staged: → bytes จาก RAM (ไม่มีไฟล์บนดิสก์/URL ให้ดึง) — + // หมดอายุ/ถูก evict/ext ไม่ตรง = 410 gate ให้ผู้ใช้กลับ e-Court กดลิงก์ใหม่ + // (annotation/bookmark ใน DB ยังอยู่ครบ) · Buffer ที่คืนเป็น reference ของ entry — + // ปลอดภัยต่อการอ่านพร้อมกันหลาย request เพราะ fixPdfActualText/pdf-lib สร้างชุด + // bytes ใหม่เอง ไม่ mutate ต้นทาง (ตรวจแล้ว lib/pdf-actual-text.ts) let srcBytes: Buffer; - if (isRemoteSource(file.pdfUrl)) { + if (isStagedSource(file.pdfUrl)) { + const staged = getStagedDoc(file.pdfUrl); + if (staged === null) { + return new Response( + JSON.stringify({ error: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" }), + { + status: 410, + headers: { "content-type": "application/json; charset=utf-8" }, + }, + ); + } + srcBytes = staged; + } else if (isRemoteSource(file.pdfUrl)) { try { srcBytes = await fetchSourceBytes(file.pdfUrl); } catch (err) { diff --git a/app/note/dev/page.tsx b/app/note/dev/page.tsx index e8dbcd9..1e034f4 100644 --- a/app/note/dev/page.tsx +++ b/app/note/dev/page.tsx @@ -4,6 +4,7 @@ import { notFound } from "next/navigation"; import AppNav from "@/components/AppNav"; import DevLoginLinkForm from "@/components/DevLoginLinkForm"; import MockEcourtForm from "@/components/MockEcourtForm"; +import MockStageForm from "@/components/MockStageForm"; import { devMintEnabled } from "@/lib/note-auth"; export const dynamic = "force-dynamic"; @@ -149,6 +150,26 @@ export default async function DevToolsPage({ + + {/* จำลอง e-Court แบบ base64 staging (N11 — คำขอผู้ใช้ 2026-11-04): ให้ dev + ฝั่ง e-Court กดทดสอบ contract ทั้ง 3 ขั้นในหน้าเดียว — stage → mint → + ลิงก์ fid-only · ต้องถือ DOCVER_API_KEY จริงจึงไม่ใช่ประตูหลัง */} +
+

+ จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64) +

+

+ สำหรับเอกสารที่ e-Court ไม่มี URL ให้ดึง — + เลือกไฟล์แล้วกดปุ่มเดียว ระบบจะทำตาม contract จริงทั้ง 3 ขั้น:{" "} + POST /api/note/files/stage (เนื้อไฟล์เป็น base64) →{" "} + POST /api/note/auth/token ด้วย fid เดิม → ลิงก์{" "} + /note?t=… (ไม่มี fileUrl) ให้กดเข้าอ่านได้เลย · ส่ง{" "} + fid เดิมซ้ำ = แทนที่เนื้อล่าสุด (annotation เดิมอยู่ครบ) +

+
+ +
+
{/* ── Flow ─────────────────────────────────────────────────────────── */} diff --git a/components/MockStageForm.tsx b/components/MockStageForm.tsx new file mode 100644 index 0000000..cbf8227 --- /dev/null +++ b/components/MockStageForm.tsx @@ -0,0 +1,227 @@ +"use client"; + +import { useState, type ChangeEvent, type FormEvent } from "react"; + +import { withBasePath } from "@/utils/base-path"; + +/** + * ฟอร์ม "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging" บนหน้า /note/dev (N11 — + * PLAN-STAGED.md · คำขอผู้ใช้ 2026-11-04): กดทดสอบ contract ทั้ง 3 ขั้นตามที่ + * backend e-Court ต้องทำจริงในหน้าเดียว — (1) POST /api/note/files/stage + * (base64 ของไฟล์ที่เลือก) → (2) POST /api/note/auth/token ด้วย fid เดิม → + * (3) ลิงก์ /note?t=… (fid-only ไม่มี fileUrl — D5) + * · ต้องใส่ DOCVER_API_KEY จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน MockEcourtForm) + * · หน้า /note/dev เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว + * · ไม่ใช่ dropzone ของแอป (การตัดสินใจข้อ 3 ของแผนยังอยู่ครบ — เครื่องมือ dev + * สำหรับผู้ถือ key เท่านั้น) + */ +export default function MockStageForm() { + const [apiKey, setApiKey] = useState(""); + const [fid, setFid] = useState("STAGE-1"); + const [userid, setUserid] = useState("oom"); + const [username, setUsername] = useState("สรวิชญ์"); + const [title, setTitle] = useState(""); + const [typeId, setTypeId] = useState(""); + const [file, setFile] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [result, setResult] = useState<{ fileId: string; link: string } | null>(null); + + const onFileChange = (e: ChangeEvent) => { + setFile(e.target.files?.[0] ?? null); + setResult(null); + }; + + const onSubmit = async (e: FormEvent) => { + e.preventDefault(); + setError(null); + setResult(null); + if (file === null) { + setError("เลือกไฟล์เอกสารก่อน (.pdf / .jpg / .png / .tif)"); + return; + } + if (file.size > 100 * 1024 * 1024) { + setError(`ไฟล์ ${file.name} ใหญ่เกิน 100MB (เนื้อไฟล์) — endpoint จะตอบ 413`); + return; + } + setBusy(true); + try { + // base64 มาตรฐานต่อเนื่อง — readAsDataURL ได้ base64 ต่อเนื่องพร้อม padding + // อยู่แล้ว (ตัด prefix data:…;base64,) และไฟล์ใหญ่ก็ไม่ชน call stack แบบ btoa + const b64 = await readFileAsBase64(file); + + // (1) stage เนื้อไฟล์ — ขั้นที่ e-Court ต้องทำก่อนแสดงปุ่มเปิดเอกสาร + const stageRes = await fetch(withBasePath("/api/note/files/stage"), { + method: "POST", + headers: { "x-api-key": apiKey, "content-type": "application/json" }, + body: JSON.stringify({ + fid: fid.trim(), + data: b64, + ...(title.trim() !== "" ? { title: title.trim() } : {}), + ...(typeId.trim() !== "" ? { typeId: typeId.trim() } : {}), + }), + }); + const stageData = (await stageRes.json().catch(() => null)) as + | { fileId?: string; error?: string } + | null; + if (!stageRes.ok || stageData?.fileId === undefined) { + throw new Error( + `stage ไม่สำเร็จ (HTTP ${stageRes.status}) — ${stageData?.error ?? "ไม่ทราบสาเหตุ"}`, + ); + } + + // (2) mint login token ด้วย fid **เดิม** — dedupe key ecourt: ผูกกัน + const tokenRes = await fetch(withBasePath("/api/note/auth/token"), { + method: "POST", + headers: { "x-api-key": apiKey, "content-type": "application/json" }, + body: JSON.stringify({ + userid: userid.trim(), + username: username.trim(), + fid: fid.trim(), + ttl_seconds: 600, + }), + }); + const tokenData = (await tokenRes.json().catch(() => null)) as + | { access_token?: string; error?: string } + | null; + if (!tokenRes.ok || tokenData?.access_token === undefined) { + throw new Error( + `stage สำเร็จ แต่ขอ token ไม่สำเร็จ (HTTP ${tokenRes.status}) — ${tokenData?.error ?? "ไม่ทราบสาเหตุ"}`, + ); + } + + // (3) ลิงก์ fid-only (ไม่มี fileUrl — เอกสาร staging) · withBasePath("") = + // /docedit/note (D11 — ห้ามส่ง "/note" เข้า withBasePath) + setResult({ + fileId: stageData.fileId, + link: `${withBasePath("")}?t=${encodeURIComponent(tokenData.access_token)}`, + }); + } catch (err) { + setError(err instanceof Error ? err.message : "ทดสอบ staging ไม่สำเร็จ"); + } finally { + setBusy(false); + } + }; + + const inputCls = + "genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint"; + const labelCls = "text-xs font-medium text-ink-soft"; + + return ( +
+
+ + setApiKey(e.target.value)} + className={inputCls} + autoComplete="off" + required + /> +
+
+ + setFid(e.target.value)} + className={inputCls} + required + /> +
+
+ + +
+
+ + setUserid(e.target.value)} className={inputCls} required /> +
+
+ + setUsername(e.target.value)} className={inputCls} required /> +
+
+ + setTitle(e.target.value)} className={inputCls} placeholder={file?.name ?? "เอกสารจาก e-Court"} /> +
+
+ + setTypeId(e.target.value)} className={inputCls} /> +
+ +
+ + {file !== null && ( + + {file.name} · {(file.size / 1024 / 1024).toFixed(2)} MB (base64 ~ + {((file.size * 1.34) / 1024 / 1024).toFixed(0)} MB) + + )} +
+ + {error && ( +

+ {error} +

+ )} + + {result && ( +
+

+ stage สำเร็จ — fileId {result.fileId}{" "} + (ลิงก์อายุ 10 นาที · เอกสารอยู่ใน RAM ตาม TTL หลังแลก session) +

+
+ + เปิดลิงก์ + + {result.link.slice(0, 80)}… +
+
+ )} +
+ ); +} + +/** base64 ต่อเนื่องของไฟล์ทั้งก้อน */ +function readFileAsBase64(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => { + const result = String(reader.result ?? ""); + const comma = result.indexOf(","); + if (!result.startsWith("data:") || comma < 0) { + reject(new Error("อ่านไฟล์ไม่สำเร็จ")); + return; + } + resolve(result.slice(comma + 1)); + }; + reader.onerror = () => reject(new Error("อ่านไฟล์ไม่สำเร็จ")); + reader.readAsDataURL(file); + }); +} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 1ae6830..d053210 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -156,6 +156,22 @@ import { type Tool, } from "./toolbar-tools"; +/** N11: error จาก /api/note/pdf ของเอกสาร staging (410 พ้นช่วงจัดเตรียม) — โชว์ข้อความ + * gate ไทยจาก route ตรง ๆ แทนข้อความทั่วไป (เอกสาร staging ไม่มีไฟล์ดิบให้ fallback) */ +class StagedGateError extends Error {} + +/** อ่านข้อความ error (JSON {"error"}) จาก response ของ route — ไม่ใช่ JSON = ข้อความ + * ทั่วไปพร้อมรหัสสถานะ */ +async function stagedGateMessage(res: Response): Promise { + try { + const body = (await res.json()) as { error?: unknown }; + if (typeof body.error === "string" && body.error !== "") return body.error; + } catch { + // ไม่ใช่ JSON — ใช้ข้อความทั่วไป + } + return `โหลดเอกสารไม่สำเร็จ (HTTP ${res.status})`; +} + /** tool ที่ลากคลุมข้อความใน textLayer ได้ (ตัวที่ textLayer ยังรับ pointer อยู่) — * ทั้งหมดซ่อน native selection แล้ววาดของเราเองแทน: highlight = preview มาร์กสด, * select/comment/textbox/stamp = selection สีฟ้าสะอาด (กรองแนวเส้นทางลาก) */ @@ -1741,24 +1757,43 @@ export default function PDFViewer({ // URL จริงที่ browser ใช้โหลดไฟล์ (fetch/img.src ตรง — ต้องเติม basePath เอง) // N8: รูปรีโมต (ลิงก์ e-Court) โหลดผ่าน /api/note/pdf ของเราแทนทางตรง — กัน CORS // ของปลายทาง + ผ่าน allowlist/จำกัดขนาดเดียวกับ PDF (route คืน bytes รูป + content-type) - const fileUrl = isHttpUrl(file.pdfUrl) - ? withBasePath(`/api/note/pdf?fileId=${file.id}`) - : withBasePath(file.pdfUrl); + // N11: รูป staging (sentinel staged:) ก็โหลดผ่าน route เดียวกัน — sentinel เป็น + // URI ที่ไม่มี static path ให้โหลดตรง (bytes อยู่ใน RAM ของ server) + const fileUrl = + isHttpUrl(file.pdfUrl) || file.pdfUrl.startsWith("staged:") + ? withBasePath(`/api/note/pdf?fileId=${file.id}`) + : withBasePath(file.pdfUrl); (async () => { let pages: { src: string | null; width: number; height: number }[]; if (imageFormatFromUrl(file.pdfUrl) === "tiff") { // N3: TIFF หลายเฟรม = หลายหน้า — decode ครั้งเดียวต่อการเปิดไฟล์ // (utif2 dynamic import ภายใน decodeTiffPages) แล้วเก็บ blob URL ไว้ใช้ต่อ - const buffer = await (await fetch(fileUrl)).arrayBuffer(); + // N11: staged หมดอายุ = 410 gate จาก route — โชว์ข้อความนั้นแทนทิ้งให้ decode พัง + const res = await fetch(fileUrl); + if (!res.ok) throw new StagedGateError(await stagedGateMessage(res)); + const buffer = await res.arrayBuffer(); pages = await decodeTiffPages(buffer, IMAGE_PAGE_WIDTH); for (const p of pages) if (p.src) objectUrls.push(p.src); } else { + // N11: รูปที่มาทาง route (รูปรีโมต N8 / staged N11) = fetch เองก่อน จะได้จับ + // 410 gate ตอน staging หมดอายุเป็นข้อความไทยจาก server (decode จาก URL ตรง + // ได้แค่ error กลาง ๆ ที่ชี้ทางแก้ผิด) — ได้ bytes แล้วผลิต object URL เก็บไว้ + // ใช้ต่อ (fetch ครั้งเดียวต่อการเปิดไฟล์: virtualization remount หน้าไม่ + // ยิง route ซ้ำ ไม่ touch TTL รอบใหม่) · รูป static ในเครื่องใช้ path ตรง + // ตามเดิม (มี browser cache ของ static อยู่แล้ว) + let imgSrc = fileUrl; + if (isHttpUrl(file.pdfUrl) || file.pdfUrl.startsWith("staged:")) { + const res = await fetch(fileUrl); + if (!res.ok) throw new StagedGateError(await stagedGateMessage(res)); + imgSrc = URL.createObjectURL(await res.blob()); + objectUrls.push(imgSrc); + } const img = new Image(); - img.src = fileUrl; + img.src = imgSrc; await img.decode(); const factor = IMAGE_PAGE_WIDTH / img.naturalWidth; pages = [ - { src: fileUrl, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor }, + { src: imgSrc, width: IMAGE_PAGE_WIDTH, height: img.naturalHeight * factor }, ]; } if (cancelled) { @@ -1771,7 +1806,12 @@ export default function PDFViewer({ setPageSizes(pages.map((p) => ({ width: p.width, height: p.height }))); })().catch((err) => { console.error("โหลดรูปไม่สำเร็จ", err); - if (!cancelled) setLoadError("โหลดรูปไม่สำเร็จ — ไฟล์อาจเสียหรือรูปแบบไม่รองรับ"); + if (!cancelled) + setLoadError( + err instanceof StagedGateError + ? err.message + : "โหลดรูปไม่สำเร็จ — ไฟล์อาจเสียหรือรูปแบบไม่รองรับ", + ); objectUrls.forEach((u) => URL.revokeObjectURL(u)); }); return () => { @@ -1786,13 +1826,20 @@ export default function PDFViewer({ pdfjs.GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs"); // โหลดผ่าน /api/note/pdf ที่ซ่อม ToUnicode จาก ActualText ของ PDF ไทยที่ cmap เพี้ยน // (ข้อความถูกทั้ง select/copy/ค้นหา) — endpoint fail ก็ถอยไปไฟล์ดิบตามเดิม + // (เอกสาร staging N11 ไม่มีไฟล์ดิบให้ถอย — 410 gate จาก route แสดงตรง ๆ) let data: ArrayBuffer; - try { + if (file.pdfUrl.startsWith("staged:")) { const res = await fetch(withBasePath(`/api/note/pdf?fileId=${file.id}`)); - if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.ok) throw new StagedGateError(await stagedGateMessage(res)); data = await res.arrayBuffer(); - } catch { - data = await (await fetch(withBasePath(file.pdfUrl))).arrayBuffer(); + } else { + try { + const res = await fetch(withBasePath(`/api/note/pdf?fileId=${file.id}`)); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + data = await res.arrayBuffer(); + } catch { + data = await (await fetch(withBasePath(file.pdfUrl))).arrayBuffer(); + } } const doc = await pdfjs.getDocument({ data }).promise; loadedDoc = doc; @@ -1816,7 +1863,12 @@ export default function PDFViewer({ setPageSizes(sizes); })().catch((err) => { console.error("โหลดเอกสารไม่สำเร็จ", err); - if (!cancelled) setLoadError("โหลดเอกสารไม่สำเร็จ — ไฟล์อาจเสียหรือไม่ใช่ PDF ที่รองรับ"); + if (!cancelled) + setLoadError( + err instanceof StagedGateError + ? err.message + : "โหลดเอกสารไม่สำเร็จ — ไฟล์อาจเสียหรือไม่ใช่ PDF ที่รองรับ", + ); }); return () => { diff --git a/docs/PLAN-API.md b/docs/PLAN-API.md index 0e53fa9..88e1922 100644 --- a/docs/PLAN-API.md +++ b/docs/PLAN-API.md @@ -13,7 +13,7 @@ ## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-22 · ปรับรอบสอง 2026-09-24 · รอบสาม 2026-09-25 · รอบสี่ 2026-09-25 · รอบห้า 2026-09-25 · รอบหก 2026-09-25 · รอบเจ็ด 2026-09-25 · รอบแปด 2026-09-25 · รอบเก้า 2026-09-25) 1. **API Key เดียวใน env** `EXTERNAL_API_KEY` — ผู้เรียกคือ backend e-Court ตัวเดียว เปลี่ยน key = แก้ env แล้ว redeploy (ไม่ทำตาราง key ใน DB / UI จัดการ key) -2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:"`) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา +2. **ระบุเอกสารด้วย fileId ของ e-Court** (ค้น `external_id = "ecourt:" + hash ของ fileId` — lib/ecourt-fid 2026-11-04: เก็บ HMAC-SHA256 แทน fid ดิบ) — ผู้เรียกรู้จัก id ของตัวเอง ไม่ต้องรู้ UUID ของเรา และยังส่ง fid ดิบมาเหมือนเดิม (เรา hash ตอน query) 3. **POST เส้นเดียว + JSON body** `POST /api/external/documents` — รอบแรกเป็น 2 endpoints แบบ GET query-param (ตาม pattern ของบ้าน), **รอบสอง 2026-09-24 ตามคำขอผู้ใช้รวมเป็นเส้นเดียวถามด้วย body** `{fileId | typeId, filter}` — GET ที่แนบ body ไม่ standard จึงใช้ POST (ยัง read-only เหมือนเดิม) · response รูปเดียวเสมอ (`{documents: [...]}`) ผู้เรียกจัดการ contract เดียว · **รอบสาม 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกันได้ = รวมผล OR** (เดิม 400 กำกวม) และ**ยุบชั้น `document` ใน response** ให้ field เอกสารอยู่ระดับเดียวกับ `annotations`/`bookmarks` (ดูง่ายขึ้น) · **รอบสี่ 2026-09-25 ตามคำขอผู้ใช้: เปลี่ยนชื่อ endpoint เป็น `documents`** (เดิม `query` — ตั้งชื่อตาม resource ที่คืน ตรงกับ response `{documents}`) · **รอบห้า 2026-09-25 ตามคำขอผู้ใช้: ส่ง `fileId`+`typeId` พร้อมกัน = AND** เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (แทน OR รวมผลของรอบสาม — ทดสอบจริงแล้ว OR คืนทั้งกลุ่ม ไม่ใช่สิ่งที่ผู้เรียกต้องการ) · ยังไม่มีผู้เรียกจริงจึงแทนที่ขาด (ถอด GET เดิมทั้งสองออก) 4. **`filter.type` เลือกส่วนที่เอา**: `"bookmarks"` | `"annotations"` — ไม่ส่ง `filter` หรือ `type` เป็นค่าว่าง = ทั้งสอง ("เอาทั้งหมด" — กฎเดียวกับ fileId ว่าง = ไม่ได้ส่ง, คำขอผู้ใช้ 2026-09-24) — ส่วนที่ไม่เลือกคืนเป็น array ว่าง (contract คงที่ ง่ายต่อผู้บริโภค) · **รอบหก 2026-09-25 เพิ่มมิติตัวกรอง**: `filter.userId` (กรองตามเจ้าของรายการ) + `filter.annotationTypes` (ชนิดของ annotation — เดิมชื่อ `types` เปลี่ยนรอบแปด) — ดู D2 @@ -51,7 +51,7 @@ Response 200 เป็นรูปเดียวเสมอ (bundle ยุบ "documents": [ // ถามด้วย fileId = ยาว 1 ชิ้น (ไม่เคยเปิดในระบบ = 404) · ถามด้วย typeId = ทุกไฟล์ในกลุ่มเรียง files.createdAt (กลุ่มว่าง = []) · ส่งทั้งคู่ = เฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข (ยาว 1 ชิ้น เช่นเดียวกับถาม fileId) { "id": "", - "externalFileId": "", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null) + "externalFileId": "", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null) "title": "…", "typeId": "…", // null ได้ (ไม่มีกลุ่ม) "createdAt": "", @@ -75,7 +75,7 @@ Response 200 เป็นรูปเดียวเสมอ (bundle ยุบ - read-only ทั้งหมด: **ไม่เขียน DB ไม่ publish SSE ไม่ `revalidatePath`** — snapshot ณ ตอนเรียก (คนละ context กับ Server Action ใน invariant #4) - ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียก; ไม่เปิด `Access-Control-Allow-Origin`) -- ค้น `fileId`: `where eq(files.externalId, "ecourt:" + fileId)` — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ) +- ค้น `fileId`: `where eq(files.externalId, ecourtExternalId(fileId))` (hash — lib/ecourt-fid 2026-11-04) — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ) - **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น - annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO) - route เรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB diff --git a/docs/PLAN-ECOURT.md b/docs/PLAN-ECOURT.md index 58db09a..d651a38 100644 --- a/docs/PLAN-ECOURT.md +++ b/docs/PLAN-ECOURT.md @@ -16,7 +16,7 @@ https://ecourt.chamomind.com/docedit/docedit/note?fileUrl="` · เพิ่ม 2026-09-22: **`typeId` แนะนำ** (ผู้ใช้เลือกชื่อนี้แทน caseId) — รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน (เช่น คดีเดียวกัน) เอกสารที่ e-Court ส่ง `typeId` เดียวกัน = กลุ่มเดียวกัน (แสดงผลใน N9) +1. **สัญญาลิงก์ = `fileUrl` จำเป็น + `fileId` แนะนำ** — มี fileId ใช้เป็น dedupe key (`external_id = "ecourt:" + hash ของ fileId` — 2026-11-04 เก็บ HMAC-SHA256 ผ่าน lib/ecourt-fid.ts แทน fid ดิบ), ไม่มี fallback เป็น `"url:"` · เพิ่ม 2026-09-22: **`typeId` แนะนำ** (ผู้ใช้เลือกชื่อนี้แทน caseId) — รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน (เช่น คดีเดียวกัน) เอกสารที่ e-Court ส่ง `typeId` เดียวกัน = กลุ่มเดียวกัน (แสดงผลใน N9) 2. **ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ** — ลง DB แค่ metadata (title จาก basename ของ URL + external_id + ตัว URL เก็บใน `pdf_url`) **ไม่ดาวน์โหลดเขียนลง `public/uploads/`** ตอนเปิดดู/export ให้ server ดึงจาก e-Court สด ๆ ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว) 3. **หลักจัดการเวอร์ชัน: id เปลี่ยน = ไฟล์ใหม่** @@ -44,7 +44,7 @@ https://ecourt.chamomind.com/docedit/docedit/note?fileUrl= แผนงานฉบับเต็ม: `docs/PLAN-STAGED.md` — ทางเข้าเอกสารที่ e-Court **ไม่มี URL ให้ดึง** (เอกสารภายใน/สร้างสด) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ผ่าน `POST /api/note/files/stage` (server-to-server, auth `x-api-key` = env `DOCVER_API_KEY`) — **ห้ามเก็บไฟล์ถาวรทุกรูปแบบ**: bytes อยู่ใน RAM ของ process (`lib/staged-docs.ts` — Map บน globalThis + TTL sliding 8 ชม. + LRU + total cap) · ไม่มี schema migration: `pdf_url` เก็บ sentinel `staged:.` (ext จาก magic bytes — `fileKindFromUrl` ทำงานทุกจุดโดยไม่แก้) · ทางอ่าน: `/api/note/pdf` + export + viewer โหลดผ่าน route (410 gate ไทยเมื่อพ้นช่วงจัดเตรียม) · ลิงก์ e-Court เป็น fid-only (`t=` เดี่ยว) — session route เช็ค staging ตั้งแต่แลก token (D5) · single-process เหมือน SSE (invariant #11) diff --git a/docs/PLAN-SSO.md b/docs/PLAN-SSO.md index 28b9731..f084e76 100644 --- a/docs/PLAN-SSO.md +++ b/docs/PLAN-SSO.md @@ -162,6 +162,7 @@ body: `{t} | {ticket_data, HmacSha256}` + optional `{fileUrl, typeId}` (allowlis | เคส | พฤติกรรม | |---|---| | `t` ถูกต้อง (signature + exp + purpose=login) | 200 `{access_token, token_type:"Bearer", expires_in: 28800, userid, username, …}` — token คือ login-token ที่ `issueDocverToken` ออกใหม่อายุ `NOTE_SESSION_TTL` (8 ชม.) · มี `fileUrl` = registerExternalFile **trusted** (fileId จาก fid ที่เซ็น) แล้วเพิ่ม `fileId` ใน response (ล้ม = ยัง 200 พร้อม `fileError` ให้หน้าแรกแสดง — session ได้ก่อนเหมือนสมัย cookie) | +| **ไม่มี `fileUrl` (ลิงก์ fid-only — N11 เอกสาร staging 2026-11-04, `docs/PLAN-STAGED.md` D5)** | เช็คตั้งแต่แลก token: select row `ecourt:` → เจอ + `pdf_url` เป็น sentinel staged + **`hasStagedDoc` = true** → คืน `fileId` (ใช้ `hasStagedDoc` ไม่ใช่ `getStagedDoc` — ไม่ยืด TTL/ถือ Buffer, การอ่านจริงเกิดที่ viewer) · เจอ + staged + ไม่มี bytes (restart/TTL/evict) → `fileError: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"` ตั้งแต่ต้น (ไม่ปล่อยเข้า viewer ไปเจอ 410 เอง) · เจอ + `pdf_url` เป็น URL/path ปกติ → คืน `fileId` (เอกสาร N8 ที่รู้จักแล้ว) · ไม่เจอ → `fileError: "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"` · ผู้ใช้ได้ token ตามปกติทุกกรณี · ทางเข้า history/bookmark (ไม่ผ่าน exchange) ยังเจอ 410 gate ที่ viewer | | `ticket_data`+`HmacSha256` ครบ (ไม่มี `t`) และ `verifyAddinTicket` ผ่าน | เหมือนแถวบน — uid/uname/fid จาก ticket (`tid` ไม่ใช้) | | credential ผิด/หมดอายุ | 401 `{error, reason: expired\|invalid}` — client เปลี่ยนเป็นแบนเนอร์ตามเหตุ | | ทาง ticket แต่ `addinTicketSecret()` resolve ได้ default `"super_secret"` **(เฉพาะ production — dev ยอมรับ default ให้ทดสอบได้)** | log ฝั่ง server + 500 — เส้นนี้ mint session ทั้งแอป ค่า default รู้กันทั้ง repo ใครก็ปลอม ticket ได้ (กลุ่ม docver/add-in ยังใช้ default ตาม requirement — คนละประตู, review 2026-10-30 รอบสอง) | @@ -211,6 +212,10 @@ readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, กดลิงก์ใหม่ (ไม่มี renew — เว็บไม่มี ticket ฝังไฟล์แบบ add-in) - ไม่มี credential ใน URL → `/note` = landing public (แบนเนอร์ `?sso=` ของลิงก์เก่ายังรองรับ) - samples หน้า `/note/dev` → `GET /api/note/files/open?fileUrl=/samples/…` (ลิงก์แบบ /tmpl, trusted:false — แถว `url:…` เท่านั้น) +- **ลิงก์ fid-only ของเอกสาร staging (N11 2026-11-04):** e-Court POST เนื้อ base64 ที่ + `/api/note/files/stage` ก่อน → mint login token ด้วย `fid` เดิม → ลิงก์มีแค่ `t=` + (ไม่มี `fileUrl`) → bootstrap แลก token พร้อมเช็ค staging ตามตาราง §3.2 → เข้า + viewer ตามปกติ (bytes ไหลทาง `/api/note/pdf` — 410 gate เมื่อพ้นช่วงจัดเตรียม) ## 6. ไฟล์ที่สร้าง/แต้/ถอด diff --git a/docs/PLAN-STAGED.md b/docs/PLAN-STAGED.md new file mode 100644 index 0000000..dc2478c --- /dev/null +++ b/docs/PLAN-STAGED.md @@ -0,0 +1,165 @@ +# แผน N11 — เอกสาร base64 จาก e-Court ผ่าน RAM staging (ไม่เก็บไฟล์) + +> สร้าง 2026-11-04 ตามข้อตกลงกับผู้ใช้ · แก้รอบ 2 2026-11-04 ตาม code review (กัน race insert, body แบบ stream + concurrency, ผู้อ่าน `pdf_url` ครบชุด, hasStaged ตั้งแต่ bootstrap, deploy single-process) · แก้รอบ 3 2026-11-04 (slot timeout/คืน slot ใน finally, คู่มือ retry 429 ให้ e-Court, test sentinel×file-kind, proxy timeout, `hasStagedDoc` ไม่ touch) · แก้รอบ 4 2026-11-04 (test 408 ด้วย fake timers + 413 ไฟล์เดี่ยวเกิน total cap, เลข RAM D7 ตรงกับ D1, เคลียร์ idle+total timer ใน finally) · **implement แล้ว 2026-11-04** (ข้อเพิ่มระหว่าง implement: cap ต่อไฟล์อ่านจาก env `STAGED_MAX_FILE_BYTES` default 100MB — ให้ test ปรับได้โดยไม่ต้องอัด 140MB จริง) · **แก้รอบ 5 2026-11-04 หลังทดสอบจริง: regex `/^[A-Za-z0-9+/]+={0,2}$/` ใน `decodeBase64Strict` ล้น stack บน dev server (RangeError ที่ base64 ~140MB — quantifier ย้อนกลับลึก V8 บางสถานะ fallback interpreter) → เปลี่ยนเป็น negated-class scan `/[^A-Za-z0-9+/=]/` + ตรวจ `=` ท้ายชุดด้วย indexOf — เดินเชิงเส้นไม่มี backtracking semantics เดิมทุกประการ + regression test payload ใหญ่** · **แก้รอบ 6 2026-11-04 ตามคำขอผู้ใช้: external_id เก็บ hash ของ fid แทน fid ดิบ — `lib/ecourt-fid.ts` (HMAC-SHA256 + env `FID_HASH_PEPPER` fail-closed) เป็นจุดเดียวของการสร้าง/เทียบทุกทาง (stage/N8/session D5/N10) + default title เลิกฝัง fid (`ECOURT_DEFAULT_TITLE`) + row เก่าแปลงด้วย `npm run migrate:ecourt-fid` (หยุดแอปก่อนรัน — ดูแผน has-fid-swift-widget) · contract ฝั่ง e-Court ไม่เปลี่ยน (ยังส่ง fid ดิบมา) · externalFileId ของ N10 กลายเป็นค่า hash one-way** · **แก้รอบ 7 2026-11-05 ตาม code review (⚠️ 1 + 💡 5): รูป staged jpg/png ทาง history ตอน staging หมดอายุได้ gate message ไทยแล้ว** (branch non-TIFF ของ PDFViewer เปลี่ยนเป็น fetch เอง → เช็ค `!res.ok` → `StagedGateError` เหมือน PDF/TIFF branch + ผลิต object URL — แก้พร้อมข้อ re-fetch ทุก virtualization remount เพราะ blob ใน memory ไม่ยิง route ซ้ำ) · **cleanup candidateId ใน 23505 path สม่ำเสมอ** (try/finally ครอบ branch — select/put รอบสองโยนก็คืน RAM ผู้แพ้) · หมายเหตุ Swagger: typeId ของ staging รับทาง stage route ทางเดียว (ที่แนบกับ session ลิงก์ fid-only ไม่ถูกใช้) + แก้ค่า default title ใน spec ให้ตรงค่าจริง "เอกสารจาก e-Court" · ลบ dead comment "GET has-check" ของ stageJson · ยอมรับตามแผน: entry กำพร้าจาก DB ล้มหลัง insert 23505 ตอนนี้ถูก finally ครอบแล้ว เหลือเคส route 500 กลางคันที่ TTL กินเอง (ต่ำมาก) — **verify รอบ 7 ผ่าน: `tsc` สะอาด + vitest 59/59 + E2E รูป staged (stage PNG → viewer render ผ่าน objectURL ไม่ยิง route ซ้ำ → restart → ทาง history แสดง "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" ไม่ใช่ generic error)** · verify ข้อ 8–11 ค้างทดสอบด้วยมือ — ดูหัวข้อ Verify · เกี่ยวข้อง: N8 (`docs/PLAN-ECOURT.md`), N3 (ไฟล์รูปภาพ), PLAN-SSO.md (ลิงก์ e-Court ฉบับ token) + +## Context + +ทางเข้าเอกสารปัจจุบัน (N8) ผูกกับ `?fileUrl=` — e-Court ต้องมี URL สาธารณะที่ server เราดึงได้ (allowlist `admincourt.go.th`) กรณีที่ e-Court มีเอกสาร**ที่ไม่มี URL ให้ดึง** (เอกสารภายใน/สร้างสด) ต้องส่งเนื้อไฟล์มาเป็น **base64 ผ่าน server-to-server POST** แทน — โจทย์กำหนดว่า **ห้ามเก็บไฟล์ถาวรทุกรูปแบบ** (ไม่เขียน disk/DB/MinIO แม้แต่ไบต์เดียว) แต่ **annotation/bookmark เก็บถาวรปกติ** ตามระบบเดิม + +## การตัดสินใจ (ยืนยันกับผู้ใช้ 2026-11-04) + +1. **annotation เก็บถาวร** → เอกสาร staging ต้องมี `files` row จริง (FK ของ annotations/bookmarks) — staging เป็นแค่ "แหล่ง bytes ชั่วคราว" ทดแทน `pdf_url` ที่ fetch ได้ +2. **base64 มาทาง e-Court เท่านั้น (POST body)** — ฝังใน URL ลิงก์ไม่ได้ (PDF 1MB = base64 ~1.4MB โดน header limit ทันที) +3. **ไม่มี dropzone/ลากไฟล์บนแอป** — ผู้พิพากษาเปิดเอกสารผ่าน e-Court ทางเดียว (ตัดทางเลือก `local:` ทั้งหมดที่เคยพิจารณา) +4. **แอปมีหน้าที่แค่ "รองรับได้"** — เปิด endpoint + contract ให้ e-Court เรียก ไม่รับผิดชอบ flow ฝั่ง e-Court (เรียกตอนไหน/กี่ครั้ง) · contract ต้องเข้า Swagger UI (`lib/openapi.ts`) เหมือนกลุ่มอื่น +5. **เก็บ bytes ใน RAM ของ process เท่านั้น** (globalThis แบบ `lib/annotate-events.ts`) — sliding TTL + LRU + cap · **ห้าม**ใช้ MinIO/disk แม้แต่แบบมี TTL (ความหมาย "ไม่เก็บไฟล์" เคร่งครัด) · **รันได้เฉพาะ single process — ดู D7 deploy** +6. **auth ใช้ `x-api-key` เทียบ env `DOCVER_API_KEY`** ผ่าน `requireDocverApiKey` (`lib/docver-token.ts` — timing-safe แล้ว: SHA-256 ทั้งสองฝั่ง + `timingSafeEqual`, fail-closed + ข้อความ 401 เดียวกันทั้งไม่ใส่/ผิด) — กลุ่มเดียวกับทางเข้า mint ของ e-Court (`POST /api/note/auth/token`) · (ทางเลือก `EXTERNAL_API_KEY` ของ N10 เป็นฝั่งอ่าน ไม่ใช้กับฝั่งเขียน) +7. **ไม่มี schema migration** — ไม่เพิ่ม column: ใช้ sentinel ใน `pdf_url` คือ **`staged:.`** (มี extension ติดไปเสมอ) เพื่อให้ `fileKindFromUrl`/`imageFormatFromUrl`/`imageContentTypeFromUrl` ทำงานทุกจุดโดยไม่แก้ (extension คือ single source of truth ของชนิดไฟล์ตาม N3) + +## Design + +### D1 — Endpoint: `POST /api/note/files/stage` + +ผู้เรียก: backend e-Court (server-to-server, ไม่มี CORS, ไม่มี session ของแอป) + +- **Auth ก่อนแตะอะไรทั้งสิ้น:** `requireDocverApiKey(req)` — env ไม่ตั้ง/ว่าง = 401 fail closed +- **Body (JSON, allowlist):** + +| field | จำเป็น | เกณฑ์ | +|---|---|---| +| `fid` | ✅ | รหัสเอกสาร e-Court — เกณฑ์เดียวกับ `parseLoginTokenBody` (cap 200, ห้ามอักขระ `& = + %`) — เป็น dedupe key `ecourt:` ผ่าน `lib/ecourt-fid.ts` (2026-11-04 รอบหลัง: external_id เก็บ HMAC-SHA256(pepper, fid) ไม่มี fid ดิบใน DB — env `FID_HASH_PEPPER` ค่าคงที่ตลอดชีพ) | +| `data` | ✅ | **base64 มาตรฐานต่อเนื่อง** — ห้ามมี whitespace/line break, **ไม่รับ base64url** (`-`/`_`) → validate ด้วย scan บน string ต้นทาง (regex ครั้งเดียว ไม่สร้างสำเนาตัด whitespace) + `len % 4 === 0` + คำนวณความยาวที่ควรได้จากสูตร `len/4×3 − padding` แล้วเทียบกับ `Buffer.length` หลัง decode — Node `Buffer.from(b64)` ยืดหยุ่นเกิน (ได้ garbage เงียบ ๆ) การเทียบความยาวเป็นตัวตัดสิน | +| `typeId` | — | กลุ่มเอกสาร N9 — normalize ผ่าน `normDocumentGroup` เหมือน N8 (ส่งมา = refresh, ไม่ส่ง = คงเดิม) | +| `title` | — | ชื่อแสดง — trim/cap 200 · insert ครั้งแรกไม่ส่ง = `ECOURT_DEFAULT_TITLE` ("เอกสารจาก e-Court" — ไม่ฝัง fid, 2026-11-04) · **re-POST อัปเดต title ได้เฉพาะเมื่อ title ปัจจุบันยังเป็นค่า default** (ครั้งแรกที่ส่ง title จริงชนะ แล้วล็อก — กันค้างค่า default ตลอดชีพเมื่อครั้งแรกลืมส่ง) | + +- **อ่าน body แบบ stream + นับไบต์ (Content-Length เพียงพอไม่ได้ — chunked transfer ไม่มี header):** ตรวจ `Content-Length` เป็น fast-path (เกิน → 413 ทันที) แล้วอ่าน `req.body` ด้วย `ReadableStream` reader สะสมนับไบต์ ตัดที่ **base64 payload ≤ ~140MB** (100MB เนื้อไฟล์ — ตัวเลขจาก env `STAGED_MAX_FILE_BYTES` default 100MB, base64 cap = `4×ceil(n/3)+8KB` envelope headroom — ปรับ env ได้เพื่อ test/ops) → เกิน = **413** ก่อนแลกทั้งก้อน +- **จำกัด concurrent request ของ endpoint นี้:** counter บน globalThis (ครอบตั้งแต่ก่อนอ่าน body จนจบ decode) — env `STAGED_MAX_CONCURRENT` **default 1** → เกิน = **429** `Retry-After: 5` — กัน peak RAM หลายสำเนาบวมเกิน cap ตอนยิงพร้อมกัน · **คืน slot ใน `finally` ทุกทางออก** (สำเร็จ/error/client ตัดการเชื่อมต่อ — ผูก `req.signal` abort) — ลืมจุดนี้ = ไฟล์ 100MB บนเน็ตช้ากิน slot นานเป็นนาที จน request ถัดไป 429 ตลอด +- **Read timeout ของ body (กัน slot ค้างแบบที่สอง):** idle **60 วิ** (ไม่มี chunk มาเลย — เน็ตช้าแต่ยังไหลไม่ตัด) และ total **10 นาที** ต่อการอ่านทั้งก้อน (100MB ใน 10 นาที ≈ 170KB/s — ช้ากว่านี้ตัดให้ retry) → เกินอย่างใด = **408** `{"error":"อัปโหลดช้าเกินกำหนดหรือการเชื่อมต่อขาด — ลองส่งใหม่"}` · ตอน implement **เคลียร์ทั้ง idle และ total timer ใน `finally` เสมอ** — ลืมแล้ว timer ค้างหลัง request จบ (โดยเฉพาะ Next dev hot-reload ที่สะสม) +- **Peak RAM ต่อ request (เอกสาร 100MB เต็ม):** chunk สะสม ~140MB + text ต่อจาก decode ~140MB + string `data` หลัง `JSON.parse` ~134MB + `Buffer` 100MB — ชั่วคราว **≈ 420–520MB** (ปลดอ้างอิงทิ้งทีละชั้นหลังใช้เสร็จ = ปลายต่ำ ~420MB, GC ไม่ทันปล่อย = ~520MB; concurrency 1) — **container ที่รัน standalone ควรมี memory ≥ 1.5–2GB** · **วัดจริงแล้ว 2026-11-05** (dev server `next dev` บน Windows, ยิง POST 100MB เต็ม + วัด WorkingSet/PrivateMemory ของ process ทุก 150ms ระหว่าง request — ไม่ใช่ `process.memoryUsage()` ใน route เพราะไม่อยากแต้โค้ด production): **สำเร็จ 200 ใน 4.5 วิ, peak WorkingSet +580MB จาก baseline** (826→1406MB; สูงกว่าประมาณการเล็กน้อยเพราะ WS นับ heap เดิมที่ถูก touch ซ้ำด้วย) ขณะที่ **PrivateMemory (commit จริง) เพิ่มแค่ +92MB** — แปลว่า memory ใหม่ที่ commit จริงต่อ request ≈ staging resident 100MB เท่านั้น ส่วนใหญ่เป็นการ reuse heap ที่ V8 commit ค้างไว้ + GC คืนแบบ lazy (หลังจบ +5s WS ยังค้าง +438MB เหนือ baseline) · **สรุปสำหรับ D7: headroom ≥1.5–2GB ยังเพียงพอ** (ตัวเลขนี้คือ `next dev` ซึ่ง baseline สูง ~826MB อยู่แล้ว — production standalone baseline ต่ำกว่ามาก ให้วัดซ้ำตอน deploy ถ้าต้องการตัวเลข production จริง) +- **ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ:** PDF (`isPdfBytes` ของ `lib/document-versioning.ts`) / jpeg `FF D8 FF` / png `89 50 4E 47` / tiff `II*`·`MM\0` → ไม่รู้จัก = **400** "ไฟล์ไม่ใช่ PDF หรือรูปภาพที่รองรับ" → ext ของ sentinel มาจากผลตรวจนี้ (`pdf`/`jpg`/`png`/`tif`) · ไฟล์เดี่ยวใหญ่กว่า `STAGED_MAX_TOTAL_BYTES` = **413** ที่ตัว stage เลย (ไม่ไล่ evict เพื่อนแล้วก็ยังไม่พอ) +- **ลำดับงาน (กัน race insert — ดู D3):** resolve id ก่อน → **stage ใต้ id นั้น** → เขียน DB → โดน `23505` → re-stage ใต้ id ผู้ชนะ + ลบของผู้แพ้ · ผลบวกของลำดับนี้: **การตรวจ cap เกิดก่อนแตะ DB** (ไม่สร้าง row ค้างไม่มี bytes) และ DB พังหลัง stage = entry กำพร้าใน RAM ที่ TTL กินเอง ไม่มีอะไรต้องเก็บกวาด (ตรงข้าม N4 ที่ไฟล์เขียนแล้วต้องลบ) +- **ตอบ:** `{fileId}` (uuid ของ row) · e-Court เอา fid เดิมไป mint login token ตามเส้นเดิม → ลิงก์เข้าแอปเหมือนวันนี้ +- **ข้อผิดพลาด:** JSON `{"error": <ไทย>}` ทั้งหก 401/400/408/413/429/500 (pattern `docverError`) · **ห้าม log bytes/key ดิบ** — log ได้เฉพาะ fid + ขนาด +- **ไม่ publish SSE / ไม่ `revalidatePath`** — รายการไฟล์ไม่ sync ผ่าน SSE (เหมือน N8/N4) + เป็น route handler ไม่ใช่ Server Action (carve-out invariant #4 — ต้องสลักใน CLAUDE.md ตอน implement) + +### D2 — RAM staging: `lib/staged-docs.ts` (pure — unit test ได้ไม่ต้อง DB) + +- `Map` บน **`globalThis`** (pattern เดียวกับ `annotate-events.ts`/`db/index.ts` — dev hot-reload จะไม่ duplicate) key = fileId (uuid) ค่า = `{ bytes: Buffer, ext, createdAt, lastAccessAt }` +- **Sentinel URL:** `stagedSourceUrl(fileId, ext)` → `staged:.` · `parseStagedSource(url)` → `{ fileId, ext } | null` (validate uuid + ext ∈ whitelist — ทั้งคู่ server generate เอง การ validate ซ้ำเป็น defense) · `isStagedSource(url)` +- **TTL แบบ sliding จาก last access:** expire เมื่อ `now − lastAccessAt > TTL` — ทุก `get` ที่สำเร็จ = touch (ระหว่าง annotate ไม่มีการอ่าน bytes — ตั้ง default **`STAGED_DOC_TTL_SECONDS = 28800` (8 ชม. เท่า `NOTE_SESSION_TTL`)** ให้ครอบ session ทั้งก้อน, env ปรับได้) +- **Cap + LRU:** ต่อไฟล์ 100MB (ที่ D1 ตัดไว้แล้ว) · **รวมทุกไฟล์ default 256MB** (`STAGED_MAX_TOTAL_BYTES`) · แทรกแล้วเกิน cap = ไล่ evict รายการที่ `lastAccessAt` เก่าสุด (LRU) จนคืนใต้ cap — re-POST fid เดิม = **replace ช่องเดิม** (ไม่งอกใหม่ต่อครั้ง) · **config guard:** module init ถ้า total cap < 100MB = `console.warn` หนึ่งครั้ง (ไฟล์เดี่ยวใหญ่สุดจะโดน 413 ตลอด — ปรับ env หรือตั้งใจใช้กับไฟล์เล็กก็ได้) +- **`getStagedDoc(url)` เทียบ ext ของ sentinel กับ ext ของ entry ด้วย** — ไม่ตรง (row เขียนค้างระหว่างอัปเดตไม่สำเร็จ) = miss เหมือนหมดอายุ → ทางอ่านได้ 410 gate เสมอ ไม่มี sentinel โกหก +- **`hasStagedDoc(url)` แยกจาก `getStagedDoc`** — เช็ค "มีอยู่ไหม" (expire + ext เทียบชุดเดียวกัน) โดย **ไม่ copy ไม่ touch TTL** — สำหรับผู้ที่แค่ตัดสินใจไม่ได้อ่าน bytes จริง อย่าง D5 ตอนแลก token (ไม่ยืดอายุ/ถือ Buffer เปล่า) · `getStagedDoc` = ผู้อ่านจริง (touch + คืน Buffer) — semantics สองตัวแยกกันชัดเจน +- **Lazy sweep ไม่มี timer:** ตรวจ expire ตอน get + เก็บกวาด entry หมดอายุตอน put (กัน `setInterval` ค้างใน dev — บริบทเดียวกับ rule ของ `annotate-events`) +- **single-process** — ข้อจำกัดเดียวกับ SSE pub/sub (invariant #11) — ขยายที่ D7 + +### D3 — ลงทะเบียน row: `registerStagedFile` ใน `lib/external-import.ts` (กัน race 23505) + +สัญญา `registerStagedFile(rawFid, bytes, ext, title?, typeId?)` — **trusted โดย construction** (ผู้เรียกผ่าน `requireDocverApiKey` ที่ route แล้ว) — bytes/ext คือของที่ผ่าน magic-byte check แล้ว: + +1. `externalId = ecourtExternalId(fid)` (= `"ecourt:" + HMAC-SHA256(pepper, fid)` — 2026-11-04 รอบหลัง: ไม่มี fid ดิบใน DB) → select ด้วย `external_id` +2. **เจอ** → `winnerId = row.id` → `putStagedDoc(winnerId, bytes, ext)` (replace — last-write-wins ตาม semantics "re-POST = เนื้อล่าสุด") +3. **ไม่เจอ** → `candidateId = randomUUID()` → `putStagedDoc(candidateId, …)` → insert row ด้วย `candidateId` + - insert โดน **`23505`** (คู่ขนาน fid เดียวกัน) → select ใหม่ได้ `winnerId` ของฝ่ายชนะ → **`putStagedDoc(winnerId, bytes, ext)` ทับ** (bytes ของเราแทนของฝ่ายชนะ — last-write-wins เหมือนข้อ 2) → **`deleteStagedDoc(candidateId)`** คืน RAM → `winnerId` ใช้ต่อ + - insert ผ่าน → `winnerId = candidateId` +4. update row เมื่อจำเป็น (รวมกันเป็น patch เดียว): `pdfUrl` sentinel เปลี่ยน (ext ต่าง) / `typeId` เมื่อส่งมา / `title` เมื่อส่งมา**และ**ค่าปัจจุบันยังเป็นค่า default `ECOURT_DEFAULT_TITLE` +5. คืน `{ fileId: winnerId }` — **ไม่มีทางเกิด "row ชี้ sentinel ที่ไม่มี bytes"**: ทุก path จบด้วย staging ใต้ id เดียวกับ row สุดท้ายที่คืน + +- **ผสาน flow กับ N8 แบบเดิม:** fid เดิมเปลี่ยนวิธีส่ง (URL ↔ base64) = `pdf_url` เปลี่ยนตามรอบล่าสุด สองทางอยู่ร่วมกันได้ · หลัก "id เปลี่ยน = ไฟล์ใหม่" ใช้เหมือนเดิมทุกประการ + +### D4 — ทางอ่าน bytes: inventory ผู้อ่าน `pdf_url` ทั้ง repo (grep 2026-11-04) + แก้ 4 จุด + +**ผู้อ่านทั้งหมดใน repo:** + +| จุด | ประเภท | อะไรทำ | +|---|---|---| +| `app/api/note/pdf/route.ts:31` | **อ่าน bytes** | แก้ — เติม branch staging ก่อน `isRemoteSource` | +| `app/api/note/annotations/export/route.ts:227` | **อ่าน bytes** | แก้ — จุดเดียวกัน | +| `components/pdf/PDFViewer.tsx:1744,1795` | **อ่าน bytes (ฝั่ง client)** | แก้ — branch รูป + ตัด fallback | +| `lib/server-pdf.ts` (`readPublicPdf`) | อ่าน path | แก้ — guard ปฏิเสธ `staged:` ชัด ๆ | +| `app/api/note/files/[id]/bootstrap/route.ts:73` | ส่งต่อให้ client | ไม่แตะ — viewer ใช้แค่ derive ชนิด/เลือกทางโหลด (แก้แล้วใน PDFViewer) | +| `components/pdf/PDFViewer.tsx:3220`, `app/note/groups/[typeId]/page.tsx:98`, `utils/file-kind.ts` | derive-only | ไม่แตะ — ext ใน sentinel ทำให้ทำงานเอง (ตรวจ `extOf()` แล้ว: ตัด `?#` → segment ท้าย → `lastIndexOf(".")` — sentinel เข้ากันดี แต่**ล็อกด้วย test ตาม Verify ข้อ 2** กัน implementation เปลี่ยนวิธี parse ภายหลัง) | +| `scripts/seed-samples.mjs` | query | ไม่แตะ — จับเฉพาะ `pdf_url like '/samples/%'` | +| `app/api/note/files/open/route.ts` | ทางเข้า | ไม่แตะ — รับ path ในเครื่อง `/…` อย่างเดียว sentinel เข้าไม่ได้ | +| N10 `lib/external-api.ts` | query | ไม่แตะ — ไม่ select ฟิลด์ `pdf_url` | +| `app/note/uploads/[name]/route.ts` | เสิร์ฟไฟล์อัปโหลดเดิม | ไม่แตะ — อ่านชื่อจาก path param ไม่ใช่ `pdf_url` | + +**การแก้ 4 จุด:** + +1. **`app/api/note/pdf/route.ts`** — ก่อน `isRemoteSource`: `isStagedSource(file.pdfUrl)` → `getStagedDoc(file.pdfUrl)` → miss/หมดอายุ/ext ไม่ตรง = **410** `{"error":"เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"}` · ได้ bytes แล้วไหลเข้า branch เดิมทั้งหมด (ext ใน sentinel ทำให้ `fileKindFromUrl` แยกรูป/PDF + `imageContentTypeFromUrl` ถูก) → **การซ่อม ToUnicode ไทยทำงานกับ staging อัตโนมัติ** · `fixPdfActualText` **ไม่ mutate input** (pdf-lib `load`→`save` สร้างชุด bytes ใหม่ — ตรวจแล้ว `lib/pdf-actual-text.ts:136-198`) ดังนั้น Buffer ใน Map ปลอดภัยแม้ถูกอ่านพร้อมกันหลาย request +2. **`app/api/note/annotations/export/route.ts`** (F8) — จุดเดียวกัน (`isRemoteSource ? … : readPublicPdf` → เติม staged ก่อนหน้า) → export ฝัง annotation ใช้ได้เลย · TIFF/EXIF ผ่าน `imageDocFromBytes` ตาม N3 +3. **`PDFViewer.tsx`** — (ก) branch รูปภาพ `:1744`: `isHttpUrl(pdfUrl)` → เพิ่ม `|| pdfUrl.startsWith("staged:")` ให้โหลดผ่าน `/api/note/pdf?fileId=` (PDF branch ไม่ต้องแตะ — fetch ผ่าน route นี้อยู่แล้ว) · (ข) **ตัด fallback** `fetch(withBasePath(file.pdfUrl))` เมื่อ `staged:` (โหลดจาก sentinel ตรง ๆ ไม่มีความหมาย) — ให้ error จาก route (410) ลอยขึ้นเป็นข้อความ gate ไทยแทน garbage fetch +4. **`lib/server-pdf.ts` — `resolvePublicPdfPath` เพิ่ม guard ต้นฟังก์ชัน:** `pdfUrl.startsWith("staged:")` → `throw new PdfPathError("เอกสารนี้ไม่มีไฟล์บนดิสก์ (staging พ้นช่วงจัดเตรียม)")` — **ผลตรวจโค้ดจริง: วันนี้ `"staged:x.pdf"` ผ่าน guard** (`path.resolve(publicDir, …)` ตกใน publicDir) **แล้วตายด้วย ENOENT เป็น 500 ดิบ** (Windows อาจ EINVAL เพราะ colon ในชื่อ) — เป็น defense in depth ให้ทุก caller ในอนาคตที่ลืมแยกทาง + +### D5 — Session route รับลิงก์ fid-only + เช็ค staging ตั้งแต่ต้น (`app/api/note/auth/session/route.ts`) + +ตอนนี้ `parsed.fileUrl !== undefined` เท่านั้นที่ลงทะเบียน — เอกสาร staging ลิงก์มีแค่ `t=` (fid อยู่ใน credential): เติม branch **`fileUrl` ไม่มี + มี `fid`** → select row `ecourt:` (lib/ecourt-fid): + +- เจอ + `pdfUrl` เป็น staged + **`hasStagedDoc` = true** → คืน `fileId` (ไม่ refresh อะไร — ใช้ `hasStagedDoc` ไม่ใช่ `getStagedDoc` เพราะตอนแลก token แค่ตัดสินใจ ไม่ต้องถือ Buffer/ยืด TTL — การอ่านจริงเกิดที่ viewer อีกที) +- เจอ + เป็น staged + **`hasStagedDoc` = false** (restart/TTL หมด/โดน evict แล้ว) → `fileError: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"` **ตั้งแต่ตอนแลก token** (ไม่ปล่อยเข้า viewer ไปเจอ 410 เอง) +- เจอ + `pdfUrl` เป็น URL/fileUrl ปกติ → คืน `fileId` ตามเดิม (เอกสาร N8 แบบเดิม) +- ไม่เจอ → `fileError: "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"` (คู่ขนานข้อความ N8) +- ผู้ใช้ได้ token ตามปกติทุกกรณี · ทางเข้าจาก history/bookmark (ไม่ผ่าน session exchange) ยังเจอ 410 gate ที่ viewer — ยอมรับ + +### D6 — เอกสาร contract + +`lib/openapi.ts` เพิ่ม `POST /api/note/files/stage` (path เต็ม `/api/…`, servers เติมแค่ basePath ตาม invariant #12) — ระบุใน description: auth `x-api-key`, base64 มาตรฐานต่อเนื่อง, cap 100MB เนื้อไฟล์ (base64 ~134MB), error 401/400/408/413/429 + **คู่มือ retry ให้ e-Court ชัด ๆ: 408/429/5xx = ยิงซ้ำ (429 ตาม `Retry-After`, ที่เหลือ backoff) และ stage ยิงซ้ำได้ปลอดภัย** (fid เดิม = replace ไม่เกิดซ้ำซ้อน ไม่เสียอะไร) — ไม่เขียนไว้ = e-Court เจอ 429 แล้วเลิกเงียบ ๆ = เอกสารตกหล่น → ปรากฏใน Swagger UI `/api/swagger` พร้อมกลุ่ม template/N10/docver + +### D8 — เครื่องมือทดสอบบน `/note/dev` (เพิ่มหลัง implement ตามคำขอผู้ใช้ 2026-11-04) + +การ์ด "จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)" (`components/MockStageForm.tsx`) ให้ dev ของ e-Court กดทดสอบ contract จริงทั้ง 3 ขั้นในหน้าเดียว: เลือกไฟล์ (FileReader→base64 ต่อเนื่อง, ตัด >100MB ก่อนยิง) → `POST /api/note/files/stage` → `POST /api/note/auth/token` ด้วย fid เดิม → ได้ลิงก์ fid-only + ปุ่มเปิด · **ไม่ใช่ dropzone ของแอป** (การตัดสินใจข้อ 3 ยังครบ): อยู่ใต้หน้า `/note/dev` ที่เปิดด้วย `NOTE_SSO_DEV_MINT=1` อย่างเดียว + ต้องใส่ `DOCVER_API_KEY` จริงทุกครั้งจึงไม่เป็นประตูหลัง (เหมือน `MockEcourtForm`) + +### D7 — Deploy (ข้อจำกัดที่ต้องรู้ก่อนขึ้น production) + +- **Single process เท่านั้น:** staging กับ SSE pub/sub อยู่บน RAM ของ process เดียว — `next start` / standalone `node server.js` **หนึ่ง process** · **ห้าม** PM2 cluster mode / หลาย replica / serverless (Vercel) — พัง**เงียบ**: request ตก instance คนละตัวได้ 410 สุ่ม ๆ / SSE event หายบางตัว ไม่มี error ชัดเจนให้เห็น +- **IIS เป็น proxy หน้าแอป** (deploy ปัจจุบัน): default `maxAllowedContentLength` = **30MB** — ตั้งเป็น ≥ 140MB ใน web.config ของ IIS ไม่งั้น **413 มาจาก IIS เป็นอังกฤษ** ก่อน request ถึงแอป (เกิดเฉพาะเอกสารใหญ่ — ทดสอบตอน deploy ด้วยไฟล์จริง) · **ข้อสองของขั้นตอน deploy:** ถ้ามี **ARR/reverse proxy ซ้อนหน้า IIS อีกชั้น ต้องขยับ request timeout ของ proxy ด้วย** — upload 100MB บนเน็ตช้ากินเวลาเป็นนาที (ARR default 120 วิ ตัดกลางทางได้) ตั้งให้ ≥ total read timeout ของแอป (D1 = 10 นาที) · Next.js route handler เองไม่มี body limit (เฉพาะ Server Actions ที่มี default 1MB — endpoint นี้เป็น route handler ไม่เกี่ยว) +- **Memory:** กราฟ peak ที่ D1 (**≈ 420–520MB ชั่วคราว** + staging resident ≤256MB) — เครื่อง/คอนเทนเนอร์ควรมี headroom ≥ 1.5–2GB + +## Edge cases + +- **เปิดลิงก์ก่อน e-Court POST ไฟล์** (และ row ไม่เคยมี) → D5 ให้ fileError · token ออกตามปกติ +- **TTL หมด/evict โดน LRU/process restart กลางวัน** → ทางเข้าลิงก์: D5 ตีตั้งแต่แลก token · ทางเข้า history: 410 gate ไทยที่ viewer/export · **annotation/bookmark/หน้าสรุปอยู่ครบ** (ผูก fileId ใน DB ไม่เกี่ยวกับ staging) · แก้ด้วยการเข้าใหม่จาก e-Court (= POST สดทุกครั้งตาม flow ปกติ) +- **POST fid เดียวกันสองครั้งพร้อมกันครั้งแรก** — D3 ลำดับ stage→insert→(23505)→re-stage ใต้ id ผู้ชนะ+ลบของผู้แพ้ → ไม่มี row ชี้ sentinel โดนใส่ · bytes สุดท้าย = ของ request ที่เสร็จทีหลัง (last-write-wins ตาม semantics "re-POST = เนื้อล่าสุด") +- **e-Court re-POST fid เดิมด้วยเนื้อใหม่** → staging replace + row เดิม → annotation เดิม (อาจคลาดตำแหน่งถ้าเลย์เอาต์ขยับ — known risk เดียวกับ N8 ข้อ "e-Court แก้เนื้อไฟล์") +- **fid สลับ base64 → URL (ลิงก์ N8 รอบหลัง)** → `pdf_url` เปลี่ยนเป็น URL แต่ entry เก่ายังค้างใน RAM จน TTL กินเอง — ไม่มีใครอ่าน (row ไม่ชี้แล้ว) ไม่เสียหาย แค่กิน quota ชั่วคราว +- **base64 เสีย (มี whitespace/base64url)/ไม่ใช่ PDF·รูป** → 400 ก่อนแตะ staging · **เกิน 100MB เนื้อ/140MB base64** → 413 (Content-Length ถ้ามี, นับ stream ถ้า chunked) · **ยิงพร้อมกันเกิน `STAGED_MAX_CONCURRENT`** → 429 + `Retry-After` · **client ตัดการเชื่อมต่อ/อ่าน body ช้าเกิน timeout** → abort + **คืน slot ทันทีใน `finally`** → 408 — staging ยังไม่ถูกสร้างเพราะยังไม่ถึงขั้น stage +- **หลายเอกสารจนเกิน total cap** → LRU evict รายการเก่าสุดเงียบ ๆ (ผู้ใช้เจอ gate เมื่อกลับไปเปิด — ทางกลับคือเปิดจาก e-Court ใหม่) · ไฟล์เดี่ยว > total cap → 413 ที่ stage +- **ผู้ถือ `DOCVER_API_KEY` ยิงอัด RAM ไล่ evict เอกสารคนอื่น (LRU) เป็น DoS** — **ความเสี่ยงที่รับไว้**: ผู้ถือ key = backend e-Court ซึ่งเชื่อถือได้ระดับเดียวกับผู้ถืออำนาจ mint login token อยู่แล้ว (ทำอันตรายได้มากกว่านี้โดยไม่ต้องพึ่ง staging) + concurrency 1 ถ่วงอัตรายิง · auth เอง timing-safe แล้ว (`lib/docver-token.ts` — SHA-256 + `timingSafeEqual`) +- **`/api/note/pdf` ไม่มี auth (status quo)** → staged bytes อ่านได้ด้วย fileId (uuid ที่ยากจะเดา) — ความเสี่ยงที่รับไว้เหมือน known risk ข้อ 3 ของ N8 ไม่เพิ่มหรือลด +- **รูป TIFF หลายเฟรม** → ผ่าน pdf route → `utif2` decode ฝั่ง client ตาม N3 เดิม + +## Verify (เกณฑ์ตอน implement) + +> สถานะหลัง implement 2026-11-04: ข้อ 1–7 **ผ่าน** (`npx tsc --noEmit` สะอาด + vitest 102 เคสของชุด N11/SSO/token ผ่านครบ, `npm test` ทั้ง repo 349/350 — ตัวที่ไม่ผ่านเป็น worker-startup timeout ของ `tests/wordaddin/metadataService.test.ts` ซึ่งผ่านตอนรันเดี่ยว = flake ของ environment ไม่เกี่ยวกับงานนี้) · **E2E บน dev server จริง (แยก port 3001) ผ่านหลัก ๆ ของข้อ 8:** stage ไฟล์ 100MB (base64 139.8M ตัวอักษร) → 200 ใน ~3.4 วินาที + audit log ครบ · ไฟล์เกิน cap 58 ไบต์ → **413** ตาม spec · mint + session fid-only (D5) → fileId ไม่มี fileError · `/api/note/pdf?fileId=` คืน bytes ครบ 100MB (หัว `%PDF-1.7`, ความยาวตรงต้นฉบับ — server ตอบใน ~4 วินาที) · **restart server → session ได้ `fileError` "พ้นช่วงจัดเตรียม" + token ยังออก + ทาง history ได้ 410 JSON ไทย** +> +> **สถานะวันที่ 2026-11-05: ครบทุกข้อ (1–11) ผ่าน** — ส่วนที่ค้างเมื่อวานทดสอบด้วย Playwright (browser จริง) บน dev server port 3000: +> - **ข้อ 8 เต็ม:** stage `thai_letterspacing.pdf` (fid `e2e-staged-thai-01`) → mint → เปิดลิงก์ fid-only `/note?t=…` ผ่าน Playwright → bootstrap แลก token + redirect เข้า viewer · textLayer ไทย 73 spans **เรียงถูกทั้งหมด — "ฉะนั้น" ติดคำเดียว ไม่มี fake space กลางคำ** (ตัวอย่างบั๊กของ patch-pdf-worker) = ToUnicode fix + patch fake-space ทำงานกับ staged bytes ผ่าน `/api/note/pdf` · มาร์ก highlight คลุมข้อความจริง (keyboard H + drag บน textLayer) + วาดเส้น (keyboard D) + bookmark (keyboard B) — **N10 ยืนยันเก็บถาวรครบ** (annotations=2, bookmarks=1) · **export PDF มี annotation ฝังจริง** (pdf-lib อ่านกลับ: rect มาร์ก 200×20pt + เส้น stroke แดง 2.38w ตำแหน่งตรงที่ลาก + outline "คั่นหน้าทดสอบ staging" อ่านไทยครบ) · **restart dev server → ลิงก์ใหม่ (mint ใหม่) ได้แบนเนอร์ "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" ตั้งแต่ bootstrap ไม่เข้า viewer + token ยังออก (note-auth มี)** · ทาง history `/api/note/pdf` = 410 JSON ไทย · หน้าสรุปยังครบ Annotation 2 + Bookmark 1 (ชื่อ default ของ highlight = ข้อความที่คลุม — ข้อความไทยจาก staged bytes ถูกต้องอีกชั้น) · **re-POST fid เดิมหลัง restart ได้ fileId เดิม** (`4c618a71-…`) = replace ช่องเดียวตาม D3 +> - **ข้อ 9:** `node scripts/make-login-link.mjs --fid e2e-staged-thai-01` (ไม่มี fileUrl, หลัง re-stage) → เปิดลิงก์ → เข้า viewer ได้ + session เก็บใน localStorage + textLayer 73 spans ครบ +> - **ข้อ 10:** `GET /api/swagger` มี `/api/note/files/stage` ใน paths พร้อมคู่มือ retry ("คู่มือ retry"/`Retry-After` ปรากฏในหน้า) +> - **ข้อ 11:** วัดแบบภายนอก (WorkingSet64/PrivateMemorySize64 ของ process ทุก 150ms ระหว่าง POST — ไม่แต้โค้ด production): ไฟล์ 100MB เต็ม → 200 ใน 4.5 วิ, **peak WS +580MB / Private commit +92MB** — บันทึกวิเคราะห์แล้วที่ D1 (headroom ≥1.5–2GB ยังเพียงพอ) +> +> หมายเหตุ environment ระหว่างทดสอบ: dev server สองตัวรันคู่กันใน repo เดียว (ตัวค้างจากก่อนหน้า + ตัวใหม่) ทำให้ `.next` ถูกเขียนทับกันเองจน route manifest เพี้ยน (POST บาง route ตอบ 404 ชั่วขณะแล้วหายเอง) — แก้ด้วยการฆ่าทั้งคู่ + ลบ `.next` + รันตัวเดียว แล้วอาการหาย ไม่ใช่ bug ของโค้ด + +1. `tests/lib/staged-docs.test.ts` (pure): put/get กลับตัว · TTL sliding (touch แล้วไม่หมด/ปล่อยแล้วหมด) · **`hasStagedDoc` ไม่ touch (`lastAccessAt` ไม่ขยับ) ไม่ copy** · LRU evict ตาม total cap · replace ช่องเดียวกันต่อ fileId · get miss เมื่อ ext ไม่ตรง · ไฟล์เดี่ยว > total cap → put ปฏิเสธ · `parseStagedSource` ปฏิเสธ uuid/ext เพี้ยน · magic bytes แยก pdf/jpg/png/tif ถูก +2. `tests/utils/file-kind.test.ts` — **sentinel ครบ 4 ext เข้า helper ทั้ง 3 ตัว**: `staged:.{pdf,jpg,png,tif}` → `fileKindFromUrl` = "pdf"/"image", `imageFormatFromUrl` = null/jpeg/png/tiff, `imageContentTypeFromUrl` = image/jpeg, image/png, image/tiff (แก้ระหว่าง implement: sentinel **pdf** ไม่ผ่าน `imageContentTypeFromUrl` ในทางปกติ — route ตอบ `application/pdf` เองตอนเป็น PDF, helper คืน `application/octet-stream` สำหรับ non-image ตาม status quo ซึ่งไม่แตะตาม D4) — ล็อกสมมติฐานของ D4 กัน helper เปลี่ยนวิธี parse ภายหลัง +3. `tests/lib/server-pdf.test.ts` — `resolvePublicPdfPath("staged:.pdf")` โยน **`PdfPathError`** (ไม่ใช่ ENOENT ทะลุเป็น 500) +4. `tests/lib/external-import-staged.test.ts` — `vi.mock("@/db")`: เจอ row → replace staging + patch ตามเงื่อนไข (ext ต่าง/typeId ส่งมา/title ยังเป็น default) · ไม่เจอ → insert ด้วย candidateId · **insert โดน 23505 → re-stage ใต้ winnerId + ลบ candidateId + คืน winnerId** (assert ลำดับการเรียก) +5. Route tests (`POST` handler เรียกตรง + mock db/staged): **401** ไม่มี key/env ว่าง · **400** base64 มี whitespace/เป็น base64url/ความยาวไม่ลงตัว/magic ไม่รู้จัก/fid มี `&` · **413** body เกิน cap (ทั้งมีและไม่มี Content-Length) · **413 ไฟล์เดี่ยวใหญ่กว่า `STAGED_MAX_TOTAL_BYTES`** (mock store ที่ตั้ง cap ต่ำกว่าขนาดไฟล์) · **429** concurrent · **408 ด้วย fake timers: stream หยุดนิ่งครบ idle 60 วิ และ total ครบ 10 นาที → 408 + คืน slot** · **mock stream โยน/`req.signal` abort กลางทาง → ได้ error และ counter คืนใน `finally` (request ถัดไปไม่โดน 429)** · happy path ได้ `{fileId}` + staging มี bytes ใต้ id นั้น +6. **D5 tests ครบ 4 branch** (session route + mock db/staged): เจอ + `hasStagedDoc` true → ได้ `fileId` · เจอ + false → `fileError` "พ้นช่วงจัดเตรียม" · เจอ + `pdfUrl` เป็น URL ปกติ (เอกสาร N8) → ได้ `fileId` · ไม่เจอ → `fileError` "ยังไม่ถูกส่งเข้าระบบ" +7. `tsc` ผ่าน + vitest ชุดเดิมไม่แตก +8. curl/PowerShell: POST stage ด้วย sample ไทย (`thai_letterspacing.pdf` fixture) → เปิด viewer → textLayer ไทยเรียงถูก (พิสูจน์ว่า ToUnicode fix ทำงานกับ staged bytes) → มาร์ก/วาด + bookmark → export ได้ไฟล์มี annotation → **restart dev server** → เปิดใหม่: ทางลิงก์ได้ fileError ตั้งแต่ bootstrap (D5) · annotation ยังอยู่ครบบนหน้าสรุป +9. ลิงก์จริงเชื่อมกัน: `make-login-link.mjs --fid <เดิม>` (ไม่มี fileUrl) → `/note` bootstrap → เข้า viewer ได้ (D5) +10. Swagger UI `/api/swagger` เห็น endpoint ใหม่ + try it out ได้ (รวมคำอธิบาย retry) +11. วัด peak RAM จริง (`process.memoryUsage()`) กับไฟล์ใกล้ 100MB แล้วบันทึกตัวเลขกลับ D1 + +## Sync เอกสาร (ตอน implement) + +- **CLAUDE.md:** invariant #4 carve-out เพิ่ม `POST /api/note/files/stage` (route handler เขียน `files` ผ่าน `registerStagedFile` — ไม่ publish SSE/`revalidatePath` เหตุผลเดียวกับ N8) · โครงสร้างไฟล์เพิ่ม `lib/staged-docs.ts` + route + อ้างอิงแผนนี้ · ทางเข้าเอกสารใน SSO section กล่าวถึง base64 channel · (พบระหว่างตรวจ: CLAUDE.md บอก `requireDocverApiKey` อยู่ `lib/addin-ticket.ts` แต่จริงอยู่ `lib/docver-token.ts:172` — แก้ drift นี้ด้วย) +- **PLAN-FEAT.md §7:** เพิ่มบรรทัด N11 +- **PLAN-SSO.md:** flow ลิงก์ fid-only + fileError ของ staging (D5) + +## Out of scope + +- dropzone/ลากไฟล์บนแอป + key `local:` (ตัดตามการตัดสินใจข้อ 3) +- cache bytes ฝั่ง client (IndexedDB) และการ re-stage อัตโนมัติ — ทางกลับคือเปิดใหม่จาก e-Court +- staging ข้าม instance (Redis/LISTEN-NOTIFY) · รับ .docx หรือชนิดอื่นนอกเหนือ PDF/รูปของ N3 · auth ของ `/api/note/pdf` (known risk ตาม status quo) · หน้าจัดการ/ลบรายการ staging · ปรับ `maxAllowedContentLength` ของ IIS ให้เอง (บันทึกไว้ในขั้นตอน deploy ของผู้ดูแลเครื่อง) diff --git a/docs/PLAN.md b/docs/PLAN.md index 0c6e617..a7ad850 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -92,7 +92,7 @@ Percent (จาก DB) ──×size÷100──▶ Pixel (render ลง SVG/div | title | text NOT NULL | | | description | text | nullable | | pdf_url | text NOT NULL | | -| external_id | text | nullable UNIQUE — N8 dedupe key เอกสารจากลิงก์ e-Court (`"ecourt:"` / `"url:"`); NULL = ไฟล์ seed/อัปโหลด | +| external_id | text | nullable UNIQUE — N8 dedupe key เอกสารจากลิงก์ e-Court (`"ecourt:" + hash ของ fileId` ผ่าน lib/ecourt-fid.ts 2026-11-04 — HMAC-SHA256 แทน fid ดิบ / `"url:"`); NULL = ไฟล์ seed/อัปโหลด | | type_id | text | nullable — N9 รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกันจากลิงก์ e-Court `?typeId=` (เขียนที่ `registerExternalFile` ของ N8 เท่านั้น); NULL = ไม่มีกลุ่ม | | created_at | timestamptz `defaultNow()` | | diff --git a/lib/ecourt-fid.ts b/lib/ecourt-fid.ts new file mode 100644 index 0000000..8b2c502 --- /dev/null +++ b/lib/ecourt-fid.ts @@ -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); +} diff --git a/lib/external-api.ts b/lib/external-api.ts index 6ad1b12..9ab0eaa 100644 --- a/lib/external-api.ts +++ b/lib/external-api.ts @@ -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, diff --git a/lib/external-import.ts b/lib/external-import.ts index 1a16a44..8882270 100644 --- a/lib/external-import.ts +++ b/lib/external-import.ts @@ -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: (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO route - // (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId + // แถว ecourt: (เอกสารของ 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 }; +} diff --git a/lib/openapi.ts b/lib/openapi.ts index 50b8cfe..86de4a1 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -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=` (ไม่ต้องมี " + + "`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:. ใน 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 }, diff --git a/lib/server-pdf.ts b/lib/server-pdf.ts index aebb048..bdd131d 100644 --- a/lib/server-pdf.ts +++ b/lib/server-pdf.ts @@ -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"); diff --git a/lib/staged-docs.ts b/lib/staged-docs.ts new file mode 100644 index 0000000..2bc03a7 --- /dev/null +++ b/lib/staged-docs.ts @@ -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 }; +const g = globalThis as typeof globalThis & StagedGlobal; +const stagedDocs: Map = + 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:.` (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(); +} diff --git a/package.json b/package.json index 0775975..a013c1d 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "test": "vitest run", "start": "next start", "seed-samples": "node --env-file=.env scripts/seed-samples.mjs", + "migrate:ecourt-fid": "node --env-file=.env scripts/migrate-ecourt-fid-hash.mjs", "db:prepare": "docker compose up -d --wait && node --env-file=.env scripts/migrate.mjs && node --env-file=.env scripts/seed-samples.mjs", "db:reset": "docker compose down && docker volume rm judes-note-db-data judes-note-minio-data && npm run db:prepare", "addin:install": "node scripts/install-wordaddin.mjs", diff --git a/scripts/migrate-ecourt-fid-hash.mjs b/scripts/migrate-ecourt-fid-hash.mjs new file mode 100644 index 0000000..9cc445a --- /dev/null +++ b/scripts/migrate-ecourt-fid-hash.mjs @@ -0,0 +1,89 @@ +// แปลง files.external_id ของเอกสาร e-Court จาก fid ดิบ ("ecourt:") เป็น hash +// ("ecourt:" + HMAC-SHA256(pepper, fid)) — รันครั้งเดียวตอน roll การเปลี่ยนของ +// lib/ecourt-fid.ts (2026-11-04) + เก็บ title default ที่ฝัง fid ให้เป็นค่ากลางใหม่ +// +// ทำเป็น Node script ไม่ใช่ SQL ใน drizzle/ เพราะ pepper ต้องมาจาก env ของเครื่อง +// (ห้าม commit ค่าลับลง migration) · สูตร hash ต้องตรงกับ lib/ecourt-fid.ts เป๊ะ +// (HMAC-SHA256 key=pepper บน fid.trim() → hex — script .mjs import TS ไม่ได้จึงมี +// สำเนาสูตรตรงนี้ แก้ที่ใดต้องแก้อีกทางคู่กัน) +// +// idempotent — รันซ้ำได้: แถวที่ tail เป็น hex 64 แล้วถูกข้าม +// · idempotent ไม่รวม "ข้ามแถวชน" — ถ้า target hash ที่คำนวณไปชนกับแถวอื่น (มีการ +// roll ใหม่ค้างก่อน migration) จะพิมพ์ warning แล้วข้าม ห้าม merge/delete เอง +// +// รัน: npm run migrate:ecourt-fid (node --env-file=.env ตาม pattern ของ seed-samples) +// ⚠️ ระหว่างรันต้องหยุด process ของแอปก่อน — โค้ดเก่าที่ยังเขียน plaintext กลับมา +// หลัง migration จะ insert แถวคู่เงียบ ๆ (ดูลำดับ roll ในแผน) +import { createHmac } from "node:crypto"; +import postgres from "postgres"; + +const ECOURT_PREFIX = "ecourt:"; +const ECOURT_DEFAULT_TITLE = "เอกสารจาก e-Court"; +const HEX64 = /^[0-9a-f]{64}$/; + +const dbUrl = process.env.DATABASE_URL; +if (!dbUrl) throw new Error("ต้องตั้ง DATABASE_URL ก่อนรัน (node --env-file=.env)"); +const pepper = process.env.FID_HASH_PEPPER?.trim(); +if (!pepper) { + throw new Error( + "ต้องตั้ง FID_HASH_PEPPER ก่อน (ค่าคงที่ตลอดชีพ — ห้ามเปลี่ยนหลังมีข้อมูล, 32+ ตัวสุ่ม)", + ); +} + +const ecourtExternalId = (fid) => + ECOURT_PREFIX + createHmac("sha256", pepper).update(fid.trim()).digest("hex"); + +const sql = postgres(dbUrl, { prepare: false, max: 1 }); +try { + const rows = await sql`select id, title, external_id from files where external_id like ${ECOURT_PREFIX + "%"}`; + + // target ทุกแถวก่อนเพื่อตรวจชน (แถวที่ hash แล้ว target = ตัวมันเอง = ไม่ชนใคร) + const targets = new Map(); // id → target external_id + for (const row of rows) { + const fid = row.external_id.slice(ECOURT_PREFIX.length); + targets.set(row.id, HEX64.test(fid) ? row.external_id : ecourtExternalId(fid)); + } + const ownerByTarget = new Map(); + for (const [id, target] of targets) { + const prev = ownerByTarget.get(target); + if (prev !== undefined && prev !== id) { + console.warn(`[fid-hash] ตรวจพบแถวชน: ${prev} และ ${id} hash ไปที่ค่าเดียวกัน — ข้ามทั้งคู่ (ตรวจข้อมูลก่อนรันใหม่)`); + targets.delete(prev); + targets.delete(id); + } else { + ownerByTarget.set(target, id); + } + } + + let converted = 0; + let titlesFixed = 0; + let skipped = 0; + for (const row of rows) { + const target = targets.get(row.id); + if (target === undefined) { + skipped += 1; + continue; + } + if (target === row.external_id) { + skipped += 1; // hash แล้ว (idempotent) + continue; + } + const fid = row.external_id.slice(ECOURT_PREFIX.length); + // title default เก่าสองรูปแบบที่ฝัง fid ดิบ: "เอกสาร " (N11) และ fid เปล่า (N8 + // fallback ของ titleFromExternalUrl ยุคเก่า) — เก็บเป็นค่ากลางใหม่ + const isDefaultTitle = row.title === `เอกสาร ${fid}` || row.title === fid; + const newTitle = isDefaultTitle ? ECOURT_DEFAULT_TITLE : row.title; + await sql`update files set external_id = ${target}, title = ${newTitle} where id = ${row.id}`; + converted += 1; + if (isDefaultTitle) titlesFixed += 1; + } + + const leftovers = + await sql`select count(*)::int as n from files where external_id like ${ECOURT_PREFIX + "%"} and external_id !~ ${ECOURT_PREFIX + "[0-9a-f]{64}$"}`; + console.log( + `[fid-hash] เสร็จ: แปลง ${converted} แถว (แก้ title default ${titlesFixed}) · ข้าม ${skipped} แถว (hash แล้ว/ชน) · เหลือ plaintext ${leftovers[0].n} แถว (ต้องเป็น 0)`, + ); + if (leftovers[0].n !== 0) process.exitCode = 1; +} finally { + await sql.end(); +} diff --git a/tests/api/note-auth-session-staged.test.ts b/tests/api/note-auth-session-staged.test.ts new file mode 100644 index 0000000..4949f22 --- /dev/null +++ b/tests/api/note-auth-session-staged.test.ts @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createDbMock, createDbMockState, dbMockStateFromGlobal, type DbMockRow } from "../helpers/db-mock"; + +vi.mock("@/db", async () => { + const { createDbMock, createDbMockState } = await import("../helpers/db-mock"); + const state = createDbMockState(); + (globalThis as unknown as { __dbMockState: unknown }).__dbMockState = state; + return { db: createDbMock(state) }; +}); + +// import หลัง vi.mock — @/db ถูกแทนก่อน +import { POST } from "@/app/api/note/auth/session/route"; +import { issueDocverToken } from "@/lib/docver-token"; +import { ecourtExternalId } from "@/lib/ecourt-fid"; +import { __clearStagedDocsForTest, putStagedDoc } from "@/lib/staged-docs"; + +// PLAN-STAGED.md D5 — ลิงก์ fid-only (body มีแค่ t=): 4 branch ของการเช็ค staging +// ตั้งแต่ตอนแลก token · ใช้ hasStagedDoc จริงของ lib/staged-docs (ไม่ยืด TTL) + +const URL_ = "http://localhost/docedit/api/note/auth/session"; +const FID = "F-STAGED-1"; +const FILE_ID = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133"; +const PDF_BYTES = Buffer.from("%PDF-1.7 เอกสาร staging"); + +function stagedRow(pdfUrl: string): DbMockRow { + return { id: FILE_ID, title: "เอกสารทดสอบ", pdfUrl, externalId: ecourtExternalId(FID), typeId: null }; +} + +async function postSession(): Promise { + const token = issueDocverToken( + { userid: "u1", username: "ผู้ทดสอบ", fid: FID, template: null }, + "test-secret", + 300, + "login", + ); + return POST( + new Request(URL_, { + method: "POST", + body: JSON.stringify({ t: token }), + }), + ); +} + +beforeEach(() => { + __clearStagedDocsForTest(); + const state = dbMockStateFromGlobal(); + state.rows = []; + state.rowsBySelect = []; + state.selectCalls = 0; + state.insertAttempts = []; + state.updates = []; + state.insertErrCode = null; + vi.stubEnv("DOCVER_TOKEN_SECRET", "test-secret"); + vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("D5 — ลิงก์ fid-only (ไม่มี fileUrl)", () => { + it("เจอ row staged + hasStagedDoc = true → ได้ fileId ไม่มี fileError", async () => { + dbMockStateFromGlobal().rows = [stagedRow(`staged:${FILE_ID}.pdf`)]; + putStagedDoc(FILE_ID, PDF_BYTES, "pdf"); + const res = await postSession(); + expect(res.status).toBe(200); + const body = (await res.json()) as { access_token?: string; fileId?: string; fileError?: string }; + expect(body.fileId).toBe(FILE_ID); + expect(body.fileError).toBeUndefined(); + expect(typeof body.access_token).toBe("string"); + }); + + it("เจอ row staged + hasStagedDoc = false (restart/TTL/evict) → fileError พ้นช่วงจัดเตรียม", async () => { + dbMockStateFromGlobal().rows = [stagedRow(`staged:${FILE_ID}.pdf`)]; + // ไม่ put — staging ว่าง + const res = await postSession(); + expect(res.status).toBe(200); + const body = (await res.json()) as { access_token?: string; fileId?: string; fileError?: string }; + expect(body.fileId).toBeUndefined(); + expect(body.fileError).toBe("เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่"); + // ผู้ใช้ยังได้ token ตามปกติ + expect(typeof body.access_token).toBe("string"); + }); + + it("เจอ row ที่ pdf_url เป็น URL ปกติ (เอกสาร N8 แบบเดิม) → ได้ fileId", async () => { + dbMockStateFromGlobal().rows = [stagedRow("https://admincourt.go.th/doc.pdf")]; + const res = await postSession(); + expect(res.status).toBe(200); + const body = (await res.json()) as { fileId?: string; fileError?: string }; + expect(body.fileId).toBe(FILE_ID); + expect(body.fileError).toBeUndefined(); + }); + + it("ไม่เจอ row → fileError ยังไม่ถูกส่งเข้าระบบ (token ออกตามปกติ)", async () => { + dbMockStateFromGlobal().rows = []; + const res = await postSession(); + expect(res.status).toBe(200); + const body = (await res.json()) as { fileId?: string; fileError?: string }; + expect(body.fileId).toBeUndefined(); + expect(body.fileError).toBe("เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่"); + }); +}); diff --git a/tests/api/note-files-stage.test.ts b/tests/api/note-files-stage.test.ts new file mode 100644 index 0000000..b292b3d --- /dev/null +++ b/tests/api/note-files-stage.test.ts @@ -0,0 +1,287 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { createDbMock, createDbMockState, dbMockStateFromGlobal } from "../helpers/db-mock"; + +vi.mock("@/db", async () => { + const { createDbMock, createDbMockState } = await import("../helpers/db-mock"); + const state = createDbMockState(); + (globalThis as unknown as { __dbMockState: unknown }).__dbMockState = state; + return { db: createDbMock(state) }; +}); + +// import หลัง vi.mock — @/db ถูกแทนก่อน (lib/external-import จะได้ mock ผ่าน chain) +import { POST } from "@/app/api/note/files/stage/route"; +import { ecourtExternalId } from "@/lib/ecourt-fid"; +import { __clearStagedDocsForTest, getStagedDoc } from "@/lib/staged-docs"; +import { ECOURT_DEFAULT_TITLE } from "@/utils/external-file"; + +// PLAN-STAGED.md D1 — POST handler เรียกตรง + db/staged จริง (ปรับ cap ผ่าน env) +// ครอบ: 401/400/413 (มีและไม่มี Content-Length + ไฟล์เดี่ยวเกิน total cap)/429/ +// 408 (idle + total ด้วย fake timers)/stream โยน/req.signal abort — ทุกทางออกต้อง +// คืน slot ของ concurrency counter + +const URL_ = "http://localhost/docedit/api/note/files/stage"; +const PDF_BYTES = Buffer.from("%PDF-1.7\n%เอกสารทดสอบ staging"); +const PDF_B64 = PDF_BYTES.toString("base64"); + +function makeReq(body: unknown, headers: Record = {}): Request { + return new Request(URL_, { + method: "POST", + body: typeof body === "string" ? body : JSON.stringify(body), + headers: { "x-api-key": "test-key", ...headers }, + }); +} + +/** Request จาก stream body — Node 24 (undici) บังคับ duplex:"half" ตอน constructor + * กับ stream แต่ lib.dom ยังไม่มี field นี้ใน type จึงต้อง cast (runtime รับค่า) */ +function streamReq( + stream: ReadableStream, + opts: { signal?: AbortSignal } = {}, +): Request { + return new Request(URL_, { + method: "POST", + body: stream, + headers: { "x-api-key": "test-key" }, + signal: opts.signal, + duplex: "half", + } as RequestInit); +} + +/** stream ที่ enqueue chunks แล้วคงเปิดไว้ (ไม่ close อัตโนมัติ) — เคส timeout/ + * concurrent ต้องการ stream ที่ค้างระหว่างทาง, controller คืนให้ปิดเองตอนจบ */ +function streamOf( + chunks: string[], +): { stream: ReadableStream; controller: ReadableStreamDefaultController | null } { + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + for (const chunk of chunks) c.enqueue(new TextEncoder().encode(chunk)); + }, + }); + return { stream, controller }; +} + +beforeEach(() => { + __clearStagedDocsForTest(); + const state = dbMockStateFromGlobal(); + state.rows = []; + state.rowsBySelect = []; + state.selectCalls = 0; + state.insertAttempts = []; + state.updates = []; + state.insertErrCode = null; + vi.stubEnv("DOCVER_API_KEY", "test-key"); + vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +describe("auth ก่อนแตะอะไรทั้งสิ้น", () => { + it("env ว่าง = 401 fail closed · ไม่ใส่ key กับ key ผิด = 401 ข้อความเดียวกัน (กัน enumerate)", async () => { + vi.stubEnv("DOCVER_API_KEY", ""); + const res = await POST(makeReq({ fid: "F", data: PDF_B64 })); + expect(res.status).toBe(401); + vi.stubEnv("DOCVER_API_KEY", "test-key"); + const res2 = await POST(makeReq({ fid: "F", data: PDF_B64 }, { "x-api-key": "wrong" })); + const res3 = await POST(makeReq({ fid: "F", data: PDF_B64 }, { "x-api-key": "" })); + expect(res2.status).toBe(401); + expect(res3.status).toBe(401); + const e2 = (await res2.json()) as { error: string }; + const e3 = (await res3.json()) as { error: string }; + expect(e2.error).toBe(e3.error); + }); +}); + +describe("400 — body/base64/fid/magic", () => { + it.each([ + ["base64 มี whitespace", { fid: "F", data: "JVBE Ri0" }], + ["base64 มี line break", { fid: "F", data: "JVBE\nRi0" }], + ["base64url ไม่รับ", { fid: "F", data: "JVBE-Ri0=" }], + ["ความยาวไม่ลงตัวส่วน 4", { fid: "F", data: "JVBERi" }], + ["magic ไม่รู้จัก", { fid: "F", data: Buffer.from("GIF89aเอกสาร").toString("base64") }], + ["fid มี &", { fid: "a&b", data: PDF_B64 }], + ["field ที่ไม่รู้จัก", { fid: "F", data: PDF_B64, fileName: "x.pdf" }], + ["ขาด data", { fid: "F" }], + ])("%s = 400", async (_label, body) => { + const res = await POST(makeReq(body)); + expect(res.status).toBe(400); + const parsed = (await res.json()) as { error: string }; + expect(parsed.error).not.toContain("undefined"); + }); + + it("body ไม่ใช่ JSON = 400", async () => { + const res = await POST(makeReq("not json")); + expect(res.status).toBe(400); + }); +}); + +describe("413 — เกิน cap", () => { + it("มี Content-Length เกิน cap = ตัดทันทีก่อนอ่าน (413)", async () => { + vi.stubEnv("STAGED_MAX_FILE_BYTES", "1000"); // body cap ≈ 4*334+8192 ≈ 9.5KB + const big = "x".repeat(20_000); + const res = await POST(makeReq(big)); + expect(res.status).toBe(413); + }); + + it("ไม่มี Content-Length (chunked stream) = นับไบต์จนเกินแล้วตัดกลางทาง (413)", async () => { + vi.stubEnv("STAGED_MAX_FILE_BYTES", "1000"); + const { stream } = streamOf(["x".repeat(20_000)]); + const res = await POST( + streamReq(stream), + ); + expect(res.status).toBe(413); + }); + + it("ไฟล์เดี่ยวใหญ่กว่า STAGED_MAX_TOTAL_BYTES = 413 ที่ตัว stage (ไม่สร้าง row)", async () => { + vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "10"); + const res = await POST(makeReq({ fid: "F1", data: PDF_B64 })); + expect(res.status).toBe(413); + expect(dbMockStateFromGlobal().insertAttempts).toHaveLength(0); + }); +}); + +describe("429 — concurrent", () => { + it("กำลัง stage อยู่ = 429 + Retry-After แล้ว request ถัดไป (หลังจบ) ผ่าน", async () => { + const { stream, controller } = streamOf([]); + const first = POST( + streamReq(stream), + ); + // POST แรกวิ่งถึง await แรกแล้ว (sync จนถึง reader.read) — counter ถูกจับไว้ + const busy = await POST(makeReq({ fid: "F1", data: PDF_B64 })); + expect(busy.status).toBe(429); + expect(busy.headers.get("retry-after")).toBe("5"); + + controller!.enqueue(new TextEncoder().encode(JSON.stringify({ fid: "F1", data: PDF_B64 }))); + controller!.close(); + const done = await first; + expect(done.status).toBe(200); + // slot คืนแล้ว — request ถัดไปไม่โดน 429 + const next = await POST(makeReq({ fid: "F2", data: PDF_B64 })); + expect(next.status).toBe(200); + }); +}); + +describe("408 — timeout ของ body (fake timers)", () => { + it("stream หยุดนิ่งครบ idle 60 วิ = 408 + คืน slot", async () => { + vi.useFakeTimers(); + const { stream } = streamOf([]); // ไม่ enqueue อะไรเลยและไม่ close + const pending = POST( + streamReq(stream), + ); + vi.advanceTimersByTime(60_000); + const res = await pending; + expect(res.status).toBe(408); + expect(((await res.json()) as { error: string }).error).toContain("ลองส่งใหม่"); + // slot คืนใน finally — request ถัดไปไม่โดน 429 + const next = await POST(makeReq({ fid: "F1", data: PDF_B64 })); + expect(next.status).toBe(200); + }); + + it("stream ไหลต่อเนื่องแต่รวมเกิน 10 นาที = 408 (total timeout แม้ไม่ idle)", async () => { + vi.useFakeTimers(); + // ปล่อย chunk เล็กทุก 50 วิ (ต่ำกว่า idle 60 วิ) — รวม 10 นาทีต้องโดนตัด + let controller: ReadableStreamDefaultController | null = null; + const stream = new ReadableStream({ + start(c) { + controller = c; + c.enqueue(new TextEncoder().encode('{"fid":"F1","data":"')); + }, + }); + const interval = setInterval(() => { + controller?.enqueue(new TextEncoder().encode("JVBERi0=")); // chunk base64 ถูกกฎ 8 ตัว + }, 50_000); + const pending = POST( + streamReq(stream), + ); + vi.advanceTimersByTime(600_000); + const res = await pending; + clearInterval(interval); + expect(res.status).toBe(408); + }); +}); + +describe("ทางออกผิดปกติของ stream — คืน slot ใน finally เสมอ", () => { + it("stream โยน error = 500 + request ถัดไปไม่โดน 429", async () => { + const stream = new ReadableStream({ + start(c) { + c.error(new Error("boom")); + }, + }); + const res = await POST( + streamReq(stream), + ); + expect(res.status).toBe(500); + const next = await POST(makeReq({ fid: "F1", data: PDF_B64 })); + expect(next.status).toBe(200); + }); + + it("client ตัดการเชื่อมต่อ (req.signal abort) = 408 + คืน slot", async () => { + const ac = new AbortController(); + const { stream } = streamOf([]); + const pending = POST(streamReq(stream, { signal: ac.signal })); + ac.abort(); + const res = await pending; + expect(res.status).toBe(408); + const next = await POST(makeReq({ fid: "F1", data: PDF_B64 })); + expect(next.status).toBe(200); + }); +}); + +describe("happy path", () => { + it("payload ใหญ่ (8MiB เนื้อไฟล์) ผ่านทั้งชุด — stream นับไบต์ + base64 decode + magic (กัน regex/backtracking regression 2026-11-04; ขนาด ~140MB เคยพิสูจน์บน server จริงแล้ว — test เล็กพอที่จะไม่กด heap ของ vitest worker)", async () => { + const chunk = Buffer.from( + "%PDF-1.7 เอกสารทดสอบขนาดใหญ่ padding padding padding padding padding\n", + "utf8", + ); + const parts: Buffer[] = []; + let size = 0; + while (size < 8 * 1024 * 1024) { + parts.push(chunk); + size += chunk.length; + } + const bytes = Buffer.concat(parts); + const res = await POST(makeReq({ fid: "F-BIG", data: bytes.toString("base64") })); + expect(res.status).toBe(200); + const body = (await res.json()) as { fileId: string }; + expect(getStagedDoc(`staged:${body.fileId}.pdf`)).toEqual(bytes); + }, 60_000); + + it("ได้ {fileId} + staging มี bytes ใต้ id นั้น + row ถูก insert ครบ", async () => { + const res = await POST( + makeReq({ fid: "F100", data: PDF_B64, title: "คำสั่งศาล", typeId: "G1" }), + ); + expect(res.status).toBe(200); + const body = (await res.json()) as { fileId: string }; + expect(getStagedDoc(`staged:${body.fileId}.pdf`)).toEqual(PDF_BYTES); + const state = dbMockStateFromGlobal(); + expect(state.insertAttempts).toHaveLength(1); + expect(state.insertAttempts[0].externalId).toBe(ecourtExternalId("F100")); + expect(state.insertAttempts[0].title).toBe("คำสั่งศาล"); + expect(state.insertAttempts[0].typeId).toBe("G1"); + }); + + it("re-POST fid เดิม (มี row แล้ว) = replace ไม่เกิด row ใหม่", async () => { + const first = await POST(makeReq({ fid: "F100", data: PDF_B64 })); + const { fileId } = (await first.json()) as { fileId: string }; + const state = dbMockStateFromGlobal(); + state.rows = [ + { + id: fileId, + title: ECOURT_DEFAULT_TITLE, + pdfUrl: `staged:${fileId}.pdf`, + externalId: ecourtExternalId("F100"), + typeId: null, + }, + ]; + const jpgBytes = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10]); + const second = await POST(makeReq({ fid: "F100", data: jpgBytes.toString("base64") })); + expect(second.status).toBe(200); + expect(((await second.json()) as { fileId: string }).fileId).toBe(fileId); + expect(state.insertAttempts).toHaveLength(1); // ไม่มี insert รอบสอง + expect(getStagedDoc(`staged:${fileId}.jpg`)).toEqual(jpgBytes); + }); +}); diff --git a/tests/helpers/db-mock.ts b/tests/helpers/db-mock.ts new file mode 100644 index 0000000..b11cabd --- /dev/null +++ b/tests/helpers/db-mock.ts @@ -0,0 +1,76 @@ +// shared mock ของ @/db สำหรับ route/lib tests ที่ต้องจำลอง drizzle chain +// (select().from().where().limit() / insert().values() / update().set().where()) +// — ใช้ร่วมโดย vi.mock("@/db", async () => ({ db: createDbMock(state) })) +// โดย state เก็บบน globalThis ของ test file (pattern เดียวกับ lib/staged-docs.ts) +export type DbMockRow = { + id: string; + title: string; + pdfUrl: string; + externalId: string; + typeId: string | null; +}; + +export type DbMockState = { + /** ค่า default ของ select ทุกครั้ง */ + rows: DbMockRow[]; + /** override รายครั้งของ select (index = ลำดับการเรียน — undefined = ใช้ rows) */ + rowsBySelect: (DbMockRow[] | undefined)[]; + selectCalls: number; + insertAttempts: Array>; + updates: Array<{ cond: unknown; patch: Record }>; + /** โค้ด error ของ postgres ที่ insert จะโยน (เช่น "23505") — null = สำเร็จ */ + insertErrCode: string | null; +}; + +export function createDbMockState(): DbMockState { + return { + rows: [], + rowsBySelect: [], + selectCalls: 0, + insertAttempts: [], + updates: [], + insertErrCode: null, + }; +} + +export function createDbMock(state: DbMockState) { + return { + select: () => ({ + from: () => ({ + where: () => ({ + limit: async (): Promise => { + const rows = state.rowsBySelect[state.selectCalls] ?? state.rows; + state.selectCalls += 1; + return rows.map((r) => ({ ...r })); + }, + }), + }), + }), + insert: () => ({ + values: async (v: Record): Promise => { + state.insertAttempts.push(v); + if (state.insertErrCode !== null) { + throw Object.assign(new Error("db error (mock)"), { + code: state.insertErrCode, + }); + } + }, + }), + update: () => ({ + set: (patch: Record) => ({ + where: async (cond: unknown): Promise => { + state.updates.push({ cond, patch }); + }, + }), + }), + }; +} + +/** คืน state ที่ vi.mock factory สร้างไว้บน globalThis (factory รันก่อน import ของ test) */ +export function dbMockStateFromGlobal(): DbMockState { + const holder = globalThis as typeof globalThis & { __dbMockState?: DbMockState }; + if (holder.__dbMockState === undefined) { + throw new Error("db mock ยังไม่ถูกสร้าง — vi.mock ต้องรันก่อน"); + } + return holder.__dbMockState; +} diff --git a/tests/lib/ecourt-fid.test.ts b/tests/lib/ecourt-fid.test.ts new file mode 100644 index 0000000..042903a --- /dev/null +++ b/tests/lib/ecourt-fid.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + ECOURT_ID_PREFIX, + ecourtExternalId, + isEcourtExternalId, +} from "@/lib/ecourt-fid"; + +// hash fid ของ e-Court ก่อนเก็บลง DB (ตามคำขอผู้ใช้ 2026-11-04) — pure module +// รันได้ไม่ต้องตั้ง DATABASE_URL · env FID_HASH_PEPPER เป็นค่าคงที่ตลอดชีพ + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("ecourtExternalId", () => { + it("deterministic + รูป ecourt: + HMAC-SHA256 hex 64 ตัว + ไม่มี fid ดิบในค่า", () => { + vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); + const a = ecourtExternalId("1"); + expect(a).toBe(ecourtExternalId("1")); + expect(a).toMatch(/^ecourt:[0-9a-f]{64}$/); + expect(a.startsWith(ECOURT_ID_PREFIX)).toBe(true); + // fid ดิบ (ที่มีเอกลักษณ์พอ) ต้องไม่หลุดไปอยู่ในค่า hash + const b = ecourtExternalId("SECRET-FID-XYZ-987654"); + expect(b).not.toContain("SECRET-FID-XYZ"); + }); + + it("fid ต่างค่า → hash ต่าง · pepper ต่างค่า → hash ต่าง (คาดเดากลับไม่ได้)", () => { + vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); + expect(ecourtExternalId("1")).not.toBe(ecourtExternalId("2")); + const withA = ecourtExternalId("F123"); + vi.stubEnv("FID_HASH_PEPPER", "other-pepper"); + expect(ecourtExternalId("F123")).not.toBe(withA); + }); + + it("trim fid ก่อน hash — มี whitespace รอบได้ค่าเดียวกัน (จุด normalize เดียว)", () => { + vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); + expect(ecourtExternalId(" F123 ")).toBe(ecourtExternalId("F123")); + expect(ecourtExternalId("\tF123\n")).toBe(ecourtExternalId("F123")); + }); + + it("ไม่ตั้ง/ว่าง pepper = throw (fail-closed กันสถานะครึ่ง ๆ สอง key)", () => { + delete process.env.FID_HASH_PEPPER; + expect(() => ecourtExternalId("1")).toThrow(/FID_HASH_PEPPER/); + vi.stubEnv("FID_HASH_PEPPER", " "); + expect(() => ecourtExternalId("1")).toThrow(/FID_HASH_PEPPER/); + }); +}); + +describe("isEcourtExternalId", () => { + it("แยกแถว ecourt: ออกจากแถว url: ของ N8", () => { + vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); + expect(isEcourtExternalId(ecourtExternalId("1"))).toBe(true); + expect(isEcourtExternalId("url:https://x.go.th/a.pdf")).toBe(false); + expect(isEcourtExternalId("url:/samples/a.pdf")).toBe(false); + }); +}); diff --git a/tests/lib/external-import-staged.test.ts b/tests/lib/external-import-staged.test.ts new file mode 100644 index 0000000..032dcc4 --- /dev/null +++ b/tests/lib/external-import-staged.test.ts @@ -0,0 +1,165 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { __clearStagedDocsForTest, getStagedDoc } from "@/lib/staged-docs"; +import { createDbMock, createDbMockState, dbMockStateFromGlobal, type DbMockRow } from "../helpers/db-mock"; + +vi.mock("@/db", async () => { + const { createDbMock, createDbMockState } = await import("../helpers/db-mock"); + const state = createDbMockState(); + (globalThis as unknown as { __dbMockState: unknown }).__dbMockState = state; + return { db: createDbMock(state) }; +}); + +// import หลัง vi.mock ให้ @/db ถูกแทนก่อน (vi hoist จัดการให้อยู่แล้ว) +import { registerStagedFile } from "@/lib/external-import"; +import { ecourtExternalId } from "@/lib/ecourt-fid"; +import { ECOURT_DEFAULT_TITLE } from "@/utils/external-file"; + +// PLAN-STAGED.md D3 — registerStagedFile: stage ใต้ id ผู้ชนะเสมอ ไม่มี row ชี้ +// sentinel ที่ไม่มี bytes · ผลข้างเคียง staging ตรวจผ่าน store จริง (lib/staged-docs) +// · external_id เก็บ hash ของ fid (lib/ecourt-fid 2026-11-04 — expected คำนวณ +// ด้วย helper ตัวเดียวกับ production, stub FID_HASH_PEPPER) + +const WINNER = "22222222-3333-4444-8555-666666666666"; +const PDF_BYTES = Buffer.from("%PDF-1.4 เอกสาร staging ทดสอบ"); + +function row(overrides: Partial = {}): DbMockRow { + return { + id: WINNER, + title: ECOURT_DEFAULT_TITLE, + pdfUrl: `staged:${WINNER}.pdf`, + externalId: ecourtExternalId("F1"), + typeId: null, + ...overrides, + }; +} + +beforeEach(() => { + vi.stubEnv("FID_HASH_PEPPER", "test-pepper"); + __clearStagedDocsForTest(); + const state = dbMockStateFromGlobal(); + state.rows = []; + state.rowsBySelect = []; + state.selectCalls = 0; + state.insertAttempts = []; + state.updates = []; + state.insertErrCode = null; +}); + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("เจอ row เดิม — replace staging + patch", () => { + it("pdf_url เป็น URL (เอกสาร N8) + ส่ง typeId + title ยังเป็นค่าจริง → patch เฉพาะที่ต้องเปลี่ยน", async () => { + const state = dbMockStateFromGlobal(); + state.rows = [ + row({ title: "คำสั่งศาลเดิม", pdfUrl: "https://admincourt.go.th/x.pdf" }), + ]; + const result = await registerStagedFile("F1", PDF_BYTES, "pdf", { + title: "ชื่อใหม่", + typeId: "G9", + }); + expect(result.fileId).toBe(WINNER); + // staging อยู่ใต้ id ของ row เดิม + expect(getStagedDoc(`staged:${WINNER}.pdf`)).toEqual(PDF_BYTES); + // insert ไม่เกิด / patch เดียว: pdfUrl เปลี่ยน (URL → sentinel) + typeId ย้ายกลุ่ม + // แต่ title ไม่แตะ (ค่าปัจจุบันไม่ใช่ค่า default) + expect(state.insertAttempts).toHaveLength(0); + expect(state.updates).toHaveLength(1); + expect(state.updates[0].patch).toEqual({ + pdfUrl: `staged:${WINNER}.pdf`, + typeId: "G9", + }); + }); + + it("title ปัจจุบันยังเป็นค่า default → title ที่ส่งมาชนะ (ครั้งแรกล็อก)", async () => { + const state = dbMockStateFromGlobal(); + state.rows = [row({ pdfUrl: `staged:${WINNER}.jpg`, typeId: "G1" })]; + await registerStagedFile("F1", PDF_BYTES, "pdf", { title: "ชื่อจริงครั้งแรก" }); + // ext ต่าง → sentinel เปลี่ยนด้วย; typeId ไม่ส่ง = คงเดิมไม่เคลียร์ + expect(state.updates[0].patch).toEqual({ + pdfUrl: `staged:${WINNER}.pdf`, + title: "ชื่อจริงครั้งแรก", + }); + }); + + it("row เดิมเป็น sentinel เดียวกันทุกค่า → ไม่มี update (แค่ replace bytes)", async () => { + const state = dbMockStateFromGlobal(); + state.rows = [row()]; + await registerStagedFile("F1", PDF_BYTES, "pdf"); + expect(state.updates).toHaveLength(0); + expect(getStagedDoc(`staged:${WINNER}.pdf`)).toEqual(PDF_BYTES); + }); +}); + +describe("ไม่เจอ row — insert ด้วย candidateId", () => { + it("สร้าง row ใหม่ sentinel ชี้ candidateId + staging ใต้ id เดียวกัน + external_id เป็น hash ของ fid", async () => { + const state = dbMockStateFromGlobal(); + state.rows = []; + const result = await registerStagedFile("F2", PDF_BYTES, "jpg", { title: "ภาพถ่าย", typeId: "G2" }); + expect(state.insertAttempts).toHaveLength(1); + expect(state.updates).toHaveLength(0); + const inserted = state.insertAttempts[0]; + expect(inserted.id).toBe(result.fileId); + expect(inserted.pdfUrl).toBe(`staged:${result.fileId}.jpg`); + expect(inserted.title).toBe("ภาพถ่าย"); + // hash ของ fid ไม่ใช่ fid ดิบ — fid ดิบต้องไม่ปรากฏในแถวเลย + expect(inserted.externalId).toBe(ecourtExternalId("F2")); + expect(JSON.stringify(inserted)).not.toContain("F2"); + expect(inserted.typeId).toBe("G2"); + expect(getStagedDoc(`staged:${result.fileId}.jpg`)).toEqual(PDF_BYTES); + }); + + it("ไม่ส่ง title = ค่า default กลาง (ไม่ฝัง fid) · typeId ไม่ส่ง = null", async () => { + const state = dbMockStateFromGlobal(); + const result = await registerStagedFile("F3", PDF_BYTES, "png"); + expect(state.insertAttempts[0].title).toBe(ECOURT_DEFAULT_TITLE); + expect(state.insertAttempts[0].typeId).toBeNull(); + expect(result.fileId).toMatch( + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, + ); + }); +}); + +describe("insert โดน 23505 — คู่ขนาน fid เดียวกัน", () => { + it("re-stage ใต้ winnerId + ลบ candidateId + คืน winnerId (bytes ของเรา = ล่าสุด)", async () => { + const state = dbMockStateFromGlobal(); + const winnerRow = row({ + pdfUrl: "https://admincourt.go.th/old.pdf", + externalId: ecourtExternalId("F4"), + }); + // select รอบแรก (ก่อน insert) ไม่เจอ — รอบสอง (หลังชน 23505) เจอผู้ชนะ + state.rowsBySelect = [[], [winnerRow]]; + state.insertErrCode = "23505"; + + const result = await registerStagedFile("F4", PDF_BYTES, "pdf"); + expect(result.fileId).toBe(WINNER); + expect(state.selectCalls).toBe(2); + // staging ทับใต้ id ผู้ชนะด้วย bytes ของเรา (last-write-wins) + expect(getStagedDoc(`staged:${WINNER}.pdf`)).toEqual(PDF_BYTES); + // candidate ของผู้แพ้ถูกลบคืน RAM — เหลือ entry เดียวใน store + const candidateId = state.insertAttempts[0].id as string; + expect(candidateId).not.toBe(WINNER); + expect(getStagedDoc(`staged:${candidateId}.pdf`)).toBeNull(); + // patch ผู้ชนะ: URL เดิม → sentinel + expect(state.updates).toHaveLength(1); + expect(state.updates[0].patch).toEqual({ pdfUrl: `staged:${WINNER}.pdf` }); + }); + + it("error อื่น (ไม่ใช่ 23505) โยนต่อ — entry กำพร้าใน RAM ให้ TTL กินเอง", async () => { + const state = dbMockStateFromGlobal(); + state.rows = []; + state.insertErrCode = "42P01"; + await expect(registerStagedFile("F5", PDF_BYTES, "pdf")).rejects.toThrow("db error (mock)"); + }); + + it("ชน 23505 แต่ select รอบสองไม่เจอ row ผู้ชนะ — โยนต่อพร้อมคืน RAM ผู้แพ้ทันที (review ข้อ 3)", async () => { + const state = dbMockStateFromGlobal(); + state.rowsBySelect = [[], []]; // ทั้งสองรอบไม่เจอ + state.insertErrCode = "23505"; + await expect(registerStagedFile("F6", PDF_BYTES, "pdf")).rejects.toThrow(); + const candidateId = state.insertAttempts[0].id as string; + expect(getStagedDoc(`staged:${candidateId}.pdf`)).toBeNull(); + }); +}); diff --git a/tests/lib/server-pdf.test.ts b/tests/lib/server-pdf.test.ts new file mode 100644 index 0000000..ea4c0bf --- /dev/null +++ b/tests/lib/server-pdf.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from "vitest"; + +import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf"; + +// PLAN-STAGED.md D4 — guard ปฏิเสธ sentinel staged: ชัด ๆ (defense in depth) +// ก่อนหน้านี้ "staged:x.pdf" ผ่าน resolve ตกใน publicDir แล้วตายด้วย ENOENT เป็น 500 ดิบ +describe("resolvePublicPdfPath — sentinel staged: (N11)", () => { + it("โยน PdfPathError ไม่ใช่ ENOENT ทะลุเป็น 500", () => { + expect(() => + resolvePublicPdfPath("staged:0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133.pdf"), + ).toThrow(PdfPathError); + expect(() => + resolvePublicPdfPath("staged:0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133.tif"), + ).toThrow(PdfPathError); + }); + + it("path ปกติ (สัมพัทธ์ public/note) ยัง resolve ได้ตามเดิม", () => { + const p = resolvePublicPdfPath("/samples/demo.pdf"); + expect(p.replace(/\\/g, "/")).toContain("public/note/samples/demo.pdf"); + }); +}); diff --git a/tests/lib/staged-docs.test.ts b/tests/lib/staged-docs.test.ts new file mode 100644 index 0000000..da37021 --- /dev/null +++ b/tests/lib/staged-docs.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + __clearStagedDocsForTest, + detectStagedExt, + getStagedDoc, + hasStagedDoc, + parseStagedSource, + putStagedDoc, + stagedSourceUrl, + StagedCapError, +} from "@/lib/staged-docs"; + +// PLAN-STAGED.md D2 — RAM staging (pure module ไม่ต้องตั้ง DATABASE_URL) +// ใช้ fake timers คุม Date.now ของ TTL sliding + env stub ปรับ cap ต่อเคส +// (store เก็บบน globalThis จึงต้อง __clearStagedDocsForTest ทุกเคส) + +const ID_A = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133"; +const ID_B = "11111111-2222-4333-8444-555555555555"; +const ID_C = "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"; + +beforeEach(() => { + __clearStagedDocsForTest(); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.useRealTimers(); +}); + +describe("sentinel: stagedSourceUrl/parseStagedSource", () => { + it("สร้าง + แกะกลับได้ครบทุก ext", () => { + for (const ext of ["pdf", "jpg", "png", "tif"] as const) { + const url = stagedSourceUrl(ID_A, ext); + expect(url).toBe(`staged:${ID_A}.${ext}`); + expect(parseStagedSource(url)).toEqual({ fileId: ID_A, ext }); + } + }); + + it("ปฏิเสธ uuid/ext เพี้ยนและค่าที่ไม่ใช่ sentinel", () => { + expect(parseStagedSource(`staged:not-a-uuid.pdf`)).toBeNull(); + expect(parseStagedSource(`staged:${ID_A}.exe`)).toBeNull(); + expect(parseStagedSource(`staged:${ID_A}`)).toBeNull(); + expect(parseStagedSource(`staged:.pdf`)).toBeNull(); + expect(parseStagedSource(`/samples/${ID_A}.pdf`)).toBeNull(); + expect(parseStagedSource("https://x.go.th/a.pdf")).toBeNull(); + expect(parseStagedSource("")).toBeNull(); + }); +}); + +describe("detectStagedExt — magic bytes", () => { + it("แยก pdf/jpg/png/tif (ทั้งสอง endian) ถูก", () => { + expect(detectStagedExt(Buffer.from("%PDF-1.7\n..."))).toBe("pdf"); + expect(detectStagedExt(Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0x00]))).toBe("jpg"); + expect(detectStagedExt(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d]))).toBe("png"); + expect(detectStagedExt(Buffer.from([0x49, 0x49, 0x2a, 0x00, 0x00]))).toBe("tif"); + expect(detectStagedExt(Buffer.from([0x4d, 0x4d, 0x00, 0x2a, 0x00]))).toBe("tif"); + }); + + it("ไม่รู้จัก = null (รวมไฟล์ว่าง/GIF/ตัด magic สั้นเกิน)", () => { + expect(detectStagedExt(Buffer.alloc(0))).toBeNull(); + expect(detectStagedExt(Buffer.from("GIF89a"))).toBeNull(); + expect(detectStagedExt(Buffer.from([0x89, 0x50]))).toBeNull(); + expect(detectStagedExt(Buffer.from("%PD"))).toBeNull(); + }); +}); + +describe("put/get ตัวตน", () => { + it("put แล้ว get ได้ bytes เดิม (อ่านซ้ำก็ได้)", () => { + const bytes = Buffer.from("%PDF-1.4 เอกสารทดสอบ"); + putStagedDoc(ID_A, bytes, "pdf"); + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes); + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes); + expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true); + }); + + it("get ด้วย ext ที่ไม่ตรง entry = miss แต่ไม่ทำลาย entry เดิม", () => { + const bytes = Buffer.from("%PDF-1.4"); + putStagedDoc(ID_A, bytes, "pdf"); + expect(getStagedDoc(`staged:${ID_A}.jpg`)).toBeNull(); + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes); + }); + + it("replace ช่องเดิมต่อ fileId — ไม่งอก entry ใหม่ต่อครั้ง", () => { + vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000"); + putStagedDoc(ID_B, Buffer.alloc(500, 1), "pdf"); + putStagedDoc(ID_B, Buffer.alloc(300, 2), "pdf"); + // ถ้างอกเป็น 2 entry รวม 800 + ID_C 400 = 1200 > 1000 จะโดน evict — แทนที่แล้ว + // รวม 300 + 400 = 700 ทุกตัวรอด + putStagedDoc(ID_C, Buffer.alloc(400, 3), "pdf"); + expect(getStagedDoc(`staged:${ID_B}.pdf`)).toEqual(Buffer.alloc(300, 2)); + expect(getStagedDoc(`staged:${ID_C}.pdf`)).toEqual(Buffer.alloc(400, 3)); + }); +}); + +describe("TTL sliding", () => { + it("get ที่สำเร็จ = touch — ใช้งานต่อเนื่องไม่หมดอายุ / ปล่อยเลยหมดแล้วถูกเก็บกวาด", () => { + vi.useFakeTimers(); + vi.stubEnv("STAGED_DOC_TTL_SECONDS", "10"); + const bytes = Buffer.from("%PDF-1.4"); + putStagedDoc(ID_A, bytes, "pdf"); + + vi.advanceTimersByTime(9_000); + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes); // touch ที่ 9 วิ + + vi.advanceTimersByTime(9_000); // 18 วินับจาก put แต่ 9 วินับจาก touch ล่าสุด + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(bytes); + + vi.advanceTimersByTime(11_000); // ไม่มีใคร touch 11 วิ = พ้น TTL + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toBeNull(); + expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false); + // หมดอายุแล้ว get เก็บกวาดทิ้ง — put ใหม่ได้ตามปกติ + putStagedDoc(ID_A, bytes, "pdf"); + expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true); + }); + + it("hasStagedDoc ไม่ touch TTL (lastAccessAt ไม่ขยับ)", () => { + vi.useFakeTimers(); + vi.stubEnv("STAGED_DOC_TTL_SECONDS", "10"); + const bytes = Buffer.from("%PDF-1.4"); + putStagedDoc(ID_A, bytes, "pdf"); + + vi.advanceTimersByTime(8_000); + expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true); + // อีก 5 วิ: รวม 13 วิจาก put — ถ้า has() แอบ touch ตัวนี้จะยังไม่หมดอายุ (5 < 10) + vi.advanceTimersByTime(5_000); + expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false); + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toBeNull(); + }); +}); + +describe("cap + LRU", () => { + it("เกิน total cap = evict รายการ lastAccessAt เก่าสุด (ผู้เพิ่งใช้รอด)", () => { + vi.useFakeTimers(); + vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000"); + putStagedDoc(ID_A, Buffer.alloc(400), "pdf"); // t=0 + vi.advanceTimersByTime(1_000); + putStagedDoc(ID_B, Buffer.alloc(400), "pdf"); // t=1s + vi.advanceTimersByTime(1_000); + expect(getStagedDoc(`staged:${ID_A}.pdf`)).toEqual(Buffer.alloc(400)); // A ถูก touch ล่าสุด + + putStagedDoc(ID_C, Buffer.alloc(400), "pdf"); // รวม 1200 > 1000 → evict B (เก่าสุด) + expect(hasStagedDoc(`staged:${ID_B}.pdf`)).toBe(false); + expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(true); + expect(hasStagedDoc(`staged:${ID_C}.pdf`)).toBe(true); + }); + + it("ไฟล์เดี่ยวใหญ่กว่า total cap = put ปฏิเสธ (StagedCapError)", () => { + vi.stubEnv("STAGED_MAX_TOTAL_BYTES", "1000"); + expect(() => putStagedDoc(ID_A, Buffer.alloc(1001), "pdf")).toThrow(StagedCapError); + expect(hasStagedDoc(`staged:${ID_A}.pdf`)).toBe(false); + }); +}); diff --git a/tests/utils/file-kind.test.ts b/tests/utils/file-kind.test.ts index 6c1dfde..fa08cb8 100644 --- a/tests/utils/file-kind.test.ts +++ b/tests/utils/file-kind.test.ts @@ -48,6 +48,29 @@ describe("imageContentTypeFromUrl", () => { }); }); +describe("sentinel staged: ของ N11 (PLAN-STAGED.md D4)", () => { + const uid = "0d7e6a52-9a71-4d40-bd1c-b2e1a44ab133"; + // ล็อกสมมติฐาน "ext ใน sentinel เข้ากับ extOf() ทุกตัว" กัน helper เปลี่ยนวิธี + // parse ภายหลัง — sentinel คือ pdf_url จริงใน DB ตั้งแต่ N11 + it("ครบ 4 ext: fileKindFromUrl แยก pdf/image ตาม extension ของ sentinel", () => { + expect(fileKindFromUrl(`staged:${uid}.pdf`)).toBe("pdf"); + expect(fileKindFromUrl(`staged:${uid}.jpg`)).toBe("image"); + expect(fileKindFromUrl(`staged:${uid}.png`)).toBe("image"); + expect(fileKindFromUrl(`staged:${uid}.tif`)).toBe("image"); + }); + + it("imageFormatFromUrl / imageContentTypeFromUrl ทำงานกับ sentinel (pdf ไม่ผ่านตัวรูป — สถานะเดิมคืน octet-stream)", () => { + expect(imageFormatFromUrl(`staged:${uid}.pdf`)).toBeNull(); + expect(imageFormatFromUrl(`staged:${uid}.jpg`)).toBe("jpeg"); + expect(imageFormatFromUrl(`staged:${uid}.png`)).toBe("png"); + expect(imageFormatFromUrl(`staged:${uid}.tif`)).toBe("tiff"); + expect(imageContentTypeFromUrl(`staged:${uid}.jpg`)).toBe("image/jpeg"); + expect(imageContentTypeFromUrl(`staged:${uid}.png`)).toBe("image/png"); + expect(imageContentTypeFromUrl(`staged:${uid}.tif`)).toBe("image/tiff"); + expect(imageContentTypeFromUrl(`staged:${uid}.pdf`)).toBe("application/octet-stream"); + }); +}); + describe("SAMPLE_FILE_EXTENSIONS", () => { it("ครบทุกนามสกุลที่ requirement 5.3.4 ระบุ", () => { for (const ext of [".pdf", ".jpg", ".jpeg", ".png", ".tif", ".tiff"]) { diff --git a/utils/external-file.ts b/utils/external-file.ts index 6da410a..7af81d9 100644 --- a/utils/external-file.ts +++ b/utils/external-file.ts @@ -59,9 +59,15 @@ export function isHttpUrl(s: string): boolean { return /^https?:\/\//i.test(s.trim()); } +/** default title ของเอกสารจาก e-Court — ไม่ฝัง fid ดิบ (2026-11-04: external_id + * เก็บเป็น hash แล้ว title ก็ต้องไม่เป็นที่รั่วของ fid) · ใช้ร่วมกันทั้ง N8 + * (fallback ของ titleFromExternalUrl) และ N11 (insert + เงื่อนไขปลดล็อก title + * ของ registerStagedFile — เทียบจากค่าคงที่ตัวนี้) */ +export const ECOURT_DEFAULT_TITLE = "เอกสารจาก e-Court"; + /** title จาก basename ของ URL (decode แล้ว ตัดนามสกุล cap 200) — - * path ลงท้าย "/" หรือ decode ไม่ได้/ไม่มีชื่อ ใช้ fallbackId หรือชื่อกลาง */ -export function titleFromExternalUrl(url: URL, fallbackId?: string): string { + * path ลงท้าย "/" หรือ decode ไม่ได้/ไม่มีชื่อ ใช้ค่า default กลาง */ +export function titleFromExternalUrl(url: URL): string { const base = url.pathname.split("/").pop() ?? ""; let name = base; try { @@ -72,14 +78,7 @@ export function titleFromExternalUrl(url: URL, fallbackId?: string): string { const dot = name.lastIndexOf("."); if (dot > 0) name = name.slice(0, dot); name = name.trim().slice(0, 200); - return name || fallbackId?.trim() || "เอกสารจาก e-Court"; -} - -/** dedupe key ของเอกสารนำเข้า — "ecourt:" เมื่อมี id ของ e-Court, - * ไม่มี fallback เป็น "url:" (id เปลี่ยน = ไฟล์ใหม่ ตามหลักของแผน) */ -export function externalIdFor(fileId: string | undefined, url: URL): string { - const id = fileId?.trim(); - return id ? `ecourt:${id}` : `url:${url.href}`; + return name || ECOURT_DEFAULT_TITLE; } /** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — ใช้ที่เดียวคือ N8