diff --git a/CLAUDE.md b/CLAUDE.md index b03c63a..38926f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -40,6 +40,7 @@ scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min. scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile) scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) scripts/make-ticket-docx.mjs ← dev tool รันมือ (ไม่ใช่ postinstall): stamp ticket ขอ e-Court (`ticket_data` + `HmacSha256`) ลงไฟล์ .docx ใด ๆ ผ่าน pizzip เพื่อทดสอบ ticket auth ของ add-in ใน Word จริงโดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง (key เรียงตามตัวอักษรหลังเติม exp + เซ็นบน string ดิบ hex lowercase) · ตัวเลือก --fid/--uid/--uname/--tid/--exp /--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9) +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/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) @@ -53,12 +54,12 @@ lib/external-api.ts ← N10 ส่วนกลางของ route — parseQ lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) -lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) -wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = ของจริง** (ปลด mock 2026-10-27 — uploadVersion/getVersions/getDownloadLink ยิงกลุ่ม `/api/documents/*` ผ่าน apiClient (Bearer same-origin ผ่าน withBasePath — `NEXT_PUBLIC_ADDIN_API_BASE_URL` ตั้งเฉพาะวัน deploy คนละ origin ต้องรวม basePath); เอกสารระบุด้วย fid ใน token — พารามิเตอร์ documentId/templateId เหลือตาม signature เดิมไม่ส่งถึง server และ **ห้ามส่ง userId/userName/templateId ใน form (contract 2026-10-22 ส่งมา = 400)**; mock เหลือเฉพาะโหมด `?addindemo` ผ่าน `authService.isDemoMode()` — memory + metadata persist ลง localStorage ต่อ documentId, download-link คืน about:blank; unit test tests/wordaddin/versioningService.test.ts คุมทั้งสองโหมด) +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 คุมทั้งสองโหมด) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts index 2173fe2..ed154dc 100644 --- a/app/api/documents/token/route.ts +++ b/app/api/documents/token/route.ts @@ -3,9 +3,10 @@ // 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY // (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง) // 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server -// ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → +// ตรวจ HMAC ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → // "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket -// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) +// (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 กับ // endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] @@ -15,8 +16,10 @@ import { addinTicketSecret, parseTicketTokenBody, ticketToDocverClaims, - verifyAddinTicket, + verifyAddinTicketHmac, } from "@/lib/addin-ticket"; +import { hasVersionByUser } from "@/lib/docver-store"; +import { isTicketExpired } from "@/utils/docver-ticket"; import { issueDocverToken, parseTokenRequestBody, @@ -66,8 +69,22 @@ async function postWithTicket(request: Request): Promise { // invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401); } - const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); + // ตรวจ 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)); } diff --git a/docs/PLAN-ADDIN-AUTH.md b/docs/PLAN-ADDIN-AUTH.md index a6b8f62..b2afb9f 100644 --- a/docs/PLAN-ADDIN-AUTH.md +++ b/docs/PLAN-ADDIN-AUTH.md @@ -59,9 +59,10 @@ Branch ตาม header (ตัวตั้ง = `x-api-key`): | เคส | พฤติกรรม | |---|---| | มี header `x-api-key` | **ทางเดิมทุกอย่าง** — `requireDocverApiKey` + `parseTokenRequestBody` (body allowlist `userid`/`username`/`fid`/`template`, ส่ง field ticket ปนมา = 400 ตาม allowlist เดิม) | -| ไม่มี `x-api-key` + body `{ticket_data: string, HmacSha256: string}` | **ทางใหม่** — `verifyAddinTicket` → map claims → `issueDocverToken` → response รูปเดียวกัน `{access_token, token_type, expires_in}` | +| ไม่มี `x-api-key` + body `{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 ไม่ผ่าน / `exp` หมดอายุ | 401 **ข้อความเดียวกันทุกสาเหตุ** (กัน oracle — addin รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) | +| 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 (เดิม) | @@ -94,14 +95,16 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข" 3. session = localStorage["wordaddin-auth"] (fallback memory) 4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่): - · ticket หมดอายุ → no-access "ticket หมดอายุ" - · เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ" - · เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}} + · ขอ 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] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร) - — token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6) - — token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน - · tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4 + · 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) ไม่เปลี่ยน ``` @@ -163,7 +166,10 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise - **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว — เช็คความยาวเพื่อข้อความที่ชัดกว่า) - **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว - (เปิดใหม่เริ่มใหม่ ขอ token ใหม่ — ยอมรับได้) + (เปิดใหม่เริ่มใหม่ ขอ 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 @@ -191,7 +197,9 @@ Dev tool รันมือ `node scripts/make-ticket-docx.mjs [opti (ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400 4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx: ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · - exp อดีต → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ → ไม่ยิง /token ซ้ำ (ใช้ token เดิม) · + 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/` diff --git a/docs/WORDADDIN-DEV.md b/docs/WORDADDIN-DEV.md index 301bf33..252aa0f 100644 --- a/docs/WORDADDIN-DEV.md +++ b/docs/WORDADDIN-DEV.md @@ -33,7 +33,27 @@ manifest ต้องชี้ https — 2 ทางเลือก: 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` -## 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 เป็น `.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 /f` หรือลบไฟล์ในโฟลเดอร์ wef เอง) · ⚠️ ไฟล์ .ps1 ต้องมี **BOM** ติดตัวมา (PowerShell 5.1 อ่าน UTF-8 ไร้ BOM เป็น ANSI แล้วข้อความไทยพัง) — แก้ไฟล์แล้วอย่าลืมเซฟแบบ UTF-8 with BOM +- รันซ้ำได้ (idempotent) — โหลด manifest ใหม่ทุกครั้งจึงได้ URL ของ environment ปัจจุบันเสมอ + +## Sideload แบบ manual (Windows) เครื่อง dev ที่มี repo (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย): @@ -49,6 +69,14 @@ 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 อยู่ดี +### 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` (สคริปต์ด้านบนตั้งชื่อ `.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) ## ทดสอบใน browser ธรรมดา (ไม่ต้องเปิด Word) @@ -72,7 +100,7 @@ node scripts/make-ticket-docx.mjs path/to/file.ticket.docx --list node scripts/make-ticket-docx.mjs path/to/file.docx --tracked --in-place ``` -แล้วเปิดไฟล์ใน Word ที่ sideload แล้ว → ต้องเข้าเฟส untracked (หรือ tracking เมื่อ `--tracked`) ทันที ชื่อผู้ใช้ = `uname` · ทดสอบ fail-case: แก้ `ticket_data` ผ่าน **File → Info → Properties → Advanced Properties → Custom** สัก 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" · สร้างด้วย `--exp +1` รอ 1 นาทีแล้วเปิด → no-access "ticket หมดอายุ" · เปิดเอกสารเดิมซ้ำ (จน ticket หมดอายุแต่ token ยังอยู่ใน localStorage) → ยังใช้ได้ตาม requirement ขั้น 7 +แล้วเปิดไฟล์ใน 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) หมายเหตุ: การขอ 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` ของสคริปต์ diff --git a/lib/addin-ticket.ts b/lib/addin-ticket.ts index 766db45..8ca876b 100644 --- a/lib/addin-ticket.ts +++ b/lib/addin-ticket.ts @@ -33,15 +33,15 @@ export function addinTicketSecret(): string { } /** - * ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา - * (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle — - * add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว) + * ตรวจ ticket แบบไม่สน exp (HMAC → parse) — ใช้เมื่อ route จะตัดสิน "ticket หมดอายุ + * แต่เอกสารนี้เคยทำงานโดย uid เดียวกัน" เองผ่าน DB (requirement ขั้น 7: กลับมาทำ + * เอกสารเดิมผ่านได้แม้ ticket หมดอายุ) — ตัวตนของผู้ถือยังยึด HMAC ของ e-Court + * เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้ */ -export function verifyAddinTicket( +export function verifyAddinTicketHmac( ticketData: string, hmacHex: string, secret: string, - nowSec: number = Math.floor(Date.now() / 1000), ): { ok: true; ticket: DocverTicket } | { ok: false } { const digest = normalizeHexDigest(hmacHex); if (digest === null) return { ok: false }; @@ -55,10 +55,26 @@ export function verifyAddinTicket( const parsed = parseDocverTicket(ticketData); if (!parsed.ok) return { ok: false }; - if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false }; return { ok: true, ticket: parsed.ticket }; } +/** + * ตรวจ ticket เต็มชุด (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 }; diff --git a/lib/docver-store.ts b/lib/docver-store.ts index 6191716..08a6575 100644 --- a/lib/docver-store.ts +++ b/lib/docver-store.ts @@ -206,6 +206,22 @@ export async function findVersionForDoc( 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 { + 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 = { /** กำหนดเอง (ไม่ปล่อย defaultRandom) เพราะ object ใน MinIO เขียนด้วย uuid นี้ไปก่อนแล้ว */ versionId: string; diff --git a/lib/openapi.ts b/lib/openapi.ts index b68a269..7700d71 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -629,8 +629,9 @@ export const openApiSpec = { "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"`) และเวลาหมดอายุ (`exp`) ของ ticket ' + - "ก่อน แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง\n\n" + + '(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง · ' + + "**ticket หมดอายุ** ยังออกให้ต่อเมื่อเอกสารนี้ (`fid`) เคยมี version ที่สร้างโดย `uid` เดียวกัน " + + "(ผู้ที่เคยทำงานกับเอกสารเดิมกลับมาใช้ต่อได้แม้ ticket หมดอายุ — ไม่เคยทำงาน = 401)\n\n" + "ได้ token แล้วแนบเป็น `Authorization: Bearer ` กับทุก request ถัดไป", security: [{ ApiKeyAuth: [] }], requestBody: { diff --git a/package.json b/package.json index fabdcde..aabee40 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "test": "vitest run", "start": "next start", "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", "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" diff --git a/scripts/install-wordaddin.mjs b/scripts/install-wordaddin.mjs new file mode 100644 index 0000000..f1fe118 --- /dev/null +++ b/scripts/install-wordaddin.mjs @@ -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: วางไฟล์ .manifest.xml ใน +// ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/ (Word สแกน *.xml ทุกไฟล์) +// +// ใช้: node scripts/install-wordaddin.mjs [options] +// --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 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 จาก — ไม่ 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("([0-9a-fA-F-]{36})<\/Id>/); + if (!idMatch) { + fail(`หา (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(/([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 /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(" 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)); +}); diff --git a/scripts/install-wordaddin.ps1 b/scripts/install-wordaddin.ps1 new file mode 100644 index 0000000..5d6469d --- /dev/null +++ b/scripts/install-wordaddin.ps1 @@ -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 ] [-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 /f" + } + $stored = Get-Content -Raw -Encoding UTF8 $ManifestPath + if ($stored -notmatch "([0-9a-fA-F-]{36})") { + 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 "([0-9a-fA-F-]{36})") { + Fail "หา (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 diff --git a/scripts/install-wordaddin.sh b/scripts/install-wordaddin.sh new file mode 100755 index 0000000..3e8a699 --- /dev/null +++ b/scripts/install-wordaddin.sh @@ -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 แล้ววางเป็น .manifest.xml +# ใน ~/Library/Containers/com.microsoft.Word/Data/Documents/wef/ ของ Word +# +# ใช้: ./install-wordaddin.sh [--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 < 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 < 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 " (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 diff --git a/tests/lib/addin-ticket.test.ts b/tests/lib/addin-ticket.test.ts index 5c75931..d5003ff 100644 --- a/tests/lib/addin-ticket.test.ts +++ b/tests/lib/addin-ticket.test.ts @@ -6,6 +6,7 @@ import { parseTicketTokenBody, ticketToDocverClaims, verifyAddinTicket, + verifyAddinTicketHmac, } from "@/lib/addin-ticket"; // PLAN-ADDIN-AUTH.md §3 — pure ล้วน (verifyAddinTicket รับ secret ทาง parameter) @@ -73,6 +74,26 @@ describe("verifyAddinTicket", () => { }); }); +// 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( diff --git a/tests/wordaddin/ticket-auth-flow.test.ts b/tests/wordaddin/ticket-auth-flow.test.ts index 250a5fa..0ab6938 100644 --- a/tests/wordaddin/ticket-auth-flow.test.ts +++ b/tests/wordaddin/ticket-auth-flow.test.ts @@ -1,7 +1,9 @@ // @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 +// 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"; @@ -16,9 +18,9 @@ function trapErrors() { window.addEventListener("unhandledrejection", (event) => errors.push(event.reason)); } -function makeTicket(): { ticketData: string; ticketHmac: string } { +function makeTicket(expSec?: number): { ticketData: string; ticketHmac: string } { const ticket = { - exp: Math.floor(Date.now() / 1000) + 300, + exp: expSec ?? Math.floor(Date.now() / 1000) + 300, fid: "DBG_FID_001", tid: "T12", uid: "oom", @@ -114,3 +116,112 @@ describe("signInWithTicket (service layer)", () => { 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([]); + }); +}); diff --git a/wordaddin/services/authService.ts b/wordaddin/services/authService.ts index 6904876..0c22b6a 100644 --- a/wordaddin/services/authService.ts +++ b/wordaddin/services/authService.ts @@ -4,8 +4,10 @@ * * ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data` * + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ - * HMAC → exp → session ใน localStorage ตาม uid/fid แล้วขอ access_token จาก - * /api/documents/token เก็บตาม fid ("คีย์คือ fid") + * 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 @@ -47,9 +49,10 @@ export function isDemoMode(): boolean { * e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7): * 1. ตรวจมี ticket_data + HmacSha256 ครบ * 2. parse + ตรวจ HMAC ฝั่ง client (UX gate) - * 3. session ใน localStorage: ยังไม่มี/user ใหม่ → ตรวจ exp → เคลียร์ session เก่า - * แล้วขอ token ใหม่ · user เดิม + fid เดิม → ผ่านเลย (renew เงียบ ๆ ถ้า token - * หมดอายุแต่ ticket ยังไม่หมด) · user เดิม + fid ใหม่ → ตรวจ exp → ขอ token เก็บเพิ่ม + * 3. session ใน localStorage ตาม uid/fid — token ยังใช้ได้ = ผ่านเลย · ต้องขอ + * token ใหม่ = ยิงขอเสมอแล้วให้ **server เป็นคนตัดสิน** (ticket หมดอายุแต่ + * เอกสารนี้เคยมี version โดย uid เดียวกัน = ออกให้ต่อ — requirement ขั้น 7 + * กลับมาทำเอกสารเดิมได้แม้ ticket หมดอายุ แม้ session หายไปจากเครื่อง) */ export async function signInWithTicket(input: { ticketData?: string; @@ -82,63 +85,57 @@ export async function signInWithTicket(input: { }; } const ticket = parsed.ticket; - const ticketAlive = !isTicketExpired(ticket.exp); + 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 ใหม่ — ตรวจ exp ก่อนแล้วเริ่ม session ใหม่ทั้งก้อน - if (!ticketAlive) { - return { - ok: false, - reason: "expired", - message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", - }; - } - ticketService.clearSession(); + // ไม่มี session / user ใหม่ — ขอ token ใหม่เสมอ (แม้ ticket หมดอายุ: server + // ออกให้ต่อเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน ซึ่งช่วยเคส localStorage หาย + // ของคนที่กลับมาทำเอกสารเดิมด้วย) แล้วเริ่ม session ใหม่ทั้งก้อน let entry: ticketService.SessionTokenEntry; try { entry = await ticketService.requestDocverToken(ticketData, ticketHmac); } catch { - return { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; + 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 ได้เฉพาะตอน ticket ยังไม่หมด; ticket หมดด้วยก็เก็บของ - // เก่าไว้ (requirement ขั้น 7: ผ่านเลยไม่ตรวจ exp) ให้ server ตัดสินตอนใช้งาน - if (ticketAlive) { - try { - entry = await ticketService.requestDocverToken(ticketData, ticketHmac); - session.tokens[ticket.fid] = entry; - } catch { - // ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access - } + // 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) { - // เอกสารใหม่ของคนเดิม — ตรวจ exp แล้วขอ token เก็บเพิ่ม - if (!ticketAlive) { - return { - ok: false, - reason: "expired", - message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง", - }; - } + // เอกสารใหม่ของคนเดิม — ขอ token เก็บเพิ่ม (server ตัดสินเรื่อง ticket หมดอายุ) try { session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac); } catch { - return { - ok: false, - reason: "token-request-failed", - message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", - }; + return isTicketExpired(ticket.exp) + ? expiredMessage + : { + ok: false, + reason: "token-request-failed", + message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง", + }; } } ticketService.saveSession(session); @@ -152,14 +149,16 @@ export async function signInWithTicket(input: { } /** - * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) — - * เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ + * ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401 + * ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server + * ออกให้ต่อได้แม้ ticket หมดอายุเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน — + * requirement ขั้น 7) 401 กลับมา = false ตามเดิม * คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่) */ export async function renewTokenFromTicket(): Promise { if (currentTicket === null || currentFid === null) return false; const parsed = parseDocverTicket(currentTicket.data); - if (!parsed.ok || isTicketExpired(parsed.ticket.exp)) return false; + if (!parsed.ok) return false; try { const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac); const session =