Merge branch 'forgejo-dev' into working
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s

Resolve CLAUDE.md: คำอธิบาย lib/document-versioning.ts เอาของ
forgejo-dev (field `fields` ถอดออก 2026-10-30) ส่วน lib/docver-token.ts
เอาของ working (purpose login / verifyDocverLoginToken — โค้ดฝั่ง
forgejo-dev มีครบแล้ว แค่คำอธิบายล้าหลัง)

Verify: npx tsc --noEmit ผ่าน · vitest 23 files / 338 tests ผ่าน

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-11-02 10:07:08 +07:00
commit a288e5643c
31 changed files with 1445 additions and 278 deletions

View file

@ -10,8 +10,8 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template # สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
EXTERNAL_API_KEY='secret-api-key-1234' EXTERNAL_API_KEY='secret-api-key-1234'
# ที่เก็บไฟล์เทมเพลต(docx) # ที่เก็บไฟล์เทมเพลต(docx) ค่า default คือ /app/public/tmpl
TMPL_PATH='./public/tmpl' #TMPL_PATH='./public/tmpl'
# Sign/Hash ที่ใช้ฝังในเอกสาร(docx) # Sign/Hash ที่ใช้ฝังในเอกสาร(docx)
SECRET="super_secret" SECRET="super_secret"
@ -39,6 +39,8 @@ MINIO_SECRET_KEY=minioadmin
# และ POST /api/note/auth/token (เส้น mint login-token ของแอป note — PLAN-SSO.md) # และ POST /api/note/auth/token (เส้น mint login-token ของแอป note — PLAN-SSO.md)
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง) # (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
# GET /api/documents/latest (2026-10-31) รับ DOCVER_API_KEY นี้ หรือ EXTERNAL_API_KEY
# (ด้านบน) ตัวใดตัวหนึ่งตรง = ผ่าน — ทั้งคู่ไม่ตั้ง = 401 fail closed เช่นกัน
DOCVER_API_KEY= DOCVER_API_KEY=
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer) # secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
# + login-token ของ SSO (แยกสิทธิ์ด้วย claim purpose — PLAN-SSO.md D4) # + login-token ของ SSO (แยกสิทธิ์ด้วย claim purpose — PLAN-SSO.md D4)

View file

@ -56,14 +56,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-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/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) — 2026-10-29 (PLAN-SSO.md D4): claims มี `purpose?: "docver"|"login"` (undefined = docver — token เก่าตอน roll ไม่ตาย), `issueDocverToken` รับ purpose ทาง param (default "docver"), `requireDocverBearer` ปฏิเสธ login-token (กัน scope escalation), **`verifyDocverLoginToken`** แยก reason expired/invalid + บังคับ purpose=login สำหรับ SSO route lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) — 2026-10-29 (PLAN-SSO.md D4): claims มี `purpose?: "docver"|"login"` (undefined = docver — token เก่าตอน roll ไม่ตาย), `issueDocverToken` รับ purpose ทาง param (default "docver"), `requireDocverBearer` ปฏิเสธ login-token (กัน scope escalation), **`verifyDocverLoginToken`** แยก reason expired/invalid + บังคับ purpose=login สำหรับ SSO route
lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/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/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/ ← **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/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/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/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) 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 ใช้ร่วม) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
@ -91,7 +91,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/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/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/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
app/note/page.tsx ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/note/auth/session → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public app/note/page.tsx ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/note/auth/session → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public
app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` + section Flow ของแอป — 2026-10-30 รอบสอง) + **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` (เปิดด้วย env `NOTE_SSO_DEV_MINT=1` เท่านั้น) · **public เหมือนหน้าแรก** (ต้อง mint ลิงก์ตอนยังไม่มี session — ฉบับ token 2026-10-30 ไม่มี middleware ทุกหน้าเป็น shell public อยู่แล้ว) · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract) app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` + section Flow ของแอป — 2026-10-30 รอบสอง) + **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` (เปิดด้วย env `NOTE_SSO_DEV_MINT=1` เท่านั้น) · **public เหมือนหน้าแรก** (ต้อง mint ลิงก์ตอนยังไม่มี session — ฉบับ token 2026-10-30 ไม่มี middleware ทุกหน้าเป็น shell public อยู่แล้ว) · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract)

View file

@ -0,0 +1,120 @@
// PLAN-DOCVER.md §1 — GET /api/documents/latest?type=docx|pdf&ticket_data=<json>
// คืนไฟล์ของ 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",
},
},
);
}

View file

@ -6,7 +6,8 @@
// ไม่รับจาก URL/form อีก (lib/docver-token.ts) // ไม่รับจาก URL/form อีก (lib/docver-token.ts)
import { createHash, randomUUID } from "node:crypto"; 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 { import {
docverError, docverError,
DOCVER_MAX_UPLOAD_BYTES, DOCVER_MAX_UPLOAD_BYTES,
@ -16,7 +17,9 @@ import {
parseVersionUploadForm, parseVersionUploadForm,
sanitizeFilePartName, sanitizeFilePartName,
} from "@/lib/document-versioning"; } from "@/lib/document-versioning";
import { extractContentControls } from "@/lib/lib-tmpl";
import { requireDocverBearer } from "@/lib/docver-token"; import { requireDocverBearer } from "@/lib/docver-token";
import { extractDocumentSnapshot } from "@/lib/lib-tmpl";
import { import {
findDocumentByDocId, findDocumentByDocId,
insertVersionRecord, insertVersionRecord,
@ -31,6 +34,7 @@ export const dynamic = "force-dynamic";
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก) /** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes → * ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
* แกะ fields จากไฟล์ (lib-tmpl เหมือน /api/template/extract) →
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4) * putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative * response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */ * จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
@ -92,6 +96,21 @@ export async function POST(request: Request) {
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400); 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 contentHash = createHash("sha256").update(docxBytes).digest("hex");
const versionId = randomUUID(); const versionId = randomUUID();
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) → // ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
@ -119,7 +138,7 @@ export async function POST(request: Request) {
clientVersionId: parsed.form.clientVersionId, clientVersionId: parsed.form.clientVersionId,
contentHash, contentHash,
message: parsed.form.message, message: parsed.form.message,
fields: parsed.form.fields, fields,
templateId: auth.claims.template, templateId: auth.claims.template,
fileName: docxDisplayName, fileName: docxDisplayName,
docxObjectKey: keys.docx, docxObjectKey: keys.docx,

View file

@ -1,46 +1,79 @@
// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework // ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework
// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ // โดย ผ่าน Form(POST) หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx // จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx
// import { headers } from "next/headers";
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY||'';
const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??""; const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl";
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??""; const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || '';
const TMPL_PATH = process.env.TMPL_PATH??"";
if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) {
console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH')
}
// console.log("download-docx env",DOCEDIT_BASE_URL,EXTERNAL_API_KEY,TMPL_PATH)
async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>> async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>>
{ {
// console.log("processRequest get:",fid,tid,uid,uname) //กัน path traversal attack
// console.log(process.cwd()) const TID = path.basename(tid)
const filePath = path.join(TMPL_PATH,`${fid}.json`); const FID = path.basename(fid)
try { const ticketObj = {fid:FID,tid:TID,uid,uname}
if (!fid||!tid||!uid||!uname) { const ticket_data = JSON.stringify({fid:FID,tid:TID,uid,uname})
const filePath = path.join(TMPL_PATH,`${FID}.json`);
// สำหรับการ demo ในแอปเดียวเท่านั้น สามารถคำนวนหา base path ได้ด้วยวิธีนี้
// const host = headers().get("host"); // e.g., localhost:3000
// const proto = headers().get("x-forwarded-proto") || "http";
// const baseUrl = process.env.NEXT_PUBLIC_BASE_PATH || ""
// const fullUrl = `${proto}://${host}${baseUrl}`;
// const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || fullUrl;
if(!DOCEDIT_BASE_URL || !EXTERNAL_API_KEY) {
return NextResponse.json({ error: 'Server must set Environment variable DOCEDIT_BASE_URL or EXTERNAL_API_KEY' }, { status: 500 });
}
if (!FID||!TID||!uid||!uname) {
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 }); return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 });
} }
try { //ตรวจสอบ docx เวอร์ชั่นล่าสุดก่อนถ้าไม่พบค่อยเอาจากฐานข้อมูลมาสร้าง raw Json
const params = new URLSearchParams({type:'docx',ticket_data});
const lastestDocxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/document/latest?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-api-key': EXTERNAL_API_KEY,
}
});
if(!lastestDocxResponse.ok && lastestDocxResponse.status !== 404){//เกิดปัญหาที่ไม่ใช่ Not Found
const errorText = await lastestDocxResponse.text();
console.error('download-docx:Invalid latest docx Response:', errorText);
return NextResponse.json(
{error:`Error from docedit latest: Expected docx stream but received ${lastestDocxResponse.status} ${lastestDocxResponse.statusText}`},
{ status: 502 }
);
}
} catch (error) {
console.error(error)
return NextResponse.json({error:'Internal Server Error'}, { status: 500 });
}
console.log('No version found, generating docx from template and JSON data...');
try { // ไม่พบเวอร์ชั้นให้สร้าง docx จาก template และ json(ฐานข้อมูล)
// อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล // อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล
const rawJson = await fs.readFile(filePath, 'utf-8'); const rawJson = await fs.readFile(filePath, 'utf-8');
const jsonData = JSON.parse(rawJson); const jsonData = JSON.parse(rawJson);
if (!jsonData || typeof jsonData !== 'object' ) { if (!jsonData || typeof jsonData !== 'object' ) {
return NextResponse.json({error:'Invalid data format'}, { status: 400 }); return NextResponse.json({error:'Invalid data format'}, { status: 400 });
} }
/////// !! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend !!//
/////// ไม่มี session return 401, ถ้าเจอแต่ไม่มีสิทธิ์ให้ return 403 , อ่านจากฐานข้อมูล ถ้าไม่พบข้อมูลให้ return 404,
//!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!! // ข้อมูลจำเป็นต้องมี jsonData.info.ticket_data ถ้าไม่มีให้สร้างขึ้นมาเอง
// ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data
if(!Object.hasOwn(jsonData, 'info')) if(!Object.hasOwn(jsonData, 'info'))
jsonData['info']={} jsonData['info']={}
jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname}) jsonData.info['ticket_data'] = JSON.stringify(ticketObj)
// sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้ // sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้
//ยิงไปหาระบบ Docedit เพื่อเอา Stream //ยิงไปหาระบบ Docedit เพื่อเอา Stream
// console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`)
const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, { const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -52,7 +85,6 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr
// เช็ค Content-Type และ Status ก่อน // เช็ค Content-Type และ Status ก่อน
const contentType = docxResponse.headers.get('content-type') || ''; const contentType = docxResponse.headers.get('content-type') || '';
if (!docxResponse.ok || contentType.includes('text/html')) { if (!docxResponse.ok || contentType.includes('text/html')) {
const errorText = await docxResponse.text(); const errorText = await docxResponse.text();
console.error('download-docx:Invalid docx Response:', errorText); console.error('download-docx:Invalid docx Response:', errorText);
@ -65,9 +97,8 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr
// 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ // 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ
const stream = docxResponse.body; const stream = docxResponse.body;
// 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์ // 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
const download_file = `${fid}_${uid}_xxx` const download_file = `${FID}_${uid}_xxx`
return new NextResponse(stream as unknown as BodyInit, { return new NextResponse(stream as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
@ -78,7 +109,7 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr
}, },
}); });
} catch (error:any) { } catch (error:any) {
if ( error.code === 'ENOENT') { if ( error.code === 'ENOENT') {// ไฟล์ JSON ไม่พบ ถ้าใช้ฐานข้อมูลจริงให้ return 404 แทน
return NextResponse.json( return NextResponse.json(
{ error: `Data not found: ${filePath}`}, { error: `Data not found: ${filePath}`},
{ status: 404 } { status: 404 }

View file

@ -1,6 +1,5 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; import { extractDocumentSnapshot } from "@/lib/lib-tmpl";
import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
// export const runtime = "nodejs"; // export const runtime = "nodejs";
const DOCX_CONTENT_TYPE = const DOCX_CONTENT_TYPE =
@ -40,32 +39,13 @@ export async function POST(request: Request) {
// File → Buffer // File → Buffer
const buffer = Buffer.from(await file.arrayBuffer()); const buffer = Buffer.from(await file.arrayBuffer());
// ใช้ lib-tmpl.ts เดิม // ชุดแกะข้อมูล `{cc, bookmark, properties, verify}` จาก lib-tmpl.ts — ตัวเดียวกับที่
// const data = extractContentControls(buffer); // ใช้เก็บเป็น fields ของ version ตอน POST /api/documents/versions (2026-10-30) ·
// secret/public_key เป็นของผู้เรียกเพื่อ "ทดสอบ verify" เฉพาะฝั่งนี้
const cc = extractContentControls(buffer); const returnData = extractDocumentSnapshot(buffer, {
const bookmark = extractBookmark(buffer) secret: TEST_SECRET || undefined,
const properties = getDocProps(buffer) publicKeyHex: TEST_PUBLIC_KEY_HEX || undefined,
let verify: Record<string, unknown> ={} });
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<string, unknown> = {cc,bookmark,properties,verify}
return NextResponse.json(returnData); return NextResponse.json(returnData);
} catch (error) { } catch (error) {
console.error(error); console.error(error);

View file

@ -6,22 +6,22 @@ import { NextRequest, NextResponse } from 'next/server';
import {readFile} from 'fs/promises'; import {readFile} from 'fs/promises';
import path from 'path'; import path from 'path';
import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234'; const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234';
const TMPL_PATH = process.env.TMPL_PATH || ''
const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||'' const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||''
const SECRET = process.env.SECRET || '' const SECRET = process.env.SECRET || 'super_secret'
const TICKET_SEC = Number(process.env.TICKET_SEC||'300') const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl";
const TICKET_SEC = Number(process.env.TICKET_SEC||'120')
if (!EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET)
console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX")
// console.log("generate env:",EXTERNAL_API_KEY,TMPL_PATH,PRIVATE_KEY_HEX,SECRET)
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (!EXTERNAL_API_KEY || !PRIVATE_KEY_HEX || !SECRET){
return NextResponse.json(
{ error: 'Server Error: require EXTERNAL_API_KEY PRIVATE_KEY_HEX and SECRET' },
{ status: 500 }
);
}
// 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key) // 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key)
const apiKey = request.headers.get('x-api-key'); const apiKey = request.headers.get('x-api-key')||'';
if (!apiKey || apiKey !== EXTERNAL_API_KEY) { if (!apiKey || apiKey !== EXTERNAL_API_KEY) {
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized: Invalid API Key ' }, { error: 'Unauthorized: Invalid API Key ' },
@ -61,7 +61,7 @@ export async function POST(request: NextRequest) {
//เปิดไฟล์ template //เปิดไฟล์ template
const tmplPath = path.join(TMPL_PATH, `${tid}.docx`); const tmplPath = path.join(TMPL_PATH, `${path.basename(tid)}.docx`);
let templateBuffer: Buffer = await readFile(tmplPath) let templateBuffer: Buffer = await readFile(tmplPath)
const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC); const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC);

View file

@ -90,12 +90,23 @@ export default function HomePage() {
Versioning(คุมเวอร์ชั่น)<br /> Versioning(คุมเวอร์ชั่น)<br />
&darr; <br /> &darr; <br />
ecourt(อัปเดตฐานข้อมูลและไฟล์)<br /> ecourt(อัปเดตฐานข้อมูลและไฟล์)<br />
</div>
</div>
</div>
<div className="p-8 max-w-md mx-auto space-y-8 bg-gray-50 min-h-screen">
<div className="p-10 max-w-md border rounded-xl bg-white shadow-sm">
<h2 className="font-semibold text-blue-600 mb-2">Addin</h2>
ติดตั้ง Word Add-ins ด้วยสคริปต์นี้ <br/>
<Link href="/wordaddin/install-word-addins.ps1"> install-word-addins.ps1 </Link>
<br/>
ติดติดปัญหาบน Power Shell ให้เรียกสองบรรทัดล่างก่อน <br />
<p className="whitespace-nowrap">Unblock-File .\install-word-addins.ps1</p>
<p className="whitespace-nowrap">Set-ExecutionPolicy -Scope Process -ExecutionPolicy RemoteSigned -Force</p>
<p className="whitespace-nowrap">.\install-word-addins.ps1 -Install</p>
</div> </div>
</div> </div>
</div> </div>
</div>
); );
} }

View file

@ -0,0 +1,17 @@
import fs from "node:fs";
import path from "node:path";
import { requestOrigin } from "@/utils/request-origin";
// GET /wordaddin/install-word-addin.ps1 — สำหรับติดตั้ง Word add-in
const DEV_ORIGIN = "https://localhost:3000";
const TEMPLATE = path.join(process.cwd(),"app","wordaddin","install-word-addins.ps1", "template.ps1");
export function GET(req: Request): Response {
const template = fs.readFileSync(TEMPLATE, "utf8");
const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req));
return new Response(manifest, {
headers: {
"content-type": "text/plain; charset=utf-8",
"Content-Disposition": "attachment; filename=install-word-addins.ps1",
"cache-control": "no-store",
},
});
}

View file

@ -0,0 +1,98 @@
# ติดตั้ง (sideload) / ถอด Word add-in "e-Court Documents Versioning" บน Windows
# เวอร์ชัน PowerShell แบบส่งให้เครื่องทดสอบที่ไม่มี Node/repo — คู่หูของ
# scripts/install-wordaddin.mjs (รันใน repo) และ scripts/install-wordaddin.sh (macOS)
# กลไกเดียวกันทั้งสามไฟล์: โหลด manifest จาก server เก็บไว้
# %USERPROFILE%\Documents\wordaddin-manifest\ แล้วจดทะเบียน registry
# HKCU\Software\Microsoft\Office\16.0\Wef\Developer (กลไกเดียวกับ office-addin-dev-settings)
#
# ใช้: powershell -ExecutionPolicy Bypass -File install-wordaddin.ps1 [-Url <manifest-url>] [-Remove]
# -Url default https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml
# -Remove ถอดการติดตั้ง
#
# (ไฟล์นี้ต้องมี BOM — Windows PowerShell 5.1 อ่าน UTF-8 ไม่มี BOM เป็น ANSI แล้วข้อความไทยพัง)
# หลังลง/ถอดต้อง restart Word ทุกครั้ง · รายละเอียดเต็ม: docs/WORDADDIN-DEV.md
param(
[string]$Url = "https://localhost:3000/docedit/wordaddin/manifest.xml",
[switch]$Remove,
[switch]$Install
)
$ErrorActionPreference = "Stop"
# ให้ console พิมพ์ไทย/✓ ถูกต้อง และบังคับ TLS 1.2 (PS 5.1 บางเครื่อง default ยังเป็น TLS 1.0)
try { [Console]::OutputEncoding = [System.Text.Encoding]::UTF8 } catch { }
[Net.ServicePointManager]::SecurityProtocol = `
[Net.ServicePointManager]::SecurityProtocol -bor [Net.SecurityProtocolType]::Tls12
$Prefix = "[install-addin]"
$DevKeyCmd = "HKCU\Software\Microsoft\Office\16.0\Wef\Developer"
$ManifestDir = Join-Path $env:USERPROFILE "Documents\wordaddin-manifest"
$ManifestPath = Join-Path $ManifestDir "manifest.xml"
function Fail($message) {
Write-Host "$Prefix ✗ $message" -ForegroundColor Red
exit 1
}
if ($Remove) {
$Prefix = "[Uninstall-addin]"
Write-Host "$Prefix platform: win32 · โหมดถอดการติดตั้ง"
if (-not (Test-Path $ManifestPath)) {
Fail "ไม่พบ manifest ที่เก็บไว้ ($ManifestPath) จึงไม่รู้ GUID ของ add-in — ลบเองได้ที่: reg delete `"$DevKeyCmd`" /v <GUID> /f"
}
$stored = Get-Content -Raw -Encoding UTF8 $ManifestPath
if ($stored -notmatch "<Id>([0-9a-fA-F-]{36})</Id>") {
Fail "อ่าน GUID จาก $ManifestPath ไม่ได้"
}
$guid = $Matches[1].ToLower()
& reg.exe delete $DevKeyCmd /v $guid /f | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail "reg delete ไม่สำเร็จ (exit $LASTEXITCODE) — ลบเองได้ที่: reg delete `"$DevKeyCmd`" /v $guid /f"
}
Remove-Item $ManifestPath -ErrorAction SilentlyContinue
Write-Host "$Prefix ✓ ถอดการติดตั้งแล้ว (registry $guid + ไฟล์ $ManifestPath)"
Write-Host "$Prefix ✓ เสร็จ"
exit 0
}
if ($Install) {
Write-Host "$Prefix platform: win32"
Write-Host "Install from $Url"
try {
$response = Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 30
} catch {
Fail "โหลด manifest จาก $Url ไม่ได้ ($_)"
}
$xml = $response.Content
if ($xml -notmatch "<OfficeApp") {
Fail "response จาก $Url ไม่ใช่ manifest ของ Office add-in (ไม่มี <OfficeApp)"
}
if ($xml -notmatch "<Id>([0-9a-fA-F-]{36})</Id>") {
Fail "หา <Id> (GUID) ใน manifest ไม่เจอจาก $Url"
}
$guid = $Matches[1].ToLower()
New-Item -ItemType Directory -Force -Path $ManifestDir | Out-Null
# เขียน UTF-8 ไม่มี BOM — ให้ manifest เหมือนต้นฉบับบน server เป๊ะ
[System.IO.File]::WriteAllText($ManifestPath, $xml, (New-Object System.Text.UTF8Encoding($false)))
Write-Host "$Prefix ✓ เก็บ manifest ไว้ที่ $ManifestPath"
& reg.exe add $DevKeyCmd /v $guid /t REG_SZ /d $ManifestPath /f | Out-Null
if ($LASTEXITCODE -ne 0) {
Fail "เขียน registry ไม่สำเร็จ (exit $LASTEXITCODE)"
}
Write-Host "$Prefix ✓ จดทะเบียน add-in ใน registry ($DevKeyCmd → $guid)"
Write-Host "$Prefix ✓ เสร็จ"
# Print-NextSteps
Write-Host ""
Write-Host "ขั้นต่อไป:"
Write-Host " 1. restart Word ให้ครบทุกหน้าต่าง (จำเป็นทุกครั้งหลังติดตั้ง/ถอนการติดตั้ง)"
Write-Host ' 2. เมนู Home > Add-ins> กดแล้วเลือก "e-Court Documents Versioning"'
Write-Host " 3. เปิดไฟล์ .docx ที่มี ticket ที่ยังไม่หมดอายุ เพื่อเริ่มทดสอบ"
exit 0
}
Write-Host "วิธีการใช้:"
Write-Host "ปิด Microsoft Word แล้วเรียกคำสั่งนี้"
Write-Host " install-word-addins [-Install] [-Remove] [-Url http://domain/manifest.yaml]"
Write-Host "ถ้าติดสิทธิ์ในการรันสคริปต์ให้เรียกคำสั่งนี้:"

View file

@ -12,6 +12,9 @@ import {
uuid, uuid,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
// type-only — ไม่ลาก runtime ของ lib-tmpl มาที่ schema (ใช้เป็นชนิดของ fields jsonb)
import type { DocumentExtractSnapshot } from "@/lib/lib-tmpl";
// ─── Enum ──────────────────────────────────────────────────────────────────── // ─── Enum ────────────────────────────────────────────────────────────────────
export const annotationType = pgEnum("annotation_type", [ export const annotationType = pgEnum("annotation_type", [
@ -275,6 +278,14 @@ export const documents = pgTable(
/** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version) /** version ของเอกสาร — ไฟล์จริงอยู่ใน MinIO ตาม object key (docx + pdf ต่อ version)
* · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf) * · pdf เป็น optional ตาม contract ของ add-in (best-effort — NULL = version นี้ไม่มี pdf)
* · object key เป็น uuid ล้วน `docver/<versionId>/document.{docx,pdf}` ไม่มี input ผู้ใช้ */ * · object key เป็น uuid ล้วน `docver/<versionId>/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( export const documentVersions = pgTable(
"document_versions", "document_versions",
{ {
@ -290,9 +301,9 @@ export const documentVersions = pgTable(
contentHash: text("content_hash").notNull(), contentHash: text("content_hash").notNull(),
/** commit message (FR-13) — trim แล้วว่าง = NULL */ /** commit message (FR-13) — trim แล้วว่าง = NULL */
message: text("message"), message: text("message"),
/** snapshot ตอน upload: map ดิบ `{ [sdtTag]: text }` จาก content controls /** snapshot ตอน upload: ก้อน extract snapshot `{cc, bookmark, properties, verify}`
* (รูปแบบ {value,missing} ของเอกสารผูก template ยังไม่มีผู้ส่ง — วันหน้ามีค่อยขยาย $type) */ * เหมือน return ของ /api/template/extract (ดู DocumentVersionFields เหนือตาราง) */
fields: jsonb("fields").$type<Record<string, string>>(), fields: jsonb("fields").$type<DocumentVersionFields>(),
templateId: text("template_id"), templateId: text("template_id"),
/** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) — /** ชื่อไฟล์ดั้งเดิมที่ browser ส่ง (ผ่าน decodeUploadFilename + strip path แล้ว) —
* ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */ * ใช้ตั้ง Content-Disposition ของ object ไม่ใช่ชื่อ object */
@ -447,7 +458,7 @@ export type DocumentVersionDTO = {
createdByUserId: string; createdByUserId: string;
createdByUserName: string; createdByUserName: string;
contentHash: string; contentHash: string;
fields: Record<string, string> | null; fields: DocumentVersionFields | null;
message: string | null; message: string | null;
hasPdf: boolean; hasPdf: boolean;
}; };

View file

@ -23,16 +23,7 @@ ENV NEXT_TELEMETRY_DISABLED=1
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data — # db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run # ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder 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 RUN npm run build

View file

@ -80,13 +80,23 @@ multipart/form-data:
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | | `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) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) — - field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) —
**breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว **breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว
(ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน (ส่งมา = 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 - เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
"สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token) "สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token)
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }` - response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
@ -106,7 +116,16 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า
createdByUserId: string; // = user_id createdByUserId: string; // = user_id
createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม) createdByUserName: string; // = user_name (ส่วนเสริมนอก contract เดิม)
contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server) contentHash: string; // SHA-256 hex ของ docx (คำนวณฝั่ง server)
fields: Record<string, string> | null; fields: { // ก้อนเดียวกับ return ของ /api/template/extract (2026-10-30)
cc: Record<string, string>; // ค่า Content Control { [tag]: text }
bookmark: Record<string, string>; // ข้อความใน bookmark { [name]: text }
properties: { // core/extended/custom props (Date → ISO string)
standard?: Record<string, string | undefined>;
extended?: Record<string, number | string | undefined>;
custom?: Record<string, string | number | boolean | null>;
};
verify: Record<string, unknown>; // exp (ฟอร์แมตไทย) + HmacSha256 (secret ฝั่ง server)
} | null; // null = row เก่าก่อน 2026-10-30 (map ดิบ) หรือไม่มีเลย
message: string | null; message: string | null;
hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว hasPdf: boolean; // derive จาก pdf_object_key !== null ตัวเดียว
} }
@ -127,15 +146,53 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า
- URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์ - URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์
ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย) ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย)
### `GET /api/documents/latest?type=docx|pdf&ticket_data=<json>` — ไฟล์ 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 ### ตาราง error
| เหตุ | สถานะ | | เหตุ | สถานะ |
|---|---| |---|---|
| รูป 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 | | `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 | | ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 | | เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี (รวม type=pdf ที่ /latest แต่ version ล่าสุดไม่มี pdf) | 404 |
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) | | MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) · stamp ticket ล้ม/magic bytes ของ object ไม่ตรง (ที่ /latest) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
## 2. ตาราง (db/schema.ts) ## 2. ตาราง (db/schema.ts)
@ -174,7 +231,9 @@ document_versions — id uuid PK · document_id uuid FK→documents CASCADE
## 4. ลำดับงานต่อ request (POST) + เก็บกวาด object กำพร้า ## 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)** — → **putObject MinIO** (docx ก่อน pdf; pdf ล้ม = ลบ docx ทิ้ง) → **insert DB (tx §3)** —
ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4) ล้ม = `removeObject` ทั้งคู่แล้วโยนต่อ (pattern เดียวกับ `unlink` + rethrow ของ N4)
@ -200,9 +259,12 @@ read-only ของ N10 แต่ตัวนี้เขียนได้)
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` | | `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) | | `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
| `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL | | `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_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) | | `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''` `putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
@ -214,15 +276,23 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้ - `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`) โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
- `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer` - `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`) + - `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/token/route.ts` — POST ขอ access_token (x-api-key)
- `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token) - `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token)
- `app/api/documents/versions/[versionId]/download-link/route.ts` — GET - `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 - `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io) (โฮสต์ 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 - `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 รอบนี้ ## 8. Out of scope รอบนี้

View file

@ -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 เงียบ ๆ /** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token * (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 = [ const FORM_FIELDS = [
"file", "file",
"pdf", "pdf",
"clientVersionId", "clientVersionId",
"fields",
"message", "message",
"fileName", "fileName",
] as const; ] as const;
@ -77,6 +78,17 @@ export function parseDownloadFormat(raw: string | null):
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" }; 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 { function trimOrNull(value: string): string | null {
const trimmed = value.trim(); const trimmed = value.trim();
return trimmed === "" ? null : trimmed; return trimmed === "" ? null : trimmed;
@ -108,39 +120,10 @@ export function sanitizeFilePartName(raw: string): string | null {
return sanitizeDisplayFileName(decodeUploadFilename(raw)); return sanitizeDisplayFileName(decodeUploadFilename(raw));
} }
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — /** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — ถอดออกแล้ว
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */ * (2026-10-30): form field "fields" ยกเลิก ไม่รับจากผู้เรียกอีก — fields ของ version
export function parseFieldsJson(raw: string): * แกะจากไฟล์ .docx ที่อัปโหลดฝั่ง server ด้วย extractContentControls ของ
| { ok: true; fields: Record<string, string> | null } * lib/lib-tmpl.ts (ตัวเดียวกับ /api/template/extract) ที่ route ของ POST versions */
| 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<string, unknown>;
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<string, string>) };
}
export type ParsedVersionUploadForm = { export type ParsedVersionUploadForm = {
clientVersionId: string | null; clientVersionId: string | null;
@ -148,7 +131,6 @@ export type ParsedVersionUploadForm = {
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง /** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */ * เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
fileName: string | null; fileName: string | null;
fields: Record<string, string> | null;
}; };
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน /** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
@ -180,15 +162,6 @@ export function parseVersionUploadForm(form: FormData):
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` }; return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
} }
const rawFields = formString(form, "fields");
if (!rawFields.ok) return rawFields;
let fields: Record<string, string> | null = null;
if (rawFields.value !== null) {
const parsed = parseFieldsJson(rawFields.value);
if (!parsed.ok) return parsed;
fields = parsed.fields;
}
const rawFileName = formString(form, "fileName"); const rawFileName = formString(form, "fileName");
if (!rawFileName.ok) return rawFileName; if (!rawFileName.ok) return rawFileName;
@ -198,7 +171,6 @@ export function parseVersionUploadForm(form: FormData):
clientVersionId: clientVersionId.value, clientVersionId: clientVersionId.value,
message: message.value, message: message.value,
fileName: sanitizeDisplayFileName(rawFileName.value), fileName: sanitizeDisplayFileName(rawFileName.value),
fields,
}, },
}; };
} }

View file

@ -13,10 +13,12 @@ import {
documents, documents,
documentVersions, documentVersions,
type DocumentRow, type DocumentRow,
type DocumentVersionFields,
type DocumentVersionRow, type DocumentVersionRow,
} from "@/db/schema"; } from "@/db/schema";
import { import {
contentDispositionValue, contentDispositionValue,
DOCVER_MAX_UPLOAD_BYTES,
DOCX_CONTENT_TYPE, DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE, PDF_CONTENT_TYPE,
} from "@/lib/document-versioning"; } from "@/lib/document-versioning";
@ -176,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<Buffer> {
const { client, config } = getClient();
const key = versionObjectKeys(versionId)[format];
const stream = await client.getObject(config.bucket, key);
return await new Promise<Buffer>((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 ────────────────────────────────────────────────────────────────────── // ─── DB ──────────────────────────────────────────────────────────────────────
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> { export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
@ -206,6 +237,21 @@ export async function findVersionForDoc(
return row?.version ?? null; return row?.version ?? null;
} }
/** version ล่าสุดของเอกสาร (docId) — join แบบ findVersionForDoc + เรียงเลข version
* จากมากไปน้อยแล้วเอาตัวแรก · ใช้กับ GET /api/documents/latest */
export async function findLatestVersionForDoc(
docId: string,
): Promise<DocumentVersionRow | null> {
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 นี้หรือยัง — ใช้เป็นหลักฐาน * เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน
* "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7: * "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7:
@ -229,7 +275,8 @@ export type InsertVersionInput = {
clientVersionId: string | null; clientVersionId: string | null;
contentHash: string; contentHash: string;
message: string | null; message: string | null;
fields: Record<string, string> | null; /** ก้อนเดียวกับ return ของ /api/template/extract (ดู DocumentVersionFields ใน db/schema.ts) */
fields: DocumentVersionFields | null;
templateId: string | null; templateId: string | null;
fileName: string | null; fileName: string | null;
docxObjectKey: string; docxObjectKey: string;

View file

@ -186,6 +186,32 @@ export function requireDocverApiKey(request: Request): Response | null {
: docverError("API key ไม่ถูกต้อง", 401); : 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 ผิด / /** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */ * หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ"; const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";

View file

@ -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 createReport from 'docx-templates';
import PizzaZip from "pizzip"; import PizzaZip from "pizzip";
import { 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<string, string | undefined>;
extended?: Record<string, number | string | undefined>;
custom?: Record<string, string | number | boolean | null>;
}
/** รูป 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<string, unknown>;
}
/** แกะข้อมูลจาก 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<string, unknown> = {};
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 ******************************************* //**************************** dynamic content *******************************************
// sdtLocked(ห้ามลบ CC), contentLocked(ลบได้ห้ามแก้), sdtContentLocked(ห้ามทุกอย่าง) // sdtLocked(ห้ามลบ CC), contentLocked(ลบได้ห้ามแก้), sdtContentLocked(ห้ามทุกอย่าง)
type LockType = 'none' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked'; type LockType = 'none' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
@ -1301,14 +1366,70 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT
// Apply Dynamic Content // Apply Dynamic Content
if (templateData?.dc && typeof templateData?.dc === "object") { if (templateData?.dc && typeof templateData?.dc === "object") {
console.log("Apply Dynamic Content") // console.log("Apply Dynamic Content")
templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc)) templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc))
} }
// Apply Content Control // Apply Content Control
if (templateData?.cc && typeof templateData?.cc === "object") { if (templateData?.cc && typeof templateData?.cc === "object") {
console.log("Apply Content Control") // console.log("Apply Content Control")
templateBuffer = templateContentControls(templateBuffer, templateData.cc); templateBuffer = templateContentControls(templateBuffer, templateData.cc);
} }
return templateBuffer 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<string, unknown>,
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 };
}

View file

@ -3,8 +3,17 @@ import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify,
/** /**
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object * คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
*/ */
/**
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign/hash) ข้อมูลด้วย HMAC-SHA256 secret
* @param data ข้อความดิบที่ต้องการเซ็น (string หรือ object)
* @param secretKey คีย์ลับในรูปแบบ string
* @returns ลายเซ็น (Signature/hash) ในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
*/
export function generateHmacSha256(data: string | object, secretKey: string): string { export function generateHmacSha256(data: string | object, secretKey: string): string {
const payload = typeof data === 'string' ? data : JSON.stringify(data); const payload = typeof data === 'string' ? data:JSON.stringify(sortObjectKeys(data as Record<string, unknown>));
return createHmac('sha256', secretKey) return createHmac('sha256', secretKey)
.update(payload, 'utf8') .update(payload, 'utf8')
.digest('hex'); .digest('hex');
@ -13,6 +22,16 @@ export function generateHmacSha256(data: string | object, secretKey: string): st
/** /**
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks) * ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
*/ */
/**
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
* @param data ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
* @param secretKey secret ที่นำมาใช้ตรวจสอบ
* @param expectedHmac ลายเซ็นในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
*/
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean { export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
const calculatedHmac = generateHmacSha256(data, secretKey); const calculatedHmac = generateHmacSha256(data, secretKey);
const a = Buffer.from(calculatedHmac, 'hex'); const a = Buffer.from(calculatedHmac, 'hex');

View file

@ -363,7 +363,9 @@ export const openApiSpec = {
tags: ["template"], tags: ["template"],
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง", summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง",
description: description:
"รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", "รับค่า fid, tid, uid, uname จาก Query Parameter "+
"ตรวจสอบรุ่นล่าสุดใน /api/document/latest ถ้าไม่พบ ต้องหาจากฐานข้อมูลแล้วส่งไปประมวลผลต่อที่ /api/template/generate "+
"เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนาเท่านั้น ไม่มีการตรวจสอบ user session ต้องนำออกก่อน production",
parameters: [ parameters: [
{ {
name: "fid", name: "fid",
@ -462,8 +464,9 @@ export const openApiSpec = {
tags: ["template"], tags: ["template"],
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง", summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง",
description: description:
"รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded อ่านข้อมูล JSON และประมวลผลสร้างไฟล์ DOCX stream คืนกลับไป "+ "รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded "+
"จะเรียก generate ในเบื้องหลัง ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", "ตรวจสอบรุ่นล่าสุดใน /api/document/latest ถ้าไม่พบ ต้องหาจากฐานข้อมูลแล้วส่งไปประมวลผลต่อที่ /api/template/generate "+
"เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนาเท่านั้น ไม่มีการตรวจสอบ user session ต้องนำออกก่อน production",
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@ -774,7 +777,11 @@ export const openApiSpec = {
"- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " + "- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " +
"หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" + "หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" +
"- ผู้อัปโหลด (userId/userName) และ templateId อ่านจาก token — " + "- ผู้อัปโหลด (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: [] }], security: [{ DocverBearer: [] }],
requestBody: { requestBody: {
required: true, required: true,
@ -799,13 +806,6 @@ export const openApiSpec = {
type: "string", type: "string",
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)", description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
}, },
fields: {
type: "string",
description:
'JSON string ของค่า Content Control **ทุกตัว** ในเอกสาร รูปแบบ ' +
'`{ [tag]: ข้อความ }` (add-in อ่านมาตอนบันทึก version) ' +
'เช่น {"blackno":"คดีหมายเลขดำที่","detail1":"…"}',
},
message: { message: {
type: "string", type: "string",
description: "commit message ของ version (≤ 2048 ตัวอักษร)", description: "commit message ของ version (≤ 2048 ตัวอักษร)",
@ -930,6 +930,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: { components: {
securitySchemes: { securitySchemes: {
@ -940,7 +1034,8 @@ export const openApiSpec = {
description: description:
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " + "x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " + "DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
"request ของกลุ่มที่ key ตรง)", "request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " +
"(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
}, },
DocverBearer: { DocverBearer: {
type: "http", type: "http",
@ -1235,6 +1330,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: { VersionRecord: {
type: "object", type: "object",
description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)", description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)",
@ -1249,11 +1378,11 @@ export const openApiSpec = {
description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)", description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)",
}, },
fields: { fields: {
type: "object",
nullable: true, nullable: true,
description: description:
"ค่า Content Control ทุกตัวตอนอัปโหลด รูปแบบ `{ [tag]: ข้อความ }` (ไม่ส่ง = null)", "snapshot ตอนอัปโหลด — **ก้อนเดียวกับ return ของ /api/template/extract** (server " +
additionalProperties: { type: "string" }, "แกะจากไฟล์ .docx เอง; null = version เก่าก่อน 2026-10-30)",
allOf: [{ $ref: "#/components/schemas/ExtractSnapshot" }],
}, },
message: { type: "string", nullable: true, description: "commit message" }, message: { type: "string", nullable: true, description: "commit message" },
hasPdf: { type: "boolean" }, hasPdf: { type: "boolean" },

View file

@ -15,7 +15,7 @@
<a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/> <a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/>
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2> <h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
Template: <a href="../tmpl/T_X.docx">T_X.docx</a>, &nbsp; Template: <a href="/docedit/tmpl/T_X.docx">T_X.docx</a>, &nbsp;
<a href="/docedit/tmpl/T12_X.docx">T12_X.docx</a><br/> <a href="/docedit/tmpl/T12_X.docx">T12_X.docx</a><br/>
Data: <a href="/docedit/tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>, &nbsp; Data: <a href="/docedit/tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>, &nbsp;
<a href="/docedit/tmpl/T_no_warning.json">T_no_warning.json</a>, &nbsp; <a href="/docedit/tmpl/T_no_warning.json">T_no_warning.json</a>, &nbsp;
@ -23,6 +23,5 @@
<a href="/docedit/tmpl/T12_b2_IN.json">T12_b2_IN.json</a>, &nbsp; <a href="/docedit/tmpl/T12_b2_IN.json">T12_b2_IN.json</a>, &nbsp;
<a href="/docedit/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a> <a href="/docedit/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a>
</body> </body>
</html> </html>

View file

@ -125,10 +125,12 @@ function unescapeXml(value) {
.replace(/&amp;/g, "&"); .replace(/&amp;/g, "&");
} }
/** ถอด <Property name value> ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */ /** ถอด <property name value> ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว
* (match ทั้งพิมพ์เล็กของตัว stamp จริง/setCustomDocProps และพิมพ์ใหญ่ <Property>
* ที่ script นี้เคยเขียนก่อน 2026-10-30 — อ่านไฟล์เก่าที่ stampค้างไว้ได้) */
function readProperties(xml) { function readProperties(xml) {
const properties = []; const properties = [];
const regex = /<Property\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|<Property\s+[^>]*name="([^"]*)"[^>]*\/>/g; const regex = /<property\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/property>|<property\s+[^>]*name="([^"]*)"[^>]*\/>/gi;
let match; let match;
while ((match = regex.exec(xml)) !== null) { while ((match = regex.exec(xml)) !== null) {
const name = match[1] ?? match[3]; const name = match[1] ?? match[3];
@ -148,7 +150,10 @@ function usedPids(xml) {
} }
function propertyXml(name, pid, value) { function propertyXml(name, pid, value) {
return ` <Property fmtid="${CUSTOM_FMTID}" pid="${pid}" name="${escapeXml(name)}"><vt:lpwstr>${escapeXml(value)}</vt:lpwstr></Property>\n`; // `<property>` พิมพ์เล็กตาม schema OOXML (ct_Property) — ตรงกับตัว stamp จริง
// setCustomDocProps ของ lib/lib-tmpl.ts เพื่อให้ getCustomDocProps/getDocProps
// (ที่ /api/template/extract กับ fields ของ version ใช้อ่าน) มองเห็นด้วย
return ` <property fmtid="${CUSTOM_FMTID}" pid="${pid}" name="${escapeXml(name)}"><vt:lpwstr>${escapeXml(value)}</vt:lpwstr></property>\n`;
} }
/** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */ /** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */
@ -156,9 +161,10 @@ function upsertProperties(existingXml, entries) {
// เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง) // เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง)
let xml = existingXml; let xml = existingXml;
for (const entry of entries) { for (const entry of entries) {
// gi — ถอดของเก่าทั้งพิมพ์เล็ก (ตัวจริง) และพิมพ์ใหญ่ (ที่ script นี้เคยเขียน) ก่อนแทรกใหม่
const removal = new RegExp( const removal = new RegExp(
`\\s*<Property\\s+[^>]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)</Property>`, `\\s*<property\\s+[^>]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)</property>`,
"g", "gi",
); );
xml = xml.replace(removal, ""); xml = xml.replace(removal, "");
} }
@ -189,7 +195,7 @@ function ensurePartRegistered(zip) {
.asText() .asText()
.replace( .replace(
/<\/Types>/, /<\/Types>/,
` <Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.document-properties.custom+xml"/>\n</Types>`, ` <Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+xml"/>\n</Types>`,
); );
zip.file(CT_PATH, updated); zip.file(CT_PATH, updated);
} }

View file

@ -7,7 +7,6 @@ import {
isDocxBytes, isDocxBytes,
isPdfBytes, isPdfBytes,
parseDownloadFormat, parseDownloadFormat,
parseFieldsJson,
parseVersionIdParam, parseVersionIdParam,
parseVersionUploadForm, parseVersionUploadForm,
sanitizeDisplayFileName, 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", () => { describe("parseVersionUploadForm", () => {
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก // identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts) // Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
@ -101,7 +83,6 @@ describe("parseVersionUploadForm", () => {
const form = baseForm(); const form = baseForm();
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 "); form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
form.append("message", " "); form.append("message", " ");
form.append("fields", '{"blackno":"1"}');
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx"); form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
const result = parseVersionUploadForm(form); const result = parseVersionUploadForm(form);
ok(result); ok(result);
@ -109,7 +90,6 @@ describe("parseVersionUploadForm", () => {
clientVersionId: "11111111-1111-4111-8111-111111111111", clientVersionId: "11111111-1111-4111-8111-111111111111",
message: null, message: null,
fileName: "คำสั่งเรียก.docx", 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 ถูกส่งมาเป็นไฟล์", () => { it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
const form = baseForm(); const form = baseForm();
form.append("message", new File(["x"], "m.txt")); form.append("message", new File(["x"], "m.txt"));
@ -147,31 +135,6 @@ describe("parseVersionUploadForm", () => {
over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1)); over.append("message", "a".repeat(DOCVER_MESSAGE_MAX + 1));
expect(parseVersionUploadForm(over).ok).toBe(false); 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", () => { describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {

View file

@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest";
import { import {
issueDocverToken, issueDocverToken,
parseTokenRequestBody, parseTokenRequestBody,
requireDocumentApiKey,
requireDocverApiKey, requireDocverApiKey,
requireDocverBearer, requireDocverBearer,
verifyDocverLoginToken, verifyDocverLoginToken,
@ -31,12 +32,16 @@ function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T
// --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load // --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load
const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY"); const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY");
const ORIG_API_KEY = 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 HAD_SECRET = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_TOKEN_SECRET");
const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET; const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET;
afterEach(() => { afterEach(() => {
if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY; if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY;
else delete process.env.DOCVER_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; if (HAD_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET;
else delete process.env.DOCVER_TOKEN_SECRET; else delete process.env.DOCVER_TOKEN_SECRET;
}); });
@ -176,6 +181,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", () => { describe("requireDocverBearer", () => {
it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => { it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => {
const deniedBodies = new Set<string>(); const deniedBodies = new Set<string>();

264
tests/lib/lib-tmpl.test.ts Normal file
View file

@ -0,0 +1,264 @@
// 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, generateKeyPairSync } from "node:crypto";
import { describe, expect, it } from "vitest";
import PizzaZip from "pizzip";
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";
const VT_NS = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes";
function escapeXml(unsafe: string): string {
return unsafe.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
/** docx จิ๋วในหน่วยความจำ: CC 1 ตัว (tag blackno) + bookmark sec1 ครอบ 2 run */
function documentXml(): string {
return (
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<w:document xmlns:w="${WORD_NS}"><w:body>` +
`<w:sdt><w:sdtPr><w:tag w:val="blackno"/></w:sdtPr>` +
`<w:sdtContent><w:p><w:r><w:t>ด1/2569</w:t></w:r></w:p></w:sdtContent></w:sdt>` +
`<w:p><w:bookmarkStart w:id="7" w:name="sec1"/>` +
`<w:r><w:t>hello </w:t></w:r><w:r><w:t>world</w:t></w:r>` +
`<w:bookmarkEnd w:id="7"/></w:p>` +
`</w:body></w:document>`
);
}
function customPropsXml(ticketData: string, hmacHex: string): string {
const prop = (pid: number, name: string, value: string) =>
`<property fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}" pid="${pid}" name="${name}">` +
`<vt:lpwstr>${escapeXml(value)}</vt:lpwstr></property>`;
return (
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}">` +
prop(2, "ticket_data", ticketData) +
prop(3, "HmacSha256", hmacHex) +
`</Properties>`
);
}
function buildDocx(entries: Record<string, string>): 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),
});
}
// setCustomDocProps (ทาง stampTicketProps) บังคับให้ zip มี _rels/.rels และ
// [Content_Types].xml ก่อน (ensureCustomRelationship/ensureCustomContentType)
const RELS_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">` +
`<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>` +
`</Relationships>`;
const CONTENT_TYPES_XML =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">` +
`<Default Extension="xml" ContentType="application/xml"/>` +
`<Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>` +
`</Types>`;
/** docx จิ๋วที่ stamp ได้ — ครบส่วนที่ setCustomDocProps บังคับ */
function stampableDocx(extra: Record<string, string> = {}): 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" });
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();
});
});
// 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();
});
});

View file

@ -4,6 +4,7 @@ import {
isTicketExpired, isTicketExpired,
normalizeHexDigest, normalizeHexDigest,
parseDocverTicket, parseDocverTicket,
parseTicketDataParam,
timingSafeEqualHex, timingSafeEqualHex,
} from "@/utils/docver-ticket"; } from "@/utils/docver-ticket";
@ -123,3 +124,84 @@ describe("timingSafeEqualHex", () => {
expect(timingSafeEqualHex(a, "ab")).toBe(false); 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);
});
});

View file

@ -2,7 +2,8 @@
// versioningService หลังปลด mock (2026-10-27) — คุมทั้งสองโหมด: // versioningService หลังปลด mock (2026-10-27) — คุมทั้งสองโหมด:
// - ของจริง: sign-in ผ่าน signInWithTicket ให้ได้ Bearer ก่อน แล้วตรวจ FormData ของ // - ของจริง: sign-in ผ่าน signInWithTicket ให้ได้ Bearer ก่อน แล้วตรวจ FormData ของ
// uploadVersion ตรง contract POST /api/documents/versions (ห้ามส่ง userId/userName/ // 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 เลย) // - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย)
import { createHmac, webcrypto } from "node:crypto"; import { createHmac, webcrypto } from "node:crypto";
import { afterEach, describe, expect, it, vi } from "vitest"; 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("file") as File).name).toBe("คำฟ้อง.docx");
expect((form.get("pdf") as File).name).toBe("document.pdf"); expect((form.get("pdf") as File).name).toBe("document.pdf");
expect(form.get("clientVersionId")).toBe("client-vid-1"); 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 แล้ว expect(form.get("message")).toBe("แก้ไขย่อหน้า 3"); // trim แล้ว
// contract 2026-10-22: ห้ามมี field identity/template เหลืออยู่เลย // contract 2026-10-22: ห้ามมี field identity/template เหลืออยู่เลย
expect(form.has("userId")).toBe(false); expect(form.has("userId")).toBe(false);
expect(form.has("userName")).toBe(false); expect(form.has("userName")).toBe(false);
expect(form.has("templateId")).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 () => { it("pdf ว่าง/ไม่แนบ = ไม่ append field pdf (ไฟล์เปล่า = version ไม่มี PDF)", async () => {
@ -105,6 +108,8 @@ describe("versioningService (real mode)", () => {
it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => { it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => {
await signInReal(); await signInReal();
// fields = ก้อนเดียวกับ return ของ /api/template/extract (server แกะจากไฟล์เอง)
const fields = { cc: { blackno: "ด1" }, bookmark: {}, properties: { custom: {} }, verify: {} };
const fetchMock = vi.fn( const fetchMock = vi.fn(
async (_url: string, _init?: RequestInit) => async (_url: string, _init?: RequestInit) =>
new Response( new Response(
@ -116,7 +121,7 @@ describe("versioningService (real mode)", () => {
createdByUserId: "oom", createdByUserId: "oom",
createdByUserName: "สรวิชญ์", createdByUserName: "สรวิชญ์",
contentHash: "ab", contentHash: "ab",
fields: { blackno: "ด1" }, fields,
message: null, message: null,
hasPdf: true, hasPdf: true,
}, },
@ -134,7 +139,7 @@ describe("versioningService (real mode)", () => {
createdByUserId: "oom", createdByUserId: "oom",
createdByUserName: "สรวิชญ์", createdByUserName: "สรวิชญ์",
contentHash: "ab", contentHash: "ab",
fields: { blackno: "ด1" }, fields,
message: null, message: null,
hasPdf: true, hasPdf: true,
}, },

View file

@ -118,6 +118,96 @@ export function parseDocverTicket(raw: string): DocverTicketParseResult {
return { ok: true, ticket: { exp, fid, tid, uid, uname } }; 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<string, unknown>;
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 พอดีก็ถือว่าหมดแล้ว */ /** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */
export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean { export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean {
return exp <= nowSec; return exp <= nowSec;

View file

@ -16,13 +16,36 @@ export interface FieldSnapshotEntry {
missing: boolean; 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<string, string>;
/** ข้อความใน bookmark `{ [name]: text }` */
bookmark: Record<string, string>;
/** document properties (standard/extended/custom) */
properties: {
standard?: Record<string, string | undefined>;
extended?: Record<string, number | string | undefined>;
custom?: Record<string, string | number | boolean | null>;
};
/** ผลตรวจ ticket ที่ stamp มากับไฟล์ (exp + HmacSha256 ด้วย secret ฝั่ง server) */
verify: Record<string, unknown>;
}
/** /**
* 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: `{ [fieldKey]: { value, missing } }`
* - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }` * - เอกสารไม่ผูก template (track เองจาก add-in): map ดิบ `{ [tag]: text }`
* UI ต้อง handle ได้ทั้งคู่ (ใช้ readSnapshotField ช่วยอ่าน) * UI ต้อง handle ได้ทั้งหมด (ใช้ readSnapshotField ช่วยอ่าน)
*/ */
export type FieldsSnapshot = Record<string, FieldSnapshotEntry> | Record<string, string>; export type FieldsSnapshot =
| ExtractSnapshotFields
| Record<string, FieldSnapshotEntry>
| Record<string, string>;
/** item ของ GET /api/documents/{documentId}/versions */ /** item ของ GET /api/documents/{documentId}/versions */
export interface VersionRecord { export interface VersionRecord {
@ -41,7 +64,8 @@ export interface VersionRecord {
message: string | null; message: string | null;
} }
/** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null */ /** อ่านค่า + สถานะ missing ของ field หนึ่งจาก snapshot ไม่ว่าจะเก็บรูปแบบไหน — ไม่มี field นี้คืน null
* รูปแบบ extract snapshot (2026-10-30) ค่า CC อยู่ใต้ `cc` — อ่านซ้ำเข้า map ดิบรอบเดียวกัน */
export function readSnapshotField( export function readSnapshotField(
snapshot: FieldsSnapshot | null, snapshot: FieldsSnapshot | null,
key: string key: string
@ -49,6 +73,13 @@ export function readSnapshotField(
if (!snapshot) { if (!snapshot) {
return null; 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]; const raw = snapshot[key];
if (raw === undefined) { if (raw === undefined) {
return null; return null;

View file

@ -6,6 +6,8 @@
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth * - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text * - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
* (ทุกชนิดบน host WordApi 1.5+ — ด้วย supportedContentControls ด้านล่าง) * (ทุกชนิดบน 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 * พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts
* *
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw** * กติกา: ทุก 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 ซึ่งเป็น * - ใช้ supportedContentControls → host 1.5+ เห็น CC ทุกชนิด (รวม plain text ซึ่งเป็น
* ส่วนใหญ่ของ template ศาล) ส่วน host ต่ำกว่า (Office 2019 = 1.3) เห็นเฉพาะ rich text * ส่วนใหญ่ของ template ศาล) ส่วน host ต่ำกว่า (Office 2019 = 1.3) เห็นเฉพาะ rich text
* เท่าเดิม — ห้ามยก MinVersion ของ manifest เพื่อปลดข้อจำกัดนี้ (บทเรียน 2026-10-16) * เท่าเดิม — ห้ามยก MinVersion ของ manifest เพื่อปลดข้อจำกัดนี้ (บทเรียน 2026-10-16)

View file

@ -7,7 +7,9 @@
* - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track * - `startVersioning` (FR-1 "Start Versioning This Document") — เอกสารใหม่ที่ยังไม่ถูก track
* - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด: * - `saveAndVersion` (FR-2) — ไล่ตามลำดับใน spec แบบเคร่งครัด:
* stamp properties → XML part (delete-then-add, fail ได้เงียบๆ) → document.save() * 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` และ `<Version id>` ใน XML part เก็บ * หมายเหตุค่า id ใน metadata: `LastVersionId` และ `<Version id>` ใน XML part เก็บ
* **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload — * **clientVersionId** (GUID ที่ client generate) เพราะต้อง stamp ลงไฟล์ *ก่อน* upload —
@ -21,7 +23,6 @@ import { DocumentCloseMode, closeDocument, saveDocumentVerified } from "./docume
import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService"; import { getCurrentDocumentFile, getCurrentDocumentPdf } from "./fileService";
import { import {
generateGuid, generateGuid,
readContentControlFields,
readVersionHistoryFromXml, readVersionHistoryFromXml,
stampTrackedProperties, stampTrackedProperties,
writeVersionHistoryToXml, writeVersionHistoryToXml,
@ -82,7 +83,6 @@ function guessDocumentFileName(): string | undefined {
async function uploadCurrentFile( async function uploadCurrentFile(
documentId: string, documentId: string,
clientVersionId: string, clientVersionId: string,
fields: Record<string, string>,
message?: string, message?: string,
onProgress?: (progress: string) => void, onProgress?: (progress: string) => void,
templateId?: string templateId?: string
@ -102,7 +102,7 @@ async function uploadCurrentFile(
documentId, documentId,
file, file,
clientVersionId, clientVersionId,
fields, undefined,
guessDocumentFileName(), guessDocumentFileName(),
message, message,
pdf, pdf,
@ -139,15 +139,14 @@ export async function startVersioning(
{ id: clientVersionId, number: 1, timestamp, user: authService.getUsername() ?? "unknown" }, { 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?.("กำลังบันทึกไฟล์…"); onProgress?.("กำลังบันทึกไฟล์…");
await saveDocumentVerified(); await saveDocumentVerified();
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields();
// 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16) // 4) upload version แรก (แนบ templateId เมื่อเป็นไฟล์ร่าง — FR-16)
onProgress?.("กำลังอัปโหลด version 1…"); 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" // FR-3 — สร้าง version สำเร็จ → เคลียร์ flag "มีแก้ไขที่ยังไม่เป็น version"
autoSaveService.clearUncommittedSinceVersion(); autoSaveService.clearUncommittedSinceVersion();
return { documentId, ...result }; return { documentId, ...result };
@ -188,15 +187,13 @@ export async function saveAndVersion(
entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user }); entries.unshift({ id: clientVersionId, number: predictedNumber, timestamp, user });
await writeVersionHistoryToXml(entries); await writeVersionHistoryToXml(entries);
// 3) native save → capture ไฟล์ + fields ปัจจุบัน (FR-11) // 3) native save → capture ไฟล์ปัจจุบัน (fields แกะฝั่ง server จากไฟล์ที่อัปโหลด — 2026-10-30)
onProgress?.("กำลังบันทึกไฟล์…"); onProgress?.("กำลังบันทึกไฟล์…");
await saveDocumentVerified(); await saveDocumentVerified();
onProgress?.("กำลังอ่านค่า fields จากเอกสาร…");
const fields = await readContentControlFields();
// 4) upload // 4) upload
onProgress?.("กำลังอัปโหลด version ใหม่…"); 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) // 5) เลขจริงจาก backend ต่างจากที่เดา (มี client อื่นแทรก) → แก้ XML part ให้ตรง (best effort)
if (result.versionNumber !== predictedNumber) { if (result.versionNumber !== predictedNumber) {

View file

@ -10,6 +10,8 @@
* ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims * ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims
* ของ token — ส่งมาใน form = 400) * ของ token — ส่งมาใน form = 400)
* - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 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 เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ * - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่
* ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block) * ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block)
* = ใช้ memory อย่างเดียว ไม่ throw * = ใช้ memory อย่างเดียว ไม่ throw
@ -18,13 +20,14 @@
import * as authService from "./authService"; import * as authService from "./authService";
import { generateGuid } from "./metadataService"; import { generateGuid } from "./metadataService";
import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient"; import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient";
import { UploadVersionResult, VersionRecord } from "../models/versionRecord"; import { ExtractSnapshotFields, UploadVersionResult, VersionRecord } from "../models/versionRecord";
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// ของจริง — กลุ่ม docver (Bearer จาก ticket auth แนบอัตโนมัติโดย apiClient) // ของจริง — กลุ่ม 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 { interface DocumentVersionDTO {
versionId: string; versionId: string;
versionNumber: number; versionNumber: number;
@ -32,14 +35,16 @@ interface DocumentVersionDTO {
createdByUserId: string; createdByUserId: string;
createdByUserName: string; createdByUserName: string;
contentHash: string; contentHash: string;
fields: Record<string, string> | null; fields: ExtractSnapshotFields | null;
message: string | null; message: string | null;
hasPdf: boolean; hasPdf: boolean;
} }
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST) /** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST)
* + FR-13: message คือ commit message ทางเลือก * + 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( export async function uploadVersion(
documentId: string, documentId: string,
file: Blob, file: Blob,
@ -52,6 +57,7 @@ export async function uploadVersion(
): Promise<UploadVersionResult> { ): Promise<UploadVersionResult> {
if (!authService.isDemoMode()) { if (!authService.isDemoMode()) {
void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token) void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token)
void fields; // contract 2026-10-30 — ห้ามส่ง (server แกะจากไฟล์เอง ดูด้านบน)
const form = new FormData(); const form = new FormData();
form.append("file", file, fileName ?? "document.docx"); form.append("file", file, fileName ?? "document.docx");
// pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF // pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF
@ -61,9 +67,6 @@ export async function uploadVersion(
if (clientVersionId) { if (clientVersionId) {
form.append("clientVersionId", clientVersionId); form.append("clientVersionId", clientVersionId);
} }
if (fields && Object.keys(fields).length > 0) {
form.append("fields", JSON.stringify(fields));
}
const trimmedMessage = message?.trim(); const trimmedMessage = message?.trim();
if (trimmedMessage) { if (trimmedMessage) {
form.append("message", trimmedMessage); form.append("message", trimmedMessage);