From 0b2c9e6343cbf5c4f9bcbf1215bb7f6ca0a3a229 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Mon, 26 Oct 2026 10:05:01 +0700 Subject: [PATCH] feat(docver): derive docId from fid claim and drop [docId] URL segment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docId of every docver operation now comes from the Bearer token: the fid claim (e-Court fileId) is used directly as docId; segment [docId] removed from URLs — POST/GET /api/documents/versions and GET /api/documents/versions/{versionId}/download-link - fid is required and must be a string (trim 1-200): token issuance rejects missing/blank/non-string fid with 400, verify rejects fid-less tokens with 401 - parseDocId removed from lib/document-versioning.ts (dead code, no callers) - openapi spec: paths renamed, docId path params removed, fid required + string - PLAN-DOCVER.md / CLAUDE.md / wordaddin versioningService comment updated - tests: fid required/string cases added, parseDocId describe removed (256 pass) Co-Authored-By: Claude Code --- CLAUDE.md | 8 +- .../[versionId]/download-link/route.ts | 13 ++-- .../documents/{[docId] => }/versions/route.ts | 23 +++--- docs/PLAN-DOCVER.md | 35 +++++---- lib/document-versioning.ts | 22 +----- lib/docver-token.ts | 20 +++-- lib/openapi.ts | 74 +++++++------------ tests/lib/document-versioning.test.ts | 23 ------ tests/lib/docver-token.test.ts | 55 +++++++++----- wordaddin/services/versioningService.ts | 7 +- 10 files changed, 122 insertions(+), 158 deletions(-) rename app/api/documents/{[docId] => }/versions/[versionId]/download-link/route.ts (76%) rename app/api/documents/{[docId] => }/versions/route.ts (91%) diff --git a/CLAUDE.md b/CLAUDE.md index 20c6558..f5ccb40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ 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 ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ 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 แกะจาก 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 ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ 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 จะทำให้ความหนาเส้นบิดเบี้ยว) @@ -50,8 +50,8 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย -lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseDocId (charset [A-Za-z0-9._:-] 1–200), parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม -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 ดิบ) + 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 เก็บเป็น claim ยังไม่ถูก operation ใดอ่าน) +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 + fields JSON ≤100 keys + 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): 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-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth) @@ -83,7 +83,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/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 + [docId]/versions/route.ts + [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): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า 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/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-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า 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 ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) 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) diff --git a/app/api/documents/[docId]/versions/[versionId]/download-link/route.ts b/app/api/documents/versions/[versionId]/download-link/route.ts similarity index 76% rename from app/api/documents/[docId]/versions/[versionId]/download-link/route.ts rename to app/api/documents/versions/[versionId]/download-link/route.ts index b35fd0a..b97ce00 100644 --- a/app/api/documents/[docId]/versions/[versionId]/download-link/route.ts +++ b/app/api/documents/versions/[versionId]/download-link/route.ts @@ -1,10 +1,9 @@ -// PLAN-DOCVER.md §1 — GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf +// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf // คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง -// ของผู้ใช้ — เช็คทั้ง docId และ versionId พร้อมกันกันโยน id ข้ามเอกสาร +// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร // auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม) import { docverError, - parseDocId, parseDownloadFormat, parseVersionIdParam, } from "@/lib/document-versioning"; @@ -16,13 +15,13 @@ export const dynamic = "force-dynamic"; export async function GET( request: Request, - { params }: { params: { docId: string; versionId: string } }, + { params }: { params: { versionId: string } }, ) { const auth = requireDocverBearer(request); if (!auth.ok) return auth.response; - const docId = parseDocId(params.docId); - if (!docId.ok) return docverError(docId.error, 400); + // docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify + const docId = auth.claims.fid; const versionId = parseVersionIdParam(params.versionId); if (!versionId.ok) return docverError(versionId.error, 400); let format: string | null = null; @@ -35,7 +34,7 @@ export async function GET( if (!parsedFormat.ok) return docverError(parsedFormat.error, 400); try { - const row = await findVersionForDoc(docId.docId, versionId.versionId); + const row = await findVersionForDoc(docId, versionId.versionId); if (!row) { return docverError("ไม่พบ version นี้ของเอกสาร", 404); } diff --git a/app/api/documents/[docId]/versions/route.ts b/app/api/documents/versions/route.ts similarity index 91% rename from app/api/documents/[docId]/versions/route.ts rename to app/api/documents/versions/route.ts index f2e8edb..79dcc2b 100644 --- a/app/api/documents/[docId]/versions/route.ts +++ b/app/api/documents/versions/route.ts @@ -1,8 +1,9 @@ -// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/{docId}/versions +// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions // กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่ // POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ -// Authorization: Bearer ทุก request — identity (userid/username) และ templateId -// แกะจาก claims ของ token ไม่รับจาก multipart form อีก (lib/docver-token.ts) +// Authorization: Bearer ทุก request — identity (userid/username), templateId และ +// docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด +// ไม่รับจาก URL/form อีก (lib/docver-token.ts) import { createHash, randomUUID } from "node:crypto"; import { toDocumentVersionDTO } from "@/db/schema"; @@ -12,7 +13,6 @@ import { DOCX_CONTENT_TYPE, isDocxBytes, isPdfBytes, - parseDocId, parseVersionUploadForm, sanitizeFilePartName, } from "@/lib/document-versioning"; @@ -34,12 +34,12 @@ export const dynamic = "force-dynamic"; * putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4) * response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative * จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */ -export async function POST(request: Request, { params }: { params: { docId: string } }) { +export async function POST(request: Request) { const auth = requireDocverBearer(request); if (!auth.ok) return auth.response; - const docId = parseDocId(params.docId); - if (!docId.ok) return docverError(docId.error, 400); + // docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify + const docId = auth.claims.fid; let form: FormData; try { @@ -115,7 +115,7 @@ export async function POST(request: Request, { params }: { params: { docId: stri try { row = await insertVersionRecord({ versionId, - docId: docId.docId, + docId, clientVersionId: parsed.form.clientVersionId, contentHash, message: parsed.form.message, @@ -148,15 +148,14 @@ export async function POST(request: Request, { params }: { params: { docId: stri /** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404 * (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */ -export async function GET(request: Request, { params }: { params: { docId: string } }) { +export async function GET(request: Request) { const auth = requireDocverBearer(request); if (!auth.ok) return auth.response; - const docId = parseDocId(params.docId); - if (!docId.ok) return docverError(docId.error, 400); + const docId = auth.claims.fid; try { - const doc = await findDocumentByDocId(docId.docId); + const doc = await findDocumentByDocId(docId); if (!doc) { return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404); } diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index b5f9e5c..fff1526 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -8,13 +8,15 @@ ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน `wordaddin/services/versioningService.ts`): -- รับ `docId` จากผู้เรียก → ตารางเอกสารที่ `docId` **ห้ามซ้ำ** (unique index) +- ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid` + (string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก + endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร) - แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน response) - เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด -- **identity (`userId`/`userName`/`templateId`) แกะจาก Bearer token** (2026-10-22 — เดิมส่ง - plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header +- **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22 + — เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header `x-api-key` แล้วแนบ `Authorization: Bearer ` กับทุก request — ระบบ stand-alone ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น พาหะ identity เดียว @@ -45,7 +47,7 @@ JSON body (allowlist — key อื่น = 400 กัน typo): |---|---|---| | `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) | | `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) | -| `fid` | integer | optional — **fileId ของ e-Court** เก็บเป็น claim อย่างเดียว ยังไม่มี operation ใดอ่าน/บังคับ (ตารางของกลุ่มนี้คีย์ด้วย `docId` ไม่มี column fileId) | +| `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid | | `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) | - env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า" @@ -57,10 +59,10 @@ JSON body (allowlist — key อื่น = 400 กัน typo): ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ) -### `POST /api/documents/{docId}/versions` — อัปโหลด version ใหม่ +### `POST /api/documents/versions` — อัปโหลด version ใหม่ -auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่ -มาจาก claims ของ token ไม่รับจาก form อีกต่อไป +auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ +`docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป multipart/form-data: @@ -77,12 +79,12 @@ multipart/form-data: **breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว (ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน - เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint - "สร้างเอกสาร" แยก (ตาม flow ของ add-in ที่ generate documentId เองแล้วอัปโหลดทันที) + "สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token) - response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }` (= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server** (client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน) -### `GET /api/documents/{docId}/versions` — history +### `GET /api/documents/versions` — history (ของเอกสาร fid ใน token) response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า** (ตาม `VersionRecord[]` ของ add-in + ส่วนเสริม `createdByUserName`): @@ -101,13 +103,14 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า } ``` -- `docId` ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ N10 — UI ของ - add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) +- `docId` (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ + N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) -### `GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf` +### `GET /api/documents/versions/{versionId}/download-link?format=docx|pdf` - format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400 -- query version row **เช็คทั้ง docId และ versionId พร้อมกัน** (กันโยน id ข้ามเอกสาร) — ไม่เจอ = 404 +- query version row **เช็ค docId (จาก fid ใน token) กับ versionId พร้อมกัน** (กันโยน id + ข้ามเอกสาร) — ไม่เจอ = 404 - `format=pdf` แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF" - response 200: `{ "url": string, "expiresAt": string }` — url เป็น **MinIO presigned GET** (อายุตาม env `DOCVER_LINK_TTL_SECONDS` default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ @@ -119,7 +122,7 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า | เหตุ | สถานะ | |---|---| -| รูป docId/versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา | 400 | +| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | | `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 | | ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 | | เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 | @@ -206,8 +209,8 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET - `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) + `ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers - `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key) -- `app/api/documents/[docId]/versions/route.ts` — POST + GET -- `app/api/documents/[docId]/versions/[versionId]/download-link/route.ts` — GET +- `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token) +- `app/api/documents/versions/[versionId]/download-link/route.ts` — GET - `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio (โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io) - `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index ebb55fe..bbbb054 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -17,9 +17,9 @@ export const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; export const PDF_CONTENT_TYPE = "application/pdf"; -/** รหัสเอกสารจากผู้เรียก — URL-path-safe ล้วน (ไม่มี / .. ช่องว่าง ไทย) กันถูก - * ฝังลง path/log แปลก ๆ และตัดขนาดให้จำกัด */ -const DOC_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/; +/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token + * (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param + * ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; @@ -46,22 +46,6 @@ export function docverError(message: string, status: number): Response { }); } -/** ตรวจ docId จาก path param — trim แล้วว่าง/เกิน 200/มีตัวอักษรนอก charset = ไม่ผ่าน - * (+ ปฏิเสธ "." / ".." เป็นพิเศษแม้อยู่ใน charset — path-traversal specials) */ -export function parseDocId(raw: string): - | { ok: true; docId: string } - | DocverError { - const docId = raw.trim(); - if (docId === "." || docId === ".." || !DOC_ID_RE.test(docId)) { - return { - ok: false, - error: - "รหัสเอกสาร (docId) ต้องยาว 1–200 ตัวอักษร และใช้ได้เฉพาะ A-Z a-z 0-9 . _ : -", - }; - } - return { ok: true, docId }; -} - /** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ * matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */ export function isDocxBytes(bytes: Buffer): boolean { diff --git a/lib/docver-token.ts b/lib/docver-token.ts index 2b48bb3..2f58ae7 100644 --- a/lib/docver-token.ts +++ b/lib/docver-token.ts @@ -6,8 +6,8 @@ // - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน — // ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer — // identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload -// - claims "fid" = fileId ของ e-Court — ตาราง docver คีย์ด้วย docId (ไม่มีคอลัมน์ -// fileId ตัวเลข) จึงเก็บเป็น claim ไว้ก่อน ยังไม่มี operation ใดอ่าน/บังคับ +// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ +// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid // // ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้ // โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts) @@ -19,7 +19,7 @@ import { docverError, type DocverError } from "@/lib/document-versioning"; export type DocverTokenClaims = { userid: string; username: string; - fid: number | null; + fid: string; template: string | null; }; @@ -68,7 +68,7 @@ function claimsFromPayload(payload: Record): DocverTokenClaims const { userid, username, fid, template } = payload; if (typeof userid !== "string" || userid === "") return null; if (typeof username !== "string" || username === "") return null; - if (fid !== null && !(typeof fid === "number" && Number.isInteger(fid))) return null; + if (typeof fid !== "string" || fid === "") return null; if (template !== null && typeof template !== "string") return null; return { userid, username, fid, template }; } @@ -190,9 +190,13 @@ export function parseTokenRequestBody(raw: unknown): return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" }; } - const rawFid = record.fid ?? null; - if (rawFid !== null && !(typeof rawFid === "number" && Number.isInteger(rawFid))) { - return { ok: false, error: "fid ต้องเป็นตัวเลขจำนวนเต็ม" }; + 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 ตัวอักษร" }; } const rawTemplate = record.template ?? null; @@ -204,5 +208,5 @@ export function parseTokenRequestBody(raw: unknown): return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" }; } - return { ok: true, claims: { userid, username, fid: rawFid, template } }; + return { ok: true, claims: { userid, username, fid, template } }; } diff --git a/lib/openapi.ts b/lib/openapi.ts index d277d38..a21749e 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -42,7 +42,8 @@ export const openApiSpec = { "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " + "ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " + "DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " + - "แกะจาก claims ของ token ไม่รับใน multipart form อีก", + "และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " + + "ไม่รับจาก URL/multipart form อีก", }, { name: "Judge's Note", @@ -262,8 +263,9 @@ export const openApiSpec = { description: "ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " + "JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " + - "DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName) " + - "และ templateId ตอนอัปโหลด version แกะจาก claims ของ token นี้", + "DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " + + "templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " + + "fid จึงบังคับ", security: [{ ApiKeyAuth: [] }], requestBody: { required: true, @@ -275,12 +277,12 @@ export const openApiSpec = { value: { userid: "123", username: "Sorawit Bholsithi", - fid: 3, + fid: "3", template: "T12", }, }, - "ขั้นต่ำ (fid/template เป็นทางเลือก)": { - value: { userid: "123", username: "Sorawit Bholsithi" }, + "ขั้นต่ำ (template เป็นทางเลือก)": { + value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" }, }, }, }, @@ -308,27 +310,18 @@ export const openApiSpec = { }, }, }, - "/api/documents/{docId}/versions": { + "/api/documents/versions": { post: { tags: ["documents"], summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)", description: - "สร้างเอกสารอัตโนมัติเมื่อ docId ยังไม่เคยมี (docId ห้ามซ้ำ) แล้วเก็บไฟล์ " + - ".docx (+ .pdf ถ้าแนบมา) ลง MinIO พร้อมผู้อัปโหลด — versionNumber ที่ server จ่าย " + - "คือค่าจริงเสมอ (client ที่เลขไม่ตรงต้องแก้ XML part ของตัวเอง) · pdf เป็น " + - "optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF (ดู hasPdf ใน response) · " + - "ผู้อัปโหลด (userId/userName) และ templateId แกะจาก Bearer token ไม่รับใน form " + - "(ส่ง field เหล่านั้นมา = 400)", + "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — สร้างเอกสาร " + + "อัตโนมัติเมื่อ docId ยังไม่เคยมี แล้วเก็บไฟล์ .docx (+ .pdf ถ้าแนบมา) ลง MinIO " + + "พร้อมผู้อัปโหลด — versionNumber ที่ server จ่ายคือค่าจริงเสมอ (client ที่เลขไม่ตรง " + + "ต้องแก้ XML part ของตัวเอง) · pdf เป็น optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มี " + + "สำเนา PDF (ดู hasPdf ใน response) · ผู้อัปโหลด (userId/userName) และ templateId " + + "แกะจาก Bearer token ไม่รับใน form (ส่ง field เหล่านั้นมา = 400)", security: [{ DocverBearer: [] }], - parameters: [ - { - name: "docId", - in: "path", - required: true, - schema: { type: "string" }, - description: "รหัสเอกสารจากผู้เรียก (add-in GUID / รหัส e-Court) — [A-Za-z0-9._:-]", - }, - ], requestBody: { required: true, content: { @@ -397,18 +390,10 @@ export const openApiSpec = { tags: ["documents"], summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)", description: - "version ทุกตัวของ docId นี้ พร้อมผู้อัปโหลด (userId/userName), เวลา, commit " + - "message, ค่า fields และ hasPdf — ใช้เขียน UI version history ของ add-in", + "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — version ทุกตัว " + + "ของเอกสารนั้น พร้อมผู้อัปโหลด (userId/userName), เวลา, commit message, ค่า fields " + + "และ hasPdf — ใช้เขียน UI version history ของ add-in", security: [{ DocverBearer: [] }], - parameters: [ - { - name: "docId", - in: "path", - required: true, - schema: { type: "string" }, - description: "รหัสเอกสารจากผู้เรียก — [A-Za-z0-9._:-]", - }, - ], responses: { 200: { description: "รายการ version เรียงใหม่ → เก่า", @@ -435,23 +420,17 @@ export const openApiSpec = { }, }, }, - "/api/documents/{docId}/versions/{versionId}/download-link": { + "/api/documents/versions/{versionId}/download-link": { get: { tags: ["documents"], summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)", description: - "คืน URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดใน " + - "เบราว์เซอร์/Word ของผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ " + - "ผู้ใช้เข้าถึงได้ · ขอ pdf ใน version ที่ไม่มีสำเนา = 404", + "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) · คืน URL อายุสั้น " + + "(env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดในเบราว์เซอร์/Word ของ " + + "ผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ผู้ใช้เข้าถึงได้ · ขอ pdf " + + "ใน version ที่ไม่มีสำเนา = 404", security: [{ DocverBearer: [] }], parameters: [ - { - name: "docId", - in: "path", - required: true, - schema: { type: "string" }, - description: "รหัสเอกสาร (ต้องตรงกับ version นี้ — โยน id ข้ามเอกสารไม่ได้)", - }, { name: "versionId", in: "path", @@ -552,15 +531,14 @@ export const openApiSpec = { TokenRequest: { type: "object", description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400", - required: ["userid", "username"], + required: ["userid", "username", "fid"], properties: { userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" }, username: { type: "string", description: "ชื่อผู้ใช้ (บังคับ, ≤ 200 ตัวอักษร)" }, fid: { - type: "integer", - nullable: true, + type: "string", description: - "fileId ของ e-Court — เก็บเป็น claim ไว้ก่อน ยังไม่ถูก operation ของ docver อ่าน", + "fileId ของ e-Court (บังคับ, ≤ 200 ตัวอักษร) — เป็น docId ของทุก operation ตรง ๆ", }, template: { type: "string", diff --git a/tests/lib/document-versioning.test.ts b/tests/lib/document-versioning.test.ts index 7a4182d..da18b93 100644 --- a/tests/lib/document-versioning.test.ts +++ b/tests/lib/document-versioning.test.ts @@ -6,7 +6,6 @@ import { docverError, isDocxBytes, isPdfBytes, - parseDocId, parseDownloadFormat, parseFieldsJson, parseVersionIdParam, @@ -23,28 +22,6 @@ function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T expect(result.ok).toBe(true); } -describe("parseDocId", () => { - it("รับ GUID / รหัส e-Court / charset ที่อนุญาตทั้งหมด และ trim รอบ ๆ", () => { - for (const raw of [ - "123e4567-e89b-12d3-a456-426614174000", - "ecourt:123", - "DOC_2026.10", - " doc1 ", - "a".repeat(200), - ]) { - const result = parseDocId(raw); - ok(result); - expect(result.docId).toBe(raw.trim()); - } - }); - - it("ตกเมื่อว่าง / เกิน 200 / มีช่องว่างกลาง / ไทย / slash / '..'", () => { - for (const raw of ["", " ", "a".repeat(201), "doc 1", "เอกสาร1", "a/b", "..", "a\\b"]) { - expect(parseDocId(raw).ok).toBe(false); - } - }); -}); - describe("parseVersionIdParam", () => { it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => { const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF "); diff --git a/tests/lib/docver-token.test.ts b/tests/lib/docver-token.test.ts index 98ff74a..6b2a5ab 100644 --- a/tests/lib/docver-token.test.ts +++ b/tests/lib/docver-token.test.ts @@ -19,7 +19,7 @@ const SECRET = "test-secret-docver"; const CLAIMS: DocverTokenClaims = { userid: "123", username: "Sorawit Bholsithi", - fid: 3, + fid: "3", template: "T12", }; @@ -76,13 +76,21 @@ describe("issueDocverToken / verifyDocverToken", () => { expect(verified.claims).toEqual(CLAIMS); }); - it("claims ทางเลือก null ทั้งคู่ (fid/template ไม่ส่ง) ก็ roundtrip ได้", () => { + it("template เป็นทางเลือก (null ได้) แต่ fid บังคับ — token ไม่มี fid = ไม่ผ่าน", () => { const verified = verifyDocverToken( - issueDocverToken({ userid: "u1", username: "ชื่อ", fid: null, template: null }, SECRET, 60), + issueDocverToken({ userid: "u1", username: "ชื่อ", fid: "7", template: null }, SECRET, 60), SECRET, ); ok(verified); - expect(verified.claims).toEqual({ userid: "u1", username: "ชื่อ", fid: null, template: null }); + expect(verified.claims).toEqual({ userid: "u1", username: "ชื่อ", fid: "7", template: null }); + const noFid = signPayload({ + userid: "u1", + username: "n", + template: "T1", + iat: 0, + exp: Math.floor(Date.now() / 1000) + 60, + }); + expect(verifyDocverToken(noFid, SECRET).ok).toBe(false); }); it("แก้ payload แล้วใช้ signature เดิม (tampered) หรือ secret ผิด = ไม่ผ่าน", () => { @@ -119,10 +127,13 @@ describe("issueDocverToken / verifyDocverToken", () => { it("claims ไม่ครบรูปร่าง (แม้เซ็นถูก) = ไม่ผ่าน — กัน token เซ็นเองหลุดไปใช้", () => { const now = Math.floor(Date.now() / 1000) + 60; for (const bad of [ - { username: "n", fid: null, template: null, iat: 0, exp: now }, // ไม่มี userid - { userid: "u", fid: null, template: null, iat: 0, exp: now }, // ไม่มี username - { userid: "", username: "n", fid: null, template: null, iat: 0, exp: now }, // userid ว่าง - { userid: "u", username: "n", fid: "3", template: null, iat: 0, exp: now }, // fid เป็น string + { username: "n", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี userid + { userid: "u", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี username + { userid: "", username: "n", fid: "3", template: null, iat: 0, exp: now }, // userid ว่าง + { userid: "u", username: "n", fid: 3, template: null, iat: 0, exp: now }, // fid เป็น number + { userid: "u", username: "n", fid: "", template: null, iat: 0, exp: now }, // fid ว่าง + { userid: "u", username: "n", template: null, iat: 0, exp: now }, // ไม่มี fid (บังคับแล้ว) + { userid: "u", username: "n", fid: null, template: null, iat: 0, exp: now }, // fid null ]) { expect(verifyDocverToken(signPayload(bad), SECRET).ok).toBe(false); } @@ -224,23 +235,23 @@ describe("requireDocverBearer", () => { }); describe("parseTokenRequestBody", () => { - it("ครบทุก field = ได้ claims (trim ค่า) · fid/template ไม่ส่ง = null", () => { + it("ครบทุก field = ได้ claims (trim ค่า) · template ไม่ส่ง = null (fid บังคับ)", () => { const full = parseTokenRequestBody({ userid: " 123 ", username: " Sorawit Bholsithi ", - fid: 3, + fid: " 3 ", template: " T12 ", }); ok(full); - expect(full.claims).toEqual({ userid: "123", username: "Sorawit Bholsithi", fid: 3, template: "T12" }); + expect(full.claims).toEqual({ userid: "123", username: "Sorawit Bholsithi", fid: "3", template: "T12" }); - const minimal = parseTokenRequestBody({ userid: "123", username: "Sorawit" }); + const minimal = parseTokenRequestBody({ userid: "123", username: "Sorawit", fid: "3" }); ok(minimal); - expect(minimal.claims).toEqual({ userid: "123", username: "Sorawit", fid: null, template: null }); + expect(minimal.claims).toEqual({ userid: "123", username: "Sorawit", fid: "3", template: null }); }); it("template ส่งมาเป็นช่องว่าง = null (ถือว่าไม่ส่ง)", () => { - const result = parseTokenRequestBody({ userid: "123", username: "n", template: " " }); + const result = parseTokenRequestBody({ userid: "123", username: "n", fid: "3", template: " " }); ok(result); expect(result.claims.template).toBeNull(); }); @@ -275,21 +286,29 @@ describe("parseTokenRequestBody", () => { } }); - it("fid ไม่ใช่ integer (string / ทศนิยม) หรือ template ไม่ใช่ string = 400", () => { + it("fid ขาด/null/ว่าง/ไม่ใช่ string (number) หรือ template ไม่ใช่ string = 400", () => { + const missing = parseTokenRequestBody({ userid: "1", username: "n" }); + expect(missing.ok).toBe(false); + if (!missing.ok) expect(missing.error).toContain("fid"); for (const raw of [ - { userid: "1", username: "n", fid: "3" }, - { userid: "1", username: "n", fid: 3.5 }, + { userid: "1", username: "n", fid: null }, + { userid: "1", username: "n", fid: 3 }, + { userid: "1", username: "n", fid: "" }, + { userid: "1", username: "n", fid: " " }, { userid: "1", username: "n", template: 12 }, ]) { expect(parseTokenRequestBody(raw).ok).toBe(false); } }); - it("ความยาวเกิน cap: userid > 100 / username > 200 / template > 100 = 400", () => { + it("ความยาวเกิน cap: userid > 100 / username > 200 / fid > 200 / template > 100 = 400", () => { expect(parseTokenRequestBody({ userid: "a".repeat(101), username: "n" }).ok).toBe(false); expect( parseTokenRequestBody({ userid: "1", username: "a".repeat(201) }).ok, ).toBe(false); + expect( + parseTokenRequestBody({ userid: "1", username: "n", fid: "a".repeat(201) }).ok, + ).toBe(false); expect( parseTokenRequestBody({ userid: "1", username: "n", template: "a".repeat(101) }).ok, ).toBe(false); diff --git a/wordaddin/services/versioningService.ts b/wordaddin/services/versioningService.ts index d3576fc..506130a 100644 --- a/wordaddin/services/versioningService.ts +++ b/wordaddin/services/versioningService.ts @@ -6,9 +6,10 @@ * วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย * - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion * - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ - * POST /api/documents/{docId}/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400): - * server แกะ identity/template จาก Bearer token (ออกที่ POST /api/documents/token - * ด้วย x-api-key) ที่ apiClient.ts แนบให้อยู่แล้ว — templateId เด้งเป็น claim "template" + * POST /api/documents/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400) และไม่มี + * segment {docId} ใน URL อีกต่อไป: server แกะ identity/template และ docId + * (= fid — fileId ของ e-Court, string) จาก Bearer token (ออกที่ POST + * /api/documents/token ด้วย x-api-key, fid บังคับ) ที่ apiClient.ts แนบให้อยู่แล้ว * - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) — * ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata * การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง