feat(docver): derive docId from fid claim and drop [docId] URL segment
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
aad6f155fe
commit
c2746f0af9
10 changed files with 122 additions and 158 deletions
|
|
@ -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=<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): 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/<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 (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:<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/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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 <token>` กับทุก 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>): 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 } };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import {
|
|||
docverError,
|
||||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseDocId,
|
||||
parseDownloadFormat,
|
||||
parseFieldsJson,
|
||||
parseVersionIdParam,
|
||||
|
|
@ -23,28 +22,6 @@ function ok<T>(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 ");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 ของไฟล์จริง
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue