Compare commits

..

16 commits
v1.0.31 ... dev

Author SHA1 Message Date
Suphonchai Phoonsawat
b1449ab668 Refactor ticket verification and installation scripts
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m17s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
- Split `verifyAddinTicket` into two functions: `verifyAddinTicketHmac` (ignores expiration) and `verifyAddinTicket` (checks expiration).
- Add `hasVersionByUser` function to check if a document has been previously worked on by a user, allowing expired tickets to be accepted if the user has a history with the document.
- Update OpenAPI specification to reflect changes in ticket expiration handling.
- Introduce new scripts for installing and uninstalling the Word add-in on Windows and macOS, providing a streamlined process for sideloading the add-in.
- Enhance tests for ticket verification to cover new functionality regarding expired tickets and user history.
2026-10-28 14:42:03 +07:00
Suphonchai Phoonsawat
3726cb742f feat: enhance document versioning and metadata handling in Word add-in
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m21s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
- Updated API documentation for document versioning to clarify usage and requirements.
- Improved metadata service to support all content control types in Word API 1.5+.
- Added tests for reading content control fields to ensure compatibility with various Word versions.
- Refactored template service to utilize shared metadata functions for better maintainability.
2026-10-28 11:22:52 +07:00
oom
677de2fc29 Merge branch 'dev' of https://forgejo.chamomind.com/chamomind/eCourt-note into dev
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
2026-10-27 13:26:17 +07:00
oom
ce646ffc20 Fix Link bug: Replace <a> with <Link> and add more demo page(dl.html) 2026-10-27 13:24:15 +07:00
Suphonchai Phoonsawat
b8ff622999 fix(docver): drop fields value limit + treat history 404 as empty in add-in
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 5s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m28s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
Two fixes from first real-Word testing of the unmocked versioning flow:

- fields value length: remove the limit entirely (was 10,000, briefly
  100,000) — real court documents run 100-200 pages with content
  controls several hundred thousand chars long; callers are Bearer-only
  and the column is Postgres jsonb (up to ~255MB), so no storage
  bottleneck. Structural guards stay: ≤1,000 keys (was 100), key ≤200
  chars, value must be string.
- getVersions in versioningService now maps the history 404 to an empty
  list: files stamped SavedByAddIn back in the mock era enter the
  tracking phase before any real upload exists, so the pane shows
  "ยังไม่มี version ในระบบ" and Save & Version creates v1 (implicit
  document creation) instead of surfacing a 404 error banner. Other
  errors still propagate.

Tests: 288/288 (new cases — 1M-char value passes, key cap 1,000/1,001,
history 404 -> []). E2E against MinIO: 50k and 1M-char Thai field values
upload 200 and roundtrip byte-exact through jsonb. Docs synced:
PLAN-DOCVER §1, CLAUDE.md.

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 13:14:04 +07:00
Suphonchai Phoonsawat
37b18c58c2 feat(addin): unmock versioning — real upload/download against docver API
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 5s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m27s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Replace the in-memory versioningService with real calls to the docver
group (/api/documents/*) via apiClient with the ticket-issued Bearer;
keep the mock only for ?addindemo (authService.isDemoMode).

- uploadVersion: multipart POST /api/documents/versions (file, optional
  pdf when non-empty, clientVersionId, fields JSON, trimmed message,
  fileName) — identity fields are never sent (userId/userName/templateId
  in the form = 400 per the 2026-10-22 contract; docId comes from the
  fid claim)
- getVersions: GET → DocumentVersionDTO mapped to VersionRecord (adds
  optional createdByUserName; history panel shows it with userId
  fallback)
- getDownloadLink: GET presigned URL (+ ?format=pdf)
- apiClient.toUrl: empty NEXT_PUBLIC_ADDIN_API_BASE_URL = same-origin
  through withBasePath (/docedit/api/...); the env is now only for a
  split deployment (must include basePath, HTTPS)
- documentId/templateId params stay in signatures but are not sent
- tests: versioningService.test.ts covers both modes (real sign-in via
  signInWithTicket, FormData contract assertions, DTO mapping, format
  query, 401 -> AuthRequiredError, demo mock without network)
- docs sync: WORDADDIN-DEV (new "การรันกับ backend จริง" section),
  addin-ui-flow status note, PLAN-DOCVER §8, PLAN-ADDIN-AUTH §12, CLAUDE.md

Verified: tsc clean, 286/286 tests, full E2E against MinIO (ticket ->
token -> upload docx / docx+pdf -> history -> presigned download of both
formats).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 12:52:29 +07:00
Suphonchai Phoonsawat
4200176239 test(addin): convert crash-repro suite to permanent ticket-auth flow tests
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 5s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m28s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Rename tests/wordaddin/ticket-auth-crash-repro.test.ts to
ticket-auth-flow.test.ts and strip the temporary [DBG-r3p1] markers.
The debug session closed with an environment cause (stale Next server
holding the port / corrupted .next chunks), not a code defect, so the
suite stays as regression coverage for the signInWithTicket service
layer: single token fetch per fid, token reuse on reopen without
re-hitting /token, memory-fallback session teardown, and no uncaught
throw/rejection on every failure path (missing/invalid ticket, expired
ticket, server 401, webview without crypto.subtle).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:54:29 +07:00
Suphonchai Phoonsawat
7cd8939789 Merge commit '69d83ea493' into dev
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m26s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
2026-10-27 11:22:02 +07:00
Suphonchai Phoonsawat
cffc571da5 feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):

- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
  client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
  {ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
  secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
  unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
  wordaddin-auth keyed by fid, same-origin token request), authService
  rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
  metadataService reads ticket_data/HmacSha256, no-access messages per
  cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
  for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
  in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
  crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
  request bodies for /token), CLAUDE.md, .env.example

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:21:15 +07:00
oom
69d83ea493 Fix build time fail: export applyTemplate not compatible with Next.js(move to lib-tmpl), add var in .env.example
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
2026-10-27 10:15:35 +07:00
Suphonchai Phoonsawat
8ebe5f8644 Merge commit 'a3d14fd58d' into dev 2026-10-27 09:45:15 +07:00
Suphonchai Phoonsawat
50af6c10fa Refactor Word Add-in authentication to use e-Court pass-through; remove Keycloak integration
- Updated documentation to reflect removal of login and transition to e-Court pass-through authentication.
- Modified `authService` to handle authentication via custom properties in documents without user login.
- Adjusted `apiClient` to immediately throw `AuthRequiredError` for invalid tokens, as refresh tokens are no longer applicable.
- Revised UI flow to accommodate new authentication method, including changes to state management and error handling.
- Added new requirements document outlining the add-in workflow and authentication process.
2026-10-27 09:44:28 +07:00
oom
a3d14fd58d Add generate API, Prototype for developer, Clean data
Some checks failed
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 5s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Failing after 1m11s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Has been skipped
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Failing after 0s
2026-10-27 01:47:08 +07:00
Suphonchai Phoonsawat
6ce071ee14 fix(token): restore environment variable checks for DOCVER_API_KEY and DOCVER_TOKEN_SECRET
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m16s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
2026-10-26 18:37:02 +07:00
Suphonchai Phoonsawat
ab62a2b3e2 feat(docker): add MinIO configuration and token settings in Dockerfile
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m6s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s
2026-10-26 13:02:25 +07:00
Suphonchai Phoonsawat
c06da83f92 fix(docver): update token secret handling to use hardcoded value for testing
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m6s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 3s
2026-10-26 12:37:29 +07:00
65 changed files with 4060 additions and 1652 deletions

View file

@ -6,6 +6,22 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# EXTERNAL_API_KEY= # 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 ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml
# (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500) # (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500)
# หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev # หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev
@ -27,3 +43,14 @@ DOCVER_API_KEY=
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret # ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret
DOCVER_TOKEN_SECRET= DOCVER_TOKEN_SECRET=
# DOCVER_TOKEN_TTL_SECONDS=3600 # 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

View file

@ -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`) - 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) - 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`) - 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`) — ปัจจุบัน 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` ใช้ไม่ได้) - **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` ใช้ไม่ได้)
- 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/แถบหัวหน้าสรุปยกเว้น) - 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) - 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,9 +39,11 @@ 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/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/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/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 <unix|+sec>/--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9)
scripts/install-wordaddin.{mjs,sh,ps1} ← dev tool ติดตั้ง/ถอด (sideload) Word add-in บนเครื่องทดสอบ Windows + macOS — 3 รูปแบบกลไกเดียวกัน (Windows = registry `Wef\Developer`, macOS = โฟลเดอร์ wef ของ Word — กลไกเดียวกับ office-addin-dev-settings): .mjs = ใน repo (`npm run addin:install`, zero-dependency), .sh (macOS) / .ps1 (Windows) = **ไฟล์ส่งต่อให้เครื่องที่ไม่มี Node/repo** (.ps1 ต้องมี BOM — PS 5.1 อ่าน UTF-8 ไร้ BOM เป็น ANSI) — โหลด manifest จาก server ปัจจุบัน (default `https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml`, `--url`/`-Url` ชี้ dev ได้) + `--remove`/`-Remove` ถอด
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) 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 — 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 ที่รับส่งต่อ)) 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 ที่รับส่งต่อ))
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้) lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
@ -50,17 +52,14 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow
lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB)
lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=<id>` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม)
lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤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/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): 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-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env)
lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid)
lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner)
wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ)
<<<<<<< HEAD wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → **ต้องการ token เมื่อไหร่ยิงขอ `/api/documents/token` เสมอ แล้วให้ server ตัดสิน exp เอง (แก้ 2026-10-28 ตาม requirement ขั้น 7: ticket หมดอายุแต่ fid เคยมี version โดย uid เดียวกัน = ออกให้ — กลับมาทำเอกสารเดิมใช้ต่อได้แม้ token เก่าหมด/ localStorage หาย)** ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ+ไม่เคยทำงาน = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง (ไม่ตัด exp ฝั่ง client) ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด)
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ · **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/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine
utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %)
utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม)
@ -69,6 +68,7 @@ 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/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/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/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/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/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) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3)
@ -83,7 +83,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login)
app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…)
app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+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/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/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) 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)
@ -92,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` ของ <main> (กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) 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` ของ <main> (กฎ 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/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 เท่านั้น) 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 + mock sign in + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง) 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/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/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/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 ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) 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 ตัวกรองหุบเป็น `<details>`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `<div>` ไม่ใช่ `<section>` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน)
components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "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/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `<Link>` "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 หน้า) components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า)

View file

@ -1,20 +1,46 @@
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver // PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ // ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง):
// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env // 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 ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET →
// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket
// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) — ticket หมดอายุ
// ยังออกให้ต่อเมื่อ fid นี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7)
// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ // DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] // endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว // (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
import { docverError } from "@/lib/document-versioning"; import { docverError } from "@/lib/document-versioning";
import {
addinTicketSecret,
parseTicketTokenBody,
ticketToDocverClaims,
verifyAddinTicketHmac,
} from "@/lib/addin-ticket";
import { hasVersionByUser } from "@/lib/docver-store";
import { isTicketExpired } from "@/utils/docver-ticket";
import { import {
issueDocverToken, issueDocverToken,
parseTokenRequestBody, parseTokenRequestBody,
requireDocverApiKey, requireDocverApiKey,
type DocverTokenClaims,
} from "@/lib/docver-token"; } from "@/lib/docver-token";
export const runtime = "nodejs"; export const runtime = "nodejs";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */
const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ";
export async function POST(request: Request) { 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<Response> {
const apiKeyFail = requireDocverApiKey(request); const apiKeyFail = requireDocverApiKey(request);
if (apiKeyFail !== null) return apiKeyFail; if (apiKeyFail !== null) return apiKeyFail;
@ -26,16 +52,57 @@ export async function POST(request: Request) {
} }
const parsed = parseTokenRequestBody(raw); const parsed = parseTokenRequestBody(raw);
if (!parsed.ok) return docverError(parsed.error, 400); 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<Response> {
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);
}
// ตรวจ HMAC ก่อนเสมอ (ตัวตนของผู้ถือไฟล์) — exp ไม่ใช่เกณฑ์ตัดสิทธิ์ ณ จุดนี้
const verified = verifyAddinTicketHmac(parsed.ticketData, parsed.hmacHex, addinTicketSecret());
if (!verified.ok) return docverError(TICKET_DENIED, 401);
// ticket หมดอายุ → ยังออก token ให้ต่อเมื่อ "เอกสารนี้เคยทำงานโดย uid เดียวกัน"
// (requirement ขั้น 7: กลับมาทำเอกสารเดิมผ่านได้แม้ ticket หมดอายุ — พิสูจน์จาก
// document_versions ของกลุ่มนี้เอง) · DB พัง = fail closed เป็น 401 เหมือนไม่มีสิทธิ์
if (isTicketExpired(verified.ticket.exp)) {
let workedBefore = false;
try {
workedBefore = await hasVersionByUser(verified.ticket.fid, verified.ticket.uid);
} catch {
workedBefore = false;
}
if (!workedBefore) return docverError(TICKET_DENIED, 401);
}
return issueToken(ticketToDocverClaims(verified.ticket));
}
/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") { if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
} }
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
const ttlSeconds = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; const ttlSeconds =
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds); const accessToken = issueDocverToken(claims, secret, ttlSeconds);
return new Response( return new Response(
JSON.stringify({ JSON.stringify({
access_token: accessToken, access_token: accessToken,

View file

@ -0,0 +1,109 @@
// ตัวอย่างรองรับค่าจาก 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<NextResponse<unknown>>
{
// 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)
}

View file

@ -1,8 +1,7 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
import { verifyDataEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
// export const runtime = "nodejs";
export const runtime = "nodejs";
const DOCX_CONTENT_TYPE = const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"; "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
@ -15,27 +14,14 @@ export async function POST(request: Request) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
const file = formData.get("file"); const file = formData.get("file");
const TEST_SECRET = String(formData.get("secret"))??""; const TEST_SECRET = String(formData.get("secret") ?? "");
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key"))??""; const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? "");
if (!(file instanceof File) ||
if (!(file instanceof File)) { (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") )
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( return NextResponse.json(
{ {
success: false,
error: 'Field "file" must be a .docx file.', error: 'Field "file" must be a .docx file.',
}, },
{ status: 400 }, { status: 400 },
@ -45,7 +31,6 @@ export async function POST(request: Request) {
if (file.size > MAX_UPLOAD_BYTES) { if (file.size > MAX_UPLOAD_BYTES) {
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: 'Field "file" exceeds the 100MB size limit.', error: 'Field "file" exceeds the 100MB size limit.',
}, },
{ status: 400 }, { status: 400 },
@ -62,35 +47,30 @@ export async function POST(request: Request) {
const bookmark = extractBookmark(buffer) const bookmark = extractBookmark(buffer)
const properties = getDocProps(buffer) const properties = getDocProps(buffer)
let verify: Record<string, unknown> ={} let verify: Record<string, unknown> ={}
const ticket_data = String(properties?.custom?.ticket_data) const ticket_data = properties?.custom?.ticket_data
const HmacSha256 = String(properties?.custom?.HmacSha256) const HmacSha256 = String(properties?.custom?.HmacSha256)
const Ed25519 = String(properties?.custom?.Ed25519) const Ed25519 = String(properties?.custom?.Ed25519)
if(ticket_data){ if(ticket_data && typeof ticket_data === 'string'){
const ticketDataObj= JSON.parse(ticket_data) const ticketDataObj= JSON.parse(ticket_data)
const unix_timestamp = Number(ticketDataObj.exp)*1000 const unix_timestamp = Number(ticketDataObj.exp)*1000
if(unix_timestamp){ if(unix_timestamp){
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }) verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" })
} }
if(TEST_SECRET && HmacSha256){ if(TEST_SECRET && HmacSha256){
verify['verifyHmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256)
} }
if(TEST_PUBLIC_KEY_HEX&& Ed25519){ if(TEST_PUBLIC_KEY_HEX&& Ed25519){
verify["Ed25519"]=verifyDataEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX)
} }
} }
let returnData : Record<string, unknown> = {
success: true,
cc,bookmark,properties,verify
}
let returnData : Record<string, unknown> = {cc,bookmark,properties,verify}
return NextResponse.json(returnData); return NextResponse.json(returnData);
} catch (error) { } catch (error) {
console.error(error); console.error(error);
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message

View file

@ -0,0 +1,104 @@
// สร้าง 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 }
);
}
}

View file

@ -1,518 +0,0 @@
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<string, unknown>;
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",
});
}

View file

@ -1,73 +1,49 @@
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { DocumentTemplate, templateContentControls, setCustomDocProps, getDocProps, templateDynamicContents } from "@/lib/lib-tmpl"; import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
import { generateHmacSha256, verifyHmacSha256, signDataEd25519, verifyDataEd25519, sortObjectKeys } from '@/lib/lib-utils';
export const runtime = "nodejs"; export const runtime = "nodejs";
const DOCX_CONTENT_TYPE = const DOCX_CONTENT_TYPE =
"application/vnd.openxmlformats-officedocument.wordprocessingml.document"; "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้ // cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions) // ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
export async function POST(request: Request) { export async function POST(request: Request) {
try { try {
const formData = await request.formData(); const formData = await request.formData();
const template = formData.get("template"); const template = formData.get("template");
const dataFile = formData.get("data"); const dataFile = formData.get("data");
const dataJson = formData.get("dataJson"); const dataJson = formData.get("dataJson");
const TEST_SECRET = (typeof formData.get("secret") === "string") ? const TEST_SECRET = String(formData.get("secret")||"")
String(formData.get("secret")) : "super_secret"; const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"")
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 // ตรวจสอบ Template
// -----------------------------
if (!(template instanceof File)) { if (!(template instanceof File)) {
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: 'Missing DOCX file in field "template".', error: 'Missing DOCX file in field "template".',
}, },
{ status: 400 }, { status: 400 },
); );
} }
if ( if (template.type !== DOCX_CONTENT_TYPE &&
template.type !== DOCX_CONTENT_TYPE && !template.name.toLowerCase().endsWith(".docx") &&
!template.name.toLowerCase().endsWith(".docx") template.size > MAX_UPLOAD_BYTES
) { ) {
return NextResponse.json( return NextResponse.json(
{ {
success: false, error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.',
error: 'Field "template" must be a .docx file.',
}, },
{ status: 400 }, { status: 400 },
); );
} }
if (template.size > MAX_UPLOAD_BYTES) {
return NextResponse.json(
{
success: false,
error: 'Field "template" exceeds the 100MB size limit.',
},
{ status: 400 },
);
}
// -----------------------------
// อ่าน JSON // อ่าน JSON
// -----------------------------
let templateData: DocumentTemplate; let templateData: DocumentTemplate;
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500 // JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น) // (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
try { try {
@ -78,13 +54,11 @@ export async function POST(request: Request) {
if (dataFile.size > MAX_UPLOAD_BYTES) { if (dataFile.size > MAX_UPLOAD_BYTES) {
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: 'Field "data" exceeds the 100MB size limit.', error: 'Field "data" exceeds the 100MB size limit.',
}, },
{ status: 400 }, { status: 400 },
); );
} }
// กรณี upload data.json // กรณี upload data.json
const text = await dataFile.text(); const text = await dataFile.text();
@ -92,7 +66,6 @@ export async function POST(request: Request) {
} else { } else {
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: error:
'Missing JSON data. Send either "data" file or "dataJson" field.', 'Missing JSON data. Send either "data" file or "dataJson" field.',
}, },
@ -102,14 +75,12 @@ export async function POST(request: Request) {
} catch (error) { } catch (error) {
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error" error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error"
}).`, }).`,
}, },
{ status: 400 }, { status: 400 },
); );
} }
// JSON ต้องเป็น object // JSON ต้องเป็น object
if ( if (
templateData === null || templateData === null ||
@ -118,7 +89,6 @@ export async function POST(request: Request) {
) { ) {
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: "JSON data must be an object.", error: "JSON data must be an object.",
}, },
{ status: 400 }, { status: 400 },
@ -132,62 +102,18 @@ export async function POST(request: Request) {
let templateBuffer: Buffer = Buffer.from( let templateBuffer: Buffer = Buffer.from(
await template.arrayBuffer(), 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 กลับ // ส่ง DOCX กลับ
// ----------------------------- // -----------------------------
if(templateData?.info?.ticket_data)
return new NextResponse(output as BodyInit, { return new NextResponse(output as BodyInit, {
status: 200, status: 200,
headers: { headers: {
"Content-Type": DOCX_CONTENT_TYPE, "Content-Type": DOCX_CONTENT_TYPE,
"Content-Disposition": "Content-Disposition":
'attachment; filename="generated-document.docx"', 'attachment; filename="generated-document.docx"',
"Content-Length": String(output.length), "Content-Length": String(output.length),
}, },
}); });
@ -196,7 +122,7 @@ export async function POST(request: Request) {
return NextResponse.json( return NextResponse.json(
{ {
success: false,
error: error:
error instanceof Error error instanceof Error
? error.message ? error.message
@ -206,3 +132,4 @@ export async function POST(request: Request) {
); );
} }
} }

View file

@ -1,29 +0,0 @@
{
"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": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"
}

Binary file not shown.

Binary file not shown.

View file

@ -1,298 +0,0 @@
// ทดลอง: เปิด 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("<?xml")
? text
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\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(
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}"/>`,
);
}
// 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(/<w:documentProtection[^>]*\/>/)?.[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(/<w:sdtPr>[\s\S]*?<\/w:sdtPr>/g) ?? [];
const plain = sdtPrs.filter((p) => /<w:text\b/.test(p)).length;
const rich = sdtPrs.length - plain;
const tagged = sdtPrs.filter((p) => /<w:tag w:val="/.test(p)).length;
const permStarts = (docXml.match(/<w:permStart /g) ?? []).length;
const permEnds = (docXml.match(/<w:permEnd /g) ?? []).length;
const missingLock = sdtPrs.filter(
(p) => /<w:tag w:val="/.test(p) && !/<w:lock /.test(p),
).length;
console.log("-- verify (อ่านไฟล์กลับหลังเขียน) --");
console.log(`documentProtection : ${prot ?? "(ไม่พบ!)"} ${enforced ? "✓" : "✗ ไม่ถูกเปิดใช้"}`);
console.log(`docProps/custom.xml: ${customXml ? "มี ✓" : "ไม่พบ ✗"}`);
console.log(
"custom properties :",
Object.entries(propsBack)
.map(([k, v]) => `${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);

View file

@ -1,127 +0,0 @@
# 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` → `<w:documentProtection w:edit="readOnly" w:enforcement="1"/>`
- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่
- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป
- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `<w:permStart w:id="N" w:edGrp="everyone"/>` … `<w:permEnd w:id="N"/>`** — คือกลไก "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` เพิ่ม `<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+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 คง `<?xml ...?>` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้)
## extract บอก alignment รายช่อง — key `<tag>@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)**
- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม
- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `<tag>@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:jc w:val="thaiDistribute"/>` ใน `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)

Binary file not shown.

Binary file not shown.

View file

@ -1,43 +0,0 @@
@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--

101
app/tmpl/page.tsx Normal file
View file

@ -0,0 +1,101 @@
// 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<HTMLInputElement>)=>{
// console.log(e.target)
return true;
}
return (
<div style={{ padding: '2rem' }}>
<div className="p-8 max-w-md mx-auto space-y-8 bg-gray-50 min-h-screen">
<h1 className="text-xl font-bold text-center">ตัวอย่างการใช้ สร้างเอกสารจาก Template</h1>
<div>
ตัวอย่าง Frontend ส่งข้อมูลผ่าน &lt;form&gt;(POST) หรือ &lt;a&gt;(GET)
เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
ตัวอย่างหน้าแบบ <Link href='/tmpl/dl.html'>Plain HTML</Link>
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<form action="/docedit/api/template/download-docx" method="POST">
<h2 className="font-semibold text-blue-600 mb-2">Form POST</h2>
<DependentDropdown data={templateData} name1='tid' name2='fid'
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
<div className="mb-2"></div>
<div>
uid:<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john"/>
</div>
<div>
uname:<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe"/>
</div>
<button
type="submit"
style={{
padding: '10px 20px',
backgroundColor: '#0070f3',
color: '#fff',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
}}
>
ดาวน์โหลดเอกสาร MS Word
</button>
</form>
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<h2 className="font-semibold text-blue-600 mb-2">A Link Get(new)</h2>
<Link href="/api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</Link><br/>
<Link href="/api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</Link><br/>
<Link href="/api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</Link><br/>
<Link href="/api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</Link><br/>
<Link href="/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</Link><br/>
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
Template: <Link href="/tmpl/T_X.docx">T_X.docx</Link>, &nbsp;
<Link href="/tmpl/T12_X.docx">T12_X.docx</Link><br/>
Data: <Link href="tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</Link>, &nbsp;
<Link href="/tmpl/T_no_warning.json">T_no_warning.json</Link>, &nbsp;
<Link href="/tmpl/TS_P2_IN.json">TS_P2_IN.json</Link><br/>
<Link href="/tmpl/T12_b2_IN.json">T12_b2_IN.json</Link>, &nbsp;
<Link href="/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</Link>
</div>
<div className="p-4 border rounded-xl bg-white shadow-sm">
<h2 className="font-semibold text-blue-600 mb-2">Flow</h2>
<div className="text-xl font-bold text-center">
หน้านี้(เลือกไฟล์)<br/>
&darr; <br/>
download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)<br/>
&darr; <br/>
generate(เปิดเทมเพลต,สร้าง docx)<br/>
&darr; <br/>
ดาว์นโหลด docx เปิด MS Word อัตโนมัติ<br/>
&darr; <br/>
Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx<br/>
&darr; <br/>
Versioning(คุมเวอร์ชั่น)<br/>
&darr; <br/>
ecourt(อัปเดตฐานข้อมูลและไฟล์)<br/>
</div>
</div>
</div>
</div>
);
}

37
app/wordaddin/error.tsx Normal file
View file

@ -0,0 +1,37 @@
"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 (
<div className="flex min-h-screen flex-col gap-3 p-4">
<h2 className="font-display text-base font-semibold text-ink">task pane ทำงานผิดพลาด</h2>
<p className="text-sm text-ink-soft">
เกิดข้อผิดพลาดฝั่งหน้าเว็บ — แจ้งข้อความด้านล่าง (ถ่ายภาพหน้าจอนี้มาด้วย) แล้วกดลองใหม่
</p>
<pre
className="max-h-64 overflow-auto whitespace-pre-wrap rounded-lg border border-line bg-canvas p-3 text-xs text-error-ink"
aria-label="รายละเอียดข้อผิดพลาด"
>
{error.message}
{error.digest ? `\n[digest ${error.digest}]` : ""}
{error.stack ? `\n\n${error.stack}` : ""}
</pre>
<button
type="button"
className="focus-ring w-fit rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink hover:border-primary hover:text-primary"
onClick={reset}
>
ลองใหม่
</button>
</div>
);
}

View file

@ -0,0 +1,91 @@
'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<string>('');
const [subSelection, setSubSelection] = useState<string>('');
const handleMainChange = (e: ChangeEvent<HTMLSelectElement>) => {
const value = e.target.value;
setMainSelection(value);
setSubSelection('');
};
const handleSubChange = (e: ChangeEvent<HTMLSelectElement>) => {
setSubSelection(e.target.value);
};
useEffect(() => {
if (onSelectChange) {
onSelectChange(mainSelection, subSelection);
}
}, [mainSelection, subSelection, onSelectChange]);
const subOptions = mainSelection ? data[mainSelection] : [];
return (
<div className="space-y-4 w-full">
{/* Dropdown ที่ 1 */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label1}</label>
<select
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={mainSelection}
onChange={handleMainChange}
required
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm"
>
<option value="">{placeholder1}</option>
{Object.keys(data).map((key) => (
<option key={key} value={key}>{key}</option>
))}
</select>
</div>
{/* Dropdown ที่ 2 */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label2}</label>
<select
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={subSelection}
onChange={handleSubChange}
disabled={!mainSelection}
required
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm"
>
<option value="">
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
</option>
{subOptions.map((item) => (
<option key={item} value={item}>{item}</option>
))}
</select>
</div>
</div>
);
}

View file

@ -3,7 +3,7 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { FileUp, GitCommitHorizontal, LogOut } from "lucide-react"; import { FileUp, GitCommitHorizontal } from "lucide-react";
import { autoSaveService } from "@/wordaddin/services/autoSaveService"; import { autoSaveService } from "@/wordaddin/services/autoSaveService";
import * as authService from "@/wordaddin/services/authService"; import * as authService from "@/wordaddin/services/authService";
@ -23,22 +23,26 @@ import TemplateToolsPanel from "./TemplateToolsPanel";
import VersionHistoryPanel from "./VersionHistoryPanel"; import VersionHistoryPanel from "./VersionHistoryPanel";
/** /**
* Task pane หลัก — state machine ตาม Phase 5: * Task pane หลัก — state machine:
* checking → (ไม่มี token) signed-out * checking → (ไม่มี/ตรวจไม่ผ่าน ticket ในไฟล์) no-access
* → (มี token + ไม่ใช่ tracked document) untracked * → (ticket ผ่าน + ไม่ใช่ tracked document) untracked
* → (มี token + tracked document) tracking * → (ticket ผ่าน + tracked document) tracking
* พอร์ตจาก App.tsx ต้นฉบับ (D:\word-addin-poc\addin) — state/handlers คงเดิมทุกตัว * ไม่มี login ตั้งแต่ยกเลิก Keycloak (2026-10-27) — auth เข้าทางเดียวคือ ticket
* เปลี่ยนเฉพาะ: import path, JSX → Genesis/Tailwind, ถอด FluentProvider/makeStyles, * ของ 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,
* เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง) * เพิ่ม prop demo (โหมด ?addindemo ของ WordAddinGate — ตั้ง phase ตรง ไม่ detect จริง)
* *
* กติกา: ทุก async ต้อง catch — AuthRequiredError → กลับ signed-out อย่างเนียน (AC-9) * กติกา: ทุก async ต้อง catch — AuthRequiredError → หน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
* error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection * error อื่นแสดง MessageBar ไม่ปล่อย unhandled rejection
* *
* FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่รอ sign-in; FR-3 reminder * FR-17 — auto-save (local เท่านั้น) เริ่มตอน mount ไม่ผูกกับ phase; FR-3 reminder
* กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า * กลายเป็น flag `uncommittedSinceVersion` ที่ periodic tick ของ autoSaveService ตั้งค่า
*/ */
type Phase = "checking" | "signed-out" | "untracked" | "tracking"; type Phase = "checking" | "no-access" | "untracked" | "tracking";
/** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls /** แท็บของ taskpane — versioning คือทุกอย่างเดิม, template คือเครื่องมือจัดการ Content Controls
* (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */ * (Office.js ล้วน ไม่ยิง backend จึงใช้ได้ทั้ง untracked และ tracking) */
@ -108,8 +112,8 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
const [historyLoading, setHistoryLoading] = useState(false); const [historyLoading, setHistoryLoading] = useState(false);
const [historyError, setHistoryError] = useState<string | null>(null); const [historyError, setHistoryError] = useState<string | null>(null);
const [signingIn, setSigningIn] = useState(false); /** ข้อความบนหน้า no-access — ต่างกันตามสาเหตุ (ไม่มี token ในไฟล์ / token หมดอายุ) */
const [loginError, setLoginError] = useState<string | null>(null); const [noAccessNotice, setNoAccessNotice] = useState<string | null>(null);
const [starting, setStarting] = useState(false); const [starting, setStarting] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
@ -135,15 +139,17 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
/** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */ /** FR-3 — periodic tick เห็นการแก้ไขหลัง version ล่าสุด (แสดงเฉพาะตอน tracking) */
const [hasUncommitted, setHasUncommitted] = useState(false); const [hasUncommitted, setHasUncommitted] = useState(false);
/** 401 ที่ refresh ไม่ช่วย → กลับหน้า sign-in อย่างเนียน (AC-9) */ /** AuthRequiredError (ticket หมดอายุ/token ขอไม่ได้ หลัง renew-retry ของ apiClient)
* → หน้า no-access — ticket ต่ออายุไม่ได้ ผู้ใช้ต้องขอไฟล์ใหม่จาก e-Court
* จึงรีเซ็ต state ทั้งชุดกันใช้ของเก่าต่อ */
const handleAuthRequired = useCallback((error: unknown): boolean => { const handleAuthRequired = useCallback((error: unknown): boolean => {
if (error instanceof AuthRequiredError) { if (error instanceof AuthRequiredError) {
setPhase("signed-out"); setPhase("no-access");
setActiveTab("versioning"); setActiveTab("versioning");
setDocumentId(null); setDocumentId(null);
setDocumentTemplateId(null); setDocumentTemplateId(null);
setVersions([]); setVersions([]);
setLoginError("เซสชันหมดอายุ — กรุณา login ใหม่"); setNoAccessNotice("ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง");
return true; return true;
} }
return false; return false;
@ -166,35 +172,38 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
[handleAuthRequired], [handleAuthRequired],
); );
/** ตรวจตอนเปิด task pane (FR-1): token → SavedByAddIn → history /** ตรวจตอนเปิด task pane (FR-1): ticket ในไฟล์ → SavedByAddIn → history
* FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked) * FR-16 — อ่าน properties ครั้งเดียวทั้งชุดแล้ว derive tracked เอง เพราะไฟล์ร่าง (untracked)
* ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo) * ต้องได้อ่าน TemplateId ไปใช้ตอน Start Versioning ด้วย (ข้ามทั้งหมดในโหมด demo)
* e-Court pass-through — ยังไม่มี session แต่ไฟล์ stamp token/owner มา = ผ่านเข้าเลยไม่ต้องกด login */ * auth ทางเข้าเดียว: ticket_data + HmacSha256 ที่ e-Court stamp มา — signInWithTicket
* ตรวจ HMAC → exp → session/localStorage แล้วขอ token ตาม fid ให้ครบก่อนไหลต่อ
* (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป no-access) */
const detect = useCallback(async () => { const detect = useCallback(async () => {
setPhase("checking"); setPhase("checking");
setBootError(null); setBootError(null);
try { try {
let properties: metadataService.TrackedDocumentProperties | null = null; let properties: metadataService.TrackedDocumentProperties | null = null;
if (!(await authService.getAccessToken())) { try {
// e-Court pass-through — อ่านไฟล์ก่อนตัดสิน: ไฟล์ที่ stamp token/owner มา = ผ่านเลยไม่ต้องกด login properties = await metadataService.readTrackedProperties();
// (นอก Word เช่น browser ธรรมดา → อ่านไม่ได้ = ไป signed-out ตามเดิม) } catch {
try { properties = null;
properties = await metadataService.readTrackedProperties(); }
} catch { if (!properties) {
properties = null; setNoAccessNotice("อ่านข้อมูลเอกสารไม่สำเร็จ — ลองปิดแล้วเปิด task pane อีกครั้ง");
} setPhase("no-access");
if ( return;
!properties || }
!authService.signInFromDocument({ token: properties.ecourtToken, owner: properties.ecourtOwner }) const signIn = await authService.signInWithTicket({
) { ticketData: properties.ticketData,
setPhase("signed-out"); ticketHmac: properties.ticketHmac,
return; });
} if (!signIn.ok) {
setNoAccessNotice(signIn.message);
setPhase("no-access");
return;
} }
authService.startAutoRefresh();
setUsername(authService.getUsername() ?? null); setUsername(authService.getUsername() ?? null);
properties ??= await metadataService.readTrackedProperties();
const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID; const tracked = properties.savedByAddIn === metadataService.ADDIN_SIGNATURE_GUID;
const id = tracked ? properties.documentTrackingId : undefined; const id = tracked ? properties.documentTrackingId : undefined;
if (!id) { if (!id) {
@ -217,7 +226,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
void detect(); void detect();
}, [demo, detect]); }, [demo, detect]);
/** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่รอ sign-in (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี) /** FR-17 — เริ่ม auto-save ตั้งแต่ mount ไม่ผูกกับ phase (ข้ามใน demo กัน error banner จาก Word ที่ไม่มี)
* ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */ * ผูก listener ให้ React state: error → banner, dirty→save → flag FR-3 */
useEffect(() => { useEffect(() => {
autoSaveService.onError = (error) => autoSaveService.onError = (error) =>
@ -235,34 +244,6 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
}; };
}, [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 อะไร) */ /** FR-18 [บันทึก] — local save เดี่ยวผ่าน lock ของ autoSaveService (ไม่ stamp metadata อะไร) */
const handleSaveOnly = async () => { const handleSaveOnly = async () => {
setSaveBusy(true); setSaveBusy(true);
@ -408,18 +389,15 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
<div className="flex min-h-screen flex-col gap-2 p-4"> <div className="flex min-h-screen flex-col gap-2 p-4">
<div className="flex items-center justify-between gap-2 border-b border-line pb-2"> <div className="flex items-center justify-between gap-2 border-b border-line pb-2">
<span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span> <span className="font-display text-sm font-semibold leading-tight text-ink">e-Court Documents Versioning</span>
{phase !== "signed-out" && phase !== "checking" && username ? ( {phase !== "no-access" && phase !== "checking" && username ? (
/* จอแคบ: username ย้ายไป title/aria-label — ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap 3-4 บรรทัด */ /* ไม่มีปุ่มออกจากระบบ (ไม่มี login ให้ออก) — แสดงชื่อเจ้าของไฟล์จาก pass-through เฉย ๆ
<button จอแคบ: ตัดด้วย truncate + เต็ม ๆ ใน title ไม่กินพื้นที่แถบหัวจนตัวชื่อแอป wrap */
type="button" <span
className="focus-ring inline-flex shrink-0 items-center gap-1 rounded-md px-2 py-1.5 text-xs text-ink-soft transition hover:text-primary" className="shrink-0 max-w-40 truncate rounded-md bg-canvas px-2 py-1.5 text-xs text-ink-soft"
onClick={handleSignOut} title={username}
aria-label={`ออกจากระบบ (${username})`}
title={`${username} — ออกจากระบบ`}
> >
<LogOut className="size-3.5" aria-hidden /> {username}
ออกจากระบบ </span>
</button>
) : null} ) : null}
</div> </div>
@ -430,7 +408,7 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
<MessageBar intent="warning">{autoSaveError}</MessageBar> <MessageBar intent="warning">{autoSaveError}</MessageBar>
) : null} ) : null}
{/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/signed-out ยังไม่มีอะไรให้จัดการ) {/* toggle แท็บ — แสดงเฉพาะ phase ที่เข้าถึงเอกสารแล้ว (checking/no-access ยังไม่มีอะไรให้จัดการ)
ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย ปิดทั้งสองปุ่มระหว่าง flow ที่เขียนเอกสาร: saving/starting ของ versioning (กัน progress หาย
กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก กลาง flow — handler อยู่ที่ component นี้ flow ยังรันจนจบ แค่หน้าตา), saveBusy ของบันทึก
local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template local/ปิดเอกสาร และ templateBusy ที่ panel รายงานผ่าน onBusyChange — กันเครื่องมือ template
@ -481,21 +459,13 @@ const TaskpaneApp: React.FC<{ demo?: "untracked" | "tracking" }> = ({ demo }) =>
</div> </div>
) : null} ) : null}
{phase === "signed-out" ? ( {phase === "no-access" ? (
/* ไม่มี login ให้กด — บอกสาเหตุและจบที่นั้น (ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) */
<div className="flex flex-col gap-2 pt-4"> <div className="flex flex-col gap-2 pt-4">
<p className="text-base text-ink">เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning</p> <p className="text-base text-ink">เอกสารนี้ยังใช้งาน add-in ไม่ได้</p>
<p className="text-sm text-ink-soft"> <p className="text-sm text-ink-soft">
ใช้บัญชีของ realm <code>word-versioning</code> — หน้าต่าง login จะเปิดแยกต่างหาก {noAccessNotice ?? "ไม่พบ ticket ของ e-Court ในไฟล์ — ขณะนี้ add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"}
</p> </p>
<button
type="button"
className={`${BTN_PRIMARY} mt-2 w-full justify-center`}
disabled={signingIn}
onClick={() => void handleSignIn()}
>
{signingIn ? "กำลังเข้าสู่ระบบ…" : "เข้าสู่ระบบ"}
</button>
{loginError ? <MessageBar intent="warning">{loginError}</MessageBar> : null}
</div> </div>
) : null} ) : null}

View file

@ -135,7 +135,7 @@ export function VersionHistoryPanel({
</p> </p>
) : null} ) : null}
<p className="mt-0.5 text-xs text-ink-soft"> <p className="mt-0.5 text-xs text-ink-soft">
{formatTimestamp(version.createdAt)} · โดย {version.createdByUserId} {formatTimestamp(version.createdAt)} · โดย {version.createdByUserName ?? version.createdByUserId}
</p> </p>
</li> </li>
); );

View file

@ -23,8 +23,20 @@ ENV NEXT_TELEMETRY_DISABLED=1
# db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data — # db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data —
# ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run # ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run
ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder
ENV MINIO_ENDPOINT=127.0.0.1
ENV MINIO_PORT=19000
ENV MINIO_USE_SSL=false
ENV MINIO_ACCESS_KEY=minioadmin
ENV MINIO_SECRET_KEY=minioadmin
ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54
ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e
ENV DOCVER_TOKEN_TTL_SECONDS=604800
ENV MINIO_BUCKET=docver
ENV DOCVER_LINK_TTL_SECONDS=900
RUN npm run build RUN npm run build
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ─── # ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
FROM node:24-alpine AS runner FROM node:24-alpine AS runner
WORKDIR /app WORKDIR /app

222
docs/PLAN-ADDIN-AUTH.md Normal file
View file

@ -0,0 +1,222 @@
# 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 = <hex 64 ตัว lowercase> = 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}` | **ทางใหม่** — `verifyAddinTicketHmac` (HMAC+parse ไม่ตรวจ exp) → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` |
| ไม่มี `x-api-key` + body ไม่ใช่ object / มี field อื่นปน / field ไม่ใช่ string | 400 (รูปผิดก่อนถึงการ verify) |
| HMAC ผิด / parse ticket ไม่ผ่าน | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) |
| ticket **หมดอายุ** แต่ HMAC ถูก | แก้ 2026-10-28 (requirement ขั้น 7): ตรวจ DB ว่า `fid` นี้เคยมี version โดย `uid` เดียวกัน (`hasVersionByUser` ของ `lib/docver-store.ts`) → เคย = ออก token ให้ต่อ · ไม่เคย (หรือ DB พัง = fail closed) = 401 ข้อความเดียวกัน |
| ไม่มี `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<string> // 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 ใหม่):
· ขอ token จาก /token เลย — **server เป็นคนตัดสิน exp** (แก้ 2026-10-28: ticket หมดอายุ
แต่ fid นี้เคยมี version โดย uid เดียวกัน = ออกให้ → คนที่กลับมาทำเอกสารเดิม
ผ่านได้แม้ session หาย/เคลียร์ · 401 + ticket หมดอายุ → no-access "ticket หมดอายุ")
· สำเร็จ → เคลียร์ session เก่า → เก็บ session ใหม่ {userId: uid, …}
5. uid ตรงกัน (คนเดิม):
· tokens[fid] มีและยังไม่หมดอายุ (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ยิง /token
· tokens[fid] มีแต่ token หมดอายุ → ยิง renew เสมอ (ticket ยังไม่หมด = ออกให้เสมอ ·
หมดแล้ว = ออกเฉพาะ "เคยทำงาน" ตามข้อ 4) → fail = เก็บ token เก่าไว้
(ถ้าเน่าจริง apiClient จะ 401 → renew 1 ครั้ง → ยัง fail = no-access ตอนใช้งาน)
· tokens[fid] ไม่มี (เอกสารใหม่) → ขอ 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 ใหม่ — และหลังแก้ 2026-10-28 ขอใหม่ได้**แม้ ticket หมดอายุ**
ตราบใดที่เอกสารนี้เคยมี version โดย uid เดียวกัน: server ออกให้จากหลักฐานใน DB
ไม่ต้องพึ่ง token เก่าในเครื่องอีกต่อไป — ข้อจำกัดเหลือเฉพาะเอกสารที่เคยเปิดแต่
**ยังไม่เคยสร้าง version** ซึ่งไม่มีร่องรอยฝั่ง server ต้องขอไฟล์ใหม่จาก e-Court)
- **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 <file.docx> [options]`:
- option: `--fid --uid --uname --tid --exp <unix|+seconds> --secret <hmac-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`**
ไม่งั้นเขียนไฟล์ใหม่ `<name>.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 อดีตของเอกสารที่ไม่เคยสร้าง version → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ →
ไม่ยิง /token ซ้ำ (ใช้ token เดิม) · **ticket หมดอายุ + เอกสารเคยสร้าง version ≥1 โดย uid เดียวกัน →
เปิดใหม่ใช้ต่อได้แม้เคลียร์ localStorage `wordaddin-auth` ก่อนเปิด (server ออก token ใหม่ — แก้ 2026-10-28)** ·
ตั้ง `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 ให้)

View file

@ -1,7 +1,8 @@
# PLAN-DOCVER — Document Versioning API (stand-alone) # PLAN-DOCVER — Document Versioning API (stand-alone)
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement > วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin) > รอบแรก (API ก่อน) · **ปลด mock wordaddin แล้ว 2026-10-27** — `versioningService`
> ยิงกลุ่มนี้จริงผ่าน Bearer (mock เหลือเฉพาะ `?addindemo`)
## 0. บริบท/เป้าหมาย ## 0. บริบท/เป้าหมาย
@ -41,6 +42,14 @@ middleware เหมือน `api/template`)
auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed · auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed ·
ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ ไม่ใส่/ผิด = 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): JSON body (allowlist — key อื่น = 400 กัน typo):
| field | type | หมายเหตุ | | field | type | หมายเหตุ |
@ -71,7 +80,7 @@ multipart/form-data:
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | | `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) | | `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` จาก content controls ทุกชนิด (add-in ใช้ `Document.getContentControls()` บน WordApi 1.5+; host ต่ำกว่าเห็นเฉพาะ rich text — ดู `docs/addin-ui-flow.md` §9 ข้อ 7) (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) |
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
@ -217,6 +226,6 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET
## 8. Out of scope รอบนี้ ## 8. Out of scope รอบนี้
- ไม่แต้ `wordaddin/` หรือ client ใด ๆ (สลับ mock ทีหลังผ่าน `NEXT_PUBLIC_ADDIN_API_BASE_URL`) - ~~ไม่แต้ `wordaddin/` หรือ client ใด ๆ~~ (ปลด mock แล้ว 2026-10-27 — `wordaddin/services/versioningService.ts` ยิงกลุ่มนี้จริง same-origin ผ่าน Bearer; ดู `docs/WORDADDIN-DEV.md` หัว "การรันกับ backend จริง")
- ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency - ไม่มีหน้า UI ดูรายการเอกสาร/version ของกลุ่มนี้ · ไม่มี GC object กำพร้า · ไม่มี idempotency
บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ) บน `clientVersionId` · ไม่มีการแปลง docx→pdf ฝั่ง server (Office.js ของ add-in ทำ)

View file

@ -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) **ทางเข้า:** เปิด `/docedit` เปล่า ๆ จะ redirect มาที่ taskpane ทันที (ไม่ผ่าน login — คำขอผู้ใช้ 2026-10-14) ยกเว้นลิงก์ e-Court `/docedit/?fileUrl=&fileId=&typeId=` ที่ยังส่งต่อให้ `/note` ตามสัญญา N8 · manifest ชี้ `/docedit/wordaddin` ตรง ๆ (ไม่ว่าผ่าน root เพื่อลด hop ใน webview)
สถานะช่วงนี้: **auth และ API เป็น mock** (login ทันที + version store เก็บ metadata ค้างข้าม session ใน localStorage ของ taskpane เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ — สลับกลับของจริงด้วยการลบ `wordaddin-mock-versions` หรือเคลียร์ storage ของ origin) รอ backend/auth จริง — ดูหัว "การสลับกลับของจริง" ด้านล่าง สถานะช่วงนี้: **ไม่มี 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 จริง")
## ไฟล์หลัก ## ไฟล์หลัก
@ -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/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 เท่านั้น) | | `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/signed-out/untracked/tracking` + ทุก handler | | `components/wordaddin/TaskpaneApp.tsx` | state machine `checking/no-access/untracked/tracking` + ทุก handler |
| `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `authService`/`versioningService` เป็น mock | | `wordaddin/services/*` | ชั้น Office.js พอร์ตดิบจากต้นฉบับ + `versioningService` เป็น mock (`authService` + `ticketService` = ticket auth จริง ไม่มี login — PLAN-ADDIN-AUTH.md) |
| `public/wordaddin/manifest.template.xml` | template ของ manifest (ค่า default dev `https://localhost:3000`) — serve ผ่าน route ด้านบนเท่านั้น ไม่ได้ใช้ตรง ๆ | | `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 ล้วน) | | `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_*` (ตารางด้านล่าง) | | `wordaddin/config.ts` | env `NEXT_PUBLIC_ADDIN_*` (ตารางด้านล่าง) |
@ -22,8 +22,9 @@ Taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตม
| ตัวแปร | default | ใช้เมื่อไหร่ | | ตัวแปร | default | ใช้เมื่อไหร่ |
|---|---|---| |---|---|---|
| `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) | | `NEXT_PUBLIC_ADDIN_API_BASE_URL` | `""` (ว่าง) | ปลด mock ต่อ backend จริง (ต้องเป็น HTTPS) — ไม่เกี่ยวกับการขอ token (ยิง same-origin `/docedit/api/documents/token` เสมอ) |
| `NEXT_PUBLIC_ADDIN_MOCK_USERNAME` | `"tester01"` | ชื่อผู้ใช้ของ mock — แสดง header + เป็น `createdByUserId` | | `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 ต้องตั้งค่าจริงทั้งสองฝั่ง** |
## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word) ## รัน dev ด้วย HTTPS (จำเป็นสำหรับ sideload ใน Word)
@ -32,7 +33,27 @@ manifest ต้องชี้ https — 2 ทางเลือก:
1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง) 1. **self-signed ของ Next (ง่ายสุด):** `npm run dev:https` → https://localhost:3000 — Word อาจเตือน certificate ครั้งแรก (เปิด URL ใน browser กดผ่านก่อน 1 ครั้ง)
2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert` 2. **ใบรับรอง trusted (แนะนำ ไม่มี warning):** รัน `npx office-addin-dev-certs install` ครั้งเดียว แล้ว copy `%USERPROFILE%\.office-addin-dev-certs\localhost.crt` + `localhost.key` มาที่ `certs/` (gitignore แล้ว) แล้วใช้ `npm run dev:https:cert`
## Sideload ใน Word Desktop (Windows) ## ติดตั้ง (sideload) ด้วยสคริปต์ — Windows + macOS
`scripts/install-wordaddin.*` (มี 3 รูปแบบ กลไกเดียวกัน) โหลด manifest จาก server แล้วจดทะเบียนให้ Word Desktop เห็น add-in ในคำสั่งเดียว:
```bash
# ใน repo (มี Node)
npm run addin:install
# ส่งให้เครื่องทดสอบที่ไม่มี Node/repo — ส่งไฟล์เดี่ยวได้เลย
./install-wordaddin.sh # macOS (bash + curl ของเครื่อง)
powershell -ExecutionPolicy Bypass -File install-wordaddin.ps1 # Windows
node install-wordaddin.mjs # ใช้ Node ก็ได้ทั้งสอง OS
```
- **default โหลดจาก server ทดสอบ** `https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml` — เครื่องทดสอบไม่ต้องรันอะไรเพิ่ม · จะชี้ dev ให้ `--url https://localhost:3000/docedit/wordaddin/manifest.xml` (ต้อง `npm run dev:https` ไว้ก่อน)
- **Windows:** เก็บ manifest ไว้ `%USERPROFILE%\Documents\wordaddin-manifest\` แล้วเขียน registry `HKCU\Software\Microsoft\Office\16.0\Wef\Developer` (กลไกเดียวกับ `office-addin-dev-settings`) — ไม่ต้องคลิก Trust Center
- **macOS:** วางไฟล์ manifest เป็น `<GUID>.manifest.xml` ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/`
- หลังรัน **restart Word** แล้วเปิดได้ที่ **Insert > My Add-ins > SHARED FOLDER** (Windows) / **Insert > My Add-ins** (macOS)
- **ถอด:** `--remove` ทุกไฟล์ (`npm run addin:install -- --remove` / `./install-wordaddin.sh --remove` / `-Remove` ของ .ps1 — ลบ registry value / ไฟล์ใน wef · ถอดแบบ manual: `reg delete "HKCU\Software\Microsoft\Office\16.0\Wef\Developer" /v <GUID> /f` หรือลบไฟล์ในโฟลเดอร์ wef เอง) · ⚠️ ไฟล์ .ps1 ต้องมี **BOM** ติดตัวมา (PowerShell 5.1 อ่าน UTF-8 ไร้ BOM เป็น ANSI แล้วข้อความไทยพัง) — แก้ไฟล์แล้วอย่าลืมเซฟแบบ UTF-8 with BOM
- รันซ้ำได้ (idempotent) — โหลด manifest ใหม่ทุกครั้งจึงได้ URL ของ environment ปัจจุบันเสมอ
## Sideload แบบ manual (Windows)
เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย): เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
@ -48,25 +69,49 @@ manifest ต้องชี้ https — 2 ทางเลือก:
Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี Deploy จริง (intranet): **ไม่ต้อง rewrite อะไรแล้ว** — manifest serve ผ่าน `app/wordaddin/manifest.xml/route.ts` ซึ่งเขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์อัตโนมัติ ดาวน์โหลด `https://<โฮสต์>/docedit/wordaddin/manifest.xml` แล้วใช้ sideload ได้ทันทีทุก environment (dev/UAT/production) · proxy ฝั่งหน้าควรส่ง `X-Forwarded-Host`/`X-Forwarded-Proto` ตามมาตรฐาน — ถ้าไม่ส่ง route ก็ยังใช้ Host ของ request และบังคับ https ให้โฮสต์ที่ไม่ใช่ localhost อยู่ดี
### macOS (manual)
Word for Mac ไม่มี Trust Center แบบ Windows — สแกน add-in จากโฟลเดอร์ของตัวเอง:
1. ดาวน์โหลด manifest จาก environment นั้น: `https://<โฮสต์>/docedit/wordaddin/manifest.xml`
2. วางไฟล์ใน `~/Library/Containers/com.microsoft.Word/Data/Documents/wef/` (สร้างโฟลเดอร์ได้ถ้ายังไม่มี) — ชื่อไฟล์เป็นอะไรก็ได้ ขอแค่ลงท้าย `.xml` (สคริปต์ด้านบนตั้งชื่อ `<GUID>.manifest.xml` ให้เอง)
3. restart Word แล้วเปิดได้ที่ **Insert > My Add-ins**
รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16) รุ่น Office ที่รองรับ: manifest กำหนด WordApi **1.3** เป็นค่าต่ำสุด (customXmlParts คือ API สูงสุดที่ใช้ประจำ) — Microsoft 365 / Office 2021+ ใช้ได้ครบทุกฟีเจอร์ · **Office 2019 โหลดได้ แต่ปุ่ม "…และปิดเอกสาร" ถูกซ่อนอัตโนมัติ** เพราะ `document.close()` ต้องการ WordApi 1.4/1.5 (Microsoft 365 เท่านั้น) — `canCloseDocument()` เช็ค `isSetSupported` ตอน runtime แล้วซ่อน พฤติกรรมเดียวกับ Word on the web · Office 2016 (WordApi 1.1) โหลดไม่ขึ้น (เดิมเคยตั้ง 1.5 ทำให้ Office 2019 โหลดไม่ขึ้นด้วย — แก้ 2026-10-16)
## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word) ## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word)
- **`/docedit/wordaddin`** — เส้นทางจริง: Office.js โหลดได้แม่นนอก host (host เป็น null) → เห็น `signed-out` → กด Sign in (mock ผ่านทันที) → detect พังที่ `Word.run` → `bootError` + ปุ่มลองใหม่ (ตรวจ state machine/handler ได้ครบ) - **`/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 + sign in mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected - **`/docedit/wordaddin?addindemo=untracked`** / **`?addindemo=tracking`** — dev-only: ข้าม Office.js + ตั้ง session mock ทันที + ตั้ง phase ตามค่า (tracking มี history ตัวอย่าง 3 รายการ) — ทุกปุ่มกดได้ แต่ flow ที่แตะ Word จริง (Start Versioning / Save & Version / บันทึก) จะจบที่ error ตาม expected
- หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง - หมายเหตุ: ใน browser ปกติ (ไม่ใช่ demo) banner "บันทึกอัตโนมัติไม่สำเร็จ" จะโผล่หลัง ~30 วิ เพราะ auto-save (FR-17) พยายาม save ทุก 30 วิแต่ไม่มี Word — ถือว่าถูกต้อง
## ทดสอบ e-Court pass-through (ข้าม login) ## ทดสอบ e-Court ticket (auth ทางเข้าเดียว)
ไฟล์ที่ 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()` ใช้ต่อ) ไฟล์ที่ 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` แยกข้อความตามสาเหตุ
ทดสอบเองใน Word โดยไม่ต้องรอ e-Court: เปิดไฟล์ → **File → Info → Properties → Advanced Properties → แท็บ Custom** → เพิ่ม property ชื่อ `token` (ค่า JWT ตัวอย่าง `eyJ...` — payload มี `uid` ก็แสดงเป็นชื่อผู้ใช้ได้) และ/หรือ `owner` (เช่น `u1001`) → เปิด task pane ใหม่ → ต้องเข้าเฟส untracked/tracking ทันทีไม่ผ่านหน้า login ทดสอบเองใน Word โดยไม่ต้องรอ e-Court — สร้างไฟล์ทดสอบด้วยสคริปต์ (คำนวณ HMAC ตรงกับตัว stamp จริงของกลุ่ม template):
## การสลับกลับของจริง (วันหน้า) ```bash
# สร้าง <file>.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
```
- **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 แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด**เอกสารที่ยังไม่เคยสร้าง version** → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำหลัง ticket หมดอายุ → ยังใช้ได้ตาม requirement ขั้น 7: add-in ยิงขอ token ใหม่แล้ว server ตรวจว่า fid นี้เคยมี version โดย uid เดียวกัน → ออกให้ต่อ (แก้ 2026-10-28 — ใช้ได้แม้ token เก่าหมดอายุหรือ localStorage `wordaddin-auth` โดนเคลียร์ เพราะไม่พึ่ง cache ในเครื่องอีกต่อไป · ข้อจำกัด: เอกสารที่เคยเปิดแต่ยังไม่เคยสร้าง version ไม่มีหลักฐานฝั่ง server ต้องขอไฟล์ใหม่จาก e-Court)
- **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/` หมายเหตุ: การขอ 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/`)
## Docker ## Docker

View file

@ -10,32 +10,33 @@
Task pane ทั้งหมดเป็น state machine เดียว **ไม่มี routing** — component เดียว (`App`) สลับหน้าตาตาม `phase`: 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` ตอน sign out / AuthRequiredError กัน surprise หลัง login ใหม่ · banner auto-save error (FR-17) ยกไว้เหนือ toggle ให้เห็นทั้งสองแท็บ ตั้งแต่ 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`)
``` ```
checking ──(ไม่มี token)──────────────→ signed-out checking ──(ไม่มี ticket / HMAC ไม่ตรง / ticket หมดอายุ / ขอ token ไม่สำเร็จ)──→ no-access
│ │
├─(มี token + ไม่ใช่ tracked doc)──→ untracked ├─(ticket ผ่าน + ไม่ใช่ tracked doc)──→ untracked
└─(มี token + tracked doc)────────→ tracking └─(ticket ผ่าน + tracked doc)────────→ tracking
``` ```
การเปลี่ยน phase อื่นๆ: การเปลี่ยน phase อื่นๆ:
- `signed-out` → (sign in สำเร็จ) → `checking` → ตัดสินใหม่ตามไฟล์ปัจจุบัน
- `untracked` → (Start Versioning สำเร็จ) → `tracking` - `untracked` → (Start Versioning สำเร็จ) → `tracking`
- ทุก phase → (401 ที่ refresh token ไม่ช่วย / กดออกจากระบบ) → `signed-out` - ทุก phase → (ไม่มี ticket ตอน detect ใหม่ / 401 หลังพยายาม renew จาก ticket แล้ว = ticket หมดอายุไม่มีทางต่ออายุ) → `no-access`
- ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ) · กลไกเดียวที่วิ่งกลับมา `checking` คือ sign-in สำเร็จ - ปุ่ม "ลองใหม่" มีจริงแค่ 2 จุด: `bootError` ใน `checking` และ history error ใน `VersionHistoryPanel` — `untracked`/`tracking` **ไม่มี**ปุ่มลองใหม่แยกต่างหาก (เริ่ม flow ใหม่ด้วยการกดปุ่ม flow ซ้ำ)
State ที่ UI ถือนอกเหนือจาก phase: State ที่ UI ถือนอกเหนือจาก phase:
| State | ความหมาย | | State | ความหมาย |
|---|---| |---|---|
| `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 | | `activeTab` | แท็บที่เปิดอยู่ `versioning` (default) / `template` — ดูหัวข้อ 10 |
| `username` | ชื่อผู้ใช้จาก token (แสดงใน header) | | `username` | ชื่อจาก `uname` ของ ticket — แสดงเป็น chip เฉย ๆ ใน header |
| `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) | | `documentId` | tracking id ของเอกสาร (มีเฉพาะ `tracking`) |
| `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) | | `documentTemplateId` | template ต้นทางของ**ไฟล์ร่าง** (มีเฉพาะ `untracked` ที่เป็นไฟล์ร่าง — FR-16) |
| `versions` + `historyLoading` + `historyError` | version history ของ `documentId` | | `versions` + `historyLoading` + `historyError` | version history ของ `documentId` |
| `signingIn`, `loginError` | สถานะหน้า sign-in | | `noAccessNotice` | ข้อความบนหน้า `no-access` — ต่างกันตามสาเหตุ (ไม่มี ticket ในไฟล์ / HMAC ไม่ตรง = ไฟล์ถูกแก้ / ticket หมดอายุ / ขอ token ไม่สำเร็จ) |
| `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version | | `starting`, `saving`, `progressMessage` | busy + ข้อความ progress ของ flow Start Versioning / Save & Version |
| `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน | | `confirmOpen`, `startConfirmOpen` | เปิด dialog ยืนยันของ flow ไหน |
| `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) | | `flowError`, `flowWarning`, `flowSuccess` | MessageBar 3 แบบของ flow หลัก (แสดงเฉพาะ `tracking`) |
@ -53,21 +54,22 @@ State ที่ UI ถือนอกเหนือจาก phase:
### Header (ทุก phase) ### Header (ทุก phase)
- ชื่อแอป: **e-Court Documents Versioning** (semibold) - ชื่อแอป: **e-Court Documents Versioning** (semibold)
- ฝั่งขวา (เมื่อ login แล้วและไม่ใช่ `checking`/`signed-out`): ปุ่มเล็ก icon + `ออกจากระบบ` — username อยู่ใน `title`/`aria-label` (จอแคบ ไม่กินพื้นที่แถบหัว) — กดแล้ว sign out + กลับ `signed-out` - ฝั่งขวา (เมื่อผ่าน ticket auth แล้วและไม่ใช่ `no-access`/`checking`): chip เล็กพื้น `canvas` แสดง `username` เฉย ๆ — ไม่ใช่ปุ่ม (ไม่มี login จึงไม่มีออกจากระบบ) · จอแคบตัดด้วย truncate ชื่อเต็มอยู่ใน `title`
### `checking` ### `checking`
- `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง - `Spinner "กำลังตรวจสอบเอกสาร…"` ตรงกลาง
- ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่) - ถ้า detect พัง (`bootError`): MessageBar **error** + ปุ่ม `ลองใหม่` ข้างใน bar (รัน detect ใหม่)
### `signed-out` ### `no-access`
- ข้อความ: "เข้าสู่ระบบเพื่อเริ่มใช้งาน versioning" - ข้อความ: "เอกสารนี้ยังใช้งาน add-in ไม่ได้"
- คำอธิบายจางๆ: "ใช้บัญชีของ realm `word-versioning` — หน้าต่าง login จะเปิดแยกต่างหาก" - คำอธิบายจางๆ (`noAccessNotice` ตามสาเหตุ):
- ปุ่ม primary `เข้าสู่ระบบ` → ระหว่างรันเปลี่ยนเป็น `กำลังเข้าสู่ระบบ…` + disable - ไม่มี ticket ในไฟล์: "ไม่พบ ticket ของ e-Court ในไฟล์ — add-in ใช้ได้กับเอกสารที่ส่งมาจาก e-Court เท่านั้น"
- ถ้า login พัง: MessageBar **warning** (`loginError`) — เช่น "เซสชันหมดอายุ — กรุณา login ใหม่" ตอนโดน 401 กลาง flow - ticket รูปผิด / HMAC ไม่ตรง: "ตรวจสอบ ticket ไม่ผ่าน — ไฟล์ถูกแก้ไขหรือเสียหาย"
- ticket หมดอายุ (ตอนเปิด หรือโดน 401 กลาง flow หลังพยายาม renew): "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง"
> ใน project จริง login เปิดหน้าต่างแยกผ่าน Office Dialog API (ห้าม redirect iframe ไป Keycloak ตรงๆ) — ถ้าโปรเจกต์ใหม่ยังไม่มี auth ให้ mock `login()` คืน `{ ok: true }` ไปก่อน - ขอ token ไม่สำเร็จ (server ลง/network): "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง"
- **ไม่มีปุ่มใด ๆ** — ไม่มี login ให้กด (ยกเลิก Keycloak 2026-10-27; ขั้นตอนสำหรับเอกสารทั่วไปจะมีในอนาคต) ผู้ใช้ได้ไฟล์ใหม่จาก e-Court แล้วเปิดใหม่เท่านั้น
### `untracked` ### `untracked`
@ -162,10 +164,10 @@ Dialog เดียวใช้ 2 flow (ปรับข้อความด้
ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout) ข้อความ flow (`flowSuccess`/`flowWarning`/`flowError`) ถูกเคลียร์เป็น null ทุกครั้งที่**เริ่ม** flow ใหม่ (Start Versioning / Save & Version / ดาวน์โหลด — ไม่ใช่ตอนจบ ไม่มี timeout)
### Detect (FR-1) — ตอนเปิด task pane / หลัง login / กดลองใหม่ ### Detect (FR-1) — ตอนเปิด task pane / กดลองใหม่
1. ตรวจ token — ไม่มี → `signed-out` 1. อ่าน custom properties ครั้งเดียวทั้งชุด: `ticket_data`, `HmacSha256`, `SavedByAddIn`, `DocumentTrackingId`, `LastVersionId`, `LastVersionTimestamp`, `TemplateId`
2. อ่าน custom properties ครั้งเดียวทั้งชุด: `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` แล้วไปต่อ
3. `tracked = (SavedByAddIn === GUID ของ add-in)`: 3. `tracked = (SavedByAddIn === GUID ของ add-in)`:
- tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history - tracked **และมี** `DocumentTrackingId` → `tracking` + โหลด history
- ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้) - ไม่ track หรือ track แต่ id หาย → `untracked` (เก็บ `TemplateId` ไว้ใช้ตอน Start Versioning — ไฟล์ร่างอยู่กลุ่มนี้)
@ -200,7 +202,7 @@ dialog ยืนยัน → progress ทั้ง 7 ขั้นลำดั
### Auto-save (FR-17) ### Auto-save (FR-17)
- เริ่มตั้งแต่ mount **ไม่รอ sign-in** - เริ่มตั้งแต่ mount **ไม่ผูกกับ phase** (detect ยังไม่จบก็รันได้)
- periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม - periodic ทุก 30 วิ: ไฟล์ dirty → save + ตั้ง flag `hasUncommitted`; สะอาด → ข้าม
- save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar - save ล้ม → `autoSaveError` (warning bar); สำเร็จครั้งถัดไป → เคลียร์ bar
- ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture) - ทุก flow versioning ต้อง **suspend** auto-save ตอนเริ่มและ **resume** ตอนจบ (กัน save แทรกกลาง stamp/capture)
@ -251,17 +253,17 @@ interface VersionRecord {
response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น response: `{ url: string, expiresAt: string }` — UI เปิด `url` ในเบราว์เซอร์เครื่องผู้ใช้เท่านั้น
### Auth — OIDC PKCE ผ่าน Office Dialog API ### Auth — e-Court ticket (ไม่มี login)
`displayDialogAsync` เปิดหน้า login แยก → `messageParent` ส่งผลกลับ task pane · token เก็บ **ใน memory เท่านั้น** (ห้าม localStorage) · refresh token auto-refresh เงียบๆ จน 401 → สถานะ `AuthRequiredError` · mock ช่วงรอ: `login()` คืน `{ ok: true }`, `getAccessToken()` คืน token ปลอม, `getUsername()` คืนชื่อ test ยกเลิก 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)
> **หมายเหตุสถานะปัจจุบัน (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` > **หมายเหตุสถานะปัจจุบัน:** 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`
--- ---
## 8. กติกา error ที่ UI ต้องรองรับ ## 8. กติกา error ที่ UI ต้องรองรับ
1. **`AuthRequiredError` (401 ที่ refresh ไม่ช่วย)** — ทุก flow ต้อง catch แล้ว: กลับ `signed-out` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `loginError` = "เซสชันหมดอายุ — กรุณา login ใหม่" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase** 1. **`AuthRequiredError` (ไม่มี token / 401 หลัง renew-retry)** — ทุก flow ต้อง catch แล้ว: กลับ `no-access` + เคลียร์ `documentId`/`documentTemplateId`/`versions` + ตั้ง `noAccessNotice` = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง" — **ไม่ throw ต่อ ไม่โชว์ error ทับ phase**
2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection 2. error อื่น → MessageBar ณ จุดที่เกิด (login/history/flow/download แยกกัน) — ห้ามปล่อย unhandled rejection
3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว 3. แยก error ของ "ตัว version" ออกจาก "ปิดเอกสารไม่สำเร็จ" — อันหลังเป็น warning เพราะข้อมูลปลอดภัยแล้ว
4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow 4. PDF เป็น best-effort เสมอ — ล้มแค่เตือน ไม่ fail flow
@ -276,7 +278,7 @@ response: `{ url: string, expiresAt: string }` — UI เปิด `url` ใน
4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน 4. UI ทั้งหมดภาษาไทย — ข้อความสำเนาได้จากไฟล์ต้นฉบับที่อ้างด้านบน
5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด 5. Task pane แคบ (side panel) — layout เป็นคอลัมน์เดียว ปุ่ม/actions ต้องรองรับความกว้างจำกัด
6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง 6. id ของ Content Control **ไม่เสถียรข้าม session/coauthor** (docstring ของ Office.js เอง) — เครื่องมือ Template ใช้ id เฉพาะภายในอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง
7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — เครื่องมือ Template จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 สแกนเห็นเฉพาะ `detail1`) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text พร้อม warning แจ้งผู้ใช้ · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16) 7. **การมองเห็นช่องตามรุ่นของ Word**: collection `contentControls` ธรรมดาเห็น**เฉพาะ rich text CC** (docstring ของ `Body.contentControls`) — จึง feature-detect `WordApi 1.5` (`isSetSupported`) แล้วใช้ `Document.getContentControls()` ที่เห็นทุกชนิด (รวม plain text ซึ่งเป็นส่วนใหญ่ของ template ศาล — เคสจริง: T3 มี plain 26 + rich 1 เดิมเห็นเฉพาะ `detail1`) — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ `metadataService` ใช้ร่วม 2 ที่: เครื่องมือ Template (สแกน/เขียนค่า) และ **`readContentControlFields` ตอนสร้าง version** (fields ที่แนบ upload เดิมเคยเหลือแค่ rich ตัวเดียว — แก้ 2026-10-28 ให้เก็บ CC ทุกตัว) บน Word ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3 ตาม manifest) มองเห็น/เขียนได้เฉพาะ rich text: เครื่องมือ Template มี warning แจ้งผู้ใช้ ส่วน fields ที่ upload ก็ rich-text-only เช่นกัน (ไม่มี UI แจ้ง — แก้ไม่ได้โดยไม่ยก MinVersion) · **ห้ามยก MinVersion ของ manifest** เพื่อปลดข้อจำกัดนี้ (Office 2019 จะโหลด add-in ไม่ขึ้น — บทเรียน 2026-10-16)
--- ---

View file

@ -0,0 +1,44 @@
# ขั้นตอนการทำงานของ 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 ทั้งหมด
```

140
lib/addin-ticket.ts Normal file
View file

@ -0,0 +1,140 @@
// 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 แบบไม่สน exp (HMAC → parse) — ใช้เมื่อ route จะตัดสิน "ticket หมดอายุ
* แต่เอกสารนี้เคยทำงานโดย uid เดียวกัน" เองผ่าน DB (requirement ขั้น 7: กลับมาทำ
* เอกสารเดิมผ่านได้แม้ ticket หมดอายุ) — ตัวตนของผู้ถือยังยึด HMAC ของ e-Court
* เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้
*/
export function verifyAddinTicketHmac(
ticketData: string,
hmacHex: string,
secret: string,
): { 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 };
return { ok: true, ticket: parsed.ticket };
}
/**
* ตรวจ 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 verified = verifyAddinTicketHmac(ticketData, hmacHex, secret);
if (!verified.ok) return { ok: false };
if (isTicketExpired(verified.ticket.exp, nowSec)) return { ok: false };
return verified;
}
/** แปลง 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<string, unknown>;
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 };
}

View file

@ -124,8 +124,11 @@ export function parseFieldsJson(raw: string):
} }
const record = parsed as Record<string, unknown>; const record = parsed as Record<string, unknown>;
const keys = Object.keys(record); const keys = Object.keys(record);
if (keys.length > 100) { // เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้:
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' }; // เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb
// ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ)
if (keys.length > 1_000) {
return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' };
} }
for (const key of keys) { for (const key of keys) {
if (key.length > 200) { if (key.length > 200) {
@ -135,9 +138,6 @@ export function parseFieldsJson(raw: string):
if (typeof value !== "string") { if (typeof value !== "string") {
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น 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<string, string>) }; return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
} }

View file

@ -206,6 +206,22 @@ export async function findVersionForDoc(
return row?.version ?? null; return row?.version ?? null;
} }
/**
* เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน
* "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7:
* กลับมาทำเอกสารเดิมผ่านได้แม้ ticket หมดอายุ — ตัวตนยังยึด HMAC ของ e-Court
* ทางเข้า /api/documents/token ของ add-in) · อ่านอย่างเดียว ไม่แตะ MinIO
*/
export async function hasVersionByUser(docId: string, userId: string): Promise<boolean> {
const [row] = await db
.select({ one: sql`1` })
.from(documentVersions)
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
.where(and(eq(documents.docId, docId), eq(documentVersions.userId, userId)))
.limit(1);
return row !== undefined;
}
export type InsertVersionInput = { export type InsertVersionInput = {
/** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */ /** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */
versionId: string; versionId: string;

View file

@ -121,12 +121,12 @@ export function verifyDocverToken(
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */ * คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
export function requireDocverApiKey(request: Request): Response | null { export function requireDocverApiKey(request: Request): Response | null {
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
//const envKey = process.env.DOCVER_API_KEY?.trim(); const envKey = process.env.DOCVER_API_KEY?.trim();
const envKey = // const envKey =
"2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54"; // "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
// if (envKey === undefined || envKey === "") { if (envKey === undefined || envKey === "") {
// return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
// } }
const provided = request.headers.get("x-api-key"); const provided = request.headers.get("x-api-key");
if (provided === null || provided === "") { if (provided === null || provided === "") {
return docverError("API key ไม่ถูกต้อง", 401); return docverError("API key ไม่ถูกต้อง", 401);
@ -150,10 +150,10 @@ export function requireDocverBearer(
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } { ): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
const denied = () => const denied = () =>
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const; ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
//const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
const secret = // const secret =
"487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
//if (secret === undefined || secret === "") return denied(); if (secret === undefined || secret === "") return denied();
const header = request.headers.get("authorization"); const header = request.headers.get("authorization");
if (header === null) return denied(); if (header === null) return denied();
const match = /^Bearer\s+(\S+)$/i.exec(header); const match = /^Bearer\s+(\S+)$/i.exec(header);

View file

@ -1,3 +1,4 @@
import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils';
import createReport from 'docx-templates'; import createReport from 'docx-templates';
import PizzaZip from "pizzip"; import PizzaZip from "pizzip";
import { import {
@ -1261,3 +1262,53 @@ export const templateDynamicContents = async (template: Buffer, data: Record<str
} }
}); });
} }
// สร้างเอกสารจาก เทมเพลต+data render และ generate ใช้งานเหมือนกัน
// ยังไม่ค่อยแน่ใจว่าควรอยู่ใน library นี้หรือเปล่าเพราะมีการทำ hash/sign ticket_data
// - ticket_data ต้องมีข้อมูลสมบูรณ์ {fid,tid,uid,uname}
// - สร้าง signature/hash จาก ticket_data
// - สร้าง Dynamic Content (docx-templates)
// - เอาข้อมูลใส่ Content Control
export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentTemplate,
secret:string,private_key:string,ttl:number):Promise<Buffer<ArrayBufferLike>>
{
//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
}

View file

@ -50,7 +50,7 @@ console.log('Is Signature Valid?:', isValid); // true
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร) * @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
*/ */
export function signDataEd25519(message: string | Buffer, privateKeyHex: string): string { export function signEd25519(message: string | Buffer, privateKeyHex: string): string {
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex'); const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา) // ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer; const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
@ -76,7 +76,7 @@ export function signDataEd25519(message: string | Buffer, privateKeyHex: string)
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน) * @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
*/ */
export function verifyDataEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean { export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
try { try {
const rawPublicKey = Buffer.from(publicKeyHex, 'hex'); const rawPublicKey = Buffer.from(publicKeyHex, 'hex');

View file

@ -32,18 +32,25 @@ export const openApiSpec = {
{ {
name: "template", name: "template",
description: description:
"Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " + "Next Generation of Template : เป็นเทมเพลต MS Word(docx) รองรับรูปแบบข้อมูลที่ซับซ้อนได้ ",
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
}, },
{ {
name: "documents", name: "documents",
description: description:
"จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " + "จัด version เอกสาร Word (.docx + .pdf) — ใช้งานจริงโดย Word add-in " +
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " + '"e-Court Documents Versioning" และระบบภายนอกที่ต้องการดึงไฟล์\n\n' +
"ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " + "**แนวคิด**\n" +
"DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " + "- เอกสารหนึ่งฉบับถูกระบุด้วย `docId` ตัวเดียว ซึ่งก็คือ `fid` (fileId ของ e-Court)\n" +
"และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " + "- ผู้ใช้กดบันทึกใน Word แต่ละครั้ง = เพิ่ม 1 version ที่เก็บไฟล์ .docx " +
"ไม่รับจาก URL/multipart form อีก", "(+ สำเนา .pdf ถ้ามี) พร้อมชื่อผู้อัปโหลดและเวลา — ไฟล์เก่าของ version ก่อนหน้าไม่ถูกแก้\n\n" +
"**วิธีเรียกใช้ 3 ขั้น**\n" +
"1. ขอ `access_token` ที่ `POST /api/documents/token`\n" +
"2. แนบ `Authorization: Bearer <access_token>` กับทุก request ถัดไปของกลุ่มนี้\n" +
"3. เรียก endpoint ที่ต้องการ: อัปโหลด version ใหม่ / ดูประวัติ version / ขอลิงก์ดาวน์โหลด\n\n" +
"**ข้อควรรู้**\n" +
"- identity (userId/userName), templateId และ docId อ่านจาก claims ใน token ทั้งหมด — " +
"ไม่รับจาก URL หรือ form (ส่งมา = 400)\n" +
"- อัปโหลด version แรกของ docId ที่ยังไม่เคยมี = ระบบสร้างเอกสารให้อัตโนมัติ",
}, },
{ {
name: "Judge's Note", name: "Judge's Note",
@ -75,67 +82,15 @@ 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 <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>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": { "/api/template/render": {
post: { post: {
tags: ["template"], tags: ["template"],
summary: "สร้างเอกสารจาก template (.docx) + data (JSON)", summary: "สร้างเอกสารจาก template (.docx) + data (JSON) ใช้สำหรับนักพัฒนาเท่านั้น",
description: description:
"แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " + "แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " +
'(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' + '(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' +
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)', 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)<br/>'+
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ private key เองไม่ใช้ของระบบของระบบ</i>',
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
@ -194,6 +149,410 @@ 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 ซึ่งเหมาะกับการเอาไปใส่ในฐานข้อมูล"+
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ Public key เองไม่ใช้ของระบบของระบบ</i>',
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 <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>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) "+
"จะได้เอกสาร รูปแบบฟอร์มที่กรอกค่าแล้วดึงค่ากลับมาใช้งานต่อได้เช่นอัปเดตฐานข้อมูล<br><ul>"+
"<li>JSON payload ต้องมี info.ticket_data เป็น JSON string รูปแบบถูกต้อวมีค่าครบ(tid, fid และ uid)</li>" +
"<li>ไฟล์เทมเพลต(tid)ในระบบ + ข้อมูล(payload) เพื่อสร้างเอกสาร </li>" +
"<li>ticket_data จะทำการ hash/sign แบบอายุสั้นแล้วนำไปฝังไว้ใน custom property เพื่อนำไปใช้ร่วมกับระบบ Versioning</li>"+
"</ul>",
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": { "/api/note/external/documents": {
post: { post: {
tags: ["external"], tags: ["external"],
@ -261,19 +620,32 @@ export const openApiSpec = {
tags: ["documents"], tags: ["documents"],
summary: "ขอ access_token ของกลุ่ม documents (docver)", summary: "ขอ access_token ของกลุ่ม documents (docver)",
description: description:
"ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " + "ออก `access_token` (JWT HS256) ไว้เรียก endpoint อื่นของกลุ่ม documents — " +
"JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " + "token ฝัง claims `{userid, username, fid, template}` อายุตาม env " +
"DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " + "`DOCVER_TOKEN_TTL_SECONDS` (default 3600 วินาที)\n\n" +
"templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " + "**ทางเข้า 2 แบบ** — เลือกตามว่าใครเป็นคนเรียก (ดูตัวอย่างใน Request body ได้ทั้งคู่):\n\n" +
"fid จึงบังคับ", "1. **ระบบภายนอก (server-to-server)** — แนบ header `x-api-key` (ค่าจาก env " +
"`DOCVER_API_KEY`) แล้วส่ง claims ตรง ๆ ใน body ตาม schema `TokenRequest`\n" +
"2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " +
"ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " +
"ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " +
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง · ' +
"**ticket หมดอายุ** ยังออกให้ต่อเมื่อเอกสารนี้ (`fid`) เคยมี version ที่สร้างโดย `uid` เดียวกัน " +
"(ผู้ที่เคยทำงานกับเอกสารเดิมกลับมาใช้ต่อได้แม้ ticket หมดอายุ — ไม่เคยทำงาน = 401)\n\n" +
"ได้ token แล้วแนบเป็น `Authorization: Bearer <token>` กับทุก request ถัดไป",
security: [{ ApiKeyAuth: [] }], security: [{ ApiKeyAuth: [] }],
requestBody: { requestBody: {
required: true, required: true,
content: { content: {
"application/json": { "application/json": {
schema: { $ref: "#/components/schemas/TokenRequest" }, schema: {
oneOf: [
{ $ref: "#/components/schemas/TokenRequest" },
{ $ref: "#/components/schemas/TicketTokenRequest" },
],
},
examples: { examples: {
"ครบทุก claim": { "server-to-server ด้วย x-api-key (ครบทุก claim)": {
value: { value: {
userid: "123", userid: "123",
username: "Sorawit Bholsithi", username: "Sorawit Bholsithi",
@ -281,9 +653,17 @@ export const openApiSpec = {
template: "T12", template: "T12",
}, },
}, },
"ขั้นต่ำ (template เป็นทางเลือก)": { "server-to-server ขั้นต่ำ (template เป็นทางเลือก)": {
value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" }, 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",
},
},
}, },
}, },
}, },
@ -299,7 +679,9 @@ export const openApiSpec = {
}, },
400: { $ref: "#/components/responses/BadRequest" }, 400: { $ref: "#/components/responses/BadRequest" },
401: { 401: {
description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY)", description:
"ทางเข้า x-api-key: ไม่ได้ส่ง header หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY) · " +
"ทางเข้า ticket: ticket ไม่ถูกต้อง/หมดอายุ หรือไม่ได้ส่งมา (ข้อความเดียวกันทุกสาเหตุ)",
content: { content: {
"application/json": { "application/json": {
schema: { $ref: "#/components/schemas/Error" }, schema: { $ref: "#/components/schemas/Error" },
@ -315,12 +697,15 @@ export const openApiSpec = {
tags: ["documents"], tags: ["documents"],
summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)", summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)",
description: description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — สร้างเอกสาร " + "บันทึกไฟล์ปัจจุบันของเอกสารเป็น **version ใหม่** — เอกสารถูกระบุด้วย `fid` " +
"อัตโนมัติเมื่อ docId ยังไม่เคยมี แล้วเก็บไฟล์ .docx (+ .pdf ถ้าแนบมา) ลง MinIO " + "ใน Bearer token ถ้า docId นี้ยังไม่เคยมีในระบบ จะสร้างเอกสารให้อัตโนมัติ (version แรก = 1)\n\n" +
"พร้อมผู้อัปโหลด — versionNumber ที่ server จ่ายคือค่าจริงเสมอ (client ที่เลขไม่ตรง " + "- ไฟล์เก็บใน MinIO แยก object ต่อ version พร้อมบันทึกผู้อัปโหลดจาก claims ใน token\n" +
"ต้องแก้ XML part ของตัวเอง) · pdf เป็น optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มี " + "- `pdf` เป็น optional — ไม่แนบหรือแนบไฟล์เปล่า = version นี้ไม่มีสำเนา PDF " +
"สำเนา PDF (ดู hasPdf ใน response) · ผู้อัปโหลด (userId/userName) และ templateId " + "(ดู `hasPdf` ใน response)\n" +
"แกะจาก Bearer token ไม่รับใน form (ส่ง field เหล่านั้นมา = 400)", "- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " +
"หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" +
"- ผู้อัปโหลด (userId/userName) และ templateId อ่านจาก token — " +
"**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**",
security: [{ DocverBearer: [] }], security: [{ DocverBearer: [] }],
requestBody: { requestBody: {
required: true, required: true,
@ -348,7 +733,9 @@ export const openApiSpec = {
fields: { fields: {
type: "string", type: "string",
description: description:
'JSON string ของค่า Content Control เช่น {"blackno":"คดีหมายเลขดำที่"}', 'JSON string ของค่า Content Control **ทุกตัว** ในเอกสาร รูปแบบ ' +
'`{ [tag]: ข้อความ }` (add-in อ่านมาตอนบันทึก version) ' +
'เช่น {"blackno":"คดีหมายเลขดำที่","detail1":"…"}',
}, },
message: { message: {
type: "string", type: "string",
@ -390,9 +777,11 @@ export const openApiSpec = {
tags: ["documents"], tags: ["documents"],
summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)", summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)",
description: description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — version ทุกตัว " + "version ทุกตัวของเอกสารที่ระบุด้วย `fid` ใน Bearer token เรียง **ใหม่ → เก่า** — " +
"ของเอกสารนั้น พร้อมผู้อัปโหลด (userId/userName), เวลา, commit message, ค่า fields " + "แต่ละรายการมีผู้อัปโหลด, เวลา, commit message, ค่า Content Control ตอนอัปโหลด " +
"และ hasPdf — ใช้เขียน UI version history ของ add-in", "(`fields`) และว่ามีสำเนา PDF ไหม (`hasPdf`) — ใช้เขียน UI version history ของ add-in\n\n" +
"docId นี้ยังไม่เคยมี version เลย = 404 (สำหรับ add-in ถือเหมือน list ว่าง — " +
"กดบันทึกเพื่อสร้าง version 1 ได้ทันที)",
security: [{ DocverBearer: [] }], security: [{ DocverBearer: [] }],
responses: { responses: {
200: { 200: {
@ -425,10 +814,12 @@ export const openApiSpec = {
tags: ["documents"], tags: ["documents"],
summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)", summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)",
description: description:
"เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) · คืน URL อายุสั้น " + "ขอ **presigned URL อายุสั้น** สำหรับดาวน์โหลดไฟล์ของ version หนึ่ง — URL ชี้ที่ " +
"(env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดในเบราว์เซอร์/Word ของ " + "MinIO ตรง ๆ ให้เปิดในเบราว์เซอร์/Word ของผู้ใช้\n\n" +
"ผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ผู้ใช้เข้าถึงได้ · ขอ pdf " + "- อายุ URL ตาม env `DOCVER_LINK_TTL_SECONDS` (default 900 วินาที) — " +
"ใน version ที่ไม่มีสำเนา = 404", "หมดอายุแล้วขอใหม่ได้เรื่อย ๆ\n" +
"- ต้องเป็น version ของเอกสารเดียวกับ `fid` ใน token (โยน versionId ข้ามเอกสารไม่ได้)\n" +
"- ขอ `format=pdf` ใน version ที่ไม่มีสำเนา PDF = 404",
security: [{ DocverBearer: [] }], security: [{ DocverBearer: [] }],
parameters: [ parameters: [
{ {
@ -487,8 +878,9 @@ export const openApiSpec = {
scheme: "bearer", scheme: "bearer",
bearerFormat: "JWT", bearerFormat: "JWT",
description: description:
"access_token จาก POST /api/documents/token — แนบเป็น Authorization: Bearer " + "access_token จาก POST /api/documents/token — แนบเป็น " +
"กับ endpoint อื่นของกลุ่ม documents ทุกตัว", "`Authorization: Bearer <token>` กับ endpoint อื่นของกลุ่ม documents ทุกตัว\n" +
"(ตอนกรอกใน Swagger: ใส่เฉพาะค่า token — คำว่า Bearer ไม่ต้องพิมพ์เอง)",
}, },
}, },
responses: { responses: {
@ -524,13 +916,13 @@ export const openApiSpec = {
Error: { Error: {
type: "object", type: "object",
properties: { properties: {
success: { type: "boolean", example: false },
error: { type: "string" }, error: { type: "string" },
}, },
}, },
TokenRequest: { TokenRequest: {
type: "object", type: "object",
description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400", description:
"claims ที่จะฝังใน access_token (ทางเข้า x-api-key) — key อื่น = 400",
required: ["userid", "username", "fid"], required: ["userid", "username", "fid"],
properties: { properties: {
userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" }, userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" },
@ -548,6 +940,27 @@ 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: { TokenResponse: {
type: "object", type: "object",
description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)", description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)",
@ -563,14 +976,44 @@ export const openApiSpec = {
ExtractResponse: { ExtractResponse: {
type: "object", type: "object",
properties: { properties: {
success: { type: "boolean", example: true },
data: { data: {
type: "object", type: "object",
description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)", description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)",
additionalProperties: { type: "string" }, additionalProperties: { type: "string" },
example: { example: {
blackno: "คดีหมายเลขดำที่", "cc": {
court: "ศาลปกครองสุพรรณบุรี", "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
}
}, },
}, },
}, },
@ -739,7 +1182,8 @@ export const openApiSpec = {
fields: { fields: {
type: "object", type: "object",
nullable: true, nullable: true,
description: "ค่า Content Control ตอนอัปโหลด (ไม่ส่ง = null)", description:
"ค่า Content Control ทุกตัวตอนอัปโหลด รูปแบบ `{ [tag]: ข้อความ }` (ไม่ส่ง = null)",
additionalProperties: { type: "string" }, additionalProperties: { type: "string" },
}, },
message: { type: "string", nullable: true, description: "commit message" }, message: { type: "string", nullable: true, description: "commit message" },

View file

@ -37,6 +37,6 @@ export function middleware(req: NextRequest) {
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ // ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
export const config = { export const config = {
matcher: [ matcher: [
"/((?!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).+)", "/((?!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).+)",
], ],
}; };

View file

@ -8,6 +8,7 @@
"test": "vitest run", "test": "vitest run",
"start": "next start", "start": "next start",
"seed-samples": "node --env-file=.env scripts/seed-samples.mjs", "seed-samples": "node --env-file=.env scripts/seed-samples.mjs",
"addin:install": "node scripts/install-wordaddin.mjs",
"postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs", "postinstall": "node scripts/patch-pdf-worker.mjs && node scripts/copy-swagger-ui.mjs && node scripts/fetch-office-js.mjs",
"dev:https": "next dev --experimental-https", "dev:https": "next dev --experimental-https",
"dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt" "dev:https:cert": "next dev --experimental-https --experimental-https-key certs/localhost.key --experimental-https-cert certs/localhost.crt"

View file

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"ticket_data": "{\"fid\":\"T12_FD155_3s-long_ZZZ\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" "ticket_data": "{\"fid\":\"T12_FD155_3s-long\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
}, },
"dc": { "dc": {
"Interpleader": false, "Interpleader": false,

View file

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"ticket_data": "{\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" "ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
}, },
"dc": { "dc": {
"Interpleader": true, "Interpleader": true,

View file

@ -1,51 +0,0 @@
{
"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"
}
}
}

View file

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" "ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
}, },
"dc": { "dc": {
"Interpleader": false, "Interpleader": false,

View file

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"ticket_data": "{\"fid\":\"TS_P2_IN_YYY\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" "ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
}, },
"dc": { "dc": {
"Interpleader": true, "Interpleader": true,

Binary file not shown.

View file

@ -1,6 +1,6 @@
{ {
"info": { "info": {
"ticket_data": "{\"fid\":\"T3_P2_IN_XXX\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" "ticket_data": "{\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
}, },
"dc": { "dc": {
"Interpleader": false, "Interpleader": false,
@ -40,7 +40,7 @@
"acc2": "ผู้ถูกฟ้องคดี", "acc2": "ผู้ถูกฟ้องคดี",
"interpleader": "สมชาย ใจดี", "interpleader": "สมชาย ใจดี",
"acc3": "ผู้ร้องสอด", "acc3": "ผู้ร้องสอด",
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ", "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว \nเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
"sendto" : "นายสมศักดิ์ ฟักมี", "sendto" : "นายสมศักดิ์ ฟักมี",
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี", "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",

28
public/tmpl/dl.html Normal file
View file

@ -0,0 +1,28 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Test Download DOCX a</title>
</head>
<body>
<h2 className="font-semibold text-blue-600 mb-2">A a Get</h2>
<a href="/docedit/api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</a><br/>
<a href="/docedit/api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</a><br/>
<a href="/docedit/api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</a><br/>
<a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</a><br/>
<a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/>
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
Template: <a href="../tmpl/T_X.docx">T_X.docx</a>, &nbsp;
<a href="/docedit/tmpl/T12_X.docx">T12_X.docx</a><br/>
Data: <a href="/docedit/tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>, &nbsp;
<a href="/docedit/tmpl/T_no_warning.json">T_no_warning.json</a>, &nbsp;
<a href="/docedit/tmpl/TS_P2_IN.json">TS_P2_IN.json</a><br/>
<a href="/docedit/tmpl/T12_b2_IN.json">T12_b2_IN.json</a>, &nbsp;
<a href="/docedit/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a>
</body>
</html>

View file

@ -1,20 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Document</title>
</head>
<body>
<h1>Template & Data</h1>
<ol>
<li><a href="T12_X.docx">T12_X</a>
(<a href="T12_b2_IN.json">T12_b2_IN</a>, <a href="T12_FD155_3s-long.json">T12_FD155_3s-long</a>)
</li>
<li><a href="T_X.docx">T_X</a>
(<a href="TF_no_warning_P2.json">TF_no_warning_P2</a>, <a href="T_no_warning">T_no_warning</a>, <a href="T_no_warning">TS_P2_IN.json</a>)
</li>
</ol>
</body>
</html>

View file

@ -11,8 +11,10 @@
ผลต่างจากต้นฉบับ: ผลต่างจากต้นฉบับ:
- taskpane ชี้ที่หน้า Next.js (/docedit/wordaddin) แทน taskpane.html ของ webpack - taskpane ชี้ที่หน้า Next.js (/docedit/wordaddin) แทน taskpane.html ของ webpack
- ตัด DialogAPI (login ช่วงนี้เป็น mock ไม่มี Keycloak dialog) — เหลือ WordApi 1.5 + OpenBrowserWindowApi - ตัด DialogAPI + AppDomain ของ Keycloak (https://localhost:8443) — ยกเลิก auth ด้วย Keycloak
- ตัด AppDomain ของ Keycloak (https://localhost:8443) 2026-10-27: ไม่มี login ใน add-in auth เป็น e-Court pass-through (token/owner ใน custom
properties ของไฟล์) requirement set เหลือ WordApi 1.3 + OpenBrowserWindowApi
(ดูรายละเอียดระดับที่ใช้จริงในคอมเมนต์ก่อน <Requirements>)
- Commands.Url ชี้ taskpane URL เดียวกัน (ไม่มี commands.html — ปุ่มเดียวคือ ShowTaskpane) - Commands.Url ชี้ taskpane URL เดียวกัน (ไม่มี commands.html — ปุ่มเดียวคือ ShowTaskpane)
- คง Id GUID เดิม — เครื่องที่เคย sideload ตัว poc จะถือว่าเป็น add-in ตัวเดิม - คง Id GUID เดิม — เครื่องที่เคย sideload ตัว poc จะถือว่าเป็น add-in ตัวเดิม
--> -->

View file

@ -0,0 +1,246 @@
#!/usr/bin/env node
// Dev tool ติดตั้ง (sideload) / ถอด Word add-in "e-Court Documents Versioning"
// บนเครื่องทดสอบ Windows + macOS — โหลด manifest จาก server (default ตัวทดสอบ
// ecourt-note.chamomind.com ที่ app/wordaddin/manifest.xml/route.ts rewrite origin
// ให้ตรงโฮสต์ผู้ขออยู่แล้ว) แล้วจดทะเบียนให้ Word Desktop เห็น add-in
//
// กลไกตรงตาม office-addin-dev-settings@4 (อ่านจาก source ใน node_modules) แต่
// เขียนเองด้วย node builtins ล้วน เพื่อใช้ได้ทั้งรันใน repo (npm run addin:install)
// และ copy ไฟล์เดี่ยวนี้ไปรัน `node install-wordaddin.mjs` บนเครื่องที่ไม่มี repo:
// - Windows: registry HKCU\Software\Microsoft\Office\16.0\Wef\Developer
// value ชื่อ = GUID ของ add-in, REG_SZ, data = path manifest บนดิสก์ (ต้องอยู่ถาวร)
// - macOS: วางไฟล์ <GUID>.manifest.xml ใน
// ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/ (Word สแกน *.xml ทุกไฟล์)
//
// ใช้: node scripts/install-wordaddin.mjs [options]
// --url <manifest-url> default https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml
// (dev: https://localhost:3000/docedit/wordaddin/manifest.xml — ต้อง npm run dev:https ก่อน)
// --remove ถอดการติดตั้ง (ไม่ต้องต่อเน็ต — ใช้ manifest ที่เก็บไว้ตอนลง)
// --help | -h
//
// หลังลง/ถอดต้อง restart Word ทุกครั้ง · รายละเอียดเต็ม: docs/WORDADDIN-DEV.md
import { spawnSync } from "node:child_process";
import { mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
const PREFIX = "[install-addin]";
const DEFAULT_MANIFEST_URL = "https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml";
/** ตรงคีย์ที่ office-addin-dev-settings ใช้ (Office 2016+ ทุกรุ่นอยู่ภายใต้ 16.0) */
const WIN_DEV_KEY = "HKCU\\Software\\Microsoft\\Office\\16.0\\Wef\\Developer";
/** โฟลเดอร์เก็บ manifest ถาวรบน Windows — path เดียวกับที่ docs/WORDADDIN-DEV.md สอน manual */
const winManifestDir = () => path.join(os.homedir(), "Documents", "wordaddin-manifest");
const winManifestPath = () => path.join(winManifestDir(), "manifest.xml");
/** โฟลเดอร์ที่ Word for Mac สแกนหา add-in ที่ sideload */
const macWefDir = () =>
path.join(os.homedir(), "Library", "Containers", "com.microsoft.Word", "Data", "Documents", "wef");
function usage(code = 1) {
console.log(`ใช้: node scripts/install-wordaddin.mjs [options]
--url <manifest-url> default ${DEFAULT_MANIFEST_URL}
--remove ถอดการติดตั้ง
--help | -h`);
process.exit(code);
}
function parseArgs(argv) {
const options = { url: DEFAULT_MANIFEST_URL, remove: 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 "--url": options.url = next(); break;
case "--remove": options.remove = true; break;
case "--help": case "-h": usage(0); break;
default: usage();
}
}
return options;
}
function fail(message) {
console.error(`${PREFIX} ✗ ${message}`);
process.exit(1);
}
/** โหลด manifest จาก server แล้วดึง GUID ของ add-in จาก <Id> — ไม่ hardcode เพราะเปลี่ยน environment ได้ */
async function downloadManifest(url) {
let response;
try {
response = await fetch(url, { redirect: "follow", signal: AbortSignal.timeout(30_000) });
} catch (error) {
fail(`โหลด manifest จาก ${url} ไม่ได้ (${error instanceof Error ? error.message : String(error)})`);
}
if (!response.ok) {
fail(`server ตอบ HTTP ${response.status} จาก ${url} — เช็คว่า environment นั้นรันอยู่`);
}
const xml = await response.text();
if (!xml.includes("<OfficeApp")) {
fail(`response จาก ${url} ไม่ใช่ manifest ของ Office add-in (ไม่มี <OfficeApp)`);
}
const idMatch = xml.match(/<Id>([0-9a-fA-F-]{36})<\/Id>/);
if (!idMatch) {
fail(`หา <Id> (GUID) ใน manifest ไม่เจอจาก ${url}`);
}
return { xml, guid: idMatch[1].toLowerCase() };
}
/** อ่าน GUID จากไฟล์ manifest ที่เก็บไว้ตอนติดตั้ง (สำหรับ --remove ที่ไม่ต้องต่อเน็ต) */
function readStoredGuid(manifestPath) {
try {
const xml = readFileSync(manifestPath, "utf8");
const idMatch = xml.match(/<Id>([0-9a-fA-F-]{36})<\/Id>/);
return idMatch ? idMatch[1].toLowerCase() : null;
} catch {
return null;
}
}
/** reg.exe ผ่าน args array เสมอ (ไม่ใช่ shell string — path มี space ก็ไม่พัง) */
function reg(args) {
return spawnSync("reg", args, { encoding: "utf8" });
}
function installWindows(xml, guid) {
const dest = winManifestPath();
mkdirSync(winManifestDir(), { recursive: true });
writeFileSync(dest, xml);
console.log(`${PREFIX} ✓ เก็บ manifest ไว้ที่ ${dest}`);
const add = reg(["add", WIN_DEV_KEY, "/v", guid, "/t", "REG_SZ", "/d", dest, "/f"]);
if (add.status !== 0) {
fail(`เขียน registry ไม่สำเร็จ (reg add exit ${add.status}): ${add.stderr?.trim() ?? "ไม่มี stderr"}`);
}
const query = reg(["query", WIN_DEV_KEY, "/v", guid]);
if (query.status !== 0) {
fail(`reg add สำเร็จแต่ยืนยันด้วย reg query ไม่ผ่าน (exit ${query.status})`);
}
console.log(`${PREFIX} ✓ จดทะเบียน add-in ใน registry (${WIN_DEV_KEY} → ${guid})`);
return dest;
}
function removeWindows() {
const stored = winManifestPath();
const guid = readStoredGuid(stored);
if (!guid) {
fail(
`ไม่พบ manifest ที่เก็บไว้ (${stored}) จึงไม่รู้ GUID ของ add-in — ` +
`ลบเองได้ที่ reg delete "${WIN_DEV_KEY}" /v <GUID> /f`,
);
}
const del = reg(["delete", WIN_DEV_KEY, "/v", guid, "/f"]);
if (del.status !== 0) {
fail(`reg delete ไม่สำเร็จ (exit ${del.status}): ${del.stderr?.trim() ?? "ไม่มี stderr"}`);
}
try {
unlinkSync(stored);
} catch {
// registry ถอดแล้ว — ไฟล์ค้างไม่ทำให้ Word เห็น add-in
}
console.log(`${PREFIX} ✓ ถอดการติดตั้งแล้ว (registry ${guid} + ไฟล์ ${stored})`);
}
/** ไฟล์ sideload ของ GUID นี้ทั้งหมดใน wef — ทั้งของเราและชื่อ basename เก่า ๆ ที่เคยใช้ */
function macSideloadFiles(guid) {
const dir = macWefDir();
try {
return readdirSync(dir)
.filter((name) => name === `${guid}.manifest.xml` || name.startsWith(`${guid}.`))
.map((name) => path.join(dir, name));
} catch {
return [];
}
}
function installMac(xml, guid) {
const dir = macWefDir();
mkdirSync(dir, { recursive: true });
// ลบของเก่าก่อนเขียนใหม่ — idempotent และไม่มีไฟล์ซ้ำ GUID ค้างในโฟลเดอร์
for (const stale of macSideloadFiles(guid)) {
try {
unlinkSync(stale);
} catch {
console.warn(`${PREFIX} ⚠ ลบไฟล์เก่าไม่ได้: ${stale}`);
}
}
const dest = path.join(dir, `${guid}.manifest.xml`);
writeFileSync(dest, xml);
console.log(`${PREFIX} ✓ วาง manifest ไว้ที่ ${dest}`);
return dest;
}
function removeMac() {
const dir = macWefDir();
// บน Mac ไม่มี manifest เก็บแยกที่อื่น (ตัวไฟล์ใน wef คือของที่ลงไว้) — หาทุก *.xml
// ในโฟลเดอร์ที่เป็น manifest ของ add-in นี้จากชื่อในไฟล์เอง จึงไม่ต้อง hardcode GUID
let files;
try {
files = readdirSync(dir)
.filter((name) => name.toLowerCase().endsWith(".xml"))
.map((name) => path.join(dir, name))
.filter((file) => {
try {
const xml = readFileSync(file, "utf8");
return xml.includes("<OfficeApp") && xml.includes("e-Court Documents Versioning");
} catch {
return false;
}
});
} catch {
files = [];
}
if (files.length === 0) {
console.log(`${PREFIX} ไม่พบไฟล์ sideload ของ add-in นี้ใน ${dir} — ถือว่าถอดแล้ว`);
return;
}
for (const file of files) {
unlinkSync(file);
console.log(`${PREFIX} ✓ ลบ ${file}`);
}
}
function printNextSteps() {
const where = process.platform === "win32" ? "Insert > My Add-ins > SHARED FOLDER" : "Insert > My Add-ins";
console.log(`
ขั้นต่อไป:
1. restart Word ให้ครบทุกหน้าต่าง (จำเป็นทุกครั้งหลังลง/ถอด)
2. ${where} > เปิด "e-Court Documents Versioning"
3. เปิดไฟล์ .docx ที่มี ticket เพื่อทดสอบ เช่นสร้างใหม่ด้วย:
node scripts/make-ticket-docx.mjs file.docx --fid DEMO_FID_001 --tid T12
รายละเอียด: docs/WORDADDIN-DEV.md`);
}
async function main() {
const options = parseArgs(process.argv.slice(2));
console.log(`${PREFIX} platform: ${process.platform}${options.remove ? " · โหมดถอดการติดตั้ง" : ""}`);
if (process.platform === "win32") {
if (options.remove) {
removeWindows();
} else {
const { xml, guid } = await downloadManifest(options.url);
installWindows(xml, guid);
}
} else if (process.platform === "darwin") {
if (options.remove) {
removeMac();
} else {
const { xml, guid } = await downloadManifest(options.url);
installMac(xml, guid);
}
} else {
fail(`รองรับเฉพาะ Windows (win32) และ macOS (darwin) — platform นี้คือ ${process.platform}`);
}
console.log(`${PREFIX} ✓ เสร็จ`);
printNextSteps();
}
main().catch((error) => {
fail(error instanceof Error ? error.stack ?? error.message : String(error));
});

View file

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

95
scripts/install-wordaddin.sh Executable file
View file

@ -0,0 +1,95 @@
#!/bin/bash
# ติดตั้ง (sideload) / ถอด Word add-in "e-Court Documents Versioning" บน macOS
# เวอร์ชัน shell แบบส่งให้เครื่องทดสอบที่ไม่มี Node/repo — คู่หูของ
# scripts/install-wordaddin.mjs (รันใน repo) และ scripts/install-wordaddin.ps1 (Windows)
# กลไกเดียวกันทั้งสามไฟล์: โหลด manifest จาก server แล้ววางเป็น <GUID>.manifest.xml
# ใน ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/ ของ Word
#
# ใช้: ./install-wordaddin.sh [--url <manifest-url>] [--remove]
# --url default https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml
# --remove ถอดการติดตั้ง
# --help | -h
#
# หลังลง/ถอดต้อง restart Word ทุกครั้ง · รายละเอียดเต็ม: docs/WORDADDIN-DEV.md
set -euo pipefail
PREFIX="[install-addin]"
DEFAULT_URL="https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml"
WEF_DIR="$HOME/Library/Containers/com.microsoft.Word/Data/Documents/wef"
usage() {
cat <<EOF
ใช้: ./install-wordaddin.sh [options]
--url <manifest-url> default ${DEFAULT_URL}
--remove ถอดการติดตั้ง
--help | -h
EOF
exit "${1:-1}"
}
fail() {
echo "${PREFIX} ✗ $1" >&2
exit 1
}
URL="$DEFAULT_URL"
REMOVE=0
while [ $# -gt 0 ]; do
case "$1" in
--url) [ $# -ge 2 ] || usage; URL="$2"; shift 2 ;;
--remove) REMOVE=1; shift ;;
--help | -h) usage 0 ;;
*) usage 1 ;;
esac
done
[ "$(uname)" = "Darwin" ] || fail "สคริปต์นี้สำหรับ macOS — Windows ใช้ install-wordaddin.ps1"
next_steps() {
cat <<EOF
ขั้นต่อไป:
1. restart Word ให้ครบทุกหน้าต่าง (จำเป็นทุกครั้งหลังลง/ถอด)
2. Insert > My Add-ins > เปิด "e-Court Documents Versioning"
3. เปิดไฟล์ .docx ที่มี ticket ของ e-Court (ขอจากทีม) เพื่อเริ่มทดสอบ
EOF
}
if [ "$REMOVE" = "1" ]; then
echo "${PREFIX} platform: $(uname) · โหมดถอดการติดตั้ง"
found=0
# บน Mac ไม่มี manifest เก็บแยกที่อื่น — หาทุก *.xml ใน wef ที่เป็นของ add-in
# นี้จากชื่อในไฟล์เอง จึงไม่ต้อง hardcode GUID
for file in "$WEF_DIR"/*.xml; do
[ -f "$file" ] || continue
if grep -q "e-Court Documents Versioning" "$file" && grep -q "<OfficeApp" "$file"; then
rm -f "$file"
echo "${PREFIX} ✓ ลบ $file"
found=1
fi
done
if [ "$found" = "0" ]; then
echo "${PREFIX} ไม่พบไฟล์ sideload ของ add-in นี้ใน $WEF_DIR — ถือว่าถอดแล้ว"
fi
echo "${PREFIX} ✓ เสร็จ"
next_steps
exit 0
fi
echo "${PREFIX} platform: $(uname)"
TMP="$(mktemp)"
trap 'rm -f "$TMP"' EXIT
curl -fsSL --max-time 30 "$URL" -o "$TMP" || fail "โหลด manifest จาก $URL ไม่ได้ — เช็คเน็ต และว่า environment นั้นรันอยู่"
grep -q "<OfficeApp" "$TMP" || fail "response จาก $URL ไม่ใช่ manifest ของ Office add-in"
GUID="$(sed -nE 's/.*<Id>([0-9a-fA-F-]{36})<\/Id>.*/\1/p' "$TMP" | head -n 1 | tr '[:upper:]' '[:lower:]')"
[ -n "$GUID" ] || fail "หา <Id> (GUID) ใน manifest ไม่เจอจาก $URL"
mkdir -p "$WEF_DIR"
rm -f "$WEF_DIR/$GUID".* # ลบของรอบก่อน — idempotent ไม่มีไฟล์ GUID เดียวค้างสองชื่อ
DEST="$WEF_DIR/$GUID.manifest.xml"
cp "$TMP" "$DEST"
echo "${PREFIX} ✓ วาง manifest ไว้ที่ $DEST"
echo "${PREFIX} ✓ เสร็จ"
next_steps

View file

@ -0,0 +1,263 @@
#!/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 <file.docx> [options]
// --fid <id> default DEMO_FID_001
// --uid <id> default oom
// --uname <name> default "สรวิชญ์"
// --tid <id> template id (optional — ไม่ส่ง = ไม่มี tid ใน ticket)
// --exp <unix|+sec> default +300 (5 นาที เท่า TICKET_SEC ของตัว stamp)
// --secret <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 <file.docx> [options]
--fid <id> --uid <id> --uname <name> --tid <id>
--exp <unix|+seconds> --secret <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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** 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(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
/** ถอด <Property name value> ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */
function readProperties(xml) {
const properties = [];
const regex = /<Property\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|<Property\s+[^>]*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 ` <Property fmtid="${CUSTOM_FMTID}" pid="${pid}" name="${escapeXml(name)}"><vt:lpwstr>${escapeXml(value)}</vt:lpwstr></Property>\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*<Property\\s+[^>]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)</Property>`,
"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("</Properties>");
if (hasRoot) {
xml = xml.replace(/<\/Properties>/, `${block}</Properties>`);
} else {
xml =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">\n${block}</Properties>`;
}
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>/,
` <Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.document-properties.custom+xml"/>\n</Types>`,
);
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>/,
` <Relationship Id="rId${maxId + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>\n</Relationships>`,
);
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")}`);

View file

@ -0,0 +1,169 @@
import { createHmac } from "node:crypto";
import { describe, expect, it } from "vitest";
import {
addinTicketSecret,
parseTicketTokenBody,
ticketToDocverClaims,
verifyAddinTicket,
verifyAddinTicketHmac,
} 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<T>(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);
});
});
// verifyAddinTicketHmac = ตัวเดียวกันแต่ไม่ตรวจ exp — route ใช้ตรวจ HMAC ก่อน
// แล้วตัดสิน "ticket หมดอายุ + เอกสารนี้เคยทำงานโดย uid เดียวกัน" จาก DB เอง
// (requirement ขั้น 7 — แก้บั๊ก 2026-10-28)
describe("verifyAddinTicketHmac", () => {
it("exp อดีต + HMAC ถูก = ผ่าน (ได้ ticket ครบ) — ต่างจาก verifyAddinTicket ที่ไม่ผ่าน", () => {
const expired = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" });
const hmacResult = verifyAddinTicketHmac(expired, sign(expired), SECRET);
ok(hmacResult);
expect(hmacResult.ticket.uid).toBe("u");
expect(verifyAddinTicket(expired, sign(expired), SECRET).ok).toBe(false);
});
it("HMAC ผิด / digest รูปเพี้ยน / ticket ผิดกติกา = ไม่ผ่านเหมือนเดิม (แม้ exp ยังไม่หมด)", () => {
expect(verifyAddinTicketHmac(TICKET_DATA, sign(TICKET_DATA, "wrong-secret"), SECRET).ok).toBe(false);
expect(verifyAddinTicketHmac(TICKET_DATA, "zz".repeat(32), SECRET).ok).toBe(false);
const extra = JSON.stringify({ exp: Math.floor(Date.now() / 1000) + 60, fid: "f", uid: "u", uname: "n", oops: 1 });
expect(verifyAddinTicketHmac(extra, sign(extra), 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;
});
});

View file

@ -153,6 +153,25 @@ describe("parseVersionUploadForm", () => {
form.append("fields", '{"a":1}'); form.append("fields", '{"a":1}');
expect(parseVersionUploadForm(form).ok).toBe(false); expect(parseVersionUploadForm(form).ok).toBe(false);
}); });
it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => {
const big = baseForm();
big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) }));
expect(parseVersionUploadForm(big).ok).toBe(true);
const manyKeys = baseForm();
manyKeys.append(
"fields",
JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))),
);
expect(parseVersionUploadForm(manyKeys).ok).toBe(true);
const over = baseForm();
over.append(
"fields",
JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))),
);
expect(parseVersionUploadForm(over).ok).toBe(false);
});
}); });
describe("sanitizeDisplayFileName / sanitizeFilePartName", () => { describe("sanitizeDisplayFileName / sanitizeFilePartName", () => {

View file

@ -0,0 +1,118 @@
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<T>(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);
});
});

View file

@ -0,0 +1,95 @@
// @vitest-environment jsdom
// readContentControlFields (FR-11) — คุมว่าตอนสร้าง version อ่านค่า CC **ทุกชนิด**:
// host WordApi 1.5+ ต้องยิง Document.getContentControls() (เห็น plain text CC ซึ่งเป็น
// ส่วนใหญ่ของ template ศาล — เคสจริง T3 มี plain 26 + rich 1 แต่เดิมเหลือแค่ rich ตัว
// เดียวคือ detail1) ส่วน host ต่ำกว่า 1.3 ยังพังลง rich-text-only ตามเดิม (fallback
// ไม่ throw) + กติกา tag ว่างข้าม / tag ซ้ำเอาตัวแรกตามลำดับเอกสาร
import { afterEach, describe, expect, it, vi } from "vitest";
import { readContentControlFields } from "@/wordaddin/services/metadataService";
interface FakeControl {
tag?: string;
text: string;
}
/** stub Word + Office ระดับ global — คืน handle ให้ test ดึง collection ที่ถูกขอไปใช้ */
function stubWord(controls: FakeControl[], options?: { getAllTypes?: boolean }): {
documentControls: { load: (p: string) => void };
allTypesControls: { load: (p: string) => void };
} {
const makeCollection = () => ({
load: vi.fn(),
items: controls.map((control) => ({ tag: control.tag, text: control.text })),
});
const documentControls = makeCollection();
const allTypesControls = makeCollection();
vi.stubGlobal("Office", {
context: {
requirements: {
isSetSupported: vi.fn(() => options?.getAllTypes ?? true),
},
},
});
vi.stubGlobal("Word", {
run: async (batch: (context: unknown) => Promise<unknown>) =>
batch({
document: {
contentControls: documentControls,
getContentControls: vi.fn(() => allTypesControls),
},
sync: async () => undefined,
}),
});
return { documentControls, allTypesControls };
}
afterEach(() => {
vi.unstubAllGlobals();
});
describe("readContentControlFields", () => {
it("host 1.5+ ใช้ getContentControls() — เก็บค่า plain text CC ได้ครบทุกตัว", async () => {
const { documentControls, allTypesControls } = stubWord([
{ tag: "case_no", text: "ด1" }, // plain text — ตัวที่เดิมหลุดไปทั้งหมด
{ tag: "court_name", text: "ศาลปกครอง" },
{ tag: "detail1", text: "ตุลาการเจ้าของสำนวนได้พิจารณา…" },
], { getAllTypes: true });
const fields = await readContentControlFields();
expect(fields).toEqual({
case_no: "ด1",
court_name: "ศาลปกครอง",
detail1: "ตุลาการเจ้าของสำนวนได้พิจารณา…",
});
expect(allTypesControls.load).toHaveBeenCalledWith("tag, text");
expect(documentControls.load).not.toHaveBeenCalled();
});
it("host ต่ำกว่า 1.5 (เช่น Office 2019 = 1.3) ยังใช้ collection rich-text-only ตามเดิม", async () => {
const { documentControls, allTypesControls } = stubWord([{ tag: "detail1", text: "rich ตัวเดียว" }], {
getAllTypes: false,
});
const fields = await readContentControlFields();
expect(fields).toEqual({ detail1: "rich ตัวเดียว" });
expect(documentControls.load).toHaveBeenCalledWith("tag, text");
expect(allTypesControls.load).not.toHaveBeenCalled();
});
it("tag ว่างข้าม + tag ซ้ำเอาตัวแรกตามลำดับในเอกสาร + ไม่มี CC คืน {}", async () => {
stubWord([
{ tag: " ", text: "tag ว่าง" },
{ tag: "plain1", text: "ค่าแรก" },
{ tag: "plain1", text: "ค่าหลัง" },
]);
expect(await readContentControlFields()).toEqual({ plain1: "ค่าแรก" });
});
it("ไม่มี content control เลย → คืน {} ไม่ error", async () => {
stubWord([]);
expect(await readContentControlFields()).toEqual({});
});
});

View file

@ -0,0 +1,227 @@
// @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 และ
// ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28:
// client ยิงขอ token เสมอ server เป็นคนตัดสินเรื่อง "เคยทำงาน" จาก DB)
// (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(expSec?: number): { ticketData: string; ticketHmac: string } {
const ticket = {
exp: expSec ?? 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([]);
});
});
// ─── ticket หมดอายุ + เอกสารที่เคยทำงาน (requirement ขั้น 7 — บั๊ก 2026-10-28) ──
// เกณฑ์: uid ตรงกัน + เคยสร้าง token ของ fid เดิม → ไม่ต้องสน expire ของ ticket
// ฝั่ง client ยิงขอ token เสมอแล้วให้ server ตัดสิน (200 = เคยทำงาน / 401 = ไม่เคย)
const FID = "DBG_FID_001";
function tokenResponse(token: string): Response {
return new Response(
JSON.stringify({
access_token: token,
token_type: "Bearer",
expires_in: 3600,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
function seedSession(token: string, tokenExpSec: number): void {
ticketService.saveSession({
userId: "oom",
userName: "สรวิชญ์",
tokens: { [FID]: { token, exp: tokenExpSec } },
});
}
describe("ticket หมดอายุ + เอกสารที่เคยทำงาน", () => {
it("session เดิม + token ยังไม่หมด → ผ่านเลย ไม่ยิง /token", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
seedSession("cached-token", Math.floor(Date.now() / 1000) + 1800);
const { ticketData, ticketHmac } = makeTicket(1); // ticket หมดอายุตั้งแต่ 1970
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(fetchMock).not.toHaveBeenCalled();
expect(errors).toEqual([]);
});
it("session เดิม + token หมดด้วย → ยิงขอใหม่ (server ตัดสิน) ได้ token ใหม่", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-fresh"));
vi.stubGlobal("fetch", fetchMock);
seedSession("token-stale", Math.floor(Date.now() / 1000) - 60);
const { ticketData, ticketHmac } = makeTicket(1);
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}");
expect(stored.tokens[FID].token).toBe("token-fresh");
expect(errors).toEqual([]);
});
it("ไม่มี session (localStorage หาย) + server 401 (ยังไม่เคยทำงาน) → expired ไม่ใช่ crash", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
vi.stubGlobal("fetch", vi.fn(async () => new Response(JSON.stringify({ error: "no" }), { status: 401 })));
const { ticketData, ticketHmac } = makeTicket(1);
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.reason).toBe("expired");
expect(typeof result.message).toBe("string");
}
expect(errors).toEqual([]);
});
it("ไม่มี session + เคยทำงาน (server 200) → สร้าง session ใหม่ได้แม้ ticket หมดอายุ", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-rebuilt"));
vi.stubGlobal("fetch", fetchMock);
const { ticketData, ticketHmac } = makeTicket(1);
const result = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(result.ok, JSON.stringify(result)).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
const stored = JSON.parse(window.localStorage.getItem("wordaddin-auth") ?? "{}");
expect(stored.userId).toBe("oom");
expect(stored.tokens[FID].token).toBe("token-rebuilt");
expect(errors).toEqual([]);
});
it("renewTokenFromTicket กับ ticket หมดอายุ + server 200 → ยังยิงขอ (server ตัดสิน) คืน true", async () => {
trapErrors();
vi.stubGlobal("crypto", webcrypto);
const fetchMock = vi.fn(async () => tokenResponse("token-renewed"));
vi.stubGlobal("fetch", fetchMock);
// sign-in ครั้งแรกด้วย ticket หมดอายุผ่านทาง server 200 → currentTicket เป็นตัวหมดอายุ
const { ticketData, ticketHmac } = makeTicket(1);
const signIn = await authService.signInWithTicket({ ticketData, ticketHmac });
expect(signIn.ok, JSON.stringify(signIn)).toBe(true);
fetchMock.mockClear();
const renewed = await authService.renewTokenFromTicket();
expect(renewed).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(errors).toEqual([]);
});
});

View file

@ -0,0 +1,209 @@
// @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<void> {
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();
});

136
utils/docver-ticket.ts Normal file
View file

@ -0,0 +1,136 @@
// 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<string, unknown>;
for (const key of Object.keys(record)) {
if (!(TICKET_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`,
};
}
}
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<string>;

View file

@ -2,12 +2,19 @@
* Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build) * Config ของ wordaddin — ค่าจาก NEXT_PUBLIC_* env (Next inline ตอน build)
* (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next) * (ต้นฉบับ D:\word-addin-poc\addin inject ผ่าน webpack DefinePlugin — พอร์ตมาเป็น env ของ Next)
* *
* ช่วง mock: authService/versioningService เป็น mock ไม่ใช้ apiBaseUrl — * ช่วง mock: versioningService เป็น mock ไม่ใช้ apiBaseUrl —
* ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http) * ตั้งค่านี้เมื่อปลด mock ต่อ backend จริง (ต้องเป็น HTTPS เสมอ — webview ของ Word บล็อก http)
* ส่วนการขอ token (ticket auth) ยิง same-origin /api/documents/token เสมอ ไม่ผูกกับ apiBaseUrl
*/ */
export const config = { export const config = {
/** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */ /** base URL ของ backend API (ไม่มี slash ท้าย) — ว่าง = ยังไม่ต่อ backend จริง */
apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "", apiBaseUrl: process.env.NEXT_PUBLIC_ADDIN_API_BASE_URL ?? "",
/** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */ /** ชื่อผู้ใช้ของ mock auth — แสดงหน้า header + เป็น createdByUserId ของ version ที่ mock สร้าง */
mockUsername: process.env.NEXT_PUBLIC_ADDIN_MOCK_USERNAME ?? "tester01", 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",
}; };

View file

@ -32,6 +32,8 @@ export interface VersionRecord {
/** ISO-8601 */ /** ISO-8601 */
createdAt: string; createdAt: string;
createdByUserId: string; createdByUserId: string;
/** ชื่อผู้สร้าง — ส่วนเสริมของ DTO กลุ่ม docver (mock/demo อาจไม่มี → UI fallback เป็น userId) */
createdByUserName?: string;
/** SHA256 hex */ /** SHA256 hex */
contentHash: string; contentHash: string;
fields: FieldsSnapshot | null; fields: FieldsSnapshot | null;

View file

@ -1,18 +1,20 @@
import { config } from "../config"; import { config } from "../config";
import { withBasePath } from "@/utils/base-path";
import * as authService from "./authService"; import * as authService from "./authService";
/** /**
* fetch wrapper ของ add-in — แนบ Bearer ทุก request * fetch wrapper ของ add-in — แนบ Bearer ทุก request (access_token ที่ authService
* โดน 401 → silent refresh แล้ว retry ครั้งเดียว; ยัง 401 → AuthRequiredError * ขอจาก /api/documents/token ด้วย ticket ของไฟล์ — PLAN-ADDIN-AUTH.md)
* (UI จับ error นี้แล้วพากลับหน้า sign-in อย่างเนียน — AC-9) * โดน 401 → ลองขอ token ใหม่จาก ticket แล้ว retry 1 ครั้ง ยังไม่ผ่าน → AuthRequiredError:
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\apiClient.ts * UI จับ error นี้แล้วพาไปหน้า no-access ให้ขอไฟล์ใหม่จาก e-Court
* พอร์ตจาก D:\word-addin-poc\addin\src\services\apiClient.ts
* (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError * (ช่วง mock ไม่มี caller จริง — เตรียมไว้ให้ versioningService วันปลด mock + AuthRequiredError
* ถูก TaskpaneApp ใช้อยู่แล้ว) * ถูก TaskpaneApp ใช้อยู่แล้ว)
*/ */
/** บอกว่าต้องกลับไป login ใหม่ (ไม่มี token / refresh ไม่ได้ / 401 ซ้ำ) */ /** บอกว่า ticket ใช้ไม่ได้แล้ว (หมดอายุ / token ขอไม่ได้) — ต้องได้ไฟล์ใหม่จาก e-Court */
export class AuthRequiredError extends Error { export class AuthRequiredError extends Error {
constructor(message = "เซสชันหมดอายุ — ต้อง login ใหม่") { constructor(message = "ticket ของ e-Court หมดอายุหรือไม่ถูกต้อง") {
super(message); super(message);
this.name = "AuthRequiredError"; this.name = "AuthRequiredError";
} }
@ -35,8 +37,15 @@ export interface ApiRequestOptions {
body?: BodyInit | null; 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 { function toUrl(path: string): string {
return path.startsWith("http") ? path : `${config.apiBaseUrl}${path}`; if (path.startsWith("http")) return path;
return config.apiBaseUrl ? `${config.apiBaseUrl}${path}` : withBasePath(path);
} }
async function doFetch( async function doFetch(
@ -54,27 +63,30 @@ async function doFetch(
} }
/** /**
* request พร้อม Bearer + retry-on-401 ครั้งเดียว * request พร้อม Bearer — โดน 401 → ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน
* Response ที่ได้คืนไปให้ caller เช็ค ok เอง (ยกเว้น 401 ที่ refresh ไม่ช่วย → AuthRequiredError) * (renewTokenFromTicket — ticket ยังไม่หมดอายุเท่านั้น) แล้ว retry 1 ครั้ง
* ยัง 401 อีก / renew ไม่ได้ = AuthRequiredError (UI พาไป no-access ให้ขอไฟล์
* ใหม่จาก e-Court) · Response ที่ได้คืนไปให้ caller เช็ค ok เอง
*/ */
export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> { export async function apiFetch(path: string, options: ApiRequestOptions = {}): Promise<Response> {
const accessToken = await authService.getAccessToken(); const accessToken = await authService.getAccessToken();
if (!accessToken) { if (!accessToken) {
throw new AuthRequiredError(); throw new AuthRequiredError();
} }
const response = await doFetch(path, options, accessToken);
let response = await doFetch(path, options, accessToken); if (response.status !== 401) {
if (response.status === 401) { return response;
const refreshedToken = await authService.refreshAccessToken(); }
if (!refreshedToken) { if (await authService.renewTokenFromTicket()) {
throw new AuthRequiredError(); const retryToken = await authService.getAccessToken();
} if (retryToken) {
response = await doFetch(path, options, refreshedToken); const retry = await doFetch(path, options, retryToken);
if (response.status === 401) { if (retry.status !== 401) {
throw new AuthRequiredError(); return retry;
}
} }
} }
return response; throw new AuthRequiredError();
} }
/** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */ /** อ่าน error message จาก response body — backend error ส่วนใหญ่เป็น plain text, บางทีเป็น JSON problem details */

View file

@ -1,97 +1,187 @@
/** /**
* MOCK — แทน Keycloak OIDC/PKCE ของต้นฉบับ (ผู้ใช้งานเลือกไม่ใช้ Keycloak ช่วงพอร์ต) * Auth ของ add-in — e-Court ticket ทางเข้าเดียว (PLAN-ADDIN-AUTH.md §5 ตาม
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\authService.ts (login ผ่าน Office Dialog API, * docs/requirement/addin-workflow.md)
* token เก็บใน memory เท่านั้น, auto-refresh)
* *
* วันสลับเป็น auth จริง: เขียน implementation ใหม่โดยคง export surface ด้านล่างครบทุกตัว * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data`
* (เพิ่ม refreshAccessToken ที่ต้นฉบับมีอยู่แล้วสำหรับ apiClient) — โค้ดอื่นไม่ต้องแตะ * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ
* HMAC → session ใน localStorage ตาม uid/fid → ขอ access_token จาก
* /api/documents/token เก็บตาม fid ("คีย์คือ fid") · exp ของ ticket ไม่ใช่
* เกณฑ์ตัดสิทธิ์ฝั่ง client — server ออก token ให้แม้ ticket หมดอายุเมื่อ
* เอกสารนี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7)
*
* token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่
* ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew
* ตอน token หมดอายุกลาง session
*/ */
import { config } from "../config"; import { config } from "../config";
import { isTicketExpired, parseDocverTicket } from "@/utils/docver-ticket";
import * as ticketService from "./ticketService";
/** discriminated union เหมือนต้นฉบับ — ok:false ต้องมี error เสมอ */ /** สาเหตุที่เข้าไม่ได้ — UI แยกข้อความตาม reason (TaskpaneApp) */
export type LoginResult = { ok: true } | { ok: false; error: string }; export type SignInFailureReason = "missing" | "tampered" | "expired" | "token-request-failed";
/** state เดียวของ mock — null = signed out */ export type SignInResult = { ok: true } | { ok: false; reason: SignInFailureReason; message: string };
/** state ของ taskpane instance นี้ (ต่อเอกสารที่เปิด) — null = ยังไม่ได้ sign in */
let username: string | null = null; let username: string | null = null;
/** token จาก custom properties ของไฟล์ (e-Court pass-through) — เก็บใน memory เท่านั้น */ /** fid ของเอกสารปัจจุบัน — token ที่ใช้ผูกกับ fid นี้เสมอ */
let docToken: string | null = null; let currentFid: string | null = null;
/** ticket ดิบของเอกสารปัจจุบัน — ไว้ขอ token ใหม่ตอน 401/token หมดอายุ */
let currentTicket: { data: string; hmac: string } | null = null;
/** โหมด ?addindemo (dev-only) — เปิดโดย signInForDemo เท่านั้น */
let demoMode = false;
function delay(ms: number): Promise<void> { /** dev-only — โหมด ?addindemo ของ WordAddinGate ใช้ตั้ง session ทันทีไม่อ่านไฟล์จริง */
return new Promise((resolve) => setTimeout(resolve, ms));
}
/** จำลองเปิดหน้าต่าง login — สำเร็จทันที ตั้งชื่อจาก NEXT_PUBLIC_ADDIN_MOCK_USERNAME */
export async function login(): Promise<LoginResult> {
await delay(300);
username = config.mockUsername;
return { ok: true };
}
/** mock-only — โหมด ?addindemo ใช้ตั้ง session ทันทีไม่ผ่านปุ่ม Sign in */
export function signInForDemo(): void { export function signInForDemo(): void {
username = config.mockUsername; username = config.mockUsername;
currentFid = null;
currentTicket = null;
demoMode = true;
} }
/** ถอด field จาก JWT payload (base64url) โดยไม่ตรวจ signature — ใช้ดู uid ประกอบชื่อผู้ใช้เท่านั้น */ /** โหมด ?addindemo หรือเปล่า — versioningService ใช้ mock แทน backend จริงเฉพาะโหมดนี้ */
function decodeJwtField(token: string, field: string): string | undefined { export function isDemoMode(): boolean {
const payload = token.split(".")[1]; return demoMode;
if (!payload) return undefined;
try {
// 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<string, unknown>)[field] : undefined;
return typeof value === "string" && value !== "" ? value : undefined;
} catch {
return undefined;
}
} }
/** /**
* e-Court pass-through (ระยะแรก) — ไฟล์ที่ e-Court stamp `token`/`owner` ไว้ใน custom properties * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7):
* ใช้งานได้เลยโดยไม่ต้องกด login: username จาก owner → uid ใน token → mockUsername * 1. ตรวจมี ticket_data + HmacSha256 ครบ
* คืน true = ผ่านเข้าได้ (มี token หรือ owner อย่างน้อยหนึ่งตัว) * 2. parse + ตรวจ HMAC ฝั่ง client (UX gate)
* 3. session ใน localStorage ตาม uid/fid — token ยังใช้ได้ = ผ่านเลย · ต้องขอ
* token ใหม่ = ยิงขอเสมอแล้วให้ **server เป็นคนตัดสิน** (ticket หมดอายุแต่
* เอกสารนี้เคยมี version โดย uid เดียวกัน = ออกให้ต่อ — requirement ขั้น 7
* กลับมาทำเอกสารเดิมได้แม้ ticket หมดอายุ แม้ session หายไปจากเครื่อง)
*/ */
export function signInFromDocument(input: { token?: string; owner?: string }): boolean { export async function signInWithTicket(input: {
const token = input.token?.trim() || undefined; ticketData?: string;
const owner = input.owner?.trim() || (token ? decodeJwtField(token, "uid") : undefined); ticketHmac?: string;
if (!token && !owner) { }): Promise<SignInResult> {
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 expiredMessage = {
ok: false as const,
reason: "expired" as const,
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
};
const session = ticketService.loadSession();
if (session === null || session.userId !== ticket.uid) {
// ไม่มี session / user ใหม่ — ขอ token ใหม่เสมอ (แม้ ticket หมดอายุ: server
// ออกให้ต่อเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน ซึ่งช่วยเคส localStorage หาย
// ของคนที่กลับมาทำเอกสารเดิมด้วย) แล้วเริ่ม session ใหม่ทั้งก้อน
let entry: ticketService.SessionTokenEntry;
try {
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
} catch {
return isTicketExpired(ticket.exp)
? expiredMessage
: {
ok: false,
reason: "token-request-failed",
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
};
}
ticketService.clearSession();
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 เสมอแล้วให้ server ตัดสิน (ticket ยังไม่หมด =
// ออกให้เสมอ · หมดแล้ว = ออกเฉพาะเอกสารนี้เคยทำงานโดย uid เดียวกัน)
try {
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
session.tokens[ticket.fid] = entry;
} catch {
// ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access
}
}
if (entry === null) {
// เอกสารใหม่ของคนเดิม — ขอ token เก็บเพิ่ม (server ตัดสินเรื่อง ticket หมดอายุ)
try {
session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac);
} catch {
return isTicketExpired(ticket.exp)
? expiredMessage
: {
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 ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401
* ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server
* ออกให้ต่อได้แม้ ticket หมดอายุเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน —
* requirement ขั้น 7) 401 กลับมา = false ตามเดิม
* คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่)
*/
export async function renewTokenFromTicket(): Promise<boolean> {
if (currentTicket === null || currentFid === null) return false;
const parsed = parseDocverTicket(currentTicket.data);
if (!parsed.ok) return false;
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;
} catch {
return false; return false;
} }
username = owner ?? config.mockUsername;
docToken = token ?? null;
return true;
} }
/** Bearer ของ apiClient — token ของ fid ปัจจุบัน (stale ได้ — apiClient เป็นคนตัดสิน 401) */
export async function getAccessToken(): Promise<string | null> { export async function getAccessToken(): Promise<string | null> {
if (docToken) return docToken; if (currentFid !== null) {
return ticketService.loadSession()?.tokens[currentFid]?.token ?? null;
}
// demo mode (?addindemo) — ไม่มี ticket ใช้ token ปลอมเหมือนเดิม
return username ? `mock-token-${username}` : null; return username ? `mock-token-${username}` : null;
} }
/** mock: token ไม่มีวันหมดอายุจริง — คืนตัวเดิมหรือ null ตามสถานะ login */
export async function refreshAccessToken(): Promise<string | null> {
return getAccessToken();
}
export function getUsername(): string | undefined { export function getUsername(): string | undefined {
return username ?? 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;
}

View file

@ -1,10 +1,11 @@
/* global Word, console */ /* global Word, Office, console */
/** /**
* Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8): * Metadata ในไฟล์ .docx ที่ add-in อ่าน/เขียน (spec ส่วน 8):
* - Custom Document Properties = primary flag ตรวจจับ tracked document * - Custom Document Properties = primary flag ตรวจจับ tracked document
* - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth * - Custom XML Part (ns `urn:myaddin:versioning`) = secondary เท่านั้น — backend คือ source of truth
* - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text * - readContentControlFields (FR-11) อ่านค่า content controls เป็น map tag→text
* (ทุกชนิดบน host WordApi 1.5+ — ด้วย supportedContentControls ด้านล่าง)
* พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts * พอร์ตดิบจาก D:\word-addin-poc\addin\src\services\metadataService.ts
* *
* กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw** * กติกา: ทุก Word.run ต้องจบด้วย context.sync(); XML part ฝั่งเขียน **ห้าม throw**
@ -23,9 +24,10 @@ export const LAST_VERSION_TIMESTAMP_PROPERTY = "LastVersionTimestamp";
/** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */ /** FR-16 — stamp เฉพาะไฟล์ร่าง (draft) ที่เว็บสร้าง: template ต้นทาง (ค่าเดียวกับ AddInMetadata.TemplateIdPropertyName ฝั่ง backend) */
export const TEMPLATE_ID_PROPERTY = "TemplateId"; export const TEMPLATE_ID_PROPERTY = "TemplateId";
/** e-Court pass-through — ไฟล์ที่ e-Court stamp มาใน custom properties เข้าใช้ได้เลยไม่ต้องกด login */ /** e-Court ticket — ไฟล์ที่ e-Court stamp คู่นี้มาใน custom properties คือ auth ทางเข้าเดียวของ add-in
export const ECOURT_TOKEN_PROPERTY = "token"; * (PLAN-ADDIN-AUTH.md §2: HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase 64 ตัว) */
export const ECOURT_OWNER_PROPERTY = "owner"; export const TICKET_DATA_PROPERTY = "ticket_data";
export const TICKET_HMAC_PROPERTY = "HmacSha256";
/** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */ /** ค่า property ที่หาไม่เจอเป็น `undefined` (ไม่ใช่ throw) */
export interface TrackedDocumentProperties { export interface TrackedDocumentProperties {
@ -35,9 +37,9 @@ export interface TrackedDocumentProperties {
lastVersionTimestamp?: string; lastVersionTimestamp?: string;
/** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */ /** มีเฉพาะไฟล์ร่าง (FR-16) — ส่งต่อให้ backend ตอน Start Versioning เพื่อผูก Document กับ template */
templateId?: string; templateId?: string;
/** e-Court pass-through — มีอย่างใดอย่างหนึ่ง = ข้ามหน้า login (username จาก owner หรือ uid ใน token) */ /** e-Court ticket — มีครบทั้งคู่ = ตรวจ HMAC แล้วเข้าใช้งานได้ (auth ทางเข้าเดียว) */
ecourtToken?: string; ticketData?: string;
ecourtOwner?: string; ticketHmac?: string;
} }
/** item ใน Custom XML Part `<VersionHistory>` */ /** item ใน Custom XML Part `<VersionHistory>` */
@ -49,7 +51,7 @@ export interface XmlVersionEntry {
} }
/** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16 /** อ่าน custom properties ทั้ง 7 ตัวที่ระบบสนใจ (4 ตัวของ tracked + TemplateId ของไฟล์ร่าง FR-16
* + token/owner ที่ e-Court stamp มากับไฟล์) */ * + ticket_data/HmacSha256 ที่ e-Court stamp มากับไฟล์) */
export async function readTrackedProperties(): Promise<TrackedDocumentProperties> { export async function readTrackedProperties(): Promise<TrackedDocumentProperties> {
return Word.run(async (context) => { return Word.run(async (context) => {
const properties = context.document.properties.customProperties; const properties = context.document.properties.customProperties;
@ -58,16 +60,16 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY); const lastVersionId = properties.getItemOrNullObject(LAST_VERSION_ID_PROPERTY);
const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY); const lastVersionTimestamp = properties.getItemOrNullObject(LAST_VERSION_TIMESTAMP_PROPERTY);
const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY); const templateId = properties.getItemOrNullObject(TEMPLATE_ID_PROPERTY);
const ecourtToken = properties.getItemOrNullObject(ECOURT_TOKEN_PROPERTY); const ticketData = properties.getItemOrNullObject(TICKET_DATA_PROPERTY);
const ecourtOwner = properties.getItemOrNullObject(ECOURT_OWNER_PROPERTY); const ticketHmac = properties.getItemOrNullObject(TICKET_HMAC_PROPERTY);
// load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync // load "value" ของ null-object proxy ก็ทำให้ isNullObject แม่นหลัง sync
savedByAddIn.load("value"); savedByAddIn.load("value");
documentTrackingId.load("value"); documentTrackingId.load("value");
lastVersionId.load("value"); lastVersionId.load("value");
lastVersionTimestamp.load("value"); lastVersionTimestamp.load("value");
templateId.load("value"); templateId.load("value");
ecourtToken.load("value"); ticketData.load("value");
ecourtOwner.load("value"); ticketHmac.load("value");
await context.sync(); await context.sync();
const read = (property: Word.CustomProperty): string | undefined => const read = (property: Word.CustomProperty): string | undefined =>
@ -81,8 +83,8 @@ export async function readTrackedProperties(): Promise<TrackedDocumentProperties
lastVersionId: read(lastVersionId), lastVersionId: read(lastVersionId),
lastVersionTimestamp: read(lastVersionTimestamp), lastVersionTimestamp: read(lastVersionTimestamp),
templateId: read(templateId), templateId: read(templateId),
ecourtToken: read(ecourtToken), ticketData: read(ticketData),
ecourtOwner: read(ecourtOwner), ticketHmac: read(ticketHmac),
}; };
}); });
} }
@ -230,14 +232,38 @@ export async function writeVersionHistoryToXml(entries: XmlVersionEntry[]): Prom
// Content controls → fields (FR-11) // Content controls → fields (FR-11)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น
* (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument)
* ย้ายมาจาก templateService (2026-10-28) ให้ readContentControlFields ใช้ร่วม —
* collection `contentControls` ธรรมดามองเห็นเฉพาะ rich text CC ทำให้ fields ตอน
* สร้าง version เหลือแค่ rich ตัวเดียว (เคสจริง T3: plain 26 + rich 1 = เห็นแค่ detail1) */
export function supportsAllContentControlTypes(): boolean {
try {
return Office.context.requirements.isSetSupported("WordApi", "1.5");
} catch {
return false;
}
}
/** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด)
* ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */
export function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection {
return supportsAllContentControlTypes()
? context.document.getContentControls()
: context.document.contentControls;
}
/** /**
* อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version * อ่าน content controls ทั้งเอกสารเป็น map `{ [tag]: text }` สำหรับแนบกับ upload version
* - ใช้ supportedContentControls → host 1.5+ เห็น CC ทุกชนิด (รวม plain text ซึ่งเป็น
* ส่วนใหญ่ของ template ศาล) ส่วน host ต่ำกว่า (Office 2019 = 1.3) เห็นเฉพาะ rich text
* เท่าเดิม — ห้ามยก MinVersion ของ manifest เพื่อปลดข้อจำกัดนี้ (บทเรียน 2026-10-16)
* - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error) * - ไม่มี content controls / tag ว่าง → ข้าม (คืน {} ไม่ error)
* - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร * - tag ซ้ำ → เอาตัวแรกตามลำดับในเอกสาร
*/ */
export async function readContentControlFields(): Promise<Record<string, string>> { export async function readContentControlFields(): Promise<Record<string, string>> {
return Word.run(async (context) => { return Word.run(async (context) => {
const controls = context.document.contentControls; const controls = supportedContentControls(context);
controls.load("tag, text"); controls.load("tag, text");
await context.sync(); await context.sync();

View file

@ -1,4 +1,4 @@
/* global Word, Office */ /* global Word */
/** /**
* แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือจัดการ Content Controls * แท็บ "เครื่องมือ Template" (แท็บที่สองของ taskpane) — เครื่องมือจัดการ Content Controls
@ -13,7 +13,8 @@
* Body.contentControls: "rich text content control objects") — template ของศาลส่วนใหญ่เป็น * Body.contentControls: "rich text content control objects") — template ของศาลส่วนใหญ่เป็น
* plain text CC จึงต้องใช้ `Document.getContentControls()` (WordApi 1.5, ครอบทุกชนิดที่รองรับ) * plain text CC จึงต้องใช้ `Document.getContentControls()` (WordApi 1.5, ครอบทุกชนิดที่รองรับ)
* ผ่าน feature-detect ตอน runtime แทนการยก MinVersion ของ manifest (Office 2019 = 1.3 * ผ่าน feature-detect ตอน runtime แทนการยก MinVersion ของ manifest (Office 2019 = 1.3
* ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน) * ต้องยังโหลดได้ — เห็นเฉพาะ rich text + UI แจ้งเตือน) — helper supportedContentControls
* อยู่ที่ metadataService (แชร์กับ readContentControlFields ของฝั่ง versioning ด้วย)
* *
* กติกาตาม repo: ห้ามมี Office/Word access ระดับ top-level (module นี้ถูก evaluate ใน * กติกาตาม repo: ห้ามมี Office/Word access ระดับ top-level (module นี้ถูก evaluate ใน
* browser ธรรมดาตอนโหมด `?addindemo` ที่ไม่มี Office global — top-level access จะ throw ก่อน UI ทัน render) · operation ที่เขียนเอกสารต้อง suspend/resume * browser ธรรมดาตอนโหมด `?addindemo` ที่ไม่มี Office global — top-level access จะ throw ก่อน UI ทัน render) · operation ที่เขียนเอกสารต้อง suspend/resume
@ -23,6 +24,7 @@
import { autoSaveService } from "./autoSaveService"; import { autoSaveService } from "./autoSaveService";
import { getDetailedErrorMessage } from "./errors"; import { getDetailedErrorMessage } from "./errors";
import { supportsAllContentControlTypes, supportedContentControls } from "./metadataService";
/** ผลสแกนหนึ่งช่อง — id ของ CC ไม่เสถียรข้าม session/coauthor (ตาม docstring ของ Office.js /** ผลสแกนหนึ่งช่อง — id ของ CC ไม่เสถียรข้าม session/coauthor (ตาม docstring ของ Office.js
* เอง: "The ID differs across sessions and coauthors") จึงใช้ได้เฉพาะอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง */ * เอง: "The ID differs across sessions and coauthors") จึงใช้ได้เฉพาะอายุผลสแกนรอบเดียว ห้ามเก็บไปใช้ภายหลัง */
@ -50,23 +52,10 @@ function toScannedField(control: Word.ContentControl): ScannedField {
}; };
} }
/** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — WordApi 1.5+ เท่านั้น /** host นี้มองเห็น CC ทุกชนิด (รวม plain text) ได้ไหม — ย้ายไปอยู่ที่ metadataService
* (เรียกนอก Word เช่น browser ธรรมดา → catch คืน false เสมอ ตามแบบ canCloseDocument) */ * (2026-10-28) ให้ readContentControlFields ของฝั่ง versioning ใช้ร่วม แล้ว re-export
export function supportsAllContentControlTypes(): boolean { * ไว้ที่นี่เพื่อให้ TemplateToolsPanel เรียกจาก templateService ได้ตามเดิม */
try { export { supportsAllContentControlTypes } from "./metadataService";
return Office.context.requirements.isSetSupported("WordApi", "1.5");
} catch {
return false;
}
}
/** collection ของ CC ที่ host รองรับสูงสุด — 1.5+ ใช้ getContentControls() (ทุกชนิด)
* ต่ำกว่านั้นต้องยอมรับ collection rich-text-only */
function supportedContentControls(context: Word.RequestContext): Word.ContentControlCollection {
return supportsAllContentControlTypes()
? context.document.getContentControls()
: context.document.contentControls;
}
/** สแกน Content Controls ทั้งเอกสาร (1 sync) — เรียงตามลำดับในเอกสาร · ไม่มีช่อง = คืน [] ไม่ error /** สแกน Content Controls ทั้งเอกสาร (1 sync) — เรียงตามลำดับในเอกสาร · ไม่มีช่อง = คืน [] ไม่ error
* อ่านอย่างเดียวจึงไม่ต้อง suspend auto-save */ * อ่านอย่างเดียวจึงไม่ต้อง suspend auto-save */

View file

@ -0,0 +1,176 @@
/**
* Ticket ของ add-in — ของมือถือ (primitives) ให้ authService ประกอบเป็น flow
* (PLAN-ADDIN-AUTH.md §5):
* - verifyTicketHmacWeb: ตรวจ HMAC ฝั่ง client ด้วย Web Crypto (UX gate — ตัดเคส
* ไฟล์ถูกแก้ก่อนยิง network ส่วนความมั่นคงจริงอยู่ที่ server ตรวจอีกชั้น)
* - session store: localStorage key เดียว `wordaddin-auth` — userId/userName +
* map token ต่อ fid · webview block localStorage = fallback memory ต่อ session
* (pattern เดียวกับ versioningService)
* - requestDocverToken: POST same-origin /api/documents/token แบบ body ticket —
* ไม่ผูกกับ config.apiBaseUrl (endpoint อยู่แอปนี้ ใช้ได้ทั้งช่วง mock และจริง)
*/
import { withBasePath } from "@/utils/base-path";
import { normalizeHexDigest } from "@/utils/docver-ticket";
/** key เดียวของ session — v ไว้ migrate รูปในอนาคต */
const SESSION_STORAGE_KEY = "wordaddin-auth";
export interface SessionTokenEntry {
token: string;
/** exp ของ JWT (unix วินาที) — decode จาก payload ตอนรับ */
exp: number;
}
export interface AddinSession {
userId: string;
userName: string;
/** token ต่อ fid — "คีย์คือ fid" ตาม requirement */
tokens: Record<string, SessionTokenEntry>;
}
/** 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<string> {
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<boolean> {
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<string, unknown>).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<SessionTokenEntry> {
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 };
}

View file

@ -1,26 +1,154 @@
/** /**
* MOCK — version API ฝั่ง client (รอ backend จริงตาม contract ใน docs/addin-ui-flow.md §7) * Version API ฝั่ง client — **ของจริง** (กลุ่ม docver /api/documents/* ผ่าน apiClient
* ต้นฉบับ: D:\word-addin-poc\addin\src\services\versioningService.ts (multipart POST ผ่าน apiClient) * ซึ่งแนบ Bearer แล้ว — contract ตาม docs/PLAN-DOCVER.md) · เหลือ mock เฉพาะโหมด
* `?addindemo` (dev-only ดู UI ใน browser ไม่ต้องมี backend/MinIO — authService.isDemoMode)
* *
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ — * - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ตามต้นฉบับ
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย * D:\word-addin-poc\addin\src\services\versioningService.ts — versionFlow เรียกต่อได้เดิม
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion * - **เอกสารถูกระบุด้วย fid ใน Bearer token** (ไม่ใช่ documentId GUID ที่ stamp ในไฟล์ —
* - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ * 2026-10-26 contract ถอด {docId} ออกจาก URL ทุก endpoint) พารามิเตอร์ documentId จึงเหลือ
* POST /api/documents/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400) และไม่มี * ไว้เพื่อ signature เดิมเท่านั้น ไม่ถูกส่งถึง server · templateId ก็เช่นกัน (มาจาก claims
* segment {docId} ใน URL อีกต่อไป: server แกะ identity/template และ docId * ของ token — ส่งมาใน form = 400)
* (= fid — fileId ของ e-Court, string) จาก Bearer token (ออกที่ POST * - **ห้ามส่ง userId/userName/templateId ใน FormData** (contract 2026-10-22 — ส่งมา = 400)
* /api/documents/token ด้วย x-api-key, fid บังคับ) ที่ apiClient.ts แนบให้อยู่แล้ว * - mock ยัง persist metadata ลง localStorage ต่อ documentId เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) — * ในโหมด demo — blob ไฟล์จริงเฉพาะ session; localStorage เข้าไม่ได้ (webview บางตัว block)
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata * = ใช้ memory อย่างเดียว ไม่ throw
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
* ยังเก็บเฉพาะ session (mock download คืน about:blank อยู่แล้ว) — localStorage เข้า
* ไม่ได้ (webview บางตัว block) = ใช้ memory อย่างเดียวแบบเดิม ไม่ throw
*/ */
import * as authService from "./authService"; import * as authService from "./authService";
import { generateGuid } from "./metadataService"; import { generateGuid } from "./metadataService";
import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient";
import { UploadVersionResult, VersionRecord } from "../models/versionRecord"; 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<string, string> | 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<string, string>,
fileName?: string,
message?: string,
pdf?: Blob,
templateId?: string
): Promise<UploadVersionResult> {
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<UploadVersionResult>(
"/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<VersionRecord[]> {
if (!authService.isDemoMode()) {
let rows: DocumentVersionDTO[];
try {
rows = await apiGetJson<DocumentVersionDTO[]>("/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<VersionDownloadLink> {
if (!authService.isDemoMode()) {
void documentId; // เอกสารมาจาก fid ใน token — versionId เช็คคู่กับ docId ฝั่ง server
const query = format && format !== "docx" ? `?format=${encodeURIComponent(format)}` : "";
return apiGetJson<VersionDownloadLink>(
`/api/documents/versions/${encodeURIComponent(versionId)}/download-link${query}`,
"ขอลิงก์ดาวน์โหลดไม่สำเร็จ",
);
}
return getDownloadLinkMock();
}
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
export function newClientVersionId(): string {
return generateGuid();
}
// ---------------------------------------------------------------------------
// Mock — เฉพาะโหมด ?addindemo (dev-only)
// ---------------------------------------------------------------------------
interface StoredVersion { interface StoredVersion {
record: VersionRecord; record: VersionRecord;
docxBlob: Blob | null; docxBlob: Blob | null;
@ -78,22 +206,15 @@ async function mockContentHash(file: Blob): Promise<string> {
} }
} }
/** FR-5 — อัปโหลดไฟล์ปัจจุบันเป็น version ใหม่ (mock: เก็บลง memory) async function uploadVersionMock(
* + FR-13: message คือ commit message ทางเลือก
* + FR-16: templateId เฉพาะไฟล์ร่าง (mock ไม่ใช้ — คงไว้ตาม signature ต้นฉบับ) */
export async function uploadVersion(
documentId: string, documentId: string,
file: Blob, file: Blob,
clientVersionId: string, clientVersionId: string,
fields?: Record<string, string>, fields?: Record<string, string>,
fileName?: string,
message?: string, message?: string,
pdf?: Blob, pdf?: Blob
templateId?: string
): Promise<UploadVersionResult> { ): Promise<UploadVersionResult> {
void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง void clientVersionId; // mock ไม่ใช้ — id ของ mock สร้างเอง
void fileName;
void templateId;
await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง await delay(500); // ให้ข้อความ progress ของ flow ขึ้นจริง
const versions = store.get(documentId) ?? []; const versions = store.get(documentId) ?? [];
@ -117,8 +238,7 @@ export async function uploadVersion(
return { hasPdf, versionId: record.versionId, versionNumber }; return { hasPdf, versionId: record.versionId, versionNumber };
} }
/** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) */ async function getVersionsMock(documentId: string): Promise<VersionRecord[]> {
export async function getVersions(documentId: string): Promise<VersionRecord[]> {
await delay(200); await delay(200);
const versions = store.get(documentId) ?? []; const versions = store.get(documentId) ?? [];
return versions return versions
@ -127,33 +247,7 @@ export async function getVersions(documentId: string): Promise<VersionRecord[]>
.map((item) => ({ ...item.record })); .map((item) => ({ ...item.record }));
} }
export interface VersionDownloadLink { async function getDownloadLinkMock(): Promise<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<VersionDownloadLink> {
void documentId;
void versionId;
void format;
await delay(150); await delay(150);
return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() }; return { url: "about:blank", expiresAt: new Date(Date.now() + 10 * 60_000).toISOString() };
} }
/** สร้าง clientVersionId ใหม่สำหรับ upload ครั้งถัดไป */
export function newClientVersionId(): string {
return generateGuid();
}