diff --git a/.env.example b/.env.example index 0aa4f54..d9988b6 100644 --- a/.env.example +++ b/.env.example @@ -6,22 +6,6 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # EXTERNAL_API_KEY= -# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template -DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit -# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template -EXTERNAL_API_KEY='secret-api-key-1234' -# ที่เก็บไฟล์เทมเพลต(docx) -TMPL_PATH='./public/tmpl' - -# Sign/Hash ที่ใช้ฝังในเอกสาร(docx) -SECRET="super_secret" -PRIVATE_KEY_HEX="8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1" -PUBLIC_KEY_HEX="66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546" -# Optional เวลาหมดอายุที่ฝังไว้ในข้อมูล (ticket_data) -TICKET_SEC=300 - - - # MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml # (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500) # หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev @@ -43,14 +27,3 @@ DOCVER_API_KEY= # ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret DOCVER_TOKEN_SECRET= # DOCVER_TOKEN_TTL_SECONDS=3600 - -# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token -# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp -# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง -# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET -# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement) -# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้ -# ADDIN_TICKET_SECRET=super_secret -# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง -# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน) -# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret diff --git a/CLAUDE.md b/CLAUDE.md index 170bbc0..f5ccb40 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,7 +12,7 @@ - Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`) - pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) -- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ใน custom properties — add-in ตรวจ HMAC เองฝั่ง client แล้วขอ token ที่ `/api/documents/token` แบบ body ticket เก็บใน localStorage `wordaddin-auth` ตาม fid, `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิง `/api/documents/*` ผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo`) (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) +- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — ปัจจุบัน auth + API เป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) - Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) - mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) @@ -39,10 +39,9 @@ docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ) scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile) scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) -scripts/make-ticket-docx.mjs ← dev tool รันมือ (ไม่ใช่ postinstall): stamp ticket ขอ e-Court (`ticket_data` + `HmacSha256`) ลงไฟล์ .docx ใด ๆ ผ่าน pizzip เพื่อทดสอบ ticket auth ของ add-in ใน Word จริงโดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง (key เรียงตามตัวอักษรหลังเติม exp + เซ็นบน string ดิบ hex lowercase) · ตัวเลือก --fid/--uid/--uname/--tid/--exp /--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9) db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) -middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้) lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) @@ -51,14 +50,17 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย -lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) -lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) -lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + 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/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) +lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) -wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) +wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth) 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()` เห็นทุกชนิด, ห้าม 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) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = 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 คุมทั้งสองโหมด) +<<<<<<< HEAD +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) +======= +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()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService = MOCK** (login ผ่านทันที คง surface เดิมครบไว้สลับของจริง — ต้นฉบับ Keycloak PKCE อยู่ที่ D:\word-addin-poc\addin + **`signInFromDocument` e-Court pass-through 2026-10-16: ไฟล์ที่ stamp custom properties `token`/`owner` มา (`readTrackedProperties` อ่านรวม) = `detect()` ของ TaskpaneApp ผ่านเข้าเลยไม่ต้องกด login — username จาก owner → uid ใน JWT → mockUsername, token ไฟล์เก็บ memory ให้ getAccessToken ใช้ต่อ**) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank) +>>>>>>> 6daee1585199721ba931b2bf4fe55277cb7c304a utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -67,7 +69,6 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) -utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย ให้สองฝั่งฉีด crypto ของตัวเอง: lib/addin-ticket.ts = node:crypto / wordaddin/services/ticketService.ts = Web Crypto): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — unit test tests/utils/docver-ticket.test.ts utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) @@ -82,7 +83,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) -app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath +app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) @@ -91,8 +92,8 @@ app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PD app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client) app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น) -components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + ตั้ง session mock (`signInForDemo`) + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง; เส้นทางจริงไม่มี demo จะจบที่หน้า no-access เพราะอ่าน properties ไม่ได้) -components/wordaddin/TaskpaneApp.tsx ← state machine checking/no-access/untracked/tracking (ไม่มี login — ยกเลิก Keycloak 2026-10-27: detect อ่าน ticket_data/HmacSha256 จากไฟล์ผ่าน signInWithTicket ไม่ผ่าน = no-access แยกข้อความตามสาเหตุ · AuthRequiredError จาก flow ก็ไป no-access) + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (เปลี่ยนแค่ JSX → Genesis + ถอดหน้า login/ปุ่มออกจากระบบ — header เหลือ chip username เฉย ๆ) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** +components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง) +components/wordaddin/TaskpaneApp.tsx ← state machine checking/signed-out/untracked/tracking + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (state/handlers คงเดิม เปลี่ยนแค่ JSX → Genesis) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts index 2173fe2..4b88776 100644 --- a/app/api/documents/token/route.ts +++ b/app/api/documents/token/route.ts @@ -1,43 +1,20 @@ // PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver -// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง): -// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY -// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง) -// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server -// ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → -// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket -// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) -// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env +// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ +// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env // DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ // endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] // (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว import { docverError } from "@/lib/document-versioning"; -import { - addinTicketSecret, - parseTicketTokenBody, - ticketToDocverClaims, - verifyAddinTicket, -} from "@/lib/addin-ticket"; import { issueDocverToken, parseTokenRequestBody, requireDocverApiKey, - type DocverTokenClaims, } from "@/lib/docver-token"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; -/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */ -const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ"; - export async function POST(request: Request) { - return request.headers.get("x-api-key") !== null - ? postWithApiKey(request) - : postWithTicket(request); -} - -/** ทางเข้า 1 (เดิม) — e-Court server-to-server */ -async function postWithApiKey(request: Request): Promise { const apiKeyFail = requireDocverApiKey(request); if (apiKeyFail !== null) return apiKeyFail; @@ -49,43 +26,20 @@ async function postWithApiKey(request: Request): Promise { } const parsed = parseTokenRequestBody(raw); if (!parsed.ok) return docverError(parsed.error, 400); - return issueToken(parsed.claims); -} -/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */ -async function postWithTicket(request: Request): Promise { - let raw: unknown; - try { - raw = await request.json(); - } catch { - return docverError("body ต้องเป็น JSON", 400); - } - const parsed = parseTicketTokenBody(raw); - if (!parsed.ok) { - // missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน) - // invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ - return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401); - } - const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); - if (!verified.ok) return docverError(TICKET_DENIED, 401); - return issueToken(ticketToDocverClaims(verified.ticket)); -} - -/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */ -async function issueToken(claims: DocverTokenClaims): Promise { // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) - const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); - // const secret = - // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; + // const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); + const secret = + "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; - if (secret === undefined || secret === "") { - return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); - } + // if (secret === undefined || secret === "") { + // return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); + // } const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); const ttlSeconds = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; - const accessToken = issueDocverToken(claims, secret, ttlSeconds); + const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds); return new Response( JSON.stringify({ access_token: accessToken, diff --git a/app/api/template/download-docx/route.ts b/app/api/template/download-docx/route.ts deleted file mode 100644 index f7e8017..0000000 --- a/app/api/template/download-docx/route.ts +++ /dev/null @@ -1,109 +0,0 @@ -// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework -// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ -// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx -import { NextRequest, NextResponse } from 'next/server'; -import path from 'path'; -import fs from 'fs/promises'; - -const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??""; -const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??""; -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> -{ - // console.log("processRequest get:",fid,tid,uid,uname) - // console.log(process.cwd()) - const filePath = path.join(TMPL_PATH,`${fid}.json`); - try { - if (!fid||!tid||!uid||!uname) { - return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 }); - } - - // อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล - const rawJson = await fs.readFile(filePath, 'utf-8'); - const jsonData = JSON.parse(rawJson); - if (!jsonData || typeof jsonData !== 'object' ) { - return NextResponse.json({error:'Invalid data format'}, { status: 400 }); - } - - //!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!! - - // ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data - if(!Object.hasOwn(jsonData, 'info')) - jsonData['info']={} - jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname}) - // sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้ - - //ยิงไปหาระบบ Docedit เพื่อเอา Stream - // console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`) - const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': EXTERNAL_API_KEY, - }, - body: JSON.stringify(jsonData), - }); - - // เช็ค Content-Type และ Status ก่อน - const contentType = docxResponse.headers.get('content-type') || ''; - - if (!docxResponse.ok || contentType.includes('text/html')) { - const errorText = await docxResponse.text(); - console.error('download-docx:Invalid docx Response:', errorText); - - return NextResponse.json( - {error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`}, - { status: 502 } - ); - } - - // 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ - const stream = docxResponse.body; - - // 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์ - const download_file = `${fid}_${uid}_xxx` - return new NextResponse(stream as unknown as BodyInit, { - status: 200, - headers: { - 'Content-Type': - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`, - 'Cache-Control': 'no-store, max-age=0', - }, - }); - } catch (error:any) { - if ( error.code === 'ENOENT') { - return NextResponse.json( - { error: `Data not found: ${filePath}`}, - { status: 404 } - ); - } - console.error(error) - return NextResponse.json({error:'Internal Server Error'}, { status: 500 }); - } -} - -export async function GET(request: NextRequest) -{ - const searchParams = request.nextUrl.searchParams; - const fid = searchParams.get('fid') || ''; - const tid = searchParams.get('tid') || ''; - const uid = searchParams.get('uid') || ''; - const uname = searchParams.get('uname') || ''; - return await processRequest(fid,tid,uid,uname) -} - -export async function POST(request: NextRequest) { - const formData = await request.formData(); - const fid = String(formData.get('fid')) - const tid = String(formData.get('tid')) - const uid = String(formData.get('uid')) - const uname = String(formData.get('uname')) - return await processRequest(fid,tid,uid,uname) -} \ No newline at end of file diff --git a/app/api/template/extract/route.ts b/app/api/template/extract/route.ts index 12108b7..e723f35 100644 --- a/app/api/template/extract/route.ts +++ b/app/api/template/extract/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; -import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; -// export const runtime = "nodejs"; +import { verifyDataEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; + +export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -14,14 +15,27 @@ export async function POST(request: Request) { try { const formData = await request.formData(); const file = formData.get("file"); - const TEST_SECRET = String(formData.get("secret") ?? ""); - const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? ""); + const TEST_SECRET = String(formData.get("secret"))??""; + const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key"))??""; - if (!(file instanceof File) || - (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") ) + + if (!(file instanceof File)) { + return NextResponse.json( + { + success: false, + error: 'Missing DOCX file in field "file".', + }, + { status: 400 }, + ); + } + + if ( + file.type !== DOCX_CONTENT_TYPE && + !file.name.toLowerCase().endsWith(".docx") ) { return NextResponse.json( { + success: false, error: 'Field "file" must be a .docx file.', }, { status: 400 }, @@ -31,6 +45,7 @@ export async function POST(request: Request) { if (file.size > MAX_UPLOAD_BYTES) { return NextResponse.json( { + success: false, error: 'Field "file" exceeds the 100MB size limit.', }, { status: 400 }, @@ -47,30 +62,35 @@ export async function POST(request: Request) { const bookmark = extractBookmark(buffer) const properties = getDocProps(buffer) let verify: Record ={} - const ticket_data = properties?.custom?.ticket_data + const ticket_data = String(properties?.custom?.ticket_data) const HmacSha256 = String(properties?.custom?.HmacSha256) const Ed25519 = String(properties?.custom?.Ed25519) - - if(ticket_data && typeof ticket_data === 'string'){ + + if(ticket_data){ 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) + verify['verifyHmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) } if(TEST_PUBLIC_KEY_HEX&& Ed25519){ - verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) + verify["Ed25519"]=verifyDataEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) } } - - let returnData : Record = {cc,bookmark,properties,verify} + let returnData : Record = { + success: true, + cc,bookmark,properties,verify + } + return NextResponse.json(returnData); } catch (error) { console.error(error); + return NextResponse.json( { + success: false, error: error instanceof Error ? error.message diff --git a/app/api/template/generate/route.ts b/app/api/template/generate/route.ts deleted file mode 100644 index 7b0f506..0000000 --- a/app/api/template/generate/route.ts +++ /dev/null @@ -1,104 +0,0 @@ -// สร้าง docx จาก json -// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้ -// - สร้าง signature/hash จาก json.info.ticket_data - -import { NextRequest, NextResponse } from 'next/server'; -import {readFile} from 'fs/promises'; -import path from 'path'; -import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; -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 SECRET = process.env.SECRET || '' -const TICKET_SEC = Number(process.env.TICKET_SEC||'300') - -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) { - // 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key) - const apiKey = request.headers.get('x-api-key'); - - if (!apiKey || apiKey !== EXTERNAL_API_KEY) { - return NextResponse.json( - { error: 'Unauthorized: Invalid API Key' }, - { status: 401 } - ); - } - - try { - // รับข้อมูล JSON Payload - const templateData:DocumentTemplate = (await request.json()); - // console.log(templateData) - // ต้องมี templateData.info.ticket_data เป็น json string เสมอ - if (!templateData || !templateData.info || - !templateData.info.ticket_data || - typeof templateData.info.ticket_data !== "string" - ) { - return NextResponse.json( - { - error: "template/generate json.info.ticket_data not avaliable", - }, - { status: 400 }, - ); - } - const ticketObj = JSON.parse(templateData.info.ticket_data) - const tid = String(ticketObj.tid) - const fid = String(ticketObj.fid) - const uid = String(ticketObj.uid) - if(!tid||!fid||!uid) - { - return NextResponse.json( - { - error: "template/generate require json.info.ticket_data.(fid|tid|uid)", - }, - { status: 400 }, - ); - } - - - //เปิดไฟล์ template - const tmplPath = path.join(TMPL_PATH, `${tid}.docx`); - let templateBuffer: Buffer = await readFile(tmplPath) - - const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC); - - // สร้าง ReadableStream ของไฟล์ .docx - // ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System - // const encoder = new TextEncoder(); - - const docxStream = new ReadableStream({ - start(controller) { - controller.enqueue(output); - controller.close(); - }, - }); - - // 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word - const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`) - return new NextResponse(docxStream, { - status: 200, - headers: { - 'Content-Type': - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'Content-Disposition': `attachment; filename="${dlFileName}.docx"`, - 'Cache-Control': 'no-store, max-age=0', - }, - }); - } catch (error:any) { - if ( error.code === 'ENOENT') { - return NextResponse.json( - { error: 'NotFound: Invalid template ID or incorrect storage path'}, - { status: 404 } - ); - } - console.error('Error generating docx:', error); - return NextResponse.json( - { error: 'Failed to generate document payload'}, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/template/lib-tmpl.ts b/app/api/template/lib-tmpl.ts new file mode 100644 index 0000000..2c15f52 --- /dev/null +++ b/app/api/template/lib-tmpl.ts @@ -0,0 +1,518 @@ +import PizzaZip from "pizzip"; +import { + DOMParser, + XMLSerializer, + type Document as XmlDocument, + type Element as XmlElement, + type Node as XmlNode, + type Attr as XmlAttr, +} from "@xmldom/xmldom"; +import xpath from "xpath"; + +export interface ExtractedData { + [tagName: string]: string; +} + +const W_NS = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +const asXpathNode = (node: XmlNode): globalThis.Node => + node as unknown as globalThis.Node; + +function createSelector() { + return xpath.useNamespaces({ w: W_NS }); +} + +/** + * อ่าน Content Control (w:sdt) จาก DOCX Buffer + * ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ + */ +export function extractContentControls( + docxBuffer: Buffer, +): ExtractedData { + const zip = new PizzaZip(docxBuffer); + const docXmlFile = zip.file("word/document.xml"); + + if (!docXmlFile) { + throw new Error("Invalid docx file: word/document.xml not found."); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); + const select = createSelector(); + const result: ExtractedData = {}; + + const sdtNodes = select( + "//w:sdt", + asXpathNode(doc), + ) as unknown as XmlElement[]; + + sdtNodes.forEach((sdt) => { + const tagAttr = select( + "./w:sdtPr/w:tag/@w:val", + asXpathNode(sdt), + true, + ) as unknown as XmlAttr | undefined; + + const tagName = tagAttr?.value; + + if (!tagName) return; + + const sdtContent = select( + "./w:sdtContent", + asXpathNode(sdt), + true, + ) as unknown as XmlElement | undefined; + + if (!sdtContent) { + result[tagName] = ""; + return; + } + + const nodes = select( + ".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + let extractedText = ""; + + nodes.forEach((node) => { + switch (node.nodeName) { + case "w:p": + if ( + extractedText.length > 0 && + !extractedText.endsWith("\n") + ) { + extractedText += "\n"; + } + break; + + case "w:t": + extractedText += node.textContent || ""; + break; + + case "w:tab": + extractedText += "\t"; + break; + + case "w:br": + extractedText += "\n"; + break; + + case "w:noBreakHyphen": + extractedText += "\u2011"; + break; + + case "w:softHyphen": + extractedText += "\u00ad"; + break; + + case "w:sym": { + const charAttr = node.getAttribute("w:char"); + if (charAttr) { + const charCode = Number.parseInt(charAttr, 16); + extractedText += String.fromCharCode(charCode); + } + break; + } + } + }); + + result[tagName] = extractedText; + }); + + return result; +} + +type SdtControlType = "plain" | "rich" | "other"; +type SdtStructure = "block" | "inline"; + +function detectSdtControlType( + sdt: XmlElement, + select: xpath.XPathSelect, +): { type: SdtControlType; multiLine: boolean } { + const sdtPr = select( + "./w:sdtPr", + asXpathNode(sdt), + true, + ) as unknown as XmlElement | undefined; + + if (!sdtPr) { + return { type: "other", multiLine: false }; + } + + const textEl = select( + "./w:text", + asXpathNode(sdtPr), + true, + ) as unknown as XmlElement | undefined; + + if (textEl) { + const multiLineAttr = select( + "./@w:multiLine", + asXpathNode(textEl), + true, + ) as unknown as XmlAttr | undefined; + + return { + type: "plain", + multiLine: + multiLineAttr?.value === "1" || + multiLineAttr?.value === "true", + }; + } + + const richTextEl = select( + "./w:richText", + asXpathNode(sdtPr), + true, + ) as unknown as XmlElement | undefined; + + if (richTextEl) { + return { type: "rich", multiLine: true }; + } + + const otherTypeTags = [ + "w:date", + "w:dropDownList", + "w:comboBox", + "w:checkbox", + "w:citation", + "w:group", + "w:docPartObj", + "w:docPartList", + ]; + + for (const tag of otherTypeTags) { + const found = select( + `./${tag}`, + asXpathNode(sdtPr), + true, + ) as unknown as XmlElement | undefined; + + if (found) { + return { type: "other", multiLine: false }; + } + } + + return { type: "rich", multiLine: true }; +} + +function detectSdtStructure( + sdtContent: XmlElement, + select: xpath.XPathSelect, +): SdtStructure { + const directP = select( + "./w:p", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + return directP.length > 0 ? "block" : "inline"; +} + +function buildRunChildren( + doc: XmlDocument, + text: string, +): XmlElement[] { + const nodes: XmlElement[] = []; + const tokens = text.split(/(\t|\n)/); + + tokens.forEach((token) => { + if (token === "") return; + + if (token === "\t") { + nodes.push(doc.createElementNS(W_NS, "w:tab")); + } else if (token === "\n") { + nodes.push(doc.createElementNS(W_NS, "w:br")); + } else { + const t = doc.createElementNS(W_NS, "w:t"); + t.setAttribute("xml:space", "preserve"); + t.textContent = token; + nodes.push(t); + } + }); + + if (nodes.length === 0) { + const t = doc.createElementNS(W_NS, "w:t"); + t.setAttribute("xml:space", "preserve"); + t.textContent = ""; + nodes.push(t); + } + + return nodes; +} + +function buildRun( + doc: XmlDocument, + text: string, + rPr?: XmlElement, +): XmlElement { + const run = doc.createElementNS(W_NS, "w:r"); + + if (rPr) { + run.appendChild(rPr.cloneNode(true)); + } + + buildRunChildren(doc, text).forEach((node) => + run.appendChild(node), + ); + + return run; +} + +function insertInlineContent( + doc: XmlDocument, + sdtContent: XmlElement, + select: xpath.XPathSelect, + text: string, + allowLineBreak: boolean, +): void { + const safeText = allowLineBreak + ? text + : text.replace(/\n/g, " "); + + const existingRuns = select( + "./w:r", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + const firstRPr = + existingRuns.length > 0 + ? (select( + "./w:rPr", + asXpathNode(existingRuns[0]), + true, + ) as unknown as XmlElement | undefined) + : undefined; + + existingRuns.forEach((r) => { + if (r.parentNode) r.parentNode.removeChild(r); + }); + + sdtContent.appendChild( + buildRun(doc, safeText, firstRPr), + ); +} + +function insertBlockContent( + doc: XmlDocument, + sdtContent: XmlElement, + select: xpath.XPathSelect, + text: string, + allowMultiParagraph: boolean, +): void { + const allParagraphs = select( + "./w:p", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + if (allParagraphs.length === 0) { + // ป้องกัน template ที่มีโครงสร้างผิดปกติ + const p = doc.createElementNS(W_NS, "w:p"); + sdtContent.appendChild(p); + allParagraphs.push(p); + } + + const firstP = allParagraphs[0]; + + const firstPPr = select( + "./w:pPr", + asXpathNode(firstP), + true, + ) as unknown as XmlElement | undefined; + + const firstRun = select( + "./w:r", + asXpathNode(firstP), + true, + ) as unknown as XmlElement | undefined; + + const firstRPr = firstRun + ? (select( + "./w:rPr", + asXpathNode(firstRun), + true, + ) as unknown as XmlElement | undefined) + : undefined; + + allParagraphs.forEach((p) => { + if (p !== firstP && p.parentNode) { + p.parentNode.removeChild(p); + } + }); + + const existingRuns = select( + "./w:r", + asXpathNode(firstP), + ) as unknown as XmlElement[]; + + existingRuns.forEach((r) => { + if (r.parentNode) r.parentNode.removeChild(r); + }); + + if (!allowMultiParagraph) { + firstP.appendChild(buildRun(doc, text, firstRPr)); + return; + } + + text.split("\n").forEach((line, index) => { + let targetP: XmlElement; + + if (index === 0) { + targetP = firstP; + } else { + targetP = doc.createElementNS(W_NS, "w:p"); + + if (firstPPr) { + targetP.appendChild(firstPPr.cloneNode(true)); + } + + sdtContent.appendChild(targetP); + } + + const run = doc.createElementNS(W_NS, "w:r"); + + if (firstRPr) { + run.appendChild(firstRPr.cloneNode(true)); + } + + buildRunChildren(doc, line).forEach((node) => + run.appendChild(node), + ); + + targetP.appendChild(run); + }); +} + +function insertFallbackContent( + sdtContent: XmlElement, + select: xpath.XPathSelect, + text: string, +): void { + const textNodes = select( + ".//w:t", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + if (textNodes.length === 0) return; + + textNodes[0].textContent = text; + + for (let i = 1; i < textNodes.length; i++) { + textNodes[i].textContent = ""; + } +} + +export type TemplateData = Record; + +function normalizeValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "string") { + return value; + } + + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + + // รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text + return JSON.stringify(value); +} + +/** + * นำ JSON data ไปแทนค่า Content Control ใน DOCX template + * คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว + */ +export function templateContentControls( + templateBuffer: Buffer, + data: TemplateData, +): Buffer { + const zip = new PizzaZip(templateBuffer); + const docXmlFile = zip.file("word/document.xml"); + + if (!docXmlFile) { + throw new Error("Invalid docx file: word/document.xml not found."); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); + const select = createSelector(); + + const sdtNodes = select( + "//w:sdt", + asXpathNode(doc), + ) as unknown as XmlElement[]; + + sdtNodes.forEach((sdt) => { + const tagAttr = select( + "./w:sdtPr/w:tag/@w:val", + asXpathNode(sdt), + true, + ) as unknown as XmlAttr | undefined; + + const tagName = tagAttr?.value; + + if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) { + return; + } + + const newValue = normalizeValue(data[tagName]); + + const sdtContent = select( + "./w:sdtContent", + asXpathNode(sdt), + true, + ) as unknown as XmlElement | undefined; + + if (!sdtContent) return; + + const { type: controlType, multiLine } = + detectSdtControlType(sdt, select); + + const structure = detectSdtStructure(sdtContent, select); + + if (controlType === "other") { + insertFallbackContent(sdtContent, select, newValue); + return; + } + + if (structure === "inline") { + const allowLineBreak = + controlType === "plain" ? multiLine : true; + + insertInlineContent( + doc, + sdtContent, + select, + newValue, + allowLineBreak, + ); + } else { + insertBlockContent( + doc, + sdtContent, + select, + newValue, + controlType === "rich", + ); + } + }); + + const serializer = new XMLSerializer(); + zip.file( + "word/document.xml", + serializer.serializeToString(doc), + ); + + return zip.generate({ + type: "nodebuffer", + compression: "DEFLATE", + }); +} diff --git a/app/api/template/render/route.ts b/app/api/template/render/route.ts index c738afe..8f04c9f 100644 --- a/app/api/template/render/route.ts +++ b/app/api/template/render/route.ts @@ -1,49 +1,73 @@ import { NextResponse } from "next/server"; -import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; -export const runtime = "nodejs"; +import { DocumentTemplate, templateContentControls, setCustomDocProps, getDocProps, templateDynamicContents } from "@/lib/lib-tmpl"; +import { generateHmacSha256, verifyHmacSha256, signDataEd25519, verifyDataEd25519, sortObjectKeys } from '@/lib/lib-utils'; +export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + // cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้ // ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions) const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB -const TICKET_SEC = Number(process.env.TICKET_SEC||'300') export async function POST(request: Request) { try { const formData = await request.formData(); + const template = formData.get("template"); const dataFile = formData.get("data"); const dataJson = formData.get("dataJson"); - const TEST_SECRET = String(formData.get("secret")||"") - const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"") + const TEST_SECRET = (typeof formData.get("secret") === "string") ? + String(formData.get("secret")) : "super_secret"; + const TEST_PRIVATE_KEY_HEX = (typeof formData.get("private_key") === "string") ? + String(formData.get("private_key")) : "8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1"; + console.log(`secret:${TEST_SECRET}`) + console.log(`private:${TEST_PRIVATE_KEY_HEX}`) + // ----------------------------- // ตรวจสอบ Template + // ----------------------------- + if (!(template instanceof File)) { return NextResponse.json( { + success: false, error: 'Missing DOCX file in field "template".', }, { status: 400 }, ); } - if (template.type !== DOCX_CONTENT_TYPE && - !template.name.toLowerCase().endsWith(".docx") && - template.size > MAX_UPLOAD_BYTES + if ( + template.type !== DOCX_CONTENT_TYPE && + !template.name.toLowerCase().endsWith(".docx") ) { return NextResponse.json( { - error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.', + success: false, + error: 'Field "template" must be a .docx file.', }, { status: 400 }, ); } + if (template.size > MAX_UPLOAD_BYTES) { + return NextResponse.json( + { + success: false, + error: 'Field "template" exceeds the 100MB size limit.', + }, + { status: 400 }, + ); + } + // ----------------------------- // อ่าน JSON + // ----------------------------- + let templateData: DocumentTemplate; + // JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500 // (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น) try { @@ -54,11 +78,13 @@ export async function POST(request: Request) { if (dataFile.size > MAX_UPLOAD_BYTES) { return NextResponse.json( { + success: false, error: 'Field "data" exceeds the 100MB size limit.', }, { status: 400 }, ); } + // กรณี upload data.json const text = await dataFile.text(); @@ -66,6 +92,7 @@ export async function POST(request: Request) { } else { return NextResponse.json( { + success: false, error: 'Missing JSON data. Send either "data" file or "dataJson" field.', }, @@ -75,12 +102,14 @@ export async function POST(request: Request) { } catch (error) { return NextResponse.json( { + success: false, error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error" }).`, }, { status: 400 }, ); } + // JSON ต้องเป็น object if ( templateData === null || @@ -89,6 +118,7 @@ export async function POST(request: Request) { ) { return NextResponse.json( { + success: false, error: "JSON data must be an object.", }, { status: 400 }, @@ -102,18 +132,62 @@ export async function POST(request: Request) { let templateBuffer: Buffer = Buffer.from( await template.arrayBuffer(), ); - const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC); + + //Calculate hash + const originalTicket_data = templateData?.info?.ticket_data //expect valid json string + // console.log("templateData",templateData?.info?.ticket_data) + if (originalTicket_data && typeof originalTicket_data === 'string') { + try { + + const ttlSeconds: number = 300 + const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data + if (originalTicketObj && typeof originalTicketObj === 'object') { + console.log("Found valid ticket data: hash it") + // คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ) + // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) + const expireTimestamp = Math.floor(Date.now() / 1000) + ttlSeconds; + const fullObj = { ...originalTicketObj, exp: expireTimestamp }; + const sortedObj = sortObjectKeys(fullObj); + + // แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash + const ticket_data = JSON.stringify(sortedObj); + // console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data) + const HmacSha256 = generateHmacSha256(ticket_data, TEST_SECRET) + + const Ed25519 = signDataEd25519(ticket_data, TEST_PRIVATE_KEY_HEX) + templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 }) + } + } catch (error) { + console.error("Set custom property fail",error); + + } + } + + // Apply Dynamic Content + if (templateData?.dc && typeof templateData?.dc === "object") { + console.log("Apply Dynamic Content") + templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc)) + } + // Apply Content Control + if (templateData?.cc && typeof templateData?.cc === "object") { + console.log("Apply Content Control") + templateBuffer = templateContentControls(templateBuffer, templateData.cc); + } + + const output = templateBuffer; // ----------------------------- // ส่ง DOCX กลับ // ----------------------------- - if(templateData?.info?.ticket_data) + return new NextResponse(output as BodyInit, { status: 200, headers: { "Content-Type": DOCX_CONTENT_TYPE, + "Content-Disposition": 'attachment; filename="generated-document.docx"', + "Content-Length": String(output.length), }, }); @@ -122,7 +196,7 @@ export async function POST(request: Request) { return NextResponse.json( { - + success: false, error: error instanceof Error ? error.message @@ -131,5 +205,4 @@ export async function POST(request: Request) { { status: 500 }, ); } -} - +} \ No newline at end of file diff --git a/app/api/template/test/T2.json b/app/api/template/test/T2.json new file mode 100644 index 0000000..799c36f --- /dev/null +++ b/app/api/template/test/T2.json @@ -0,0 +1,29 @@ +{ + "notname": "คำสั่งเรียก ให้ทำคำให้การ", + "nottype": "(ต. ๑)", + "blackno": "คดีหมายเลขดำที่", + "blackyear": "๑๐/๒๕๖๙", + "redno": "คดีหมายเลขแดงที่", + "redyear": "", + "court": "ศาลปกครองสุพรรณบุรี", + "curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", + "accuser": "นายธัญวิสิษฐ์ ศรแก้ว", + "acc1": "ผู้ฟ้องคดี", + "accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน", + "acc2": "ผู้ถูกฟ้องคดี", + "nottitle": "คำสั่งถึง", + "commto": "เจ้าคุณมด", + "groupname": "ผู้ถูกฟ้องคดีที่ ๑", + "detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้", + "pname": "นางนพเก้า เถื่อนชัย", + "courtname": "ศาลปกครองสุพรรณบุรี", + "courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี", + "courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐", + "courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓", + "courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”", + "sendto": "เจ้าคุณมด", + "addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -", + "remarkhead": "", + "remark": "", + "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี" +} \ No newline at end of file diff --git a/app/api/template/test/T3.docx b/app/api/template/test/T3.docx new file mode 100644 index 0000000..7573a45 Binary files /dev/null and b/app/api/template/test/T3.docx differ diff --git a/app/api/template/test/Test.docx b/app/api/template/test/Test.docx new file mode 100644 index 0000000..f078f00 Binary files /dev/null and b/app/api/template/test/Test.docx differ diff --git a/app/api/template/test/make-protected.mjs b/app/api/template/test/make-protected.mjs new file mode 100644 index 0000000..c35f2b2 --- /dev/null +++ b/app/api/template/test/make-protected.mjs @@ -0,0 +1,298 @@ +// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties +// (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md +// +// ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx] +// ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด) +// +// สิ่งที่แก้ในไฟล์ docx: +// 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1" +// (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย +// จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้) +// 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย +// w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone" +// ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค) +// พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้ +// 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr +// 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml +// 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml +import PizzaZip from "pizzip"; +import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; +import xpath from "xpath"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx"); +const outPath = process.argv[3] ?? inPath; + +// ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร) +const CUSTOM_PROPS = { + token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo", + "primary-key": "DOC-2026-000123", + owner: "u1001", +}; + +const W_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"; +const REL_CUSTOM = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; +const CT_CUSTOM = + "application/vnd.openxmlformats-officedocument.custom-properties+xml"; +const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; + +const parser = new DOMParser(); +const serializer = new XMLSerializer(); + +const parseXml = (text) => parser.parseFromString(text, "text/xml"); + +// xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี +// (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid) +const serialize = (doc) => { + const text = serializer.serializeToString(doc); + return text.startsWith("\r\n' + text; +}; + +// ครอบ w:sdt ที่มี w:tag ทุกตัวด้วย w:permStart/w:permEnd (edGrp="everyone") +// และเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี — คืนจำนวน CC ที่ครอบ +function unlockContentControls(zip) { + const select = xpath.useNamespaces({ w: W_NS }); + const asNode = (n) => n; + const parts = Object.keys(zip.files).filter((n) => + /^word\/(document|header\d*|footer\d*)\.xml$/.test(n), + ); + + // id ของ perm ต้องไม่ซ้ำในเอกสาร — เริ่มสูงกว่าค่าสูงสุดที่มีอยู่ (จากทุก part) + let nextId = 999; + for (const part of parts) { + const ids = select( + "//w:permStart/@w:id | //w:permEnd/@w:id", + asNode(parseXml(zip.file(part).asText())), + ); + for (const id of ids) { + const n = Number.parseInt(id.value, 10); + if (Number.isFinite(n) && n >= nextId) nextId = n; + } + } + + let wrapped = 0; + let locked = 0; + for (const part of parts) { + const doc = parseXml(zip.file(part).asText()); + + // เฉพาะ sdt ระดับบนสุด (ไม่อยู่ใน sdt อื่น) มี w:tag = ช่องข้อมูลของ template + // และยังไม่ถูกครอบด้วย permStart อยู่แล้ว (กัน double-wrap ตอนรันซ้ำ) + const sdts = select( + "//w:sdt[./w:sdtPr/w:tag][not(ancestor::w:sdt)][not(preceding-sibling::*[1][self::w:permStart])]", + asNode(doc), + ); + if (sdts.length === 0) continue; + + for (const sdt of sdts) { + // เติม lock ให้ CC ที่ยังไม่มี — กันลบช่องตอนเปิดช่องให้แก้ผ่าน permission + const sdtPr = select("./w:sdtPr", sdt)[0]; + const hasLock = select("./w:lock", sdtPr).length > 0; + if (!hasLock) { + const lock = doc.createElementNS(W_NS, "w:lock"); + lock.setAttribute("w:val", "sdtLocked"); + // w:lock ต้องอยู่หลัง w:id ตามลำดับ schema ของ sdtPr + const idEl = select("./w:id", sdtPr)[0]; + if (idEl && idEl.nextSibling) { + sdtPr.insertBefore(lock, idEl.nextSibling); + } else { + sdtPr.appendChild(lock); + } + locked++; + } + + const start = doc.createElementNS(W_NS, "w:permStart"); + start.setAttribute("w:id", String(nextId)); + start.setAttribute("w:edGrp", "everyone"); + const end = doc.createElementNS(W_NS, "w:permEnd"); + end.setAttribute("w:id", String(nextId)); + nextId++; + + sdt.parentNode.insertBefore(start, sdt); + sdt.parentNode.insertBefore(end, sdt.nextSibling); + wrapped++; + } + + zip.file(part, serialize(doc)); + } + return { wrapped, locked }; +} + +function enableReadOnlyProtection(zip) { + const file = zip.file("word/settings.xml"); + if (!file) throw new Error("word/settings.xml not found"); + + const doc = parseXml(file.asText()); + const prots = doc.getElementsByTagNameNS(W_NS, "documentProtection"); + const prot = prots.length > 0 ? prots[0] : null; + + if (prot) { + prot.setAttribute("w:edit", "readOnly"); + prot.setAttribute("w:enforcement", "1"); + } else { + // T3 มี element นี้อยู่แล้ว แต่รองรับไฟล์ที่ไม่มี: แทรกเป็น child แรกของ w:settings + const el = doc.createElementNS(W_NS, "w:documentProtection"); + el.setAttribute("w:edit", "readOnly"); + el.setAttribute("w:enforcement", "1"); + doc.documentElement.insertBefore(el, doc.documentElement.firstChild); + } + + zip.file("word/settings.xml", serialize(doc)); +} + +function setCustomProperties(zip, props) { + // --- 1. docProps/custom.xml : สร้างใหม่ หรือ merge กับของเดิม --- + const existing = zip.file("docProps/custom.xml"); + let doc; + if (existing) { + doc = parseXml(existing.asText()); + // ลบ property ชื่อซ้ำออกก่อน (เขียนทับค่าใหม่) + const all = Array.from(doc.getElementsByTagNameNS(CUSTOM_NS, "property")); + for (const p of all) { + if (Object.prototype.hasOwnProperty.call(props, p.getAttribute("name"))) { + p.parentNode.removeChild(p); + } + } + } else { + doc = parseXml( + `` + + ``, + ); + } + + // pid ต้อง >= 2 และไม่ซ้ำ — เริ่มจากค่าสูงสุดที่มีอยู่ +1 + const usedPids = Array.from( + doc.getElementsByTagNameNS(CUSTOM_NS, "property"), + ).map((p) => Number.parseInt(p.getAttribute("pid"), 10) || 1); + let nextPid = Math.max(1, ...usedPids) + 1; + + for (const [name, value] of Object.entries(props)) { + const prop = doc.createElementNS(CUSTOM_NS, "property"); + prop.setAttribute("fmtid", FMTID); + prop.setAttribute("pid", String(nextPid++)); + prop.setAttribute("name", name); + const val = doc.createElementNS(VT_NS, "vt:lpwstr"); + val.textContent = value; + prop.appendChild(val); + doc.documentElement.appendChild(prop); + } + zip.file("docProps/custom.xml", serialize(doc)); + + // --- 2. [Content_Types].xml : เพิ่ม Override (ครั้งเดียว) --- + const ct = parseXml(zip.file("[Content_Types].xml").asText()); + const hasCt = Array.from(ct.getElementsByTagName("Override")).some( + (o) => o.getAttribute("PartName") === "/docProps/custom.xml", + ); + if (!hasCt) { + const el = ct.createElement("Override"); + el.setAttribute("PartName", "/docProps/custom.xml"); + el.setAttribute("ContentType", CT_CUSTOM); + ct.documentElement.appendChild(el); + zip.file("[Content_Types].xml", serialize(ct)); + } + + // --- 3. _rels/.rels : เพิ่ม Relationship (ครั้งเดียว) --- + const rels = parseXml(zip.file("_rels/.rels").asText()); + const relNodes = Array.from(rels.getElementsByTagName("Relationship")); + if (!relNodes.some((r) => r.getAttribute("Type") === REL_CUSTOM)) { + const usedIds = new Set(relNodes.map((r) => r.getAttribute("Id"))); + let n = 1; + while (usedIds.has(`rId${n}`)) n++; + const el = rels.createElement("Relationship"); + el.setAttribute("Id", `rId${n}`); + el.setAttribute("Type", REL_CUSTOM); + el.setAttribute("Target", "docProps/custom.xml"); + rels.documentElement.appendChild(el); + zip.file("_rels/.rels", serialize(rels)); + } +} + +// อ่านกลับมาตรวจทุกอย่างหลังเขียนไฟล์ +function verify(path) { + const zip = new PizzaZip(readFileSync(path)); + + const settings = zip.file("word/settings.xml")?.asText() ?? ""; + const prot = settings.match(/]*\/>/)?.[0]; + const enforced = + prot?.includes('w:edit="readOnly"') && prot?.includes('w:enforcement="1"'); + + const customXml = zip.file("docProps/custom.xml")?.asText(); + const propsBack = {}; + if (customXml) { + const doc = parseXml(customXml); + for (const p of Array.from( + doc.getElementsByTagNameNS(CUSTOM_NS, "property"), + )) { + propsBack[p.getAttribute("name")] = p.textContent; + } + } + + const rels = zip.file("_rels/.rels")?.asText() ?? ""; + const ct = zip.file("[Content_Types].xml")?.asText() ?? ""; + + const docXml = zip.file("word/document.xml")?.asText() ?? ""; + const sdtPrs = docXml.match(/[\s\S]*?<\/w:sdtPr>/g) ?? []; + const plain = sdtPrs.filter((p) => / / `${k}="${v.length > 40 ? v.slice(0, 40) + "…" : v}"`) + .join(", ") || "(ว่าง)", + ); + console.log( + `relationship : ${rels.includes(REL_CUSTOM) ? "✓" : "✗ ไม่พบ"} | content-type override: ${ct.includes(CT_CUSTOM) ? "✓" : "✗ ไม่พบ"}`, + ); + console.log(`content controls : ${sdtPrs.length} ตัว (plain=${plain}, rich=${rich}, มี tag=${tagged})`); + console.log( + `permission ranges : permStart=${permStarts} / permEnd=${permEnds} (ต้องเท่ากับมี tag=${tagged}) ` + + (permStarts === tagged && permEnds === tagged ? "✓" : "✗"), + ); + console.log( + `CC ไม่มี lock : ${missingLock} ตัว (ต้องเป็น 0 — กันลบช่อง) ${missingLock === 0 ? "✓" : "✗"}`, + ); + + return ( + enforced && + !!customXml && + permStarts === tagged && + permEnds === tagged && + missingLock === 0 + ); +} + +const zip = new PizzaZip(readFileSync(inPath)); +enableReadOnlyProtection(zip); +const { wrapped, locked } = unlockContentControls(zip); +setCustomProperties(zip, CUSTOM_PROPS); + +if (locked > 0) { + console.log(`เติม w:lock="sdtLocked" ให้ CC อีก ${locked} ตัวที่ยังไม่มี`); +} +console.log(`ครอบ CC ด้วย permission range: ${wrapped} ตัว`); + +const out = zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); +writeFileSync(outPath, out); +console.log(`เขียนไฟล์แล้ว: ${outPath} (${out.length.toLocaleString()} bytes)`); + +const ok = verify(outPath); +process.exit(ok ? 0 : 1); diff --git a/app/api/template/test/note.md b/app/api/template/test/note.md new file mode 100644 index 0000000..919190b --- /dev/null +++ b/app/api/template/test/note.md @@ -0,0 +1,127 @@ +# Docx Template +lib-tmpl.ts เป็นแชร์ไลบารีสำหรับการทำ Template ใช้ Content Control ใน MS Word +## วิธีการใช้ API +```sh +## Swagger UI — เปิดในเบราว์เซอร์ (ลองยิงจริงผ่านปุ่ม Try it out ได้เลย ไม่ต้อง login) +http://localhost:3000/docedit/api/swagger + +## template/test ไว้ทดสอบเท่านั้น +curl.exe http://localhost:3000/docedit/api/template/test + +## extract ค่าออกมาจาก content control +curl.exe -X POST http://localhost:3000/docedit/api/template/extract -F "file=@./output-T3.docx" + +## render เอกสาร (docx) = template(docx)+data(json) +### อาจจะเพิ่มการเอา template จาก url และ file(local) ยังไม่รู้ว่าทางนั้นจะส่งไฟล์แบบไหนมาให้ เอาแค่นี้ไว้ทดสอบก่อน +curl.exe -X POST http://localhost:3000/docedit/api/template/render -F "template=@./T3.docx" -F "data=@./T2.json" -o output.docx +### data เอาจาก string +curl -X POST http://localhost:3000/docedit/api/template/render \ + -F "template=@template.docx" \ + -F 'dataJson={"member_name":"นายสมชาย","member_id":"123456"}' \ + -o generated-document.docx + +``` +data(json) ตอนนี้อยู่ในรูปแบบ +```json +{ + "notname": "คำสั่งเรียก ให้ทำคำให้การ", + "nottype": "(ต. ๑)", + "blackno": "คดีหมายเลขดำที่", + "blackyear": "๑๐/๒๕๖๙", + ... +} +``` +data(json)น่าจะปรับเป็นแบบนี้เพื่อรองรับการทำ Token-Based Seamless Integration +หลักผ่าน API +- ส่วนของ cc จะไปกรอกใน Content Control +- uid,uname,fid,ott,expire สร้างเป็น token ฝังใน custom property ของเอกสาร +```json +{ + "uid":"id ผู้มีสิทธิ์แก้ไขเอกสาร", + "uname": "ชื่อผู้มีสิทธิ์แก้ไขเอกสาร สำหรับแสดงผล", + "fid":"file id จะได้เซฟข้อมูลกลับไปที่ Oracle ถูกตารางและหน่วยงาน", + "ott":"อายุของ one time token แบบระยะสั้น", + "expire": "อายุเอกสารแบบระยะยาวหลังเอา ott ไปแลก (optional)", + cc:{ + "notname": "คำสั่งเรียก ให้ทำคำให้การ", + "nottype": "(ต. ๑)", + "blackno": "คดีหมายเลขดำที่", + "blackyear": "๑๐/๒๕๖๙", + ... + } +} +``` +ที่ต้องทำเพิ่ม +- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต +- Backend จัดการ version +- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน + +# ทดลอง Restrict Edit + Custom Properties (2026-10-07) + +สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง + +```sh +node app/api/template/test/make-protected.mjs [in.docx] [out.docx] +# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS) +``` + +## Restrict Edit (เปิดผ่านโค้ด) + +- อยู่ที่ `word/settings.xml` → `` +- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่ +- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป +- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `` … ``** — คือกลไก "Exceptions (optional): Everyone" ที่แผง Restrict Editing ของ Word ใช้เวลาเราเลือกบริเวณแล้วติ๊ก Everyone (บริเวณใน permission range แก้ได้ นอกนั้นล็อค) — แทรกที่ระดับเดียวกับ sdt (inline ใน `w:p` หรือ block ใน `w:body` ก็ valid ตาม grammar) +- สคริปต์เติม `w:lock="sdtLocked"` ให้ CC ที่ยังไม่มีด้วย (T3 ขาด acc1, nottitle) — พอช่องถูกเปิดให้แก้ผ่าน permission แล้ว ช่องที่ไม่มี lock จะถูก**ลบ**ได้ตามไปด้วย +- ไม่ใส่ password → ผู้ใช้ปลดเองได้จาก Review → Restrict Editing → Stop Protection — ตั้งใจไว้แบบนี้เพราะ concept คือ token ใน custom property เป็นตัวกำกับสิทธิ์จริง ส่วน protection เป็นแค่เกราะกันแก้พลาด (ถ้าจะใส่ password ต้องคำนวณ legacy hash: SHA-1 ของ password+salt วนตาม `w:cryptSpinCount` ตาม ECMA-376 — ยังไม่ทำ) + +## ฝังข้อมูลใน Property เอกสาร (token / primary-key / owner) + +ทางเลือกที่ศึกษา: +1. **`docProps/custom.xml` (custom properties) — ที่เลือกใช้** เห็นได้ใน Word: File → Info → Properties → Advanced Properties → แท็บ Custom; add-in ฝั่ง Word (Office.js) อ่าน/เขียนได้ผ่าน `Document.properties.customProperties` (WordApi 1.3+) + - ไฟล์ที่ไม่มี part นี้อยู่ (กรณีปกติของ docx) ต้องทำครบ 3 อย่างพร้อมกัน ไม่งั้น Word ถือว่าไฟล์เสีย: + 1. สร้าง `docProps/custom.xml` — property แต่ละตัว: `fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` คงที่ + `pid` ≥ 2 ไม่ซ้ำ + `name` + ค่า string ใน `vt:lpwstr` + 2. `[Content_Types].xml` เพิ่ม `` + 3. `_rels/.rels` เพิ่ม Relationship ชนิด `.../relationships/custom-properties` Target `docProps/custom.xml` +2. `docProps/core.xml` (core properties: title/subject/creator/description) — แก้ง่าย เห็นในหน้า Properties ปกติ เหมาะกับข้อมูลที่แสดงต่อผู้ใช้ ไม่ใช่ token +3. `w:docVar` ใน `word/settings.xml` (document variables) — ไม่โผล่ใน UI เลย อ่านได้ผ่าน VBA/VSTO (`Document.Variables`) แต่ Word JS API ไม่มีทางตรง — ไม่เลือก + +gotcha ที่เจอ: `XMLSerializer` ของ @xmldom/xmldom คง `` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้) + +## extract บอก alignment รายช่อง — key `@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)** + +- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม +- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `@align` (ค่า w:jc เช่น `thaiDistribute`) เฉพาะช่องที่ต่างจาก default ของเอกสาร โดย resolve: `w:pPr/w:jc` ตรงของย่อหน้า → style ที่ใช้ (ไล่ `w:pStyle` → `w:basedOn`) → `docDefaults` ("left"/"start" นับเป็นไม่ตั้ง) — บริบทย่อหน้าของช่อง: CC block = `w:p` แรกใน `w:sdtContent` / CC inline = `w:p` ที่ครอบ sdt +- ผลทดสอบตอน implement: direct pPr ชนะ style ✓ inline/block จับได้ทั้งคู่ ✓ และ T3 จัดช่อง 8 ตัวผ่าน style อยู่แล้ว (nottype/blackyear/redyear/acc1/acc2/groupname/detail1/pname — ชิดขวา/กลาง) — ช่องเหล่านี้จะโผล่ `@align` ตามจริง + +## จัด paragraph แบบ Thai Distributed — ตัดสินใจ: จัดเองใน Word ตอนทำ template (2026-10-07) + +- ค่า OOXML คือ `` ใน `w:pPr` (ใน Word UI: Paragraph → Alignment → Thai Distributed, ทางลัด Ctrl+Shift+J) — ต่างจาก `distribute` ธรรมดาตรงที่จัดกระจายช่องว่างระหว่างคำเป็นหลัก ไม่บีบยืดตัวอักษร/สระ-วรรณยุกต์ +- **ไม่ตั้งผ่านโค้ด** — ผู้ออกแบบ template จัดเองใน Word ตอนทำ template ทางเดียวกับที่ตั้งฟอนต์/ย่อหน้าอื่น ๆ (เคยทดลองตั้ง default ทั้งเอกสารผ่าน `styles.xml` → `w:docDefaults/w:pPrDefault/w:pPr` แล้วถอดออกตามคำขอ) +- วิธีจัดเองใน Word: + - รายย่อหน้า/ราย CC: คลิกในย่อหน้า → `Ctrl+Shift+J` หรือ Home → Paragraph launcher → Alignment เลือก Thai Distributed + - ทั้งเอกสาร: คลิกขวา style Normal → Modify → Format → Paragraph → Thai Distributed + - จัด "เฉพาะช่อง CC" ได้เฉพาะ CC แบบ block (มี `w:p` ใน `w:sdtContent` ของตัวเอง เช่น `detail1`) — CC แบบ inline อยู่ในบรรทัดเดียวกับข้อความอื่น การจัดมีผลทั้งบรรทัด +- จุดสำคัญต่อ render pipeline: `insertBlockContent` ของ lib-tmpl clone `w:pPr` ของย่อหน้าแรกให้ทุกบรรทัดที่แทนค่า — จัดไว้ใน CC ตั้งแต่ต้นค่าจะสืบทอดไปทุกบรรทัดที่ render ใส่ข้อมูลอัตโนมัติ ไม่ต้องทำอะไรฝั่ง render +- (อ้างอิงเชิงเทคนิคกรณีต้องตั้งผ่านโค้ดภายหลัง: `w:jc` ใน `w:pPr` เป็น fixed sequence — ต้องอยู่หลัง `w:autoSpaceDN`/`w:spacing`/`w:ind` และก่อน `w:textDirection`/`w:rPr`/`w:sectPr`) + +## ผล verify (อัตโนมัติทุกครั้งที่รันสคริปต์) + +- `documentProtection` เปิดใช้ (readOnly + enforcement=1) ✓ +- `docProps/custom.xml` มี + relationship + content-type override ครบ ✓ +- Content Control 27 ตัวครบเดิม (plain 26 = มี `w:text`, rich 1 = `detail1`) ✓ +- permission ranges: permStart/permEnd ครบตามจำนวน CC ที่มี tag (27/27) ✓ +- ทุก CC มี `w:lock="sdtLocked"` (กันลบช่อง) ✓ +- `extractContentControls` (lib-tmpl) อ่านคืนได้ทุก tag ตรงกับ output.docx ก่อน protect 100% ✓ + +## ผลทดสอบใน Word จริง (2026-10-07 รอบ 1) + +- ✅ แถบเหลือง protection ขึ้น / แก้นอก CC ไม่ได้ / ลบ CC ไม่ได้ +- ❌ B5: **CC แก้ไม่ได้** เพราะ readOnly ล็อคทั้งเอกสาร → แก้ด้วย permStart/permEnd ตามด้านบนแล้ว (รอบ 2) +- ✅ custom properties ครบ / ปลด protection ได้แบบไม่มี password + +## เหลือตรวจใน Word จริง (รอบ 2) + +- เปิด `output-T3.docx` → พิมพ์/แก้ในช่อง CC ต้องได้แล้ว (B5) +- ลบ CC ต้องไม่ได้ทุกตัว (รวม acc1, nottitle ที่เติม lock ให้) +- ขั้น E เดิม: F12 เซฟสำเนา → เปิดใหม่ → properties ยังอยู่ครบ +- (ผ่านแล้วทั้งหมด → ย้าย logic ไปเป็นฟังก์ชันใน lib-tmpl.ts แล้วต่อเข้า render pipeline) diff --git a/app/api/template/test/output-T3.docx b/app/api/template/test/output-T3.docx new file mode 100644 index 0000000..c7619be Binary files /dev/null and b/app/api/template/test/output-T3.docx differ diff --git a/app/api/template/test/output.docx b/app/api/template/test/output.docx new file mode 100644 index 0000000..2622682 Binary files /dev/null and b/app/api/template/test/output.docx differ diff --git a/app/api/template/test/test.http b/app/api/template/test/test.http new file mode 100644 index 0000000..d615855 --- /dev/null +++ b/app/api/template/test/test.http @@ -0,0 +1,43 @@ +@baseUrl = http://localhost:3000/docedit/api + +### 1. Extract Template +POST {{baseUrl}}/template/extract +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="file"; filename="output-T3.docx" +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + +< ./output-T3.docx +--WebAppBoundary-- + +### 2. Render Template with JSON File +POST {{baseUrl}}/template/render +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="template"; filename="T3.docx" +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + +< ./T3.docx +--WebAppBoundary +Content-Disposition: form-data; name="data"; filename="T2.json" +Content-Type: application/json + +< ./T2.json +--WebAppBoundary-- + +### 3. Render Template with Raw JSON String +POST {{baseUrl}}/template/render +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="template"; filename="template.docx" +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + +< ./T3.docx +--WebAppBoundary +Content-Disposition: form-data; name="dataJson" + +{"notname": "คำสั่งเรียก ให้ทำคำให้การ","nottype": "(ต. ๑)","blackno": "คดีหมายเลขดำที่","blackyear": "๑๐/๒๕๖๙","redno": "คดีหมายเลขแดงที่","redyear": "","court": "ศาลปกครองสุพรรณบุรี","curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙","accuser": "นายธัญวิสิษฐ์ ศรแก้ว","acc1": "ผู้ฟ้องคดี","accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน","acc2": "ผู้ถูกฟ้องคดี","nottitle": "คำสั่งถึง","commto": "เจ้าคุณมด","groupname": "ผู้ถูกฟ้องคดีที่ ๑","detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้","pname": "นางนพเก้า เถื่อนชัย","courtname": "ศาลปกครองสุพรรณบุรี","courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี","courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐","courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓","courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”","sendto": "เจ้าคุณมด","addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -","remarkhead": "","remark": "","warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"} +--WebAppBoundary-- diff --git a/app/tmpl/page.tsx b/app/tmpl/page.tsx deleted file mode 100644 index fa31778..0000000 --- a/app/tmpl/page.tsx +++ /dev/null @@ -1,101 +0,0 @@ -// app/page.tsx (ระบบ B) -'use client'; -import Link from "next/link"; -import type { ChangeEvent } from 'react'; -// import { createDownloadFormAction } from './actions'; -import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown'; - - - -export default function HomePage() { - const templateData: DropdownData = { - T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'], - T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'], - }; - const handleTmplSelect = (template: string, data: string) => { - //console.log('Location Selected:', { template, data }); - }; - const handleInput = (e: ChangeEvent)=>{ - // console.log(e.target) - return true; - } - return ( -
-
-

ตัวอย่างการใช้ สร้างเอกสารจาก Template

-
- ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET) - เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ - ตัวอย่างหน้าแบบ Plain HTML -
-
-
-

Form POST

- -
-
- uid: -
-
- uname: -
- - -
-
-

A Link Get(new)

- T_no_warning_john_xxx.docx
- TF_no_warning_P2_john_xxx.docx
- TS_P2_IN_john_xxx.docx
- - T12_b2_IN_john_xxx.docx
- T12_FD155_3s-long_john_xxx.docx
-
-
-

Template & Data

- Template: T_X.docx,   - T12_X.docx
- Data: TF_no_warning_P2.json,   - T_no_warning.json,   - TS_P2_IN.json
- T12_b2_IN.json,   - T12_FD155_3s-long.json -
-
-

Flow

-
- หน้านี้(เลือกไฟล์)
- ↓
- download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)
- ↓
- generate(เปิดเทมเพลต,สร้าง docx)
- ↓
- ดาว์นโหลด docx เปิด MS Word อัตโนมัติ
- ↓
- Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx
- ↓
- Versioning(คุมเวอร์ชั่น)
- ↓
- ecourt(อัปเดตฐานข้อมูลและไฟล์)
- -
- -
- -
-
- ); -} \ No newline at end of file diff --git a/app/wordaddin/error.tsx b/app/wordaddin/error.tsx deleted file mode 100644 index ac42382..0000000 --- a/app/wordaddin/error.tsx +++ /dev/null @@ -1,37 +0,0 @@ -"use client"; - -// [DBG-r3p1] error boundary ของ taskpane — แสดง error จริงแทนหน้า -// "Application error: a client-side exception has occurred" ของ Next -// (ไว้ใช้ debug ใน webview ของ Word ที่เปิด devtools ไม่ได้ — คงไว้ถาวร -// เพราะดีกว่าข้อความกลาง ๆ ของ Next เสมอ สำหรับ add-in ที่ผู้ใช้เปิด console ไม่ได้) -export default function WordAddinError({ - error, - reset, -}: { - error: Error & { digest?: string }; - reset: () => void; -}) { - return ( -
-

task pane ทำงานผิดพลาด

-

- เกิดข้อผิดพลาดฝั่งหน้าเว็บ — แจ้งข้อความด้านล่าง (ถ่ายภาพหน้าจอนี้มาด้วย) แล้วกดลองใหม่ -

-
-        {error.message}
-        {error.digest ? `\n[digest ${error.digest}]` : ""}
-        {error.stack ? `\n\n${error.stack}` : ""}
-      
- -
- ); -} diff --git a/components/utils/DependentDropdown.tsx b/components/utils/DependentDropdown.tsx deleted file mode 100644 index 7331bd9..0000000 --- a/components/utils/DependentDropdown.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { useState, ChangeEvent, useEffect } from 'react'; - -export interface DropdownData { - [key: string]: string[]; -} - -interface DependentDropdownProps { - data: DropdownData; - name1: string; // เพิ่มมาเพื่อใช้กับ Form Action - name2: string; // เพิ่มมาเพื่อใช้กับ Form Action - label1?: string; - label2?: string; - placeholder1?: string; - placeholder2?: string; - onSelectChange?: (mainValue: string, subValue: string) => void; -} - -export default function DependentDropdown({ - data, - name1, - name2, - label1 = 'เลือกตัวเลือกหลัก:', - label2 = 'เลือกตัวเลือกย่อย:', - placeholder1 = '-- กรุณาเลือก --', - placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --', - onSelectChange, -}: DependentDropdownProps) { - const [mainSelection, setMainSelection] = useState(''); - const [subSelection, setSubSelection] = useState(''); - - const handleMainChange = (e: ChangeEvent) => { - const value = e.target.value; - setMainSelection(value); - setSubSelection(''); - }; - - const handleSubChange = (e: ChangeEvent) => { - setSubSelection(e.target.value); - }; - - useEffect(() => { - if (onSelectChange) { - onSelectChange(mainSelection, subSelection); - } - }, [mainSelection, subSelection, onSelectChange]); - - const subOptions = mainSelection ? data[mainSelection] : []; - - return ( -
- {/* Dropdown ที่ 1 */} -
- - -
- - {/* Dropdown ที่ 2 */} -
- - -
-
- ); -} diff --git a/components/wordaddin/TaskpaneApp.tsx b/components/wordaddin/TaskpaneApp.tsx index 35895b7..5191a79 100644 --- a/components/wordaddin/TaskpaneApp.tsx +++ b/components/wordaddin/TaskpaneApp.tsx @@ -3,7 +3,7 @@ "use client"; import { useCallback, useEffect, useState } from "react"; -import { FileUp, GitCommitHorizontal } from "lucide-react"; +import { FileUp, GitCommitHorizontal, LogOut } from "lucide-react"; import { autoSaveService } from "@/wordaddin/services/autoSaveService"; import * as authService from "@/wordaddin/services/authService"; @@ -23,26 +23,22 @@ import TemplateToolsPanel from "./TemplateToolsPanel"; import VersionHistoryPanel from "./VersionHistoryPanel"; /** - * Task pane หลัก — state machine: - * checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access - * → (ticket ผ่าน + ไม่ใช่ tracked document) untracked - * → (ticket ผ่าน + tracked document) tracking - * ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket - * ของ e-Court: `detect()` อ่าน ticket_data + HmacSha256 จาก custom properties - * แล้วเรียก `authService.signInWithTicket` (ตรวจ HMAC → exp → session → ขอ token - * ตาม fid — PLAN-ADDIN-AUTH.md) - * พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — เปลี่ยน: import path, - * JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, ถอดหน้า login, + * Task pane หลัก — state machine ตาม Phase 5: + * checking → (ไม่มี token) signed-out + * → (มี token + ไม่ใช่ tracked document) untracked + * → (มี token + tracked document) tracking + * พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — state/handlers คงเดิมทุกตัว + * เปลี่ยนเฉพาะ: import path, JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, * เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง) * - * กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court + * กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9) * error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection * - * FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder + * FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่รอ sign-in; FR-3 reminder * กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า */ -type Phase = "checking" | "no-access" | "untracked" | "tracking"; +type Phase = "checking" | "signed-out" | "untracked" | "tracking"; /** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls * (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */ @@ -112,8 +108,8 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => const [historyLoading, setHistoryLoading] = useState(false); const [historyError, setHistoryError] = useState(null); - /** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */ - const [noAccessNotice, setNoAccessNotice] = useState(null); + const [signingIn, setSigningIn] = useState(false); + const [loginError, setLoginError] = useState(null); const [starting, setStarting] = useState(false); const [saving, setSaving] = useState(false); @@ -139,17 +135,15 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => /** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */ const [hasUncommitted, setHasUncommitted] = useState(false); - /** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient) - * → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court - * จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */ + /** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */ const handleAuthRequired = useCallback((error: unknown): boolean => { if (error instanceof AuthRequiredError) { - setPhase("no-access"); + setPhase("signed-out"); setActiveTab("versioning"); setDocumentId(null); setDocumentTemplateId(null); setVersions([]); - setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง"); + setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่"); return true; } return false; @@ -172,38 +166,35 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => [handleAuthRequired], ); - /** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history + /** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history * FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked) * ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo) - * auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket - * ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ - * (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */ + * e-Court pass-through — ยังไม่มี session แต่ไฟล์ stamp token/owner มา = ผ่านเข้าเลยไม่ต้องกด login */ const detect = useCallback(async () => { setPhase("checking"); setBootError(null); try { let properties: metadataService.TrackedDocumentProperties | null = null; - try { - properties = await metadataService.readTrackedProperties(); - } catch { - properties = null; - } - if (!properties) { - setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง"); - setPhase("no-access"); - return; - } - const signIn = await authService.signInWithTicket({ - ticketData: properties.ticketData, - ticketHmac: properties.ticketHmac, - }); - if (!signIn.ok) { - setNoAccessNotice(signIn.message); - setPhase("no-access"); - return; + if (!(await authService.getAccessToken())) { + // e-Court pass-through — อ่านไฟล์ก่อนตัดสิน: ไฟล์ที่ stamp token/owner มา = ผ่านเลยไม่ต้องกด login + // (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป signed-out ตามเดิม) + try { + properties = await metadataService.readTrackedProperties(); + } catch { + properties = null; + } + if ( + !properties || + !authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner }) + ) { + setPhase("signed-out"); + return; + } } + authService.startAutoRefresh(); setUsername(authService.getUsername() ?? null); + properties ??= await metadataService.readTrackedProperties(); const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID; const id = tracked ? properties.documentTrackingId : undefined; if (!id) { @@ -226,7 +217,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => void detect(); }, [demo, detect]); - /** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่ผูกกับ phase (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี) + /** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่รอ sign-in (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี) * ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */ useEffect(() => { autoSaveService.onError = (error) => @@ -244,6 +235,34 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => }; }, [demo]); + const handleSignIn = async () => { + setSigningIn(true); + setLoginError(null); + try { + const result = await authService.login(); + if (result.ok) { + await detect(); + } else { + setLoginError(result.error); + } + } catch (error) { + setLoginError(getErrorMessage(error, "เข้าสู่ระบบไม่สำเร็จ")); + } finally { + setSigningIn(false); + } + }; + + const handleSignOut = () => { + authService.signOut(); + setUsername(null); + setDocumentId(null); + setDocumentTemplateId(null); + setVersions([]); + setLoginError(null); + setPhase("signed-out"); + setActiveTab("versioning"); + }; + /** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */ const handleSaveOnly = async () => { setSaveBusy(true); @@ -389,15 +408,18 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
e-Court Documents Versioning - {phase !== "no-access" && phase !== "checking" && username ? ( - /* ไม่มีปุ่มออกจากระบบ (ไม่มี login ให้ออก) — แสดงชื่อเจ้าของไฟล์จาก pass-through เฉย ๆ - จอแคบ: ตัดด้วย truncate + เต็ม ๆ ใน title ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap */ - - {username} - + + ออกจากระบบ + ) : null}
@@ -408,7 +430,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) => {autoSaveError} ) : null} - {/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/no-access ยังไม่มีอะไรให้จัดการ) + {/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/signed-out ยังไม่มีอะไรให้จัดการ) ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template @@ -459,13 +481,21 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
) : null} - {phase === "no-access" ? ( - /* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */ + {phase === "signed-out" ? (
-

เอกสารนี้ยังใช้งาน add-in ไม่ได้

+

เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning

- {noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"} + ใช้บัญชีของ realm word-versioning — หน้าต่าง login จะเปิดแยกต่างหาก

+ + {loginError ? {loginError} : null}
) : null} diff --git a/components/wordaddin/VersionHistoryPanel.tsx b/components/wordaddin/VersionHistoryPanel.tsx index 930a4d1..7755035 100644 --- a/components/wordaddin/VersionHistoryPanel.tsx +++ b/components/wordaddin/VersionHistoryPanel.tsx @@ -135,7 +135,7 @@ export function VersionHistoryPanel({

) : null}

- {formatTimestamp(version.createdAt)} · โดย {version.createdByUserName ?? version.createdByUserId} + {formatTimestamp(version.createdAt)} · โดย {version.createdByUserId}

); diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 84788ea..a3d2923 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -37,6 +37,8 @@ ENV DOCVER_LINK_TTL_SECONDS=900 RUN npm run build + + # ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ─── FROM node:24-alpine AS runner WORKDIR /app diff --git a/docs/PLAN-ADDIN-AUTH.md b/docs/PLAN-ADDIN-AUTH.md deleted file mode 100644 index a6b8f62..0000000 --- a/docs/PLAN-ADDIN-AUTH.md +++ /dev/null @@ -1,214 +0,0 @@ -# PLAN-ADDIN-AUTH — Add-in Ticket Authen (`ticket_data` + `HmacSha256`) - -> วันที่ตกลงแผน: 2026-10-27 · สถานะ: แผนรออนุมัติ · requirement ต้นทาง: `docs/requirement/addin-workflow.md` - -## 0. บริบท/เป้าหมาย - -เปลี่ยน auth ของ wordaddin จาก e-Court pass-through แบบเดิม (e-Court ขอ token เองแล้ว stamp -`token`/`owner` ลงไฟล์) เป็น **ticket** ตาม requirement: - -- e-Court stamp custom properties 2 ค่าในไฟล์ .docx: `ticket_data` (JSON) + `HmacSha256` -- addin ตรวจ HMAC เองฝั่ง client (gate เชิง UX — ตัดเคสไฟล์ถูกแก้ก่อนยิง network) -- **addin เป็นฝ่ายขอ access_token** ที่ `POST /api/documents/token` เก็บใน localStorage - ตาม fid (session ต่อ userId) — ไม่ใช่ e-Court อีกต่อไป -- ถอดระบบเก่า `token`/`owner` ออกทั้งหมด (implement ไว้ 2026-10-27 ยังไม่มีไฟล์จริงใช้ — - ไม่ต้องรองรับสองแบบพร้อมกัน) - -## 1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-27) - -| # | เรื่อง | ข้อตกลง | -|---|---|---| -| D1 | addin ยืนยันกับ `/token` อย่างไร | **ขยาย `POST /api/documents/token` รับ ticket เพิ่ม** — body `{ticket_data, HmacSha256}` server ตรวจ HMAC+exp ด้วย secret ใน env ฝั่ง server ก่อนออก token · ทางเข้า `x-api-key` เดิม (e-Court server-to-server) ใช้ต่อคู่กัน | -| D2 | โค้ดซ้ำสองฝั่ง | แชร์ส่วน pure ผ่าน **`utils/docver-ticket.ts`** (โซนกลาง client ใช้ได้ — ไม่แตะกติกา "wordaddin ห้าม import จาก `lib/`") · ฝั่ง crypto ฉีดเข้าทาง parameter: client = Web Crypto, server = `node:crypto` | -| D3 | client-side HMAC | ทำตาม requirement (sign เทียบเองก่อน) โดย secret มาจาก `NEXT_PUBLIC_ADDIN_TICKET_SECRET` — **ยอมรับว่าค่าใน bundle สกัดได้** จึงถือเป็นแค่ UX gate, ความมั่นคงจริงอยู่ที่ server verify | -| D4 | รูปแบบ HMAC | sign บน **string ดิบของ `ticket_data` ตามที่อ่านได้จาก property** (ห้าม trim/normalize) · digest เป็น **hex lowercase 64 ตัว** — การเทียบ normalize เป็น lowercase ก่อน (ฝั่ง e-Court stamp ตัวพิมพ์ใหญ่ก็ผ่าน) | -| D5 | `tid` | = `template` ของ claims (`tid` optional → `template: null`) · map `uid→userid`, `uname→username`, `fid` ตรง ๆ | -| D6 | token หมดอายุ (TTL ~1 ชม.) แต่ ticket ยังไม่หมด | เก็บ `exp` ของ token (decode จาก JWT) — ก่อนใช้ถ้าหมดอายุและ ticket ยังไม่หมด → **renew อัตโนมัติ** · โดน 401 กลาง session → renew จาก ticket 1 ครั้งแล้ว retry ก่อนไป no-access | -| D7 | localStorage | key เดียว `wordaddin-auth` เก็บ JSON `{v:1, userId, userName, tokens: {[fid]: {token, exp}}}` · เจอ user ใหม่ = **เคลียร์ทั้งก้อน** (รวม token ทุก fid ของ user เก่า) · webview block localStorage = fallback memory (pattern `versioningService`) | -| D8 | "disable Add-in ทั้งหมด" | = phase `no-access` ที่มีอยู่ โดยแยกข้อความตามสาเหตุ: ไม่พบ ticket / ไฟล์ถูกแก้ไข (HMAC) / ticket หมดอายุ | -| D9 | ช่วง mock (`NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง) | addin ยิงขอ token จริงที่ same-origin `withBasePath("/api/documents/token")` (ไม่ผูกกับ apiBaseUrl ของ versioning) — versioning ยัง mock ต่อ | -| D10 | เครื่องมือทดสอบ | มีสคริปต์ stamp ticket ลงไฟล์ .docx ตัวอย่าง (`scripts/make-ticket-docx.mjs`) — ไม่งั้นทดสอบ flow นี้ใน Word จริงไม่ได้เลย | - -## 2. Contract ของ ticket (ต้องส่งให้ทีม e-Court ฝั่ง stamp) - -Custom properties ของไฟล์ .docx (ชื่อ key ต้องตรงเป๊ะ — case-sensitive): - -``` -ticket_data = {"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"} -HmacSha256 = = HMAC-SHA256(secret, ticket_data ดิบทั้ง string ไม่ trim) -``` - -| field | type | เงื่อนไข | -|---|---|---| -| `exp` | int (unix วินาที) | บังคับ · `exp <= now` = หมดอายุ | -| `fid` | string | บังคับ · trim 1–200 · เป็น `docId` ของทุก operation ของกลุ่ม docver | -| `uid` | string | บังคับ · trim 1–100 | -| `uname` | string | บังคับ · trim 1–200 | -| `tid` | string | optional · ≤100 · template ต้นทาง (ไม่มี = `template: null`) | - -- field อื่นปนมา = reject (allowlist ตาม convention ของ repo) -- secret ปัจจุบัน = `"super_secret"` — **e-Court + `ADDIN_TICKET_SECRET` (server) + - `NEXT_PUBLIC_ADDIN_TICKET_SECRET` (client) ต้องตรงกันทั้งสาม** -- ⚠️ **custom property ของ Word จำกัด value ~255 ตัวอักษร** — JSON ของ `ticket_data` - ต้องออกแบบสั้น (fid/tid/uname ยาวมากจะทะลุ; ฝั่ง addin เจอค่าเกิน = ถือว่าไฟล์เสียหาย → no-access) - -## 3. ฝั่ง server — `POST /api/documents/token` รับ ticket เพิ่ม - -Branch ตาม header (ตัวตั้ง = `x-api-key`): - -| เคส | พฤติกรรม | -|---|---| -| มี header `x-api-key` | **ทางเดิมทุกอย่าง** — `requireDocverApiKey` + `parseTokenRequestBody` (body allowlist `userid`/`username`/`fid`/`template`, ส่ง field ticket ปนมา = 400 ตาม allowlist เดิม) | -| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` | **ทางใหม่** — `verifyAddinTicket` → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` | -| ไม่มี `x-api-key` + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string | 400 (รูปผิดก่อนถึงการ verify) | -| HMAC ผิด / parse ticket ไม่ผ่าน / `exp` หมดอายุ | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) | -| ไม่มี `x-api-key` และไม่มี ticket fields เลย | 401 | -| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) | - -- การเทียบ digest ฝั่ง server: hash ทั้งสองฝั่งด้วย SHA-256 แล้ว `timingSafeEqual` — pattern - `verifyDocverToken` ของ `lib/docver-token.ts` เป๊ะ -- middleware ยกเว้น `api/documents/*` อยู่แล้ว — ไม่ต้องแต้ - -## 4. โค้ดแชร์ — `utils/docver-ticket.ts` (pure, ไม่มี dependency เลย) - -```ts -export interface DocverTicket { exp: number; fid: string; tid: string | null; uid: string; uname: string } -export function parseDocverTicket(raw: string | unknown): { ok: true; ticket: DocverTicket } | { ok: false; error: string } -export function isTicketExpired(exp: number, nowSec?: number): boolean // exp <= now -export function normalizeHexDigest(value: string): string | null // trim+lowercase+ตรวจ ^[0-9a-f]{64}$ -export function timingSafeEqualHex(a: string, b: string): boolean // constant-time บน hex ที่ normalize แล้ว -export type HmacSha256Hex = (message: string, secret: string) => Promise // adapter ที่สองฝั่งฉีด -``` - -- `lib/` (server) กับ `wordaddin/` (client) ใช้ `parseDocverTicket`/`isTicketExpired` ตัวเดียวกัน — - schema drift กันได้จากฝั่งเดียว - -## 5. ฝั่ง addin — flow ตอนเปิดเอกสาร (map ขั้นตอน 1–7 ของ requirement) - -`TaskpaneApp.detect()` → `authService.signInWithTicket({ticketData, ticketHmac})`: - -``` -1. อ่าน properties ticket_data + HmacSha256 (readTrackedProperties) -2. ขาดข้อใดข้อหนึ่ง / ค่ายาวเกิน 255 → no-access "ไม่พบ ticket ของ e-Court ในไฟล์" - parseDocverTicket ไม่ผ่าน → no-access "ไฟล์ถูกแก้ไข" - HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข" -3. session = localStorage["wordaddin-auth"] (fallback memory) -4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่): - · ticket หมดอายุ → no-access "ticket หมดอายุ" - · เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ" - · เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}} -5. uid ตรงกัน (คนเดิม): - · tokens[fid] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร) - — token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6) - — token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน - · tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4 -6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase - untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน -``` - -- `authService` surface ใหม่: `signInWithTicket` / `getAccessToken` (token ของ fid ปัจจุบัน) / - `renewTokenFromTicket` / `getUsername` — ถอด `signInFromDocument` + `decodeJwtField` สำหรับ uid - (ticket มี uid/uname ตรง ๆ แล้ว เหลือ decode เฉพาะ `exp` ของ JWT ตอนเก็บ token) -- `apiClient` ตอนโดน 401: `renewTokenFromTicket()` → สำเร็จ = retry request 1 ครั้ง → ยัง 401/ไม่มี - ticket = `AuthRequiredError` (UI ไป no-access — เดิมมีอยู่แล้ว) -- `?addindemo` ยังใช้ได้ (`signInForDemo` คงเดิม) - -## 6. ไฟล์ที่สร้าง/แต้/ถอด - -**สร้าง** -- `utils/docver-ticket.ts` — §4 -- `lib/addin-ticket.ts` — server-only: `addinTicketSecret()` (env `ADDIN_TICKET_SECRET` trim หรือ default - `"super_secret"`) + `verifyAddinTicket(ticketData, hmacHex, secret, nowSec?)` (HMAC `node:crypto` + - timing-safe + parse + exp) + `ticketToDocverClaims(ticket)` — ห้าม import `@/db`/minio (unit test รันได้เปล่า ๆ) -- `wordaddin/services/ticketService.ts` — client: `verifyTicketHmacWeb` (crypto.subtle), session store - (`loadSession`/`saveSession`/`clearSession` — try/catch + memory fallback), `requestDocverToken` - (POST `withBasePath("/api/documents/token")` body ticket) -- `scripts/make-ticket-docx.mjs` — §9 -- `tests/utils/docver-ticket.test.ts`, `tests/lib/addin-ticket.test.ts` (vitest — pattern `tests/lib/docver-token.test.ts`) - -**แต้** -- `app/api/documents/token/route.ts` — branch ตาม §3 -- `wordaddin/config.ts` — `ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret"` -- `wordaddin/services/metadataService.ts` — ถอด `ECOURT_TOKEN_PROPERTY`/`ECOURT_OWNER_PROPERTY` → - `TICKET_DATA_PROPERTY = "ticket_data"` + `TICKET_HMAC_PROPERTY = "HmacSha256"` (`readTrackedProperties` - อ่านคู่นี้แทน `ecourtToken`/`ecourtOwner`) -- `wordaddin/services/authService.ts` — rewrite ตาม §5 -- `wordaddin/services/apiClient.ts` — renew-retry ตอน 401 -- `components/wordaddin/TaskpaneApp.tsx` — `detect()` เรียก `signInWithTicket` + ข้อความ no-access 3 แบบ -- `lib/openapi.ts` — POST /token มี 2 รูป body (oneOf: identity+x-api-key / ticket) -- `.env.example`, `docs/WORDADDIN-DEV.md`, `docs/addin-ui-flow.md`, `docs/PLAN-DOCVER.md` (§token ชี้มาที่นี่), `CLAUDE.md` - -**ถอด** -- ระบบ `token`/`owner` ทั้งเส้น (properties + `signInFromDocument` + `decodeJwtField` ส่วน uid) — - แทนด้วย ticket เต็มตัว - -## 7. Env - -| env | default | หมายเหตุ | -|---|---|---| -| `ADDIN_TICKET_SECRET` | `SECRET` ของกลุ่ม template → `"super_secret"` | server ตรวจ HMAC ทาง ticket ของ /token — อ่านตามลำดับ `ADDIN_TICKET_SECRET` → `SECRET` (deployment เดียวกับตัว stamp ของกลุ่ม template มักตั้งไว้แล้ว) → `"super_secret"` · **production ต้องตั้งค่าใหม่** (ค่า default รู้กันหมด = ปลอม ticket ได้) และต้องตรงกับฝั่ง e-Court · ห้าม log ค่าดิบ | -| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | ฝั่ง addin (UX gate) — inline ลง bundle · ต้องตรงกับฝั่ง e-Court เช่นกัน | - -> หมายเหตุจากตอน implement: ตัว stamp ticket จริงคือกลุ่ม template -> (`app/api/template/render/route.ts` → `applyTemplate` — sort key ตามตัวอักษรหลังเติม -> `exp` แล้ว HMAC hex lowercase ผ่าน `lib/lib-utils.ts` + stamp ลงไฟล์ด้วย -> `setCustomDocProps` ของ `lib/lib-tmpl.ts` อ่าน env `SECRET` กับ `TICKET_SEC` default -> 300 วิ — ไฟล์ยังมี property `Ed25519` เพิ่มด้วยแต่ out of scope ของแผนนี้) - -(เลือก default แทน fail-closed ตาม requirement ที่ระบุค่าปัจจุบันไว้ — dev ใช้ได้ทันที -แต่ comment เตือน production ชัด ๆ ทั้ง `.env.example` และแผนนี้) - -## 8. Edge cases - -- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว — - เช็คความยาวเพื่อข้อความที่ชัดกว่า) -- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว - (เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — ยอมรับได้) -- **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload) -- **exp == now** → หมดอายุ -- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access -- **Word เปิดหลายเอกสารพร้อมกัน (user เดียวกัน fid ต่างกัน)** — tokens map รองรับ; current fid เป็น - state ต่อ taskpane instance (memory) — แยกกันอยู่แล้ว -- **ไฟล์ทั่วไปไม่มี ticket** → no-access เหมือนเดิม (ข้อความเดิมปรับคำ) - -## 9. สคริปต์ทดสอบ — `scripts/make-ticket-docx.mjs` - -Dev tool รันมือ `node scripts/make-ticket-docx.mjs [options]`: - -- option: `--fid --uid --uname --tid --exp --secret ` และ - `--tracked` (stamp `SavedByAddIn`+`DocumentTrackingId` เพื่อทดสอบ phase tracking ด้วย) -- ทำงาน: สร้าง JSON ticket → HMAC-SHA256 hex → เขียน custom properties ลง `docProps/custom.xml` - ผ่าน **pizzip** (dependency อยู่แล้วจากกลุ่ม template) — สร้าง custom.xml เองถ้าไฟล์ยังไม่มี - (พร้อม override ใน `[Content_Types].xml`) · เขียนทับไฟล์เดิม**เฉพาะเมื่อส่ง `--in-place`** - ไม่งั้นเขียนไฟล์ใหม่ `.ticket.docx` (กฎ non-destructive ผูกกับ `public/` เท่านั้นแต่ถือหลักเดียวกัน) -- default secret/exp สอดคล้อง .env ตัวอย่าง - -## 10. Verify criteria - -1. `npx vitest run` ผ่านทั้งชุด (ไฟล์ใหม่ + เดิม — โดยเฉพาะ `tests/lib/docver-token.test.ts` ไม่พัง) -2. `npx tsc --noEmit` ผ่าน -3. curl regression ที่ /token: (ก) `x-api-key` + identity body ออก token ได้เหมือนเดิม - (ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400 -4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx: - ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · - exp อดีต → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → ไม่ยิง /token ซ้ำ (ใช้ token เดิม) · - ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ · - เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์ -5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/` - -## 11. ลำดับงาน - -1. `utils/docver-ticket.ts` + `tests/utils/docver-ticket.test.ts` -2. `lib/addin-ticket.ts` + `tests/lib/addin-ticket.test.ts` -3. `app/api/documents/token/route.ts` + `lib/openapi.ts` → verify 10.3 ด้วย curl -4. `wordaddin/config.ts` + `metadataService.ts` (properties) -5. `ticketService.ts` + `authService.ts` (rewrite) -6. `TaskpaneApp.tsx` (detect + ข้อความ) + `apiClient.ts` (renew-retry) -7. `scripts/make-ticket-docx.mjs` -8. env/docs/CLAUDE.md sync → verify 10.1–10.5 - -## 12. Out of scope - -- ~~ปลด mock `versioningService`~~ (ทำแล้ว 2026-10-27 — `versioningService` ยิงกลุ่ม docver จริงผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo` ดู `docs/WORDADDIN-DEV.md`) -- ~~ความไม่ตรงกันของ `documentId` (GUID ที่ stamp) กับ `docId` (= fid)~~ (ยุติแล้ว 2026-10-27 — เอกสารระบุด้วย fid ใน token ฝั่งเดียว; พารามิเตอร์ documentId ของ versioningService เหลือตาม signature เดิมไม่ส่งถึง server) -- UI จัดการ session/ออกจากระบบ, refresh token แบบมี revoke, ฝั่ง e-Court stamp (เขาทำ — เราส่ง §2 ให้) diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index 64de641..fff1526 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -1,8 +1,7 @@ # PLAN-DOCVER — Document Versioning API (stand-alone) > วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement -> รอบแรก (API ก่อน) · **ปลด mock wordaddin แล้ว 2026-10-27** — `versioningService` -> ยิงกลุ่มนี้จริงผ่าน Bearer (mock เหลือเฉพาะ `?addindemo`) +> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin) ## 0. บริบท/เป้าหมาย @@ -42,14 +41,6 @@ middleware เหมือน `api/template`) auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ -**ทางเข้าที่สอง (2026-10-27, add-in ใน Word — PLAN-ADDIN-AUTH.md):** ไม่มี header -`x-api-key` + body `{ticket_data, HmacSha256}` (ชื่อ field เหมือน custom properties -ในไฟล์) — server ตรวจ HMAC-SHA256(secret, ticket_data ดิบ) + exp ผ่าน -`lib/addin-ticket.ts` (env `ADDIN_TICKET_SECRET` → `SECRET` → default `"super_secret"`) -แล้วออก token จาก claims ใน ticket (`uid→userid`, `uname→username`, `fid`, `tid→template`) -· response/error รูปเดียวกับทางแรกทุกอย่าง (HMAC ผิด/หมดอายุ/ไม่ส่งมา = 401 ข้อความ -เดียวกัน · body ผิดรูป = 400) - JSON body (allowlist — key อื่น = 400 กัน typo): | field | type | หมายเหตุ | @@ -80,7 +71,7 @@ multipart/form-data: | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | -| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) | +| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | @@ -226,6 +217,6 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ## 8. Out of scope รอบนี้ -- ~~ไม่แต้ `wordaddin/` หรือ client ใด ๆ~~ (ปลด mock แล้ว 2026-10-27 — `wordaddin/services/versioningService.ts` ยิงกลุ่มนี้จริง same-origin ผ่าน Bearer; ดู `docs/WORDADDIN-DEV.md` หัว "การรันกับ backend จริง") +- ไม่แต้ `wordaddin/` หรือ client ใด ๆ (สลับ mock ทีหลังผ่าน `NEXT_PUBLIC_ADDIN_API_BASE_URL`) - ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ) diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 301bf33..23af799 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -4,7 +4,7 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม **ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview) -สถานะช่วงนี้: **ไม่มี login แล้ว** (ยกเลิก Keycloak 2026-10-27 — auth เข้าทางเดียวคือ **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ในไฟล์): add-in ตรวจ HMAC เอง → ขอ token ที่ `/api/documents/token` → เก็บใน localStorage ตาม fid ดูหัว "ทดสอบ e-Court ticket" และแผน `docs/PLAN-ADDIN-AUTH.md`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer จาก ticket: Save & Version ขึ้น MinIO จริง ดาวน์โหลดเปิด presigned URL ในเบราว์เซอร์; เหลือ mock เฉพาะโหมด `?addindemo` เพื่อดู UI ใน browser โดยไม่ต้องมี backend — ดูหัว "การรันกับ backend จริง") +สถานะช่วงนี้: **auth และ API เป็น mock** (login ทันที + version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend/auth จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง ## ไฟล์หลัก @@ -12,8 +12,8 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม |---|---| | `app/wordaddin/page.tsx` + `components/wordaddin/WordAddinGate.tsx` | gate โหลด Office.js (self-host) → `Office.onReady` → mount `TaskpaneApp` | | `app/wordaddin/manifest.xml/route.ts` | serve manifest พร้อม **rewrite origin ตามโฮสต์ที่ขอ** (อ่าน `x-forwarded-proto`/`x-forwarded-host` ก่อนแล้ว fallback host ตรง ผ่าน `utils/request-origin.ts` ที่แชร์กับ origin ของ deep link N10 — โฮสต์ที่ไม่ใช่ localhost บังคับ https เพราะ Office รับ https เท่านั้น) | -| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/no-access/untracked/tracking` + ทุก handler | -| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` + `ticketService` = ticket auth จริง ไม่มี login — PLAN-ADDIN-AUTH.md) | +| `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/signed-out/untracked/tracking` + ทุก handler | +| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `authService`/`versioningService` เป็น mock | | `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ | | `public/wordaddin/office-js/` | Office.js runtime — สร้างโดย postinstall `scripts/fetch-office-js.mjs` ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft (**gitignore** — รัน `npm install` ทุกครั้งหลัง clone เครื่องต้องออกอินเทอร์เน็ตตอนติดตั้ง ส่วนตัวระบบ deploy ใช้ intranet ล้วน) | | `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) | @@ -22,9 +22,8 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม | ตัวแปร | default | ใช้เมื่อไหร่ | |---|---|---| -| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) — ไม่เกี่ยวกับการขอ token (ยิง same-origin `/docedit/api/documents/token` เสมอ) | -| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อ fallback ของโหมด `?addindemo` (แสดง header + เป็น `createdByUserId` ของ mock) | -| `NEXT_PUBLIC_ADDIN_TICKET_SECRET` | `"super_secret"` | secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate) — ต้องตรงกับตัว stamp ของ e-Court และ `ADDIN_TICKET_SECRET`/`SECRET` ฝั่ง server (PLAN-ADDIN-AUTH.md §7) · **production ต้องตั้งค่าจริงทั้งสองฝั่ง** | +| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) | +| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อผู้ใช้ของ mock — แสดง header + เป็น `createdByUserId` | ## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word) @@ -53,37 +52,21 @@ Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแ ## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word) -- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม้นอก host (host เป็น null) → `detect()` อ่าน custom properties ไม่ได้ (ไม่มี `Word.run`) → เห็น `no-access` — ตรวจ state machine ได้แค่นี้ใน browser ปกติ ส่วน phase `untracked`/`tracking` ต้องใช้ demo ด้านล่าง (หรือ sideload ใน Word จริง) -- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + ตั้ง session mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected +- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ) +- **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + sign in mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected - หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง -## ทดสอบ e-Court ticket (auth ทางเข้าเดียว) +## ทดสอบ e-Court pass-through (ข้าม login) -ไฟล์ที่ e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ปัจจุบัน `super_secret`) ไว้ จะใช้ได้เลย — ไม่มี login ให้กด: `detect()` อ่านคู่นี้ตอนเปิด task pane แล้วเรียก `authService.signInWithTicket()` ตาม flow ของ `docs/requirement/addin-workflow.md` (ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage `wordaddin-auth` ตาม uid/fid → ขอ access_token ที่ `/docedit/api/documents/token` แบบ body ticket → เก็บ token ตาม fid) · ไฟล์ที่ไม่มีคู่นี้ / HMAC ไม่ตรง / ticket หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ +ไฟล์ที่ e-Court stamp custom properties `token` และ/หรือ `owner` ไว้ จะเข้าใช้ได้เลยโดยไม่ต้องกด Sign in — `detect()` อ่านค่าทั้งสองตอนเปิด task pane แล้วเรียก `authService.signInFromDocument()` (username ได้จาก `owner` → ถอด `uid` ใน JWT payload → `NEXT_PUBLIC_ADDIN_MOCK_USERNAME`, token จากไฟล์เก็บใน memory ให้ `getAccessToken()` ใช้ต่อ) -ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template): +ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า login -```bash -# สร้าง .ticket.docx (ไม่แตะไฟล์เดิม) exp +5 นาที เท่า TICKET_SEC ของตัว stamp -node scripts/make-ticket-docx.mjs path/to/file.docx --fid DEMO_FID_001 --uid oom --uname "สรวิชญ์" --tid T12 -# ดู custom properties ที่มีในไฟล์ -node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list -# ทดสอบ phase tracking ด้วย (stamp SavedByAddIn + DocumentTrackingId เพิ่ม) -node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place -``` +## การสลับกลับของจริง (วันหน้า) -แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ (จน ticket หมดอายุแต่ token ยังอยู่ใน localStorage) → ยังใช้ได้ตาม requirement ขั้น 7 - -หมายเหตุ: การขอ token ยิง same-origin `/docedit/api/documents/token` จริง (ไม่ผูก `NEXT_PUBLIC_ADDIN_API_BASE_URL`) — dev server ต้องรันอยู่และ secret ฝั่ง server (`ADDIN_TICKET_SECRET` → `SECRET` → default `super_secret`) ตรงกับ `--secret` ของสคริปต์ - -## การรันกับ backend จริง (ปลด mock แล้ว 2026-10-27) - -- **Auth:** ไม่มี login — **ยกเลิก Keycloak ถาวร 2026-10-27** · auth ของ add-in คือ ticket ของ e-Court ผ่าน `signInWithTicket` (ticket_data + HmacSha256 ในไฟล์ — แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): add-in ตรวจ HMAC ฝั่ง client (UX gate) แล้วขอ access_token ที่ `/api/documents/token` ซึ่ง server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server · โดน 401 กลาง flow = ลอง renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` (ticket ต่ออายุไม่ได้ — ต้องได้ไฟล์ใหม่จาก e-Court) -- **Upload/download ของจริงเป็น default แล้ว:** `wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน `apiClient` (Bearer จาก ticket) — **ไม่ต้องตั้ง env อะไรเพิ่ม** เพราะ `apiClient.toUrl` ใช้ same-origin `withBasePath("/api/documents/…")` เมื่อ `NEXT_PUBLIC_ADDIN_API_BASE_URL` ว่าง (API ของกลุ่ม docver อยู่แอปนี้เอง) · ตั้งค่า env นั้นเฉพาะวันที่ deploy add-in กับ API คนละ origin (ต้องรวม basePath เช่น `https://host/docedit` และเป็น HTTPS) - - **เอกสารถูกระบุด้วย fid ใน token** — พารามิเตอร์ `documentId` (GUID ที่ stamp ในไฟล์) ของ uploadVersion/getVersions/getDownloadLink เหลือไว้ตาม signature เดิม ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims ของ token — ส่งใน form = 400) -- **สิ่งที่ต้องรันฝั่ง server ก่อนใช้จริง:** MinIO (`docker compose up -d minio` — bucket `docver` app สร้างเอง) + env `DOCVER_API_KEY`/`DOCVER_TOKEN_SECRET`/`MINIO_*` ตาม `.env.example` ของกลุ่ม docver (docs/PLAN-DOCVER.md §6) · ทดสอบวงเต็ม: สร้างไฟล์ด้วย `make-ticket-docx.mjs` → เปิดใน Word → Save & Version → version ขึ้น MinIO จริง → กดดาวน์โหลด/PDF เปิด presigned URL ในเบราว์เซอร์ (โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้) -- **mock เหลือเฉพาะโหมด `?addindemo`** (dev-only — ดู UI ใน browser ไม่ต้องมี backend: version เก็บในเครื่อง metadata ใน localStorage `wordaddin-mock-versions`, download-link คืน about:blank — เคลียร์ด้วยการลบ key นี้หรือ clear storage ของ origin) -- ชุด unit test ของ services: ต้นฉบับ jest อยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` — ฝั่งนี้เขียนใหม่เป็น vitest เฉพาะส่วนที่เป็นโค้ดใหม่ (ticket auth + versioningService สองโหมด ที่ `tests/wordaddin/`) +- **Auth:** `wordaddin/services/authService.ts` เป็น mock คง surface เดิมครบ (`login/getAccessToken/refreshAccessToken/getUsername/signOut/startAutoRefresh/...`) — เขียน implementation จริงทับ (ต้นฉบับ Keycloak PKCE + Office Dialog API อยู่ที่ `D:\word-addin-poc\addin\src\services\authService.ts`; ถ้าใช้ ต้องเพิม DialogAPI กลับเข้า manifest + หน้า auth-start/auth-callback) · `signInFromDocument` (pass-through จาก token/owner ในไฟล์) เป็น mock-only — วันหน้า token ในไฟล์ควรเป็นแค่ตัวจับคู่เอกสาร ส่วนสิทธิ์ผู้ใช้พิสูจน์ตอน runtime +- **API:** `wordaddin/services/versioningService.ts` เก็บชื่อ/ลายเซ็นเหมือนต้นฉบับเป๊ะ — ก้อบ body เดิม (multipart POST ผ่าน `apiClient` ซึ่งพอร์ตไว้แล้ว) กลับมา + ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` +- ชุด unit test ของ services (jest → vitest) ยังไม่พอร์ต — ต้นฉบับอยู่ที่ `D:\word-addin-poc\addin\src\services\*.test.ts` + mock ที่ `src/test/` ## Docker diff --git a/docs/addin-ui-flow.md b/docs/addin-ui-flow.md index 7739cb5..83ef6da 100644 --- a/docs/addin-ui-flow.md +++ b/docs/addin-ui-flow.md @@ -10,33 +10,32 @@ Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`: -ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอนเข้า `no-access` กัน surprise · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ - -**ไม่มี login ตั้งแต่ 2026-10-27** (ยกเลิก Keycloak ตามคำขอผู้ใช้) — auth ทางเข้าเดียวคือ **ticket ของ e-Court**: `detect()` อ่าน custom properties `ticket_data` + `HmacSha256` ที่ e-Court stamp มากับไฟล์ แล้วเรียก `authService.signInWithTicket()` — ตรวจ HMAC ฝั่ง client → ตรวจ exp → ดู session ใน localStorage ตาม uid/fid → ขอ access_token ที่ `/api/documents/token` เก็บตาม fid (flow เต็ม: `docs/requirement/addin-workflow.md` + แผน `docs/PLAN-ADDIN-AUTH.md`) +ตั้งแต่ 2026-10-19 taskpane มี **2 แท็บ** (toggle กลุ่มปุ่ม `aria-pressed` ใต้ header — แสดงเฉพาะ phase `untracked`/`tracking`): **Versioning** คือทุกอย่างในเอกสารนี้ตามเดิม ส่วน **เครื่องมือ Template** (ดูหัวข้อ 10) คือเครื่องมือจัดการ Content Controls ฝั่ง Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้งสอง phase ปุ่ม toggle **ทั้งสองปิดระหว่าง flow ที่เขียนเอกสาร** — `saving || starting` (versioning), `saveBusy` (บันทึก local/ปิดเอกสาร), `templateBusy` (operation ของแท็บ template รายงานผ่าน `onBusyChange` — กันสลับกลาง operation แล้ว flow versioning มา stamp/capture ซ้อน หรือกลับกัน; code review 2026-10-20) · สถานะแท็บรีเซ็ตกลับ `versioning` ตอน sign out / AuthRequiredError กัน surprise หลัง login ใหม่ · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ ``` -checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access +checking ──(ไม่มี token)──────────────→ signed-out │ - ├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked - └─(ticket ผ่าน + tracked doc)────────→ tracking + ├─(มี token + ไม่ใช่ tracked doc)──→ untracked + └─(มี token + tracked doc)────────→ tracking ``` การเปลี่ยน phase อื่นๆ: +- `signed-out` → (sign in สำเร็จ) → `checking` → ตัดสินใหม่ตามไฟล์ปัจจุบัน - `untracked` → (Start Versioning สำเร็จ) → `tracking` -- ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access` -- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) +- ทุก phase → (401 ที่ refresh token ไม่ช่วย / กดออกจากระบบ) → `signed-out` +- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) · กลไกเดียวที่วิ่งกลับมา `checking` คือ sign-in สำเร็จ State ที่ UI ถือนอกเหนือจาก phase: | State | ความหมาย | |---|---| | `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 | -| `username` | ชื่อจาก `uname` ของ ticket — แสดงเป็น chip เฉย ๆ ใน header | +| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) | | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | | `versions` + `historyLoading` + `historyError` | version history ของ `documentId` | -| `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) | +| `signingIn`, `loginError` | สถานะหน้า sign-in | | `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version | | `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน | | `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) | @@ -54,22 +53,21 @@ State ที่ UI ถือนอกเหนือจาก phase: ### Header (ทุก phase) - ชื่อแอป: **e-Court Documents Versioning** (semibold) -- ฝั่งขวา (เมื่อผ่าน ticket auth แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title` +- ฝั่งขวา (เมื่อ login แล้วและไม่ใช่ `checking`/`signed-out`): ปุ่มเล็ก icon + `ออกจากระบบ` — username อยู่ใน `title`/`aria-label` (จอแคบ ไม่กินพื้นที่แถบหัว) — กดแล้ว sign out + กลับ `signed-out` ### `checking` - `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง - ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่) -### `no-access` +### `signed-out` -- ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้" -- คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ): - - ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น" - - ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย" - - ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" - - ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง" -- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น +- ข้อความ: "เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning" +- คำอธิบายจางๆ: "ใช้บัญชีของ realm `word-versioning` — หน้าต่าง login จะเปิดแยกต่างหาก" +- ปุ่ม primary `เข้าสู่ระบบ` → ระหว่างรันเปลี่ยนเป็น `กำลังเข้าสู่ระบบ…` + disable +- ถ้า login พัง: MessageBar **warning** (`loginError`) — เช่น "เซสชันหมดอายุ — กรุณา login ใหม่" ตอนโดน 401 กลาง flow + +> ใน project จริง login เปิดหน้าต่างแยกผ่าน Office Dialog API (ห้าม redirect iframe ไป Keycloak ตรงๆ) — ถ้าโปรเจกต์ใหม่ยังไม่มี auth ให้ mock `login()` คืน `{ ok: true }` ไปก่อน ### `untracked` @@ -164,10 +162,10 @@ Dialog เดียวใช้ 2 flow (ปรับข้อความด้ ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout) -### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่ +### Detect (FR-1) — ตอนเปิด task pane / หลัง login / กดลองใหม่ -1. อ่าน custom properties ครั้งเดียวทั้งชุด: `ticket_data`, `HmacSha256`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId` -2. ticket auth (`signInWithTicket` — PLAN-ADDIN-AUTH.md §5): อ่านไฟล์ไม่ได้ / ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ (ตามเงื่อนไข uid/fid ของ session) / ขอ token ไม่สำเร็จ → `no-access` แยกข้อความตามสาเหตุ · ผ่าน = มี access_token ของ fid นี้เก็บใน localStorage `wordaddin-auth` แล้วไปต่อ +1. ตรวจ token — ไม่มี → `signed-out` +2. อ่าน custom properties ครั้งเดียวทั้งชุด: `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId` 3. `tracked = (SavedByAddIn === GUID ของ add-in)`: - tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history - ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้) @@ -202,7 +200,7 @@ dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดั ### Auto-save (FR-17) -- เริ่มตั้งแต่ mount **ไม่ผูกกับ phase** (detect ยังไม่จบก็รันได้) +- เริ่มตั้งแต่ mount **ไม่รอ sign-in** - periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม - save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar - ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture) @@ -253,17 +251,17 @@ interface VersionRecord { response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น -### Auth — e-Court ticket (ไม่มี login) +### Auth — OIDC PKCE ผ่าน Office Dialog API -ยกเลิก Keycloak OIDC/PKCE แล้ว (2026-10-27) — ไม่มี Office Dialog ไม่มีหน้า sign-in: e-Court stamp custom properties **`ticket_data`** (JSON `exp`/`fid`/`tid`/`uid`/`uname`) + **`HmacSha256`** (HMAC-SHA256 ของ string ดิบ ด้วย secret ที่ config ได้ — ปัจจุบัน `super_secret`) ลงไฟล์ตอนสร้าง แล้ว `signInWithTicket()` ใช้ได้ทันที · flow ตาม `docs/requirement/addin-workflow.md` (แผนเต็ม `docs/PLAN-ADDIN-AUTH.md`): ตรวจ HMAC ฝั่ง client (UX gate) → ตรวจ exp → session ใน localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ session เก่าทั้งก้อน) → **ขอ access_token ที่ `POST /api/documents/token`** แบบ body `{ticket_data, HmacSha256}` (server ตรวจ HMAC+exp อีกชั้นด้วย secret ฝั่ง server) → เก็บ token ตาม fid ("คีย์คือ fid") — กลับมาเปิดเอกสารเดิม = ใช้ token เดิมไม่ต้องขอใหม่ · token หมดอายุกลางใช้งาน (โดน 401) = ลองขอใหม่จาก ticket 1 ครั้ง (ticket ต้องยังไม่หมดอายุ) ยังไม่ผ่าน = `AuthRequiredError` → หน้า `no-access` ให้ขอไฟล์ใหม่จาก e-Court · token นี้คือสิ่งที่ `apiClient` แนบเป็น `Authorization: Bearer` ทุก request ตอนต่อ backend จริง (กลุ่ม docver บังคับ Bearer — docs/PLAN-DOCVER.md) +`displayDialogAsync` เปิดหน้า login แยก → `messageParent` ส่งผลกลับ task pane · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) · refresh token auto-refresh เงียบๆ จน 401 → สถานะ `AuthRequiredError` · mock ช่วงรอ: `login()` คืน `{ ok: true }`, `getAccessToken()` คืน token ปลอม, `getUsername()` คืนชื่อ test -> **หมายเหตุสถานะปัจจุบัน:** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — auth เป็น ticket auth จริงครบ flow (`wordaddin/services/authService.ts` + `ticketService.ts` + ทางเข้า ticket ของ `/api/documents/token`) และ **API ทั้งสามข้างบนเป็นของจริงแล้ว** (`wordaddin/services/versioningService.ts` ยิงกลุ่ม `/api/documents/*` ผ่าน Bearer — ปลด mock 2026-10-27; เหลือ mock เฉพาะโหมด `?addindemo`) · รายละเอียดการรันกับ backend: `docs/WORDADDIN-DEV.md` · สร้างไฟล์ทดสอบพร้อม ticket: `node scripts/make-ticket-docx.mjs` +> **หมายเหตุสถานะปัจจุบัน (2026-10-14):** UI นี้ถูก implement แล้วใน repo นี้ที่ route `/docedit/wordaddin` (ดู `components/wordaddin/` + `wordaddin/`) — ทั้ง auth (แทนด้วย mock `wordaddin/services/authService.ts`) และ API ทั้งสามข้างบน (แทนด้วย in-memory mock `wordaddin/services/versioningService.ts`) · วิธีสลับกลับของจริง: `docs/WORDADDIN-DEV.md` --- ## 8. กติกา error ที่ UI ต้องรองรับ -1. **`AuthRequiredError` (ไม่มี token / 401 หลัง renew-retry)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase** +1. **`AuthRequiredError` (401 ที่ refresh ไม่ช่วย)** — ทุก flow ต้อง catch แล้ว: กลับ `signed-out` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `loginError` = "เซสชันหมดอายุ — กรุณา login ใหม่" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase** 2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection 3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว 4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow diff --git a/docs/requirement/addin-workflow.md b/docs/requirement/addin-workflow.md deleted file mode 100644 index a4a6bf1..0000000 --- a/docs/requirement/addin-workflow.md +++ /dev/null @@ -1,44 +0,0 @@ -# ขั้นตอนการทำงานของ Addin - -## 1. เมื่อเปิดเอกสาร - -- อ่าน custom property ของ word - 1. ticket_data ซึ่งจะมีข้อมูลเบื้องต้น ดังนี้ - ```jsonc - { - "exp": 1793067433, - "fid": "T12_FD155_3s-long_ZZZ", - "tid": "T12", - "uid": "oom", - "uname": "สรวิชญ์", - } - ``` - 2. นำค่า ticket_data ที่เป็น string ก่อนนำไปแปลง json ทำการ sign HMAC-SHA256 โดยใช้ secret "super_secret" ซึ่งต้องรองรับการ config ค่านี้ในอนาคต - 3. นำค่าที่ sign ได้ มาเทียบกับค่า property "HmacSha256" -- ขั้นตอนการทำ authen - ``` - ขั้นตอน - 1. เมื่อเปิดไฟล์ Word ให้อ่าน Custom Property 2 ค่าที่ฝังมาในไฟล์ คือ value จริง และ Hash Value (HMAC) เพื่อตรวจสอบว่าไฟล์สร้างผ่านระบบอย่างถูกต้อง - 2. ถ้า HMAC ไม่ตรงกัน แสดงว่าไฟล์ถูกแก้ไข ให้ Add-in disable การทำงานทั้งหมด - 3. ถ้า HMAC ตรงกัน ให้เก็บค่า uId, uName, fId, tid ไว้ แล้วตรวจสอบว่า userId นี้มีใน localStorage อยู่แล้วหรือไม่ - 4. ถ้ายังไม่มีให้ตรวจค่า Exp ที่ได้จากไฟล์ Word - - หมดอายุ → disable Add-in ทั้งหมด - - ยังไม่หมดอายุ → เก็บใน localStorage - - userName: ค่าจาก uName - - คีย์คือ fid: เอาค่า { - "userid": uid, - "username": uname, - "fid": fid (เป็น  string), - "tid": tid - } - ไปทำการขอ token ที่ /api/documents/token แล้วนำค่าที่ได้มาเก็บไว้ที่ตัวแปรนี้ เพื่อให้อ่านค่าไปใช้ให้ตรงกับค่า fid ที่ได้รับ เนื่องจาก api ในส่วน /documents จะต้องแนบ token ไปไม่ส่งค่าไปตรงๆ เพื่อความถูกต้อง - 5. ถ้ามี Session อยู่แล้ว ให้ตรวจว่า userId ตรงกับ Session หรือไม่ - 6. ถ้า userId ไม่ตรงกัน (user ใหม่) ให้ตรวจค่า Expire - - ยังไม่หมดอายุ → เคลียร์ Session เดิม แล้วสร้าง Session 2 ค่าใหม่ - - หมดอายุ → disable Add-in ทั้งหมด - 7. ถ้า userId ตรงกัน (คนเดิม) ให้ตรวจว่า fid มีใน localStorage แล้วหรือไม่ - - มีแล้ว (กลับมาทำเอกสารเดิม) → ผ่านได้เลย ไม่ต้องตรวจ Expire - - ยังไม่มี (เอกสารใหม่) → ตรวจค่า Expire - - ยังไม่หมดอายุ → ทำการขอ token แล้วเก็บใน localStorage ตามขั้นตอนก่อนหน้า - - หมดอายุ → disable Add-in ทั้งหมด - ``` diff --git a/lib/addin-ticket.ts b/lib/addin-ticket.ts deleted file mode 100644 index 766db45..0000000 --- a/lib/addin-ticket.ts +++ /dev/null @@ -1,124 +0,0 @@ -// PLAN-ADDIN-AUTH.md §3 — ตรวจ ticket ของ add-in ฝั่ง server (ทางเข้าที่สองของ -// POST /api/documents/token) — secret อยู่ฝั่ง server อย่างเดียว เพราะทางเข้านี้ -// ผู้เรียกคือ add-in ใน webview ของ Word ซึ่งแนบ x-api-key (server-to-server -// ของ e-Court) มาไม่ได้ — ตัวพิสูจน์ตัวตนคือ ticket_data + HmacSha256 ที่ e-Court -// stamp มากับไฟล์ (คำนวณ HMAC ตรงกับ lib/lib-utils.ts generateHmacSha256 -// ของกลุ่ม template: hex lowercase บน string ดิบ) -// -// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรัน -// ได้โดยไม่ต้องตั้ง env อะไรเลย (pattern เดียวกับ lib/docver-token.ts) -import { createHash, createHmac, timingSafeEqual } from "node:crypto"; - -import { - isTicketExpired, - normalizeHexDigest, - parseDocverTicket, - type DocverTicket, -} from "@/utils/docver-ticket"; -import type { DocverTokenClaims } from "@/lib/docver-token"; - -/** - * secret ของ ticket — อ่านตอนเรียก ไม่ใช่ตอน module load - * ลำดับ: ADDIN_TICKET_SECRET (ของกลุ่มนี้) → SECRET (env เดียวกับตัว stamp - * ของกลุ่ม template — deployment เดียวกันมักตั้งค่านี้อยู่แล้ว) → "super_secret" - * (ค่าตาม requirement — dev ใช้ได้ทันที แต่ production ต้องตั้งค่าจริงเพราะ - * ค่าเดิมรู้กันหมด = ใครก็ปลอม ticket ได้) · ห้าม log ค่าดิบ - */ -export function addinTicketSecret(): string { - return ( - process.env.ADDIN_TICKET_SECRET?.trim() || - process.env.SECRET?.trim() || - "super_secret" - ); -} - -/** - * ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา - * (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle — - * add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) - */ -export function verifyAddinTicket( - ticketData: string, - hmacHex: string, - secret: string, - nowSec: number = Math.floor(Date.now() / 1000), -): { ok: true; ticket: DocverTicket } | { ok: false } { - const digest = normalizeHexDigest(hmacHex); - if (digest === null) return { ok: false }; - - // เทียบแบบ timing-safe ตาม pattern verifyDocverToken — hash ทั้งสองฝั่ง - // ด้วย SHA-256 ก่อนเพื่อให้ digest ยาวเท่ากันเสมอ (timingSafeEqual ไม่ throw) - const expected = createHmac("sha256", secret).update(ticketData, "utf8").digest(); - const expectedHash = createHash("sha256").update(expected).digest(); - const providedHash = createHash("sha256").update(Buffer.from(digest, "hex")).digest(); - if (!timingSafeEqual(providedHash, expectedHash)) return { ok: false }; - - const parsed = parseDocverTicket(ticketData); - if (!parsed.ok) return { ok: false }; - if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false }; - return { ok: true, ticket: parsed.ticket }; -} - -/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */ -export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims { - return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid }; -} - -/** body ของทางเข้า ticket — allowlist 2 field เป๊ะ (ชื่อเดียวกับ custom properties) */ -const TICKET_BODY_FIELDS = ["ticket_data", "HmacSha256"] as const; - -export type TicketTokenBodyResult = - | { ok: true; ticketData: string; hmacHex: string } - | { ok: false; kind: "missing" | "invalid"; error: string }; - -/** - * parse body ของ POST /api/documents/token ทางเข้า ticket - * - `missing` = ไม่มี ticket มาเลย (route ตอบ 401 — คำเดียวกับตอน verify ไม่ผ่าน) - * - `invalid` = รูปผิด (route ตอบ 400 ตาม convention กัน typo เงียบ ๆ) - */ -export function parseTicketTokenBody(raw: unknown): TicketTokenBodyResult { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - return { ok: false, kind: "invalid", error: "body ต้องเป็น JSON object" }; - } - const record = raw as Record; - for (const key of Object.keys(record)) { - if (!(TICKET_BODY_FIELDS as readonly string[]).includes(key)) { - return { - ok: false, - kind: "invalid", - error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TICKET_BODY_FIELDS.join(", ")})`, - }; - } - } - - const read = (name: (typeof TICKET_BODY_FIELDS)[number]): string | null => { - const value = record[name]; - if (value === undefined || value === null || value === "") return null; // ไม่ส่ง = missing - if (typeof value !== "string") return ""; // ส่งผิดชนิด = invalid - return value; - }; - - const ticketData = read("ticket_data"); - if (ticketData === null) { - return { ok: false, kind: "missing", error: "ต้องระบุ ticket_data (string)" }; - } - if (ticketData === "") { - return { ok: false, kind: "invalid", error: "ticket_data ต้องเป็น string" }; - } - if (ticketData.length > 2048) { - return { ok: false, kind: "invalid", error: "ticket_data ยาวเกินไป" }; - } - - const hmacHex = read("HmacSha256"); - if (hmacHex === null) { - return { ok: false, kind: "missing", error: "ต้องระบุ HmacSha256 (string)" }; - } - if (hmacHex === "") { - return { ok: false, kind: "invalid", error: "HmacSha256 ต้องเป็น string" }; - } - if (hmacHex.length > 128) { - return { ok: false, kind: "invalid", error: "HmacSha256 ยาวเกินไป" }; - } - - return { ok: true, ticketData, hmacHex }; -} diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index 7d1dd63..bbbb054 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -124,11 +124,8 @@ export function parseFieldsJson(raw: string): } const record = parsed as Record; const keys = Object.keys(record); - // เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้: - // เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb - // ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ) - if (keys.length > 1_000) { - return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' }; + if (keys.length > 100) { + return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' }; } for (const key of keys) { if (key.length > 200) { @@ -138,6 +135,9 @@ export function parseFieldsJson(raw: string): if (typeof value !== "string") { return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' }; } + if (value.length > 10_000) { + return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' }; + } } return { ok: true, fields: keys.length === 0 ? null : (parsed as Record) }; } diff --git a/lib/docver-token.ts b/lib/docver-token.ts index 92bfc41..ce41a3e 100644 --- a/lib/docver-token.ts +++ b/lib/docver-token.ts @@ -121,12 +121,12 @@ export function verifyDocverToken( * คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */ export function requireDocverApiKey(request: Request): Response | null { // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file - const envKey = process.env.DOCVER_API_KEY?.trim(); - // const envKey = - // "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54"; - if (envKey === undefined || envKey === "") { - return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); - } + //const envKey = process.env.DOCVER_API_KEY?.trim(); + const envKey = + "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54"; + // if (envKey === undefined || envKey === "") { + // return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); + // } const provided = request.headers.get("x-api-key"); if (provided === null || provided === "") { return docverError("API key ไม่ถูกต้อง", 401); @@ -150,10 +150,10 @@ export function requireDocverBearer( ): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } { const denied = () => ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const; - const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); - // const secret = - // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; - if (secret === undefined || secret === "") return denied(); + //const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); + const secret = + "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; + //if (secret === undefined || secret === "") return denied(); const header = request.headers.get("authorization"); if (header === null) return denied(); const match = /^Bearer\s+(\S+)$/i.exec(header); diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index bb293d0..4bf4b70 100755 --- a/lib/lib-tmpl.ts +++ b/lib/lib-tmpl.ts @@ -1,4 +1,3 @@ -import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils'; import createReport from 'docx-templates'; import PizzaZip from "pizzip"; import { @@ -1262,53 +1261,3 @@ export const templateDynamicContents = async (template: Buffer, data: Record> -{ - //Calculate hash - const originalTicket_data = templateData?.info?.ticket_data //expect valid json string - // console.log("templateData",templateData?.info?.ticket_data) - if (originalTicket_data && typeof originalTicket_data === 'string') { - try { - const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data - if (originalTicketObj && typeof originalTicketObj === 'object') { - console.log("Found valid ticket data: hash it") - // คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ) - // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) - const expireTimestamp = Math.floor(Date.now() / 1000) + ttl; - const fullObj = { ...originalTicketObj, exp: expireTimestamp }; - const sortedObj = sortObjectKeys(fullObj); - - // แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash - const ticket_data = JSON.stringify(sortedObj); - // console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data) - const HmacSha256 = generateHmacSha256(ticket_data, secret) - - const Ed25519 = signEd25519(ticket_data, private_key) - templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 }) - } - } catch (error) { //just skip - console.error("Set custom property fail",error); - } - } - - // Apply Dynamic Content - if (templateData?.dc && typeof templateData?.dc === "object") { - console.log("Apply Dynamic Content") - templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc)) - } - // Apply Content Control - if (templateData?.cc && typeof templateData?.cc === "object") { - console.log("Apply Content Control") - templateBuffer = templateContentControls(templateBuffer, templateData.cc); - } - return templateBuffer - -} \ No newline at end of file diff --git a/lib/lib-utils.ts b/lib/lib-utils.ts index 312d3cf..f17a247 100644 --- a/lib/lib-utils.ts +++ b/lib/lib-utils.ts @@ -50,7 +50,7 @@ console.log('Is Signature Valid?:', isValid); // true * @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร) */ -export function signEd25519(message: string | Buffer, privateKeyHex: string): string { +export function signDataEd25519(message: string | Buffer, privateKeyHex: string): string { const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex'); // ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา) const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer; @@ -76,7 +76,7 @@ export function signEd25519(message: string | Buffer, privateKeyHex: string): st * @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน) */ -export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean { +export function verifyDataEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean { try { const rawPublicKey = Buffer.from(publicKeyHex, 'hex'); diff --git a/lib/openapi.ts b/lib/openapi.ts index 4dc3b66..a21749e 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -32,16 +32,16 @@ export const openApiSpec = { { name: "template", description: - "Next Generation of Template : เป็นเทมเพลต MS Word(docx) รองรับรูปแบบข้อมูลที่ซับซ้อนได้ ", + "Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " + + "แล้วได้เอกสารที่กรอกค่าแล้วกลับมา", }, { name: "documents", description: - "จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " + + "จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " + "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " + - "ของกลุ่มนี้: ขอที่ POST /api/documents/token ได้ 2 ทาง (x-api-key + body claims " + - "สำหรับระบบภายนอก หรือ body ticket_data+HmacSha256 สำหรับ add-in — PLAN-ADDIN-AUTH.md) " + - "แล้วแนบ Authorization: Bearer ทุก request — identity/template " + + "ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " + + "DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " + "และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " + "ไม่รับจาก URL/multipart form อีก", }, @@ -75,15 +75,67 @@ export const openApiSpec = { }, }, }, + "/api/template/extract": { + post: { + tags: ["template"], + summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx", + description: + "อ่าน w:sdt (Content Control) ทุกตัวใน word/document.xml — คืน map " + + "ชื่อแท็ก (w:tag) → ข้อความปัจจุบันในนั้น (ใช้สำรวจว่า template มีแท็กอะไรบ้างก่อนเรียก render)", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: ["file"], + properties: { + file: { + type: "string", + format: "binary", + description: "ไฟล์ .docx ที่มี Content Control", + }, + secret: { + type: "string", + description: + "ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property" , + example: + "super_secret", + }, + public_key: { + type: "string", + description: + "Public key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", + example: + "66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "รายการแท็กพร้อมข้อความ", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ExtractResponse" }, + }, + }, + }, + 400: { $ref: "#/components/responses/BadRequest" }, + 500: { $ref: "#/components/responses/ServerError" }, + }, + }, + }, "/api/template/render": { post: { tags: ["template"], - summary: "สร้างเอกสารจาก template (.docx) + data (JSON) ใช้สำหรับนักพัฒนาเท่านั้น", + summary: "สร้างเอกสารจาก template (.docx) + data (JSON)", description: "แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " + '(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' + - 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)
'+ - 'สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ private key เองไม่ใช้ของระบบของระบบ', + 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)', requestBody: { required: true, content: { @@ -142,410 +194,6 @@ export const openApiSpec = { }, }, }, - "/api/template/extract": { - post: { - tags: ["template"], - summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx ใช้สำหรับนักพัฒนาเท่านั้น", - description: - "อ่าน Content Control, bookmark , property ของ MS Word(docx) ออกมาแสดง" + - "ค่าของ Content Control จะเป็น Plain Text ไม่มี markup ซึ่งเหมาะกับการเอาไปใส่ในฐานข้อมูล"+ - 'สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ Public key เองไม่ใช้ของระบบของระบบ', - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - required: ["file"], - properties: { - file: { - type: "string", - format: "binary", - description: "ไฟล์ .docx ที่มี Content Control", - }, - secret: { - type: "string", - description: - "ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property", - example: - "super_secret", - }, - public_key: { - type: "string", - description: - "Public key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", - example: - "66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546", - }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "รายการแท็กพร้อมข้อความ", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ExtractResponse" }, - }, - }, - }, - 400: { $ref: "#/components/responses/BadRequest" }, - 500: { $ref: "#/components/responses/ServerError" }, - }, - }, - }, - "/api/template/generate": { - post: { - tags: ["template"], - summary: "สร้างเอกสาร DOCX จากข้อมูล JSON และ template ที่กำหนด", - description: - "เทมเพลตมีแท็กแบบพิเศษ(รองรับตัวแปร ลูป เงื่อนไข), Content Control เมื่อรวมกับข้อมูล(JSON) "+ - "จะได้เอกสาร รูปแบบฟอร์มที่กรอกค่าแล้วดึงค่ากลับมาใช้งานต่อได้เช่นอัปเดตฐานข้อมูล
    "+ - "
  • JSON payload ต้องมี info.ticket_data เป็น JSON string รูปแบบถูกต้อวมีค่าครบ(tid, fid และ uid)
  • " + - "
  • ไฟล์เทมเพลต(tid)ในระบบ + ข้อมูล(payload) เพื่อสร้างเอกสาร
  • " + - "
  • ticket_data จะทำการ hash/sign แบบอายุสั้นแล้วนำไปฝังไว้ใน custom property เพื่อนำไปใช้ร่วมกับระบบ Versioning
  • "+ - "
", - security: [{ ApiKeyAuth: [] }], - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - required: ["info"], - properties: { - info: { - type: "object", - required: ["ticket_data"], - properties: { - ticket_data: { - type: "string", - description: - "JSON string ที่ต้อง parse ได้ และต้องมี tid, fid และ uid", - example: - '{"tid":"T12","fid":"DOC-2569-0001","uid":"123"}', - }, - }, - additionalProperties: true, - }, - }, - additionalProperties: true, - }, - example: { - "info": { - "ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" - }, - "dc": { - "Interpleader": true, - "docname": "", - "docabbr": "(ต. ๑๒)", - "blackno": "คดีหมายเลขดำที่", - "redno": "คดีหมายเลขแดงที่", - "court": "ศาลปกครองนครราชสีมา", - "panels": [ - { - "pname": "นายวิรุฬห์ จินตนะกุล", - "pmname": "ตุลาการศาลปกครองกลาง", - "pm": "ตุลาการเจ้าของสำนวน" - }, - { - "pname": "นายสัมฤทธิ์ อ่อนคำ", - "pmname": "ตุลาการศาลปกครองสูงสุด" - } - ], - "pnameT": "", - "pmnameT": "", - "pmT": "", - "acc_name": "" - - }, - "cc": { - "blackyear": "๙๐/๒๕๖๙", - "redyear": "", - "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", - "accuser": "นายธีรพล ร่มเริง", - "acc1": "ผู้ฟ้องคดี", - "accused": "รร", - "acc2": "ผู้ถูกฟ้องคดี", - "interpleader": "สมชาย ใจดี", - "acc3": "ผู้ร้องสอด", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง" - } - }, - }, - }, - }, - responses: { - 200: { - description: "ไฟล์ DOCX ที่สร้างสำเร็จ", - headers: { - "Content-Disposition": { - description: "ชื่อไฟล์สำหรับดาวน์โหลด", - schema: { type: "string" }, - }, - "Cache-Control": { - description: "ป้องกันการ cache response", - schema: { type: "string", example: "no-store, max-age=0" }, - }, - }, - content: { - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { - schema: { type: "string", format: "binary" }, - }, - }, - }, - 400: { - description: - "JSON ไม่มี info.ticket_data, ticket_data ไม่ใช่ string, JSON string ไม่ถูกต้อง หรือไม่มี tid/fid/uid", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - examples: { - missingTicketData: { - value: { - error: - "template/generate json.info.ticket_data not avaliable", - }, - }, - missingIdentifiers: { - value: { - error: - "template/generate require json.info.ticket_data.(fid|tid|uid)", - }, - }, - }, - }, - }, - }, - 401: { - description: "ไม่ได้ส่ง x-api-key หรือ API key ไม่ถูกต้อง", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Unauthorized: Invalid API Key" }, - }, - }, - }, - 404: { - description: "ไม่สามารถดึงข้อมูลขึ้นมาได้", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "NotFound: Invalid template ID or incorrect storage path" }, - }, - }, - }, - 500: { - description: - "เกิดข้อผิดพลาดระหว่างอ่าน template หรือสร้างเอกสาร", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Failed to generate document payload" }, - }, - }, - }, - }, - }, - }, - "/api/template/download-docx": { - get: { - tags: ["template"], - summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง", - description: - "รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", - parameters: [ - { - name: "fid", - in: "query", - required: true, - schema: { type: "string" }, - description: "ID เอกสาร (File ID)", - example: "T_no_warning", - }, - { - name: "tid", - in: "query", - required: true, - schema: { type: "string" }, - description: "ID เทมเพลต (Template ID)", - example: "T_X", - }, - { - name: "uid", - in: "query", - required: true, - schema: { type: "string" }, - description: "รหัสผู้ใช้งาน (User ID)", - example: "123", - }, - { - name: "uname", - in: "query", - required: true, - schema: { type: "string" }, - description: "ชื่อผู้ใช้งาน (User Name)", - example: "สมชาย ใจดี", - }, - ], - responses: { - 200: { - description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ", - headers: { - "Content-Disposition": { - description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)", - schema: { type: "string", example: 'attachment; filename="T_no_warning_123_xxx.docx"' }, - }, - "Cache-Control": { - description: "การควบคุม Cache (no-store)", - schema: { type: "string", example: "no-store, max-age=0" }, - }, - }, - content: { - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { - schema: { type: "string", format: "binary" }, - }, - }, - }, - 400: { - description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง", - content: { - "text/plain": { - schema: { type: "string" }, - examples: { - missingParams: { value: "Missing fid,tid,uid or uname" }, - invalidData: { value: "Invalid data format" }, - }, - }, - }, - }, - 404: { - description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Data not found: /path/to/TMPL_PATH/T_no_warning.json" }, - }, - }, - }, - 502: { - description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)", - }, - }, - }, - 500: { - description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Internal Server Error", - }, - }, - }, - }, - }, - post: { - tags: ["template"], - summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง", - description: - "รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded อ่านข้อมูล JSON และประมวลผลสร้างไฟล์ DOCX stream คืนกลับไป "+ - "จะเรียก generate ในเบื้องหลัง ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - required: ["fid", "tid", "uid", "uname"], - properties: { - fid: { type: "string", description: "ID เอกสาร (File ID)",example:"T12_b2_IN" }, - tid: { type: "string", description: "ID เทมเพลต (Template ID)",example:"T12_X" }, - uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)", example:"john" }, - uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)", example:"John Doe"}, - }, - }, - }, - "application/x-www-form-urlencoded": { - schema: { - type: "object", - required: ["fid", "tid", "uid", "uname"], - properties: { - fid: { type: "string", description: "ID เอกสาร (File ID)" }, - tid: { type: "string", description: "ID เทมเพลต (Template ID)" }, - uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)" }, - uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)" }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ", - headers: { - "Content-Disposition": { - description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)", - schema: { type: "string", example: 'attachment; filename="T12_b2_IN_john_xxx.docx"' }, - }, - "Cache-Control": { - description: "การควบคุม Cache (no-store)", - schema: { type: "string", example: "no-store, max-age=0" }, - }, - }, - content: { - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { - schema: { type: "string", format: "binary" }, - }, - }, - }, - 400: { - description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง", - content: { - "text/plain": { - schema: { type: "string" }, - examples: { - missingParams: { value: "Missing fid,tid,uid or uname" }, - invalidData: { value: "Invalid data format" }, - }, - }, - }, - }, - 404: { - description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Data not found: /path/to/TMPL_PATH/T12_b2_IN.json" }, - }, - }, - }, - 502: { - description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)", - }, - }, - }, - 500: { - description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Internal Server Error", - }, - }, - }, - }, - }, - }, "/api/note/external/documents": { post: { tags: ["external"], @@ -614,28 +262,18 @@ export const openApiSpec = { summary: "ขอ access_token ของกลุ่ม documents (docver)", description: "ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " + - "JWT HS256 ฝัง claims {userid, username, fid, template} อายุตาม env " + + "JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " + "DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " + "templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " + - "fid จึงบังคับ · ทางเข้า 2 ทาง (header x-api-key เป็นตัวตั้ง): " + - "(1) ระบบภายนอก server-to-server — header x-api-key (env DOCVER_API_KEY) + body " + - "claims ตรง ๆ (2) add-in ใน Word — ไม่มี x-api-key + body {ticket_data, " + - "HmacSha256} ตามที่ e-Court stamp มากับไฟล์ — server ตรวจ HMAC-SHA256 ด้วย env " + - "ADDIN_TICKET_SECRET (ไม่ตั้ง = env SECRET ไม่ตั้ง = \"super_secret\") และ exp " + - "ของ ticket ก่อนออก token จาก claims ในตัว ticket เอง", + "fid จึงบังคับ", security: [{ ApiKeyAuth: [] }], requestBody: { required: true, content: { "application/json": { - schema: { - oneOf: [ - { $ref: "#/components/schemas/TokenRequest" }, - { $ref: "#/components/schemas/TicketTokenRequest" }, - ], - }, + schema: { $ref: "#/components/schemas/TokenRequest" }, examples: { - "server-to-server ด้วย x-api-key (ครบทุก claim)": { + "ครบทุก claim": { value: { userid: "123", username: "Sorawit Bholsithi", @@ -643,17 +281,9 @@ export const openApiSpec = { template: "T12", }, }, - "server-to-server ขั้นต่ำ (template เป็นทางเลือก)": { + "ขั้นต่ำ (template เป็นทางเลือก)": { value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" }, }, - "add-in ด้วย ticket จากไฟล์ (ไม่ต้องมี x-api-key)": { - value: { - ticket_data: - '{"exp":1793067433,"fid":"T12_FD155_3s-long_ZZZ","tid":"T12","uid":"oom","uname":"สรวิชญ์"}', - HmacSha256: - "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9", - }, - }, }, }, }, @@ -669,9 +299,7 @@ export const openApiSpec = { }, 400: { $ref: "#/components/responses/BadRequest" }, 401: { - description: - "ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " + - "ทางเข้า ticket: ticket ไม่ถูกต้อง/หมดอายุ หรือไม่ได้ส่งมา (ข้อความเดียวกันทุกสาเหตุ)", + description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY)", content: { "application/json": { schema: { $ref: "#/components/schemas/Error" }, @@ -896,13 +524,13 @@ export const openApiSpec = { Error: { type: "object", properties: { + success: { type: "boolean", example: false }, error: { type: "string" }, }, }, TokenRequest: { type: "object", - description: - "claims ที่จะฝังใน access_token (ทางเข้า x-api-key) — key อื่น = 400", + description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400", required: ["userid", "username", "fid"], properties: { userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" }, @@ -920,27 +548,6 @@ export const openApiSpec = { }, }, }, - TicketTokenRequest: { - type: "object", - description: - "ทางเข้าของ add-in (ไม่ต้องมี x-api-key) — ส่ง ticket ที่ e-Court stamp มากับ " + - "ไฟล์ .docx ใน custom properties ตามเป๊ะ · key อื่น = 400 · server ตรวจ " + - "HMAC-SHA256(ลับ, ticket_data ดิบ) เทียบ HmacSha256 + exp ของ ticket — " + - "ไม่ผ่าน = 401 ข้อความเดียวกันทุกสาเหตุ", - required: ["ticket_data", "HmacSha256"], - properties: { - ticket_data: { - type: "string", - description: - 'JSON string ของ ticket เช่น {"exp":1793067433,"fid":"…","tid":"…","uid":"…","uname":"…"} ' + - "(exp = unix วินาที · tid optional · ส่งตรง ๆ ไม่แก้ string — HMAC ผูกกับค่าดิบ)", - }, - HmacSha256: { - type: "string", - description: "hex 64 ตัว ของ HMAC-SHA256(secret, ticket_data ดิบ)", - }, - }, - }, TokenResponse: { type: "object", description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)", @@ -956,44 +563,14 @@ export const openApiSpec = { ExtractResponse: { type: "object", properties: { + success: { type: "boolean", example: true }, data: { type: "object", description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)", additionalProperties: { type: "string" }, example: { - "cc": { - "blackyear": "๙๐/๒๕๖๙", - "redyear": "xxx", - "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ", - "accuser": "นายธีรพล ร่มเริง", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง...", - }, - "bookmark": { - "signature": "" - }, - "properties": { - "standard": { - "creator": "mam", - "created": "2026-10-22T02:58:00.000Z", - "modified": "2026-10-22T07:08:00.000Z" - }, - "extended": { - "template": "TS_P2_IN.dotx", - "totalTime": 172, - "pages": 2, - "words": 124, - }, - "custom": { - "ticket_data": "{\"exp\":1793013726,\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}", - "HmacSha256": "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9", - "Ed25519": "95ea0ca6d81df885c22a2334ed89b2fc3c75a1a309fb5843e0deb0a16c9d05a45b345fb28fd606f3b152285e70560ad43a6389534794aa0d6f06d13f1307b005" - } - }, - "verify": { - "exp": "26/10/2569 18:22:06", - "HmacSha256": true, - "Ed25519": true - } + blackno: "คดีหมายเลขดำที่", + court: "ศาลปกครองสุพรรณบุรี", }, }, }, diff --git a/middleware.ts b/middleware.ts index 712d46e..a67185a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -37,6 +37,6 @@ export function middleware(req: NextRequest) { // ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ export const config = { matcher: [ - "/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)", + "/((?!note/login|api/note|api/template|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)", ], }; diff --git a/public/tmpl/T12_FD155_3s-long.json b/public/tmpl/T12_FD155_3s-long.json index 294791e..8a27173 100644 --- a/public/tmpl/T12_FD155_3s-long.json +++ b/public/tmpl/T12_FD155_3s-long.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T12_FD155_3s-long\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"T12_FD155_3s-long_ZZZ\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": false, diff --git a/public/tmpl/T12_b2_IN.json b/public/tmpl/T12_b2_IN.json index 4bd98a5..d38a698 100644 --- a/public/tmpl/T12_b2_IN.json +++ b/public/tmpl/T12_b2_IN.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": true, diff --git a/public/tmpl/T12_b2_IN.out.json b/public/tmpl/T12_b2_IN.out.json new file mode 100644 index 0000000..e1b24b9 --- /dev/null +++ b/public/tmpl/T12_b2_IN.out.json @@ -0,0 +1,51 @@ +{ + "cc": { + "blackyear": "๙๐/๒๕๖๙", + "redyear": "", + "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", + "accuser": "นายธีรพล ร่มเริง", + "acc1": "ผู้ฟ้องคดี", + "interpleader": "สมชาย ใจดี", + "acc3": "ผู้ร้องสอด", + "accused": "รร", + "acc2": "ผู้ถูกฟ้องคดี", + "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง", + "pmname1": "ตุลาการศาลปกครองกลาง", + "pmname2": "ตุลาการศาลปกครองสูงสุด" + }, + "bookmark": { + "signature1": "", + "signature2": "" + }, + "properties": { + "standard": { + "title": " ", + "subject": "", + "creator": "oom", + "keywords": "", + "lastModifiedBy": "Sorawit Bholsithi", + "revision": "61", + "created": "2026-10-15T05:18:00.000Z", + "modified": "2026-10-21T04:59:00.000Z" + }, + "extended": { + "template": "Normal.dotm", + "totalTime": 345, + "pages": 1, + "words": 42, + "characters": 493, + "charactersWithSpaces": 534, + "lines": 4, + "paragraphs": 1, + "application": "Microsoft Office Word", + "appVersion": "16.0000", + "company": "", + "docSecurity": 0 + }, + "custom": { + "ticket_data": "{\"exp\":1792667826,\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}", + "HmacSha256": "791987c9274493db02561f4ec16b83d05c89766b54661da615e8e02db293d1d4", + "Ed25519": "7ef8b0f2a03a52c49f8c348e3c392f448b71c8462ee860342b0e06a91b555f860cad5649c648dc466982017683f3718caff139c85e17e952ee1570ecfc021e08" + } + } +} \ No newline at end of file diff --git a/public/tmpl/TF_no_warning_P2.json b/public/tmpl/TF_no_warning_P2.json index 152736c..f2ef337 100755 --- a/public/tmpl/TF_no_warning_P2.json +++ b/public/tmpl/TF_no_warning_P2.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": false, diff --git a/public/tmpl/TS_P2_IN.json b/public/tmpl/TS_P2_IN.json index 479478a..a70c9fe 100755 --- a/public/tmpl/TS_P2_IN.json +++ b/public/tmpl/TS_P2_IN.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"TS_P2_IN_YYY\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": true, diff --git a/public/tmpl/T_X.docx b/public/tmpl/T_X.docx index 06182a4..3ae25d7 100755 Binary files a/public/tmpl/T_X.docx and b/public/tmpl/T_X.docx differ diff --git a/public/tmpl/T_no_warning.json b/public/tmpl/T_no_warning.json index 7697123..59a8b65 100755 --- a/public/tmpl/T_no_warning.json +++ b/public/tmpl/T_no_warning.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"T3_P2_IN_XXX\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": false, @@ -40,7 +40,7 @@ "acc2": "ผู้ถูกฟ้องคดี", "interpleader": "สมชาย ใจดี", "acc3": "ผู้ร้องสอด", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว \nเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ", + "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ", "sendto" : "นายสมศักดิ์ ฟักมี", "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี", diff --git a/public/tmpl/dl.html b/public/tmpl/dl.html deleted file mode 100644 index 6114ce9..0000000 --- a/public/tmpl/dl.html +++ /dev/null @@ -1,28 +0,0 @@ - - - - - - Test Download DOCX a - - -

A a Get

- T_no_warning_john_xxx.docx
- TF_no_warning_P2_john_xxx.docx
- TS_P2_IN_john_xxx.docx
- - T12_b2_IN_john_xxx.docx
- T12_FD155_3s-long_john_xxx.docx
- -

Template & Data

- Template: T_X.docx,   - T12_X.docx
- Data: TF_no_warning_P2.json,   - T_no_warning.json,   - TS_P2_IN.json
- T12_b2_IN.json,   - T12_FD155_3s-long.json - - - - \ No newline at end of file diff --git a/public/tmpl/index.html b/public/tmpl/index.html new file mode 100644 index 0000000..e47e08c --- /dev/null +++ b/public/tmpl/index.html @@ -0,0 +1,20 @@ + + + + + + Document + + +

Template & Data

+ +
    +
  1. T12_X + (T12_b2_IN, T12_FD155_3s-long) +
  2. +
  3. T_X + (TF_no_warning_P2, T_no_warning, TS_P2_IN.json) +
  4. +
+ + \ No newline at end of file diff --git a/public/wordaddin/manifest.template.xml b/public/wordaddin/manifest.template.xml index a51ebb1..efe2e93 100644 --- a/public/wordaddin/manifest.template.xml +++ b/public/wordaddin/manifest.template.xml @@ -11,10 +11,8 @@ ผลต่างจากต้นฉบับ: - taskpane ชี้ที่หน้า Next.js (/docedit/wordaddin) แทน taskpane.html ของ webpack - - ตัด DialogAPI + AppDomain ของ Keycloak (https://localhost:8443) — ยกเลิก auth ด้วย Keycloak - 2026-10-27: ไม่มี login ใน add-in auth เป็น e-Court pass-through (token/owner ใน custom - properties ของไฟล์) requirement set เหลือ WordApi 1.3 + OpenBrowserWindowApi - (ดูรายละเอียดระดับที่ใช้จริงในคอมเมนต์ก่อน ) + - ตัด DialogAPI (login ช่วงนี้เป็น mock ไม่มี Keycloak dialog) — เหลือ WordApi 1.5 + OpenBrowserWindowApi + - ตัด AppDomain ของ Keycloak (https://localhost:8443) - Commands.Url ชี้ taskpane URL เดียวกัน (ไม่มี commands.html — ปุ่มเดียวคือ ShowTaskpane) - คง Id GUID เดิม — เครื่องที่เคย sideload ตัว poc จะถือว่าเป็น add-in ตัวเดิม --> diff --git a/scripts/make-ticket-docx.mjs b/scripts/make-ticket-docx.mjs deleted file mode 100644 index e22f6d0..0000000 --- a/scripts/make-ticket-docx.mjs +++ /dev/null @@ -1,263 +0,0 @@ -#!/usr/bin/env node -// PLAN-ADDIN-AUTH.md §9 — dev tool stamp ticket ของ e-Court (custom properties -// `ticket_data` + `HmacSha256`) ลงไฟล์ .docx เพื่อทดสอบ auth ของ add-in ใน Word จริง -// โดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง -// (app/api/template/render/route.ts: key เรียงตามตัวอักษรหลังเติม exp → -// HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase) -// -// ใช้: node scripts/make-ticket-docx.mjs [options] -// --fid default DEMO_FID_001 -// --uid default oom -// --uname default "สรวิชญ์" -// --tid template id (optional — ไม่ส่ง = ไม่มี tid ใน ticket) -// --exp default +300 (5 นาที เท่า TICKET_SEC ของตัว stamp) -// --secret default "super_secret" (ต้องตรง env ฝั่ง server/add-in) -// --tracked [id] stamp SavedByAddIn + DocumentTrackingId ด้วย (ทดสอบ phase tracking) -// --in-place เขียนทับไฟล์เดิม (ปกติเขียน <ชื่อเดิม>.ticket.docx) -// --list แค่อ่าน custom properties ที่มีในไฟล์แล้วออกมาดู -// -// รันใน repo เท่านั้น (import pizzip จาก node_modules) — ไม่ใช่ postinstall - -import { createHmac, randomUUID } from "node:crypto"; -import { readFileSync, writeFileSync } from "node:fs"; -import path from "node:path"; -import PizZip from "pizzip"; - -const CUSTOM_PATH = "docProps/custom.xml"; -const CT_PATH = "[Content_Types].xml"; -const RELS_PATH = "_rels/.rels"; -const CUSTOM_FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; -/** ต้องตรง metadataService.ADDIN_SIGNATURE_GUID ฝั่ง add-in */ -const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D"; - -function usage(code = 1) { - console.log(`ใช้: node scripts/make-ticket-docx.mjs [options] - --fid --uid --uname --tid - --exp --secret - --tracked [trackingId] --in-place --list`); - process.exit(code); -} - -function parseArgs(argv) { - const options = { - file: null, - fid: "DEMO_FID_001", - uid: "oom", - uname: "สรวิชญ์", - tid: null, - exp: "+300", - secret: "super_secret", - tracked: false, - trackingId: null, - inPlace: false, - list: false, - }; - for (let i = 0; i < argv.length; i++) { - const arg = argv[i]; - const next = () => { - const value = argv[++i]; - if (value === undefined) usage(); - return value; - }; - switch (arg) { - case "--fid": options.fid = next(); break; - case "--uid": options.uid = next(); break; - case "--uname": options.uname = next(); break; - case "--tid": options.tid = next(); break; - case "--exp": options.exp = next(); break; - case "--secret": options.secret = next(); break; - case "--tracked": - options.tracked = true; - if (argv[i + 1] && !argv[i + 1].startsWith("--")) options.trackingId = next(); - break; - case "--in-place": options.inPlace = true; break; - case "--list": options.list = true; break; - case "--help": case "-h": usage(0); break; - default: - if (arg.startsWith("--")) usage(); - if (options.file !== null) usage(); - options.file = arg; - } - } - if (options.file === null) usage(); - return options; -} - -function resolveExp(raw) { - if (raw.startsWith("+")) { - const seconds = Number(raw.slice(1)); - if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--exp +<วินาที> ไม่ถูกต้อง"); - return Math.floor(Date.now() / 1000) + Math.floor(seconds); - } - const value = Number(raw); - if (!Number.isInteger(value) || value <= 0) throw new Error("--exp ต้องเป็น unix วินาที หรือ +<วินาที>"); - return value; -} - -function escapeXml(value) { - return String(value) - .replace(/&/g, "&") - .replace(//g, ">") - .replace(/"/g, """) - .replace(/'/g, "'"); -} - -/** ticket_data ตามรูปตัว stamp จริง — key เรียงตามตัวอักษรหลังเติม exp */ -function buildTicketData(options) { - const ticket = { exp: resolveExp(options.exp), fid: options.fid, uid: options.uid, uname: options.uname }; - if (options.tid !== null) ticket.tid = options.tid; - const sorted = Object.keys(ticket) - .sort() - .reduce((result, key) => { - result[key] = ticket[key]; - return result; - }, {}); - return JSON.stringify(sorted); -} - -function unescapeXml(value) { - return String(value) - .replace(/</g, "<") - .replace(/>/g, ">") - .replace(/"/g, '"') - .replace(/'/g, "'") - .replace(/&/g, "&"); -} - -/** ถอด ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */ -function readProperties(xml) { - const properties = []; - const regex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|]*name="([^"]*)"[^>]*\/>/g; - let match; - while ((match = regex.exec(xml)) !== null) { - const name = match[1] ?? match[3]; - const inner = match[2] ?? ""; - const value = inner.replace(/<[^>]+>/g, ""); - properties.push({ name: unescapeXml(name), value: unescapeXml(value.trim()) }); - } - return properties; -} - -function usedPids(xml) { - const pids = new Set([1]); - const regex = /pid="(\d+)"/g; - let match; - while ((match = regex.exec(xml)) !== null) pids.add(Number(match[1])); - return pids; -} - -function propertyXml(name, pid, value) { - return ` ${escapeXml(value)}\n`; -} - -/** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */ -function upsertProperties(existingXml, entries) { - // เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง) - let xml = existingXml; - for (const entry of entries) { - const removal = new RegExp( - `\\s*]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)`, - "g", - ); - xml = xml.replace(removal, ""); - } - const pids = usedPids(xml); - let nextPid = 2; - const takePid = () => { - while (pids.has(nextPid)) nextPid++; - pids.add(nextPid); - return nextPid++; - }; - const block = entries.map((entry) => propertyXml(entry.name, takePid(), entry.value)).join(""); - const hasRoot = xml.includes("
"); - if (hasRoot) { - xml = xml.replace(/<\/Properties>/, `${block}`); - } else { - xml = - `\n` + - `\n${block}`; - } - return xml; -} - -/** ผูก docProps/custom.xml เข้ากับแพ็กเกจ — Word ไม่เห็น part ที่ไม่มี relationship/content-type */ -function ensurePartRegistered(zip) { - const ct = zip.file(CT_PATH); - if (ct && !ct.asText().includes('PartName="/docProps/custom.xml"')) { - const updated = ct - .asText() - .replace( - /<\/Types>/, - ` \n`, - ); - zip.file(CT_PATH, updated); - } - const rels = zip.file(RELS_PATH); - if (rels && !rels.asText().includes('Target="docProps/custom.xml"')) { - const match = rels.asText().match(/Id="rId(\d+)"/g); - const maxId = match ? Math.max(...match.map((item) => Number(item.match(/\d+/)[0]))) : 0; - const updated = rels - .asText() - .replace( - /<\/Relationships>/, - ` \n`, - ); - zip.file(RELS_PATH, updated); - } -} - -// --------------------------------------------------------------------------- - -const options = parseArgs(process.argv.slice(2)); -const source = readFileSync(options.file); - -if (source.subarray(0, 2).toString("latin1") !== "PK") { - console.error(`✗ ${options.file} ไม่ใช่ไฟล์ .docx (zip)`); - process.exit(1); -} - -const zip = new PizZip(source); - -if (options.list) { - const custom = zip.file(CUSTOM_PATH); - if (!custom) { - console.log(`(ไม่มี ${CUSTOM_PATH} ในไฟล์นี้)`); - } else { - for (const property of readProperties(custom.asText())) { - console.log(`${property.name} = ${property.value}`); - } - } - process.exit(0); -} - -const ticketData = buildTicketData(options); -const hmac = createHmac("sha256", options.secret).update(ticketData, "utf8").digest("hex"); -if (ticketData.length > 255) { - console.warn(`⚠ ticket_data ยาว ${ticketData.length} ตัวอักษร — เกินขีดจำกัด ~255 ของ Word custom property อาจถูกตัด`); -} - -const entries = [ - { name: "ticket_data", value: ticketData }, - { name: "HmacSha256", value: hmac }, -]; -if (options.tracked) { - entries.push({ name: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID }); - entries.push({ name: "DocumentTrackingId", value: options.trackingId ?? randomUUID() }); -} - -const existing = zip.file(CUSTOM_PATH); -zip.file(CUSTOM_PATH, upsertProperties(existing ? existing.asText() : "", entries)); -ensurePartRegistered(zip); - -const output = zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); -const target = options.inPlace - ? options.file - : `${options.file.replace(/\.docx$/i, "")}.ticket.docx`; -writeFileSync(target, output); - -console.log(`✓ stamp ticket ลง ${path.relative(process.cwd(), target)}${options.inPlace ? " (ทับไฟล์เดิม)" : ""}`); -console.log(` ticket_data : ${ticketData}`); -console.log(` HmacSha256 : ${hmac}`); -console.log(` secret : ${options.secret}`); -console.log(` exp : ${new Date(resolveExp(options.exp) * 1000).toLocaleString("th-TH")}`); diff --git a/tests/lib/addin-ticket.test.ts b/tests/lib/addin-ticket.test.ts deleted file mode 100644 index 5c75931..0000000 --- a/tests/lib/addin-ticket.test.ts +++ /dev/null @@ -1,148 +0,0 @@ -import { createHmac } from "node:crypto"; -import { describe, expect, it } from "vitest"; - -import { - addinTicketSecret, - parseTicketTokenBody, - ticketToDocverClaims, - verifyAddinTicket, -} from "@/lib/addin-ticket"; - -// PLAN-ADDIN-AUTH.md §3 — pure ล้วน (verifyAddinTicket รับ secret ทาง parameter) -// ไม่แตะ @/db/minio — รันได้โดยไม่ต้องตั้ง env อะไร (เหมือน docver-token.test.ts) - -const SECRET = "super_secret"; - -/** ตัว ticket ตัวอย่าง — key เรียงตามตัวอักษรเหมือนตัว stamp จริง (sortObjectKeys ของ lib-utils) */ -const TICKET_DATA = JSON.stringify({ - exp: Math.floor(Date.now() / 1000) + 300, - fid: "T12_FD155_3s-long_ZZZ", - tid: "T12", - uid: "oom", - uname: "สรวิชญ์", -}); - -function sign(message: string, secret: string = SECRET): string { - return createHmac("sha256", secret).update(message, "utf8").digest("hex"); -} - -function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T { - expect(result.ok).toBe(true); -} - -describe("verifyAddinTicket", () => { - it("ticket ถูกต้อง + HMAC ตรง = ผ่าน ได้ ticket แกะแล้ว", () => { - const result = verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA), SECRET); - ok(result); - expect(result.ticket.fid).toBe("T12_FD155_3s-long_ZZZ"); - expect(result.ticket.uid).toBe("oom"); - expect(result.ticket.tid).toBe("T12"); - }); - - it("HMAC ผิด (แก้ ticket 1 ตัวอักษร / secret ผิด / digest เพี้ยน) = ไม่ผ่านทุกกรณี", () => { - const digest = sign(TICKET_DATA); - const tampered = `${TICKET_DATA.slice(0, -2)}xx}`; - expect(verifyAddinTicket(tampered, digest, SECRET).ok).toBe(false); - expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA, "wrong-secret"), SECRET).ok).toBe(false); - const flipped = `${digest.slice(0, -1)}${digest.endsWith("0") ? "1" : "0"}`; - expect(verifyAddinTicket(TICKET_DATA, flipped, SECRET).ok).toBe(false); - }); - - it("digest รูปไม่ใช่ (ไม่ใช่ hex / สั้น / ว่าง) = ไม่ผ่าน · uppercase hex normalize แล้วผ่านได้", () => { - expect(verifyAddinTicket(TICKET_DATA, "zz".repeat(32), SECRET).ok).toBe(false); - expect(verifyAddinTicket(TICKET_DATA, "abcd", SECRET).ok).toBe(false); - expect(verifyAddinTicket(TICKET_DATA, "", SECRET).ok).toBe(false); - expect(verifyAddinTicket(TICKET_DATA, sign(TICKET_DATA).toUpperCase(), SECRET).ok).toBe(true); - }); - - it("ticket หมดอายุ (exp อดีต / เท่ากับ now) = ไม่ผ่าน แม้ HMAC ถูก", () => { - const now = Math.floor(Date.now() / 1000); - for (const exp of [now - 1, now]) { - const expired = JSON.stringify({ exp, fid: "f", uid: "u", uname: "n" }); - expect(verifyAddinTicket(expired, sign(expired), SECRET, now).ok, `exp=${exp}`).toBe(false); - } - const alive = JSON.stringify({ exp: now + 1, fid: "f", uid: "u", uname: "n" }); - expect(verifyAddinTicket(alive, sign(alive), SECRET, now).ok).toBe(true); - }); - - it("HMAC ถูกแต่ ticket ผิดกติกา (field หลุด allowlist / exp ไม่ใช่ integer) = ไม่ผ่าน", () => { - const extra = JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 60, fid: "f", uid: "u", uname: "n", oops: 1 }); - expect(verifyAddinTicket(extra, sign(extra), SECRET).ok).toBe(false); - const badExp = JSON.stringify({ exp: "123", fid: "f", uid: "u", uname: "n" }); - expect(verifyAddinTicket(badExp, sign(badExp), SECRET).ok).toBe(false); - }); -}); - -describe("ticketToDocverClaims", () => { - it("แปลง uid→userid, uname→username, tid→template · tid ไม่มี = null", () => { - expect( - ticketToDocverClaims({ exp: 1, fid: "F1", tid: "T12", uid: "oom", uname: "สรวิชญ์" }), - ).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: "T12" }); - expect( - ticketToDocverClaims({ exp: 1, fid: "F1", tid: null, uid: "oom", uname: "สรวิชญ์" }), - ).toEqual({ userid: "oom", username: "สรวิชญ์", fid: "F1", template: null }); - }); -}); - -describe("parseTicketTokenBody", () => { - it("ครบทั้งคู่ = ได้ค่าตรง ๆ (ไม่แตะค่า — HMAC ต้องคำนวณบน string ดิบ)", () => { - const digest = sign(TICKET_DATA); - const result = parseTicketTokenBody({ ticket_data: TICKET_DATA, HmacSha256: digest }); - ok(result); - expect(result.ticketData).toBe(TICKET_DATA); - expect(result.hmacHex).toBe(digest); - }); - - it("ไม่ใช่ object / มี field อื่นปน = invalid (→ 400)", () => { - for (const raw of [[], "text", null, { ticket_data: "x", HmacSha256: "y", extra: 1 }]) { - const result = parseTicketTokenBody(raw); - expect(result.ok, JSON.stringify(raw)).toBe(false); - if (!result.ok) expect(result.kind).toBe("invalid"); - } - }); - - it("ขาด field (ไม่ส่ง/ว่าง/null) = missing (→ 401 กัน oracle)", () => { - for (const raw of [{}, { ticket_data: "" }, { HmacSha256: "y" }, { ticket_data: null, HmacSha256: "y" }]) { - const result = parseTicketTokenBody(raw); - expect(result.ok, JSON.stringify(raw)).toBe(false); - if (!result.ok) expect(result.kind).toBe("missing"); - } - }); - - it("field ไม่ใช่ string / ยาวเกิน cap = invalid", () => { - for (const raw of [ - { ticket_data: 123, HmacSha256: "y" }, - { ticket_data: "x", HmacSha256: "y".repeat(129) }, - { ticket_data: "x".repeat(2049), HmacSha256: "y" }, - ]) { - const result = parseTicketTokenBody(raw); - expect(result.ok, JSON.stringify(raw)).toBe(false); - if (!result.ok) expect(result.kind).toBe("invalid"); - } - }); -}); - -describe("addinTicketSecret", () => { - // env save/restore — addinTicketSecret อ่านตอนเรียก - const ORIG_ADDIN = process.env.ADDIN_TICKET_SECRET; - const ORIG_SECRET = process.env.SECRET; - - it("ลำดับ ADDIN_TICKET_SECRET → SECRET → default \"super_secret\" (trim ทุกตัว)", () => { - delete process.env.ADDIN_TICKET_SECRET; - delete process.env.SECRET; - expect(addinTicketSecret()).toBe("super_secret"); - - process.env.SECRET = " tmpl-secret "; - expect(addinTicketSecret()).toBe("tmpl-secret"); - - process.env.ADDIN_TICKET_SECRET = " addin-secret "; - expect(addinTicketSecret()).toBe("addin-secret"); - - delete process.env.ADDIN_TICKET_SECRET; - process.env.SECRET = " "; // ว่างล้วน = ข้ามไปตัวถัดไป - expect(addinTicketSecret()).toBe("super_secret"); - - process.env.ADDIN_TICKET_SECRET = ORIG_ADDIN; - process.env.SECRET = ORIG_SECRET; - }); -}); diff --git a/tests/lib/document-versioning.test.ts b/tests/lib/document-versioning.test.ts index a8d13a4..da18b93 100644 --- a/tests/lib/document-versioning.test.ts +++ b/tests/lib/document-versioning.test.ts @@ -153,25 +153,6 @@ describe("parseVersionUploadForm", () => { form.append("fields", '{"a":1}'); expect(parseVersionUploadForm(form).ok).toBe(false); }); - - it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => { - const big = baseForm(); - big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) })); - expect(parseVersionUploadForm(big).ok).toBe(true); - - const manyKeys = baseForm(); - manyKeys.append( - "fields", - JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))), - ); - expect(parseVersionUploadForm(manyKeys).ok).toBe(true); - const over = baseForm(); - over.append( - "fields", - JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))), - ); - expect(parseVersionUploadForm(over).ok).toBe(false); - }); }); describe("sanitizeDisplayFileName / sanitizeFilePartName", () => { diff --git a/tests/utils/docver-ticket.test.ts b/tests/utils/docver-ticket.test.ts deleted file mode 100644 index bdde305..0000000 --- a/tests/utils/docver-ticket.test.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { describe, expect, it } from "vitest"; - -import { - isTicketExpired, - normalizeHexDigest, - parseDocverTicket, - timingSafeEqualHex, -} from "@/utils/docver-ticket"; - -// PLAN-ADDIN-AUTH.md §4 — pure ล้วนไม่แตะ env/DB อะไรเลย -// รูป ticket_data อ้างอิงจากตัว stamp จริงของกลุ่ม template (app/api/template/ -// render/route.ts applyTemplate — key เรียงตามตัวอักษรหลังเติม exp) - -const VALID = JSON.stringify({ - exp: 1793067433, - fid: "T12_FD155_3s-long_ZZZ", - tid: "T12", - uid: "oom", - uname: "สรวิชญ์", -}); - -function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T { - expect(result.ok).toBe(true); -} - -describe("parseDocverTicket", () => { - it("ticket ครบทุก field = แกะได้ (trim ค่า string)", () => { - const result = parseDocverTicket(VALID); - ok(result); - expect(result.ticket).toEqual({ - exp: 1793067433, - fid: "T12_FD155_3s-long_ZZZ", - tid: "T12", - uid: "oom", - uname: "สรวิชญ์", - }); - }); - - it("tid optional — ไม่ส่ง/ส่งค่าว่าง = null", () => { - const noTid = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" })); - ok(noTid); - expect(noTid.ticket.tid).toBeNull(); - const blankTid = parseDocverTicket( - JSON.stringify({ exp: 1, fid: "f", tid: " ", uid: "u", uname: "n" }), - ); - ok(blankTid); - expect(blankTid.ticket.tid).toBeNull(); - }); - - it("JSON ไม่ผ่าน / ไม่ใช่ object (array/string/null) = ไม่ผ่าน", () => { - for (const raw of ["not json", "[1,2]", '"text"', "null", "{}"]) { - expect(parseDocverTicket(raw).ok, raw).toBe(false); - } - }); - - it("field อื่นนอก allowlist = ไม่ผ่าน (กัน typo/field หลุดจาก spec)", () => { - const result = parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", oops: 1 })); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("oops"); - }); - - it("exp ไม่ใช่จำนวนเต็ม / หาย / uid-uname-fid หายหรือไม่ใช่ string = ไม่ผ่าน", () => { - for (const ticket of [ - { fid: "f", uid: "u", uname: "n" }, // ไม่มี exp - { exp: "1793067433", fid: "f", uid: "u", uname: "n" }, // exp เป็น string - { exp: 1.5, fid: "f", uid: "u", uname: "n" }, // exp ไม่ใช่ integer - { exp: 1, uid: "u", uname: "n" }, // ไม่มี fid - { exp: 1, fid: "f", uname: "n" }, // ไม่มี uid - { exp: 1, fid: "f", uid: "u" }, // ไม่มี uname - { exp: 1, fid: "", uid: "u", uname: "n" }, // fid ว่าง - { exp: 1, fid: 3, uid: "u", uname: "n" }, // fid เป็น number - { exp: 1, fid: "f", uid: "u", uname: "n", tid: 12 }, // tid ไม่ใช่ string - ]) { - expect(parseDocverTicket(JSON.stringify(ticket)).ok, JSON.stringify(ticket)).toBe(false); - } - }); - - it("ความยาวเกิน cap: uid > 100 / uname > 200 / fid > 200 / tid > 100 = ไม่ผ่าน", () => { - expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u".repeat(101), uname: "n" })).ok).toBe(false); - expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n".repeat(201) })).ok).toBe(false); - expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false); - expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false); - }); -}); - -describe("isTicketExpired", () => { - it("exp อนาคต = ยังไม่หมด · exp ตรง now พอดี/อดีต = หมดแล้ว", () => { - const now = 1_793_000_000; - expect(isTicketExpired(now + 1, now)).toBe(false); - expect(isTicketExpired(now, now)).toBe(true); - expect(isTicketExpired(now - 1, now)).toBe(true); - }); -}); - -describe("normalizeHexDigest", () => { - const HEX64 = "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9"; - - it("hex lowercase ครบ 64 = ผ่าน · trim และ uppercase ก็ normalize ให้", () => { - expect(normalizeHexDigest(HEX64)).toBe(HEX64); - expect(normalizeHexDigest(` ${HEX64} `)).toBe(HEX64); - expect(normalizeHexDigest(HEX64.toUpperCase())).toBe(HEX64); - }); - - it("รูปไม่ใช่ (สั้น/ยาว/ไม่ใช่ hex/ว่าง) = null", () => { - for (const bad of ["", "abc", `${HEX64}00`, `g${HEX64.slice(1)}`, " "]) { - expect(normalizeHexDigest(bad), bad).toBeNull(); - } - }); -}); - -describe("timingSafeEqualHex", () => { - it("ค่าเท่ากัน = true ต่างกัน = false · ความยาวไม่เท่า = false ทันที", () => { - const a = "ab".repeat(32); - expect(timingSafeEqualHex(a, a)).toBe(true); - expect(timingSafeEqualHex(a, "cd".repeat(32))).toBe(false); - expect(timingSafeEqualHex(a, "ab")).toBe(false); - }); -}); diff --git a/tests/wordaddin/ticket-auth-flow.test.ts b/tests/wordaddin/ticket-auth-flow.test.ts deleted file mode 100644 index 250a5fa..0000000 --- a/tests/wordaddin/ticket-auth-flow.test.ts +++ /dev/null @@ -1,116 +0,0 @@ -// @vitest-environment jsdom -// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5) — คุมพฤติกรรม service layer: -// happy-path ขอ token ครั้งเดียวต่อ fid, เปิดเอกสารเดิมซ้ำใช้ token เดิมไม่ยิง /token ซ้ำ, -// memory fallback ของ session, และทุก fail path ต้องคืน result ไม่ใช่ throw -// (jsdom ไม่มี crypto.subtle — ฉีด webcrypto ของ node แทน secure context ของ webview จริง) -import { createHmac, webcrypto } from "node:crypto"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import * as authService from "@/wordaddin/services/authService"; -import * as ticketService from "@/wordaddin/services/ticketService"; - -// จับ error ทุกทาง (window error / unhandled rejection) — flow ต้องไม่ปล่อยอะไรหลุดเลย -const errors: unknown[] = []; -function trapErrors() { - window.addEventListener("error", (event) => errors.push(event.error ?? event.message)); - window.addEventListener("unhandledrejection", (event) => errors.push(event.reason)); -} - -function makeTicket(): { ticketData: string; ticketHmac: string } { - const ticket = { - exp: Math.floor(Date.now() / 1000) + 300, - fid: "DBG_FID_001", - tid: "T12", - uid: "oom", - uname: "สรวิชญ์", - }; - const ticketData = JSON.stringify(ticket); - const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex"); - return { ticketData, ticketHmac }; -} - -afterEach(() => { - window.localStorage.clear(); - // memory fallback ของ session เป็น module state — เคลียร์ด้วยไม่งั้นค้างข้ามเคส - ticketService.clearSession(); - authService.signInForDemo(); // รีเซ็ต state ของ authService (username/fid/ticket) - vi.restoreAllMocks(); - vi.unstubAllGlobals(); - errors.length = 0; -}); - -describe("signInWithTicket (service layer)", () => { - it("B: signInWithTicket happy-path (มี crypto.subtle + fetch ตอบ 200) ไม่ต้อง throw", async () => { - trapErrors(); - // jsdom ไม่มี crypto.subtle — ใส่ webcrypto ของ node แทน (secure context ของ webview จริงมีอยู่แล้ว) - vi.stubGlobal("crypto", webcrypto); - - const { ticketData, ticketHmac } = makeTicket(); - const fetchMock = vi.fn( - async () => - new Response( - JSON.stringify({ - access_token: [ - "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9", - Buffer.from( - JSON.stringify({ userid: "oom", fid: "DBG_FID_001", exp: Math.floor(Date.now() / 1000) + 3600 }), - ).toString("base64url"), - "sig", - ].join("."), - token_type: "Bearer", - expires_in: 3600, - }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - vi.stubGlobal("fetch", fetchMock); - - const first = await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(first.ok, JSON.stringify(first)).toBe(true); - - // เปิดเอกสารเดิมซ้ำ — ต้องไม่ยิง /token ซ้ำ (ใช้ token เดิมจาก localStorage) - await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(fetchMock).toHaveBeenCalledTimes(1); - - expect(errors).toEqual([]); - expect(window.localStorage.getItem("wordaddin-auth")).toBeTruthy(); - }); - - it("C: signInWithTicket แบบไม่มี crypto.subtle (webview เก่า) ต้องจบเป็น no-access ไม่ใช่ throw", async () => { - trapErrors(); - const noSubtle = { getRandomValues: () => new Uint8Array(0) }; - vi.stubGlobal("crypto", noSubtle); // ไม่มี .subtle เลย - const { ticketData, ticketHmac } = makeTicket(); - const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(result.ok).toBe(false); - expect(errors).toEqual([]); - }); - - it("D: ticket ผิดทุกแบบ (ขาด/hex เพี้ยน/parse ไม่ผ่าน/หมดอายุ/fetch 401) ต้องคืน result ไม่ใช่ throw", async () => { - trapErrors(); - vi.stubGlobal("crypto", webcrypto); - vi.stubGlobal( - "fetch", - vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })), - ); - const { ticketData, ticketHmac } = makeTicket(); - const bad = [ - { ticketData: undefined, ticketHmac: undefined }, - { ticketData: "not-json", ticketHmac }, - { ticketData, ticketHmac: "zz".repeat(32) }, - { - ticketData: JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), // หมดอายุ + HMAC ถูก - ticketHmac: createHmac("sha256", "super_secret") - .update(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }), "utf8") - .digest("hex"), - }, - { ticketData, ticketHmac }, // ถูก แต่ server 401 → token-request-failed - ] satisfies { ticketData?: string; ticketHmac?: string }[]; - for (const input of bad) { - const result = await authService.signInWithTicket(input); - expect(result.ok, JSON.stringify(input)).toBe(false); - if (!result.ok) expect(typeof result.message).toBe("string"); - } - expect(errors).toEqual([]); - }); -}); diff --git a/tests/wordaddin/versioningService.test.ts b/tests/wordaddin/versioningService.test.ts deleted file mode 100644 index 4de9046..0000000 --- a/tests/wordaddin/versioningService.test.ts +++ /dev/null @@ -1,209 +0,0 @@ -// @vitest-environment jsdom -// versioningService หลังปลด mock (2026-10-27) — คุมทั้งสองโหมด: -// - ของจริง: sign-in ผ่าน signInWithTicket ให้ได้ Bearer ก่อน แล้วตรวจ FormData ของ -// uploadVersion ตรง contract POST /api/documents/versions (ห้ามส่ง userId/userName/ -// templateId — identity มาจาก Bearer หมด), getVersions แปลง DTO, getDownloadLink ใส่ ?format= -// - โหมด ?addindemo: ใช้ mock ในเครื่อง (ไม่ยิง network เลย) -import { createHmac, webcrypto } from "node:crypto"; -import { afterEach, describe, expect, it, vi } from "vitest"; - -import * as authService from "@/wordaddin/services/authService"; -import * as ticketService from "@/wordaddin/services/ticketService"; -import { - getDownloadLink, - getVersions, - uploadVersion, -} from "@/wordaddin/services/versioningService"; - -const TOKEN = "header.payload.sig"; // ค่าหยง ๆ ก็ได้ — apiClient แนบตรง ๆ ไม่ decode - -/** sign-in จริงผ่าน signInWithTicket (fetch ตอบ token 1 ครั้ง) — real mode + มี Bearer ใน session */ -async function signInReal(): Promise { - vi.stubGlobal("crypto", webcrypto); // jsdom ไม่มี crypto.subtle - const ticket = { - exp: Math.floor(Date.now() / 1000) + 300, - fid: "REAL_FID_001", - uid: "oom", - uname: "สรวิชญ์", - }; - const ticketData = JSON.stringify(ticket); - const ticketHmac = createHmac("sha256", "super_secret").update(ticketData, "utf8").digest("hex"); - vi.stubGlobal( - "fetch", - vi.fn( - async () => - new Response( - JSON.stringify({ access_token: TOKEN, token_type: "Bearer", expires_in: 3600 }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ), - ); - const result = await authService.signInWithTicket({ ticketData, ticketHmac }); - expect(result.ok, JSON.stringify(result)).toBe(true); - vi.unstubAllGlobals(); -} - -describe("versioningService (real mode)", () => { - it("uploadVersion สร้าง multipart ตรง contract + แนบ Bearer ผ่าน apiClient + parse ผล", async () => { - await signInReal(); - const fetchMock = vi.fn( - async (_url: string, _init?: RequestInit) => - new Response( - JSON.stringify({ hasPdf: true, versionId: "v-1", versionNumber: 3 }), - { status: 200, headers: { "content-type": "application/json" } }, - ), - ); - vi.stubGlobal("fetch", fetchMock); - - const docx = new Blob(["PK\x03\x04 docx"], { - type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - }); - const pdf = new Blob(["%PDF-"], { type: "application/pdf" }); - const result = await uploadVersion( - "doc-guid-ไม่ส่งถึง-server", - docx, - "client-vid-1", - { blackno: "ด1" }, - "คำฟ้อง.docx", - " แก้ไขย่อหน้า 3 ", - pdf, - "T12-ไม่ส่งถึง-server", - ); - - expect(result).toEqual({ hasPdf: true, versionId: "v-1", versionNumber: 3 }); - expect(fetchMock).toHaveBeenCalledTimes(1); - const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; - // same-origin ผ่าน withBasePath (ใน vitest env NEXT_PUBLIC_BASE_PATH ว่าง — Next inline ตอน build - // เท่านั้น จึง assert เฉพาะ path ที่อยู่หลัง basePath) - expect(url.endsWith("/api/documents/versions")).toBe(true); - expect(init.method).toBe("POST"); - expect((init.headers as Headers).get("authorization")).toBe(`Bearer ${TOKEN}`); - const form = init.body as FormData; - expect(form.get("file")).toBeInstanceOf(File); - expect((form.get("file") as File).name).toBe("คำฟ้อง.docx"); - expect((form.get("pdf") as File).name).toBe("document.pdf"); - expect(form.get("clientVersionId")).toBe("client-vid-1"); - expect(JSON.parse(form.get("fields") as string)).toEqual({ blackno: "ด1" }); - expect(form.get("message")).toBe("แก้ไขย่อหน้า 3"); // trim แล้ว - // contract 2026-10-22: ห้ามมี field identity/template เหลืออยู่เลย - expect(form.has("userId")).toBe(false); - expect(form.has("userName")).toBe(false); - expect(form.has("templateId")).toBe(false); - }); - - it("pdf ว่าง/ไม่แนบ = ไม่ append field pdf (ไฟล์เปล่า = version ไม่มี PDF)", async () => { - await signInReal(); - const fetchMock = vi.fn( - async (_url: string, _init?: RequestInit) => - new Response(JSON.stringify({ hasPdf: false, versionId: "v-2", versionNumber: 1 }), { status: 200 }), - ); - vi.stubGlobal("fetch", fetchMock); - await uploadVersion("d", new Blob(["PK"]), "cv", undefined, undefined, undefined, new Blob([])); - const form = (fetchMock.mock.calls[0] as unknown[])[1] as RequestInit; - expect((form.body as FormData).has("pdf")).toBe(false); - }); - - it("getVersions แปลง DocumentVersionDTO → VersionRecord ครบทุก field", async () => { - await signInReal(); - const fetchMock = vi.fn( - async (_url: string, _init?: RequestInit) => - new Response( - JSON.stringify([ - { - versionId: "v-9", - versionNumber: 9, - createdAt: "2026-10-27T05:00:00Z", - createdByUserId: "oom", - createdByUserName: "สรวิชญ์", - contentHash: "ab", - fields: { blackno: "ด1" }, - message: null, - hasPdf: true, - }, - ]), - { status: 200 }, - ), - ); - vi.stubGlobal("fetch", fetchMock); - const rows = await getVersions("anything"); - expect(rows).toEqual([ - { - versionId: "v-9", - versionNumber: 9, - createdAt: "2026-10-27T05:00:00Z", - createdByUserId: "oom", - createdByUserName: "สรวิชญ์", - contentHash: "ab", - fields: { blackno: "ด1" }, - message: null, - hasPdf: true, - }, - ]); - }); - - it("getDownloadLink ใส่ ?format=pdf · format docx/ไม่ส่ง = ไม่มี query", async () => { - await signInReal(); - const fetchMock = vi.fn( - async (_url: string, _init?: RequestInit) => - new Response(JSON.stringify({ url: "http://minio/x", expiresAt: "2026-10-27T06:00:00Z" }), { status: 200 }), - ); - vi.stubGlobal("fetch", fetchMock); - await getDownloadLink("d", "v-9", "pdf"); - expect(String(fetchMock.mock.calls[0][0]).endsWith("/api/documents/versions/v-9/download-link?format=pdf")).toBe(true); - await getDownloadLink("d", "v-9"); - expect(String(fetchMock.mock.calls[1][0]).endsWith("/api/documents/versions/v-9/download-link")).toBe(true); - await getDownloadLink("d", "v-9", "docx"); - expect(String(fetchMock.mock.calls[2][0]).endsWith("/api/documents/versions/v-9/download-link")).toBe(true); - }); - - it("โดน 401 แล้ว renew ไม่สำเร็จ = AuthRequiredError (ไม่ใช่ error อื่น)", async () => { - await signInReal(); - // fetch ตอบ 401 ทุกอัน (ทั้ง request หลักและการ renew token จาก ticket) - vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 401 }))); - await expect(getVersions("d")).rejects.toMatchObject({ name: "AuthRequiredError" }); - }); - - it("404 จาก history (เอกสารยังไม่เคยมี version เช่น stamp ค้างสมัย mock) = list ว่าง ไม่ใช่ error", async () => { - await signInReal(); - vi.stubGlobal( - "fetch", - vi.fn( - async (_url: string, _init?: RequestInit) => - new Response(JSON.stringify({ error: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)" }), { status: 404 }), - ), - ); - await expect(getVersions("d")).resolves.toEqual([]); - }); -}); - -// โหมด demo ไว้ท้ายสุด — signInForDemo เปิด flag ค้างใน module จนกว่าจะ sign-in จริง -describe("versioningService (demo mode = mock)", () => { - it("signInForDemo แล้วใช้ mock ในเครื่อง — ไม่ยิง network และ persist ตาม documentId", async () => { - authService.signInForDemo(); - const fetchMock = vi.fn(); - vi.stubGlobal("fetch", fetchMock); - - const first = await uploadVersion("demo-doc", new Blob(["PK"]), "cv1", undefined, undefined, "vแรก"); - const second = await uploadVersion("demo-doc", new Blob(["PK2"]), "cv2"); - expect(first.versionNumber).toBe(1); - expect(second.versionNumber).toBe(2); - - const rows = await getVersions("demo-doc"); - expect(rows.map((row) => row.versionNumber)).toEqual([2, 1]); // ใหม่ → เก่า - expect(rows[0].message).toBeNull(); - expect(rows[1].message).toBe("vแรก"); - - const link = await getDownloadLink("demo-doc", rows[0].versionId, "pdf"); - expect(link.url).toBe("about:blank"); - expect(fetchMock).not.toHaveBeenCalled(); - expect(window.localStorage.getItem("wordaddin-mock-versions")).toBeTruthy(); - }); -}); - -afterEach(() => { - window.localStorage.clear(); - ticketService.clearSession(); - authService.signInForDemo(); // รีเซ็ต authService ให้มี username เสมอ (เคสถัดไป sign-in ใหม่เอง) - vi.restoreAllMocks(); - vi.unstubAllGlobals(); -}); diff --git a/utils/docver-ticket.ts b/utils/docver-ticket.ts deleted file mode 100644 index 14130c6..0000000 --- a/utils/docver-ticket.ts +++ /dev/null @@ -1,136 +0,0 @@ -// PLAN-ADDIN-AUTH.md §4 — ticket ของ add-in (custom properties `ticket_data` + -// `HmacSha256` ที่ e-Court ผ่านกลุ่ม template stamp ลงไฟล์) — ส่วน pure ที่ -// ใช้ร่วมกันทั้งฝั่ง server (lib/addin-ticket.ts — route /api/documents/token) -// และฝั่ง client ของ add-in (wordaddin/services/ticketService.ts) -// -// กติกา (docs/requirement/addin-workflow.md + lib/lib-utils.ts generateHmacSha256 -// ของตัว stamp ฝั่ง template): -// - ticket_data เป็น JSON string ตามที่ stamp ไว้เป๊ะ (key เรียงตามตัวอักษร -// หลังเติม exp) — **HMAC ต้องคำนวณบน string ดิบนี้ ห้าม trim/normalize** -// - HmacSha256 เป็น hex lowercase 64 ตัว (การเทียบ normalize เป็น lowercase -// ให้ก่อน — ฝั่ง stamp ตัวพิมพ์ใหญ่ก็ผ่านได้) -// - ห้าม import อะไรที่เป็น platform-specific (node:crypto/crypto.subtle) — -// module นี้ต้องรันได้ทั้งใน route handler และ webview ของ Word โดยส่ง -// ตัวคำนวณ HMAC เข้ามาทาง parameter แทน - -/** ticket ที่แกะจาก ticket_data — ชื่อ field ตามที่ e-Court กำหนด (requirement) */ -export interface DocverTicket { - /** หมดอายุเมื่อไร — unix epoch วินาที */ - exp: number; - /** fileId ของ e-Court — เป็น docId ของทุก operation ของกลุ่ม docver ตรง ๆ */ - fid: string; - /** template ต้นทาง — optional (ไม่มี = null) */ - tid: string | null; - /** รหัสผู้ใช้ → claim `userid` */ - uid: string; - /** ชื่อผู้ใช้ → claim `username` */ - uname: string; -} - -/** ความยาว cap ต่อ field — ตาม parseTokenRequestBody ของ /api/documents/token */ -const TICKET_LIMITS = { - uid: 100, - uname: 200, - fid: 200, - tid: 100, -} as const; - -/** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */ -const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const; - -export type DocverTicketParseResult = - | { ok: true; ticket: DocverTicket } - | { ok: false; error: string }; - -/** - * แกะ + ตรวจ ticket จาก string ดิบของ property `ticket_data` - * คืนค่าที่ trim แล้ว (uid/uname/fid/tid) — exp เป็น number ตรง ๆ - */ -export function parseDocverTicket(raw: string): DocverTicketParseResult { - let parsed: unknown; - try { - parsed = JSON.parse(raw); - } catch { - return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" }; - } - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return { ok: false, error: "ticket_data ต้องเป็น JSON object" }; - } - const record = parsed as Record; - for (const key of Object.keys(record)) { - if (!(TICKET_FIELDS as readonly string[]).includes(key)) { - return { - ok: false, - error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`, - }; - } - } - - const exp = record.exp; - if (typeof exp !== "number" || !Number.isSafeInteger(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: { exp, fid, tid, uid, uname } }; -} - -/** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */ -export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean { - return exp <= nowSec; -} - -/** - * normalize ค่า digest ที่ได้รับ (property `HmacSha256` / body field) — - * trim + lowercase แล้วตรวจว่าเป็น hex ครบ 64 ตัว คืน null เมื่อรูปไม่ใช่ - */ -export function normalizeHexDigest(value: string): string | null { - const normalized = value.trim().toLowerCase(); - return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null; -} - -/** - * เทียบ hex digest สองค่าแบบ constant-time (best-effort ของ pure JS — - * ฝั่ง server จริงใช้ node:crypto timingSafeEqual ผ่าน lib/addin-ticket.ts - * ตัวนี้ใช้กับ UX gate ฝั่ง client เป็นหลัก) — ค่าที่ส่งเข้าควร normalize แล้ว - */ -export function timingSafeEqualHex(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) { - diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - } - return diff === 0; -} - -/** ตัวคำนวณ HMAC-SHA256 คืนค่าเป็น hex lowercase — สองฝั่งฉีดของตัวเอง (Web Crypto / node:crypto) */ -export type HmacSha256Hex = (message: string, secret: string) => Promise; diff --git a/wordaddin/config.ts b/wordaddin/config.ts index 646079e..e142b70 100644 --- a/wordaddin/config.ts +++ b/wordaddin/config.ts @@ -2,19 +2,12 @@ * Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build) * (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next) * - * ช่วง mock: versioningService เป็น mock ไม่ใช้ apiBaseUrl — + * ช่วง mock: authService/versioningService เป็น mock ไม่ใช้ apiBaseUrl — * ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http) - * ส่วนการขอ token (ticket auth) ยิง same-origin /api/documents/token เสมอ ไม่ผูกกับ apiBaseUrl */ export const config = { /** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */ apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "", /** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */ mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01", - /** - * secret ตรวจ HMAC ของ ticket ฝั่ง client (UX gate — ตัดเคสไฟล์ถูกแก้ก่อนยิง network; - * ความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น เพราะค่าใน bundle สกัดได้) — ต้องตรงกับ - * ตัว stamp ของ e-Court และ env ADDIN_TICKET_SECRET/SECRET ฝั่ง server (PLAN-ADDIN-AUTH.md §7) - */ - ticketSecret: process.env.NEXT_PUBLIC_ADDIN_TICKET_SECRET ?? "super_secret", }; diff --git a/wordaddin/models/versionRecord.ts b/wordaddin/models/versionRecord.ts index 3a1087d..7af9352 100644 --- a/wordaddin/models/versionRecord.ts +++ b/wordaddin/models/versionRecord.ts @@ -32,8 +32,6 @@ export interface VersionRecord { /** ISO-8601 */ createdAt: string; createdByUserId: string; - /** ชื่อผู้สร้าง — ส่วนเสริมของ DTO กลุ่ม docver (mock/demo อาจไม่มี → UI fallback เป็น userId) */ - createdByUserName?: string; /** SHA256 hex */ contentHash: string; fields: FieldsSnapshot | null; diff --git a/wordaddin/services/apiClient.ts b/wordaddin/services/apiClient.ts index 0b4476e..59f9e06 100644 --- a/wordaddin/services/apiClient.ts +++ b/wordaddin/services/apiClient.ts @@ -1,20 +1,18 @@ import { config } from "../config"; -import { withBasePath } from "@/utils/base-path"; import * as authService from "./authService"; /** - * fetch wrapper ของ add-in — แนบ Bearer ทุก request (access_token ที่ authService - * ขอจาก /api/documents/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md) - * โดน 401 → ลองขอ token ใหม่จาก ticket แล้ว retry 1 ครั้ง ยังไม่ผ่าน → AuthRequiredError: - * UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court - * พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts + * fetch wrapper ของ add-in — แนบ Bearer ทุก request + * โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError + * (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9) + * พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts * (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError * ถูก TaskpaneApp ใช้อยู่แล้ว) */ -/** บอกว่า ticket ใช้ไม่ได้แล้ว (หมดอายุ / token ขอไม่ได้) — ต้องได้ไฟล์ใหม่จาก e-Court */ +/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */ export class AuthRequiredError extends Error { - constructor(message = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง") { + constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") { super(message); this.name = "AuthRequiredError"; } @@ -37,15 +35,8 @@ export interface ApiRequestOptions { body?: BodyInit | null; } -/** - * path เต็มเสมอ — ไม่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` = same-origin ผ่าน withBasePath - * (ได้ /docedit/api/… ถูกต้องตาม invariant #12 — API ของกลุ่ม docver อยู่แอปนี้เอง) - * · ตั้งค่า = ชี้ backend คนละ origin (ต้องรวม basePath แล้ว เช่น https://host/docedit — ใช้ตอน - * deploy add-in กับ API คนละตัว) และต้องเป็น HTTPS เสมอ (webview ของ Word บล็อก http) - */ function toUrl(path: string): string { - if (path.startsWith("http")) return path; - return config.apiBaseUrl ? `${config.apiBaseUrl}${path}` : withBasePath(path); + return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`; } async function doFetch( @@ -63,30 +54,27 @@ async function doFetch( } /** - * request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน - * (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง - * ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์ - * ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง + * request พร้อม Bearer + retry-on-401 ครั้งเดียว + * Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError) */ export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise { const accessToken = await authService.getAccessToken(); if (!accessToken) { throw new AuthRequiredError(); } - const response = await doFetch(path, options, accessToken); - if (response.status !== 401) { - return response; - } - if (await authService.renewTokenFromTicket()) { - const retryToken = await authService.getAccessToken(); - if (retryToken) { - const retry = await doFetch(path, options, retryToken); - if (retry.status !== 401) { - return retry; - } + + let response = await doFetch(path, options, accessToken); + if (response.status === 401) { + const refreshedToken = await authService.refreshAccessToken(); + if (!refreshedToken) { + throw new AuthRequiredError(); + } + response = await doFetch(path, options, refreshedToken); + if (response.status === 401) { + throw new AuthRequiredError(); } } - throw new AuthRequiredError(); + return response; } /** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */ diff --git a/wordaddin/services/authService.ts b/wordaddin/services/authService.ts index 6904876..7cd7509 100644 --- a/wordaddin/services/authService.ts +++ b/wordaddin/services/authService.ts @@ -1,188 +1,97 @@ /** - * Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม - * docs/requirement/addin-workflow.md) + * MOCK — แทน Keycloak OIDC/PKCE ของต้นฉบับ (ผู้ใช้งานเลือกไม่ใช้ Keycloak ช่วงพอร์ต) + * ต้นฉบับ: D:\word-addin-poc\addin\src\services\authService.ts (login ผ่าน Office Dialog API, + * token เก็บใน memory เท่านั้น, auto-refresh) * - * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data` - * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ - * HMAC → exp → session ใน localStorage ตาม uid/fid แล้วขอ access_token จาก - * /api/documents/token เก็บตาม fid ("คีย์คือ fid") - * - * token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่ - * ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew - * ตอน token หมดอายุกลาง session + * วันสลับเป็น auth จริง: เขียน implementation ใหม่โดยคง export surface ด้านล่างครบทุกตัว + * (เพิ่ม refreshAccessToken ที่ต้นฉบับมีอยู่แล้วสำหรับ apiClient) — โค้ดอื่นไม่ต้องแตะ */ import { config } from "../config"; -import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket"; -import * as ticketService from "./ticketService"; -/** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */ -export type SignInFailureReason = "missing" | "tampered" | "expired" | "token-request-failed"; +/** discriminated union เหมือนต้นฉบับ — ok:false ต้องมี error เสมอ */ +export type LoginResult = { ok: true } | { ok: false; error: string }; -export type SignInResult = { ok: true } | { ok: false; reason: SignInFailureReason; message: string }; - -/** state ของ taskpane instance นี้ (ต่อเอกสารที่เปิด) — null = ยังไม่ได้ sign in */ +/** state เดียวของ mock — null = signed out */ let username: string | null = null; -/** fid ของเอกสารปัจจุบัน — token ที่ใช้ผูกกับ fid นี้เสมอ */ -let currentFid: string | null = null; -/** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */ -let currentTicket: { data: string; hmac: string } | null = null; -/** โหมด ?addindemo (dev-only) — เปิดโดย signInForDemo เท่านั้น */ -let demoMode = false; +/** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */ +let docToken: string | null = null; -/** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */ -export function signInForDemo(): void { +function delay(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +/** จำลองเปิดหน้าต่าง login — สำเร็จทันที ตั้งชื่อจาก NEXT_PUBLIC_ADDIN_MOCK_USERNAME */ +export async function login(): Promise { + await delay(300); username = config.mockUsername; - currentFid = null; - currentTicket = null; - demoMode = true; -} - -/** โหมด ?addindemo หรือเปล่า — versioningService ใช้ mock แทน backend จริงเฉพาะโหมดนี้ */ -export function isDemoMode(): boolean { - return demoMode; -} - -/** - * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7): - * 1. ตรวจมี ticket_data + HmacSha256 ครบ - * 2. parse + ตรวจ HMAC ฝั่ง client (UX gate) - * 3. session ใน localStorage: ยังไม่มี/user ใหม่ → ตรวจ exp → เคลียร์ session เก่า - * แล้วขอ token ใหม่ · user เดิม + fid เดิม → ผ่านเลย (renew เงียบ ๆ ถ้า token - * หมดอายุแต่ ticket ยังไม่หมด) · user เดิม + fid ใหม่ → ตรวจ exp → ขอ token เก็บเพิ่ม - */ -export async function signInWithTicket(input: { - ticketData?: string; - ticketHmac?: string; -}): Promise { - const ticketData = input.ticketData ?? ""; - const ticketHmac = input.ticketHmac ?? ""; - // Word custom property จำกัด value ~255 ตัวอักษร — เกิน = ไฟล์เสียหาย (ถูกตัด) - if (ticketData === "" || ticketHmac === "" || ticketData.length > 255 || ticketHmac.length > 255) { - return { - ok: false, - reason: "missing", - message: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น", - }; - } - - const parsed = parseDocverTicket(ticketData); - if (!parsed.ok) { - return { - ok: false, - reason: "tampered", - message: "ตรวจสอบ ticket ไม่ผ่าน (รูปแบบไม่ถูกต้อง) — ไฟล์ถูกแก้ไขหรือเสียหาย", - }; - } - if (!(await ticketService.verifyTicketHmacWeb(ticketData, ticketHmac, config.ticketSecret))) { - return { - ok: false, - reason: "tampered", - message: "ตรวจสอบลายเซ็น (HMAC) ของ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไข", - }; - } - const ticket = parsed.ticket; - const ticketAlive = !isTicketExpired(ticket.exp); - - const session = ticketService.loadSession(); - if (session === null || session.userId !== ticket.uid) { - // ไม่มี session / user ใหม่ — ตรวจ exp ก่อนแล้วเริ่ม session ใหม่ทั้งก้อน - if (!ticketAlive) { - return { - ok: false, - reason: "expired", - message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", - }; - } - ticketService.clearSession(); - let entry: ticketService.SessionTokenEntry; - try { - entry = await ticketService.requestDocverToken(ticketData, ticketHmac); - } catch { - return { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; - } - ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: entry } }); - } else { - // คนเดิม — userName อัปเดตตามไฟล์ล่าสุด (ชื่อเปลี่ยนได้) - session.userName = ticket.uname; - let entry = session.tokens[ticket.fid] ?? null; - if (entry !== null && ticketService.isTokenExpired(entry)) { - // token หมดอายุ — renew ได้เฉพาะตอน ticket ยังไม่หมด; ticket หมดด้วยก็เก็บของ - // เก่าไว้ (requirement ขั้น 7: ผ่านเลยไม่ตรวจ exp) ให้ server ตัดสินตอนใช้งาน - if (ticketAlive) { - try { - entry = await ticketService.requestDocverToken(ticketData, ticketHmac); - session.tokens[ticket.fid] = entry; - } catch { - // ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access - } - } - } - if (entry === null) { - // เอกสารใหม่ของคนเดิม — ตรวจ exp แล้วขอ token เก็บเพิ่ม - if (!ticketAlive) { - return { - ok: false, - reason: "expired", - message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", - }; - } - try { - session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac); - } catch { - return { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; - } - } - ticketService.saveSession(session); - } - - currentFid = ticket.fid; - currentTicket = { data: ticketData, hmac: ticketHmac }; - username = ticket.uname; - demoMode = false; // sign-in จริงเคลียร์โหมด demo (ถ้าเคยเปิดค้างใน page เดียวกัน) return { ok: true }; } -/** - * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) — - * เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ - * คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่) - */ -export async function renewTokenFromTicket(): Promise { - if (currentTicket === null || currentFid === null) return false; - const parsed = parseDocverTicket(currentTicket.data); - if (!parsed.ok || isTicketExpired(parsed.ticket.exp)) return false; +/** mock-only — โหมด ?addindemo ใช้ตั้ง session ทันทีไม่ผ่านปุ่ม Sign in */ +export function signInForDemo(): void { + username = config.mockUsername; +} + +/** ถอด field จาก JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ดู uid ประกอบชื่อผู้ใช้เท่านั้น */ +function decodeJwtField(token: string, field: string): string | undefined { + const payload = token.split(".")[1]; + if (!payload) return undefined; try { - const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); - const session = - ticketService.loadSession() ?? - { userId: parsed.ticket.uid, userName: parsed.ticket.uname, tokens: {} }; - session.userName = parsed.ticket.uname; - session.tokens[parsed.ticket.fid] = entry; - ticketService.saveSession(session); - return true; + // atob คืน binary string (1 char = 1 byte) — ต้องถอด UTF-8 ด้วย TextDecoder ก่อน JSON.parse + // ไม่งั้น uid ที่ไม่ใช่ ASCII (เช่น ชื่อไทย) จะกลายเป็น mojibake + const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0)); + const json: unknown = JSON.parse(new TextDecoder().decode(bytes)); + const value = typeof json === "object" && json !== null ? (json as Record)[field] : undefined; + return typeof value === "string" && value !== "" ? value : undefined; } catch { - return false; + return undefined; } } -/** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */ -export async function getAccessToken(): Promise { - if (currentFid !== null) { - return ticketService.loadSession()?.tokens[currentFid]?.token ?? null; +/** + * e-Court pass-through (ระยะแรก) — ไฟล์ที่ e-Court stamp `token`/`owner` ไว้ใน custom properties + * ใช้งานได้เลยโดยไม่ต้องกด login: username จาก owner → uid ใน token → mockUsername + * คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว) + */ +export function signInFromDocument(input: { token?: string; owner?: string }): boolean { + const token = input.token?.trim() || undefined; + const owner = input.owner?.trim() || (token ? decodeJwtField(token, "uid") : undefined); + if (!token && !owner) { + return false; } - // demo mode (?addindemo) — ไม่มี ticket ใช้ token ปลอมเหมือนเดิม + username = owner ?? config.mockUsername; + docToken = token ?? null; + return true; +} + +export async function getAccessToken(): Promise { + if (docToken) return docToken; return username ? `mock-token-${username}` : null; } +/** mock: token ไม่มีวันหมดอายุจริง — คืนตัวเดิมหรือ null ตามสถานะ login */ +export async function refreshAccessToken(): Promise { + return getAccessToken(); +} + export function getUsername(): string | undefined { return username ?? undefined; } + +export function isTokenValid(): boolean { + return username !== null; +} + +export function hasRefreshToken(): boolean { + return username !== null; +} + +export function startAutoRefresh(): void { + // mock: ไม่มีอะไรต้อง refresh +} + +export function signOut(): void { + username = null; + docToken = null; +} diff --git a/wordaddin/services/metadataService.ts b/wordaddin/services/metadataService.ts index 68a8693..1119a3b 100644 --- a/wordaddin/services/metadataService.ts +++ b/wordaddin/services/metadataService.ts @@ -23,10 +23,9 @@ export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp"; /** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */ export const TEMPLATE_ID_PROPERTY = "TemplateId"; -/** e-Court ticket — ไฟล์ที่ e-Court stamp คู่นี้มาใน custom properties คือ auth ทางเข้าเดียวของ add-in - * (PLAN-ADDIN-AUTH.md §2: HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase 64 ตัว) */ -export const TICKET_DATA_PROPERTY = "ticket_data"; -export const TICKET_HMAC_PROPERTY = "HmacSha256"; +/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลยไม่ต้องกด login */ +export const ECOURT_TOKEN_PROPERTY = "token"; +export const ECOURT_OWNER_PROPERTY = "owner"; /** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */ export interface TrackedDocumentProperties { @@ -36,9 +35,9 @@ export interface TrackedDocumentProperties { lastVersionTimestamp?: string; /** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */ templateId?: string; - /** e-Court ticket — มีครบทั้งคู่ = ตรวจ HMAC แล้วเข้าใช้งานได้ (auth ทางเข้าเดียว) */ - ticketData?: string; - ticketHmac?: string; + /** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ข้ามหน้า login (username จาก owner หรือ uid ใน token) */ + ecourtToken?: string; + ecourtOwner?: string; } /** item ใน Custom XML Part `` */ @@ -50,7 +49,7 @@ export interface XmlVersionEntry { } /** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16 - * + ticket_data/HmacSha256 ที่ e-Court stamp มากับไฟล์) */ + * + token/owner ที่ e-Court stamp มากับไฟล์) */ export async function readTrackedProperties(): Promise { return Word.run(async (context) => { const properties = context.document.properties.customProperties; @@ -59,16 +58,16 @@ export async function readTrackedProperties(): Promise @@ -82,8 +81,8 @@ export async function readTrackedProperties(): Promise; -} - -/** memory fallback — ใช้เมื่อ localStorage โดน block (webview บางตัว/private mode) */ -let memorySession: AddinSession | null = null; - -function parseSession(raw: string | null): AddinSession | null { - if (!raw) return null; - try { - const parsed = JSON.parse(raw) as AddinSession; - if ( - typeof parsed.userId !== "string" || - typeof parsed.userName !== "string" || - parsed.userId === "" || - typeof parsed.tokens !== "object" || - parsed.tokens === null || - Array.isArray(parsed.tokens) - ) { - return null; - } - for (const entry of Object.values(parsed.tokens)) { - if (typeof entry.token !== "string" || typeof entry.exp !== "number") return null; - } - return parsed; - } catch { - return null; - } -} - -export function loadSession(): AddinSession | null { - if (typeof window === "undefined") return memorySession; // SSR ของ client component - try { - return parseSession(window.localStorage.getItem(SESSION_STORAGE_KEY)) ?? memorySession; - } catch { - return memorySession; - } -} - -/** เขียน session — localStorage พัง = เก็บ memory ต่อ (ไม่ throw) */ -export function saveSession(session: AddinSession): void { - memorySession = session; - if (typeof window === "undefined") return; - try { - window.localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); - } catch { - // ignore — quota/private mode: มี memory ของ session นี้อยู่ - } -} - -/** ล้างทั้งก้อน — ใช้ตอนเจอ user ใหม่ (requirement ขั้น 6: เคลียร์ Session เดิม) */ -export function clearSession(): void { - memorySession = null; - if (typeof window === "undefined") return; - try { - window.localStorage.removeItem(SESSION_STORAGE_KEY); - } catch { - // ignore - } -} - -/** token หมดอายุหรือยัง (มี margin 30 วิ กันขอบเขตตกหล่น) */ -export function isTokenExpired(entry: SessionTokenEntry): boolean { - return entry.exp * 1000 <= Date.now() - 30_000; -} - -// --------------------------------------------------------------------------- -// HMAC ฝั่ง client — Web Crypto (crypto.subtle ให้ promise เสมอ) -// --------------------------------------------------------------------------- - -/** คำนวณ HMAC-SHA256 คืน hex lowercase — รูปแบบเดียวกับ generateHmacSha256 ฝั่ง server */ -export async function hmacSha256HexWeb(message: string, secret: string): Promise { - const encoder = new TextEncoder(); - const key = await crypto.subtle.importKey( - "raw", - encoder.encode(secret), - { name: "HMAC", hash: "SHA-256" }, - false, - ["sign"], - ); - const signature = await crypto.subtle.sign("HMAC", key, encoder.encode(message)); - return Array.from(new Uint8Array(signature), (byte) => byte.toString(16).padStart(2, "0")).join(""); -} - -/** - * ตรวจ HMAC ของ ticket ฝั่ง client — message คือ string ดิบของ ticket_data ตามที่อ่านจาก - * property (ห้าม trim — ตัว stamp เซ็นบนค่าดิบ) · เทียบหลัง normalize lowercase - */ -export async function verifyTicketHmacWeb(ticketData: string, expectedHex: string, secret: string): Promise { - const expected = normalizeHexDigest(expectedHex); - if (expected === null) return false; - try { - return (await hmacSha256HexWeb(ticketData, secret)) === expected; - } catch { - return false; - } -} - -// --------------------------------------------------------------------------- -// ขอ token จาก /api/documents/token (ทางเข้า ticket — PLAN-ADDIN-AUTH.md §3) -// --------------------------------------------------------------------------- - -interface TokenResponse { - access_token: string; - token_type: string; - expires_in: number; -} - -/** ถอด exp จาก JWT payload โดยไม่ตรวจ signature (แม่นกว่า expires_in — ใช้นาฬิกา server) */ -function jwtExp(token: string): number | undefined { - const payload = token.split(".")[1]; - if (!payload) return undefined; - try { - const bytes = Uint8Array.from(atob(payload.replace(/-/g, "+").replace(/_/g, "/")), (char) => char.charCodeAt(0)); - const json: unknown = JSON.parse(new TextDecoder().decode(bytes)); - const exp = typeof json === "object" && json !== null ? (json as Record).exp : undefined; - return typeof exp === "number" && Number.isFinite(exp) ? exp : undefined; - } catch { - return undefined; - } -} - -/** - * ขอ access_token ด้วย ticket — โยน Error เมื่อ status ไม่สำเร็จ - * (401 = ticket ไม่ผ่านการตรวจฝั่ง server — ให้ caller ไป no-access ตาม flow) - */ -export async function requestDocverToken(ticketData: string, ticketHmac: string): Promise { - const response = await fetch(withBasePath("/api/documents/token"), { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ ticket_data: ticketData, HmacSha256: ticketHmac }), - }); - if (!response.ok) { - throw new Error(`ขอ token ไม่สำเร็จ (HTTP ${response.status})`); - } - let data: TokenResponse; - try { - data = (await response.json()) as TokenResponse; - } catch { - throw new Error("ขอ token ไม่สำเร็จ (response ไม่ใช่ JSON)"); - } - if (typeof data.access_token !== "string" || data.access_token === "") { - throw new Error("ขอ token ไม่สำเร็จ (ไม่มี access_token)"); - } - const exp = - jwtExp(data.access_token) ?? - (typeof data.expires_in === "number" ? Math.floor(Date.now() / 1000) + data.expires_in : 0); - return { token: data.access_token, exp }; -} diff --git a/wordaddin/services/versioningService.ts b/wordaddin/services/versioningService.ts index f8fb51a..506130a 100644 --- a/wordaddin/services/versioningService.ts +++ b/wordaddin/services/versioningService.ts @@ -1,154 +1,26 @@ /** - * Version API ฝั่ง client — **ของจริง** (กลุ่ม docver /api/documents/* ผ่าน apiClient - * ซึ่งแนบ Bearer แล้ว — contract ตาม docs/PLAN-DOCVER.md) · เหลือ mock เฉพาะโหมด - * `?addindemo` (dev-only ดู UI ใน browser ไม่ต้องมี backend/MinIO — authService.isDemoMode) + * MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7) + * ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient) * - * - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ตามต้นฉบับ - * D:\word-addin-poc\addin\src\services\versioningService.ts — versionFlow เรียกต่อได้เดิม - * - **เอกสารถูกระบุด้วย fid ใน Bearer token** (ไม่ใช่ documentId GUID ที่ stamp ในไฟล์ — - * 2026-10-26 contract ถอด {docId} ออกจาก URL ทุก endpoint) พารามิเตอร์ documentId จึงเหลือ - * ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims - * ของ token — ส่งมาใน form = 400) - * - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 400) - * - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ - * ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block) - * = ใช้ memory อย่างเดียว ไม่ throw + * - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ — + * วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย + * - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion + * - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ + * POST /api/documents/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400) และไม่มี + * segment {docId} ใน URL อีกต่อไป: server แกะ identity/template และ docId + * (= fid — fileId ของ e-Court, string) จาก Bearer token (ออกที่ POST + * /api/documents/token ด้วย x-api-key, fid บังคับ) ที่ apiClient.ts แนบให้อยู่แล้ว + * - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) — + * ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata + * การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง + * ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า + * ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw */ import * as authService from "./authService"; import { generateGuid } from "./metadataService"; -import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient"; import { UploadVersionResult, VersionRecord } from "../models/versionRecord"; -// --------------------------------------------------------------------------- -// ของจริง — กลุ่ม docver (Bearer จาก ticket auth แนบอัตโนมัติโดย apiClient) -// --------------------------------------------------------------------------- - -/** DTO ของ GET /api/documents/versions (ใหม่ → เก่า) — PLAN-DOCVER.md §1 */ -interface DocumentVersionDTO { - versionId: string; - versionNumber: number; - createdAt: string; - createdByUserId: string; - createdByUserName: string; - contentHash: string; - fields: Record | null; - message: string | null; - hasPdf: boolean; -} - -/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (multipart POST) - * + FR-13: message คือ commit message ทางเลือก - * + FR-16: templateId เก็บไว้ตาม signature เดิม — ไม่ส่งถึง server (มาจาก claims ของ token) */ -export async function uploadVersion( - documentId: string, - file: Blob, - clientVersionId: string, - fields?: Record, - fileName?: string, - message?: string, - pdf?: Blob, - templateId?: string -): Promise { - if (!authService.isDemoMode()) { - void templateId; // contract 2026-10-22 — ห้ามส่ง (identity ทั้งหมดจาก Bearer token) - const form = new FormData(); - form.append("file", file, fileName ?? "document.docx"); - // pdf optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF - if (pdf && pdf.size > 0) { - form.append("pdf", pdf, "document.pdf"); - } - if (clientVersionId) { - form.append("clientVersionId", clientVersionId); - } - if (fields && Object.keys(fields).length > 0) { - form.append("fields", JSON.stringify(fields)); - } - const trimmedMessage = message?.trim(); - if (trimmedMessage) { - form.append("message", trimmedMessage); - } - if (fileName) { - form.append("fileName", fileName); - } - return apiSendExpectJson( - "/api/documents/versions", - { method: "POST", body: form }, - "อัปโหลด version ไม่สำเร็จ", - ); - } - return uploadVersionMock(documentId, file, clientVersionId, fields, message, pdf); -} - -/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) - * docId มาจาก fid ใน token — documentId พารามิเตอร์ไม่ถูกใช้กับ server ของจริง - * 404 = เอกสารนี้ยังไม่เคยมี version (เช่น ไฟล์ที่ stamp SavedByAddIn ไว้สมัย mock แล้วเพิ่ง - * มาใช้ backend จริง) — สำหรับ add-in ความหมายเดียวกับ list ว่าง: UI แสดง "ยังไม่มี version - * ในระบบ" และกด Save & Version สร้าง v1 ได้เลย (upload สร้างเอกสารให้อัตโนมัติ) */ -export async function getVersions(documentId: string): Promise { - if (!authService.isDemoMode()) { - let rows: DocumentVersionDTO[]; - try { - rows = await apiGetJson("/api/documents/versions", "โหลด version history ไม่สำเร็จ"); - } catch (error) { - if (error instanceof ApiError && error.status === 404) { - return []; - } - throw error; - } - // server เรียงใหม่ → เก่าอยู่แล้ว — แปลงเป็น VersionRecord ตามที่ UI ใช้ - return rows.map((row) => ({ - versionId: row.versionId, - versionNumber: row.versionNumber, - createdAt: row.createdAt, - createdByUserId: row.createdByUserId, - createdByUserName: row.createdByUserName, - contentHash: row.contentHash, - fields: row.fields && Object.keys(row.fields).length > 0 ? row.fields : null, - message: row.message, - hasPdf: row.hasPdf, - })); - } - return getVersionsMock(documentId); -} - -export interface VersionDownloadLink { - url: string; - /** ISO-8601 เวลาที่ URL หมดอายุ */ - expiresAt: string; -} - -/** - * FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้ - * (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน) - * URL เป็น MinIO presigned GET (อายุ env DOCVER_LINK_TTL_SECONDS default 900 วิ) — - * โฮสต์ MinIO ต้องเข้าถึงได้จากเครื่องผู้ใช้ - */ -export async function getDownloadLink( - documentId: string, - versionId: string, - format?: "docx" | "pdf" -): Promise { - if (!authService.isDemoMode()) { - void documentId; // เอกสารมาจาก fid ใน token — versionId เช็คคู่กับ docId ฝั่ง server - const query = format && format !== "docx" ? `?format=${encodeURIComponent(format)}` : ""; - return apiGetJson( - `/api/documents/versions/${encodeURIComponent(versionId)}/download-link${query}`, - "ขอลิงก์ดาวน์โหลดไม่สำเร็จ", - ); - } - return getDownloadLinkMock(); -} - -/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */ -export function newClientVersionId(): string { - return generateGuid(); -} - -// --------------------------------------------------------------------------- -// Mock — เฉพาะโหมด ?addindemo (dev-only) -// --------------------------------------------------------------------------- - interface StoredVersion { record: VersionRecord; docxBlob: Blob | null; @@ -206,15 +78,22 @@ async function mockContentHash(file: Blob): Promise { } } -async function uploadVersionMock( +/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory) + * + FR-13: message คือ commit message ทางเลือก + * + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */ +export async function uploadVersion( documentId: string, file: Blob, clientVersionId: string, fields?: Record, + fileName?: string, message?: string, - pdf?: Blob + pdf?: Blob, + templateId?: string ): Promise { void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง + void fileName; + void templateId; await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง const versions = store.get(documentId) ?? []; @@ -238,7 +117,8 @@ async function uploadVersionMock( return { hasPdf, versionId: record.versionId, versionNumber }; } -async function getVersionsMock(documentId: string): Promise { +/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */ +export async function getVersions(documentId: string): Promise { await delay(200); const versions = store.get(documentId) ?? []; return versions @@ -247,7 +127,33 @@ async function getVersionsMock(documentId: string): Promise { .map((item) => ({ ...item.record })); } -async function getDownloadLinkMock(): Promise { +export interface VersionDownloadLink { + url: string; + /** ISO-8601 เวลาที่ URL หมดอายุ */ + expiresAt: string; +} + +/** + * FR-7 — ขอ presigned URL อายุสั้นของ version หนึ่ง เพื่อเปิดในเบราว์เซอร์ของผู้ใช้ + * (webview ของ Word ทิ้ง blob download เงียบๆ — ให้เบราว์เซอร์จริงจัดการแทน) + * mock: ไม่มี URL จริงให้คืน — blob:/data: ใช้ผ่าน openBrowserWindow ไม่ได้ (blob ผูกกับ + * context ของ webview, data: โดนเบราว์เซอร์ block) ส่วน about:blank ที่คืนไปนี้ host จะ + * ปฏิเสธเงียบๆ เพราะ openBrowserWindow เปิดเฉพาะ http/https และเป็น void API ไม่รายงานกลับ + * — กดปุ่มดาวน์โหลด/PDF ตอน mock จึงไม่มีหน้าต่างเบราว์เซอร์เปิด (UI ยังเดินครบ flow) + */ +export async function getDownloadLink( + documentId: string, + versionId: string, + format?: "docx" | "pdf" +): Promise { + void documentId; + void versionId; + void format; await delay(150); return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() }; } + +/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */ +export function newClientVersionId(): string { + return generateGuid(); +}