diff --git a/.env.example b/.env.example index 7af635b..0a4a54b 100644 --- a/.env.example +++ b/.env.example @@ -6,22 +6,6 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # EXTERNAL_API_KEY= -# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template -DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit -# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template -EXTERNAL_API_KEY='secret-api-key-1234' -# ที่เก็บไฟล์เทมเพลต(docx) -TMPL_PATH='./public/tmpl' - -# Sign/Hash ที่ใช้ฝังในเอกสาร(docx) -SECRET="super_secret" -PRIVATE_KEY_HEX="8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1" -PUBLIC_KEY_HEX="66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546" -# Optional เวลาหมดอายุที่ฝังไว้ในข้อมูล (ticket_data) -TICKET_SEC=300 - - - # MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml # (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500) # หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev @@ -34,12 +18,3 @@ MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin # MINIO_BUCKET=docver # DOCVER_LINK_TTL_SECONDS=900 - -# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key) -# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร -# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง) -DOCVER_API_KEY= -# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer) -# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret -DOCVER_TOKEN_SECRET= -# DOCVER_TOKEN_TTL_SECONDS=3600 diff --git a/CLAUDE.md b/CLAUDE.md index f5ccb40..c1656e7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,7 +21,7 @@ 1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม) 2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด 3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน -4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName, templateId และ docId (จาก claim fid) แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath` +4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone บอก userId/userName เองใน request ไม่มี session ของแอป note) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath` 5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ 6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)` 7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว) @@ -35,13 +35,13 @@ ``` docker-compose.yml ← dev dependencies: `db` (judes-note-db — ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README migrate ได้เลย, POSTGRES_DB ecourt-note) + `minio` (judes-note-minio — object storage ของกลุ่ม docver; โฮสต์แมพ 19000 API/19001 console เพราะ 9000/9001 ชน container โปรเจกต์อื่นในเครื่อง dev, image จาก quay.io ทางการ; bucket `docver` ไม่ต้องสร้างเอง app สร้างตอน first use ผ่าน ensureBucket) -docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 4 endpoint (เพิ่ม POST /token ขอ access_token — Bearer บังคับทุก endpoint), ดีไซน์ตาราง, env MINIO_* + DOCVER_API_KEY/DOCVER_TOKEN_SECRET/DOCVER_TOKEN_TTL_SECONDS ทั้งหมด +docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 3 endpoint, ดีไซน์ตาราง, env MINIO_* ทั้งหมด scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ) scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile) scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) 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 — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ผู้เรียกบอก userId/userName เอง ไม่มี session, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้) lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) @@ -50,8 +50,7 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย -lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) -lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseDocId (charset [A-Za-z0-9._:-] 1–200), parseVersionUploadForm (allowlist field + userId/userName บังคับ + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม 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 (mock auth) @@ -83,7 +82,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) -app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath +app/api/documents/[docId]/versions/route.ts + [versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, ไม่มี auth เปิดกว้างเหมือน api/template): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+userId/userName+fields/message/clientVersionId/templateId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route) app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component) app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line) diff --git a/app/api/documents/versions/[versionId]/download-link/route.ts b/app/api/documents/[docId]/versions/[versionId]/download-link/route.ts similarity index 65% rename from app/api/documents/versions/[versionId]/download-link/route.ts rename to app/api/documents/[docId]/versions/[versionId]/download-link/route.ts index b97ce00..0b91a6c 100644 --- a/app/api/documents/versions/[versionId]/download-link/route.ts +++ b/app/api/documents/[docId]/versions/[versionId]/download-link/route.ts @@ -1,13 +1,12 @@ -// PLAN-DOCVER.md §1 — GET /api/documents/versions/{versionId}/download-link?format=docx|pdf +// PLAN-DOCVER.md §1 — GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf // คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง -// ของผู้ใช้ — เช็ค docId (จาก fid ใน token) กับ versionId คู่กันกันโยน id ข้ามเอกสาร -// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม) +// ของผู้ใช้ — เช็คทั้ง docId และ versionId พร้อมกันกันโยน id ข้ามเอกสาร import { docverError, + parseDocId, parseDownloadFormat, parseVersionIdParam, } from "@/lib/document-versioning"; -import { requireDocverBearer } from "@/lib/docver-token"; import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store"; export const runtime = "nodejs"; @@ -15,13 +14,10 @@ export const dynamic = "force-dynamic"; export async function GET( request: Request, - { params }: { params: { versionId: string } }, + { params }: { params: { docId: string; versionId: string } }, ) { - const auth = requireDocverBearer(request); - if (!auth.ok) return auth.response; - - // docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify - const docId = auth.claims.fid; + const docId = parseDocId(params.docId); + if (!docId.ok) return docverError(docId.error, 400); const versionId = parseVersionIdParam(params.versionId); if (!versionId.ok) return docverError(versionId.error, 400); let format: string | null = null; @@ -34,7 +30,7 @@ export async function GET( if (!parsedFormat.ok) return docverError(parsedFormat.error, 400); try { - const row = await findVersionForDoc(docId, versionId.versionId); + const row = await findVersionForDoc(docId.docId, versionId.versionId); if (!row) { return docverError("ไม่พบ version นี้ของเอกสาร", 404); } diff --git a/app/api/documents/versions/route.ts b/app/api/documents/[docId]/versions/route.ts similarity index 81% rename from app/api/documents/versions/route.ts rename to app/api/documents/[docId]/versions/route.ts index 79dcc2b..d022100 100644 --- a/app/api/documents/versions/route.ts +++ b/app/api/documents/[docId]/versions/route.ts @@ -1,9 +1,6 @@ -// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/versions -// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่ -// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ -// Authorization: Bearer ทุก request — identity (userid/username), templateId และ -// docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด -// ไม่รับจาก URL/form อีก (lib/docver-token.ts) +// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/{docId}/versions +// กลุ่ม API stand-alone ไม่มี auth (เปิดกว้างเหมือน api/template — intranet) — ผู้เรียกบอก +// identity เองผ่าน field userId/userName เพราะไม่มี session ร่วมกับใคร import { createHash, randomUUID } from "node:crypto"; import { toDocumentVersionDTO } from "@/db/schema"; @@ -13,10 +10,10 @@ import { DOCX_CONTENT_TYPE, isDocxBytes, isPdfBytes, + parseDocId, parseVersionUploadForm, sanitizeFilePartName, } from "@/lib/document-versioning"; -import { requireDocverBearer } from "@/lib/docver-token"; import { findDocumentByDocId, insertVersionRecord, @@ -30,16 +27,13 @@ export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก) - * ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes → + * ลำดับ: validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes → * putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4) * response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative * จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */ -export async function POST(request: Request) { - const auth = requireDocverBearer(request); - if (!auth.ok) return auth.response; - - // docId มาจาก fid ใน token — claim ถูกบังคับเป็น string ไม่ว่างตั้งแต่ขอ token + verify - const docId = auth.claims.fid; +export async function POST(request: Request, { params }: { params: { docId: string } }) { + const docId = parseDocId(params.docId); + if (!docId.ok) return docverError(docId.error, 400); let form: FormData; try { @@ -115,19 +109,19 @@ export async function POST(request: Request) { try { row = await insertVersionRecord({ versionId, - docId, + docId: docId.docId, clientVersionId: parsed.form.clientVersionId, contentHash, message: parsed.form.message, fields: parsed.form.fields, - templateId: auth.claims.template, + templateId: parsed.form.templateId, fileName: docxDisplayName, docxObjectKey: keys.docx, pdfObjectKey: pdfBytes ? keys.pdf : null, docxSize: docxBytes.length, pdfSize: pdfBytes?.length ?? null, - userId: auth.claims.userid, - userName: auth.claims.username, + userId: parsed.form.userId, + userName: parsed.form.userName, }); } catch (err) { // DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้ @@ -148,14 +142,12 @@ export async function POST(request: Request) { /** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404 * (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */ -export async function GET(request: Request) { - const auth = requireDocverBearer(request); - if (!auth.ok) return auth.response; - - const docId = auth.claims.fid; +export async function GET(_request: Request, { params }: { params: { docId: string } }) { + const docId = parseDocId(params.docId); + if (!docId.ok) return docverError(docId.error, 400); try { - const doc = await findDocumentByDocId(docId); + const doc = await findDocumentByDocId(docId.docId); if (!doc) { return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404); } diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts deleted file mode 100644 index 07cd544..0000000 --- a/app/api/documents/token/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver -// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ -// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env -// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ -// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] -// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว -import { docverError } from "@/lib/document-versioning"; -import { - issueDocverToken, - parseTokenRequestBody, - requireDocverApiKey, -} from "@/lib/docver-token"; - -export const runtime = "nodejs"; -export const dynamic = "force-dynamic"; - -export async function POST(request: Request) { - const apiKeyFail = requireDocverApiKey(request); - if (apiKeyFail !== null) return apiKeyFail; - - let raw: unknown; - try { - raw = await request.json(); - } catch { - return docverError("body ต้องเป็น JSON", 400); - } - const parsed = parseTokenRequestBody(raw); - if (!parsed.ok) return docverError(parsed.error, 400); - - // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) - const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); - // const secret = - // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; - - if (secret === undefined || secret === "") { - return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); - } - const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); - const ttlSeconds = - Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; - - const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds); - return new Response( - JSON.stringify({ - access_token: accessToken, - token_type: "Bearer", - expires_in: ttlSeconds, - }), - { headers: { "content-type": "application/json; charset=utf-8" } }, - ); -} diff --git a/app/api/template/download-docx/route.ts b/app/api/template/download-docx/route.ts deleted file mode 100644 index 13f8725..0000000 --- a/app/api/template/download-docx/route.ts +++ /dev/null @@ -1,108 +0,0 @@ -// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework -// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ -// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx -import { NextRequest, NextResponse } from 'next/server'; -import path from 'path'; -import fs from 'fs/promises'; - -const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??""; -const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??""; -const TMPL_PATH = process.env.TMPL_PATH??""; -if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) { - console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH') -} - - -async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise> -{ - // console.log("processRequest get:",fid,tid,uid,uname) - // console.log(process.cwd()) - const filePath = path.join(TMPL_PATH,`${fid}.json`); - try { - if (!fid||!tid||!uid||!uname) { - return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 }); - } - - // อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล - const rawJson = await fs.readFile(filePath, 'utf-8'); - const jsonData = JSON.parse(rawJson); - if (!jsonData || typeof jsonData !== 'object' ) { - return NextResponse.json({error:'Invalid data format'}, { status: 400 }); - } - - //!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!! - - // ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data - if(!Object.hasOwn(jsonData, 'info')) - jsonData['info']={} - jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname}) - // sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้ - - //ยิงไปหาระบบ Docedit เพื่อเอา Stream - // console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`) - const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'x-api-key': EXTERNAL_API_KEY, - }, - body: JSON.stringify(jsonData), - }); - - // เช็ค Content-Type และ Status ก่อน - const contentType = docxResponse.headers.get('content-type') || ''; - - if (!docxResponse.ok || contentType.includes('text/html')) { - const errorText = await docxResponse.text(); - console.error('download-docx:Invalid docx Response:', errorText); - - return NextResponse.json( - {error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`}, - { status: 502 } - ); - } - - // 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ - const stream = docxResponse.body; - - // 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์ - const download_file = `${fid}_${uid}_xxx` - return new NextResponse(stream as unknown as BodyInit, { - status: 200, - headers: { - 'Content-Type': - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`, - 'Cache-Control': 'no-store, max-age=0', - }, - }); - } catch (error:any) { - if ( error.code === 'ENOENT') { - return NextResponse.json( - { error: `Data not found: ${filePath}`}, - { status: 404 } - ); - } - console.error(error) - return NextResponse.json({error:'Internal Server Error'}, { status: 500 }); - } -} - -export async function GET(request: NextRequest) -{ - const searchParams = request.nextUrl.searchParams; - const fid = searchParams.get('fid') || ''; - const tid = searchParams.get('tid') || ''; - const uid = searchParams.get('uid') || ''; - const uname = searchParams.get('uname') || ''; - return await processRequest(fid,tid,uid,uname) -} - -export async function POST(request: NextRequest) { - const formData = await request.formData(); - const fid = String(formData.get('fid')) - const tid = String(formData.get('tid')) - const uid = String(formData.get('uid')) - const uname = String(formData.get('uname')) - return await processRequest(fid,tid,uid,uname) -} \ No newline at end of file diff --git a/app/api/template/extract/route.ts b/app/api/template/extract/route.ts index 12108b7..e723f35 100644 --- a/app/api/template/extract/route.ts +++ b/app/api/template/extract/route.ts @@ -1,7 +1,8 @@ import { NextResponse } from "next/server"; import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; -import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; -// export const runtime = "nodejs"; +import { verifyDataEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; + +export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -14,14 +15,27 @@ export async function POST(request: Request) { try { const formData = await request.formData(); const file = formData.get("file"); - const TEST_SECRET = String(formData.get("secret") ?? ""); - const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? ""); + const TEST_SECRET = String(formData.get("secret"))??""; + const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key"))??""; - if (!(file instanceof File) || - (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") ) + + if (!(file instanceof File)) { + return NextResponse.json( + { + success: false, + error: 'Missing DOCX file in field "file".', + }, + { status: 400 }, + ); + } + + if ( + file.type !== DOCX_CONTENT_TYPE && + !file.name.toLowerCase().endsWith(".docx") ) { return NextResponse.json( { + success: false, error: 'Field "file" must be a .docx file.', }, { status: 400 }, @@ -31,6 +45,7 @@ export async function POST(request: Request) { if (file.size > MAX_UPLOAD_BYTES) { return NextResponse.json( { + success: false, error: 'Field "file" exceeds the 100MB size limit.', }, { status: 400 }, @@ -47,30 +62,35 @@ export async function POST(request: Request) { const bookmark = extractBookmark(buffer) const properties = getDocProps(buffer) let verify: Record ={} - const ticket_data = properties?.custom?.ticket_data + const ticket_data = String(properties?.custom?.ticket_data) const HmacSha256 = String(properties?.custom?.HmacSha256) const Ed25519 = String(properties?.custom?.Ed25519) - - if(ticket_data && typeof ticket_data === 'string'){ + + if(ticket_data){ const ticketDataObj= JSON.parse(ticket_data) const unix_timestamp = Number(ticketDataObj.exp)*1000 if(unix_timestamp){ verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" }) } if(TEST_SECRET && HmacSha256){ - verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) + verify['verifyHmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) } if(TEST_PUBLIC_KEY_HEX&& Ed25519){ - verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) + verify["Ed25519"]=verifyDataEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) } } - - let returnData : Record = {cc,bookmark,properties,verify} + let returnData : Record = { + success: true, + cc,bookmark,properties,verify + } + return NextResponse.json(returnData); } catch (error) { console.error(error); + return NextResponse.json( { + success: false, error: error instanceof Error ? error.message diff --git a/app/api/template/generate/route.ts b/app/api/template/generate/route.ts deleted file mode 100644 index 1a057ee..0000000 --- a/app/api/template/generate/route.ts +++ /dev/null @@ -1,101 +0,0 @@ -// สร้าง docx จาก json -// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้ -// - สร้าง signature/hash จาก json.info.ticket_data - -import { NextRequest, NextResponse } from 'next/server'; -import {readFile} from 'fs/promises'; -import path from 'path'; -import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; -const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234'; -const TMPL_PATH = process.env.TMPL_PATH || '' -const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||'' -const SECRET = process.env.SECRET || '' -const TICKET_SEC = Number(process.env.TICKET_SEC||'300') - -if (EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET) - console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX") - -export async function POST(request: NextRequest) { - // 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key) - const apiKey = request.headers.get('x-api-key'); - - if (!apiKey || apiKey !== EXTERNAL_API_KEY) { - return NextResponse.json( - { error: 'Unauthorized: Invalid API Key' }, - { status: 401 } - ); - } - - try { - // รับข้อมูล JSON Payload - const templateData:DocumentTemplate = (await request.json()); - console.log(templateData) - // ต้องมี templateData.info.ticket_data เป็น json string เสมอ - if (!templateData || !templateData.info || - !templateData.info.ticket_data || - typeof templateData.info.ticket_data !== "string" - ) { - return NextResponse.json( - { - error: "template/generate json.info.ticket_data not avaliable", - }, - { status: 400 }, - ); - } - const ticketObj = JSON.parse(templateData.info.ticket_data) - const tid = String(ticketObj.tid) - const fid = String(ticketObj.fid) - const uid = String(ticketObj.uid) - if(!tid||!fid||!uid) - { - return NextResponse.json( - { - error: "template/generate require json.info.ticket_data.(fid|tid|uid)", - }, - { status: 400 }, - ); - } - - - //เปิดไฟล์ template - const tmplPath = path.join(TMPL_PATH, `${tid}.docx`); - let templateBuffer: Buffer = await readFile(tmplPath) - - const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC); - - // สร้าง ReadableStream ของไฟล์ .docx - // ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System - // const encoder = new TextEncoder(); - - const docxStream = new ReadableStream({ - start(controller) { - controller.enqueue(output); - controller.close(); - }, - }); - - // 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word - const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`) - return new NextResponse(docxStream, { - status: 200, - headers: { - 'Content-Type': - 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - 'Content-Disposition': `attachment; filename="${dlFileName}.docx"`, - 'Cache-Control': 'no-store, max-age=0', - }, - }); - } catch (error:any) { - if ( error.code === 'ENOENT') { - return NextResponse.json( - { error: 'NotFound: Invalid template ID or incorrect storage path'}, - { status: 404 } - ); - } - console.error('Error generating docx:', error); - return NextResponse.json( - { error: 'Failed to generate document payload'}, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/app/api/template/lib-tmpl.ts b/app/api/template/lib-tmpl.ts new file mode 100644 index 0000000..2c15f52 --- /dev/null +++ b/app/api/template/lib-tmpl.ts @@ -0,0 +1,518 @@ +import PizzaZip from "pizzip"; +import { + DOMParser, + XMLSerializer, + type Document as XmlDocument, + type Element as XmlElement, + type Node as XmlNode, + type Attr as XmlAttr, +} from "@xmldom/xmldom"; +import xpath from "xpath"; + +export interface ExtractedData { + [tagName: string]: string; +} + +const W_NS = + "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; + +const asXpathNode = (node: XmlNode): globalThis.Node => + node as unknown as globalThis.Node; + +function createSelector() { + return xpath.useNamespaces({ w: W_NS }); +} + +/** + * อ่าน Content Control (w:sdt) จาก DOCX Buffer + * ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ + */ +export function extractContentControls( + docxBuffer: Buffer, +): ExtractedData { + const zip = new PizzaZip(docxBuffer); + const docXmlFile = zip.file("word/document.xml"); + + if (!docXmlFile) { + throw new Error("Invalid docx file: word/document.xml not found."); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); + const select = createSelector(); + const result: ExtractedData = {}; + + const sdtNodes = select( + "//w:sdt", + asXpathNode(doc), + ) as unknown as XmlElement[]; + + sdtNodes.forEach((sdt) => { + const tagAttr = select( + "./w:sdtPr/w:tag/@w:val", + asXpathNode(sdt), + true, + ) as unknown as XmlAttr | undefined; + + const tagName = tagAttr?.value; + + if (!tagName) return; + + const sdtContent = select( + "./w:sdtContent", + asXpathNode(sdt), + true, + ) as unknown as XmlElement | undefined; + + if (!sdtContent) { + result[tagName] = ""; + return; + } + + const nodes = select( + ".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + let extractedText = ""; + + nodes.forEach((node) => { + switch (node.nodeName) { + case "w:p": + if ( + extractedText.length > 0 && + !extractedText.endsWith("\n") + ) { + extractedText += "\n"; + } + break; + + case "w:t": + extractedText += node.textContent || ""; + break; + + case "w:tab": + extractedText += "\t"; + break; + + case "w:br": + extractedText += "\n"; + break; + + case "w:noBreakHyphen": + extractedText += "\u2011"; + break; + + case "w:softHyphen": + extractedText += "\u00ad"; + break; + + case "w:sym": { + const charAttr = node.getAttribute("w:char"); + if (charAttr) { + const charCode = Number.parseInt(charAttr, 16); + extractedText += String.fromCharCode(charCode); + } + break; + } + } + }); + + result[tagName] = extractedText; + }); + + return result; +} + +type SdtControlType = "plain" | "rich" | "other"; +type SdtStructure = "block" | "inline"; + +function detectSdtControlType( + sdt: XmlElement, + select: xpath.XPathSelect, +): { type: SdtControlType; multiLine: boolean } { + const sdtPr = select( + "./w:sdtPr", + asXpathNode(sdt), + true, + ) as unknown as XmlElement | undefined; + + if (!sdtPr) { + return { type: "other", multiLine: false }; + } + + const textEl = select( + "./w:text", + asXpathNode(sdtPr), + true, + ) as unknown as XmlElement | undefined; + + if (textEl) { + const multiLineAttr = select( + "./@w:multiLine", + asXpathNode(textEl), + true, + ) as unknown as XmlAttr | undefined; + + return { + type: "plain", + multiLine: + multiLineAttr?.value === "1" || + multiLineAttr?.value === "true", + }; + } + + const richTextEl = select( + "./w:richText", + asXpathNode(sdtPr), + true, + ) as unknown as XmlElement | undefined; + + if (richTextEl) { + return { type: "rich", multiLine: true }; + } + + const otherTypeTags = [ + "w:date", + "w:dropDownList", + "w:comboBox", + "w:checkbox", + "w:citation", + "w:group", + "w:docPartObj", + "w:docPartList", + ]; + + for (const tag of otherTypeTags) { + const found = select( + `./${tag}`, + asXpathNode(sdtPr), + true, + ) as unknown as XmlElement | undefined; + + if (found) { + return { type: "other", multiLine: false }; + } + } + + return { type: "rich", multiLine: true }; +} + +function detectSdtStructure( + sdtContent: XmlElement, + select: xpath.XPathSelect, +): SdtStructure { + const directP = select( + "./w:p", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + return directP.length > 0 ? "block" : "inline"; +} + +function buildRunChildren( + doc: XmlDocument, + text: string, +): XmlElement[] { + const nodes: XmlElement[] = []; + const tokens = text.split(/(\t|\n)/); + + tokens.forEach((token) => { + if (token === "") return; + + if (token === "\t") { + nodes.push(doc.createElementNS(W_NS, "w:tab")); + } else if (token === "\n") { + nodes.push(doc.createElementNS(W_NS, "w:br")); + } else { + const t = doc.createElementNS(W_NS, "w:t"); + t.setAttribute("xml:space", "preserve"); + t.textContent = token; + nodes.push(t); + } + }); + + if (nodes.length === 0) { + const t = doc.createElementNS(W_NS, "w:t"); + t.setAttribute("xml:space", "preserve"); + t.textContent = ""; + nodes.push(t); + } + + return nodes; +} + +function buildRun( + doc: XmlDocument, + text: string, + rPr?: XmlElement, +): XmlElement { + const run = doc.createElementNS(W_NS, "w:r"); + + if (rPr) { + run.appendChild(rPr.cloneNode(true)); + } + + buildRunChildren(doc, text).forEach((node) => + run.appendChild(node), + ); + + return run; +} + +function insertInlineContent( + doc: XmlDocument, + sdtContent: XmlElement, + select: xpath.XPathSelect, + text: string, + allowLineBreak: boolean, +): void { + const safeText = allowLineBreak + ? text + : text.replace(/\n/g, " "); + + const existingRuns = select( + "./w:r", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + const firstRPr = + existingRuns.length > 0 + ? (select( + "./w:rPr", + asXpathNode(existingRuns[0]), + true, + ) as unknown as XmlElement | undefined) + : undefined; + + existingRuns.forEach((r) => { + if (r.parentNode) r.parentNode.removeChild(r); + }); + + sdtContent.appendChild( + buildRun(doc, safeText, firstRPr), + ); +} + +function insertBlockContent( + doc: XmlDocument, + sdtContent: XmlElement, + select: xpath.XPathSelect, + text: string, + allowMultiParagraph: boolean, +): void { + const allParagraphs = select( + "./w:p", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + if (allParagraphs.length === 0) { + // ป้องกัน template ที่มีโครงสร้างผิดปกติ + const p = doc.createElementNS(W_NS, "w:p"); + sdtContent.appendChild(p); + allParagraphs.push(p); + } + + const firstP = allParagraphs[0]; + + const firstPPr = select( + "./w:pPr", + asXpathNode(firstP), + true, + ) as unknown as XmlElement | undefined; + + const firstRun = select( + "./w:r", + asXpathNode(firstP), + true, + ) as unknown as XmlElement | undefined; + + const firstRPr = firstRun + ? (select( + "./w:rPr", + asXpathNode(firstRun), + true, + ) as unknown as XmlElement | undefined) + : undefined; + + allParagraphs.forEach((p) => { + if (p !== firstP && p.parentNode) { + p.parentNode.removeChild(p); + } + }); + + const existingRuns = select( + "./w:r", + asXpathNode(firstP), + ) as unknown as XmlElement[]; + + existingRuns.forEach((r) => { + if (r.parentNode) r.parentNode.removeChild(r); + }); + + if (!allowMultiParagraph) { + firstP.appendChild(buildRun(doc, text, firstRPr)); + return; + } + + text.split("\n").forEach((line, index) => { + let targetP: XmlElement; + + if (index === 0) { + targetP = firstP; + } else { + targetP = doc.createElementNS(W_NS, "w:p"); + + if (firstPPr) { + targetP.appendChild(firstPPr.cloneNode(true)); + } + + sdtContent.appendChild(targetP); + } + + const run = doc.createElementNS(W_NS, "w:r"); + + if (firstRPr) { + run.appendChild(firstRPr.cloneNode(true)); + } + + buildRunChildren(doc, line).forEach((node) => + run.appendChild(node), + ); + + targetP.appendChild(run); + }); +} + +function insertFallbackContent( + sdtContent: XmlElement, + select: xpath.XPathSelect, + text: string, +): void { + const textNodes = select( + ".//w:t", + asXpathNode(sdtContent), + ) as unknown as XmlElement[]; + + if (textNodes.length === 0) return; + + textNodes[0].textContent = text; + + for (let i = 1; i < textNodes.length; i++) { + textNodes[i].textContent = ""; + } +} + +export type TemplateData = Record; + +function normalizeValue(value: unknown): string { + if (value === null || value === undefined) { + return ""; + } + + if (typeof value === "string") { + return value; + } + + if ( + typeof value === "number" || + typeof value === "boolean" || + typeof value === "bigint" + ) { + return String(value); + } + + // รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text + return JSON.stringify(value); +} + +/** + * นำ JSON data ไปแทนค่า Content Control ใน DOCX template + * คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว + */ +export function templateContentControls( + templateBuffer: Buffer, + data: TemplateData, +): Buffer { + const zip = new PizzaZip(templateBuffer); + const docXmlFile = zip.file("word/document.xml"); + + if (!docXmlFile) { + throw new Error("Invalid docx file: word/document.xml not found."); + } + + const parser = new DOMParser(); + const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); + const select = createSelector(); + + const sdtNodes = select( + "//w:sdt", + asXpathNode(doc), + ) as unknown as XmlElement[]; + + sdtNodes.forEach((sdt) => { + const tagAttr = select( + "./w:sdtPr/w:tag/@w:val", + asXpathNode(sdt), + true, + ) as unknown as XmlAttr | undefined; + + const tagName = tagAttr?.value; + + if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) { + return; + } + + const newValue = normalizeValue(data[tagName]); + + const sdtContent = select( + "./w:sdtContent", + asXpathNode(sdt), + true, + ) as unknown as XmlElement | undefined; + + if (!sdtContent) return; + + const { type: controlType, multiLine } = + detectSdtControlType(sdt, select); + + const structure = detectSdtStructure(sdtContent, select); + + if (controlType === "other") { + insertFallbackContent(sdtContent, select, newValue); + return; + } + + if (structure === "inline") { + const allowLineBreak = + controlType === "plain" ? multiLine : true; + + insertInlineContent( + doc, + sdtContent, + select, + newValue, + allowLineBreak, + ); + } else { + insertBlockContent( + doc, + sdtContent, + select, + newValue, + controlType === "rich", + ); + } + }); + + const serializer = new XMLSerializer(); + zip.file( + "word/document.xml", + serializer.serializeToString(doc), + ); + + return zip.generate({ + type: "nodebuffer", + compression: "DEFLATE", + }); +} diff --git a/app/api/template/render/route.ts b/app/api/template/render/route.ts index c738afe..8f04c9f 100644 --- a/app/api/template/render/route.ts +++ b/app/api/template/render/route.ts @@ -1,49 +1,73 @@ import { NextResponse } from "next/server"; -import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; -export const runtime = "nodejs"; +import { DocumentTemplate, templateContentControls, setCustomDocProps, getDocProps, templateDynamicContents } from "@/lib/lib-tmpl"; +import { generateHmacSha256, verifyHmacSha256, signDataEd25519, verifyDataEd25519, sortObjectKeys } from '@/lib/lib-utils'; +export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; + // cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้ // ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions) const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB -const TICKET_SEC = Number(process.env.TICKET_SEC||'300') export async function POST(request: Request) { try { const formData = await request.formData(); + const template = formData.get("template"); const dataFile = formData.get("data"); const dataJson = formData.get("dataJson"); - const TEST_SECRET = String(formData.get("secret")||"") - const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"") + const TEST_SECRET = (typeof formData.get("secret") === "string") ? + String(formData.get("secret")) : "super_secret"; + const TEST_PRIVATE_KEY_HEX = (typeof formData.get("private_key") === "string") ? + String(formData.get("private_key")) : "8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1"; + console.log(`secret:${TEST_SECRET}`) + console.log(`private:${TEST_PRIVATE_KEY_HEX}`) + // ----------------------------- // ตรวจสอบ Template + // ----------------------------- + if (!(template instanceof File)) { return NextResponse.json( { + success: false, error: 'Missing DOCX file in field "template".', }, { status: 400 }, ); } - if (template.type !== DOCX_CONTENT_TYPE && - !template.name.toLowerCase().endsWith(".docx") && - template.size > MAX_UPLOAD_BYTES + if ( + template.type !== DOCX_CONTENT_TYPE && + !template.name.toLowerCase().endsWith(".docx") ) { return NextResponse.json( { - error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.', + success: false, + error: 'Field "template" must be a .docx file.', }, { status: 400 }, ); } + if (template.size > MAX_UPLOAD_BYTES) { + return NextResponse.json( + { + success: false, + error: 'Field "template" exceeds the 100MB size limit.', + }, + { status: 400 }, + ); + } + // ----------------------------- // อ่าน JSON + // ----------------------------- + let templateData: DocumentTemplate; + // JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500 // (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น) try { @@ -54,11 +78,13 @@ export async function POST(request: Request) { if (dataFile.size > MAX_UPLOAD_BYTES) { return NextResponse.json( { + success: false, error: 'Field "data" exceeds the 100MB size limit.', }, { status: 400 }, ); } + // กรณี upload data.json const text = await dataFile.text(); @@ -66,6 +92,7 @@ export async function POST(request: Request) { } else { return NextResponse.json( { + success: false, error: 'Missing JSON data. Send either "data" file or "dataJson" field.', }, @@ -75,12 +102,14 @@ export async function POST(request: Request) { } catch (error) { return NextResponse.json( { + success: false, error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error" }).`, }, { status: 400 }, ); } + // JSON ต้องเป็น object if ( templateData === null || @@ -89,6 +118,7 @@ export async function POST(request: Request) { ) { return NextResponse.json( { + success: false, error: "JSON data must be an object.", }, { status: 400 }, @@ -102,18 +132,62 @@ export async function POST(request: Request) { let templateBuffer: Buffer = Buffer.from( await template.arrayBuffer(), ); - const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC); + + //Calculate hash + const originalTicket_data = templateData?.info?.ticket_data //expect valid json string + // console.log("templateData",templateData?.info?.ticket_data) + if (originalTicket_data && typeof originalTicket_data === 'string') { + try { + + const ttlSeconds: number = 300 + const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data + if (originalTicketObj && typeof originalTicketObj === 'object') { + console.log("Found valid ticket data: hash it") + // คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ) + // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) + const expireTimestamp = Math.floor(Date.now() / 1000) + ttlSeconds; + const fullObj = { ...originalTicketObj, exp: expireTimestamp }; + const sortedObj = sortObjectKeys(fullObj); + + // แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash + const ticket_data = JSON.stringify(sortedObj); + // console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data) + const HmacSha256 = generateHmacSha256(ticket_data, TEST_SECRET) + + const Ed25519 = signDataEd25519(ticket_data, TEST_PRIVATE_KEY_HEX) + templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 }) + } + } catch (error) { + console.error("Set custom property fail",error); + + } + } + + // Apply Dynamic Content + if (templateData?.dc && typeof templateData?.dc === "object") { + console.log("Apply Dynamic Content") + templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc)) + } + // Apply Content Control + if (templateData?.cc && typeof templateData?.cc === "object") { + console.log("Apply Content Control") + templateBuffer = templateContentControls(templateBuffer, templateData.cc); + } + + const output = templateBuffer; // ----------------------------- // ส่ง DOCX กลับ // ----------------------------- - if(templateData?.info?.ticket_data) + return new NextResponse(output as BodyInit, { status: 200, headers: { "Content-Type": DOCX_CONTENT_TYPE, + "Content-Disposition": 'attachment; filename="generated-document.docx"', + "Content-Length": String(output.length), }, }); @@ -122,7 +196,7 @@ export async function POST(request: Request) { return NextResponse.json( { - + success: false, error: error instanceof Error ? error.message @@ -131,5 +205,4 @@ export async function POST(request: Request) { { status: 500 }, ); } -} - +} \ No newline at end of file diff --git a/app/api/template/test/T2.json b/app/api/template/test/T2.json new file mode 100644 index 0000000..799c36f --- /dev/null +++ b/app/api/template/test/T2.json @@ -0,0 +1,29 @@ +{ + "notname": "คำสั่งเรียก ให้ทำคำให้การ", + "nottype": "(ต. ๑)", + "blackno": "คดีหมายเลขดำที่", + "blackyear": "๑๐/๒๕๖๙", + "redno": "คดีหมายเลขแดงที่", + "redyear": "", + "court": "ศาลปกครองสุพรรณบุรี", + "curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", + "accuser": "นายธัญวิสิษฐ์ ศรแก้ว", + "acc1": "ผู้ฟ้องคดี", + "accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน", + "acc2": "ผู้ถูกฟ้องคดี", + "nottitle": "คำสั่งถึง", + "commto": "เจ้าคุณมด", + "groupname": "ผู้ถูกฟ้องคดีที่ ๑", + "detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้", + "pname": "นางนพเก้า เถื่อนชัย", + "courtname": "ศาลปกครองสุพรรณบุรี", + "courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี", + "courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐", + "courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓", + "courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”", + "sendto": "เจ้าคุณมด", + "addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -", + "remarkhead": "", + "remark": "", + "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี" +} \ No newline at end of file diff --git a/app/api/template/test/T3.docx b/app/api/template/test/T3.docx new file mode 100644 index 0000000..7573a45 Binary files /dev/null and b/app/api/template/test/T3.docx differ diff --git a/app/api/template/test/Test.docx b/app/api/template/test/Test.docx new file mode 100644 index 0000000..f078f00 Binary files /dev/null and b/app/api/template/test/Test.docx differ diff --git a/app/api/template/test/make-protected.mjs b/app/api/template/test/make-protected.mjs new file mode 100644 index 0000000..c35f2b2 --- /dev/null +++ b/app/api/template/test/make-protected.mjs @@ -0,0 +1,298 @@ +// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties +// (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md +// +// ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx] +// ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด) +// +// สิ่งที่แก้ในไฟล์ docx: +// 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1" +// (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย +// จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้) +// 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย +// w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone" +// ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค) +// พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้ +// 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr +// 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml +// 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml +import PizzaZip from "pizzip"; +import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; +import xpath from "xpath"; +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, join } from "node:path"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx"); +const outPath = process.argv[3] ?? inPath; + +// ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร) +const CUSTOM_PROPS = { + token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo", + "primary-key": "DOC-2026-000123", + owner: "u1001", +}; + +const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; +const CUSTOM_NS = + "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; +const VT_NS = + "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; +const REL_CUSTOM = + "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; +const CT_CUSTOM = + "application/vnd.openxmlformats-officedocument.custom-properties+xml"; +const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; + +const parser = new DOMParser(); +const serializer = new XMLSerializer(); + +const parseXml = (text) => parser.parseFromString(text, "text/xml"); + +// xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี +// (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid) +const serialize = (doc) => { + const text = serializer.serializeToString(doc); + return text.startsWith("\r\n' + text; +}; + +// ครอบ w:sdt ที่มี w:tag ทุกตัวด้วย w:permStart/w:permEnd (edGrp="everyone") +// และเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี — คืนจำนวน CC ที่ครอบ +function unlockContentControls(zip) { + const select = xpath.useNamespaces({ w: W_NS }); + const asNode = (n) => n; + const parts = Object.keys(zip.files).filter((n) => + /^word\/(document|header\d*|footer\d*)\.xml$/.test(n), + ); + + // id ของ perm ต้องไม่ซ้ำในเอกสาร — เริ่มสูงกว่าค่าสูงสุดที่มีอยู่ (จากทุก part) + let nextId = 999; + for (const part of parts) { + const ids = select( + "//w:permStart/@w:id | //w:permEnd/@w:id", + asNode(parseXml(zip.file(part).asText())), + ); + for (const id of ids) { + const n = Number.parseInt(id.value, 10); + if (Number.isFinite(n) && n >= nextId) nextId = n; + } + } + + let wrapped = 0; + let locked = 0; + for (const part of parts) { + const doc = parseXml(zip.file(part).asText()); + + // เฉพาะ sdt ระดับบนสุด (ไม่อยู่ใน sdt อื่น) มี w:tag = ช่องข้อมูลของ template + // และยังไม่ถูกครอบด้วย permStart อยู่แล้ว (กัน double-wrap ตอนรันซ้ำ) + const sdts = select( + "//w:sdt[./w:sdtPr/w:tag][not(ancestor::w:sdt)][not(preceding-sibling::*[1][self::w:permStart])]", + asNode(doc), + ); + if (sdts.length === 0) continue; + + for (const sdt of sdts) { + // เติม lock ให้ CC ที่ยังไม่มี — กันลบช่องตอนเปิดช่องให้แก้ผ่าน permission + const sdtPr = select("./w:sdtPr", sdt)[0]; + const hasLock = select("./w:lock", sdtPr).length > 0; + if (!hasLock) { + const lock = doc.createElementNS(W_NS, "w:lock"); + lock.setAttribute("w:val", "sdtLocked"); + // w:lock ต้องอยู่หลัง w:id ตามลำดับ schema ของ sdtPr + const idEl = select("./w:id", sdtPr)[0]; + if (idEl && idEl.nextSibling) { + sdtPr.insertBefore(lock, idEl.nextSibling); + } else { + sdtPr.appendChild(lock); + } + locked++; + } + + const start = doc.createElementNS(W_NS, "w:permStart"); + start.setAttribute("w:id", String(nextId)); + start.setAttribute("w:edGrp", "everyone"); + const end = doc.createElementNS(W_NS, "w:permEnd"); + end.setAttribute("w:id", String(nextId)); + nextId++; + + sdt.parentNode.insertBefore(start, sdt); + sdt.parentNode.insertBefore(end, sdt.nextSibling); + wrapped++; + } + + zip.file(part, serialize(doc)); + } + return { wrapped, locked }; +} + +function enableReadOnlyProtection(zip) { + const file = zip.file("word/settings.xml"); + if (!file) throw new Error("word/settings.xml not found"); + + const doc = parseXml(file.asText()); + const prots = doc.getElementsByTagNameNS(W_NS, "documentProtection"); + const prot = prots.length > 0 ? prots[0] : null; + + if (prot) { + prot.setAttribute("w:edit", "readOnly"); + prot.setAttribute("w:enforcement", "1"); + } else { + // T3 มี element นี้อยู่แล้ว แต่รองรับไฟล์ที่ไม่มี: แทรกเป็น child แรกของ w:settings + const el = doc.createElementNS(W_NS, "w:documentProtection"); + el.setAttribute("w:edit", "readOnly"); + el.setAttribute("w:enforcement", "1"); + doc.documentElement.insertBefore(el, doc.documentElement.firstChild); + } + + zip.file("word/settings.xml", serialize(doc)); +} + +function setCustomProperties(zip, props) { + // --- 1. docProps/custom.xml : สร้างใหม่ หรือ merge กับของเดิม --- + const existing = zip.file("docProps/custom.xml"); + let doc; + if (existing) { + doc = parseXml(existing.asText()); + // ลบ property ชื่อซ้ำออกก่อน (เขียนทับค่าใหม่) + const all = Array.from(doc.getElementsByTagNameNS(CUSTOM_NS, "property")); + for (const p of all) { + if (Object.prototype.hasOwnProperty.call(props, p.getAttribute("name"))) { + p.parentNode.removeChild(p); + } + } + } else { + doc = parseXml( + `` + + ``, + ); + } + + // pid ต้อง >= 2 และไม่ซ้ำ — เริ่มจากค่าสูงสุดที่มีอยู่ +1 + const usedPids = Array.from( + doc.getElementsByTagNameNS(CUSTOM_NS, "property"), + ).map((p) => Number.parseInt(p.getAttribute("pid"), 10) || 1); + let nextPid = Math.max(1, ...usedPids) + 1; + + for (const [name, value] of Object.entries(props)) { + const prop = doc.createElementNS(CUSTOM_NS, "property"); + prop.setAttribute("fmtid", FMTID); + prop.setAttribute("pid", String(nextPid++)); + prop.setAttribute("name", name); + const val = doc.createElementNS(VT_NS, "vt:lpwstr"); + val.textContent = value; + prop.appendChild(val); + doc.documentElement.appendChild(prop); + } + zip.file("docProps/custom.xml", serialize(doc)); + + // --- 2. [Content_Types].xml : เพิ่ม Override (ครั้งเดียว) --- + const ct = parseXml(zip.file("[Content_Types].xml").asText()); + const hasCt = Array.from(ct.getElementsByTagName("Override")).some( + (o) => o.getAttribute("PartName") === "/docProps/custom.xml", + ); + if (!hasCt) { + const el = ct.createElement("Override"); + el.setAttribute("PartName", "/docProps/custom.xml"); + el.setAttribute("ContentType", CT_CUSTOM); + ct.documentElement.appendChild(el); + zip.file("[Content_Types].xml", serialize(ct)); + } + + // --- 3. _rels/.rels : เพิ่ม Relationship (ครั้งเดียว) --- + const rels = parseXml(zip.file("_rels/.rels").asText()); + const relNodes = Array.from(rels.getElementsByTagName("Relationship")); + if (!relNodes.some((r) => r.getAttribute("Type") === REL_CUSTOM)) { + const usedIds = new Set(relNodes.map((r) => r.getAttribute("Id"))); + let n = 1; + while (usedIds.has(`rId${n}`)) n++; + const el = rels.createElement("Relationship"); + el.setAttribute("Id", `rId${n}`); + el.setAttribute("Type", REL_CUSTOM); + el.setAttribute("Target", "docProps/custom.xml"); + rels.documentElement.appendChild(el); + zip.file("_rels/.rels", serialize(rels)); + } +} + +// อ่านกลับมาตรวจทุกอย่างหลังเขียนไฟล์ +function verify(path) { + const zip = new PizzaZip(readFileSync(path)); + + const settings = zip.file("word/settings.xml")?.asText() ?? ""; + const prot = settings.match(/]*\/>/)?.[0]; + const enforced = + prot?.includes('w:edit="readOnly"') && prot?.includes('w:enforcement="1"'); + + const customXml = zip.file("docProps/custom.xml")?.asText(); + const propsBack = {}; + if (customXml) { + const doc = parseXml(customXml); + for (const p of Array.from( + doc.getElementsByTagNameNS(CUSTOM_NS, "property"), + )) { + propsBack[p.getAttribute("name")] = p.textContent; + } + } + + const rels = zip.file("_rels/.rels")?.asText() ?? ""; + const ct = zip.file("[Content_Types].xml")?.asText() ?? ""; + + const docXml = zip.file("word/document.xml")?.asText() ?? ""; + const sdtPrs = docXml.match(/[\s\S]*?<\/w:sdtPr>/g) ?? []; + const plain = sdtPrs.filter((p) => / / `${k}="${v.length > 40 ? v.slice(0, 40) + "…" : v}"`) + .join(", ") || "(ว่าง)", + ); + console.log( + `relationship : ${rels.includes(REL_CUSTOM) ? "✓" : "✗ ไม่พบ"} | content-type override: ${ct.includes(CT_CUSTOM) ? "✓" : "✗ ไม่พบ"}`, + ); + console.log(`content controls : ${sdtPrs.length} ตัว (plain=${plain}, rich=${rich}, มี tag=${tagged})`); + console.log( + `permission ranges : permStart=${permStarts} / permEnd=${permEnds} (ต้องเท่ากับมี tag=${tagged}) ` + + (permStarts === tagged && permEnds === tagged ? "✓" : "✗"), + ); + console.log( + `CC ไม่มี lock : ${missingLock} ตัว (ต้องเป็น 0 — กันลบช่อง) ${missingLock === 0 ? "✓" : "✗"}`, + ); + + return ( + enforced && + !!customXml && + permStarts === tagged && + permEnds === tagged && + missingLock === 0 + ); +} + +const zip = new PizzaZip(readFileSync(inPath)); +enableReadOnlyProtection(zip); +const { wrapped, locked } = unlockContentControls(zip); +setCustomProperties(zip, CUSTOM_PROPS); + +if (locked > 0) { + console.log(`เติม w:lock="sdtLocked" ให้ CC อีก ${locked} ตัวที่ยังไม่มี`); +} +console.log(`ครอบ CC ด้วย permission range: ${wrapped} ตัว`); + +const out = zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); +writeFileSync(outPath, out); +console.log(`เขียนไฟล์แล้ว: ${outPath} (${out.length.toLocaleString()} bytes)`); + +const ok = verify(outPath); +process.exit(ok ? 0 : 1); diff --git a/app/api/template/test/note.md b/app/api/template/test/note.md new file mode 100644 index 0000000..919190b --- /dev/null +++ b/app/api/template/test/note.md @@ -0,0 +1,127 @@ +# Docx Template +lib-tmpl.ts เป็นแชร์ไลบารีสำหรับการทำ Template ใช้ Content Control ใน MS Word +## วิธีการใช้ API +```sh +## Swagger UI — เปิดในเบราว์เซอร์ (ลองยิงจริงผ่านปุ่ม Try it out ได้เลย ไม่ต้อง login) +http://localhost:3000/docedit/api/swagger + +## template/test ไว้ทดสอบเท่านั้น +curl.exe http://localhost:3000/docedit/api/template/test + +## extract ค่าออกมาจาก content control +curl.exe -X POST http://localhost:3000/docedit/api/template/extract -F "file=@./output-T3.docx" + +## render เอกสาร (docx) = template(docx)+data(json) +### อาจจะเพิ่มการเอา template จาก url และ file(local) ยังไม่รู้ว่าทางนั้นจะส่งไฟล์แบบไหนมาให้ เอาแค่นี้ไว้ทดสอบก่อน +curl.exe -X POST http://localhost:3000/docedit/api/template/render -F "template=@./T3.docx" -F "data=@./T2.json" -o output.docx +### data เอาจาก string +curl -X POST http://localhost:3000/docedit/api/template/render \ + -F "template=@template.docx" \ + -F 'dataJson={"member_name":"นายสมชาย","member_id":"123456"}' \ + -o generated-document.docx + +``` +data(json) ตอนนี้อยู่ในรูปแบบ +```json +{ + "notname": "คำสั่งเรียก ให้ทำคำให้การ", + "nottype": "(ต. ๑)", + "blackno": "คดีหมายเลขดำที่", + "blackyear": "๑๐/๒๕๖๙", + ... +} +``` +data(json)น่าจะปรับเป็นแบบนี้เพื่อรองรับการทำ Token-Based Seamless Integration +หลักผ่าน API +- ส่วนของ cc จะไปกรอกใน Content Control +- uid,uname,fid,ott,expire สร้างเป็น token ฝังใน custom property ของเอกสาร +```json +{ + "uid":"id ผู้มีสิทธิ์แก้ไขเอกสาร", + "uname": "ชื่อผู้มีสิทธิ์แก้ไขเอกสาร สำหรับแสดงผล", + "fid":"file id จะได้เซฟข้อมูลกลับไปที่ Oracle ถูกตารางและหน่วยงาน", + "ott":"อายุของ one time token แบบระยะสั้น", + "expire": "อายุเอกสารแบบระยะยาวหลังเอา ott ไปแลก (optional)", + cc:{ + "notname": "คำสั่งเรียก ให้ทำคำให้การ", + "nottype": "(ต. ๑)", + "blackno": "คดีหมายเลขดำที่", + "blackyear": "๑๐/๒๕๖๙", + ... + } +} +``` +ที่ต้องทำเพิ่ม +- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต +- Backend จัดการ version +- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน + +# ทดลอง Restrict Edit + Custom Properties (2026-10-07) + +สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง + +```sh +node app/api/template/test/make-protected.mjs [in.docx] [out.docx] +# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS) +``` + +## Restrict Edit (เปิดผ่านโค้ด) + +- อยู่ที่ `word/settings.xml` → `` +- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่ +- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป +- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `` … ``** — คือกลไก "Exceptions (optional): Everyone" ที่แผง Restrict Editing ของ Word ใช้เวลาเราเลือกบริเวณแล้วติ๊ก Everyone (บริเวณใน permission range แก้ได้ นอกนั้นล็อค) — แทรกที่ระดับเดียวกับ sdt (inline ใน `w:p` หรือ block ใน `w:body` ก็ valid ตาม grammar) +- สคริปต์เติม `w:lock="sdtLocked"` ให้ CC ที่ยังไม่มีด้วย (T3 ขาด acc1, nottitle) — พอช่องถูกเปิดให้แก้ผ่าน permission แล้ว ช่องที่ไม่มี lock จะถูก**ลบ**ได้ตามไปด้วย +- ไม่ใส่ password → ผู้ใช้ปลดเองได้จาก Review → Restrict Editing → Stop Protection — ตั้งใจไว้แบบนี้เพราะ concept คือ token ใน custom property เป็นตัวกำกับสิทธิ์จริง ส่วน protection เป็นแค่เกราะกันแก้พลาด (ถ้าจะใส่ password ต้องคำนวณ legacy hash: SHA-1 ของ password+salt วนตาม `w:cryptSpinCount` ตาม ECMA-376 — ยังไม่ทำ) + +## ฝังข้อมูลใน Property เอกสาร (token / primary-key / owner) + +ทางเลือกที่ศึกษา: +1. **`docProps/custom.xml` (custom properties) — ที่เลือกใช้** เห็นได้ใน Word: File → Info → Properties → Advanced Properties → แท็บ Custom; add-in ฝั่ง Word (Office.js) อ่าน/เขียนได้ผ่าน `Document.properties.customProperties` (WordApi 1.3+) + - ไฟล์ที่ไม่มี part นี้อยู่ (กรณีปกติของ docx) ต้องทำครบ 3 อย่างพร้อมกัน ไม่งั้น Word ถือว่าไฟล์เสีย: + 1. สร้าง `docProps/custom.xml` — property แต่ละตัว: `fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` คงที่ + `pid` ≥ 2 ไม่ซ้ำ + `name` + ค่า string ใน `vt:lpwstr` + 2. `[Content_Types].xml` เพิ่ม `` + 3. `_rels/.rels` เพิ่ม Relationship ชนิด `.../relationships/custom-properties` Target `docProps/custom.xml` +2. `docProps/core.xml` (core properties: title/subject/creator/description) — แก้ง่าย เห็นในหน้า Properties ปกติ เหมาะกับข้อมูลที่แสดงต่อผู้ใช้ ไม่ใช่ token +3. `w:docVar` ใน `word/settings.xml` (document variables) — ไม่โผล่ใน UI เลย อ่านได้ผ่าน VBA/VSTO (`Document.Variables`) แต่ Word JS API ไม่มีทางตรง — ไม่เลือก + +gotcha ที่เจอ: `XMLSerializer` ของ @xmldom/xmldom คง `` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้) + +## extract บอก alignment รายช่อง — key `@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)** + +- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม +- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `@align` (ค่า w:jc เช่น `thaiDistribute`) เฉพาะช่องที่ต่างจาก default ของเอกสาร โดย resolve: `w:pPr/w:jc` ตรงของย่อหน้า → style ที่ใช้ (ไล่ `w:pStyle` → `w:basedOn`) → `docDefaults` ("left"/"start" นับเป็นไม่ตั้ง) — บริบทย่อหน้าของช่อง: CC block = `w:p` แรกใน `w:sdtContent` / CC inline = `w:p` ที่ครอบ sdt +- ผลทดสอบตอน implement: direct pPr ชนะ style ✓ inline/block จับได้ทั้งคู่ ✓ และ T3 จัดช่อง 8 ตัวผ่าน style อยู่แล้ว (nottype/blackyear/redyear/acc1/acc2/groupname/detail1/pname — ชิดขวา/กลาง) — ช่องเหล่านี้จะโผล่ `@align` ตามจริง + +## จัด paragraph แบบ Thai Distributed — ตัดสินใจ: จัดเองใน Word ตอนทำ template (2026-10-07) + +- ค่า OOXML คือ `` ใน `w:pPr` (ใน Word UI: Paragraph → Alignment → Thai Distributed, ทางลัด Ctrl+Shift+J) — ต่างจาก `distribute` ธรรมดาตรงที่จัดกระจายช่องว่างระหว่างคำเป็นหลัก ไม่บีบยืดตัวอักษร/สระ-วรรณยุกต์ +- **ไม่ตั้งผ่านโค้ด** — ผู้ออกแบบ template จัดเองใน Word ตอนทำ template ทางเดียวกับที่ตั้งฟอนต์/ย่อหน้าอื่น ๆ (เคยทดลองตั้ง default ทั้งเอกสารผ่าน `styles.xml` → `w:docDefaults/w:pPrDefault/w:pPr` แล้วถอดออกตามคำขอ) +- วิธีจัดเองใน Word: + - รายย่อหน้า/ราย CC: คลิกในย่อหน้า → `Ctrl+Shift+J` หรือ Home → Paragraph launcher → Alignment เลือก Thai Distributed + - ทั้งเอกสาร: คลิกขวา style Normal → Modify → Format → Paragraph → Thai Distributed + - จัด "เฉพาะช่อง CC" ได้เฉพาะ CC แบบ block (มี `w:p` ใน `w:sdtContent` ของตัวเอง เช่น `detail1`) — CC แบบ inline อยู่ในบรรทัดเดียวกับข้อความอื่น การจัดมีผลทั้งบรรทัด +- จุดสำคัญต่อ render pipeline: `insertBlockContent` ของ lib-tmpl clone `w:pPr` ของย่อหน้าแรกให้ทุกบรรทัดที่แทนค่า — จัดไว้ใน CC ตั้งแต่ต้นค่าจะสืบทอดไปทุกบรรทัดที่ render ใส่ข้อมูลอัตโนมัติ ไม่ต้องทำอะไรฝั่ง render +- (อ้างอิงเชิงเทคนิคกรณีต้องตั้งผ่านโค้ดภายหลัง: `w:jc` ใน `w:pPr` เป็น fixed sequence — ต้องอยู่หลัง `w:autoSpaceDN`/`w:spacing`/`w:ind` และก่อน `w:textDirection`/`w:rPr`/`w:sectPr`) + +## ผล verify (อัตโนมัติทุกครั้งที่รันสคริปต์) + +- `documentProtection` เปิดใช้ (readOnly + enforcement=1) ✓ +- `docProps/custom.xml` มี + relationship + content-type override ครบ ✓ +- Content Control 27 ตัวครบเดิม (plain 26 = มี `w:text`, rich 1 = `detail1`) ✓ +- permission ranges: permStart/permEnd ครบตามจำนวน CC ที่มี tag (27/27) ✓ +- ทุก CC มี `w:lock="sdtLocked"` (กันลบช่อง) ✓ +- `extractContentControls` (lib-tmpl) อ่านคืนได้ทุก tag ตรงกับ output.docx ก่อน protect 100% ✓ + +## ผลทดสอบใน Word จริง (2026-10-07 รอบ 1) + +- ✅ แถบเหลือง protection ขึ้น / แก้นอก CC ไม่ได้ / ลบ CC ไม่ได้ +- ❌ B5: **CC แก้ไม่ได้** เพราะ readOnly ล็อคทั้งเอกสาร → แก้ด้วย permStart/permEnd ตามด้านบนแล้ว (รอบ 2) +- ✅ custom properties ครบ / ปลด protection ได้แบบไม่มี password + +## เหลือตรวจใน Word จริง (รอบ 2) + +- เปิด `output-T3.docx` → พิมพ์/แก้ในช่อง CC ต้องได้แล้ว (B5) +- ลบ CC ต้องไม่ได้ทุกตัว (รวม acc1, nottitle ที่เติม lock ให้) +- ขั้น E เดิม: F12 เซฟสำเนา → เปิดใหม่ → properties ยังอยู่ครบ +- (ผ่านแล้วทั้งหมด → ย้าย logic ไปเป็นฟังก์ชันใน lib-tmpl.ts แล้วต่อเข้า render pipeline) diff --git a/app/api/template/test/output-T3.docx b/app/api/template/test/output-T3.docx new file mode 100644 index 0000000..c7619be Binary files /dev/null and b/app/api/template/test/output-T3.docx differ diff --git a/app/api/template/test/output.docx b/app/api/template/test/output.docx new file mode 100644 index 0000000..2622682 Binary files /dev/null and b/app/api/template/test/output.docx differ diff --git a/app/api/template/test/test.http b/app/api/template/test/test.http new file mode 100644 index 0000000..d615855 --- /dev/null +++ b/app/api/template/test/test.http @@ -0,0 +1,43 @@ +@baseUrl = http://localhost:3000/docedit/api + +### 1. Extract Template +POST {{baseUrl}}/template/extract +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="file"; filename="output-T3.docx" +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + +< ./output-T3.docx +--WebAppBoundary-- + +### 2. Render Template with JSON File +POST {{baseUrl}}/template/render +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="template"; filename="T3.docx" +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + +< ./T3.docx +--WebAppBoundary +Content-Disposition: form-data; name="data"; filename="T2.json" +Content-Type: application/json + +< ./T2.json +--WebAppBoundary-- + +### 3. Render Template with Raw JSON String +POST {{baseUrl}}/template/render +Content-Type: multipart/form-data; boundary=WebAppBoundary + +--WebAppBoundary +Content-Disposition: form-data; name="template"; filename="template.docx" +Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document + +< ./T3.docx +--WebAppBoundary +Content-Disposition: form-data; name="dataJson" + +{"notname": "คำสั่งเรียก ให้ทำคำให้การ","nottype": "(ต. ๑)","blackno": "คดีหมายเลขดำที่","blackyear": "๑๐/๒๕๖๙","redno": "คดีหมายเลขแดงที่","redyear": "","court": "ศาลปกครองสุพรรณบุรี","curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙","accuser": "นายธัญวิสิษฐ์ ศรแก้ว","acc1": "ผู้ฟ้องคดี","accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน","acc2": "ผู้ถูกฟ้องคดี","nottitle": "คำสั่งถึง","commto": "เจ้าคุณมด","groupname": "ผู้ถูกฟ้องคดีที่ ๑","detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้","pname": "นางนพเก้า เถื่อนชัย","courtname": "ศาลปกครองสุพรรณบุรี","courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี","courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐","courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓","courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”","sendto": "เจ้าคุณมด","addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -","remarkhead": "","remark": "","warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"} +--WebAppBoundary-- diff --git a/app/tmpl/page.tsx b/app/tmpl/page.tsx deleted file mode 100644 index 0eaec44..0000000 --- a/app/tmpl/page.tsx +++ /dev/null @@ -1,100 +0,0 @@ -// app/page.tsx (ระบบ B) -'use client'; -import type { ChangeEvent } from 'react'; -// import { createDownloadFormAction } from './actions'; -import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown'; - - - -export default function HomePage() { - const templateData: DropdownData = { - T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'], - T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'], - }; - const handleTmplSelect = (template: string, data: string) => { - //console.log('Location Selected:', { template, data }); - }; - const handleInput = (e: ChangeEvent)=>{ - // console.log(e.target) - return true; - } - return ( -
-
-

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

-
- ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET) - เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ - ต้องทำให้ ASP.NET มีฟังก์ชั่นเหมือน /docedit/api/template/download-docx -
-
-
-

Form POST

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

Template & Data

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

Flow

-
- หน้านี้(เลือกไฟล์)
- ↓
- download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)
- ↓
- generate(เปิดเทมเพลต,สร้าง docx)
- ↓
- ดาว์นโหลด docx เปิด MS Word อัตโนมัติ
- ↓
- Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx
- ↓
- Versioning(คุมเวอร์ชั่น)
- ↓
- ecourt(อัปเดตฐานข้อมูลและไฟล์)
- -
- -
- -
-
- ); -} \ No newline at end of file diff --git a/components/utils/DependentDropdown.tsx b/components/utils/DependentDropdown.tsx deleted file mode 100644 index 7331bd9..0000000 --- a/components/utils/DependentDropdown.tsx +++ /dev/null @@ -1,91 +0,0 @@ -'use client'; - -import { useState, ChangeEvent, useEffect } from 'react'; - -export interface DropdownData { - [key: string]: string[]; -} - -interface DependentDropdownProps { - data: DropdownData; - name1: string; // เพิ่มมาเพื่อใช้กับ Form Action - name2: string; // เพิ่มมาเพื่อใช้กับ Form Action - label1?: string; - label2?: string; - placeholder1?: string; - placeholder2?: string; - onSelectChange?: (mainValue: string, subValue: string) => void; -} - -export default function DependentDropdown({ - data, - name1, - name2, - label1 = 'เลือกตัวเลือกหลัก:', - label2 = 'เลือกตัวเลือกย่อย:', - placeholder1 = '-- กรุณาเลือก --', - placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --', - onSelectChange, -}: DependentDropdownProps) { - const [mainSelection, setMainSelection] = useState(''); - const [subSelection, setSubSelection] = useState(''); - - const handleMainChange = (e: ChangeEvent) => { - const value = e.target.value; - setMainSelection(value); - setSubSelection(''); - }; - - const handleSubChange = (e: ChangeEvent) => { - setSubSelection(e.target.value); - }; - - useEffect(() => { - if (onSelectChange) { - onSelectChange(mainSelection, subSelection); - } - }, [mainSelection, subSelection, onSelectChange]); - - const subOptions = mainSelection ? data[mainSelection] : []; - - return ( -
- {/* Dropdown ที่ 1 */} -
- - -
- - {/* Dropdown ที่ 2 */} -
- - -
-
- ); -} diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 84788ea..38f1602 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -23,20 +23,8 @@ ENV NEXT_TELEMETRY_DISABLED=1 # db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data — # ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder -ENV MINIO_ENDPOINT=127.0.0.1 -ENV MINIO_PORT=19000 -ENV MINIO_USE_SSL=false -ENV MINIO_ACCESS_KEY=minioadmin -ENV MINIO_SECRET_KEY=minioadmin -ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54 -ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e -ENV DOCVER_TOKEN_TTL_SECONDS=604800 -ENV MINIO_BUCKET=docver -ENV DOCVER_LINK_TTL_SECONDS=900 - RUN npm run build - # ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ─── FROM node:24-alpine AS runner WORKDIR /app diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index fff1526..771669a 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -1,28 +1,20 @@ # PLAN-DOCVER — Document Versioning API (stand-alone) -> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement -> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin) +> วันที่ตกลงแผน: 2026-10-20 · สถานะ: implement รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin) ## 0. บริบท/เป้าหมาย ทำ backend จริงของฟีเจอร์ **"e-Court Documents Versioning"** (ตอนนี้ wordaddin ใช้ mock ใน `wordaddin/services/versioningService.ts`): -- ตารางเอกสารคีย์ด้วย `docId` **ห้ามซ้ำ** (unique index) — **`docId` = claim `fid` - (string) จาก Bearer token ตรง ๆ** (2026-10-26 ถอด path param `{docId}` ออกจาก URL ทุก - endpoint — fileId ของ e-Court ใน token เป็นที่มาเดียวของรหัสเอกสาร) +- รับ `docId` จากผู้เรียก → ตารางเอกสารที่ `docId` **ห้ามซ้ำ** (unique index) - แต่ละ version อ้าง **object ใน MinIO** — เก็บทั้ง `.docx` และ `.pdf` (pdf เป็น optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน response) - เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด -- **identity (`userId`/`userName`/`templateId`) และ `docId` แกะจาก Bearer token** (2026-10-22 - — เดิมส่ง plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header - `x-api-key` แล้วแนบ `Authorization: Bearer ` กับทุก request — ระบบ stand-alone - ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น - พาหะ identity เดียว -- **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET` - (ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts` - ของ N10) · ไม่มี CORS +- **endpoint รับ `userId`/`userName` ใน request** เพราะเป็นระบบ stand-alone ไม่มี session + ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก — identity ส่งมาแบบ plain field) +- **ไม่มี auth** — เปิดกว้างเหมือนกลุ่ม `api/template` (intranet) · ไม่มี CORS ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body @@ -36,33 +28,7 @@ Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/service ## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก middleware เหมือน `api/template`) -### `POST /api/documents/token` — ขอ access_token (2026-10-22) - -auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed · -ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ - -JSON body (allowlist — key อื่น = 400 กัน typo): - -| field | type | หมายเหตุ | -|---|---|---| -| `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) | -| `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) | -| `fid` | string | **บังคับ** — trim 1–200 · **fileId ของ e-Court** เป็น `docId` ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid | -| `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) | - -- env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า" - (ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret) -- response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }` - — JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600) - · verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe -- **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer `** — - ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย - เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ) - -### `POST /api/documents/versions` — อัปโหลด version ใหม่ - -auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่และ -`docId` (= `fid` — string) มาจาก claims ของ token ทั้งหมด ไม่รับจาก URL/form อีกต่อไป +### `POST /api/documents/{docId}/versions` — อัปโหลด version ใหม่ multipart/form-data: @@ -70,21 +36,22 @@ multipart/form-data: |---|---|---| | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | +| `userId` | string | **บังคับ** (stand-alone) — trim 1–100 | +| `userName` | string | **บังคับ** (stand-alone) — trim 1–200 | | `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | | `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) | +| `templateId` | string | optional ≤100 | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | -- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) — - **breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว - (ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน +- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) - เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint - "สร้างเอกสาร" แยก (เอกสารถูกระบุที่ fid ใน token ตั้งแต่ขอ token) + "สร้างเอกสาร" แยก (ตาม flow ของ add-in ที่ generate documentId เองแล้วอัปโหลดทันที) - response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }` (= `UploadVersionResult` เป๊ะ) — `versionNumber` เป็นค่า **authoritative จาก server** (client เดา `latest+1` แล้วแก้ XML part ตัวเองเมื่อไม่ตรง — กรณีอัปโหลดชนกัน) -### `GET /api/documents/versions` — history (ของเอกสาร fid ใน token) +### `GET /api/documents/{docId}/versions` — history response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า** (ตาม `VersionRecord[]` ของ add-in + ส่วนเสริม `createdByUserName`): @@ -103,14 +70,13 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า } ``` -- `docId` (จาก fid ใน token) ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ - N10 — UI ของ add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) +- `docId` ไม่รู้จัก (ยังไม่เคยมี version) = **404** (สัญญาณ REST ตรง ๆ แบบ N10 — UI ของ + add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) -### `GET /api/documents/versions/{versionId}/download-link?format=docx|pdf` +### `GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf` - format ไม่ส่ง/"" = `docx` · ค่าอื่น = 400 -- query version row **เช็ค docId (จาก fid ใน token) กับ versionId พร้อมกัน** (กันโยน id - ข้ามเอกสาร) — ไม่เจอ = 404 +- query version row **เช็คทั้ง docId และ versionId พร้อมกัน** (กันโยน id ข้ามเอกสาร) — ไม่เจอ = 404 - `format=pdf` แต่ version นั้นไม่มี pdf = 404 "version นี้ไม่มีสำเนา PDF" - response 200: `{ "url": string, "expiresAt": string }` — url เป็น **MinIO presigned GET** (อายุตาม env `DOCVER_LINK_TTL_SECONDS` default 900 วิ) ให้ UI เปิดในเบราว์เซอร์จริงของ @@ -122,11 +88,10 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า | เหตุ | สถานะ | |---|---| -| รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | -| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 | +| รูป docId/versionId/format ผิด · field ไม่รู้จัก · ไม่ใช่ multipart · magic bytes ผิด | 400 | | ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 | | เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 | -| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) | +| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) | ## 2. ตาราง (db/schema.ts) @@ -191,9 +156,6 @@ read-only ของ N10 แต่ตัวนี้เขียนได้) | `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` | | `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) | | `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL | -| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ | -| `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ | -| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) | `putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''` best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย @@ -204,16 +166,13 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET - `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO` - `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้ โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`) -- `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer` - + `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`) - `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) + `ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers -- `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key) -- `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token) -- `app/api/documents/versions/[versionId]/download-link/route.ts` — GET +- `app/api/documents/[docId]/versions/route.ts` — POST + GET +- `app/api/documents/[docId]/versions/[versionId]/download-link/route.ts` — GET - `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio (โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io) -- `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure +- `tests/lib/document-versioning.test.ts` — unit test ส่วน pure ## 8. Out of scope รอบนี้ diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index bbbb054..9973286 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -17,21 +17,22 @@ export const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; export const PDF_CONTENT_TYPE = "application/pdf"; -/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token - * (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param - * ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */ +/** รหัสเอกสารจากผู้เรียก — URL-path-safe ล้วน (ไม่มี / .. ช่องว่าง ไทย) กันถูก + * ฝังลง path/log แปลก ๆ และตัดขนาดให้จำกัด */ +const DOC_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; -/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ - * (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token - * ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */ +/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ */ const FORM_FIELDS = [ "file", "pdf", + "userId", + "userName", "clientVersionId", "fields", "message", + "templateId", "fileName", ] as const; @@ -46,6 +47,22 @@ export function docverError(message: string, status: number): Response { }); } +/** ตรวจ docId จาก path param — trim แล้วว่าง/เกิน 200/มีตัวอักษรนอก charset = ไม่ผ่าน + * (+ ปฏิเสธ "." / ".." เป็นพิเศษแม้อยู่ใน charset — path-traversal specials) */ +export function parseDocId(raw: string): + | { ok: true; docId: string } + | DocverError { + const docId = raw.trim(); + if (docId === "." || docId === ".." || !DOC_ID_RE.test(docId)) { + return { + ok: false, + error: + "รหัสเอกสาร (docId) ต้องยาว 1–200 ตัวอักษร และใช้ได้เฉพาะ A-Z a-z 0-9 . _ : -", + }; + } + return { ok: true, docId }; +} + /** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ * matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */ export function isDocxBytes(bytes: Buffer): boolean { @@ -143,8 +160,11 @@ export function parseFieldsJson(raw: string): } export type ParsedVersionUploadForm = { + userId: string; + userName: string; clientVersionId: string | null; message: string | null; + templateId: string | null; /** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง * เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */ fileName: string | null; @@ -152,8 +172,7 @@ export type ParsedVersionUploadForm = { }; /** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน - * size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form - * แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */ + * size ก่อน buffer) — userId/userName บังคับเพราะระบบ stand-alone ไม่มี session */ export function parseVersionUploadForm(form: FormData): | { ok: true; form: ParsedVersionUploadForm } | DocverError { @@ -168,12 +187,36 @@ export function parseVersionUploadForm(form: FormData): } } + const userId = formString(form, "userId"); + if (!userId.ok) return userId; + if (userId.value === null) { + return { ok: false, error: "ต้องระบุ userId (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" }; + } + if (userId.value.length > 100) { + return { ok: false, error: "userId ยาวเกิน 100 ตัวอักษร" }; + } + + const userName = formString(form, "userName"); + if (!userName.ok) return userName; + if (userName.value === null) { + return { ok: false, error: "ต้องระบุ userName (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" }; + } + if (userName.value.length > 200) { + return { ok: false, error: "userName ยาวเกิน 200 ตัวอักษร" }; + } + const clientVersionId = formString(form, "clientVersionId"); if (!clientVersionId.ok) return clientVersionId; if (clientVersionId.value && clientVersionId.value.length > 100) { return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" }; } + const templateId = formString(form, "templateId"); + if (!templateId.ok) return templateId; + if (templateId.value && templateId.value.length > 100) { + return { ok: false, error: "templateId ยาวเกิน 100 ตัวอักษร" }; + } + const message = formString(form, "message"); if (!message.ok) return message; if (message.value && message.value.length > DOCVER_MESSAGE_MAX) { @@ -195,8 +238,11 @@ export function parseVersionUploadForm(form: FormData): return { ok: true, form: { + userId: userId.value, + userName: userName.value, clientVersionId: clientVersionId.value, message: message.value, + templateId: templateId.value, fileName: sanitizeDisplayFileName(rawFileName.value), fields, }, diff --git a/lib/docver-token.ts b/lib/docver-token.ts deleted file mode 100644 index 92bfc41..0000000 --- a/lib/docver-token.ts +++ /dev/null @@ -1,223 +0,0 @@ -// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning) -// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key" -// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ -// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual, -// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ) -// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน — -// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer — -// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload -// - claims "fid" = fileId ของ e-Court (string) — เป็น docId ของทุก operation ตรง ๆ -// — บังคับตั้งแต่ขอ token และ verify ไม่รับ token ที่ไม่มี fid -// -// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้ -// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts) -import { createHash, createHmac, timingSafeEqual } from "node:crypto"; - -import { docverError, type DocverError } from "@/lib/document-versioning"; - -/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */ -export type DocverTokenClaims = { - userid: string; - username: string; - fid: string; - template: string | null; -}; - -function base64url(input: string): string { - return Buffer.from(input, "utf8").toString("base64url"); -} - -function decodeJsonSegment(segment: string): Record | null { - let text: string; - try { - text = Buffer.from(segment, "base64url").toString("utf8"); - } catch { - return null; - } - try { - const parsed = JSON.parse(text) as unknown; - if ( - parsed === null || - typeof parsed !== "object" || - Array.isArray(parsed) - ) { - return null; - } - return parsed as Record; - } catch { - return null; - } -} - -/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */ -export function issueDocverToken( - claims: DocverTokenClaims, - secret: string, - ttlSeconds: number, -): string { - const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); - const iat = Math.floor(Date.now() / 1000); - const payload = base64url( - JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }), - ); - const signature = createHmac("sha256", secret) - .update(`${header}.${payload}`) - .digest("base64url"); - return `${header}.${payload}.${signature}`; -} - -/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid - * (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */ -function claimsFromPayload( - payload: Record, -): DocverTokenClaims | null { - const { userid, username, fid, template } = payload; - if (typeof userid !== "string" || userid === "") return null; - if (typeof username !== "string" || username === "") return null; - if (typeof fid !== "string" || fid === "") return null; - if (template !== null && typeof template !== "string") return null; - return { userid, username, fid, template }; -} - -/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") → - * เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) → - * exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */ -export function verifyDocverToken( - token: string, - secret: string, -): { ok: true; claims: DocverTokenClaims } | { ok: false } { - const segments = token.split("."); - if (segments.length !== 3) return { ok: false }; - const [headerSegment, payloadSegment, signatureSegment] = segments; - - const header = decodeJsonSegment(headerSegment); - if (header === null || header.alg !== "HS256") return { ok: false }; - - const expected = createHmac("sha256", secret) - .update(`${headerSegment}.${payloadSegment}`) - .digest(); - // ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง - // (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง — - // timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน) - const provided = createHash("sha256") - .update(Buffer.from(signatureSegment, "base64url")) - .digest(); - const expectedHash = createHash("sha256").update(expected).digest(); - if (!timingSafeEqual(provided, expectedHash)) return { ok: false }; - - const payload = decodeJsonSegment(payloadSegment); - if (payload === null) return { ok: false }; - const exp = payload.exp; - if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false }; - if (exp <= Math.floor(Date.now() / 1000)) return { ok: false }; - - const claims = claimsFromPayload(payload); - if (claims === null) return { ok: false }; - return { ok: true, claims }; -} - -/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่ - * env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้ - * คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */ -export function requireDocverApiKey(request: Request): Response | null { - // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file - const envKey = process.env.DOCVER_API_KEY?.trim(); - // const envKey = - // "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54"; - if (envKey === undefined || envKey === "") { - return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); - } - const provided = request.headers.get("x-api-key"); - if (provided === null || provided === "") { - return docverError("API key ไม่ถูกต้อง", 401); - } - const providedHash = createHash("sha256").update(provided).digest(); - const envHash = createHash("sha256").update(envKey).digest(); - return timingSafeEqual(providedHash, envHash) - ? null - : docverError("API key ไม่ถูกต้อง", 401); -} - -/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด / - * หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */ -const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ"; - -/** บังคับ Authorization: Bearer ของกลุ่ม docver — เรียกเป็นอย่างแรกของ - * ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี - * token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */ -export function requireDocverBearer( - request: Request, -): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } { - const denied = () => - ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const; - const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); - // const secret = - // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; - if (secret === undefined || secret === "") return denied(); - const header = request.headers.get("authorization"); - if (header === null) return denied(); - const match = /^Bearer\s+(\S+)$/i.exec(header); - if (match === null) return denied(); - const verified = verifyDocverToken(match[1], secret); - if (!verified.ok) return denied(); - return { ok: true, claims: verified.claims }; -} - -/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม - * convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */ -const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const; - -export function parseTokenRequestBody( - raw: unknown, -): { ok: true; claims: DocverTokenClaims } | DocverError { - if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { - return { ok: false, error: "body ต้องเป็น JSON object" }; - } - const record = raw as Record; - for (const key of Object.keys(record)) { - if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) { - return { - ok: false, - error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`, - }; - } - } - - const rawUserid = record.userid; - if (typeof rawUserid !== "string" || rawUserid.trim() === "") { - return { ok: false, error: "ต้องระบุ userid (string)" }; - } - const userid = rawUserid.trim(); - if (userid.length > 100) { - return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" }; - } - - const rawUsername = record.username; - if (typeof rawUsername !== "string" || rawUsername.trim() === "") { - return { ok: false, error: "ต้องระบุ username (string)" }; - } - const username = rawUsername.trim(); - if (username.length > 200) { - return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" }; - } - - const rawFid = record.fid; - if (typeof rawFid !== "string" || rawFid.trim() === "") { - return { ok: false, error: "ต้องระบุ fid (string)" }; - } - const fid = rawFid.trim(); - if (fid.length > 200) { - return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" }; - } - - const rawTemplate = record.template ?? null; - if (rawTemplate !== null && typeof rawTemplate !== "string") { - return { ok: false, error: "template ต้องเป็น string" }; - } - const template = rawTemplate?.trim() || null; - if (template !== null && template.length > 100) { - return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" }; - } - - return { ok: true, claims: { userid, username, fid, template } }; -} diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index bb293d0..4bf4b70 100755 --- a/lib/lib-tmpl.ts +++ b/lib/lib-tmpl.ts @@ -1,4 +1,3 @@ -import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils'; import createReport from 'docx-templates'; import PizzaZip from "pizzip"; import { @@ -1262,53 +1261,3 @@ export const templateDynamicContents = async (template: Buffer, data: Record> -{ - //Calculate hash - const originalTicket_data = templateData?.info?.ticket_data //expect valid json string - // console.log("templateData",templateData?.info?.ticket_data) - if (originalTicket_data && typeof originalTicket_data === 'string') { - try { - const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data - if (originalTicketObj && typeof originalTicketObj === 'object') { - console.log("Found valid ticket data: hash it") - // คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ) - // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) - const expireTimestamp = Math.floor(Date.now() / 1000) + ttl; - const fullObj = { ...originalTicketObj, exp: expireTimestamp }; - const sortedObj = sortObjectKeys(fullObj); - - // แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash - const ticket_data = JSON.stringify(sortedObj); - // console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data) - const HmacSha256 = generateHmacSha256(ticket_data, secret) - - const Ed25519 = signEd25519(ticket_data, private_key) - templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 }) - } - } catch (error) { //just skip - console.error("Set custom property fail",error); - } - } - - // Apply Dynamic Content - if (templateData?.dc && typeof templateData?.dc === "object") { - console.log("Apply Dynamic Content") - templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc)) - } - // Apply Content Control - if (templateData?.cc && typeof templateData?.cc === "object") { - console.log("Apply Content Control") - templateBuffer = templateContentControls(templateBuffer, templateData.cc); - } - return templateBuffer - -} \ No newline at end of file diff --git a/lib/lib-utils.ts b/lib/lib-utils.ts index 312d3cf..f17a247 100644 --- a/lib/lib-utils.ts +++ b/lib/lib-utils.ts @@ -50,7 +50,7 @@ console.log('Is Signature Valid?:', isValid); // true * @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร) */ -export function signEd25519(message: string | Buffer, privateKeyHex: string): string { +export function signDataEd25519(message: string | Buffer, privateKeyHex: string): string { const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex'); // ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา) const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer; @@ -76,7 +76,7 @@ export function signEd25519(message: string | Buffer, privateKeyHex: string): st * @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน) */ -export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean { +export function verifyDataEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean { try { const rawPublicKey = Buffer.from(publicKeyHex, 'hex'); diff --git a/lib/openapi.ts b/lib/openapi.ts index a407cdf..967b6bf 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -32,17 +32,15 @@ export const openApiSpec = { { name: "template", description: - "Next Generation of Template : เป็นเทมเพลต MS Word(docx) รองรับรูปแบบข้อมูลที่ซับซ้อนได้ ", + "Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " + + "แล้วได้เอกสารที่กรอกค่าแล้วกลับมา", }, { name: "documents", description: - "จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " + - "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " + - "ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " + - "DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " + - "และ docId (= fid — fileId ของ e-Court, string) แกะจาก claims ของ token ทั้งหมด " + - "ไม่รับจาก URL/multipart form อีก", + "จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " + + "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — ไม่มี auth (stand-alone): " + + "ผู้เรียกบอก userId/userName เองใน multipart", }, { name: "Judge's Note", @@ -74,15 +72,67 @@ export const openApiSpec = { }, }, }, + "/api/template/extract": { + post: { + tags: ["template"], + summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx", + description: + "อ่าน w:sdt (Content Control) ทุกตัวใน word/document.xml — คืน map " + + "ชื่อแท็ก (w:tag) → ข้อความปัจจุบันในนั้น (ใช้สำรวจว่า template มีแท็กอะไรบ้างก่อนเรียก render)", + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: ["file"], + properties: { + file: { + type: "string", + format: "binary", + description: "ไฟล์ .docx ที่มี Content Control", + }, + secret: { + type: "string", + description: + "ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property" , + example: + "super_secret", + }, + public_key: { + type: "string", + description: + "Public key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", + example: + "66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "รายการแท็กพร้อมข้อความ", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ExtractResponse" }, + }, + }, + }, + 400: { $ref: "#/components/responses/BadRequest" }, + 500: { $ref: "#/components/responses/ServerError" }, + }, + }, + }, "/api/template/render": { post: { tags: ["template"], - summary: "สร้างเอกสารจาก template (.docx) + data (JSON) ใช้สำหรับนักพัฒนาเท่านั้น", + summary: "สร้างเอกสารจาก template (.docx) + data (JSON)", description: "แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " + '(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' + - 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)
'+ - 'สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ private key เองไม่ใช้ของระบบของระบบ', + 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)', requestBody: { required: true, content: { @@ -141,410 +191,6 @@ export const openApiSpec = { }, }, }, - "/api/template/extract": { - post: { - tags: ["template"], - summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx ใช้สำหรับนักพัฒนาเท่านั้น", - description: - "อ่าน Content Control, bookmark , property ของ MS Word(docx) ออกมาแสดง" + - "ค่าของ Content Control จะเป็น Plain Text ไม่มี markup ซึ่งเหมาะกับการเอาไปใส่ในฐานข้อมูล"+ - 'สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ Public key เองไม่ใช้ของระบบของระบบ', - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - required: ["file"], - properties: { - file: { - type: "string", - format: "binary", - description: "ไฟล์ .docx ที่มี Content Control", - }, - secret: { - type: "string", - description: - "ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property", - example: - "super_secret", - }, - public_key: { - type: "string", - description: - "Public key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", - example: - "66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546", - }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "รายการแท็กพร้อมข้อความ", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ExtractResponse" }, - }, - }, - }, - 400: { $ref: "#/components/responses/BadRequest" }, - 500: { $ref: "#/components/responses/ServerError" }, - }, - }, - }, - "/api/template/generate": { - post: { - tags: ["template"], - summary: "สร้างเอกสาร DOCX จากข้อมูล JSON และ template ที่กำหนด", - description: - "เทมเพลตมีแท็กแบบพิเศษ(รองรับตัวแปร ลูป เงื่อนไข), Content Control เมื่อรวมกับข้อมูล(JSON) "+ - "จะได้เอกสาร รูปแบบฟอร์มที่กรอกค่าแล้วดึงค่ากลับมาใช้งานต่อได้เช่นอัปเดตฐานข้อมูล
    "+ - "
  • JSON payload ต้องมี info.ticket_data เป็น JSON string รูปแบบถูกต้อวมีค่าครบ(tid, fid และ uid)
  • " + - "
  • ไฟล์เทมเพลต(tid)ในระบบ + ข้อมูล(payload) เพื่อสร้างเอกสาร
  • " + - "
  • ticket_data จะทำการ hash/sign แบบอายุสั้นแล้วนำไปฝังไว้ใน custom property เพื่อนำไปใช้ร่วมกับระบบ Versioning
  • "+ - "
", - security: [{ ApiKeyAuth: [] }], - requestBody: { - required: true, - content: { - "application/json": { - schema: { - type: "object", - required: ["info"], - properties: { - info: { - type: "object", - required: ["ticket_data"], - properties: { - ticket_data: { - type: "string", - description: - "JSON string ที่ต้อง parse ได้ และต้องมี tid, fid และ uid", - example: - '{"tid":"T12","fid":"DOC-2569-0001","uid":"123"}', - }, - }, - additionalProperties: true, - }, - }, - additionalProperties: true, - }, - example: { - "info": { - "ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" - }, - "dc": { - "Interpleader": true, - "docname": "", - "docabbr": "(ต. ๑๒)", - "blackno": "คดีหมายเลขดำที่", - "redno": "คดีหมายเลขแดงที่", - "court": "ศาลปกครองนครราชสีมา", - "panels": [ - { - "pname": "นายวิรุฬห์ จินตนะกุล", - "pmname": "ตุลาการศาลปกครองกลาง", - "pm": "ตุลาการเจ้าของสำนวน" - }, - { - "pname": "นายสัมฤทธิ์ อ่อนคำ", - "pmname": "ตุลาการศาลปกครองสูงสุด" - } - ], - "pnameT": "", - "pmnameT": "", - "pmT": "", - "acc_name": "" - - }, - "cc": { - "blackyear": "๙๐/๒๕๖๙", - "redyear": "", - "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", - "accuser": "นายธีรพล ร่มเริง", - "acc1": "ผู้ฟ้องคดี", - "accused": "รร", - "acc2": "ผู้ถูกฟ้องคดี", - "interpleader": "สมชาย ใจดี", - "acc3": "ผู้ร้องสอด", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง" - } - }, - }, - }, - }, - responses: { - 200: { - description: "ไฟล์ DOCX ที่สร้างสำเร็จ", - headers: { - "Content-Disposition": { - description: "ชื่อไฟล์สำหรับดาวน์โหลด", - schema: { type: "string" }, - }, - "Cache-Control": { - description: "ป้องกันการ cache response", - schema: { type: "string", example: "no-store, max-age=0" }, - }, - }, - content: { - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { - schema: { type: "string", format: "binary" }, - }, - }, - }, - 400: { - description: - "JSON ไม่มี info.ticket_data, ticket_data ไม่ใช่ string, JSON string ไม่ถูกต้อง หรือไม่มี tid/fid/uid", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - examples: { - missingTicketData: { - value: { - error: - "template/generate json.info.ticket_data not avaliable", - }, - }, - missingIdentifiers: { - value: { - error: - "template/generate require json.info.ticket_data.(fid|tid|uid)", - }, - }, - }, - }, - }, - }, - 401: { - description: "ไม่ได้ส่ง x-api-key หรือ API key ไม่ถูกต้อง", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Unauthorized: Invalid API Key" }, - }, - }, - }, - 404: { - description: "ไม่สามารถดึงข้อมูลขึ้นมาได้", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "NotFound: Invalid template ID or incorrect storage path" }, - }, - }, - }, - 500: { - description: - "เกิดข้อผิดพลาดระหว่างอ่าน template หรือสร้างเอกสาร", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Failed to generate document payload" }, - }, - }, - }, - }, - }, - }, - "/api/template/download-docx": { - get: { - tags: ["template"], - summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง", - description: - "รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", - parameters: [ - { - name: "fid", - in: "query", - required: true, - schema: { type: "string" }, - description: "ID เอกสาร (File ID)", - example: "T_no_warning", - }, - { - name: "tid", - in: "query", - required: true, - schema: { type: "string" }, - description: "ID เทมเพลต (Template ID)", - example: "T_X", - }, - { - name: "uid", - in: "query", - required: true, - schema: { type: "string" }, - description: "รหัสผู้ใช้งาน (User ID)", - example: "123", - }, - { - name: "uname", - in: "query", - required: true, - schema: { type: "string" }, - description: "ชื่อผู้ใช้งาน (User Name)", - example: "สมชาย ใจดี", - }, - ], - responses: { - 200: { - description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ", - headers: { - "Content-Disposition": { - description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)", - schema: { type: "string", example: 'attachment; filename="T_no_warning_123_xxx.docx"' }, - }, - "Cache-Control": { - description: "การควบคุม Cache (no-store)", - schema: { type: "string", example: "no-store, max-age=0" }, - }, - }, - content: { - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { - schema: { type: "string", format: "binary" }, - }, - }, - }, - 400: { - description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง", - content: { - "text/plain": { - schema: { type: "string" }, - examples: { - missingParams: { value: "Missing fid,tid,uid or uname" }, - invalidData: { value: "Invalid data format" }, - }, - }, - }, - }, - 404: { - description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Data not found: /path/to/TMPL_PATH/T_no_warning.json" }, - }, - }, - }, - 502: { - description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)", - }, - }, - }, - 500: { - description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Internal Server Error", - }, - }, - }, - }, - }, - post: { - tags: ["template"], - summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง", - description: - "รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded อ่านข้อมูล JSON และประมวลผลสร้างไฟล์ DOCX stream คืนกลับไป "+ - "จะเรียก generate ในเบื้องหลัง ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - required: ["fid", "tid", "uid", "uname"], - properties: { - fid: { type: "string", description: "ID เอกสาร (File ID)",example:"T12_b2_IN" }, - tid: { type: "string", description: "ID เทมเพลต (Template ID)",example:"T12_X" }, - uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)", example:"john" }, - uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)", example:"John Doe"}, - }, - }, - }, - "application/x-www-form-urlencoded": { - schema: { - type: "object", - required: ["fid", "tid", "uid", "uname"], - properties: { - fid: { type: "string", description: "ID เอกสาร (File ID)" }, - tid: { type: "string", description: "ID เทมเพลต (Template ID)" }, - uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)" }, - uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)" }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ", - headers: { - "Content-Disposition": { - description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)", - schema: { type: "string", example: 'attachment; filename="T12_b2_IN_john_xxx.docx"' }, - }, - "Cache-Control": { - description: "การควบคุม Cache (no-store)", - schema: { type: "string", example: "no-store, max-age=0" }, - }, - }, - content: { - "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { - schema: { type: "string", format: "binary" }, - }, - }, - }, - 400: { - description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง", - content: { - "text/plain": { - schema: { type: "string" }, - examples: { - missingParams: { value: "Missing fid,tid,uid or uname" }, - invalidData: { value: "Invalid data format" }, - }, - }, - }, - }, - 404: { - description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - example: { error: "Data not found: /path/to/TMPL_PATH/T12_b2_IN.json" }, - }, - }, - }, - 502: { - description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)", - }, - }, - }, - 500: { - description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", - content: { - "text/plain": { - schema: { type: "string" }, - example: "Internal Server Error", - }, - }, - }, - }, - }, - }, "/api/note/external/documents": { post: { tags: ["external"], @@ -607,79 +253,31 @@ export const openApiSpec = { }, }, }, - "/api/documents/token": { - post: { - tags: ["documents"], - summary: "ขอ access_token ของกลุ่ม documents (docver)", - description: - "ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " + - "JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " + - "DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName), " + - "templateId และ docId (= fid) ตอนทำงานแกะจาก claims ของ token นี้ — " + - "fid จึงบังคับ", - security: [{ ApiKeyAuth: [] }], - requestBody: { - required: true, - content: { - "application/json": { - schema: { $ref: "#/components/schemas/TokenRequest" }, - examples: { - "ครบทุก claim": { - value: { - userid: "123", - username: "Sorawit Bholsithi", - fid: "3", - template: "T12", - }, - }, - "ขั้นต่ำ (template เป็นทางเลือก)": { - value: { userid: "123", username: "Sorawit Bholsithi", fid: "3" }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/TokenResponse" }, - }, - }, - }, - 400: { $ref: "#/components/responses/BadRequest" }, - 401: { - description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY)", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - }, - }, - }, - 500: { $ref: "#/components/responses/ServerError" }, - }, - }, - }, - "/api/documents/versions": { + "/api/documents/{docId}/versions": { post: { tags: ["documents"], summary: "อัปโหลด version ใหม่ของเอกสาร (docx + pdf)", description: - "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — สร้างเอกสาร " + - "อัตโนมัติเมื่อ docId ยังไม่เคยมี แล้วเก็บไฟล์ .docx (+ .pdf ถ้าแนบมา) ลง MinIO " + - "พร้อมผู้อัปโหลด — versionNumber ที่ server จ่ายคือค่าจริงเสมอ (client ที่เลขไม่ตรง " + - "ต้องแก้ XML part ของตัวเอง) · pdf เป็น optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มี " + - "สำเนา PDF (ดู hasPdf ใน response) · ผู้อัปโหลด (userId/userName) และ templateId " + - "แกะจาก Bearer token ไม่รับใน form (ส่ง field เหล่านั้นมา = 400)", - security: [{ DocverBearer: [] }], + "สร้างเอกสารอัตโนมัติเมื่อ docId ยังไม่เคยมี (docId ห้ามซ้ำ) แล้วเก็บไฟล์ " + + ".docx (+ .pdf ถ้าแนบมา) ลง MinIO พร้อมผู้อัปโหลด — versionNumber ที่ server จ่าย " + + "คือค่าจริงเสมอ (client ที่เลขไม่ตรงต้องแก้ XML part ของตัวเอง) · pdf เป็น " + + "optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF (ดู hasPdf ใน response)", + parameters: [ + { + name: "docId", + in: "path", + required: true, + schema: { type: "string" }, + description: "รหัสเอกสารจากผู้เรียก (add-in GUID / รหัส e-Court) — [A-Za-z0-9._:-]", + }, + ], requestBody: { required: true, content: { "multipart/form-data": { schema: { type: "object", - required: ["file"], + required: ["file", "userId", "userName"], properties: { file: { type: "string", @@ -692,6 +290,14 @@ export const openApiSpec = { description: "ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)", }, + userId: { + type: "string", + description: "รหัสผู้อัปโหลด (ระบบ stand-alone ไม่มี session)", + }, + userName: { + type: "string", + description: "ชื่อผู้อัปโหลด (แสดงใน version history)", + }, clientVersionId: { type: "string", description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)", @@ -705,6 +311,10 @@ export const openApiSpec = { type: "string", description: "commit message ของ version (≤ 2048 ตัวอักษร)", }, + templateId: { + type: "string", + description: "รหัส template ที่ใช้สร้างเอกสาร (ถ้ามี)", + }, fileName: { type: "string", description: @@ -725,7 +335,6 @@ export const openApiSpec = { }, }, 400: { $ref: "#/components/responses/BadRequest" }, - 401: { $ref: "#/components/responses/DocverUnauthorized" }, 413: { description: "ไฟล์แนบใหญ่เกิน 100MB", content: { @@ -741,10 +350,17 @@ export const openApiSpec = { tags: ["documents"], summary: "รายการ version ทั้งหมดของเอกสาร (ใหม่ → เก่า)", description: - "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) — version ทุกตัว " + - "ของเอกสารนั้น พร้อมผู้อัปโหลด (userId/userName), เวลา, commit message, ค่า fields " + - "และ hasPdf — ใช้เขียน UI version history ของ add-in", - security: [{ DocverBearer: [] }], + "version ทุกตัวของ docId นี้ พร้อมผู้อัปโหลด (userId/userName), เวลา, commit " + + "message, ค่า fields และ hasPdf — ใช้เขียน UI version history ของ add-in", + parameters: [ + { + name: "docId", + in: "path", + required: true, + schema: { type: "string" }, + description: "รหัสเอกสารจากผู้เรียก — [A-Za-z0-9._:-]", + }, + ], responses: { 200: { description: "รายการ version เรียงใหม่ → เก่า", @@ -758,7 +374,6 @@ export const openApiSpec = { }, }, 400: { $ref: "#/components/responses/BadRequest" }, - 401: { $ref: "#/components/responses/DocverUnauthorized" }, 404: { description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", content: { @@ -771,17 +386,22 @@ export const openApiSpec = { }, }, }, - "/api/documents/versions/{versionId}/download-link": { + "/api/documents/{docId}/versions/{versionId}/download-link": { get: { tags: ["documents"], summary: "ขอ presigned URL ดาวน์โหลดไฟล์ของ version (docx/pdf)", description: - "เอกสารถูกระบุด้วย fid ใน Bearer token (docId = fid) · คืน URL อายุสั้น " + - "(env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดในเบราว์เซอร์/Word ของ " + - "ผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ผู้ใช้เข้าถึงได้ · ขอ pdf " + - "ใน version ที่ไม่มีสำเนา = 404", - security: [{ DocverBearer: [] }], + "คืน URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดใน " + + "เบราว์เซอร์/Word ของผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ " + + "ผู้ใช้เข้าถึงได้ · ขอ pdf ใน version ที่ไม่มีสำเนา = 404", parameters: [ + { + name: "docId", + in: "path", + required: true, + schema: { type: "string" }, + description: "รหัสเอกสาร (ต้องตรงกับ version นี้ — โยน id ข้ามเอกสารไม่ได้)", + }, { name: "versionId", in: "path", @@ -807,7 +427,6 @@ export const openApiSpec = { }, }, 400: { $ref: "#/components/responses/BadRequest" }, - 401: { $ref: "#/components/responses/DocverUnauthorized" }, 404: { description: "ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF", @@ -829,17 +448,7 @@ export const openApiSpec = { in: "header", name: "x-api-key", description: - "x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " + - "DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " + - "request ของกลุ่มที่ key ตรง)", - }, - DocverBearer: { - type: "http", - scheme: "bearer", - bearerFormat: "JWT", - description: - "access_token จาก POST /api/documents/token — แนบเป็น Authorization: Bearer " + - "กับ endpoint อื่นของกลุ่ม documents ทุกตัว", + "ค่าเดียวกับ env EXTERNAL_API_KEY ของ server — กดปุ่ม Authorize ด้านบนเพื่อใส่ครั้งเดียวแล้วใช้ทุก request", }, }, responses: { @@ -853,15 +462,6 @@ export const openApiSpec = { }, }, }, - DocverUnauthorized: { - description: - "ไม่ได้แนบ Authorization: Bearer หรือ token ไม่ถูกต้อง/หมดอายุ (กลุ่ม documents — ข้อความเดียวกันทุกสาเหตุ)", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/Error" }, - }, - }, - }, ServerError: { description: "เกิดข้อผิดพลาดของระบบ (เช่นไฟล์ .docx ไม่ถูกต้อง / DB ล้ม)", content: { @@ -875,82 +475,21 @@ export const openApiSpec = { Error: { type: "object", properties: { + success: { type: "boolean", example: false }, error: { type: "string" }, }, }, - TokenRequest: { - type: "object", - description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400", - required: ["userid", "username", "fid"], - properties: { - userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" }, - username: { type: "string", description: "ชื่อผู้ใช้ (บังคับ, ≤ 200 ตัวอักษร)" }, - fid: { - type: "string", - description: - "fileId ของ e-Court (บังคับ, ≤ 200 ตัวอักษร) — เป็น docId ของทุก operation ตรง ๆ", - }, - template: { - type: "string", - nullable: true, - description: - "รหัส template ที่ใช้สร้างเอกสาร — กลายเป็น templateId ตอนอัปโหลด version", - }, - }, - }, - TokenResponse: { - type: "object", - description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)", - properties: { - access_token: { type: "string", description: "JWT พร้อมใช้แนบเป็น Bearer" }, - token_type: { type: "string", example: "Bearer" }, - expires_in: { - type: "integer", - description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 3600)", - }, - }, - }, ExtractResponse: { type: "object", properties: { + success: { type: "boolean", example: true }, data: { type: "object", description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)", additionalProperties: { type: "string" }, example: { - "cc": { - "blackyear": "๙๐/๒๕๖๙", - "redyear": "xxx", - "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ", - "accuser": "นายธีรพล ร่มเริง", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง...", - }, - "bookmark": { - "signature": "" - }, - "properties": { - "standard": { - "creator": "mam", - "created": "2026-10-22T02:58:00.000Z", - "modified": "2026-10-22T07:08:00.000Z" - }, - "extended": { - "template": "TS_P2_IN.dotx", - "totalTime": 172, - "pages": 2, - "words": 124, - }, - "custom": { - "ticket_data": "{\"exp\":1793013726,\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}", - "HmacSha256": "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9", - "Ed25519": "95ea0ca6d81df885c22a2334ed89b2fc3c75a1a309fb5843e0deb0a16c9d05a45b345fb28fd606f3b152285e70560ad43a6389534794aa0d6f06d13f1307b005" - } - }, - "verify": { - "exp": "26/10/2569 18:22:06", - "HmacSha256": true, - "Ed25519": true - } + blackno: "คดีหมายเลขดำที่", + court: "ศาลปกครองสุพรรณบุรี", }, }, }, diff --git a/middleware.ts b/middleware.ts index 712d46e..a67185a 100644 --- a/middleware.ts +++ b/middleware.ts @@ -37,6 +37,6 @@ export function middleware(req: NextRequest) { // ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ export const config = { matcher: [ - "/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)", + "/((?!note/login|api/note|api/template|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)", ], }; diff --git a/public/tmpl/T12_FD155_3s-long.json b/public/tmpl/T12_FD155_3s-long.json index 294791e..8a27173 100644 --- a/public/tmpl/T12_FD155_3s-long.json +++ b/public/tmpl/T12_FD155_3s-long.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T12_FD155_3s-long\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"T12_FD155_3s-long_ZZZ\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": false, diff --git a/public/tmpl/T12_b2_IN.json b/public/tmpl/T12_b2_IN.json index 4bd98a5..d38a698 100644 --- a/public/tmpl/T12_b2_IN.json +++ b/public/tmpl/T12_b2_IN.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": true, diff --git a/public/tmpl/T12_b2_IN.out.json b/public/tmpl/T12_b2_IN.out.json new file mode 100644 index 0000000..e1b24b9 --- /dev/null +++ b/public/tmpl/T12_b2_IN.out.json @@ -0,0 +1,51 @@ +{ + "cc": { + "blackyear": "๙๐/๒๕๖๙", + "redyear": "", + "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", + "accuser": "นายธีรพล ร่มเริง", + "acc1": "ผู้ฟ้องคดี", + "interpleader": "สมชาย ใจดี", + "acc3": "ผู้ร้องสอด", + "accused": "รร", + "acc2": "ผู้ถูกฟ้องคดี", + "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง", + "pmname1": "ตุลาการศาลปกครองกลาง", + "pmname2": "ตุลาการศาลปกครองสูงสุด" + }, + "bookmark": { + "signature1": "", + "signature2": "" + }, + "properties": { + "standard": { + "title": " ", + "subject": "", + "creator": "oom", + "keywords": "", + "lastModifiedBy": "Sorawit Bholsithi", + "revision": "61", + "created": "2026-10-15T05:18:00.000Z", + "modified": "2026-10-21T04:59:00.000Z" + }, + "extended": { + "template": "Normal.dotm", + "totalTime": 345, + "pages": 1, + "words": 42, + "characters": 493, + "charactersWithSpaces": 534, + "lines": 4, + "paragraphs": 1, + "application": "Microsoft Office Word", + "appVersion": "16.0000", + "company": "", + "docSecurity": 0 + }, + "custom": { + "ticket_data": "{\"exp\":1792667826,\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}", + "HmacSha256": "791987c9274493db02561f4ec16b83d05c89766b54661da615e8e02db293d1d4", + "Ed25519": "7ef8b0f2a03a52c49f8c348e3c392f448b71c8462ee860342b0e06a91b555f860cad5649c648dc466982017683f3718caff139c85e17e952ee1570ecfc021e08" + } + } +} \ No newline at end of file diff --git a/public/tmpl/TF_no_warning_P2.json b/public/tmpl/TF_no_warning_P2.json index 152736c..f2ef337 100755 --- a/public/tmpl/TF_no_warning_P2.json +++ b/public/tmpl/TF_no_warning_P2.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": false, diff --git a/public/tmpl/TS_P2_IN.json b/public/tmpl/TS_P2_IN.json index 479478a..a70c9fe 100755 --- a/public/tmpl/TS_P2_IN.json +++ b/public/tmpl/TS_P2_IN.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"TS_P2_IN_YYY\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": true, diff --git a/public/tmpl/T_X.docx b/public/tmpl/T_X.docx index 06182a4..3ae25d7 100755 Binary files a/public/tmpl/T_X.docx and b/public/tmpl/T_X.docx differ diff --git a/public/tmpl/T_no_warning.json b/public/tmpl/T_no_warning.json index 7697123..59a8b65 100755 --- a/public/tmpl/T_no_warning.json +++ b/public/tmpl/T_no_warning.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" + "ticket_data": "{\"fid\":\"T3_P2_IN_XXX\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" }, "dc": { "Interpleader": false, @@ -40,7 +40,7 @@ "acc2": "ผู้ถูกฟ้องคดี", "interpleader": "สมชาย ใจดี", "acc3": "ผู้ร้องสอด", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว \nเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ", + "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ", "sendto" : "นายสมศักดิ์ ฟักมี", "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี", diff --git a/public/tmpl/index.html b/public/tmpl/index.html new file mode 100644 index 0000000..e47e08c --- /dev/null +++ b/public/tmpl/index.html @@ -0,0 +1,20 @@ + + + + + + Document + + +

Template & Data

+ +
    +
  1. T12_X + (T12_b2_IN, T12_FD155_3s-long) +
  2. +
  3. T_X + (TF_no_warning_P2, T_no_warning, TS_P2_IN.json) +
  4. +
+ + \ No newline at end of file diff --git a/tests/lib/document-versioning.test.ts b/tests/lib/document-versioning.test.ts index da18b93..635fb84 100644 --- a/tests/lib/document-versioning.test.ts +++ b/tests/lib/document-versioning.test.ts @@ -6,6 +6,7 @@ import { docverError, isDocxBytes, isPdfBytes, + parseDocId, parseDownloadFormat, parseFieldsJson, parseVersionIdParam, @@ -22,6 +23,28 @@ function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T expect(result.ok).toBe(true); } +describe("parseDocId", () => { + it("รับ GUID / รหัส e-Court / charset ที่อนุญาตทั้งหมด และ trim รอบ ๆ", () => { + for (const raw of [ + "123e4567-e89b-12d3-a456-426614174000", + "ecourt:123", + "DOC_2026.10", + " doc1 ", + "a".repeat(200), + ]) { + const result = parseDocId(raw); + ok(result); + expect(result.docId).toBe(raw.trim()); + } + }); + + it("ตกเมื่อว่าง / เกิน 200 / มีช่องว่างกลาง / ไทย / slash / '..'", () => { + for (const raw of ["", " ", "a".repeat(201), "doc 1", "เอกสาร1", "a/b", "..", "a\\b"]) { + expect(parseDocId(raw).ok).toBe(false); + } + }); +}); + describe("parseVersionIdParam", () => { it("รับ UUID ทั้งพิมพ์ใหญ่/เล็ก (normalize ลงตัวพิมพ์เล็ก) + trim", () => { const result = parseVersionIdParam(" 6F9619FF-8B86-D011-B42D-00C04FC964FF "); @@ -89,11 +112,11 @@ describe("parseFieldsJson", () => { }); describe("parseVersionUploadForm", () => { - // identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก - // Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts) function baseForm(): FormData { const form = new FormData(); form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx")); + form.append("userId", "u0001"); + form.append("userName", "สมชาย ใจดี"); return form; } @@ -101,13 +124,17 @@ describe("parseVersionUploadForm", () => { const form = baseForm(); form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 "); form.append("message", " "); + form.append("templateId", "tmpl-main"); form.append("fields", '{"blackno":"1"}'); form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx"); const result = parseVersionUploadForm(form); ok(result); expect(result.form).toEqual({ + userId: "u0001", + userName: "สมชาย ใจดี", clientVersionId: "11111111-1111-4111-8111-111111111111", message: null, + templateId: "tmpl-main", fileName: "คำสั่งเรียก.docx", fields: { blackno: "1" }, }); @@ -121,22 +148,22 @@ describe("parseVersionUploadForm", () => { if (!result.ok) expect(result.error).toContain("oops"); }); - it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => { - for (const legacy of ["userId", "userName", "templateId"]) { - const form = baseForm(); - form.append(legacy, "x"); - const result = parseVersionUploadForm(form); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain(legacy); - } + it("ตกเมื่อขาด userId หรือ userName (stand-alone บอกผู้อัปโหลดเอง)", () => { + const noUser = baseForm(); + noUser.delete("userId"); + expect(parseVersionUploadForm(noUser).ok).toBe(false); + const noName = baseForm(); + noName.delete("userName"); + expect(parseVersionUploadForm(noName).ok).toBe(false); }); it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => { const form = baseForm(); - form.append("message", new File(["x"], "m.txt")); + form.delete("userId"); + form.append("userId", new File(["x"], "u.txt")); const result = parseVersionUploadForm(form); expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("message"); + if (!result.ok) expect(result.error).toContain("userId"); }); it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => { diff --git a/tests/lib/docver-token.test.ts b/tests/lib/docver-token.test.ts deleted file mode 100644 index 6b2a5ab..0000000 --- a/tests/lib/docver-token.test.ts +++ /dev/null @@ -1,316 +0,0 @@ -import { createHmac } from "node:crypto"; -import { afterEach, describe, expect, it } from "vitest"; - -import { - issueDocverToken, - parseTokenRequestBody, - requireDocverApiKey, - requireDocverBearer, - verifyDocverToken, - type DocverTokenClaims, -} from "@/lib/docver-token"; - -// docver token (PLAN-DOCVER.md §auth) — validation/auth ล้วนของ /api/documents/*: -// ไฟล์นี้ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ -// external-api-auth.test.ts — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ต้องตั้ง -// DATABASE_URL/MINIO_*) - -const SECRET = "test-secret-docver"; -const CLAIMS: DocverTokenClaims = { - userid: "123", - username: "Sorawit Bholsithi", - fid: "3", - template: "T12", -}; - -function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T { - expect(result.ok).toBe(true); -} - -// --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load -const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY"); -const ORIG_API_KEY = process.env.DOCVER_API_KEY; -const HAD_SECRET = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_TOKEN_SECRET"); -const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET; - -afterEach(() => { - if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY; - else delete process.env.DOCVER_API_KEY; - if (HAD_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET; - else delete process.env.DOCVER_TOKEN_SECRET; -}); - -function reqWithKey(key?: string): Request { - return new Request("http://localhost/docedit/api/documents/token", { - headers: key === undefined ? {} : { "x-api-key": key }, - }); -} - -function reqWithBearer(authorization: string | null): Request { - return new Request("http://localhost/docedit/api/documents/doc1/versions", { - headers: authorization === null ? {} : { authorization }, - }); -} - -async function bodyOf(res: Response): Promise<{ error: string }> { - expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8"); - return (await res.json()) as { error: string }; -} - -/** เซ็น payload ใด ๆ ด้วย SECRET ผ่านมือ — ใช้ประกอบเคส claims ผิดรูปที่ issueDocverToken สร้างเองไม่ได้ */ -function signPayload(payload: Record): string { - const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString( - "base64url", - ); - const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url"); - const signature = createHmac("sha256", SECRET) - .update(`${header}.${body}`) - .digest("base64url"); - return `${header}.${body}.${signature}`; -} - -describe("issueDocverToken / verifyDocverToken", () => { - it("issue → verify ได้ claims ครบทุกตัว (userid/username/fid/template)", () => { - const verified = verifyDocverToken(issueDocverToken(CLAIMS, SECRET, 60), SECRET); - ok(verified); - expect(verified.claims).toEqual(CLAIMS); - }); - - it("template เป็นทางเลือก (null ได้) แต่ fid บังคับ — token ไม่มี fid = ไม่ผ่าน", () => { - const verified = verifyDocverToken( - issueDocverToken({ userid: "u1", username: "ชื่อ", fid: "7", template: null }, SECRET, 60), - SECRET, - ); - ok(verified); - expect(verified.claims).toEqual({ userid: "u1", username: "ชื่อ", fid: "7", template: null }); - const noFid = signPayload({ - userid: "u1", - username: "n", - template: "T1", - iat: 0, - exp: Math.floor(Date.now() / 1000) + 60, - }); - expect(verifyDocverToken(noFid, SECRET).ok).toBe(false); - }); - - it("แก้ payload แล้วใช้ signature เดิม (tampered) หรือ secret ผิด = ไม่ผ่าน", () => { - const token = issueDocverToken(CLAIMS, SECRET, 60); - const [header, , signature] = token.split("."); - const tamperedPayload = Buffer.from( - JSON.stringify({ ...CLAIMS, userid: "999" }), - "utf8", - ).toString("base64url"); - expect(verifyDocverToken(`${header}.${tamperedPayload}.${signature}`, SECRET).ok).toBe(false); - expect(verifyDocverToken(token, "wrong-secret").ok).toBe(false); - }); - - it("header alg อื่น (none / HS512) = ไม่ผ่าน แม้จะเซ็นด้วย secret ถูก (กัน alg-confusion)", () => { - const body = Buffer.from( - JSON.stringify({ ...CLAIMS, iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }), - "utf8", - ).toString("base64url"); - for (const alg of ["none", "HS512"]) { - const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" }), "utf8").toString("base64url"); - const signature = createHmac("sha256", SECRET) - .update(`${header}.${body}`) - .digest("base64url"); - expect(verifyDocverToken(`${header}.${body}.${signature}`, SECRET).ok).toBe(false); - } - }); - - it("token หมดอายุ (ttl ติดลบ) หรือไม่มี exp = ไม่ผ่าน", () => { - expect(verifyDocverToken(issueDocverToken(CLAIMS, SECRET, -10), SECRET).ok).toBe(false); - const noExp = signPayload({ ...CLAIMS, iat: Math.floor(Date.now() / 1000) }); - expect(verifyDocverToken(noExp, SECRET).ok).toBe(false); - }); - - it("claims ไม่ครบรูปร่าง (แม้เซ็นถูก) = ไม่ผ่าน — กัน token เซ็นเองหลุดไปใช้", () => { - const now = Math.floor(Date.now() / 1000) + 60; - for (const bad of [ - { username: "n", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี userid - { userid: "u", fid: "3", template: null, iat: 0, exp: now }, // ไม่มี username - { userid: "", username: "n", fid: "3", template: null, iat: 0, exp: now }, // userid ว่าง - { userid: "u", username: "n", fid: 3, template: null, iat: 0, exp: now }, // fid เป็น number - { userid: "u", username: "n", fid: "", template: null, iat: 0, exp: now }, // fid ว่าง - { userid: "u", username: "n", template: null, iat: 0, exp: now }, // ไม่มี fid (บังคับแล้ว) - { userid: "u", username: "n", fid: null, template: null, iat: 0, exp: now }, // fid null - ]) { - expect(verifyDocverToken(signPayload(bad), SECRET).ok).toBe(false); - } - }); - - it("รูป token ผิด (ไม่ใช่ 3 segment / segment เป็น base64 ปกติไม่ใช่ base64url) = ไม่ผ่าน", () => { - expect(verifyDocverToken("not-a-jwt", SECRET).ok).toBe(false); - expect(verifyDocverToken("a.b.c.d", SECRET).ok).toBe(false); - // base64 ปกติมี + / ซึ่ง base64url decode แล้วได้ byte ผิด → header/payload พัง - expect(verifyDocverToken("???.???.???", SECRET).ok).toBe(false); - }); -}); - -describe("requireDocverApiKey", () => { - it("env ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => { - for (const value of [undefined, "", " "]) { - if (value === undefined) delete process.env.DOCVER_API_KEY; - else process.env.DOCVER_API_KEY = value; - const res = requireDocverApiKey(reqWithKey("test-key")); - expect(res?.status).toBe(401); - expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" }); - } - }); - - it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => { - process.env.DOCVER_API_KEY = "test-key"; - const missing = requireDocverApiKey(reqWithKey()); - const wrong = requireDocverApiKey(reqWithKey("not-the-key")); - expect(missing?.status).toBe(401); - expect(wrong?.status).toBe(401); - expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!)); - }); - - it("key ตรง = ผ่าน (คืน null) · env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้", () => { - process.env.DOCVER_API_KEY = "test-key"; - expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull(); - process.env.DOCVER_API_KEY = " test-key "; - expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull(); - }); -}); - -describe("requireDocverBearer", () => { - it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => { - const deniedBodies = new Set(); - const cases: { label: string; secret: string | undefined; authorization: string | null }[] = [ - { label: "ไม่มี Authorization", secret: SECRET, authorization: null }, - { label: "scheme อื่น (Basic)", secret: SECRET, authorization: "Basic dXNlcjpwYXNz" }, - { label: "Bearer ว่างเปล่า", secret: SECRET, authorization: "Bearer " }, - { label: "token เน่า", secret: SECRET, authorization: "Bearer garbage.token.here" }, - { - label: "token หมดอายุ", - secret: SECRET, - authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, -10)}`, - }, - { - label: "เซ็นด้วย secret ผิด", - secret: SECRET, - authorization: `Bearer ${issueDocverToken(CLAIMS, "other-secret", 60)}`, - }, - { label: "secret ไม่ตั้ง (fail closed)", secret: undefined, authorization: null }, - { - label: "secret ไม่ตั้งแม้จะมี token ถูก", - secret: undefined, - authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`, - }, - ]; - for (const testCase of cases) { - if (testCase.secret === undefined) delete process.env.DOCVER_TOKEN_SECRET; - else process.env.DOCVER_TOKEN_SECRET = testCase.secret; - const auth = requireDocverBearer(reqWithBearer(testCase.authorization)); - expect(auth.ok, testCase.label).toBe(false); - if (!auth.ok) { - expect(auth.response.status, testCase.label).toBe(401); - deniedBodies.add(JSON.stringify(await bodyOf(auth.response))); - } - } - expect(deniedBodies.size).toBe(1); // ข้อความ 401 เดียวกันหมด — ไม่ใช้ endpoint เป็น oracle - }); - - it("Bearer ที่ verify ผ่าน = ได้ claims · คำว่า bearer พิมพ์เล็กก็รับ (case-insensitive)", () => { - process.env.DOCVER_TOKEN_SECRET = SECRET; - const token = issueDocverToken(CLAIMS, SECRET, 60); - const auth = requireDocverBearer(reqWithBearer(`Bearer ${token}`)); - ok(auth); - expect(auth.claims).toEqual(CLAIMS); - const lower = requireDocverBearer(reqWithBearer(`bearer ${token}`)); - ok(lower); - expect(lower.claims).toEqual(CLAIMS); - }); - - it("env มี whitespace รอบ secret ก็ trim แล้ว verify ได้", () => { - process.env.DOCVER_TOKEN_SECRET = ` ${SECRET} `; - const auth = requireDocverBearer( - reqWithBearer(`Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`), - ); - ok(auth); - expect(auth.claims).toEqual(CLAIMS); - }); -}); - -describe("parseTokenRequestBody", () => { - it("ครบทุก field = ได้ claims (trim ค่า) · template ไม่ส่ง = null (fid บังคับ)", () => { - const full = parseTokenRequestBody({ - userid: " 123 ", - username: " Sorawit Bholsithi ", - fid: " 3 ", - template: " T12 ", - }); - ok(full); - expect(full.claims).toEqual({ userid: "123", username: "Sorawit Bholsithi", fid: "3", template: "T12" }); - - const minimal = parseTokenRequestBody({ userid: "123", username: "Sorawit", fid: "3" }); - ok(minimal); - expect(minimal.claims).toEqual({ userid: "123", username: "Sorawit", fid: "3", template: null }); - }); - - it("template ส่งมาเป็นช่องว่าง = null (ถือว่าไม่ส่ง)", () => { - const result = parseTokenRequestBody({ userid: "123", username: "n", fid: "3", template: " " }); - ok(result); - expect(result.claims.template).toBeNull(); - }); - - it("ไม่ใช่ object (array / string / null) = 400", () => { - for (const raw of [[], "text", null]) { - const result = parseTokenRequestBody(raw); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toBe("body ต้องเป็น JSON object"); - } - }); - - it("key อื่นนอก allowlist = 400 ไม่รู้จัก field (กัน typo เงียบ ๆ)", () => { - const result = parseTokenRequestBody({ userid: "1", username: "n", oops: 1 }); - expect(result.ok).toBe(false); - if (!result.ok) expect(result.error).toContain("oops"); - }); - - it("ขาด userid หรือ username / ส่งมาไม่ใช่ string = 400 ข้อความเฉพาะ field", () => { - for (const raw of [ - { username: "n" }, - { userid: " ", username: "n" }, - { userid: 123, username: "n" }, - { userid: "1" }, - { userid: "1", username: "" }, - ]) { - const result = parseTokenRequestBody(raw); - expect(result.ok).toBe(false); - if (!result.ok) { - expect(result.error).toMatch(/ต้องระบุ (userid|username)/); - } - } - }); - - it("fid ขาด/null/ว่าง/ไม่ใช่ string (number) หรือ template ไม่ใช่ string = 400", () => { - const missing = parseTokenRequestBody({ userid: "1", username: "n" }); - expect(missing.ok).toBe(false); - if (!missing.ok) expect(missing.error).toContain("fid"); - for (const raw of [ - { userid: "1", username: "n", fid: null }, - { userid: "1", username: "n", fid: 3 }, - { userid: "1", username: "n", fid: "" }, - { userid: "1", username: "n", fid: " " }, - { userid: "1", username: "n", template: 12 }, - ]) { - expect(parseTokenRequestBody(raw).ok).toBe(false); - } - }); - - it("ความยาวเกิน cap: userid > 100 / username > 200 / fid > 200 / template > 100 = 400", () => { - expect(parseTokenRequestBody({ userid: "a".repeat(101), username: "n" }).ok).toBe(false); - expect( - parseTokenRequestBody({ userid: "1", username: "a".repeat(201) }).ok, - ).toBe(false); - expect( - parseTokenRequestBody({ userid: "1", username: "n", fid: "a".repeat(201) }).ok, - ).toBe(false); - expect( - parseTokenRequestBody({ userid: "1", username: "n", template: "a".repeat(101) }).ok, - ).toBe(false); - }); -}); diff --git a/wordaddin/services/versioningService.ts b/wordaddin/services/versioningService.ts index 506130a..54dd519 100644 --- a/wordaddin/services/versioningService.ts +++ b/wordaddin/services/versioningService.ts @@ -5,11 +5,6 @@ * - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ — * วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย * - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion - * - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ - * POST /api/documents/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400) และไม่มี - * segment {docId} ใน URL อีกต่อไป: server แกะ identity/template และ docId - * (= fid — fileId ของ e-Court, string) จาก Bearer token (ออกที่ POST - * /api/documents/token ด้วย x-api-key, fid บังคับ) ที่ apiClient.ts แนบให้อยู่แล้ว * - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) — * ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata * การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง