feat(n11): add base64 document staging from e-Court
เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
2e951f99f4
commit
a02e983ba8
34 changed files with 2396 additions and 62 deletions
22
.env.example
22
.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 ทั้งสองฝั่ง
|
||||
|
|
|
|||
|
|
@ -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:<fileId>" ไม่เจอ 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
|
||||
|
|
|
|||
25
CLAUDE.md
25
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/`<img>` 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/`<img>` 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 `<a href>`/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน `<Link>`/`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:<fileId>` เฉพาะเมื่อ 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:<hash ของ fid>` เฉพาะเมื่อ 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:<uuid>.<ext>` (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=<id>` / 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/<versionId>/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:<hash ของ fid>` (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:<fileId>" ไม่เจอ 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` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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<Response> {
|
|||
});
|
||||
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(
|
||||
|
|
|
|||
9
app/api/note/external/documents/route.ts
vendored
9
app/api/note/external/documents/route.ts
vendored
|
|
@ -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) {
|
||||
|
|
|
|||
291
app/api/note/files/stage/route.ts
Normal file
291
app/api/note/files/stage/route.ts
Normal file
|
|
@ -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<string, string>,
|
||||
): 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<Buffer> {
|
||||
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<typeof setTimeout> | null = null;
|
||||
let totalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// timer ทั้งสอง + abort ส่งสัญญาณ rejecting ผ่าน promise ที่ race กับ read()
|
||||
let timedOutReject: ((e: StageReadError) => void) | null = null;
|
||||
const timedOut = new Promise<never>((_, reject) => {
|
||||
timedOutReject = reject;
|
||||
});
|
||||
let abortReject: ((e: StageReadError) => void) | null = null;
|
||||
const aborted = new Promise<never>((_, 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<string, unknown>;
|
||||
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<Response> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
|||
<MockEcourtForm files={Object.values(SAMPLE_DOCS).flat()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* จำลอง e-Court แบบ base64 staging (N11 — คำขอผู้ใช้ 2026-11-04): ให้ dev
|
||||
ฝั่ง e-Court กดทดสอบ contract ทั้ง 3 ขั้นในหน้าเดียว — stage → mint →
|
||||
ลิงก์ fid-only · ต้องถือ DOCVER_API_KEY จริงจึงไม่ใช่ประตูหลัง */}
|
||||
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6">
|
||||
<h3 className="font-display text-subhead text-primary">
|
||||
จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)
|
||||
</h3>
|
||||
<p className="mt-1.5 text-sm text-ink-soft">
|
||||
สำหรับเอกสารที่ e-Court <strong className="font-medium">ไม่มี URL ให้ดึง</strong> —
|
||||
เลือกไฟล์แล้วกดปุ่มเดียว ระบบจะทำตาม contract จริงทั้ง 3 ขั้น:{" "}
|
||||
<Code>POST /api/note/files/stage</Code> (เนื้อไฟล์เป็น base64) →{" "}
|
||||
<Code>POST /api/note/auth/token</Code> ด้วย <Code>fid</Code> เดิม → ลิงก์{" "}
|
||||
<Code>/note?t=…</Code> (ไม่มี fileUrl) ให้กดเข้าอ่านได้เลย · ส่ง{" "}
|
||||
<Code>fid</Code> เดิมซ้ำ = แทนที่เนื้อล่าสุด (annotation เดิมอยู่ครบ)
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<MockStageForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Flow ─────────────────────────────────────────────────────────── */}
|
||||
|
|
|
|||
227
components/MockStageForm.tsx
Normal file
227
components/MockStageForm.tsx
Normal file
|
|
@ -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<File | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [result, setResult] = useState<{ fileId: string; link: string } | null>(null);
|
||||
|
||||
const onFileChange = (e: ChangeEvent<HTMLInputElement>) => {
|
||||
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:<fid> ผูกกัน
|
||||
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 (
|
||||
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="flex flex-col gap-1.5 sm:col-span-2">
|
||||
<label className={labelCls} htmlFor="stage-key">
|
||||
API key (env DOCVER_API_KEY ของเครื่องนี้)
|
||||
</label>
|
||||
<input
|
||||
id="stage-key"
|
||||
type="password"
|
||||
value={apiKey}
|
||||
onChange={(e) => setApiKey(e.target.value)}
|
||||
className={inputCls}
|
||||
autoComplete="off"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="stage-fid">
|
||||
fid (รหัสเอกสารของ e-Court — ตัวเดียวกันทั้ง stage และ mint)
|
||||
</label>
|
||||
<input
|
||||
id="stage-fid"
|
||||
value={fid}
|
||||
onChange={(e) => setFid(e.target.value)}
|
||||
className={inputCls}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="stage-file">
|
||||
ไฟล์เอกสาร (PDF/รูป ≤ 100MB — ส่งเป็น base64)
|
||||
</label>
|
||||
<input
|
||||
id="stage-file"
|
||||
type="file"
|
||||
accept=".pdf,.jpg,.jpeg,.png,.tif,.tiff"
|
||||
onChange={onFileChange}
|
||||
className={`${inputCls} py-1.5`}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="stage-uid">userid</label>
|
||||
<input id="stage-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="stage-uname">username</label>
|
||||
<input id="stage-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="stage-title">ชื่อแสดง (ทางเลือก)</label>
|
||||
<input id="stage-title" value={title} onChange={(e) => setTitle(e.target.value)} className={inputCls} placeholder={file?.name ?? "เอกสารจาก e-Court"} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<label className={labelCls} htmlFor="stage-type">กลุ่ม typeId (ทางเลือก)</label>
|
||||
<input id="stage-type" value={typeId} onChange={(e) => setTypeId(e.target.value)} className={inputCls} />
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={busy}
|
||||
className="focus-ring inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover disabled:pointer-events-none disabled:opacity-50"
|
||||
>
|
||||
{busy ? "กำลังส่ง…" : "stage + สร้างลิงก์ทดสอบ"}
|
||||
</button>
|
||||
{file !== null && (
|
||||
<span className="ml-3 text-xs text-ink-faint">
|
||||
{file.name} · {(file.size / 1024 / 1024).toFixed(2)} MB (base64 ~
|
||||
{((file.size * 1.34) / 1024 / 1024).toFixed(0)} MB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<div className="sm:col-span-2 rounded-lg border border-success/30 bg-success/5 p-3 text-sm">
|
||||
<p className="text-success-ink">
|
||||
stage สำเร็จ — fileId <code className="rounded bg-ink/[0.05] px-1 py-0.5 text-[13px] text-ink">{result.fileId}</code>{" "}
|
||||
(ลิงก์อายุ 10 นาที · เอกสารอยู่ใน RAM ตาม TTL หลังแลก session)
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||
<a
|
||||
href={result.link}
|
||||
className="focus-ring inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover"
|
||||
>
|
||||
เปิดลิงก์
|
||||
</a>
|
||||
<span className="break-all text-xs text-ink-soft">{result.link.slice(0, 80)}…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** base64 ต่อเนื่องของไฟล์ทั้งก้อน */
|
||||
function readFileAsBase64(file: File): Promise<string> {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string> {
|
||||
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 () => {
|
||||
|
|
|
|||
|
|
@ -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:<fileId>"`) — ผู้เรียกรู้จัก 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": "<uuid ของเรา>",
|
||||
"externalFileId": "<fileId ของ e-Court>", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null)
|
||||
"externalFileId": "<hash ของ fileId (HMAC ทางเดียว 2026-11-04 — คงเดิมทุกครั้งที่ถามด้วย fid เดียวกัน ใช้เทียบความเหมือนได้อย่างเดียว)>", // null ได้ — เอกสารในกลุ่มที่ไม่มี fileId ของ e-Court (url: fallback ของ N8 / อัปโหลด N4 พร้อมช่องกลุ่ม — mirror กฎ typeId: null)
|
||||
"title": "…",
|
||||
"typeId": "…", // null ได้ (ไม่มีกลุ่ม)
|
||||
"createdAt": "<ISO string>",
|
||||
|
|
@ -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:<href>"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ)
|
||||
- ค้น `fileId`: `where eq(files.externalId, ecourtExternalId(fileId))` (hash — lib/ecourt-fid 2026-11-04) — ไฟล์ที่ลงทะเบียนแบบ fallback `"url:<href>"` (ลิงก์ไม่มี fileId) ค้นด้วยวิธีนี้ไม่ได้ = 404 (ยอมรับ — สัญญาลิงก์ N8 แนะนำให้ส่ง fileId เสมอ)
|
||||
- **REDACTION รวมใน `annotations` เมื่อเลือก include** — ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง (DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง) — ระบุไว้ในสเปกว่า key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น
|
||||
- annotation ทุกสถานะรวมหมด (resolved ดูจาก field `resolved` ได้) ทุกผู้ใช้ (userId/userName อยู่ใน DTO)
|
||||
- route เรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ https://ecourt.chamomind.com/docedit/docedit/note?fileUrl=<URL-encoded URL ไ
|
|||
|
||||
## การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-18)
|
||||
|
||||
1. **สัญญาลิงก์ = `fileUrl` จำเป็น + `fileId` แนะนำ** — มี fileId ใช้เป็น dedupe key (`external_id = "ecourt:<fileId>"`), ไม่มี fallback เป็น `"url:<href>"` · เพิ่ม 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:<href>"` · เพิ่ม 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=<URL-encoded URL ไ
|
|||
- `isAllowedExternalHost(host)` — suffix match: `host === entry || host.endsWith("." + entry)` (กัน `admincourt.go.th.evil.com` และ `xadmincourt.go.th`)
|
||||
- `validateExternalFileUrl(raw)` — `new URL` parse ได้ + `https:` เท่านั้น + ไม่มี `user:pass@` + host ผ่าน allowlist → ไม่ผ่านคืน error code (`invalid_url` / `not_https` / `has_credentials` / `host_not_allowed`)
|
||||
- `titleFromExternalUrl(url, fallbackId?)` — basename ของ pathname แล้ว `decodeURIComponent` (try/catch `URIError`) ตัดนามสกุล cap 200 ตัวอักษร; URL ไม่มีชื่อไฟล์ (path ลงท้าย `/`) → fallback `fallbackId` หรือ `"เอกสารจาก e-Court"`
|
||||
- `externalIdFor(fileId?, url)` — `"ecourt:" + fileId` (trim) หรือ `"url:" + url.href`
|
||||
- ~~`externalIdFor(fileId?, url)`~~ (ถอดแล้ว 2026-11-04) — external_id ของ e-Court สร้างผ่าน `ecourtExternalId(fid)` ของ `lib/ecourt-fid.ts` (hash) ส่วน fallback เป็น `"url:" + url.href` เหมือนเดิม · title fallback ของ `titleFromExternalUrl` เปลี่ยนเป็น `ECOURT_DEFAULT_TITLE` (ตัด fallbackId ที่เคยหยิบ fid มาใส่ title ออก)
|
||||
- `sanitizeNextPath(s)` — ใช้ได้เฉพาะขึ้นต้น `/` และไม่ใช่ `//` / `/\` (กัน open-redirect ตอน login วนกลับ) — ไม่ผ่านคืน `null`
|
||||
- `isHttpUrl(s)` — absolute `http(s)://` หรือไม่ (ใช้ทั้งฝั่ง server เช็ค `pdf_url` เป็นรีโมต และฝั่ง client เลือกทางโหลดรูป)
|
||||
|
||||
|
|
|
|||
|
|
@ -375,3 +375,7 @@ Dependencies เพิ่มเติม: F3 ← F2 · F12/F13/F19 ← F1 · F15
|
|||
- **ส่วนกลางของ route:** `lib/external-api.ts` — `parseQueryBody` (คืน discriminated 2 กรณี: `mode: "file"` มี fileId + typeId อาจมาด้วยสำหรับ AND / `mode: "type"` typeId เดี่ยว) + `loadDocumentBundles(fileRows, parts)` (**bulk** — `inArray` ทุกไฟล์ในชุด query เดียว: annotations/bookmarks คู่ขนาน + replies ตามหลัง = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25) แล้วจัดกลุ่มรายไฟล์ใน memory map เป็น DTO — ใช้ function เดียวกันทุกทาง (เอกสารเดียว = array ยาว 1) ให้รูปร่าง bundle ตรงกันเป๊ะ; route **ครอบ try/catch ทุก DB call** — ล้ม = log ฝั่ง server + 500 JSON ตาม contract (ไม่ปล่อยเป็น 500 ของ framework ที่ format ต่าง)
|
||||
- **ความปลอดภัย:** read-only ทั้งหมด — **ไม่เขียน DB ไม่ publish SSE ไม่ `revalidatePath`** (snapshot ณ ตอนเรียก — คนละ context กับ Server Action ใน invariant #4); ไม่ใส่ CORS headers (server-to-server — browser ไม่ควรเรียน); **REDACTION รวมใน annotations เมื่อเลือก include** (ผู้ถือ key เห็นตำแหน่งกล่องปิดพราง — DB เก็บแค่พิกัด ไม่มีเนื้อหาใต้กล่อง — key นี้เชื่อถือได้ระดับ backend e-Court เท่านั้น); annotation ทุกสถานะ (resolved ดูจาก field) ทุกผู้ใช้; route ทั้งสองเรียก `requireExternalApiKey` เป็นอย่างแรกก่อนแตะ DB
|
||||
- **Out of scope ของ N10:** หลาย key/ตาราง key ใน DB/UI จัดการ key, rate limiting/pagination/caching, write API (สร้าง/แก้ annotation ผ่าน API), ดึง bytes ไฟล์ผ่าน API นี้, webhook/event push, XML/CSV, auth จริงเทียบระบบ e-Court
|
||||
|
||||
### N11 · เอกสาร base64 จาก e-Court ผ่าน RAM staging (2026-11-04 · implement แล้ว)
|
||||
|
||||
> แผนงานฉบับเต็ม: `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:<uuid>.<ext>` (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)
|
||||
|
|
|
|||
|
|
@ -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:<fid>` → เจอ + `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. ไฟล์ที่สร้าง/แต้/ถอด
|
||||
|
||||
|
|
|
|||
165
docs/PLAN-STAGED.md
Normal file
165
docs/PLAN-STAGED.md
Normal file
|
|
@ -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:<hash>` ทั้งหมดที่เคยพิจารณา)
|
||||
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:<uuid>.<ext>`** (มี 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:<hash ของ fid>` ผ่าน `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:<uuid>.<ext>` · `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:<hash ของ fid>` (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:<uuid>.{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:<uuid>.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:<hash>` (ตัดตามการตัดสินใจข้อ 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 ของผู้ดูแลเครื่อง)
|
||||
|
|
@ -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:<fileId>"` / `"url:<href>"`); 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:<href>"`); NULL = ไฟล์ seed/อัปโหลด |
|
||||
| type_id | text | nullable — N9 รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกันจากลิงก์ e-Court `?typeId=` (เขียนที่ `registerExternalFile` ของ N8 เท่านั้น); NULL = ไม่มีกลุ่ม |
|
||||
| created_at | timestamptz `defaultNow()` | |
|
||||
|
||||
|
|
|
|||
38
lib/ecourt-fid.ts
Normal file
38
lib/ecourt-fid.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// dedupe key ของเอกสาร e-Court แบบ hash (ตามคำขอผู้ใช้ 2026-11-04) —
|
||||
// files.external_id เก็บ "ecourt:" + HMAC-SHA256(pepper, fid) แทน fid ดิบ
|
||||
// ไม่ให้ fid ของ e-Court หลุดลง DB (fid บางรูปสั้น/เรียงง่าย sha256 เปล่าจะ
|
||||
// brute force กลับได้ จึงต้องมี pepper)
|
||||
//
|
||||
// หลักการ: hash เกิดฝั่งเราตอนรับ request ทุกครั้ง (stage/D5/N10 ถามด้วย fid ดิบ
|
||||
// แล้วเรา hash ตอน query) — deterministic + pepper เดียวกัน = digest เดิมเสมอ
|
||||
// contract ฝั่ง e-Court ไม่เปลี่ยน · one-way: กู้ fid คืนจาก DB ไม่ได้ (โดยตั้งใจ —
|
||||
// field externalFileId ของ N10 จึงคืนค่า hash)
|
||||
//
|
||||
// ⚠️ env FID_HASH_PEPPER เป็นค่าคงที่ตลอดชีพของข้อมูล — **ห้ามเปลี่ยนหลังมีแถว**
|
||||
// (เปลี่ยน = แถวเก่าค้นไม่เจอ + re-INSERT เกิดแถวคู่เงียบ ๆ) · ห้าม commit ลง repo
|
||||
//
|
||||
// pure module ไม่ import อะไรที่ลาก @/db มาด้วย — unit test (tests/lib/) รันได้
|
||||
// โดยไม่ต้องตั้ง DATABASE_URL (pattern เดียวกับ lib/docver-token.ts)
|
||||
import { createHmac } from "node:crypto";
|
||||
|
||||
export const ECOURT_ID_PREFIX = "ecourt:";
|
||||
|
||||
/** external_id ของเอกสาร e-Court — "ecourt:" + HMAC-SHA256 hex 64 ตัวของ fid
|
||||
* (trim ก่อน hash — caller หลายทางส่ง fid ที่ trim แล้ว จุดเดียวที่ normalize)
|
||||
* · env FID_HASH_PEPPER ไม่ตั้ง/ว่าง = throw (fail-closed: ยอม hash แบบไม่มี
|
||||
* pepper แล้วเติมทีหลัง = fid เดิมได้ 2 key แถวแตก) */
|
||||
export function ecourtExternalId(fid: string): string {
|
||||
const pepper = process.env.FID_HASH_PEPPER?.trim();
|
||||
if (pepper === undefined || pepper === "") {
|
||||
throw new Error(
|
||||
"ตั้งค่า FID_HASH_PEPPER ก่อน (ค่าคงที่ตลอดชีพ — ห้ามเปลี่ยนหลังมีข้อมูล)",
|
||||
);
|
||||
}
|
||||
const digest = createHmac("sha256", pepper).update(fid.trim()).digest("hex");
|
||||
return `${ECOURT_ID_PREFIX}${digest}`;
|
||||
}
|
||||
|
||||
/** แถวนี้เป็นเอกสารของ e-Court ไหม (แยกจากแถว `url:` fallback ของ N8) */
|
||||
export function isEcourtExternalId(externalId: string): boolean {
|
||||
return externalId.startsWith(ECOURT_ID_PREFIX);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -8,9 +8,16 @@ import { eq } from "drizzle-orm";
|
|||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { ecourtExternalId, isEcourtExternalId } from "@/lib/ecourt-fid";
|
||||
import {
|
||||
deleteStagedDoc,
|
||||
putStagedDoc,
|
||||
stagedSourceUrl,
|
||||
type StagedExt,
|
||||
} from "@/lib/staged-docs";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import {
|
||||
externalIdFor,
|
||||
ECOURT_DEFAULT_TITLE,
|
||||
isHttpUrl,
|
||||
normDocumentGroup,
|
||||
titleFromExternalUrl,
|
||||
|
|
@ -84,8 +91,9 @@ export async function registerExternalFile(
|
|||
const checked = validateExternalFileUrl(trimmed);
|
||||
if (!checked.ok) return { ok: false, error: URL_ERROR_TEXT[checked.error] };
|
||||
sourceUrl = checked.url.href;
|
||||
title = titleFromExternalUrl(checked.url, rawFileId);
|
||||
externalId = externalIdFor(rawFileId, checked.url);
|
||||
title = titleFromExternalUrl(checked.url);
|
||||
const id = rawFileId?.trim();
|
||||
externalId = id ? ecourtExternalId(id) : `url:${checked.url.href}`;
|
||||
} else if (trimmed.startsWith("/")) {
|
||||
const local = validateLocalPath(trimmed);
|
||||
if (!local.ok) return local;
|
||||
|
|
@ -94,7 +102,7 @@ export async function registerExternalFile(
|
|||
title =
|
||||
base.slice(0, base.length - local.ext.length).trim().slice(0, 200) || "เอกสาร";
|
||||
const id = rawFileId?.trim();
|
||||
externalId = id ? `ecourt:${id}` : `url:${local.path}`;
|
||||
externalId = id ? ecourtExternalId(id) : `url:${local.path}`;
|
||||
} else {
|
||||
return {
|
||||
ok: false,
|
||||
|
|
@ -102,12 +110,13 @@ export async function registerExternalFile(
|
|||
};
|
||||
}
|
||||
|
||||
// แถว ecourt:<fileId> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO route
|
||||
// (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
|
||||
// แถว ecourt:<hash ของ fid> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO
|
||||
// route (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
|
||||
// พิมพ์เองโดยผู้มี session ใด ๆ เคยใช้เขียน/refresh pdf_url ของเอกสารคนอื่นให้ชี้
|
||||
// PDF ที่ตัวเองเลือก — ตอนนี้เปิดดูของที่รู้จักแล้วได้อย่างเดียว (แถว url:… ของ
|
||||
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง)
|
||||
const ecourtRow = externalId.startsWith("ecourt:");
|
||||
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง) · external_id เก็บ hash ของ fid
|
||||
// ผ่าน lib/ecourt-fid.ts (ไม่มี fid ดิบใน DB — 2026-11-04)
|
||||
const ecourtRow = isEcourtExternalId(externalId);
|
||||
const trusted = opts?.trusted === true;
|
||||
|
||||
const [existing] = await db
|
||||
|
|
@ -158,3 +167,91 @@ export async function registerExternalFile(
|
|||
}
|
||||
return { ok: true, fileId: id };
|
||||
}
|
||||
|
||||
/** N11 (PLAN-STAGED.md D3) — ลงทะเบียนเอกสาร base64 จาก e-Court: bytes เก็บใน RAM
|
||||
* staging (lib/staged-docs.ts) ใต้ id ของ row สุดท้ายที่คืน จึงไม่มีทางเกิด
|
||||
* "row ชี้ sentinel ที่ไม่มี bytes" · ผู้เรียกต้องผ่าน requireDocverApiKey +
|
||||
* magic-byte check (ext มาจากผลตรวจ) มาก่อนแล้ว — trusted by construction
|
||||
* · ลำดับ: resolve id ก่อน → stage ใต้ id นั้น → เขียน DB (ตรวจ cap เกิดก่อนแตะ DB —
|
||||
* DB พังหลัง stage = entry กำพร้าใน RAM ที่ TTL กินเอง ไม่มีอะไรต้องเก็บกวาด)
|
||||
* · insert ชน 23505 (คู่ขนาน fid เดียวกัน) = re-stage ใต้ id ผู้ชนะ + ลบของผู้แพ้
|
||||
* (bytes สุดท้าย = request ที่เสร็จทีหลัง — last-write-wins ตาม semantics
|
||||
* "re-POST = เนื้อล่าสุด") · โยน error ของ DB ต่อให้ route ตอบ 500 · ไม่ publish
|
||||
* SSE / ไม่ revalidatePath (รายการไฟล์ไม่ sync ผ่าน SSE — carve-out invariant #4) */
|
||||
export async function registerStagedFile(
|
||||
fid: string,
|
||||
bytes: Buffer,
|
||||
ext: StagedExt,
|
||||
opts?: { title?: string; typeId?: string },
|
||||
): Promise<{ fileId: string }> {
|
||||
const externalId = ecourtExternalId(fid);
|
||||
// typeId: ส่งมา = refresh ย้ายกลุ่ม, ไม่ส่ง = คงเดิมห้ามเคลียร์ (เกณฑ์เดียวกับ N8)
|
||||
const typeId = normDocumentGroup(opts?.typeId);
|
||||
const title = opts?.title ?? null;
|
||||
|
||||
const [existing] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.externalId, externalId))
|
||||
.limit(1);
|
||||
|
||||
let winnerId: string;
|
||||
// row ปัจจุบันของผู้ชนะ — มีค่าเฉพาะเมื่อเป็น row เดิม (insert ใหม่ครบทุกค่าแล้ว
|
||||
// ไม่ต้อง patch)
|
||||
let current: typeof files.$inferSelect | undefined;
|
||||
if (existing) {
|
||||
winnerId = existing.id;
|
||||
current = existing;
|
||||
putStagedDoc(winnerId, bytes, ext); // replace ช่องเดิม — re-POST = เนื้อล่าสุด
|
||||
} else {
|
||||
const candidateId = randomUUID();
|
||||
putStagedDoc(candidateId, bytes, ext);
|
||||
try {
|
||||
await db.insert(files).values({
|
||||
id: candidateId,
|
||||
title: title ?? ECOURT_DEFAULT_TITLE,
|
||||
pdfUrl: stagedSourceUrl(candidateId, ext),
|
||||
externalId,
|
||||
typeId,
|
||||
});
|
||||
winnerId = candidateId;
|
||||
} catch (err) {
|
||||
if ((err as { code?: string })?.code !== "23505") throw err;
|
||||
// คู่ขนาน fid เดียวกันชนะไปก่อน — หา row ผู้ชนะ แล้ว staging ทับด้วย bytes
|
||||
// ของเรา (last-write-wins) + คืน RAM ของผู้แพ้ — finally ครอบทุกทางออกของ
|
||||
// branch นี้ (select/put รอบสองโยนก็ห้ามทิ้ง candidate ไว้กิน total cap จน
|
||||
// TTL หมด — code review 2026-11-05)
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.externalId, externalId))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
// ผู้ชนะหายไปกลางคัน (แทบเป็นไปไม่ได้) — โยน error 23505 ต่อ (review ข้อ 3)
|
||||
throw err;
|
||||
}
|
||||
putStagedDoc(row.id, bytes, ext);
|
||||
winnerId = row.id;
|
||||
current = row;
|
||||
} finally {
|
||||
deleteStagedDoc(candidateId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (current) {
|
||||
const sentinel = stagedSourceUrl(winnerId, ext);
|
||||
const patch: { pdfUrl?: string; typeId?: string; title?: string } = {};
|
||||
if (current.pdfUrl !== sentinel) patch.pdfUrl = sentinel;
|
||||
if (typeId !== null && current.typeId !== typeId) patch.typeId = typeId;
|
||||
// title ปลดล็อกเฉพาะค่าปัจจุบันยังเป็น default (ECOURT_DEFAULT_TITLE — ไม่ฝัง
|
||||
// fid) — ครั้งแรกที่ส่ง title จริงชนะแล้วล็อก (กันค้างชื่อ default ตลอดชีพเมื่อ
|
||||
// ครั้งแรกลืมส่ง)
|
||||
if (title !== null && current.title === ECOURT_DEFAULT_TITLE) patch.title = title;
|
||||
if (Object.keys(patch).length > 0) {
|
||||
await db.update(files).set(patch).where(eq(files.id, winnerId));
|
||||
}
|
||||
}
|
||||
return { fileId: winnerId };
|
||||
}
|
||||
|
|
|
|||
152
lib/openapi.ts
152
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=<access_token>` (ไม่ต้องมี " +
|
||||
"`fileUrl`) ให้ผู้ใช้คลิกตามเส้นทางเดิม\n\n" +
|
||||
"- auth: header `x-api-key` (env `DOCVER_API_KEY` — ตัวเดียวกับทางเข้า mint)\n" +
|
||||
"- `data`: **base64 มาตรฐานต่อเนื่อง** — ห้าม whitespace/line break และไม่รับ " +
|
||||
"base64url (`-`/`_`)\n" +
|
||||
"- ขนาด: เนื้อไฟล์ ≤ 100MB (base64 ยาว ~134MB) — ระบบตรวจ magic bytes เอง " +
|
||||
"(PDF/JPEG/PNG/TIFF) และใช้ผลตรวจเป็นชนิดไฟล์ ไม่เชื่อค่าที่ประกาศ\n" +
|
||||
"- การ stage ด้วย `fid` เดิมซ้ำ = **replace เนื้อล่าสุด** (row เดิม " +
|
||||
"annotation ครบ — พิกัดอาจคลาดถ้าเลย์เอาต์ขยับ เช่นเดียวกับ N8)\n\n" +
|
||||
"**คู่มือ retry:** `408`/`429`/`5xx` = **ยิงซ้ำได้** (429 ตาม header " +
|
||||
"`Retry-After`, ที่เหลือ backoff) และการ stage ซ้ำ **ปลอดภัย** — `fid` เดิม " +
|
||||
"แทนที่ของเดิม ไม่เกิดรายการซ้ำ ไม่เสียอะไร · `400`/`413` = แก้ request " +
|
||||
"ก่อนยิงใหม่ · อัปโหลดช้าถูกตัดที่ 60 วิว่างเปล่า/10 นาทีรวม (408) — " +
|
||||
"ลองส่งใหม่บนเครือข่ายที่ดีกว่า",
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
required: ["fid", "data"],
|
||||
properties: {
|
||||
fid: {
|
||||
type: "string",
|
||||
description:
|
||||
"รหัสเอกสารของ e-Court (1–200 ตัวอักษร, ห้ามอักขระ & = + %) — ใช้ mint login token " +
|
||||
"ต่อด้วย fid เดิม · ฝั่งระบบนี้เก็บเป็น hash (ไม่มี fid ดิบใน DB) แต่ผู้เรียกยังส่ง " +
|
||||
"fid ดิบมาก่อน hash ทุกเส้น — contract ไม่เปลี่ยน",
|
||||
},
|
||||
data: {
|
||||
type: "string",
|
||||
description:
|
||||
"เนื้อไฟล์ (.pdf/.jpg/.png/.tif) เข้ารหัส base64 มาตรฐานต่อเนื่อง — เนื้อไฟล์ ≤ 100MB",
|
||||
},
|
||||
typeId: {
|
||||
type: "string",
|
||||
description:
|
||||
"รหัสกลุ่มเอกสาร N9 (ทางเลือก) — ส่งมา = ย้ายกลุ่มตามค่าล่าสุด ไม่ส่ง = คงเดิม · " +
|
||||
"⚠️ ของเอกสาร staging รับทางเส้นนี้**ทางเดียว** — typeId ที่แนบมากับ body ของ " +
|
||||
"`POST /api/note/auth/session` (ตอนแลกลิงก์ fid-only) ไม่ถูกใช้กับกลุ่มของไฟล์",
|
||||
},
|
||||
title: {
|
||||
type: "string",
|
||||
description:
|
||||
"ชื่อแสดง (ทางเลือก, ≤ 200 ตัวอักษร) — อัปเดตได้เฉพาะเมื่อชื่อปัจจุบันยังเป็นค่า default " +
|
||||
'"เอกสารจาก e-Court" (ครั้งแรกที่ส่งชื่อจริงชนะแล้วล็อก)',
|
||||
},
|
||||
},
|
||||
},
|
||||
examples: {
|
||||
"stage คำสั่งศาล PDF": {
|
||||
value: {
|
||||
fid: "F123",
|
||||
data: "JVBERi0xLjQK…",
|
||||
title: "คำสั่งศาลที่ 2501/2569",
|
||||
typeId: "GROUP-01",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"ลงทะเบียนสำเร็จ — `fileId` คือ id ของ row เอกสาร (ผู้ใช้เข้าผ่านลิงก์ fid ตามปกติ)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
fileId: {
|
||||
type: "string",
|
||||
description: "uuid ของ row เอกสาร (sentinel staged:<uuid>.<ext> ใน pdf_url)",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
400: {
|
||||
description:
|
||||
"body ผิด (field ที่ไม่รู้จัก / base64 เสีย / magic bytes ไม่ใช่ PDF หรือรูปที่รองรับ / fid มีอักขระ & = + %)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
401: {
|
||||
description:
|
||||
"ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY — ข้อความเดียวกันทุกสาเหตุ)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
408: {
|
||||
description:
|
||||
"อ่าน body ช้าเกินกำหนด (ไม่มี chunk 60 วิ / เกิน 10 นาทีรวม) หรือการเชื่อมต่อขาด — ยิงซ้ำได้",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
413: {
|
||||
description:
|
||||
"ไฟล์ใหญ่เกินขนาดที่รองรับ (เนื้อไฟล์เกิน 100MB / base64 เกิน ~140MB) หรือใหญ่เกินโควตารวม — แก้ไฟล์ก่อนส่งใหม่",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
429: {
|
||||
description:
|
||||
"มีการ stage เอกสารอื่นกำลังดำเนินการ (จำกัด concurrent) — ยิงซ้ำตาม header Retry-After",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/token": {
|
||||
post: {
|
||||
tags: ["documents"],
|
||||
|
|
@ -1033,9 +1173,10 @@ export const openApiSpec = {
|
|||
name: "x-api-key",
|
||||
description:
|
||||
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
|
||||
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
|
||||
"request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " +
|
||||
"(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
|
||||
"DOCVER_API_KEY · ของกลุ่ม Judge's Note (mint login-token + stage เอกสาร base64) " +
|
||||
"= env DOCVER_API_KEY เช่นกัน (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียว " +
|
||||
"แล้วใช้ได้ทุก request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: " +
|
||||
"รับได้ทั้งสองค่า (DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
|
||||
},
|
||||
DocverBearer: {
|
||||
type: "http",
|
||||
|
|
@ -1241,7 +1382,10 @@ export const openApiSpec = {
|
|||
externalFileId: {
|
||||
type: "string",
|
||||
nullable: true,
|
||||
description: "id ของ e-Court (null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)",
|
||||
description:
|
||||
"ค่า hash ของ fileId (HMAC-SHA256 ทางเดียว — แปลงกลับเป็น fid เดิมไม่ได้ " +
|
||||
"2026-11-04: ระบบเก็บ hash แทน fid ดิบ; ค่านี้คงเดิมทุกครั้งที่ถามด้วย fid เดียวกัน " +
|
||||
"จึงใช้เทียบความเหมือนได้อย่างเดียว · null = ไฟล์ url:-fallback หรืออัปโหลดในกลุ่ม)",
|
||||
},
|
||||
title: { type: "string" },
|
||||
typeId: { type: "string", nullable: true },
|
||||
|
|
|
|||
|
|
@ -8,6 +8,12 @@ import path from "node:path";
|
|||
export class PdfPathError extends Error {}
|
||||
|
||||
export function resolvePublicPdfPath(pdfUrl: string): string {
|
||||
// N11: เอกสาร staging (sentinel staged:) ไม่มีไฟล์บนดิสก์ — ปฏิเสธชัด ๆ กัน caller
|
||||
// ในอนาคตลืมแยกทาง (วันนี้ "staged:x.pdf" ผ่าน resolve ตกใน publicDir แล้วตายด้วย
|
||||
// ENOENT เป็น 500 ดิบ — Windows อาจ EINVAL เพราะ colon ในชื่อ)
|
||||
if (pdfUrl.startsWith("staged:")) {
|
||||
throw new PdfPathError("เอกสารนี้ไม่มีไฟล์บนดิสก์ (staging พ้นช่วงจัดเตรียม)");
|
||||
}
|
||||
// statics ของแอปอยู่ใต้ public/note/** (URL /docedit/note/… ของ Next เสิร์ฟจากที่นี่ —
|
||||
// pdf_url ใน DB เป็น logical path ไม่รวม /note เติมตรงนี้ฝั่ง disk คู่กับ withBasePath ฝั่ง browser)
|
||||
const publicDir = path.resolve(process.cwd(), "public", "note");
|
||||
|
|
|
|||
219
lib/staged-docs.ts
Normal file
219
lib/staged-docs.ts
Normal file
|
|
@ -0,0 +1,219 @@
|
|||
// N11 (PLAN-STAGED.md D2) — RAM staging ของเอกสาร base64 จาก e-Court: "แหล่ง bytes
|
||||
// ชั่วคราว" ทดแทน pdf_url ที่ fetch ได้ — annotation/bookmark เก็บถาวรใน DB ปกติ
|
||||
// (row files คือ FK anchor, staging เป็นแค่เนื้อไฟล์)
|
||||
// - เก็บใน Map บน globalThis (pattern เดียวกับ lib/annotate-events.ts/db/index.ts —
|
||||
// Next dev แยก bundle "use server"/route handler แล้ว HMR recompile จะ duplicate
|
||||
// module-level state) · ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียว (ความหมาย
|
||||
// "ไม่เก็บไฟล์" ของแผน)
|
||||
// - TTL sliding จาก lastAccessAt + LRU evict ตาม total cap + lazy sweep ตอน put
|
||||
// (ไม่มี setInterval ค้างใน dev — บริบทเดียวกับ rule ของ annotate-events)
|
||||
// - single-process (ข้อจำกัดเดียวกับ SSE pub/sub — invariant #11)
|
||||
// - pure module: ไม่ import อะไรที่ลาก @/db มาด้วย — unit test รันได้ไม่ต้องตั้ง DATABASE_URL
|
||||
import { isPdfBytes } from "@/lib/document-versioning";
|
||||
|
||||
/** ext ของ sentinel = ผลตรวจ magic bytes ที่ stage route สร้างเอง (pdf/jpg/png/tif) */
|
||||
export const STAGED_EXTS = ["pdf", "jpg", "png", "tif"] as const;
|
||||
export type StagedExt = (typeof STAGED_EXTS)[number];
|
||||
|
||||
const UUID_RE =
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
type StagedEntry = {
|
||||
bytes: Buffer;
|
||||
ext: StagedExt;
|
||||
createdAt: number;
|
||||
lastAccessAt: number;
|
||||
};
|
||||
|
||||
type StagedGlobal = { __stagedDocs?: Map<string, StagedEntry> };
|
||||
const g = globalThis as typeof globalThis & StagedGlobal;
|
||||
const stagedDocs: Map<string, StagedEntry> =
|
||||
g.__stagedDocs ?? (g.__stagedDocs = new Map());
|
||||
|
||||
/** env จำนวนเต็มบวก — ค่าเสีย/ว่าง = ใช้ default (ปรับได้ทุก environment) */
|
||||
function positiveIntEnv(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
if (raw === undefined || raw.trim() === "") return fallback;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n > 0 ? n : fallback;
|
||||
}
|
||||
|
||||
/** TTL แบบ sliding — default 8 ชม. เท่า NOTE_SESSION_TTL ให้ครอบ session ทั้งก้อน
|
||||
* (ระหว่าง annotate ไม่มีการอ่าน bytes การเปิดดูครั้งเดียวจึงต้องไม่หมดกลาง session) */
|
||||
export function stagedDocTtlMs(): number {
|
||||
return positiveIntEnv("STAGED_DOC_TTL_SECONDS", 28_800) * 1000;
|
||||
}
|
||||
|
||||
/** โควตารวมทุกไฟล์ใน RAM — เกิน = LRU evict รายการ lastAccessAt เก่าสุด */
|
||||
export function stagedMaxTotalBytes(): number {
|
||||
return positiveIntEnv("STAGED_MAX_TOTAL_BYTES", 256 * 1024 * 1024);
|
||||
}
|
||||
|
||||
/** ขนาดเนื้อไฟล์ต่อไฟล์ (คู่กับ base64 cap ที่ stage route ใช้ตัดกลาง stream) */
|
||||
export function stagedMaxFileBytes(): number {
|
||||
return positiveIntEnv("STAGED_MAX_FILE_BYTES", 100 * 1024 * 1024);
|
||||
}
|
||||
|
||||
/** ความยาว body (JSON) สูงสุดที่ stage route รับ = base64 ยาวสุดที่ decode แล้ว
|
||||
* ได้ ≤ stagedMaxFileBytes บวก headroom ของ envelope (fid/title/typeId + วงเล็บ) */
|
||||
export function stagedMaxBodyBytes(): number {
|
||||
return 4 * Math.ceil(stagedMaxFileBytes() / 3) + 8192;
|
||||
}
|
||||
|
||||
// config guard: total cap ต่ำกว่าไฟล์เดี่ยวสูงสุด = ไฟล์ใหญ่สุดโดน 413 ตลอด — เตือน
|
||||
// หนึ่งครั้งตอนบูต (ปรับ env หรือตั้งใจใช้กับไฟล์เล็กก็ได้)
|
||||
if (stagedMaxTotalBytes() < stagedMaxFileBytes()) {
|
||||
console.warn(
|
||||
"[staged] STAGED_MAX_TOTAL_BYTES ต่ำกว่า STAGED_MAX_FILE_BYTES — ไฟล์เดี่ยวขนาดใหญ่สุดจะโดนปฏิเสธ (413) เสมอ",
|
||||
);
|
||||
}
|
||||
|
||||
/** sentinel URL ใน files.pdf_url — `staged:<uuid>.<ext>` (ext ติดไปเสมอเพื่อให้
|
||||
* fileKindFromUrl/imageFormatFromUrl ทำงานทุกจุดโดยไม่แก้ — D2/การตัดสินใจข้อ 7) */
|
||||
export function stagedSourceUrl(fileId: string, ext: StagedExt): string {
|
||||
return `staged:${fileId}.${ext}`;
|
||||
}
|
||||
|
||||
/** แกะ sentinel — validate uuid + ext ซ้ำ (ทั้งคู่ server generate เอง การ validate
|
||||
* เป็น defense กัน row ถูกเขียนค้างระหว่างอัปเดตไม่สำเร็จ) */
|
||||
export function parseStagedSource(
|
||||
url: string,
|
||||
): { fileId: string; ext: StagedExt } | null {
|
||||
if (!url.startsWith("staged:")) return null;
|
||||
const rest = url.slice("staged:".length);
|
||||
const dot = rest.lastIndexOf(".");
|
||||
if (dot <= 0) return null;
|
||||
const fileId = rest.slice(0, dot);
|
||||
const ext = rest.slice(dot + 1).toLowerCase();
|
||||
if (!UUID_RE.test(fileId)) return null;
|
||||
if (!(STAGED_EXTS as readonly string[]).includes(ext)) return null;
|
||||
return { fileId, ext: ext as StagedExt };
|
||||
}
|
||||
|
||||
export function isStagedSource(url: string): boolean {
|
||||
return parseStagedSource(url) !== null;
|
||||
}
|
||||
|
||||
/** ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ (D1) — ext ของ sentinel
|
||||
* มาจากผลตรวจนี้ · ไม่รู้จัก = null → route ตอบ 400 */
|
||||
export function detectStagedExt(bytes: Buffer): StagedExt | null {
|
||||
if (isPdfBytes(bytes)) return "pdf";
|
||||
const b = bytes;
|
||||
if (b.length >= 3 && b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) {
|
||||
return "jpg";
|
||||
}
|
||||
if (
|
||||
b.length >= 4 &&
|
||||
b[0] === 0x89 &&
|
||||
b[1] === 0x50 &&
|
||||
b[2] === 0x4e &&
|
||||
b[3] === 0x47
|
||||
) {
|
||||
return "png";
|
||||
}
|
||||
if (
|
||||
b.length >= 4 &&
|
||||
((b[0] === 0x49 && b[1] === 0x49 && b[2] === 0x2a && b[3] === 0x00) || // II*\0 little-endian
|
||||
(b[0] === 0x4d && b[1] === 0x4d && b[2] === 0x00 && b[3] === 0x2a)) // MM\0* big-endian
|
||||
) {
|
||||
return "tif";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** ไฟล์เดี่ยวใหญ่กว่าโควตารวม — stage route แปลงเป็น 413 (ไม่ไล่ evict เพื่อนแล้ว
|
||||
* ก็ยังไม่พอ) */
|
||||
export class StagedCapError extends Error {}
|
||||
|
||||
function totalStagedBytes(): number {
|
||||
let sum = 0;
|
||||
stagedDocs.forEach((e) => {
|
||||
sum += e.bytes.length;
|
||||
});
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** เก็บกวาด entry หมดอายุตอน put — lazy sweep ไม่มี timer (กัน setInterval ค้างใน dev) */
|
||||
function sweepExpiredStagedDocs(now: number): void {
|
||||
const ttl = stagedDocTtlMs();
|
||||
// Array.from แทน for...of บน Map โดยตรง — tsconfig ไม่ได้ตั้ง target ≥ es2015
|
||||
// (pattern เดียวกับ parseVersionUploadForm ของ lib/document-versioning.ts) ·
|
||||
// เก็บเป็น array ก่อนเพราะลบระหว่างเดิน Map ไม่ได้
|
||||
const expired: string[] = [];
|
||||
stagedDocs.forEach((e, id) => {
|
||||
if (now - e.lastAccessAt > ttl) expired.push(id);
|
||||
});
|
||||
for (const id of expired) stagedDocs.delete(id);
|
||||
}
|
||||
|
||||
/** เก็บ/แทนที่ bytes ใต้ fileId — re-POST fid เดิม = replace ช่องเดิม (ไม่งอกใหม่ต่อครั้ง)
|
||||
* · แทรกแล้วเกิน total cap = ไล่ evict รายการ lastAccessAt เก่าสุดจนคืนใต้ cap
|
||||
* (รายการที่เพิ่งใส่ lastAccessAt = now จึงไม่ถูกไล่เอง) */
|
||||
export function putStagedDoc(
|
||||
fileId: string,
|
||||
bytes: Buffer,
|
||||
ext: StagedExt,
|
||||
): void {
|
||||
const now = Date.now();
|
||||
sweepExpiredStagedDocs(now);
|
||||
if (bytes.length > stagedMaxTotalBytes()) {
|
||||
throw new StagedCapError(
|
||||
"ไฟล์ใหญ่เกินโควตารวมของพื้นที่จัดเตรียมเอกสาร (STAGED_MAX_TOTAL_BYTES)",
|
||||
);
|
||||
}
|
||||
stagedDocs.set(fileId, { bytes, ext, createdAt: now, lastAccessAt: now });
|
||||
while (totalStagedBytes() > stagedMaxTotalBytes()) {
|
||||
let oldestId: string | null = null;
|
||||
let oldestAt = Infinity;
|
||||
stagedDocs.forEach((e, id) => {
|
||||
if (e.lastAccessAt < oldestAt) {
|
||||
oldestAt = e.lastAccessAt;
|
||||
oldestId = id;
|
||||
}
|
||||
});
|
||||
if (oldestId === null) break;
|
||||
stagedDocs.delete(oldestId);
|
||||
}
|
||||
}
|
||||
|
||||
/** ผู้อ่าน bytes จริง (pdf route / export) — สำเร็จ = touch ยืด TTL (sliding) แล้วคืน
|
||||
* Buffer · miss ทุกกรณี (ไม่ใช่ sentinel / ไม่มี entry / หมดอายุ / ext ไม่ตรงกับ
|
||||
* entry) = null ให้ caller ตอบ 410 gate · Buffer คืนเป็น reference ของ entry —
|
||||
* ผู้อ่านต้องไม่ mutate (fixPdfActualText/pdf-lib สร้างชุด bytes ใหม่เองตรวจแล้ว) */
|
||||
export function getStagedDoc(url: string): Buffer | null {
|
||||
const parsed = parseStagedSource(url);
|
||||
if (parsed === null) return null;
|
||||
const entry = stagedDocs.get(parsed.fileId);
|
||||
if (entry === undefined) return null;
|
||||
if (Date.now() - entry.lastAccessAt > stagedDocTtlMs()) {
|
||||
stagedDocs.delete(parsed.fileId);
|
||||
return null;
|
||||
}
|
||||
// ext ของ sentinel ไม่ตรง entry (row เขียนค้างระหว่างอัปเดตไม่สำเร็จ) = miss เหมือน
|
||||
// หมดอายุ — ไม่มี sentinel โกหก และไม่ touch entry ของ ext จริง
|
||||
if (entry.ext !== parsed.ext) return null;
|
||||
entry.lastAccessAt = Date.now();
|
||||
return entry.bytes;
|
||||
}
|
||||
|
||||
/** เช็ค "มีอยู่ไหม" อย่างเดียว (expire + ext เทียบชุดเดียวกับ getStagedDoc) โดยไม่
|
||||
* copy ไม่ touch TTL — สำหรับผู้ที่แค่ตัดสินใจไม่ได้อ่าน bytes จริง อย่าง session
|
||||
* route ตอนแลก token (ไม่ยืดอายุ/ถือ Buffer เปล่า — การอ่านจริงเกิดที่ viewer) */
|
||||
export function hasStagedDoc(url: string): boolean {
|
||||
const parsed = parseStagedSource(url);
|
||||
if (parsed === null) return false;
|
||||
const entry = stagedDocs.get(parsed.fileId);
|
||||
if (entry === undefined) return false;
|
||||
if (Date.now() - entry.lastAccessAt > stagedDocTtlMs()) return false;
|
||||
return entry.ext === parsed.ext;
|
||||
}
|
||||
|
||||
/** ลบ entry ตาม fileId — ใช้คืน RAM ของ "ผู้แพ้" ตอน insert ชน 23505 (D3) */
|
||||
export function deleteStagedDoc(fileId: string): void {
|
||||
stagedDocs.delete(fileId);
|
||||
}
|
||||
|
||||
/** เฉพาะ test — ล้าง store ทั้งก้อนระหว่างเคส (Map บน globalThis ชื่นชมข้ามเคส) */
|
||||
export function __clearStagedDocsForTest(): void {
|
||||
stagedDocs.clear();
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
89
scripts/migrate-ecourt-fid-hash.mjs
Normal file
89
scripts/migrate-ecourt-fid-hash.mjs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
// แปลง files.external_id ของเอกสาร e-Court จาก fid ดิบ ("ecourt:<fid>") เป็น 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 ดิบ: "เอกสาร <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();
|
||||
}
|
||||
103
tests/api/note-auth-session-staged.test.ts
Normal file
103
tests/api/note-auth-session-staged.test.ts
Normal file
|
|
@ -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<Response> {
|
||||
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 กดลิงก์ใหม่");
|
||||
});
|
||||
});
|
||||
287
tests/api/note-files-stage.test.ts
Normal file
287
tests/api/note-files-stage.test.ts
Normal file
|
|
@ -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<string, string> = {}): 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<Uint8Array>,
|
||||
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<Uint8Array>; controller: ReadableStreamDefaultController<Uint8Array> | null } {
|
||||
let controller: ReadableStreamDefaultController<Uint8Array> | null = null;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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<Uint8Array> | null = null;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
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<Uint8Array>({
|
||||
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);
|
||||
});
|
||||
});
|
||||
76
tests/helpers/db-mock.ts
Normal file
76
tests/helpers/db-mock.ts
Normal file
|
|
@ -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<Record<string, unknown>>;
|
||||
updates: Array<{ cond: unknown; patch: Record<string, unknown> }>;
|
||||
/** โค้ด 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<DbMockRow[]> => {
|
||||
const rows = state.rowsBySelect[state.selectCalls] ?? state.rows;
|
||||
state.selectCalls += 1;
|
||||
return rows.map((r) => ({ ...r }));
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
insert: () => ({
|
||||
values: async (v: Record<string, unknown>): Promise<void> => {
|
||||
state.insertAttempts.push(v);
|
||||
if (state.insertErrCode !== null) {
|
||||
throw Object.assign(new Error("db error (mock)"), {
|
||||
code: state.insertErrCode,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
update: () => ({
|
||||
set: (patch: Record<string, unknown>) => ({
|
||||
where: async (cond: unknown): Promise<void> => {
|
||||
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;
|
||||
}
|
||||
57
tests/lib/ecourt-fid.test.ts
Normal file
57
tests/lib/ecourt-fid.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
165
tests/lib/external-import-staged.test.ts
Normal file
165
tests/lib/external-import-staged.test.ts
Normal file
|
|
@ -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> = {}): 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();
|
||||
});
|
||||
});
|
||||
21
tests/lib/server-pdf.test.ts
Normal file
21
tests/lib/server-pdf.test.ts
Normal file
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
153
tests/lib/staged-docs.test.ts
Normal file
153
tests/lib/staged-docs.test.ts
Normal file
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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"]) {
|
||||
|
|
|
|||
|
|
@ -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:<fileId>" เมื่อมี id ของ e-Court,
|
||||
* ไม่มี fallback เป็น "url:<href>" (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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue