From cdae1ceedd7031372ad5ac6adde2451d8a8ea9c2 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Sat, 31 Oct 2026 10:21:17 +0700 Subject: [PATCH 1/3] Refactor document versioning to extract fields from uploaded .docx files on the server side - Removed the `fields` parameter from the version upload form, as it is now extracted from the uploaded .docx file using the `extractContentControls` function. - Updated related types and interfaces to reflect the new structure of version fields. - Modified the OpenAPI specification to document the changes in the versioning process. - Adjusted tests to ensure compliance with the new behavior, including validation for the absence of the `fields` parameter. - Implemented a new function `extractDocumentSnapshot` to handle the extraction of content controls and document properties from .docx files. - Updated metadata and versioning services to align with the new extraction logic. --- CLAUDE.md | 6 +- app/api/documents/versions/route.ts | 23 ++++- app/api/template/extract/route.ts | 36 ++------ db/schema.ts | 19 +++- docs/PLAN-DOCVER.md | 29 ++++++- lib/document-versioning.ts | 53 ++---------- lib/docver-store.ts | 4 +- lib/lib-tmpl.ts | 67 +++++++++++++- lib/openapi.ts | 53 +++++++++--- scripts/make-ticket-docx.mjs | 18 ++-- tests/lib/document-versioning.test.ts | 53 ++---------- tests/lib/lib-tmpl.test.ts | 101 ++++++++++++++++++++++ tests/wordaddin/versioningService.test.ts | 13 ++- wordaddin/models/versionRecord.ts | 39 ++++++++- wordaddin/services/metadataService.ts | 7 +- wordaddin/services/versionFlow.ts | 21 ++--- wordaddin/services/versioningService.ts | 17 ++-- 17 files changed, 380 insertions(+), 179 deletions(-) create mode 100644 tests/lib/lib-tmpl.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 38926f8..0bb872c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,14 +52,14 @@ 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): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + 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/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts · **field `fields` ก็ถอดออก 2026-10-30** server แกะจากไฟล์เองที่ route ผ่าน lib/lib-tmpl.ts + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400) เช่นกันกับ `fields` (contract 2026-10-30 — server แกะจากไฟล์เองเก็บทั้งก้อนเหมือน return ของ `/api/template/extract` `{cc, bookmark, properties, verify}`, versionFlow จึงไม่มีขั้นอ่าน CC ฝั่ง client แล้ว)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -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 + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+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-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+message/clientVersionId/fileName (**form field `fields` ยกเลิก 2026-10-30 ส่งมา = 400 — fields ของ version แกะจากไฟล์ .docx ฝั่ง server เก็บทั้งก้อนเหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` ผ่าน `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket ฝั่ง server ผ่าน `addinTicketSecret()` · เห็น CC ทุกชนิดเสมอไม่ขึ้นกับ WordApi ของ host แบบตอน add-in อ่านเอง; แกะไม่ได้ = 400 · row เก่าก่อน 2026-10-30 เก็บ map ดิบ — `FieldsSnapshot`/`readSnapshotField` ของ add-in แยกรูปเอง) → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/note/page.tsx ← รายการไฟล์จัด 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/versions/route.ts b/app/api/documents/versions/route.ts index 79dcc2b..2a5248d 100644 --- a/app/api/documents/versions/route.ts +++ b/app/api/documents/versions/route.ts @@ -6,7 +6,8 @@ // ไม่รับจาก URL/form อีก (lib/docver-token.ts) import { createHash, randomUUID } from "node:crypto"; -import { toDocumentVersionDTO } from "@/db/schema"; +import { toDocumentVersionDTO, type DocumentVersionFields } from "@/db/schema"; +import { addinTicketSecret } from "@/lib/addin-ticket"; import { docverError, DOCVER_MAX_UPLOAD_BYTES, @@ -16,7 +17,9 @@ import { parseVersionUploadForm, sanitizeFilePartName, } from "@/lib/document-versioning"; +import { extractContentControls } from "@/lib/lib-tmpl"; import { requireDocverBearer } from "@/lib/docver-token"; +import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; import { findDocumentByDocId, insertVersionRecord, @@ -31,6 +34,7 @@ export const dynamic = "force-dynamic"; /** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก) * ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes → + * แกะ fields จากไฟล์ (lib-tmpl เหมือน /api/template/extract) → * putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4) * response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative * จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */ @@ -92,6 +96,21 @@ export async function POST(request: Request) { return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400); } + // fields snapshot แกะจากไฟล์เองฝั่ง server — **เก็บทั้งก้อนเหมือน return ของ + // /api/template/extract** ({cc, bookmark, properties, verify} ผ่าน extractDocumentSnapshot + // ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น — 2026-10-30 ถอด form field "fields" + // ที่ add-in อ่านผ่าน Office.js ออกจาก contract): มองเห็น CC ทุกชนิดเสมอไม่ขึ้นกับ + // เวอร์ชัน WordApi ของ host · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket + // ฝั่ง server (env chain เดียวกับ lib/addin-ticket.ts) · ZIP ไม่มี word/document.xml, + // parse ไม่ได้ หรือ ticket_data ไม่ใช่ JSON = 400 + let fields: DocumentVersionFields; + try { + fields = extractDocumentSnapshot(docxBytes, { secret: addinTicketSecret() }); + } catch (err) { + console.error("docver: แกะข้อมูลจากไฟล์ไม่สำเร็จ", err); + return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (แกะข้อมูลไม่สำเร็จ)", 400); + } + const contentHash = createHash("sha256").update(docxBytes).digest("hex"); const versionId = randomUUID(); // ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) → @@ -119,7 +138,7 @@ export async function POST(request: Request) { clientVersionId: parsed.form.clientVersionId, contentHash, message: parsed.form.message, - fields: parsed.form.fields, + fields, templateId: auth.claims.template, fileName: docxDisplayName, docxObjectKey: keys.docx, diff --git a/app/api/template/extract/route.ts b/app/api/template/extract/route.ts index 12108b7..bc96235 100644 --- a/app/api/template/extract/route.ts +++ b/app/api/template/extract/route.ts @@ -1,6 +1,5 @@ import { NextResponse } from "next/server"; -import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; -import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; +import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; // export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = @@ -40,32 +39,13 @@ export async function POST(request: Request) { // File → Buffer const buffer = Buffer.from(await file.arrayBuffer()); - // ใช้ lib-tmpl.ts เดิม - // const data = extractContentControls(buffer); - - const cc = extractContentControls(buffer); - const bookmark = extractBookmark(buffer) - const properties = getDocProps(buffer) - let verify: Record ={} - const ticket_data = properties?.custom?.ticket_data - const HmacSha256 = String(properties?.custom?.HmacSha256) - const Ed25519 = String(properties?.custom?.Ed25519) - - if(ticket_data && typeof ticket_data === 'string'){ - const ticketDataObj= JSON.parse(ticket_data) - const unix_timestamp = Number(ticketDataObj.exp)*1000 - if(unix_timestamp){ - verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }) - } - if(TEST_SECRET && HmacSha256){ - verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) - } - if(TEST_PUBLIC_KEY_HEX&& Ed25519){ - verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) - } - } - - let returnData : Record = {cc,bookmark,properties,verify} + // ชุดแกะข้อมูล `{cc, bookmark, properties, verify}` จาก lib-tmpl.ts — ตัวเดียวกับที่ + // ใช้เก็บเป็น fields ของ version ตอน POST /api/documents/versions (2026-10-30) · + // secret/public_key เป็นของผู้เรียกเพื่อ "ทดสอบ verify" เฉพาะฝั่งนี้ + const returnData = extractDocumentSnapshot(buffer, { + secret: TEST_SECRET || undefined, + publicKeyHex: TEST_PUBLIC_KEY_HEX || undefined, + }); return NextResponse.json(returnData); } catch (error) { console.error(error); diff --git a/db/schema.ts b/db/schema.ts index 840c79d..3ded779 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -12,6 +12,9 @@ import { uuid, } from "drizzle-orm/pg-core"; +// type-only — ไม่ลาก runtime ของ lib-tmpl มาที่ schema (ใช้เป็นชนิดของ fields jsonb) +import type { DocumentExtractSnapshot } from "@/lib/lib-tmpl"; + // ─── Enum ──────────────────────────────────────────────────────────────────── export const annotationType = pgEnum("annotation_type", [ @@ -275,6 +278,14 @@ export const documents = pgTable( /** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version) * · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf) * · object key เป็น uuid ล้วน `docver//document.{docx,pdf}` ไม่มี input ผู้ใช้ */ + +/** fields ของ version — **ก้อนเดียวกับ return ของ `/api/template/extract`** (ผ่าน + * `extractDocumentSnapshot` ของ lib/lib-tmpl.ts ตัวเดียวกัน): `{cc, bookmark, properties, + * verify}` แกะจากไฟล์ .docx ฝั่ง server ตอน upload (2026-10-30) · row เก่าก่อนวันนั้น + * เก็บ map ดิบ `{ [tag]: text }` / `{ [fieldKey]: {value, missing} }` — ฝั่งอ่านของ add-in + * (FieldsSnapshot + readSnapshotField) แยกรูปเอง จึงไม่ต้อง migrate ข้อมูลเก่า */ +export type DocumentVersionFields = DocumentExtractSnapshot; + export const documentVersions = pgTable( "document_versions", { @@ -290,9 +301,9 @@ export const documentVersions = pgTable( contentHash: text("content_hash").notNull(), /** commit message (FR-13) — trim แล้วว่าง = NULL */ message: text("message"), - /** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls - * (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */ - fields: jsonb("fields").$type>(), + /** snapshot ตอน upload: ก้อน extract snapshot `{cc, bookmark, properties, verify}` + * เหมือน return ของ /api/template/extract (ดู DocumentVersionFields เหนือตาราง) */ + fields: jsonb("fields").$type(), templateId: text("template_id"), /** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) — * ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */ @@ -447,7 +458,7 @@ export type DocumentVersionDTO = { createdByUserId: string; createdByUserName: string; contentHash: string; - fields: Record | null; + fields: DocumentVersionFields | null; message: string | null; hasPdf: boolean; }; diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index 23271c9..e3abd79 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -80,13 +80,23 @@ multipart/form-data: | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | -| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls ทุกชนิด (add-in ใช้ `Document.getContentControls()` บน WordApi 1.5+; host ต่ำกว่าเห็นเฉพาะ rich text — ดู `docs/addin-ui-flow.md` §9 ข้อ 7) (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | - field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) — **breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว (ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน + · **breaking change 2026-10-30:** `fields` ถอดออกจาก form เช่นกัน (ส่งมา = 400) — + `fields` ของ version ถูกแกะจากไฟล์ .docx ที่อัปโหลด**ฝั่ง server** เก็บ**ทั้งก้อน + เหมือน return ของ `/api/template/extract` เลย** (`{cc, bookmark, properties, verify}` + ผ่าน `extractDocumentSnapshot` ของ `lib/lib-tmpl.ts` ตัวเดียวกับ route นั้น — route + extract เรียก helper ตัวเดียวกัน จึงตรงกันเป๊ะโดยการก่อสร้าง) · `verify.HmacSha256` + ตรวจด้วย secret ของ add-in ticket ฝั่ง server (env chain `ADDIN_TICKET_SECRET` → + `SECRET` → `"super_secret"` ของ `lib/addin-ticket.ts` — ต่างจาก extract ที่ใช้ secret + ของผู้เรียกเพื่อทดสอบ) · เห็น content control ทุกชนิดเสมอ ไม่ขึ้นกับเวอร์ชัน WordApi + ของ host แบบตอน add-in อ่านเองผ่าน Office.js · ไฟล์ที่แกะไม่ได้ (ไม่มี + `word/document.xml`/ZIP เสีย/`ticket_data` ไม่ใช่ JSON) = 400 · snapshot เก็บทุกครั้ง + ที่แกะสำเร็จ (NULL เหลือเฉพาะ row เก่าก่อน 2026-10-30 ที่เก็บ map ดิบ) - เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint "สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token) - response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }` @@ -106,7 +116,16 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า createdByUserId: string; // = user_id createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม) contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server) - fields: Record | null; + fields: { // ก้อนเดียวกับ return ของ /api/template/extract (2026-10-30) + cc: Record; // ค่า Content Control { [tag]: text } + bookmark: Record; // ข้อความใน bookmark { [name]: text } + properties: { // core/extended/custom props (Date → ISO string) + standard?: Record; + extended?: Record; + custom?: Record; + }; + verify: Record; // exp (ฟอร์แมตไทย) + HmacSha256 (secret ฝั่ง server) + } | null; // null = row เก่าก่อน 2026-10-30 (map ดิบ) หรือไม่มีเลย message: string | null; hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว } @@ -131,7 +150,7 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า | เหตุ | สถานะ | |---|---| -| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | +| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId/fields เก่า) · ไม่ใช่ 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 | @@ -174,7 +193,9 @@ document_versions — id uuid PK · document_id uuid FK→documents CASCADE ## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า -validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → sha256 → `versionId` +validate (text field ก่อนแตะไฟล์) → cap ขนาด → buffer → magic bytes → **แกะ fields snapshot +จากไฟล์ทั้งก้อนด้วย `extractDocumentSnapshot` ของ lib/lib-tmpl.ts (ตัวเดียวกับ +`/api/template/extract` — 2026-10-30)** → sha256 → `versionId` → **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** — ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4) diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index 7d1dd63..69ee6fd 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -25,12 +25,13 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ /** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ * (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token - * ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */ + * ผ่าน lib/docver-token.ts · `fields` ถอดออก 2026-10-30 — server แกะจากไฟล์ .docx + * ด้วย extractContentControls ของ lib/lib-tmpl.ts ตัวเดียวกับ /api/template/extract + * ส่ง field เก่ามา = 400 ตาม convention) */ const FORM_FIELDS = [ "file", "pdf", "clientVersionId", - "fields", "message", "fileName", ] as const; @@ -108,39 +109,10 @@ export function sanitizeFilePartName(raw: string): string | null { return sanitizeDisplayFileName(decodeUploadFilename(raw)); } -/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — - * `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */ -export function parseFieldsJson(raw: string): - | { ok: true; fields: Record | null } - | DocverError { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' }; - } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' }; - } - const record = parsed as Record; - const keys = Object.keys(record); - // เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้: - // เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb - // ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ) - if (keys.length > 1_000) { - return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' }; - } - for (const key of keys) { - if (key.length > 200) { - return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' }; - } - const value = record[key]; - if (typeof value !== "string") { - return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' }; - } - } - return { ok: true, fields: keys.length === 0 ? null : (parsed as Record) }; -} +/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — ถอดออกแล้ว + * (2026-10-30): form field "fields" ยกเลิก ไม่รับจากผู้เรียกอีก — fields ของ version + * แกะจากไฟล์ .docx ที่อัปโหลดฝั่ง server ด้วย extractContentControls ของ + * lib/lib-tmpl.ts (ตัวเดียวกับ /api/template/extract) ที่ route ของ POST versions */ export type ParsedVersionUploadForm = { clientVersionId: string | null; @@ -148,7 +120,6 @@ export type ParsedVersionUploadForm = { /** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง * เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */ fileName: string | null; - fields: Record | null; }; /** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน @@ -180,15 +151,6 @@ export function parseVersionUploadForm(form: FormData): return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` }; } - const rawFields = formString(form, "fields"); - if (!rawFields.ok) return rawFields; - let fields: Record | null = null; - if (rawFields.value !== null) { - const parsed = parseFieldsJson(rawFields.value); - if (!parsed.ok) return parsed; - fields = parsed.fields; - } - const rawFileName = formString(form, "fileName"); if (!rawFileName.ok) return rawFileName; @@ -198,7 +160,6 @@ export function parseVersionUploadForm(form: FormData): clientVersionId: clientVersionId.value, message: message.value, fileName: sanitizeDisplayFileName(rawFileName.value), - fields, }, }; } diff --git a/lib/docver-store.ts b/lib/docver-store.ts index 08a6575..4a98f3a 100644 --- a/lib/docver-store.ts +++ b/lib/docver-store.ts @@ -13,6 +13,7 @@ import { documents, documentVersions, type DocumentRow, + type DocumentVersionFields, type DocumentVersionRow, } from "@/db/schema"; import { @@ -229,7 +230,8 @@ export type InsertVersionInput = { clientVersionId: string | null; contentHash: string; message: string | null; - fields: Record | null; + /** ก้อนเดียวกับ return ของ /api/template/extract (ดู DocumentVersionFields ใน db/schema.ts) */ + fields: DocumentVersionFields | null; templateId: string | null; fileName: string | null; docxObjectKey: string; diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index bb293d0..631ff46 100755 --- a/lib/lib-tmpl.ts +++ b/lib/lib-tmpl.ts @@ -1,4 +1,4 @@ -import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils'; +import { generateHmacSha256, signEd25519, sortObjectKeys, verifyEd25519, verifyHmacSha256 } from '@/lib/lib-utils'; import createReport from 'docx-templates'; import PizzaZip from "pizzip"; import { @@ -1217,6 +1217,71 @@ export function getDocProps( } +//**************************** document snapshot ******************************************* + +/** DocPropsResult หลังทำ JSON-safe (Date → ISO string) — ชนิดของ `properties` ใน + * DocumentExtractSnapshot ทั้งฝั่ง response ของ /api/template/extract และ column + * `fields` jsonb ของ document_versions (ค่าข้าม server→client เป็น string เสมอ) */ +export interface DocPropsJson { + standard?: Record; + extended?: Record; + custom?: Record; +} + +/** รูป return ของ /api/template/extract — ใช้ร่วม 2 ที่: route นั้นคืนทั้งก้อนตรง ๆ + * และ POST /api/documents/versions เก็บทั้งก้อนเป็น fields ของ version (2026-10-30) */ +export interface DocumentExtractSnapshot { + /** ค่า Content Control `{ [tag]: text }` */ + cc: ExtractedData; + /** ข้อความใน bookmark `{ [name]: text }` */ + bookmark: ExtractedData; + /** document properties (standard/extended/custom) */ + properties: DocPropsJson; + /** ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (ฟอร์แมตไทย) + HmacSha256/Ed25519 เฉพาะเมื่อ + * ผู้เรียกส่ง secret/publicKeyHex มาให้ใช้ตรวจ */ + verify: Record; +} + +/** แกะข้อมูลจาก docx ทั้งชุด `{cc, bookmark, properties, verify}` — logic ย้ายมาจาก + * app/api/template/extract/route.ts แบบเดิม (แถวต่อแถว) เพื่อให้ return ของ extract + * กับ fields ของ version ที่สร้างใหม่ตรงกันเป๊ะโดยการก่อสร้าง + * · secret/publicKeyHex = ของผู้เรียกเพื่อ "ทดสอบ verify" (extract) หรือ secret ของ + * add-in ticket ฝั่ง server (ตอนสร้าง version) — ไม่ส่ง = ไม่มี key นั้นใน verify + * · ticket_data ที่ไม่ใช่ JSON จะ throw ผู้เรียกจัดการเอง (extract = 500 / versions = 400) */ +export function extractDocumentSnapshot( + docxBuffer: Buffer, + options: { secret?: string; publicKeyHex?: string } = {}, +): DocumentExtractSnapshot { + const cc = extractContentControls(docxBuffer); + const bookmark = extractBookmark(docxBuffer); + // JSON-safe ทันที (Date → ISO string): response ของ extract กับ jsonb ของ version ได้ + // ค่าชนิดเดียวกัน (NextResponse.json / drizzle serialize Date เป็น ISO อยู่แล้ว — + // ผลลัพธ์ JSON ไม่ต่างจากเดิม เปลี่ยนแค่ชนิดฝั่ง TS) + const properties = JSON.parse(JSON.stringify(getDocProps(docxBuffer))) as DocPropsJson; + + const verify: Record = {}; + const ticket_data = properties.custom?.ticket_data; + const HmacSha256 = String(properties.custom?.HmacSha256); + const Ed25519 = String(properties.custom?.Ed25519); + + if (ticket_data && typeof ticket_data === "string") { + const ticketDataObj = JSON.parse(ticket_data); + const unix_timestamp = Number(ticketDataObj.exp) * 1000; + if (unix_timestamp) { + verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }); + } + if (options.secret && HmacSha256) { + verify['HmacSha256'] = verifyHmacSha256(ticket_data, options.secret, HmacSha256); + } + if (options.publicKeyHex && Ed25519) { + verify["Ed25519"] = verifyEd25519(ticket_data, Ed25519, options.publicKeyHex); + } + } + + return { cc, bookmark, properties, verify }; +} + + //**************************** dynamic content ******************************************* // sdtLocked(ห้ามลบ CC), contentLocked(ลบได้ห้ามแก้), sdtContentLocked(ห้ามทุกอย่าง) type LockType = 'none' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; diff --git a/lib/openapi.ts b/lib/openapi.ts index 7700d71..43eff5d 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -705,7 +705,11 @@ export const openApiSpec = { "- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " + "หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" + "- ผู้อัปโหลด (userId/userName) และ templateId อ่านจาก token — " + - "**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**", + "**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**\n" + + "- `fields` ของ version ถูก server แกะจากไฟล์ .docx ที่อัปโหลดเอง เก็บ**ทั้งก้อนเหมือน " + + "return ของ `/api/template/extract`** (`{cc, bookmark, properties, verify}` — ผ่าน helper " + + "ตัวเดียวกัน) โดย `verify.HmacSha256` ตรวจด้วย secret ของ ticket ฝั่ง server — " + + "**ห้ามส่ง field `fields` ใน form (ส่งมา = 400)**", security: [{ DocverBearer: [] }], requestBody: { required: true, @@ -730,13 +734,6 @@ export const openApiSpec = { type: "string", description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)", }, - fields: { - type: "string", - description: - 'JSON string ของค่า Content Control **ทุกตัว** ในเอกสาร รูปแบบ ' + - '`{ [tag]: ข้อความ }` (add-in อ่านมาตอนบันทึก version) ' + - 'เช่น {"blackno":"คดีหมายเลขดำที่","detail1":"…"}', - }, message: { type: "string", description: "commit message ของ version (≤ 2048 ตัวอักษร)", @@ -1166,6 +1163,40 @@ export const openApiSpec = { }, }, }, + ExtractSnapshot: { + type: "object", + description: + "ก้อนข้อมูลแกะจากไฟล์ .docx — รูปเดียวกับ return ของ POST /api/template/extract " + + "(ใช้ helper ตัวเดียวกันใน lib/lib-tmpl.ts)", + properties: { + cc: { + type: "object", + description: "ค่า Content Control ทุกตัว รูปแบบ `{ [tag]: ข้อความ }`", + additionalProperties: { type: "string" }, + }, + bookmark: { + type: "object", + description: "ข้อความใน bookmark รูปแบบ `{ [ชื่อ bookmark]: ข้อความ }`", + additionalProperties: { type: "string" }, + }, + properties: { + type: "object", + description: "document properties — standard (core) / extended (app) / custom", + properties: { + standard: { type: "object", additionalProperties: { type: "string" } }, + extended: { type: "object", additionalProperties: { type: "string" } }, + custom: { type: "object", additionalProperties: {} }, + }, + }, + verify: { + type: "object", + description: + "ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (วันที่ไทย +07:00) และ HmacSha256 " + + "(boolean — ตรวจด้วย secret ของ ticket ฝั่ง server)", + additionalProperties: {}, + }, + }, + }, VersionRecord: { type: "object", description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)", @@ -1180,11 +1211,11 @@ export const openApiSpec = { description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)", }, fields: { - type: "object", nullable: true, description: - "ค่า Content Control ทุกตัวตอนอัปโหลด รูปแบบ `{ [tag]: ข้อความ }` (ไม่ส่ง = null)", - additionalProperties: { type: "string" }, + "snapshot ตอนอัปโหลด — **ก้อนเดียวกับ return ของ /api/template/extract** (server " + + "แกะจากไฟล์ .docx เอง; null = version เก่าก่อน 2026-10-30)", + allOf: [{ $ref: "#/components/schemas/ExtractSnapshot" }], }, message: { type: "string", nullable: true, description: "commit message" }, hasPdf: { type: "boolean" }, diff --git a/scripts/make-ticket-docx.mjs b/scripts/make-ticket-docx.mjs index e22f6d0..9292581 100644 --- a/scripts/make-ticket-docx.mjs +++ b/scripts/make-ticket-docx.mjs @@ -125,10 +125,12 @@ function unescapeXml(value) { .replace(/&/g, "&"); } -/** ถอด ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */ +/** ถอด ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว + * (match ทั้งพิมพ์เล็กของตัว stamp จริง/setCustomDocProps และพิมพ์ใหญ่ + * ที่ script นี้เคยเขียนก่อน 2026-10-30 — อ่านไฟล์เก่าที่ stampค้างไว้ได้) */ function readProperties(xml) { const properties = []; - const regex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|]*name="([^"]*)"[^>]*\/>/g; + const regex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/property>|]*name="([^"]*)"[^>]*\/>/gi; let match; while ((match = regex.exec(xml)) !== null) { const name = match[1] ?? match[3]; @@ -148,7 +150,10 @@ function usedPids(xml) { } function propertyXml(name, pid, value) { - return ` ${escapeXml(value)}\n`; + // `` พิมพ์เล็กตาม schema OOXML (ct_Property) — ตรงกับตัว stamp จริง + // setCustomDocProps ของ lib/lib-tmpl.ts เพื่อให้ getCustomDocProps/getDocProps + // (ที่ /api/template/extract กับ fields ของ version ใช้อ่าน) มองเห็นด้วย + return ` ${escapeXml(value)}\n`; } /** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */ @@ -156,9 +161,10 @@ function upsertProperties(existingXml, entries) { // เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง) let xml = existingXml; for (const entry of entries) { + // gi — ถอดของเก่าทั้งพิมพ์เล็ก (ตัวจริง) และพิมพ์ใหญ่ (ที่ script นี้เคยเขียน) ก่อนแทรกใหม่ const removal = new RegExp( - `\\s*]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)`, - "g", + `\\s*]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)`, + "gi", ); xml = xml.replace(removal, ""); } @@ -189,7 +195,7 @@ function ensurePartRegistered(zip) { .asText() .replace( /<\/Types>/, - ` \n`, + ` \n`, ); zip.file(CT_PATH, updated); } diff --git a/tests/lib/document-versioning.test.ts b/tests/lib/document-versioning.test.ts index a8d13a4..090e7f2 100644 --- a/tests/lib/document-versioning.test.ts +++ b/tests/lib/document-versioning.test.ts @@ -7,7 +7,6 @@ import { isDocxBytes, isPdfBytes, parseDownloadFormat, - parseFieldsJson, parseVersionIdParam, parseVersionUploadForm, sanitizeDisplayFileName, @@ -71,23 +70,6 @@ describe("isDocxBytes / isPdfBytes", () => { }); }); -describe("parseFieldsJson", () => { - it("แยก JSON object ล้วนได้ และ `{}` = null (ถือว่าไม่ส่ง)", () => { - const result = parseFieldsJson('{"blackno":"คดีหมายเลขดำที่"}'); - ok(result); - expect(result.fields).toEqual({ blackno: "คดีหมายเลขดำที่" }); - const empty = parseFieldsJson("{}"); - ok(empty); - expect(empty.fields).toBeNull(); - }); - - it("ตกเมื่อ JSON เพี้ยน / ไม่ใช่ object / value ไม่ใช่ string", () => { - for (const raw of ["ไม่ใช่ JSON", "[1,2]", '"string"', "null", '{"a":1}']) { - expect(parseFieldsJson(raw).ok).toBe(false); - } - }); -}); - describe("parseVersionUploadForm", () => { // identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก // Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts) @@ -101,7 +83,6 @@ describe("parseVersionUploadForm", () => { const form = baseForm(); form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 "); form.append("message", " "); - form.append("fields", '{"blackno":"1"}'); form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx"); const result = parseVersionUploadForm(form); ok(result); @@ -109,7 +90,6 @@ describe("parseVersionUploadForm", () => { clientVersionId: "11111111-1111-4111-8111-111111111111", message: null, fileName: "คำสั่งเรียก.docx", - fields: { blackno: "1" }, }); }); @@ -131,6 +111,14 @@ describe("parseVersionUploadForm", () => { } }); + it("ส่ง fields ใน form = 400 (2026-10-30 — server แกะจากไฟล์เองแบบ /api/template/extract)", () => { + const form = baseForm(); + form.append("fields", '{"blackno":"1"}'); + const result = parseVersionUploadForm(form); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("fields"); + }); + it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => { const form = baseForm(); form.append("message", new File(["x"], "m.txt")); @@ -147,31 +135,6 @@ describe("parseVersionUploadForm", () => { over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1)); expect(parseVersionUploadForm(over).ok).toBe(false); }); - - it("fields ใน form ผิดกติกาส่งต่อ error ของ parseFieldsJson", () => { - const form = baseForm(); - form.append("fields", '{"a":1}'); - expect(parseVersionUploadForm(form).ok).toBe(false); - }); - - it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => { - const big = baseForm(); - big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) })); - expect(parseVersionUploadForm(big).ok).toBe(true); - - const manyKeys = baseForm(); - manyKeys.append( - "fields", - JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))), - ); - expect(parseVersionUploadForm(manyKeys).ok).toBe(true); - const over = baseForm(); - over.append( - "fields", - JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))), - ); - expect(parseVersionUploadForm(over).ok).toBe(false); - }); }); describe("sanitizeDisplayFileName / sanitizeFilePartName", () => { diff --git a/tests/lib/lib-tmpl.test.ts b/tests/lib/lib-tmpl.test.ts new file mode 100644 index 0000000..fd585d8 --- /dev/null +++ b/tests/lib/lib-tmpl.test.ts @@ -0,0 +1,101 @@ +// extractDocumentSnapshot — หัวใจของ "fields ของ version = return ของ /api/template/extract" +// (2026-10-30): คุมว่าก้อน {cc, bookmark, properties, verify} แกะได้ครบและ verify ทำงาน +// ตาม secret ที่ผู้เรียกส่ง · ไฟล์นี้ตั้งใจไม่แตะ module ที่ลาก @/db หรือ minio มาด้วย +// (pattern document-versioning.test.ts — รันได้ไม่ต้องตั้ง DATABASE_URL/MINIO_*) +import { createHmac } from "node:crypto"; +import { describe, expect, it } from "vitest"; +import PizzaZip from "pizzip"; + +import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; + +const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +const CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; +const VT_NS = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; + +function escapeXml(unsafe: string): string { + return unsafe.replace(/&/g, "&").replace(//g, ">"); +} + +/** docx จิ๋วในหน่วยความจำ: CC 1 ตัว (tag blackno) + bookmark sec1 ครอบ 2 run */ +function documentXml(): string { + return ( + `\n` + + `` + + `` + + `ด1/2569` + + `` + + `hello world` + + `` + + `` + ); +} + +function customPropsXml(ticketData: string, hmacHex: string): string { + const prop = (pid: number, name: string, value: string) => + `` + + `${escapeXml(value)}`; + return ( + `\n` + + `` + + prop(2, "ticket_data", ticketData) + + prop(3, "HmacSha256", hmacHex) + + `` + ); +} + +function buildDocx(entries: Record): Buffer { + const zip = new PizzaZip(); + for (const [name, content] of Object.entries(entries)) { + zip.file(name, content); + } + return Buffer.from(zip.generate({ type: "nodebuffer" })); +} + +const TICKET_DATA = JSON.stringify({ exp: 2_000_000_000, fid: "F1", uid: "u1", uname: "ทดสอบ" }); +const TICKET_HMAC = createHmac("sha256", "super_secret").update(TICKET_DATA, "utf8").digest("hex"); + +function stampedDocx(): Buffer { + return buildDocx({ + "word/document.xml": documentXml(), + "docProps/custom.xml": customPropsXml(TICKET_DATA, TICKET_HMAC), + }); +} + +describe("extractDocumentSnapshot", () => { + it("แกะครบทั้งก้อน {cc, bookmark, properties, verify} เหมือน /api/template/extract", () => { + const snapshot = extractDocumentSnapshot(stampedDocx(), { secret: "super_secret" }); + + expect(snapshot.cc).toEqual({ blackno: "ด1/2569" }); + expect(snapshot.bookmark).toEqual({ sec1: "hello world" }); + expect(snapshot.properties.custom?.ticket_data).toBe(TICKET_DATA); + expect(snapshot.properties.custom?.HmacSha256).toBe(TICKET_HMAC); + expect(snapshot.verify.HmacSha256).toBe(true); + // exp (unix sec) ฟอร์แมต th-TH เป็นปี พ.ศ. — 2000000000 = 18 พ.ค. ค.ศ. 2033 = พ.ศ. 2576 + expect(typeof snapshot.verify.exp).toBe("string"); + expect(snapshot.verify.exp).toContain("2576"); + // ไม่ส่ง public key = ไม่มี key Ed25519 ใน verify (เหมือน extract ที่ไม่ส่ง public_key) + expect(snapshot.verify.Ed25519).toBeUndefined(); + }); + + it("secret ผิด = HmacSha256 false · ไม่ส่ง secret = ไม่มี key HmacSha256 เลย", () => { + expect(extractDocumentSnapshot(stampedDocx(), { secret: "wrong-secret" }).verify.HmacSha256).toBe(false); + const noSecret = extractDocumentSnapshot(stampedDocx()); + expect(noSecret.verify.HmacSha256).toBeUndefined(); + // exp ยังอ่านได้แม้ไม่ตรวจ HMAC (มาจาก ticket_data ตรง ๆ — ปี พ.ศ. 2576) + expect(noSecret.verify.exp).toContain("2576"); + }); + + it("ไฟล์ไม่มี custom properties = verify ว่าง แต่ cc/bookmark ยังแกะได้", () => { + const bare = buildDocx({ "word/document.xml": documentXml() }); + const snapshot = extractDocumentSnapshot(bare, { secret: "super_secret" }); + + expect(snapshot.verify).toEqual({}); + // getDocProps เติม custom เป็น {} เสมอ (ไม่มี core/app ใน zip จิ๋วนี้) + expect(snapshot.properties).toEqual({ custom: {} }); + expect(snapshot.cc).toEqual({ blackno: "ด1/2569" }); + }); + + it("ไฟล์เสีย (ไม่ใช่ ZIP ที่มี word/document.xml) = throw ให้ route ตอบ 400/500 ตามกลุ่ม", () => { + expect(() => extractDocumentSnapshot(Buffer.from("PK\x03\x04-not-a-zip"))).toThrow(); + }); +}); diff --git a/tests/wordaddin/versioningService.test.ts b/tests/wordaddin/versioningService.test.ts index 4de9046..a61ce94 100644 --- a/tests/wordaddin/versioningService.test.ts +++ b/tests/wordaddin/versioningService.test.ts @@ -2,7 +2,8 @@ // versioningService หลังปลด mock (2026-10-27) — คุมทั้งสองโหมด: // - ของจริง: sign-in ผ่าน signInWithTicket ให้ได้ Bearer ก่อน แล้วตรวจ FormData ของ // uploadVersion ตรง contract POST /api/documents/versions (ห้ามส่ง userId/userName/ -// templateId — identity มาจาก Bearer หมด), getVersions แปลง DTO, getDownloadLink ใส่ ?format= +// templateId — identity มาจาก Bearer หมด · และห้ามส่ง fields — server แกะจากไฟล์เอง +// 2026-10-30), getVersions แปลง DTO, getDownloadLink ใส่ ?format= // - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย) import { createHmac, webcrypto } from "node:crypto"; import { afterEach, describe, expect, it, vi } from "vitest"; @@ -83,12 +84,14 @@ describe("versioningService (real mode)", () => { expect((form.get("file") as File).name).toBe("คำฟ้อง.docx"); expect((form.get("pdf") as File).name).toBe("document.pdf"); expect(form.get("clientVersionId")).toBe("client-vid-1"); - expect(JSON.parse(form.get("fields") as string)).toEqual({ blackno: "ด1" }); expect(form.get("message")).toBe("แก้ไขย่อหน้า 3"); // trim แล้ว // contract 2026-10-22: ห้ามมี field identity/template เหลืออยู่เลย expect(form.has("userId")).toBe(false); expect(form.has("userName")).toBe(false); expect(form.has("templateId")).toBe(false); + // contract 2026-10-30: fields ก็ห้ามส่ง — server แกะจากไฟล์ .docx เอง + // (ตัวเดียวกับ /api/template/extract) แม้ caller จะ pass เข้ามาตาม signature เดิม + expect(form.has("fields")).toBe(false); }); it("pdf ว่าง/ไม่แนบ = ไม่ append field pdf (ไฟล์เปล่า = version ไม่มี PDF)", async () => { @@ -105,6 +108,8 @@ describe("versioningService (real mode)", () => { it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => { await signInReal(); + // fields = ก้อนเดียวกับ return ของ /api/template/extract (server แกะจากไฟล์เอง) + const fields = { cc: { blackno: "ด1" }, bookmark: {}, properties: { custom: {} }, verify: {} }; const fetchMock = vi.fn( async (_url: string, _init?: RequestInit) => new Response( @@ -116,7 +121,7 @@ describe("versioningService (real mode)", () => { createdByUserId: "oom", createdByUserName: "สรวิชญ์", contentHash: "ab", - fields: { blackno: "ด1" }, + fields, message: null, hasPdf: true, }, @@ -134,7 +139,7 @@ describe("versioningService (real mode)", () => { createdByUserId: "oom", createdByUserName: "สรวิชญ์", contentHash: "ab", - fields: { blackno: "ด1" }, + fields, message: null, hasPdf: true, }, diff --git a/wordaddin/models/versionRecord.ts b/wordaddin/models/versionRecord.ts index 3a1087d..a4db131 100644 --- a/wordaddin/models/versionRecord.ts +++ b/wordaddin/models/versionRecord.ts @@ -16,13 +16,36 @@ export interface FieldSnapshotEntry { missing: boolean; } +/** fields ของ version รูปแบบปัจจุบัน — **ก้อนเดียวกับ return ของ `/api/template/extract`** + * ที่ server แกะจากไฟล์ .docx ฝั่ง server ตอนอัปโหลด (2026-10-30) · ค่าวันที่ใน + * properties มาเป็น ISO string ตาม DTO ของกลุ่ม docver */ +export interface ExtractSnapshotFields { + /** ค่า Content Control `{ [tag]: text }` */ + cc: Record; + /** ข้อความใน bookmark `{ [name]: text }` */ + bookmark: Record; + /** document properties (standard/extended/custom) */ + properties: { + standard?: Record; + extended?: Record; + custom?: Record; + }; + /** ผลตรวจ ticket ที่ stamp มากับไฟล์ (exp + HmacSha256 ด้วย secret ฝั่ง server) */ + verify: Record; +} + /** - * snapshot fields ต่อ version — backend เก็บ 2 รูปแบบ: + * snapshot fields ต่อ version — backend เก็บ 3 รูปแบบ: + * - ปัจจุบัน (2026-10-30): ก้อน extract snapshot `{cc, bookmark, properties, verify}` + * เหมือน return ของ /api/template/extract (server แกะจากไฟล์เอง) * - เอกสารผูก template: `{ [fieldKey]: { value, missing } }` * - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }` - * UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน) + * UI ต้อง handle ได้ทั้งหมด (ใช้ readSnapshotField ช่วยอ่าน) */ -export type FieldsSnapshot = Record | Record; +export type FieldsSnapshot = + | ExtractSnapshotFields + | Record + | Record; /** item ของ GET /api/documents/{documentId}/versions */ export interface VersionRecord { @@ -41,7 +64,8 @@ export interface VersionRecord { message: string | null; } -/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */ +/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null + * รูปแบบ extract snapshot (2026-10-30) ค่า CC อยู่ใต้ `cc` — อ่านซ้ำเข้า map ดิบรอบเดียวกัน */ export function readSnapshotField( snapshot: FieldsSnapshot | null, key: string @@ -49,6 +73,13 @@ export function readSnapshotField( if (!snapshot) { return null; } + // Record ธรรมดาไม่ถูก `in` ตัดออกจาก union (มี index signature ครอบทุก key) — เช็คแล้ว + // บอกชนิดตรง ๆ ว่าเป็นก้อน extract snapshot (key "cc" ชนกับ tag ชื่อเดียวกันยอมรับเป็น + // edge case ของการเดารูปจากข้อมูลเก่า) + if ("cc" in snapshot) { + const cc = (snapshot as ExtractSnapshotFields).cc; + return readSnapshotField(cc, key); + } const raw = snapshot[key]; if (raw === undefined) { return null; diff --git a/wordaddin/services/metadataService.ts b/wordaddin/services/metadataService.ts index e25c5db..cd25bc8 100644 --- a/wordaddin/services/metadataService.ts +++ b/wordaddin/services/metadataService.ts @@ -6,6 +6,8 @@ * - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth * - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text * (ทุกชนิดบน host WordApi 1.5+ — ด้วย supportedContentControls ด้านล่าง) + * · ไม่ได้ใช้ตอนสร้าง version แล้ว (2026-10-30 — server แกะจากไฟล์ .docx ด้วยตัว + * เดียวกับ /api/template/extract) เหลือเป็น service API ที่มี test คุม semantics * พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts * * กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw** @@ -254,7 +256,10 @@ export function supportedContentControls(context: Word.RequestContext): Word.Con } /** - * อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version + * อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` — เดิมใช้แนบกับ upload + * version (FR-11) แต่ตั้งแต่ 2026-10-30 server แกะจากไฟล์ .docx เองด้วยตัวเดียวกับ + * /api/template/extract (เห็น CC ทุกชนิดเสมอไม่ขึ้นกับเวอร์ชัน WordApi) — เหลือไว้เป็น + * service API ให้เรียกใช้/test ต่อ * - ใช้ supportedContentControls → host 1.5+ เห็น CC ทุกชนิด (รวม plain text ซึ่งเป็น * ส่วนใหญ่ของ template ศาล) ส่วน host ต่ำกว่า (Office 2019 = 1.3) เห็นเฉพาะ rich text * เท่าเดิม — ห้ามยก MinVersion ของ manifest เพื่อปลดข้อจำกัดนี้ (บทเรียน 2026-10-16) diff --git a/wordaddin/services/versionFlow.ts b/wordaddin/services/versionFlow.ts index 951d4a8..11f369c 100644 --- a/wordaddin/services/versionFlow.ts +++ b/wordaddin/services/versionFlow.ts @@ -7,7 +7,9 @@ * - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track * - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด: * stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save() - * → ดึงไฟล์ + extract fields (FR-11) → POST version → คืนผลให้ UI แสดง/refresh history + * → ดึงไฟล์ → POST version → คืนผลให้ UI แสดง/refresh history + * (FR-11 fields เดิมอ่านฝั่ง client ตอน capture — 2026-10-30 ยกให้ server แกะจาก + * ไฟล์ .docx ด้วยตัวเดียวกับ /api/template/extract แทน จึงไม่มีขั้นอ่าน fields แล้ว) * * หมายเหตุค่า id ใน metadata: `LastVersionId` และ `` ใน XML part เก็บ * **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload — @@ -21,7 +23,6 @@ import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./docume import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService"; import { generateGuid, - readContentControlFields, readVersionHistoryFromXml, stampTrackedProperties, writeVersionHistoryToXml, @@ -82,7 +83,6 @@ function guessDocumentFileName(): string | undefined { async function uploadCurrentFile( documentId: string, clientVersionId: string, - fields: Record, message?: string, onProgress?: (progress: string) => void, templateId?: string @@ -102,7 +102,7 @@ async function uploadCurrentFile( documentId, file, clientVersionId, - fields, + undefined, guessDocumentFileName(), message, pdf, @@ -139,15 +139,14 @@ export async function startVersioning( { id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" }, ]); - // 3) native save แล้ว capture ไฟล์ + fields + // 3) native save แล้ว capture ไฟล์ (fields ของ version แกะจากไฟล์ฝั่ง server + // ด้วยตัวเดียวกับ /api/template/extract — contract 2026-10-30 ไม่ต้องอ่านเองแล้ว) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); - onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); - const fields = await readContentControlFields(); // 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16) onProgress?.("กำลังอัปโหลด version 1…"); - const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress, templateId); + const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress, templateId); // FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version" autoSaveService.clearUncommittedSinceVersion(); return { documentId, ...result }; @@ -188,15 +187,13 @@ export async function saveAndVersion( entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user }); await writeVersionHistoryToXml(entries); - // 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11) + // 3) native save → capture ไฟล์ปัจจุบัน (fields แกะฝั่ง server จากไฟล์ที่อัปโหลด — 2026-10-30) onProgress?.("กำลังบันทึกไฟล์…"); await saveDocumentVerified(); - onProgress?.("กำลังอ่านค่า fields จากเอกสาร…"); - const fields = await readContentControlFields(); // 4) upload onProgress?.("กำลังอัปโหลด version ใหม่…"); - const result = await uploadCurrentFile(documentId, clientVersionId, fields, message, onProgress); + const result = await uploadCurrentFile(documentId, clientVersionId, message, onProgress); // 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort) if (result.versionNumber !== predictedNumber) { diff --git a/wordaddin/services/versioningService.ts b/wordaddin/services/versioningService.ts index f8fb51a..0aabf10 100644 --- a/wordaddin/services/versioningService.ts +++ b/wordaddin/services/versioningService.ts @@ -10,6 +10,8 @@ * ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims * ของ token — ส่งมาใน form = 400) * - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 400) + * - **ห้ามส่ง fields ใน FormData เช่นกัน** (contract 2026-10-30) — server แกะค่า Content + * Control จากไฟล์ .docx ที่อัปโหลดเองด้วยตัวเดียวกับ /api/template/extract) * - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ * ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block) * = ใช้ memory อย่างเดียว ไม่ throw @@ -18,13 +20,14 @@ import * as authService from "./authService"; import { generateGuid } from "./metadataService"; import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient"; -import { UploadVersionResult, VersionRecord } from "../models/versionRecord"; +import { ExtractSnapshotFields, UploadVersionResult, VersionRecord } from "../models/versionRecord"; // --------------------------------------------------------------------------- // ของจริง — กลุ่ม docver (Bearer จาก ticket auth แนบอัตโนมัติโดย apiClient) // --------------------------------------------------------------------------- -/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 */ +/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 + * fields = ก้อนเดียวกับ return ของ /api/template/extract (server แกะจากไฟล์เอง) */ interface DocumentVersionDTO { versionId: string; versionNumber: number; @@ -32,14 +35,16 @@ interface DocumentVersionDTO { createdByUserId: string; createdByUserName: string; contentHash: string; - fields: Record | null; + fields: ExtractSnapshotFields | null; message: string | null; hasPdf: boolean; } /** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST) * + FR-13: message คือ commit message ทางเลือก - * + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) */ + * + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) + * + fields ก็เก็บไว้ตาม signature เดิมเช่นกัน — ไม่ส่งถึง server (contract 2026-10-30: + * server แกะจากไฟล์ .docx เองด้วยตัวเดียวกับ /api/template/extract ส่งมา = 400) */ export async function uploadVersion( documentId: string, file: Blob, @@ -52,6 +57,7 @@ export async function uploadVersion( ): Promise { if (!authService.isDemoMode()) { void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token) + void fields; // contract 2026-10-30 — ห้ามส่ง (server แกะจากไฟล์เอง ดูด้านบน) const form = new FormData(); form.append("file", file, fileName ?? "document.docx"); // pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF @@ -61,9 +67,6 @@ export async function uploadVersion( if (clientVersionId) { form.append("clientVersionId", clientVersionId); } - if (fields && Object.keys(fields).length > 0) { - form.append("fields", JSON.stringify(fields)); - } const trimmedMessage = message?.trim(); if (trimmedMessage) { form.append("message", trimmedMessage); From 5bc8d9a817130c61581cc0b9dc0571388662c359 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Sat, 31 Oct 2026 10:22:00 +0700 Subject: [PATCH 2/3] chore: remove unused MinIO and document versioning environment variables from Dockerfile --- docker/web.Dockerfile | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 84788ea..369f419 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -23,16 +23,7 @@ ENV NEXT_TELEMETRY_DISABLED=1 # db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data — # ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder -ENV MINIO_ENDPOINT=127.0.0.1 -ENV MINIO_PORT=19000 -ENV MINIO_USE_SSL=false -ENV MINIO_ACCESS_KEY=minioadmin -ENV MINIO_SECRET_KEY=minioadmin -ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54 -ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e -ENV DOCVER_TOKEN_TTL_SECONDS=604800 -ENV MINIO_BUCKET=docver -ENV DOCVER_LINK_TTL_SECONDS=900 + RUN npm run build From 0b6ee8cc8494342505d4e9fa4a3ff665bbae2f87 Mon Sep 17 00:00:00 2001 From: Suphonchai Phoonsawat Date: Sat, 31 Oct 2026 11:09:42 +0700 Subject: [PATCH 3/3] feat: add GET /api/documents/latest returning latest version file with stamped ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Server-to-server endpoint for the e-Court backend (x-api-key against DOCVER_API_KEY or EXTERNAL_API_KEY): - type=pdf returns the latest version's PDF bytes (404 when absent) - type=docx stamps fresh ticket custom properties (exp + HmacSha256 via addinTicketSecret(), Ed25519 when PRIVATE_KEY_HEX is set) into an in-memory copy before returning — MinIO object is never modified - docId comes from fid inside the ticket_data query param, which must include fid/uid/uname so the stamped file stays redeemable by the add-in at /api/documents/token (buildStampedTicketData drops null values because parseDocverTicket rejects tid:null) - adds parseTicketDataParam, parseLatestTypeParam, requireDocumentApiKey, findLatestVersionForDoc, getVersionObjectBytes + unit tests (320 pass) - syncs PLAN-DOCVER.md, OpenAPI spec and .env.example Co-Authored-By: Claude Code --- .env.example | 2 + app/api/documents/latest/route.ts | 120 +++++++++++++++++++++ docs/PLAN-DOCVER.md | 61 +++++++++-- lib/document-versioning.ts | 11 ++ lib/docver-store.ts | 45 ++++++++ lib/docver-token.ts | 26 +++++ lib/lib-tmpl.ts | 56 ++++++++++ lib/openapi.ts | 97 ++++++++++++++++- tests/lib/docver-token.test.ts | 58 +++++++++++ tests/lib/lib-tmpl.test.ts | 167 +++++++++++++++++++++++++++++- tests/utils/docver-ticket.test.ts | 82 +++++++++++++++ utils/docver-ticket.ts | 90 ++++++++++++++++ 12 files changed, 806 insertions(+), 9 deletions(-) create mode 100644 app/api/documents/latest/route.ts diff --git a/.env.example b/.env.example index 0aa4f54..473e4d9 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,8 @@ MINIO_SECRET_KEY=minioadmin # docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key) # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง) +# GET /api/documents/latest (2026-10-31) รับ DOCVER_API_KEY นี้ หรือ EXTERNAL_API_KEY +# (ด้านบน) ตัวใดตัวหนึ่งตรง = ผ่าน — ทั้งคู่ไม่ตั้ง = 401 fail closed เช่นกัน DOCVER_API_KEY= # secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer) # ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret diff --git a/app/api/documents/latest/route.ts b/app/api/documents/latest/route.ts new file mode 100644 index 0000000..4e7cad8 --- /dev/null +++ b/app/api/documents/latest/route.ts @@ -0,0 +1,120 @@ +// PLAN-DOCVER.md §1 — GET /api/documents/latest?type=docx|pdf&ticket_data= +// คืนไฟล์ของ version ล่าสุดให้ backend e-Court (server-to-server): +// - type=pdf → ไบต์ pdf ตรง ๆ (version ล่าสุดไม่มี pdf = 404) +// - type=docx → stamp ticket ใหม่ (exp + HmacSha256 + Ed25519 เมื่อมี private key) +// ลงสำเนาในหน่วยความจำก่อนคืน — ไม่เขียนกลับ MinIO (non-destructive) +// - auth: header x-api-key เทียบ env DOCVER_API_KEY หรือ EXTERNAL_API_KEY +// (ต่างจาก /token ที่รับ DOCVER_API_KEY อย่างเดียว) · docId = fid ใน ticket_data +// - secret ของ HMAC = addinTicketSecret() ให้ตรงฝั่ง verify ของ add-in / +// POST /api/documents/token ทาง ticket · TTL = env TICKET_SEC (default 300) +// carve-out invariant #4: route handler ของกลุ่ม docver — ไม่ publish SSE +// ไม่ revalidatePath (ไม่มีหน้า note ใดอ่านตารางของกลุ่มนี้) +import { addinTicketSecret } from "@/lib/addin-ticket"; +import { + contentDispositionValue, + docverError, + DOCX_CONTENT_TYPE, + isDocxBytes, + isPdfBytes, + parseLatestTypeParam, + PDF_CONTENT_TYPE, +} from "@/lib/document-versioning"; +import { requireDocumentApiKey } from "@/lib/docver-token"; +import { + findLatestVersionForDoc, + getVersionObjectBytes, +} from "@/lib/docver-store"; +import { buildStampedTicketData, stampTicketProps } from "@/lib/lib-tmpl"; +import { parseTicketDataParam } from "@/utils/docver-ticket"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const GENERIC_500 = "เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง"; + +export async function GET(request: Request) { + const auth = requireDocumentApiKey(request); + if (auth !== null) return auth; + + let searchParams: URLSearchParams; + try { + searchParams = new URL(request.url).searchParams; + } catch { + return docverError("URL ของ request ไม่ถูกต้อง", 400); + } + + const type = parseLatestTypeParam(searchParams.get("type")); + if (!type.ok) return docverError(type.error, 400); + + const ticket = parseTicketDataParam(searchParams.get("ticket_data")); + if (!ticket.ok) return docverError(ticket.error, 400); + + // เติม exp + เรียง key + เช็คความยาวก่อนแตะ MinIO — Word ตัดทอนค่า custom + // property ที่ 255 ตัวอักษร คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ ปฏิเสธไปเลยดีกว่า + let ticketData: string | null = null; + if (type.type === "docx") { + const ttl = Number(process.env.TICKET_SEC); + const ttlSeconds = Number.isFinite(ttl) && ttl > 0 ? ttl : 300; + ticketData = buildStampedTicketData(ticket.ticket, ttlSeconds); + if (ticketData.length > 255) { + return docverError( + "ticket_data ยาวเกิน 255 ตัวอักษร (ข้อจำกัดของ Word custom property)", + 400, + ); + } + } + + let row; + try { + row = await findLatestVersionForDoc(ticket.ticket.fid); + } catch (err) { + console.error("docver: ค้น version ล่าสุดล้ม", err); + return docverError(GENERIC_500, 500); + } + if (!row) { + return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404); + } + if (type.type === "pdf" && row.pdfObjectKey === null) { + return docverError("version นี้ไม่มีสำเนา PDF", 404); + } + + let bytes: Buffer; + try { + bytes = await getVersionObjectBytes(row.id, type.type); + } catch (err) { + console.error("docver: ดึง object จาก MinIO ล้ม", err); + return docverError(GENERIC_500, 500); + } + // object เขียนผ่านกลุ่มนี้เองตอน upload (ผ่าน magic bytes แล้ว) — ไม่ตรงตอนนี้ + // = ความผิดปกติของพื้นที่เก็บ ไม่ใช่ความผิดของผู้เรียก จึงเป็น 500 + if (type.type === "docx" ? !isDocxBytes(bytes) : !isPdfBytes(bytes)) { + console.error(`docver: object ของ version ${row.id} magic bytes ไม่ตรงชนิดไฟล์`); + return docverError(GENERIC_500, 500); + } + + if (type.type === "docx") { + // ticketData ถูกตั้งค่าแล้วเสมอเมื่อ type=docx (ด้านบน) — ห้ามกลืน error: + // ไฟล์ไม่มี stamp = add-in เปิดไม่ได้ ต้องตอบ 500 ไม่ใช่คืนไฟล์เสียเงียบ ๆ + try { + bytes = stampTicketProps(bytes, ticketData!, { + secret: addinTicketSecret(), + privateKeyHex: process.env.PRIVATE_KEY_HEX?.trim() || null, + }).buffer; + } catch (err) { + console.error("docver: stamp ticket ล้ม", err); + return docverError(GENERIC_500, 500); + } + } + + return new Response( + // Uint8Array ใหม่ (ก็อปปี้) ตามที่ BodyInit รับ — pattern เดียวกับ route pdf/export + new Uint8Array(bytes), + { + headers: { + "content-type": type.type === "docx" ? DOCX_CONTENT_TYPE : PDF_CONTENT_TYPE, + "content-disposition": contentDispositionValue(row.fileName, type.type), + "cache-control": "no-store, max-age=0", + }, + }, + ); +} diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index e3abd79..166d583 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -146,15 +146,53 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า - URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์ ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย) +### `GET /api/documents/latest?type=docx|pdf&ticket_data=` — ไฟล์ version ล่าสุด (2026-10-31) + +ให้ backend e-Court ดึงไฟล์ **version ล่าสุด** กลับไปเปิดให้ผู้ใช้ (server-to-server — +ไม่ใช่ Bearer: auth ด้วย `x-api-key` อย่างเดียว) · `docId` = ฟิลด์ `fid` **ใน ticket_data** +ไม่ใช่ claim จาก token + +- auth: header `x-api-key` เทียบ env **`DOCVER_API_KEY` หรือ `EXTERNAL_API_KEY` ตัวใด + ตัวหนึ่ง** (ต่างจาก /token ที่รับ `DOCVER_API_KEY` อย่างเดียว — ผู้เรียกถือ key สาย N10 + หรือสาย docver ก็ได้) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความ + เดียวกันเป๊ะ (timing-safe เหมือน `requireDocverApiKey`) +- query (บังคับทั้งคู่): + - `type` = `docx` | `pdf` — **ไม่มี default** (ต่างจาก `format` ของ download-link) + - `ticket_data` = JSON string ของ ticket — **บังคับ `fid` + `uid` + `uname`** (`tid` + optional, `exp` ถ้าส่งต้องเป็นจำนวนเต็มแต่จะถูกเติมใหม่เสมอ) เพราะ + `parseDocverTicket` (`utils/docver-ticket.ts`) บังคับทั้งสามตอน add-in เอาไฟล์ไป + redeem ที่ /token ทาง ticket — validate ตั้งแต่รับเข้า กันคืนไฟล์ที่ add-in เปิด + ไม่ได้เงียบ ๆ · ⚠️ **หมายเหตุถึงฝั่ง e-Court:** `uid` ต้องเป็น*รหัส*ผู้ใช้ (ไม่ใช่ + ชื่อ) และต้องส่ง `uname` (ชื่อแสดงผล) เช่น `{"fid":"DOC-2569-0001","uid":"123", + "uname":"สมชาย ใจดี"}` +- **type=pdf** → คืนไบต์ pdf ของ version ล่าสุดตรง ๆ (`Content-Type: application/pdf`) · + version ล่าสุดไม่มี pdf = 404 (ข้อความเดียวกับ download-link) +- **type=docx** → ดึงไบต์ docx จาก MinIO มา **stamp ticket ใหม่บนสำเนาในหน่วยความจำ** + ก่อนคืน (`buildStampedTicketData` + `stampTicketProps` ของ `lib/lib-tmpl.ts` — recipe + เดียวกับ `applyTemplate` ของกลุ่ม template: เติม `exp = now + TICKET_SEC` → เรียง key + → `HmacSha256` ด้วย secret จาก `addinTicketSecret()` (chain `ADDIN_TICKET_SECRET` → + `SECRET` → `"super_secret"` — ตรงฝั่ง verify ของ add-in//token ทาง ticket เป๊ะ) → + `Ed25519` เฉพาะเมื่อ `PRIVATE_KEY_HEX` ตั้ง (ไม่ตั้ง = ไม่ใส่ prop — chain ของ add-in + verify แค่ HMAC) → `setCustomDocProps` upsert เขียนทับ stamp เดิมได้) — + **ไม่เขียนกลับ MinIO ตาม invariant #1** (non-destructive) · stamp ล้ม = 500 + (ห้ามกลืนเหมือน `applyTemplate` — ไฟล์ไม่มี stamp = add-in เปิดไม่ได้) +- ticket_data หลังเติม exp ยาวเกิน **255 ตัวอักษร = 400** (Word ตัดทอนค่า custom + property ที่ 255 — คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ) +- response 200: ไบต์ไฟล์ + `Content-Type` ตามชนิด + `Content-Disposition` จาก + `fileName` ของ version (`contentDispositionValue`) + `Cache-Control: no-store` + · เช็ค magic bytes ของไบต์ที่อ่านได้ ไม่ตรง = 500 (ความผิดปกติของพื้นที่เก็บ + ไม่ใช่ความผิดของผู้เรียก) + ### ตาราง error | เหตุ | สถานะ | |---|---| | รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId/fields เก่า) · ไม่ใช่ multipart · magic bytes ผิด · แกะข้อมูลจากไฟล์ไม่ได้ · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | -| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 | +| `type` ไม่ส่ง/ค่าอื่น · `ticket_data` ไม่ส่ง/ไม่ใช่ JSON object/field นอก allowlist/ขาด fid·uid·uname/เกิน cap/ยาว >2048 · ticket_data หลังเติม exp ยาว >255 (ที่ /latest เฉพาะ type=docx) — ทั้งหมดของ GET /latest | 400 | +| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) · `x-api-key` ไม่ตั้ง (ทั้งคู่)/ไม่ใส่/ไม่ตรงทั้ง `DOCVER_API_KEY`·`EXTERNAL_API_KEY` (ที่ /latest) | 401 | | ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 | -| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 | -| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) | +| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี (รวม type=pdf ที่ /latest แต่ version ล่าสุดไม่มี pdf) | 404 | +| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) · stamp ticket ล้ม/magic bytes ของ object ไม่ตรง (ที่ /latest) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) | ## 2. ตาราง (db/schema.ts) @@ -221,9 +259,12 @@ read-only ของ N10 แต่ตัวนี้เขียนได้) | `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` | | `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) | | `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL | -| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ | +| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ · GET /latest ใช้ตัวนี้**หรือ** `EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง | | `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ | | `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) | +| `TICKET_SEC` | `300` | TTL (วินาที) ของ `exp` ที่ stamp ตอน GET /latest type=docx — env เดียวกับกลุ่ม template (`app/api/template/*`) | +| `PRIVATE_KEY_HEX` | — optional | private key Ed25519 (hex seed) — ตั้งแล้ว GET /latest type=docx จะ stamp prop `Ed25519` เพิ่ม · ไม่ตั้ง = ไม่ใส่ (chain ของ add-in verify แค่ `HmacSha256`) · env เดียวกับกลุ่ม template | +| `ADDIN_TICKET_SECRET` | chain → `SECRET` → `"super_secret"` | secret ของ HMAC ที่ stamp ที่ /latest — ใช้ `addinTicketSecret()` ให้ตรงฝั่ง verify ของ /token ทาง ticket (ดู `docs/PLAN-ADDIN-AUTH.md`) | `putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''` best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย @@ -235,15 +276,23 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET - `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้ โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`) - `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer` - + `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`) + + `parseTokenRequestBody` + `requireDocumentApiKey` (auth ของ GET /latest — สอง env) + (pure เหมือนกัน — ไม่ import `@/db`/`minio`) - `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) + - `ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers + `ensureBucket` + put/remove/presign + `getVersionObjectBytes` (อ่านไบต์ของ /latest) + + `insertVersionRecord` (tx) + query helpers (`findLatestVersionForDoc` ของ /latest) - `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key) - `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token) - `app/api/documents/versions/[versionId]/download-link/route.ts` — GET +- `app/api/documents/latest/route.ts` — GET ไฟล์ version ล่าสุด (2026-10-31 — docx + stamp ticket บนสำเนาในหน่วยความจำผ่าน `buildStampedTicketData`/`stampTicketProps` + ของ `lib/lib-tmpl.ts` + `parseTicketDataParam` ของ `utils/docver-ticket.ts` + + `parseLatestTypeParam` ของ `lib/document-versioning.ts`) - `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 + (+ `parseTicketDataParam` ใน `tests/utils/docver-ticket.test.ts` + stamp helpers ใน + `tests/lib/lib-tmpl.test.ts` ของ /latest) ## 8. Out of scope รอบนี้ diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index 69ee6fd..9cbc18e 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -78,6 +78,17 @@ export function parseDownloadFormat(raw: string | null): return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" }; } +/** ?type= ของ GET /api/documents/latest — บังคับระบุต่างจาก format ของ + * download-link (ไม่มีค่า default): type=docx เดินไปทาง stamping ซึ่งเปลี่ยน + * ไฟล์ที่คืน จึงอย่าเดาแทนผู้เรียก */ +export function parseLatestTypeParam(raw: string | null): + | { ok: true; type: "docx" | "pdf" } + | DocverError { + const type = raw?.trim() ?? ""; + if (type === "docx" || type === "pdf") return { ok: true, type }; + return { ok: false, error: "type รับค่า docx หรือ pdf เท่านั้น" }; +} + function trimOrNull(value: string): string | null { const trimmed = value.trim(); return trimmed === "" ? null : trimmed; diff --git a/lib/docver-store.ts b/lib/docver-store.ts index 4a98f3a..11083cd 100644 --- a/lib/docver-store.ts +++ b/lib/docver-store.ts @@ -18,6 +18,7 @@ import { } from "@/db/schema"; import { contentDispositionValue, + DOCVER_MAX_UPLOAD_BYTES, DOCX_CONTENT_TYPE, PDF_CONTENT_TYPE, } from "@/lib/document-versioning"; @@ -177,6 +178,35 @@ export async function presignVersionUrl( }; } +/** อ่านไบต์ของ object หนึ่งกลับมาเป็น Buffer — ใช้กับ GET /api/documents/latest + * ที่ต้องคืนไฟล์ผ่าน response เอง (ไม่ใช่ presigned URL) · อ่านอย่างเดียว ไม่เขียน + * กลับ: การ stamp ticket ของ route ทำบนสำเนอในหน่วยความจำ · กันขนาดเกิน cap ของ + * กลุ่ม (object เขียนผ่านกลุ่มนี้เองจึงไม่ควรเกิน — เกิน = ข้อมูลแปลกปลอม อ่านต่อไม่ได้) + * · ล้มทุกกรณี = throw ให้ route catch เป็น 500 */ +export async function getVersionObjectBytes( + versionId: string, + format: "docx" | "pdf", +): Promise { + const { client, config } = getClient(); + const key = versionObjectKeys(versionId)[format]; + const stream = await client.getObject(config.bucket, key); + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + stream.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > DOCVER_MAX_UPLOAD_BYTES) { + stream.destroy(); + reject(new Error(`docver: object ${key} ใหญ่เกิน ${DOCVER_MAX_UPLOAD_BYTES} bytes`)); + return; + } + chunks.push(chunk); + }); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} + // ─── DB ────────────────────────────────────────────────────────────────────── export async function findDocumentByDocId(docId: string): Promise { @@ -207,6 +237,21 @@ export async function findVersionForDoc( return row?.version ?? null; } +/** version ล่าสุดของเอกสาร (docId) — join แบบ findVersionForDoc + เรียงเลข version + * จากมากไปน้อยแล้วเอาตัวแรก · ใช้กับ GET /api/documents/latest */ +export async function findLatestVersionForDoc( + docId: string, +): Promise { + const [row] = await db + .select({ version: documentVersions }) + .from(documentVersions) + .innerJoin(documents, eq(documentVersions.documentId, documents.id)) + .where(eq(documents.docId, docId)) + .orderBy(desc(documentVersions.versionNumber)) + .limit(1); + return row?.version ?? null; +} + /** * เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน * "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7: diff --git a/lib/docver-token.ts b/lib/docver-token.ts index 92bfc41..7d80335 100644 --- a/lib/docver-token.ts +++ b/lib/docver-token.ts @@ -138,6 +138,32 @@ export function requireDocverApiKey(request: Request): Response | null { : docverError("API key ไม่ถูกต้อง", 401); } +/** auth ของ GET /api/documents/latest — x-api-key เทียบ env DOCVER_API_KEY + * หรือ EXTERNAL_API_KEY (ตัวใดตัวหนึ่งตรง = ผ่าน — ผู้เรียกเป็น backend e-Court + * ที่ถือ key สาย N10 หรือสาย docver ก็ได้ ต่างจาก /token ที่รับ DOCVER_API_KEY + * อย่างเดียว) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ข้อความ 401 ของ "ไม่ใส่ + * key" กับ "key ผิด" เป็นอันเดียวกันเหมือน requireDocverApiKey + * คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */ +export function requireDocumentApiKey(request: Request): Response | null { + // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file + const envKeys = [process.env.DOCVER_API_KEY, process.env.EXTERNAL_API_KEY] + .map((value) => value?.trim()) + .filter((value): value is string => value !== undefined && value !== ""); + if (envKeys.length === 0) { + return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); + } + const provided = request.headers.get("x-api-key"); + if (provided === null || provided === "") { + return docverError("API key ไม่ถูกต้อง", 401); + } + const providedHash = createHash("sha256").update(provided).digest(); + const matched = envKeys.some((envKey) => { + const envHash = createHash("sha256").update(envKey).digest(); + return timingSafeEqual(providedHash, envHash); + }); + return matched ? null : docverError("API key ไม่ถูกต้อง", 401); +} + /** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด / * หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */ const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ"; diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index 631ff46..3ebe83b 100755 --- a/lib/lib-tmpl.ts +++ b/lib/lib-tmpl.ts @@ -1376,4 +1376,60 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT } return templateBuffer +} + +// ─── ticket stamp ของ GET /api/documents/latest (กลุ่ม docver) ──────────────── +// recipe เดียวกับ applyTemplate ด้านบนแต่แยกเป็น fn เองเพราะที่มาของ ticket +// ต่างกัน: ของ route นั้นคือ query param จากผู้เรียก (ไม่ใช่ templateData.info) +// และห้ามกลืน error แบบ applyTemplate — การ stamp คือตัวประสงค์ของ endpoint +// ล้ม = throw ให้ route ตอบ 500 (คืนไฟล์ไม่มี stamp เงียบ ๆ = add-in เปิดไม่ได้) + +/** เติม exp ใหม่ (now + ttlSeconds) + เรียง key ตามตัวอักษรแล้วคืน JSON string + * ที่จะ stamp — HMAC/ลายเซ็นต้องคำนวณบน string ดิบนี้เป๊ะ ๆ (ห้าม trim/normalize) + * · ตัด key ที่ค่า null/undefined ทิ้งก่อน เพราะ parser ฝั่ง redeem + * (parseDocverTicket ของ utils/docver-ticket.ts) ไม่ยอม tid:null (ต้องเป็น + * string หรือไม่มี key เลย) — ตัวรับเข้าของ route ใช้ null แทน "ไม่มี" */ +export function buildStampedTicketData( + ticket: Record, + ttlSeconds: number, + nowMs: number = Date.now(), +): string { + const withoutNulls = Object.fromEntries( + Object.entries(ticket).filter(([, value]) => value !== null && value !== undefined), + ); + // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) + const expireTimestamp = Math.floor(nowMs / 1000) + ttlSeconds; + const fullObj = { ...withoutNulls, exp: expireTimestamp }; + const sortedObj = sortObjectKeys(fullObj); + return JSON.stringify(sortedObj); +} + +export type StampTicketOptions = { + /** secret ของ HMAC — ฝั่ง caller ของกลุ่ม docver ใช้ addinTicketSecret() เพื่อ + * ตรงกับฝั่ง verify ของ add-in / POST /api/documents/token ทาง ticket */ + secret: string; + /** hex ของ private key (seed 32 หรือ 64 byte) — ไม่ตั้ง = ไม่ stamp Ed25519 + * (chain ของ add-in verify แค่ HmacSha256 — Ed25519 เป็นข้อมูลเสริม) */ + privateKeyHex?: string | null; +}; + +/** stamp custom properties `ticket_data`/`HmacSha256`(+`Ed25519`) ลงสำเนาในหน่วย + * ความจำของ .docx — setCustomDocProps คืน Buffer ใหม่ ไม่แตะไฟล์ต้นทาง + * (non-destructive) และ upsert ตามชื่อ จึงเขียนทับ stamp เดิมของ template ได้ · + * ล้มทุกกรณี = throw */ +export function stampTicketProps( + docxBuffer: Buffer, + ticketData: string, + options: StampTicketOptions, +): { buffer: Buffer; hmacSha256: string; ed25519: string | null } { + const hmacSha256 = generateHmacSha256(ticketData, options.secret); + const privateKeyHex = options.privateKeyHex?.trim() || null; + const ed25519 = privateKeyHex !== null ? signEd25519(ticketData, privateKeyHex) : null; + const buffer = setCustomDocProps( + docxBuffer, + ed25519 === null + ? { ticket_data: ticketData, HmacSha256: hmacSha256 } + : { ticket_data: ticketData, HmacSha256: hmacSha256, Ed25519: ed25519 }, + ); + return { buffer, hmacSha256, ed25519 }; } \ No newline at end of file diff --git a/lib/openapi.ts b/lib/openapi.ts index 43eff5d..bac961a 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -858,6 +858,100 @@ export const openApiSpec = { }, }, }, + "/api/documents/latest": { + get: { + tags: ["documents"], + summary: + "ดาวน์โหลดไฟล์ของ version ล่าสุด (docx พร้อม stamp ticket ใหม่ / pdf ตรง ๆ)", + description: + "ให้ backend e-Court ดึงไฟล์ **version ล่าสุด** ของเอกสารกลับไปเปิดให้ผู้ใช้ " + + "(server-to-server — ไม่ใช้ Bearer token)\n\n" + + "- **auth**: header `x-api-key` เทียบ env `DOCVER_API_KEY` **หรือ** " + + "`EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง (ต่างจาก /token ที่รับ DOCVER_API_KEY " + + "อย่างเดียว) · ทั้งคู่ไม่ตั้ง = 401 fail closed\n" + + "- เอกสารถูกระบุด้วย `fid` **ใน ticket_data** (ไม่ใช่ token)\n" + + "- `type=pdf` → คืนไบต์ pdf ตรง ๆ (version ล่าสุดไม่มีสำเนา PDF = 404)\n" + + "- `type=docx` → server เติม `exp` ใหม่ (now + `TICKET_SEC` default 300 วิ) + " + + "คำนวณ `HmacSha256` (secret ตาม chain `ADDIN_TICKET_SECRET` → `SECRET` → " + + "`\"super_secret\"` ให้ตรงฝั่ง verify ของ add-in) และ `Ed25519` (เฉพาะเมื่อ " + + "`PRIVATE_KEY_HEX` ตั้ง) แล้วเขียน custom properties ลง**สำเนาในหน่วยความจำ** " + + "ก่อนคืนไฟล์ — object ใน MinIO ไม่ถูกแต่ (non-destructive) และเขียนทับ stamp " + + "เดิมของ template ได้\n" + + "- **ticket_data บังคับ `fid` + `uid` + `uname`** (`tid` optional · `exp` ถ้าส่ง " + + "ต้องเป็นจำนวนเต็ม แต่จะถูกเติมใหม่เสมอ) เพราะ parser ฝั่ง add-in บังคับทั้งสาม — " + + "⚠️ `uid` ต้องเป็น*รหัส*ผู้ใช้ (ไม่ใช่ชื่อ) และต้องส่ง `uname` (ชื่อแสดงผล)\n" + + "- ticket_data หลังเติม exp ยาวเกิน 255 ตัวอักษร = 400 (ข้อจำกัด custom property " + + "ของ Word)", + security: [{ ApiKeyAuth: [] }], + parameters: [ + { + name: "type", + in: "query", + required: true, + schema: { type: "string", enum: ["docx", "pdf"] }, + description: "ชนิดไฟล์ที่จะดาวน์โหลด (บังคับระบุ — ไม่มีค่า default)", + }, + { + name: "ticket_data", + in: "query", + required: true, + schema: { type: "string" }, + description: + "JSON string ของ ticket เช่น " + + "`{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}` " + + "(ต้อง URL-encode — ใช้ URLSearchParams ของฝั่งเรียกก็ได้)", + example: + "{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}", + }, + ], + responses: { + 200: { + description: + "ไบต์ของไฟล์ (docx = ผ่านการ stamp ticket ใหม่ · pdf = ตรงจาก version ล่าสุด)", + headers: { + "Content-Disposition": { + description: "attachment พร้อมชื่อไฟล์ของ version (ชื่อไทยผ่าน filename*)", + schema: { type: "string" }, + }, + "Cache-Control": { + description: "no-store — ไฟล์ผูกกับ ticket/exp ของ request นั้น", + schema: { type: "string" }, + }, + }, + content: { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { + schema: { type: "string", format: "binary" }, + }, + "application/pdf": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + 400: { $ref: "#/components/responses/BadRequest" }, + 401: { + description: + "x-api-key ไม่ตรงทั้ง DOCVER_API_KEY และ EXTERNAL_API_KEY หรือทั้งคู่ไม่ตั้งค่า " + + "(ไม่ใส่ header กับ key ผิดได้ข้อความเดียวกัน)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 404: { + description: + "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version) หรือ type=pdf แต่ version " + + "ล่าสุดไม่มีสำเนา PDF", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 500: { $ref: "#/components/responses/ServerError" }, + }, + }, + }, }, components: { securitySchemes: { @@ -868,7 +962,8 @@ export const openApiSpec = { description: "x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " + "DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " + - "request ของกลุ่มที่ key ตรง)", + "request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " + + "(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)", }, DocverBearer: { type: "http", diff --git a/tests/lib/docver-token.test.ts b/tests/lib/docver-token.test.ts index 6b2a5ab..02bc6e7 100644 --- a/tests/lib/docver-token.test.ts +++ b/tests/lib/docver-token.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { issueDocverToken, parseTokenRequestBody, + requireDocumentApiKey, requireDocverApiKey, requireDocverBearer, verifyDocverToken, @@ -30,12 +31,16 @@ function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T // --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY"); const ORIG_API_KEY = process.env.DOCVER_API_KEY; +const HAD_EXTERNAL_KEY = Object.prototype.hasOwnProperty.call(process.env, "EXTERNAL_API_KEY"); +const ORIG_EXTERNAL_KEY = process.env.EXTERNAL_API_KEY; const HAD_SECRET = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_TOKEN_SECRET"); const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET; afterEach(() => { if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY; else delete process.env.DOCVER_API_KEY; + if (HAD_EXTERNAL_KEY) process.env.EXTERNAL_API_KEY = ORIG_EXTERNAL_KEY; + else delete process.env.EXTERNAL_API_KEY; if (HAD_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET; else delete process.env.DOCVER_TOKEN_SECRET; }); @@ -175,6 +180,59 @@ describe("requireDocverApiKey", () => { }); }); +// requireDocumentApiKey — auth ของ GET /api/documents/latest: x-api-key เทียบ +// DOCVER_API_KEY หรือ EXTERNAL_API_KEY ตัวใดตัวหนึ่ง (ต่างจาก /token) +describe("requireDocumentApiKey", () => { + function setKeys(docver: string | undefined, external: string | undefined): void { + if (docver === undefined) delete process.env.DOCVER_API_KEY; + else process.env.DOCVER_API_KEY = docver; + if (external === undefined) delete process.env.EXTERNAL_API_KEY; + else process.env.EXTERNAL_API_KEY = external; + } + + it("ทั้งคู่ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => { + for (const [docver, external] of [ + [undefined, undefined], + ["", ""], + [" ", " "], + ] as const) { + setKeys(docver, external); + const res = requireDocumentApiKey(reqWithKey("test-key")); + expect(res?.status, `${docver}/${external}`).toBe(401); + expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" }); + } + }); + + it("ตั้งอันเดียวตัวไหนก็ผ่านเมื่อ key ตรงตัวนั้น · ตั้งทั้งคู่ตรงอันที่สองก็ผ่าน", () => { + setKeys("docver-key", undefined); + expect(requireDocumentApiKey(reqWithKey("docver-key"))).toBeNull(); + expect(requireDocumentApiKey(reqWithKey("external-key"))?.status).toBe(401); + + setKeys(undefined, "external-key"); + expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull(); + expect(requireDocumentApiKey(reqWithKey("docver-key"))?.status).toBe(401); + + setKeys("docver-key", "external-key"); + expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull(); + }); + + it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => { + setKeys("docver-key", "external-key"); + const missing = requireDocumentApiKey(reqWithKey()); + const wrong = requireDocumentApiKey(reqWithKey("not-any-key")); + expect(missing?.status).toBe(401); + expect(wrong?.status).toBe(401); + expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!)); + }); + + it("env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้ทั้งสองตัว", () => { + setKeys(" docver-key ", undefined); + expect(requireDocumentApiKey(reqWithKey("docver-key"))).toBeNull(); + setKeys(undefined, " external-key "); + expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull(); + }); +}); + describe("requireDocverBearer", () => { it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => { const deniedBodies = new Set(); diff --git a/tests/lib/lib-tmpl.test.ts b/tests/lib/lib-tmpl.test.ts index fd585d8..a3baf51 100644 --- a/tests/lib/lib-tmpl.test.ts +++ b/tests/lib/lib-tmpl.test.ts @@ -2,11 +2,19 @@ // (2026-10-30): คุมว่าก้อน {cc, bookmark, properties, verify} แกะได้ครบและ verify ทำงาน // ตาม secret ที่ผู้เรียกส่ง · ไฟล์นี้ตั้งใจไม่แตะ module ที่ลาก @/db หรือ minio มาด้วย // (pattern document-versioning.test.ts — รันได้ไม่ต้องตั้ง DATABASE_URL/MINIO_*) -import { createHmac } from "node:crypto"; +import { createHmac, generateKeyPairSync } from "node:crypto"; import { describe, expect, it } from "vitest"; import PizzaZip from "pizzip"; -import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; +import { verifyAddinTicketHmac } from "@/lib/addin-ticket"; +import { verifyEd25519 } from "@/lib/lib-utils"; +import { + buildStampedTicketData, + extractDocumentSnapshot, + getCustomDocProps, + stampTicketProps, +} from "@/lib/lib-tmpl"; +import { parseDocverTicket } from "@/utils/docver-ticket"; const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; @@ -61,6 +69,31 @@ function stampedDocx(): Buffer { }); } +// setCustomDocProps (ทาง stampTicketProps) บังคับให้ zip มี _rels/.rels และ +// [Content_Types].xml ก่อน (ensureCustomRelationship/ensureCustomContentType) +const RELS_XML = + `\n` + + `` + + `` + + ``; + +const CONTENT_TYPES_XML = + `\n` + + `` + + `` + + `` + + ``; + +/** docx จิ๋วที่ stamp ได้ — ครบส่วนที่ setCustomDocProps บังคับ */ +function stampableDocx(extra: Record = {}): Buffer { + return buildDocx({ + "word/document.xml": documentXml(), + "_rels/.rels": RELS_XML, + "[Content_Types].xml": CONTENT_TYPES_XML, + ...extra, + }); +} + describe("extractDocumentSnapshot", () => { it("แกะครบทั้งก้อน {cc, bookmark, properties, verify} เหมือน /api/template/extract", () => { const snapshot = extractDocumentSnapshot(stampedDocx(), { secret: "super_secret" }); @@ -99,3 +132,133 @@ describe("extractDocumentSnapshot", () => { expect(() => extractDocumentSnapshot(Buffer.from("PK\x03\x04-not-a-zip"))).toThrow(); }); }); + +// buildStampedTicketData/stampTicketProps — ฝั่ง stamp ของ GET /api/documents/latest +// (recipe เดียวกับ applyTemplate แต่ ticket มาจาก query param และห้ามกลืน error) +describe("buildStampedTicketData", () => { + it("เติม exp = floor(now/1000)+ttl เรียง key ตามตัวอักษร — nowMs กำหนดได้ให้ deterministic", () => { + const nowMs = 1_793_000_000_000; + const result = buildStampedTicketData({ fid: "F1", uid: "u1", uname: "น" }, 300, nowMs); + expect(result).toBe( + JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "น" }), + ); + // ลำดับ key ขาเข้าไม่มีผล — เรียงเป็น exp,fid,tid,uid,uname เสมอ (tid มีก็เข้าที่) + const shuffled = buildStampedTicketData( + { uname: "น", uid: "u1", tid: "T12", fid: "F1" }, + 300, + nowMs, + ); + expect(Object.keys(JSON.parse(shuffled))).toEqual(["exp", "fid", "tid", "uid", "uname"]); + }); + + it("ผลลัพธ์ redeem ได้จริง — parseDocverTicket (ฝั่ง add-in) แกะผ่าน", () => { + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + expect(parseDocverTicket(ticketData).ok).toBe(true); + }); + + it("ค่า null/undefined ถูกตัดออก — parseTicketDataParam ใช้ null แทน tid ที่ไม่มี แต่ parseDocverTicket ไม่ยอม tid:null", () => { + const result = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ", tid: null }, + 300, + 1_793_000_000_000, + ); + expect(result).toBe( + JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "ทดสอบ" }), + ); + expect(parseDocverTicket(result).ok).toBe(true); + }); +}); + +describe("stampTicketProps", () => { + const SECRET = "super_secret"; + + /** seed 32 byte (hex) + public key 32 byte (hex) จาก node:crypto — เข้ารูปที่ + * signEd25519/verifyEd25519 ของ lib-utils รับ */ + function ed25519KeyPair(): { seedHex: string; publicHex: string } { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const privDer = privateKey.export({ type: "pkcs8", format: "der" }) as Buffer; + const pubDer = publicKey.export({ type: "spki", format: "der" }) as Buffer; + return { + seedHex: privDer.subarray(privDer.length - 32).toString("hex"), + publicHex: pubDer.subarray(pubDer.length - 32).toString("hex"), + }; + } + + it("stamp ticket_data+HmacSha256 ลงไฟล์เปล่า · ไม่ให้ private key = ไม่มี Ed25519", () => { + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + const bare = stampableDocx(); + const result = stampTicketProps(bare, ticketData, { secret: SECRET }); + + expect(result.ed25519).toBeNull(); + expect(result.hmacSha256).toBe( + createHmac("sha256", SECRET).update(ticketData, "utf8").digest("hex"), + ); + const props = getCustomDocProps(result.buffer); + expect(props.ticket_data).toBe(ticketData); + expect(props.HmacSha256).toBe(result.hmacSha256); + expect(props.Ed25519).toBeUndefined(); + // non-destructive — buffer ต้นฉบับไม่ถูกแตะ (route ห้ามเขียนกลับ MinIO) + expect(bare.equals(stampableDocx())).toBe(true); + }); + + it("ให้ private key = มี Ed25519 และ verify ผ่านด้วย public key คู่กัน", () => { + const { seedHex, publicHex } = ed25519KeyPair(); + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + const result = stampTicketProps(stampableDocx(), ticketData, { + secret: SECRET, + privateKeyHex: seedHex, + }); + expect(result.ed25519).not.toBeNull(); + expect(verifyEd25519(ticketData, result.ed25519!, publicHex)).toBe(true); + expect(getCustomDocProps(result.buffer).Ed25519).toBe(result.ed25519); + }); + + it("chain จบถึง end: verifyAddinTicketHmac (ทางเข้า /api/documents/token) ผ่าน", () => { + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + const result = stampTicketProps(stampableDocx(), ticketData, { + secret: SECRET, + }); + const verified = verifyAddinTicketHmac(ticketData, result.hmacSha256, SECRET); + expect(verified.ok).toBe(true); + // secret ผิด = ไม่ผ่าน (HMAC ผูกกับ secret ของ add-in จริง) + expect(verifyAddinTicketHmac(ticketData, result.hmacSha256, "wrong").ok).toBe(false); + }); + + it("stamp ทับไฟล์ที่มี ticket เดิม = ค่าถูกแทนทั้งก้อน (exp เก่าหาย)", () => { + const fresh = buildStampedTicketData( + { fid: "F2", uid: "u2", uname: "ใหม่" }, + 300, + Date.now(), + ); + const result = stampTicketProps( + stampableDocx({ "docProps/custom.xml": customPropsXml(TICKET_DATA, TICKET_HMAC) }), + fresh, + { secret: SECRET }, + ); + const props = getCustomDocProps(result.buffer); + expect(props.ticket_data).toBe(fresh); + expect(String(props.ticket_data)).not.toContain("2000000000"); // exp เก่าของ TICKET_DATA + }); + + it("ไฟล์เสีย = throw (route ตอบ 500 — ห้ามกลืนแบบ applyTemplate)", () => { + expect(() => + stampTicketProps(Buffer.from("not-a-zip"), "{}", { secret: SECRET }), + ).toThrow(); + }); +}); diff --git a/tests/utils/docver-ticket.test.ts b/tests/utils/docver-ticket.test.ts index bdde305..14011e8 100644 --- a/tests/utils/docver-ticket.test.ts +++ b/tests/utils/docver-ticket.test.ts @@ -4,6 +4,7 @@ import { isTicketExpired, normalizeHexDigest, parseDocverTicket, + parseTicketDataParam, timingSafeEqualHex, } from "@/utils/docver-ticket"; @@ -116,3 +117,84 @@ describe("timingSafeEqualHex", () => { expect(timingSafeEqualHex(a, "ab")).toBe(false); }); }); + +// parseTicketDataParam — query param `ticket_data` ของ GET /api/documents/latest: +// ticket ก่อนเติม exp (server เติมตอน stamp) แต่บังคับ fid/uid/uname เพราะ +// parseDocverTicket บังคับทั้งสามตอน add-in redeem — ขาด = ไฟล์ที่คืนไปเปิดไม่ได้ +describe("parseTicketDataParam", () => { + it("fid+uid+uname น้อยสุด = ผ่าน (trim ค่า) · +tid ก็ได้ · ไม่มี exp ก็ผ่าน", () => { + const minimal = parseTicketDataParam( + JSON.stringify({ fid: " F1 ", uid: " u1 ", uname: " ทดสอบ " }), + ); + ok(minimal); + expect(minimal.ticket).toEqual({ fid: "F1", uid: "u1", uname: "ทดสอบ", tid: null }); + + const withTid = parseTicketDataParam( + JSON.stringify({ fid: "F1", tid: "T12", uid: "u1", uname: "ทดสอบ" }), + ); + ok(withTid); + expect(withTid.ticket.tid).toBe("T12"); + }); + + it("tid ส่งค่าว่าง = null · exp ถ้าส่งมาเป็นจำนวนเต็ม = รับ (เติมทับตอน stamp)", () => { + const blankTid = parseTicketDataParam( + JSON.stringify({ fid: "F1", tid: " ", uid: "u1", uname: "n" }), + ); + ok(blankTid); + expect(blankTid.ticket.tid).toBeNull(); + + const withExp = parseTicketDataParam( + JSON.stringify({ exp: 1_793_067_433, fid: "F1", uid: "u1", uname: "n" }), + ); + ok(withExp); + }); + + it("ไม่ส่ง / ว่าง / ยาวเกิน 2048 = ไม่ผ่าน", () => { + expect(parseTicketDataParam(null).ok).toBe(false); + expect(parseTicketDataParam(" ").ok).toBe(false); + expect(parseTicketDataParam("x".repeat(2049)).ok).toBe(false); + }); + + it("JSON ไม่ผ่าน / ไม่ใช่ object (array/string/null/ตัวเลข) = ไม่ผ่าน", () => { + for (const raw of ["not json", "[1,2]", '"text"', "null", "42"]) { + expect(parseTicketDataParam(raw).ok, raw).toBe(false); + } + }); + + it("field อื่นนอก allowlist = ไม่ผ่าน (error ระบุชื่อ field)", () => { + const result = parseTicketDataParam( + JSON.stringify({ fid: "f", uid: "u", uname: "n", oops: 1 }), + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("oops"); + }); + + it("exp ส่งมาผิดชนิด (string/ทศนิยม) = ไม่ผ่าน แม้จะไม่ได้ใช้ค่าเดิม", () => { + for (const exp of ["1793067433", 1.5]) { + const result = parseTicketDataParam( + JSON.stringify({ exp, fid: "f", uid: "u", uname: "n" }), + ); + expect(result.ok, String(exp)).toBe(false); + } + }); + + it("ขาด/ผิดชนิด fid·uid·uname รายตัว = ไม่ผ่าน — บังคับครบเหมือน parseDocverTicket", () => { + for (const ticket of [ + { uid: "u", uname: "n" }, // ไม่มี fid + { fid: "f", uname: "n" }, // ไม่มี uid + { fid: "f", uid: "u" }, // ไม่มี uname + { fid: "", uid: "u", uname: "n" }, // fid ว่าง + { fid: 3, uid: "u", uname: "n" }, // fid เป็น number + { fid: "f", uid: "u", uname: "n", tid: 12 }, // tid ไม่ใช่ string + ]) { + expect(parseTicketDataParam(JSON.stringify(ticket)).ok, JSON.stringify(ticket)).toBe(false); + } + }); + + it("ความยาวเกิน cap: uid > 100 / uname > 200 / fid > 200 / tid > 100 = ไม่ผ่าน", () => { + expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u".repeat(101), uname: "n" })).ok).toBe(false); + expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u", uname: "n".repeat(201) })).ok).toBe(false); + expect(parseTicketDataParam(JSON.stringify({ fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false); + expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false); + }); +}); diff --git a/utils/docver-ticket.ts b/utils/docver-ticket.ts index 14130c6..26b3719 100644 --- a/utils/docver-ticket.ts +++ b/utils/docver-ticket.ts @@ -104,6 +104,96 @@ export function parseDocverTicket(raw: string): DocverTicketParseResult { return { ok: true, ticket: { exp, fid, tid, uid, uname } }; } +/** ticket ก่อนเติม exp ของ parseTicketDataParam — exp เติมตอน stamp เสมอ */ +export type TicketDataParam = { + fid: string; + uid: string; + uname: string; + tid: string | null; +}; + +export type TicketDataParamResult = + | { ok: true; ticket: TicketDataParam } + | { ok: false; error: string }; + +/** cap ความยาวรวมของ query param ticket_data — เทียบ parseTicketTokenBody + * ของ lib/addin-ticket.ts (body ทาง ticket ของ POST /api/documents/token) */ +const TICKET_DATA_PARAM_MAX = 2048; + +/** + * แกะ + ตรวจ query param `ticket_data` ของ GET /api/documents/latest — + * JSON string ของ ticket ที่ยังไม่มี exp (server เติม exp ใหม่ตอน stamp เสมอ + * จึงไม่บังคับ แต่ถ้าส่งมาต้องเป็นจำนวนเต็ม) + * + * บังคับ fid/uid/uname ครบตั้งแต่รับเข้า: parseDocverTicket ด้านบนบังคับทั้งสาม + * ตอน add-in เอาไฟล์ไป redeem ที่ /api/documents/token — ปล่อย ticket ที่ขาด + * field ผ่านไปเงียบ ๆ = คืนไฟล์ที่ add-in เปิดใช้ไม่ได้ภายหลัง + */ +export function parseTicketDataParam(raw: string | null): TicketDataParamResult { + if (raw === null || raw.trim() === "") { + return { ok: false, error: "ต้องระบุ ticket_data (JSON string ของ ticket)" }; + } + if (raw.length > TICKET_DATA_PARAM_MAX) { + return { ok: false, error: `ticket_data ยาวเกิน ${TICKET_DATA_PARAM_MAX} ตัวอักษร` }; + } + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" }; + } + if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + return { ok: false, error: "ticket_data ต้องเป็น JSON object" }; + } + const record = parsed as Record; + for (const key of Object.keys(record)) { + if (!(TICKET_FIELDS as readonly string[]).includes(key)) { + return { + ok: false, + error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`, + }; + } + } + + // exp ไม่บังคับ (server เติมใหม่ตอน stamp) — แต่ถ้าส่งมาต้องผิดรูปไม่ได้ กันเงียบ ๆ + if (record.exp !== undefined && + (typeof record.exp !== "number" || !Number.isSafeInteger(record.exp))) { + return { ok: false, error: "ticket_data ของ exp ต้องเป็นจำนวนเต็ม (unix วินาที)" }; + } + + const field = (name: "uid" | "uname" | "fid"): string | null => { + const value = record[name]; + if (typeof value !== "string" || value.trim() === "") { + return null; + } + const trimmed = value.trim(); + return trimmed.length <= TICKET_LIMITS[name] ? trimmed : null; + }; + + const uid = field("uid"); + if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" }; + const uname = field("uname"); + if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" }; + const fid = field("fid"); + if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" }; + + let tid: string | null = null; + if (record.tid !== undefined) { + if (typeof record.tid !== "string") { + return { ok: false, error: "ticket_data ของ tid ต้องเป็น string" }; + } + const trimmed = record.tid.trim(); + if (trimmed !== "") { + if (trimmed.length > TICKET_LIMITS.tid) { + return { ok: false, error: "ticket_data ของ tid ยาวเกิน 100 ตัวอักษร" }; + } + tid = trimmed; + } + } + + return { ok: true, ticket: { fid, uid, uname, tid } }; +} + /** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */ export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean { return exp <= nowSec;