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
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.
This commit is contained in:
parent
3726cb742f
commit
b1449ab668
14 changed files with 727 additions and 75 deletions
|
|
@ -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/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/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 — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
|
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี 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/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย
|
||||||
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param)
|
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + 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/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/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
|
lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net)
|
||||||
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
||||||
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner)
|
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner)
|
||||||
wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ)
|
wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ)
|
||||||
wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด — helper `supportedContentControls`/`supportsAllContentControlTypes` อยู่ที่ **metadataService** แชร์กับ `readContentControlFields` (fields ที่แนบตอนสร้าง version เก็บ CC ทุกตัวแก้ 2026-10-28 — เดิมเหลือแค่ rich ตัวเดียว), ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → 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/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 ใช้ร่วม)
|
||||||
|
|
|
||||||
|
|
@ -3,9 +3,10 @@
|
||||||
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY
|
||||||
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง)
|
||||||
// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server
|
// 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
|
// "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
|
// ทั้งสองทางออก 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]
|
||||||
|
|
@ -15,8 +16,10 @@ import {
|
||||||
addinTicketSecret,
|
addinTicketSecret,
|
||||||
parseTicketTokenBody,
|
parseTicketTokenBody,
|
||||||
ticketToDocverClaims,
|
ticketToDocverClaims,
|
||||||
verifyAddinTicket,
|
verifyAddinTicketHmac,
|
||||||
} from "@/lib/addin-ticket";
|
} from "@/lib/addin-ticket";
|
||||||
|
import { hasVersionByUser } from "@/lib/docver-store";
|
||||||
|
import { isTicketExpired } from "@/utils/docver-ticket";
|
||||||
import {
|
import {
|
||||||
issueDocverToken,
|
issueDocverToken,
|
||||||
parseTokenRequestBody,
|
parseTokenRequestBody,
|
||||||
|
|
@ -66,8 +69,22 @@ async function postWithTicket(request: Request): Promise<Response> {
|
||||||
// invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ
|
// invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ
|
||||||
return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401);
|
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);
|
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));
|
return issueToken(ticketToDocverClaims(verified.ticket));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 เดิม) |
|
| มี 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) |
|
| ไม่มี `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 |
|
| ไม่มี `x-api-key` และไม่มี ticket fields เลย | 401 |
|
||||||
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) |
|
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 (เดิม) |
|
||||||
|
|
||||||
|
|
@ -94,14 +95,16 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise<string>
|
||||||
HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข"
|
HMAC (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET) ไม่ตรง → no-access "ไฟล์ถูกแก้ไข"
|
||||||
3. session = localStorage["wordaddin-auth"] (fallback memory)
|
3. session = localStorage["wordaddin-auth"] (fallback memory)
|
||||||
4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่):
|
4. ไม่มี session หรือ uid ≠ session.userId (user ใหม่):
|
||||||
· ticket หมดอายุ → no-access "ticket หมดอายุ"
|
· ขอ token จาก /token เลย — **server เป็นคนตัดสิน exp** (แก้ 2026-10-28: ticket หมดอายุ
|
||||||
· เคลียร์ session ทั้งก้อน → ขอ token (ทาง ticket ของ /token) → fail → no-access "ขอ token ไม่สำเร็จ"
|
แต่ fid นี้เคยมี version โดย uid เดียวกัน = ออกให้ → คนที่กลับมาทำเอกสารเดิม
|
||||||
· เก็บ session ใหม่ {userId: uid, userName: uname, tokens: {[fid]: {token, exp}}}
|
ผ่านได้แม้ session หาย/เคลียร์ · 401 + ticket หมดอายุ → no-access "ticket หมดอายุ")
|
||||||
|
· สำเร็จ → เคลียร์ session เก่า → เก็บ session ใหม่ {userId: uid, …}
|
||||||
5. uid ตรงกัน (คนเดิม):
|
5. uid ตรงกัน (คนเดิม):
|
||||||
· tokens[fid] มี (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ตรวจ ticket exp (ตามเอกสาร)
|
· tokens[fid] มีและยังไม่หมดอายุ (กลับมาทำเอกสารเดิม) → ผ่านเลย ไม่ยิง /token
|
||||||
— token หมดอายุ + ticket ยังไม่หมด → renew เงียบ ๆ ก่อน (D6)
|
· tokens[fid] มีแต่ token หมดอายุ → ยิง renew เสมอ (ticket ยังไม่หมด = ออกให้เสมอ ·
|
||||||
— token หมดอายุ + ticket หมดด้วย → เข้า UI ได้แต่ API จะ 401 → renew fail → no-access ตอนใช้งาน
|
หมดแล้ว = ออกเฉพาะ "เคยทำงาน" ตามข้อ 4) → fail = เก็บ token เก่าไว้
|
||||||
· tokens[fid] ไม่มี (เอกสารใหม่) → ตรวจ exp แล้วขอ token เก็บเหมือนข้อ 4
|
(ถ้าเน่าจริง apiClient จะ 401 → renew 1 ครั้ง → ยัง fail = no-access ตอนใช้งาน)
|
||||||
|
· tokens[fid] ไม่มี (เอกสารใหม่) → ขอ token เก็บเพิ่มเหมือนข้อ 4
|
||||||
6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase
|
6. ตั้ง state ภายใน (fid/token/username/ticket ดิบไว้ renew) → ไหลต่อ phase
|
||||||
untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน
|
untracked/tracking เดิม (SavedByAddIn → history) ไม่เปลี่ยน
|
||||||
```
|
```
|
||||||
|
|
@ -163,7 +166,10 @@ export type HmacSha256Hex = (message: string, secret: string) => Promise<string>
|
||||||
- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว —
|
- **property ยาวเกิน 255** → ไฟล์เสียหาย → no-access (Word ตัดค่าทิ้งจะ parse/HMAC ไม่ผ่านอยู่แล้ว —
|
||||||
เช็คความยาวเพื่อข้อความที่ชัดกว่า)
|
เช็คความยาวเพื่อข้อความที่ชัดกว่า)
|
||||||
- **localStorage ถูก block** (webview บางตัว/private mode) → session อยู่ใน memory ต่อ session เดียว
|
- **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)
|
- **uname ไทย** — เป็น string ตรง ๆ ไม่ผ่าน base64 ไม่มีปัญหา mojibake (ต่างจาก decode JWT payload)
|
||||||
- **exp == now** → หมดอายุ
|
- **exp == now** → หมดอายุ
|
||||||
- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access
|
- **401 จาก endpoint อื่นระหว่างใช้งาน** → renew 1 ครั้ง → retry → ยังไม่ผ่าน → no-access
|
||||||
|
|
@ -191,7 +197,9 @@ Dev tool รันมือ `node scripts/make-ticket-docx.mjs <file.docx> [opti
|
||||||
(ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400
|
(ข) ticket body ออก token ได้ (ค) HMAC ผิดกับ exp หมด = 401 ข้อความเดียวกัน (ง) body ปนกัน = 400
|
||||||
4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx:
|
4. ทดสอบใน Word จริง (sideload ตาม `docs/WORDADDIN-DEV.md`) ด้วยไฟล์จาก make-ticket-docx:
|
||||||
ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = uname · แก้ `ticket_data` 1 ตัวอักษร → no-access "ไฟล์ถูกแก้ไข" ·
|
ไฟล์ถูกต้อง → ใช้ได้ ชื่อผู้ใช้ = 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 อัตโนมัติใช้ต่อได้ ·
|
ตั้ง `DOCVER_TOKEN_TTL_SECONDS` สั้น ๆ แล้วเปิดซ้ำหลังหมดอายุ → renew อัตโนมัติใช้ต่อได้ ·
|
||||||
เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์
|
เปิดไฟล์ uid คนละคน → session เดิมถูกเคลียร์
|
||||||
5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
|
5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
|
||||||
|
|
|
||||||
|
|
@ -33,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 (แนะนำ — ไม่ต้องดาวน์โหลด/คัดลอกอะไรเลย):
|
||||||
|
|
||||||
|
|
@ -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 อยู่ดี
|
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)
|
||||||
|
|
@ -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
|
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` ของสคริปต์
|
หมายเหตุ: การขอ 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` ของสคริปต์
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -33,15 +33,15 @@ export function addinTicketSecret(): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ตรวจ ticket เต็มชุด (HMAC → parse → exp) — ไม่แยกสาเหตุออกมา
|
* ตรวจ ticket แบบไม่สน exp (HMAC → parse) — ใช้เมื่อ route จะตัดสิน "ticket หมดอายุ
|
||||||
* (route ตอบ 401 ข้อความเดียวกันทุกกรณี กันใช้ endpoint เป็น oracle —
|
* แต่เอกสารนี้เคยทำงานโดย uid เดียวกัน" เองผ่าน DB (requirement ขั้น 7: กลับมาทำ
|
||||||
* add-in รู้สาเหตุจริงจากการตรวจเองฝั่ง client อยู่แล้ว)
|
* เอกสารเดิมผ่านได้แม้ ticket หมดอายุ) — ตัวตนของผู้ถือยังยึด HMAC ของ e-Court
|
||||||
|
* เหมือนเดิม แค่ไม่ใช้อายุ ticket เป็นเกณฑ์ตัดสิทธิ์ ณ จุดนี้
|
||||||
*/
|
*/
|
||||||
export function verifyAddinTicket(
|
export function verifyAddinTicketHmac(
|
||||||
ticketData: string,
|
ticketData: string,
|
||||||
hmacHex: string,
|
hmacHex: string,
|
||||||
secret: string,
|
secret: string,
|
||||||
nowSec: number = Math.floor(Date.now() / 1000),
|
|
||||||
): { ok: true; ticket: DocverTicket } | { ok: false } {
|
): { ok: true; ticket: DocverTicket } | { ok: false } {
|
||||||
const digest = normalizeHexDigest(hmacHex);
|
const digest = normalizeHexDigest(hmacHex);
|
||||||
if (digest === null) return { ok: false };
|
if (digest === null) return { ok: false };
|
||||||
|
|
@ -55,10 +55,26 @@ export function verifyAddinTicket(
|
||||||
|
|
||||||
const parsed = parseDocverTicket(ticketData);
|
const parsed = parseDocverTicket(ticketData);
|
||||||
if (!parsed.ok) return { ok: false };
|
if (!parsed.ok) return { ok: false };
|
||||||
if (isTicketExpired(parsed.ticket.exp, nowSec)) return { ok: false };
|
|
||||||
return { ok: true, ticket: parsed.ticket };
|
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) */
|
/** แปลง ticket → claims ของ access_token (uid→userid, uname→username, tid→template) */
|
||||||
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
|
export function ticketToDocverClaims(ticket: DocverTicket): DocverTokenClaims {
|
||||||
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };
|
return { userid: ticket.uid, username: ticket.uname, fid: ticket.fid, template: ticket.tid };
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -629,8 +629,9 @@ export const openApiSpec = {
|
||||||
"2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " +
|
"2. **add-in ใน Word** — ไม่ต้องมี `x-api-key` ส่ง body `{ticket_data, HmacSha256}` " +
|
||||||
"ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " +
|
"ตาม schema `TicketTokenRequest` (คู่ค่าที่ e-Court stamp ไว้ใน custom properties " +
|
||||||
"ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " +
|
"ของไฟล์ .docx) — server ตรวจลายเซ็น HMAC-SHA256 ด้วย env `ADDIN_TICKET_SECRET` " +
|
||||||
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) และเวลาหมดอายุ (`exp`) ของ ticket ' +
|
'(ไม่ตั้ง → env `SECRET` → `"super_secret"`) แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง · ' +
|
||||||
"ก่อน แล้วออก token จาก claims ที่อยู่ในตัว ticket เอง\n\n" +
|
"**ticket หมดอายุ** ยังออกให้ต่อเมื่อเอกสารนี้ (`fid`) เคยมี version ที่สร้างโดย `uid` เดียวกัน " +
|
||||||
|
"(ผู้ที่เคยทำงานกับเอกสารเดิมกลับมาใช้ต่อได้แม้ ticket หมดอายุ — ไม่เคยทำงาน = 401)\n\n" +
|
||||||
"ได้ token แล้วแนบเป็น `Authorization: Bearer <token>` กับทุก request ถัดไป",
|
"ได้ token แล้วแนบเป็น `Authorization: Bearer <token>` กับทุก request ถัดไป",
|
||||||
security: [{ ApiKeyAuth: [] }],
|
security: [{ ApiKeyAuth: [] }],
|
||||||
requestBody: {
|
requestBody: {
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
246
scripts/install-wordaddin.mjs
Normal file
246
scripts/install-wordaddin.mjs
Normal 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));
|
||||||
|
});
|
||||||
92
scripts/install-wordaddin.ps1
Normal file
92
scripts/install-wordaddin.ps1
Normal 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
95
scripts/install-wordaddin.sh
Executable 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
|
||||||
|
|
@ -6,6 +6,7 @@ import {
|
||||||
parseTicketTokenBody,
|
parseTicketTokenBody,
|
||||||
ticketToDocverClaims,
|
ticketToDocverClaims,
|
||||||
verifyAddinTicket,
|
verifyAddinTicket,
|
||||||
|
verifyAddinTicketHmac,
|
||||||
} from "@/lib/addin-ticket";
|
} from "@/lib/addin-ticket";
|
||||||
|
|
||||||
// PLAN-ADDIN-AUTH.md §3 — pure ล้วน (verifyAddinTicket รับ secret ทาง parameter)
|
// 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", () => {
|
describe("ticketToDocverClaims", () => {
|
||||||
it("แปลง uid→userid, uname→username, tid→template · tid ไม่มี = null", () => {
|
it("แปลง uid→userid, uname→username, tid→template · tid ไม่มี = null", () => {
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,9 @@
|
||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5) — คุมพฤติกรรม service layer:
|
// ticket auth ของ add-in (PLAN-ADDIN-AUTH.md §5) — คุมพฤติกรรม service layer:
|
||||||
// happy-path ขอ token ครั้งเดียวต่อ fid, เปิดเอกสารเดิมซ้ำใช้ token เดิมไม่ยิง /token ซ้ำ,
|
// 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 จริง)
|
// (jsdom ไม่มี crypto.subtle — ฉีด webcrypto ของ node แทน secure context ของ webview จริง)
|
||||||
import { createHmac, webcrypto } from "node:crypto";
|
import { createHmac, webcrypto } from "node:crypto";
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
@ -16,9 +18,9 @@ function trapErrors() {
|
||||||
window.addEventListener("unhandledrejection", (event) => errors.push(event.reason));
|
window.addEventListener("unhandledrejection", (event) => errors.push(event.reason));
|
||||||
}
|
}
|
||||||
|
|
||||||
function makeTicket(): { ticketData: string; ticketHmac: string } {
|
function makeTicket(expSec?: number): { ticketData: string; ticketHmac: string } {
|
||||||
const ticket = {
|
const ticket = {
|
||||||
exp: Math.floor(Date.now() / 1000) + 300,
|
exp: expSec ?? Math.floor(Date.now() / 1000) + 300,
|
||||||
fid: "DBG_FID_001",
|
fid: "DBG_FID_001",
|
||||||
tid: "T12",
|
tid: "T12",
|
||||||
uid: "oom",
|
uid: "oom",
|
||||||
|
|
@ -114,3 +116,112 @@ describe("signInWithTicket (service layer)", () => {
|
||||||
expect(errors).toEqual([]);
|
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([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,10 @@
|
||||||
*
|
*
|
||||||
* ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data`
|
* ไม่มี login ใน add-in: ไฟล์ที่ e-Court stamp custom properties `ticket_data`
|
||||||
* + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ
|
* + `HmacSha256` มา (ผ่านกลุ่ม template) = ผ่านเข้าได้ — signInWithTicket ตรวจ
|
||||||
* HMAC → exp → session ใน localStorage ตาม uid/fid แล้วขอ access_token จาก
|
* HMAC → session ใน localStorage ตาม uid/fid → ขอ access_token จาก
|
||||||
* /api/documents/token เก็บตาม fid ("คีย์คือ fid")
|
* /api/documents/token เก็บตาม fid ("คีย์คือ fid") · exp ของ ticket ไม่ใช่
|
||||||
|
* เกณฑ์ตัดสิทธิ์ฝั่ง client — server ออก token ให้แม้ ticket หมดอายุเมื่อ
|
||||||
|
* เอกสารนี้เคยมี version โดย uid เดียวกัน (requirement ขั้น 7)
|
||||||
*
|
*
|
||||||
* token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่
|
* token เก็บใน localStorage (ข้าม session ได้ — กลับมาเปิดเอกสารเดิมไม่ต้องขอใหม่
|
||||||
* ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew
|
* ตาม requirement ขั้น 7) ส่วน ticket ดิบของเอกสารปัจจุบันเก็บใน memory ไว้ renew
|
||||||
|
|
@ -47,9 +49,10 @@ export function isDemoMode(): boolean {
|
||||||
* e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7):
|
* e-Court ticket — จุดเข้าเดียวของ auth ตอนเปิดเอกสาร (requirement ขั้น 1–7):
|
||||||
* 1. ตรวจมี ticket_data + HmacSha256 ครบ
|
* 1. ตรวจมี ticket_data + HmacSha256 ครบ
|
||||||
* 2. parse + ตรวจ HMAC ฝั่ง client (UX gate)
|
* 2. parse + ตรวจ HMAC ฝั่ง client (UX gate)
|
||||||
* 3. session ใน localStorage: ยังไม่มี/user ใหม่ → ตรวจ exp → เคลียร์ session เก่า
|
* 3. session ใน localStorage ตาม uid/fid — token ยังใช้ได้ = ผ่านเลย · ต้องขอ
|
||||||
* แล้วขอ token ใหม่ · user เดิม + fid เดิม → ผ่านเลย (renew เงียบ ๆ ถ้า token
|
* token ใหม่ = ยิงขอเสมอแล้วให้ **server เป็นคนตัดสิน** (ticket หมดอายุแต่
|
||||||
* หมดอายุแต่ ticket ยังไม่หมด) · user เดิม + fid ใหม่ → ตรวจ exp → ขอ token เก็บเพิ่ม
|
* เอกสารนี้เคยมี version โดย uid เดียวกัน = ออกให้ต่อ — requirement ขั้น 7
|
||||||
|
* กลับมาทำเอกสารเดิมได้แม้ ticket หมดอายุ แม้ session หายไปจากเครื่อง)
|
||||||
*/
|
*/
|
||||||
export async function signInWithTicket(input: {
|
export async function signInWithTicket(input: {
|
||||||
ticketData?: string;
|
ticketData?: string;
|
||||||
|
|
@ -82,63 +85,57 @@ export async function signInWithTicket(input: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const ticket = parsed.ticket;
|
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();
|
const session = ticketService.loadSession();
|
||||||
if (session === null || session.userId !== ticket.uid) {
|
if (session === null || session.userId !== ticket.uid) {
|
||||||
// ไม่มี session / user ใหม่ — ตรวจ exp ก่อนแล้วเริ่ม session ใหม่ทั้งก้อน
|
// ไม่มี session / user ใหม่ — ขอ token ใหม่เสมอ (แม้ ticket หมดอายุ: server
|
||||||
if (!ticketAlive) {
|
// ออกให้ต่อเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน ซึ่งช่วยเคส localStorage หาย
|
||||||
return {
|
// ของคนที่กลับมาทำเอกสารเดิมด้วย) แล้วเริ่ม session ใหม่ทั้งก้อน
|
||||||
ok: false,
|
|
||||||
reason: "expired",
|
|
||||||
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
ticketService.clearSession();
|
|
||||||
let entry: ticketService.SessionTokenEntry;
|
let entry: ticketService.SessionTokenEntry;
|
||||||
try {
|
try {
|
||||||
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return isTicketExpired(ticket.exp)
|
||||||
ok: false,
|
? expiredMessage
|
||||||
reason: "token-request-failed",
|
: {
|
||||||
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
|
ok: false,
|
||||||
};
|
reason: "token-request-failed",
|
||||||
|
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
ticketService.clearSession();
|
||||||
ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: entry } });
|
ticketService.saveSession({ userId: ticket.uid, userName: ticket.uname, tokens: { [ticket.fid]: entry } });
|
||||||
} else {
|
} else {
|
||||||
// คนเดิม — userName อัปเดตตามไฟล์ล่าสุด (ชื่อเปลี่ยนได้)
|
// คนเดิม — userName อัปเดตตามไฟล์ล่าสุด (ชื่อเปลี่ยนได้)
|
||||||
session.userName = ticket.uname;
|
session.userName = ticket.uname;
|
||||||
let entry = session.tokens[ticket.fid] ?? null;
|
let entry = session.tokens[ticket.fid] ?? null;
|
||||||
if (entry !== null && ticketService.isTokenExpired(entry)) {
|
if (entry !== null && ticketService.isTokenExpired(entry)) {
|
||||||
// token หมดอายุ — renew ได้เฉพาะตอน ticket ยังไม่หมด; ticket หมดด้วยก็เก็บของ
|
// token หมดอายุ — ยิง renew เสมอแล้วให้ server ตัดสิน (ticket ยังไม่หมด =
|
||||||
// เก่าไว้ (requirement ขั้น 7: ผ่านเลยไม่ตรวจ exp) ให้ server ตัดสินตอนใช้งาน
|
// ออกให้เสมอ · หมดแล้ว = ออกเฉพาะเอกสารนี้เคยทำงานโดย uid เดียวกัน)
|
||||||
if (ticketAlive) {
|
try {
|
||||||
try {
|
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
||||||
entry = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
session.tokens[ticket.fid] = entry;
|
||||||
session.tokens[ticket.fid] = entry;
|
} catch {
|
||||||
} catch {
|
// ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access
|
||||||
// ใช้ token เก่าต่อ — ถ้าเน่าจริง apiClient จะ 401 → renew-retry → no-access
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (entry === null) {
|
if (entry === null) {
|
||||||
// เอกสารใหม่ของคนเดิม — ตรวจ exp แล้วขอ token เก็บเพิ่ม
|
// เอกสารใหม่ของคนเดิม — ขอ token เก็บเพิ่ม (server ตัดสินเรื่อง ticket หมดอายุ)
|
||||||
if (!ticketAlive) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
reason: "expired",
|
|
||||||
message: "ticket ของ e-Court หมดอายุ — โปรดขอไฟล์ใหม่จาก e-Court แล้วเปิดอีกครั้ง",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
try {
|
try {
|
||||||
session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
session.tokens[ticket.fid] = await ticketService.requestDocverToken(ticketData, ticketHmac);
|
||||||
} catch {
|
} catch {
|
||||||
return {
|
return isTicketExpired(ticket.exp)
|
||||||
ok: false,
|
? expiredMessage
|
||||||
reason: "token-request-failed",
|
: {
|
||||||
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
|
ok: false,
|
||||||
};
|
reason: "token-request-failed",
|
||||||
|
message: "ขอ token จากระบบไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วเปิดเอกสารอีกครั้ง",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
ticketService.saveSession(session);
|
ticketService.saveSession(session);
|
||||||
|
|
@ -152,14 +149,16 @@ export async function signInWithTicket(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน (ticket ต้องยังไม่หมดอายุ) —
|
* ขอ token ใหม่จาก ticket ของเอกสารปัจจุบัน — เรียกจาก apiClient ตอนโดน 401
|
||||||
* เรียกจาก apiClient ตอนโดน 401 ก่อน throw AuthRequiredError 1 รอบ
|
* ก่อน throw AuthRequiredError 1 รอบ · ไม่ตัดสิน exp ของ ticket ฝั่งนี้ (server
|
||||||
|
* ออกให้ต่อได้แม้ ticket หมดอายุเมื่อเอกสารนี้เคยทำงานโดย uid เดียวกัน —
|
||||||
|
* requirement ขั้น 7) 401 กลับมา = false ตามเดิม
|
||||||
* คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่)
|
* คืน true = ได้ token ใหม่เรียบร้อย (getAccessToken จะเห็นค่าใหม่)
|
||||||
*/
|
*/
|
||||||
export async function renewTokenFromTicket(): Promise<boolean> {
|
export async function renewTokenFromTicket(): Promise<boolean> {
|
||||||
if (currentTicket === null || currentFid === null) return false;
|
if (currentTicket === null || currentFid === null) return false;
|
||||||
const parsed = parseDocverTicket(currentTicket.data);
|
const parsed = parseDocverTicket(currentTicket.data);
|
||||||
if (!parsed.ok || isTicketExpired(parsed.ticket.exp)) return false;
|
if (!parsed.ok) return false;
|
||||||
try {
|
try {
|
||||||
const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac);
|
const entry = await ticketService.requestDocverToken(currentTicket.data, currentTicket.hmac);
|
||||||
const session =
|
const session =
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue