diff --git a/.env.example b/.env.example index 7e1100d..0aa4f54 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,52 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note # N10 (optional): API key ของ /api/external/* (backend e-Court เรียก server-to-server) # ไม่ตั้ง/ว่าง = ปฏิเสธทุก 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 +# (ใน production ตั้งเป็นพอร์ตจริงที่ deploy) และ presigned URL อ้าง endpoint นี้ตรง ๆ — +# production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้ +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=19000 +MINIO_USE_SSL=false +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 + +# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token +# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp +# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง +# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET +# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement) +# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้ +# ADDIN_TICKET_SECRET=super_secret +# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง +# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน) +# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret diff --git a/.gitignore b/.gitignore index c42a1e7..6ef65e7 100644 --- a/.gitignore +++ b/.gitignore @@ -31,5 +31,14 @@ next-env.d.ts # N4: ไฟล์ที่ผู้ใช้อัปโหลดผ่านหน้ารายการ (รันไทม์ — ไม่เข้า repo) public/note/uploads/ +# wordaddin: Office.js runtime สร้างใหม่ได้เสมอจาก postinstall scripts/fetch-office-js.mjs +# (ร้อยกว่าไฟล์หลาย MB — ไม่ commit เข้า repo เหมือน swagger-ui ที่มีแค่ 2 ไฟล์) +public/wordaddin/office-js/ + +# wordaddin: ใบรับรอง https ของ dev (สร้างเองเฉพาะเครื่อง — ดู docs/WORDADDIN-DEV.md) +/certs/ + # compose ทดสอบบนเครื่อง dev (build จาก repo + DB แยก รันที่ 6704) — ไม่ใช่ของ production docker-compose.test.yml + +certificates \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md index 8621a78..f7d1fe1 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,7 @@ - Next.js 14 (App Router) + TypeScript **strict** — serve ใต้ basePath `/docedit` + route ทั้งหมดของแอปใต้ `app/note/**` (URL ปลายทางยังเป็น `/docedit/note/…` เหมือนเดิม — basePath `/docedit` เป็นพื้นที่กลางรองรับ sibling ในอนาคต เช่น wordaddin, `next.config.mjs`) - pdfjs-dist **v4** (`^4.10.38`) — built-in TextLayer + worker จาก `public/note/pdf.worker.min.mjs` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกจาก node_modules + **patch fake-space ไทย** — รายละเอียด invariant #6) - Drizzle ORM + PostgreSQL (Docker container เดียว: `judes-note-db`, image `postgres:17-alpine`, พอร์ต 54322) — **ไม่ใช้ Supabase** + realtime sync ด้วย **SSE (Server-Sent Events)** ผ่าน `app/api/note/annotations/stream` + pub/sub in-process (`lib/annotate-events.ts`) +- **wordaddin** — taskpane ของ Word add-in "e-Court Documents Versioning" พอร์ตมาเป็น route `/docedit/wordaddin` ของแอปนี้ (UI = Genesis, ชั้น services Office.js พอร์ตดิบจาก `D:\word-addin-poc\addin`) — **ไม่มี login ยกเลิก Keycloak 2026-10-27**: auth = **ticket ของ e-Court** (`ticket_data` + `HmacSha256` ใน custom properties — add-in ตรวจ HMAC เองฝั่ง client แล้วขอ token ที่ `/api/documents/token` แบบ body ticket เก็บใน localStorage `wordaddin-auth` ตาม fid, `docs/PLAN-ADDIN-AUTH.md`) ส่วน API ยังเป็น **mock** รอของจริง (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload/สลับกลับ: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) - Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) - mock login e-Court — หน้า `/note/login` (dropdown `MOCK_USERS` user1–user5 + รหัสผ่านกลาง `Demo1234`) ตั้ง cookie `oacCase` ตาม format จริงของระบบศาล → identity (userId = `u_userid`, userName = `u_userlogin` — **ต่างกันต่อคน** เพื่อสิทธิ์เจ้าของ) อ่านจาก cookie ผ่าน `lib/session.ts` + บังคับทุกหน้าด้วย `middleware.ts` (auth จริงเทียบระบบ e-Court + roles ยัง out of scope) @@ -20,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`) +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` 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 จะทำให้ความหนาเส้นบิดเบี้ยว) @@ -28,16 +29,20 @@ 9. **Optimistic update ใช้ `useOptimistic` (React 18) ภายใน `startTransition`** — React rollback อัตโนมัติเมื่อ action ล้มเหลว + dedupe ด้วย id ใน `hooks/useAnnotationRealtime.ts` (duplicate ชั่วขณะกับ realtime event หายเอง) 10. **Page Virtualization:** render เฉพาะหน้าใน viewport ± 1 หน้าด้วย IntersectionObserver — หน้าพ้นจอเก็บ placeholder ขนาดจริง (scroll ไม่ jump, bookmark ยังคำนวณตำแหน่งได้) และ unmount canvas/TextLayer/InkLayer คืน memory — ห้าม render ทุกหน้าพร้อมกัน 11. **DB connection:** `DATABASE_URL` ชี้ที่ plain PostgreSQL container `judes-note-db` (`postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note`) + `prepare: false` ใน postgres.js เสมอ — pub/sub ของ SSE เป็น in-memory (ใช้ได้เมื่อรัน process เดียว; ถ้ารันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) -12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export) +12. **URL ฝั่ง browser ที่สร้างเองต้องผ่าน `withBasePath()`** (`utils/base-path.ts` — เติม basePath `/docedit` **+ app path `/note`** จาก env `NEXT_PUBLIC_BASE_PATH`/`NEXT_PUBLIC_APP_PATH` ประกาศที่ `next.config.mjs` ที่เดียว) ครอบคลุม: `fetch()`/`EventSource`/plain ``/`workerSrc`/`img.src`/`file.pdfUrl` ตอนโหลดไฟล์/ลิงก์คัดลอก F12 — ส่วน ``/`next/navigation` (router.push/replace/redirect) Next เติม basePath `/docedit` ให้เองเท่านั้น **path ที่เขียนถึง route จึงต้องมี `/note` นำหน้าเสมอ** (เช่น `href="/note/login"`, `redirect("/note/…")`, `revalidatePath("/note/…")`) **ยกเว้น API — route API อยู่ใต้ `app/api/note/**` (URL `/docedit/api/note/…`): path ที่ขึ้นต้น `/api/` เขียนครบเป็น `/api/note/…` แล้ว `withBasePath` เติมแค่ basePath ไม่มี `/note` ซ้อน — เช่นเดียวกับ static/หน้าของ sibling wordaddin: path ที่ขึ้นต้น `/wordaddin/` (office.js self-host, manifest, icons) เติมแค่ basePath**; `middleware.ts` จับ path ที่ตัด basePath แล้ว (หน้า/ไฟล์ของแอปขึ้นต้น `/note/…` ส่วน API ขึ้นต้น `/api/note/…` — รายการยกเว้นใน matcher: ของแอปนำหน้า `note/` เช่น `note/uploads`, API เป็น `api/note`, `wordaddin` (taskpane ของ Word add-in — รันใน webview ของ Word ไม่มี cookie oacCase และมี auth ของตัวเองแยกจากแอป note), ส่วน `fonts`/`favicon` อยู่ public/ รากไม่มี prefix — NextURL ใส่ basePath กลับตอน redirect เอง); `pdf_url` ใน DB เก็บ logical path ไม่รวม prefix เติมตอนใช้: ฝั่ง browser ผ่าน `withBasePath` / ฝั่ง disk ผ่าน `resolvePublicPdfPath` (`lib/server-pdf.ts` root ที่ `public/note/` — statics ของแอปอยู่ใต้ `public/note/**` ทั้งหมดยกเว้นฟอนต์ export) ## โครงสร้างไฟล์ (ตามแผน) ``` +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 ทั้งหมด scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ) scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile) +scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) +scripts/make-ticket-docx.mjs ← dev tool รันมือ (ไม่ใช่ postinstall): stamp ticket ขอ e-Court (`ticket_data` + `HmacSha256`) ลงไฟล์ .docx ใด ๆ ผ่าน pizzip เพื่อทดสอบ ticket auth ของ add-in ใน Word จริงโดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง (key เรียงตามตัวอักษรหลังเติม exp + เซ็นบน string ดิบ hex lowercase) · ตัวเลือก --fid/--uid/--uname/--tid/--exp /--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9) db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) -middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/swagger/* (หน้า Swagger UI), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon) +middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe) lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้) lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court) @@ -45,7 +50,15 @@ lib/external-import.ts ← N8 registerExternalFile — ลงทะเบีย lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allowlist (validate ซ้ำหลัง redirect + จำกัด 100MB + timeout 60s — /api/note/pdf กับ export ใช้ร่วม) 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 — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย +lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย +lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) +lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) +lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) +lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) +wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) +wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (ชื่อ fallback ของ ?addindemo/ไฟล์ที่มี token ไม่มี owner) +wordaddin/models/versionRecord.ts ← types VersionRecord/FieldsSnapshot + readSnapshotField (พอร์ตดิบ) +wordaddin/services/ ← documentService/fileService/metadataService/autoSaveService/versionFlow/apiClient/errors พอร์ตดิบทั้งไฟล์ + **templateService** (แท็บที่ 2 "เครื่องมือ Template" 2026-10-19 — list/select/applyTestValues + แทรกย่อหน้า-ตัวแบ่งบรรทัด-ตัวแบ่งหน้า ที่ cursor (page break มี fallback OOXML กัน E_FAIL), Office.js ล้วน — ⚠️ `contentControls` ธรรมดาเห็นเฉพาะ rich text CC: feature-detect WordApi 1.5 ใช้ `getContentControls()` เห็นทุกชนิด, ห้าม top-level Office access) · **authService + ticketService = ticket auth ของ e-Court ทางเดียว ไม่มี login แล้ว** (ยกเลิก Keycloak → pass-through token/owner → ticket 2026-10-27, `docs/PLAN-ADDIN-AUTH.md` ตาม `docs/requirement/addin-workflow.md` — surface เหลือ signInForDemo (dev-only) / signInWithTicket / getAccessToken / renewTokenFromTicket / getUsername; ไฟล์ที่ stamp custom properties `ticket_data`+`HmacSha256` มา = `detect()` ของ TaskpaneApp เรียก signInWithTicket: ตรวจ HMAC ฝั่ง client (Web Crypto + NEXT_PUBLIC_ADDIN_TICKET_SECRET — UX gate) → exp → session localStorage `wordaddin-auth` ตาม uid (user ใหม่ = เคลียร์ทั้งก้อน) → ขอ token ที่ `/api/documents/token` แบบ body ticket ผูก fid; ไม่มี ticket/HMAC ไม่ตรง/หมดอายุ = phase `no-access` แยกข้อความตามสาเหตุ; apiClient โดน 401 = renew จาก ticket 1 ครั้ง ยังไม่ผ่าน = AuthRequiredError → no-access) · **versioningService = MOCK** (memory + **metadata persist ลง localStorage ต่อ documentId** เพื่อทดสอบวงปิดเอกสาร→เปิดใหม่ได้ — blob ไฟล์จริงเฉพาะ session; คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId เป๊ะ, download-link คืน about:blank — **backend จริงของ surface นี้มีแล้ว**: กลุ่ม `app/api/documents/**` (docver, docs/PLAN-DOCVER.md — สลับ mock ด้วยการตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL`) utils/coordinates.ts ← pixel⇄% + getPointerPercent + clamp + mergeRectsByLine utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation (pure, หน่วย %) utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) @@ -54,6 +67,7 @@ utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wr utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {userId: u_userid, userName: u_userlogin} (pure module — middleware ใช้ร่วมได้) utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit + app path /note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) +utils/docver-ticket.ts ← ticket ของ add-in ส่วน pure แชร์ client/server (2026-10-27, PLAN-ADDIN-AUTH.md §4 — ไม่ import อะไรเลย ให้สองฝั่งฉีด crypto ของตัวเอง: lib/addin-ticket.ts = node:crypto / wordaddin/services/ticketService.ts = Web Crypto): parseDocverTicket (allowlist exp/fid/tid/uid/uname + cap ยาวตาม endpoint) + isTicketExpired + normalizeHexDigest + timingSafeEqualHex + type HmacSha256Hex — unit test tests/utils/docver-ticket.test.ts utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) @@ -68,12 +82,17 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก) app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login) app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…) +app/api/documents/token/route.ts + versions/route.ts + versions/[versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field · **2026-10-27 POST token มีทางเข้าที่สองสำหรับ add-in ใน Word** (PLAN-ADDIN-AUTH.md): ไม่มี x-api-key + body `{ticket_data, HmacSha256}` — lib/addin-ticket.ts ตรวจ HMAC+exp (secret env ADDIN_TICKET_SECRET → SECRET → default "super_secret") แล้วออก token จาก claims ของ ticket เอง · **2026-10-26 ถอด segment `[docId]` ออกจาก URL ทุก endpoint — docId = claim `fid` (string) จาก token ตรง ๆ (บังคับตั้งแต่ขอ token)**): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath app/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) app/note/login/page.tsx ← หน้า login (มี session แล้ว redirect /note) app/note/annotate/[id]/page.tsx ← โหลด file + initial data → PDFViewer app/note/annotate/[id]/summary/page.tsx ← หน้าสรุป Annotation+Bookmark ของไฟล์เดียว (server component — query ทั้งหมดฝั่ง server แล้วส่ง `ExplorerItem[]` เรียง หน้า → หน้าเดียวกัน bookmark ก่อน annotation (bookmark ต่อหน้าเรียงตาม yOffset N5 เหมือนเดิม, annotation ตาม createdAt) ให้ `SummaryExplorer` กรองใน memory; design "แฟ้มสำนวน" 2026-09-17 แถบหัวน้ำเงิน + การ์ดรายหน้าขอบ folio + รายการตามหน้าจุดไข่ปลา → **redesign 2026-09-24 ตามคำขอผู้ใช้เป็น explorer 2 คอลัมน์แบบหน้ากลุ่ม N9 โดยมิติแรกของตัวกรองเป็น "หน้า" แทน "ไฟล์"** — แถบหัวน้ำเงิน + action bar (กลับหน้าอ่าน — ปุ่มส่งออก CSV F16/ส่งออก Bookmark JSON N7 ถอด 2026-09-25 แทนด้วย N10 API) + empty state คงเดิม, ไฟล์ว่าง = ไม่ mount explorer เลย; generateMetadata ตั้งชื่อแท็บ browser ตามคดี; ตอนพิมพ์ header เป็นกรอบเส้นน้ำเงิน + แผงตัวกรอง/แถบ action ซ่อนด้วย print:hidden — กฎ @media print อยู่ใน globals.css) — อย่าถอด class `summary-page` ของ
(กฎ coarse 44px ใน globals.css ผูกอยู่กับ selector นี้ ครอบทั้ง `a` และ `button`) +app/wordaddin/layout.tsx + page.tsx ← taskpane ของ Word add-in (URL `/docedit/wordaddin` — nested layout แรกของ repo override title แท็บ browser; page เป็น server component บาง ๆ render WordAddinGate ทั้งหมดฝั่ง client) +app/wordaddin/manifest.xml/route.ts ← GET serve manifest ของ add-in พร้อม rewrite origin ตามโฮสต์ที่ขอ (template `public/wordaddin/manifest.template.xml` ค่า dev — dev/UAT/production โหลด manifest.xml ขอ environment ตัวเองแล้วใช้ได้เลย ไม่ต้อง rewrite มือ; origin จาก `utils/request-origin.ts` แชร์กับ deep link N10 — x-forwarded-proto/host ก่อน fallback host ตรง + บังคับ https โฮสต์ที่ไม่ใช่ localhost เพราะ Office รับ https เท่านั้น) +components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office.js self-host (ผ่าน withBasePath "/wordaddin/office-js/office.js") → Office.onReady → mount TaskpaneApp · `?addindemo=untracked|tracking` (dev-only) ข้าม office.js + ตั้ง session mock (`signInForDemo`) + ตั้ง phase ตรง เพื่อดู UI ใน browser ธรรมดา — flow ที่แตะ Word จริงจะจบที่ error ตาม expected (FR-17: ใน browser ปกติ banner auto-save error โผล่หลัง ~30 วิ — ถูกต้อง; เส้นทางจริงไม่มี demo จะจบที่หน้า no-access เพราะอ่าน properties ไม่ได้) +components/wordaddin/TaskpaneApp.tsx ← state machine checking/no-access/untracked/tracking (ไม่มี login — ยกเลิก Keycloak 2026-10-27: detect อ่าน ticket_data/HmacSha256 จากไฟล์ผ่าน signInWithTicket ไม่ผ่าน = no-access แยกข้อความตามสาเหตุ · AuthRequiredError จาก flow ก็ไป no-access) + handler ทุกตัว พอร์ตจาก App.tsx ต้นฉบับ (เปลี่ยนแค่ JSX → Genesis + ถอดหน้า login/ปุ่มออกจากระบบ — header เหลือ chip username เฉย ๆ) · SaveControls/MessageBar/Spinner/ConfirmVersionDialog(portal แบบ AnnotationSidebar)/VersionHistoryPanel · **2 แท็บ (Versioning / เครื่องมือ Template — toggle `aria-pressed` เฉพาะ phase untracked|tracking, 2026-10-19): แท็บ template = TemplateToolsPanel (สแกน CC/กรอกค่าทดสอบ/แทรกย่อหน้า-ตัวแบ่ง — spec ที่ docs/addin-ui-flow.md §10)** components/summary/SummaryExplorer.tsx ← client root ของหน้าสรุป (redesign 2026-09-24) — state ตัวกรอง `FacetFilterState` จากแผงกลาง explorer + กรองใน memory + facet count/static users derive เอง + มิติ "หน้า" เป็นตัวเลือกแบบ single-select เฉพาะหน้าที่มีรายการ ≥1 เรียงน้อย→มาก (`optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหน้าเยอะดันกลุ่มอื่นออกจอ) + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
`, desktop aside sticky 15rem — pattern เดียวกับ GroupExplorer; คอลัมน์ content ครอบด้วย `
` ไม่ใช่ `
` เพราะกฎ print break-inside ของ `main.summary-page section` มุ่งการ์ดรายหน้าข้างใน) components/summary/SummaryList.tsx ← คอลัมน์ content ของหน้าสรุป — การ์ดรายหน้า "1 หน้า 1 การ์ด" หัวการ์ด sticky ระดับ viewport (เลขหน้า + บรรทัดสรุปหน้า `pageSummaryLine` **คำนวณจากรายการที่เห็นจริงหลังกรอง** + ปุ่ม "เปิดหน้านี้"; กลับเป็น position static ตอนพิมพ์ผ่านกฎ `.page-head` ใน globals.css) + แถว passive จุดคลิกเดียวคือปุ่ม `` "View" เหมือน GroupList (deep link `?ann=`/`?page=` — bookmark ไปหน้า, annotation ไปรายการ; meta line ไม่มี segment ไฟล์เพราะไฟล์เดียวทั้งหน้า) + header "พบ N รายการ" (aria-live) + ปุ่ม "ล้างตัวกรอง" (เฉพาะมีตัวกรอง active) + empty state กรองจนไม่เหลือ + **ตอนพิมพ์แสดงฉบับเต็มเสมอ** — ตัวกรองที่ตั้งค้างต้องไม่ตัดรายการออกเงียบ ๆ บนกระดาษ: ตอน activeCount > 0 render ชุดบนจอ (print:hidden) + ชุดเต็ม (hidden print:block, ไม่มี id/aria-labelledby ซ้ำ) แยกกัน; anchor `#page-N` ของ section คงจาก design เดิมให้ลิงก์เก่ายังใช้ได้ components/pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) diff --git a/README.md b/README.md index 31ffd89..c914253 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,7 @@ | เครื่องมือ | เวอร์ชันที่ใช้พัฒนา/ทดสอบ | หมายเหตุ | |---|---|---| | Node.js | v24.3.0 | ≥ 20 น่าจะใช้ได้ | -| Docker | 29.1.3 | ใช้รัน PostgreSQL container ตัวเดียว | +| Docker | 29.1.3 | ใช้รัน PostgreSQL + MinIO ผ่าน `docker-compose.yml` | ## ขั้นตอน Setup จากศูนย์ @@ -31,17 +31,16 @@ npm install > postinstall จะคัดลอก `pdf.worker.min.mjs` จาก `node_modules/pdfjs-dist/build/` มาไว้ที่ `public/` ให้อัตโนมัติ -### 2. รัน PostgreSQL container +### 2. รัน PostgreSQL + MinIO ด้วย docker compose ```bash -docker run -d --name judes-note-db \ - -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=postgres \ - -p 54322:5432 \ - -v judes-note-db-data:/var/lib/postgresql/data \ - postgres:17-alpine +docker compose up -d ``` -ครั้งต่อไปแค่ `docker start judes-note-db` — ข้อมูลอยู่ใน volume `judes-note-db-data` ไม่หาย +- `db` = PostgreSQL (`judes-note-db` พอร์ต 54322, database `ecourt-note` สร้างตอน first init) — ใช้ชื่อ container/วอลุ่มเดียวกับคำสั่ง `docker run` เดิม: ถ้ามี container เก่าจาก `docker run` อยู่ ให้ `docker rm -f judes-note-db` ก่อน (วอลุ่ม `judes-note-db-data` เดิม ข้อมูลไม่หาย — ถ้าวอลุ่มเดิม init ด้วย database `postgres` ให้สร้าง `ecourt-note` เอง: `docker exec judes-note-db psql -U postgres -c 'CREATE DATABASE "ecourt-note"'`) · จะรันแค่ MinIO ก็ได้ด้วย `docker compose up -d minio` +- `minio` = object storage ของ API เอกสาร versioning (`judes-note-minio` — API ที่ **พอร์ตโฮสต์ 19000**, console ดูผ่าน browser ที่ http://localhost:19001; ข้างใน container ยังเป็น 9000/9001 — host เลี่ยงมา 19000/19001 เพราะ 9000/9001 มักชนกับ service อื่นในเครื่อง dev; image ดึงจาก `quay.io/minio/minio` ซึ่งเป็น registry ทางการของ MinIO) — bucket ไม่ต้องสร้างเอง app สร้างตอน first use + +ครั้งต่อไปแค่ `docker compose start` — ข้อมูลอยู่ใน volume `judes-note-db-data`/`judes-note-minio-data` ไม่หาย ### 3. สร้างไฟล์ `.env` @@ -50,6 +49,12 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note # N10 (optional): API key ของ /api/note/external/* สำหรับ backend e-Court เรียกแบบ server-to-server # ไม่ตั้งหรือเว้นว่าง = API ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # EXTERNAL_API_KEY= +# MinIO ของ API เอกสาร versioning (ค่าตรง docker-compose.yml — ไม่ต้องแก้ถ้ารันตามขั้นตอน 2) +MINIO_ENDPOINT=127.0.0.1 +MINIO_PORT=19000 +MINIO_USE_SSL=false +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin ``` (เทียบกับ `.env.example`) @@ -125,6 +130,28 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso "http://localhost:3000/docedit/api/note/external/documents" ``` +## API จัด version เอกสาร (docver — stand-alone) + +กลุ่ม API ให้ **Word add-in / ระบบภายนอก** เก็บเอกสารเป็น version: เอกสารเดียวต่อ `docId` (unique) แต่ละ version เก็บไฟล์ **.docx + .pdf ใน MinIO** พร้อม `userId`/`userName`/`createdAt` ของผู้อัปโหลด — **ไม่มี auth ไม่มี session** (stand-alone): ผู้เรียกบอก identity เองผ่าน field `userId`/`userName` ของ multipart · contract ตรง mock ของ wordaddin (`versioningService`) เป๊ะเพื่อวันสลับของจริง · สเปก/เหตุผล: `docs/PLAN-DOCVER.md` + +```bash +# POST อัปโหลด version (เอกสารถูกสร้างแบบ implicit เมื่อเป็น version แรก) +curl -F "file=@คำสั่งเรียก.docx" -F "pdf=@คำสั่งเรียก.pdf" \ + -F "userId=u0001" -F "userName=ผู้พิพากษา ก." \ + "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions" +# → {"hasPdf":true,"versionId":"","versionNumber":1} (versionNumber เป็นค่าจาก server) + +# GET history (ใหม่ → เก่า) — docId ที่ยังไม่เคยมี version = 404 +curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions" + +# GET presigned URL อายุสั้น (default docx, ?format=pdf — version ไม่มี pdf = 404) +curl "http://localhost:3000/docedit/api/documents/CASE-2569-0001/versions//download-link?format=pdf" +``` + +- ไฟล์แนบ `file` (.docx) บังคับ · `pdf` optional (add-in แนบ blob เปล่าได้ = ถือว่าไม่มี ดู `hasPdf`) · ทั้งคู่ ≤ 100MB + ตรวจ magic bytes · field นอก allowlist = 400 กัน typo เงียบ ๆ +- error ทุกสถานะเป็น JSON `{ "error": "<ข้อความไทย>" }` — 400 (รูปผิด/ไม่ใช่ multipart/เนื้อไฟล์เพี้ยน) · 413 (เกิน 100MB) · 404 (ไม่มีเอกสาร/version/pdf) · 500 (MinIO env ไม่ตั้ง/ล้ม, DB ล้ม — log เต็มฝั่ง server) +- **presigned URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` ตรง ๆ** — production ต้องเป็นโฮสต์ที่เบราว์เซอร์ของผู้ใช้เข้าถึงได้ ไม่ใช่แค่ที่ Next server เข้าถึงได้ + ## Troubleshooting | อาการ | สาเหตุ/ทางแก้ | @@ -132,7 +159,8 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso | Realtime ไม่ซิงก์ | sync ใช้ pub/sub **in-memory ของ process เดียว** — ทุกแท็บต้องเข้าผ่าน dev server เดียวกัน (ถ้าวันหนึ่งรันหลาย instance ต้องเพิ่ม Redis pub/sub หรือ Postgres LISTEN/NOTIFY ก่อน) | | หน้าเว็บ 500 หลังรัน `next build` ระหว่างที่ dev server กำลังรัน | build เขียนทับ cache ของ dev — ปิด dev server ก่อน build หรือ restart dev server หลัง build | | `tsc --noEmit` error ชี้ route ที่ถูกลบไปแล้ว (เช่น `app/api/external/document*` หลังรวมเป็น `query` รอบสองของ N10) | type stub ค้างใน `.next/types/` ของ dev/build รอบก่อน — ลบ `.next` หรือรัน `next build`/restart dev server หนึ่งครั้งให้ regenerate แล้วรันใหม่ | -| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น | +| ต่อ DB ไม่ได้ | ตรวจว่า container รันอยู่ (`docker compose start db` — เดิม `docker start judes-note-db`) และพอร์ต 54322 ไม่ถูกใช้โดย container อื่น | +| API เอกสาร versioning ตอบ 500 ทุก call | ตรวจ env `MINIO_ENDPOINT`/`MINIO_ACCESS_KEY`/`MINIO_SECRET_KEY` และ container minio (`docker compose start minio`) — รายละเอียดอยู่ใน log ฝั่ง server | | Highlight ใช้ไม่ได้กับไฟล์คดีตัวอย่าง | ไฟล์ `01012-*.pdf` ทั้งสองเป็น**ภาพสแกนล้วน** (ไม่มี text layer) — ใช้ `dashboard-report-2026-09-03.pdf` ซึ่งเป็น text-based PDF แทน จึงจะคลุมดำได้ (DRAW/COMMENT/BOOKMARK ใช้ได้ปกติกับทุกไฟล์) | ## หมายเหตุทางเทคนิค @@ -148,11 +176,14 @@ curl -X POST -H "x-api-key: $EXTERNAL_API_KEY" -H "content-type: application/jso db/schema.ts ตาราง + JSONB strict union + DTO + toDTO db/index.ts postgres.js + drizzle (prepare: false) lib/annotate-events.ts pub/sub in-process (actions → SSE route) +lib/document-versioning.ts validation ล้วนของกลุ่ม /api/documents (docver) +lib/docver-store.ts MinIO client + tx จ่ายเลข version + query ของ docver utils/coordinates.ts px ⇄ % + clamp + mergeRectsByLine utils/user.ts parse/build cookie oacCase (mock login e-Court) hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว) app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=) +app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link) app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4) app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts new file mode 100644 index 0000000..2173fe2 --- /dev/null +++ b/app/api/documents/token/route.ts @@ -0,0 +1,97 @@ +// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver +// ทางเข้า 2 ทาง (PLAN-ADDIN-AUTH.md §3 — header x-api-key เป็นตัวตั้ง): +// 1) e-Court server-to-server: header "x-api-key" เทียบ env DOCVER_API_KEY +// (fail closed เมื่อ env ไม่ตั้ง/ว่าง) + body claims ตรง ๆ (เดิมทุกอย่าง) +// 2) add-in ใน Word: ไม่มี x-api-key + body {ticket_data, HmacSha256} — server +// ตรวจ HMAC+exp ด้วย env ฝั่ง server (ADDIN_TICKET_SECRET → SECRET → +// "super_secret", lib/addin-ticket.ts) แล้วออก token จาก claims ของ ticket +// (secret ตัวเซ็น JWT ไม่มีทางไปอยู่ใน bundle ของ add-in) +// ทั้งสองทางออก JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env +// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ +// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId] +// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว +import { docverError } from "@/lib/document-versioning"; +import { + addinTicketSecret, + parseTicketTokenBody, + ticketToDocverClaims, + verifyAddinTicket, +} from "@/lib/addin-ticket"; +import { + issueDocverToken, + parseTokenRequestBody, + requireDocverApiKey, + type DocverTokenClaims, +} from "@/lib/docver-token"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** 401 ของทางเข้า ticket — ข้อความเดียวทุกสาเหตุ (HMAC ผิด/รูปผิด/หมดอายุ/ไม่ส่งมา) กัน oracle */ +const TICKET_DENIED = "ticket ไม่ถูกต้องหรือหมดอายุ"; + +export async function POST(request: Request) { + return request.headers.get("x-api-key") !== null + ? postWithApiKey(request) + : postWithTicket(request); +} + +/** ทางเข้า 1 (เดิม) — e-Court server-to-server */ +async function postWithApiKey(request: Request): Promise { + const apiKeyFail = requireDocverApiKey(request); + if (apiKeyFail !== null) return apiKeyFail; + + 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); + return issueToken(parsed.claims); +} + +/** ทางเข้า 2 — add-in ส่ง ticket ที่ e-Court stamp มากับไฟล์ */ +async function postWithTicket(request: Request): Promise { + let raw: unknown; + try { + raw = await request.json(); + } catch { + return docverError("body ต้องเป็น JSON", 400); + } + const parsed = parseTicketTokenBody(raw); + if (!parsed.ok) { + // missing = ไม่มี ticket มาเล่น ๆ (401 เดียวกับตอน verify ไม่ผ่าน) + // invalid = รูป body ผิดจริง ๆ ให้ 400 ตาม convention กัน typo เงียบ ๆ + return docverError(parsed.error, parsed.kind === "invalid" ? 400 : 401); + } + const verified = verifyAddinTicket(parsed.ticketData, parsed.hmacHex, addinTicketSecret()); + if (!verified.ok) return docverError(TICKET_DENIED, 401); + return issueToken(ticketToDocverClaims(verified.ticket)); +} + +/** ออก JWT ของ claims ที่ผ่านการยืนยันแล้วจากทั้งสองทาง — ส่วนกลาง */ +async function issueToken(claims: DocverTokenClaims): Promise { + // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) + const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); + // const secret = + // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; + + if (secret === undefined || secret === "") { + return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); + } + const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); + const ttlSeconds = + Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; + + const accessToken = issueDocverToken(claims, secret, ttlSeconds); + 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/documents/versions/[versionId]/download-link/route.ts b/app/api/documents/versions/[versionId]/download-link/route.ts new file mode 100644 index 0000000..b97ce00 --- /dev/null +++ b/app/api/documents/versions/[versionId]/download-link/route.ts @@ -0,0 +1,52 @@ +// PLAN-DOCVER.md §1 — GET /api/documents/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 ของกลุ่ม) +import { + docverError, + parseDownloadFormat, + parseVersionIdParam, +} from "@/lib/document-versioning"; +import { requireDocverBearer } from "@/lib/docver-token"; +import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +export async function GET( + request: Request, + { params }: { params: { 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 versionId = parseVersionIdParam(params.versionId); + if (!versionId.ok) return docverError(versionId.error, 400); + let format: string | null = null; + try { + format = new URL(request.url).searchParams.get("format"); + } catch { + return docverError("URL ของ request ไม่ถูกต้อง", 400); + } + const parsedFormat = parseDownloadFormat(format); + if (!parsedFormat.ok) return docverError(parsedFormat.error, 400); + + try { + const row = await findVersionForDoc(docId, versionId.versionId); + if (!row) { + return docverError("ไม่พบ version นี้ของเอกสาร", 404); + } + if (parsedFormat.format === "pdf" && row.pdfObjectKey === null) { + return docverError("version นี้ไม่มีสำเนา PDF", 404); + } + const link = await presignVersionUrl(row.id, parsedFormat.format); + return new Response(JSON.stringify(link), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + } catch (err) { + console.error("docver: สร้าง download link ล้ม", err); + return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); + } +} diff --git a/app/api/documents/versions/route.ts b/app/api/documents/versions/route.ts new file mode 100644 index 0000000..79dcc2b --- /dev/null +++ b/app/api/documents/versions/route.ts @@ -0,0 +1,170 @@ +// 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) +import { createHash, randomUUID } from "node:crypto"; + +import { toDocumentVersionDTO } from "@/db/schema"; +import { + docverError, + DOCVER_MAX_UPLOAD_BYTES, + DOCX_CONTENT_TYPE, + isDocxBytes, + isPdfBytes, + parseVersionUploadForm, + sanitizeFilePartName, +} from "@/lib/document-versioning"; +import { requireDocverBearer } from "@/lib/docver-token"; +import { + findDocumentByDocId, + insertVersionRecord, + listVersionsByDocId, + putVersionObjects, + removeVersionObjects, + versionObjectKeys, +} from "@/lib/docver-store"; + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก) + * ลำดับ: Bearer token → 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; + + let form: FormData; + try { + form = await request.formData(); + } catch { + return docverError("body ต้องเป็น multipart/form-data", 400); + } + + const parsed = parseVersionUploadForm(form); + if (!parsed.ok) return docverError(parsed.error, 400); + + const file = form.get("file"); + if (!(file instanceof File)) { + return docverError('ต้องแนบไฟล์ .docx ใน field "file"', 400); + } + if (file.size === 0) { + return docverError('ไฟล์ใน field "file" ว่าง (0 ไบต์)', 400); + } + // route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions — ต้องเช็คเองก่อน buffer + if (file.size > DOCVER_MAX_UPLOAD_BYTES) { + return docverError('ไฟล์ใน field "file" ใหญ่เกิน 100MB', 413); + } + if (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx")) { + return docverError('field "file" ต้องเป็นไฟล์ .docx', 400); + } + + // add-in อาจแนบ pdf เป็น blob เปล่าตอน capture ไม่สำเร็จ — ถือว่าไม่มี pdf (best-effort) + const rawPdf = form.get("pdf"); + let pdf: File | null = null; + if (rawPdf instanceof File && rawPdf.size > 0) { + if (rawPdf.size > DOCVER_MAX_UPLOAD_BYTES) { + return docverError('ไฟล์ใน field "pdf" ใหญ่เกิน 100MB', 413); + } + pdf = rawPdf; + } + + let docxBytes: Buffer; + let pdfBytes: Buffer | null = null; + try { + docxBytes = Buffer.from(await file.arrayBuffer()); + pdfBytes = pdf ? Buffer.from(await pdf.arrayBuffer()) : null; + } catch (err) { + console.error("docver: อ่านไฟล์แนบเข้าหน่วยความจำไม่สำเร็จ", err); + return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); + } + if (!isDocxBytes(docxBytes)) { + return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (ไม่ใช่ OOXML/ZIP)", 400); + } + if (pdfBytes && !isPdfBytes(pdfBytes)) { + return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400); + } + + const contentHash = createHash("sha256").update(docxBytes).digest("hex"); + const versionId = randomUUID(); + // ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) → + // ไม่ส่งค่อยใช้ชื่อของ File (ผ่าน decodeUploadFilename — undici ถอดชื่อไทยเป็น latin1) + const docxDisplayName = + parsed.form.fileName ?? sanitizeFilePartName(file.name); + + try { + await putVersionObjects({ + versionId, + docx: { bytes: docxBytes, fileName: docxDisplayName }, + pdf: pdf ? { bytes: pdfBytes!, fileName: sanitizeFilePartName(pdf.name) } : undefined, + }); + } catch (err) { + console.error("docver: เขียน object ลง MinIO ไม่สำเร็จ", err); + return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); + } + + const keys = versionObjectKeys(versionId); + let row; + try { + row = await insertVersionRecord({ + versionId, + docId, + clientVersionId: parsed.form.clientVersionId, + contentHash, + message: parsed.form.message, + fields: parsed.form.fields, + templateId: auth.claims.template, + 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, + }); + } catch (err) { + // DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้ + console.error("docver: insert version ล้ม — เก็บกวาด object ที่เขียนไปแล้ว", err); + await removeVersionObjects(versionId, pdfBytes !== null); + return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); + } + + return new Response( + JSON.stringify({ + hasPdf: pdfBytes !== null, + versionId: row.id, + versionNumber: row.versionNumber, + }), + { headers: { "content-type": "application/json; charset=utf-8" } }, + ); +} + +/** 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; + + try { + const doc = await findDocumentByDocId(docId); + if (!doc) { + return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404); + } + const rows = await listVersionsByDocId(doc.id); + return new Response(JSON.stringify(rows.map(toDocumentVersionDTO)), { + headers: { "content-type": "application/json; charset=utf-8" }, + }); + } catch (err) { + console.error("docver: โหลด version history ล้ม", err); + return docverError("เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง", 500); + } +} diff --git a/app/api/note/external/documents/route.ts b/app/api/note/external/documents/route.ts index 3d9f4a1..8a4faff 100644 --- a/app/api/note/external/documents/route.ts +++ b/app/api/note/external/documents/route.ts @@ -4,6 +4,7 @@ import { db } from "@/db"; import { files } from "@/db/schema"; import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth"; import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api"; +import { requestOrigin } from "@/utils/request-origin"; // N10 (PLAN-API.md D2) — POST /api/note/external/documents // (เดิมชื่อ /api/external/query ถึงรอบสาม — เปลี่ยนชื่อตาม resource ที่คืน 2026-09-25) @@ -19,21 +20,8 @@ import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api"; const JSON_HEADERS = { "content-type": "application/json; charset=utf-8" }; -/** origin เต็มของ request นี้ (ใช้ประกอบ url เต็มใน response — คำขอผู้ใช้ - * 2026-09-25): เชื่อ header ของ reverse proxy ก่อน (x-forwarded-proto / - * x-forwarded-host — proxy หลายชั้นส่งเป็น comma list ใช้ค่าแรก) แล้วค่อย - * fallback จาก host/protocol ของ request ตรง ๆ */ -function requestOrigin(req: Request): string { - const url = new URL(req.url); - const proto = - req.headers.get("x-forwarded-proto")?.split(",")[0]?.trim() ?? - url.protocol.replace(":", ""); - const host = - req.headers.get("x-forwarded-host")?.split(",")[0]?.trim() ?? - req.headers.get("host") ?? - url.host; - return `${proto}://${host}`; -} +// origin เต็มของ request สำหรับ url เต็มใน response — ถอดเป็น helper ร่วม +// (utils/request-origin.ts) ให้ route manifest ของ wordaddin ใช้เหมือนกัน (2026-10-16) export async function POST(req: Request) { const unauthorized = requireExternalApiKey(req); diff --git a/app/api/template/download-docx/route.ts b/app/api/template/download-docx/route.ts new file mode 100644 index 0000000..13f8725 --- /dev/null +++ b/app/api/template/download-docx/route.ts @@ -0,0 +1,108 @@ +// ตัวอย่างรองรับค่าจาก 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 dc5b49c..12108b7 100644 --- a/app/api/template/extract/route.ts +++ b/app/api/template/extract/route.ts @@ -1,7 +1,7 @@ import { NextResponse } from "next/server"; -import { extractContentControls } from "../lib-tmpl"; - -export const runtime = "nodejs"; +import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; +import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; +// export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -14,24 +14,14 @@ 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") ?? ""); - 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") + if (!(file instanceof File) || + (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 }, @@ -41,7 +31,6 @@ 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 }, @@ -52,18 +41,36 @@ export async function POST(request: Request) { const buffer = Buffer.from(await file.arrayBuffer()); // ใช้ lib-tmpl.ts เดิม - const data = extractContentControls(buffer); + // const data = extractContentControls(buffer); - return NextResponse.json({ - success: true, - data, - }); + const cc = extractContentControls(buffer); + const bookmark = extractBookmark(buffer) + const properties = getDocProps(buffer) + let verify: Record ={} + const ticket_data = properties?.custom?.ticket_data + const HmacSha256 = String(properties?.custom?.HmacSha256) + const Ed25519 = String(properties?.custom?.Ed25519) + + if(ticket_data && typeof ticket_data === 'string'){ + 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) + } + if(TEST_PUBLIC_KEY_HEX&& Ed25519){ + verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) + } + } + + let returnData : Record = {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 new file mode 100644 index 0000000..1a057ee --- /dev/null +++ b/app/api/template/generate/route.ts @@ -0,0 +1,101 @@ +// สร้าง 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 deleted file mode 100644 index 2c15f52..0000000 --- a/app/api/template/lib-tmpl.ts +++ /dev/null @@ -1,518 +0,0 @@ -import PizzaZip from "pizzip"; -import { - DOMParser, - XMLSerializer, - type Document as XmlDocument, - type Element as XmlElement, - type Node as XmlNode, - type Attr as XmlAttr, -} from "@xmldom/xmldom"; -import xpath from "xpath"; - -export interface ExtractedData { - [tagName: string]: string; -} - -const W_NS = - "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - -const asXpathNode = (node: XmlNode): globalThis.Node => - node as unknown as globalThis.Node; - -function createSelector() { - return xpath.useNamespaces({ w: W_NS }); -} - -/** - * อ่าน Content Control (w:sdt) จาก DOCX Buffer - * ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ - */ -export function extractContentControls( - docxBuffer: Buffer, -): ExtractedData { - const zip = new PizzaZip(docxBuffer); - const docXmlFile = zip.file("word/document.xml"); - - if (!docXmlFile) { - throw new Error("Invalid docx file: word/document.xml not found."); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); - const select = createSelector(); - const result: ExtractedData = {}; - - const sdtNodes = select( - "//w:sdt", - asXpathNode(doc), - ) as unknown as XmlElement[]; - - sdtNodes.forEach((sdt) => { - const tagAttr = select( - "./w:sdtPr/w:tag/@w:val", - asXpathNode(sdt), - true, - ) as unknown as XmlAttr | undefined; - - const tagName = tagAttr?.value; - - if (!tagName) return; - - const sdtContent = select( - "./w:sdtContent", - asXpathNode(sdt), - true, - ) as unknown as XmlElement | undefined; - - if (!sdtContent) { - result[tagName] = ""; - return; - } - - const nodes = select( - ".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - let extractedText = ""; - - nodes.forEach((node) => { - switch (node.nodeName) { - case "w:p": - if ( - extractedText.length > 0 && - !extractedText.endsWith("\n") - ) { - extractedText += "\n"; - } - break; - - case "w:t": - extractedText += node.textContent || ""; - break; - - case "w:tab": - extractedText += "\t"; - break; - - case "w:br": - extractedText += "\n"; - break; - - case "w:noBreakHyphen": - extractedText += "\u2011"; - break; - - case "w:softHyphen": - extractedText += "\u00ad"; - break; - - case "w:sym": { - const charAttr = node.getAttribute("w:char"); - if (charAttr) { - const charCode = Number.parseInt(charAttr, 16); - extractedText += String.fromCharCode(charCode); - } - break; - } - } - }); - - result[tagName] = extractedText; - }); - - return result; -} - -type SdtControlType = "plain" | "rich" | "other"; -type SdtStructure = "block" | "inline"; - -function detectSdtControlType( - sdt: XmlElement, - select: xpath.XPathSelect, -): { type: SdtControlType; multiLine: boolean } { - const sdtPr = select( - "./w:sdtPr", - asXpathNode(sdt), - true, - ) as unknown as XmlElement | undefined; - - if (!sdtPr) { - return { type: "other", multiLine: false }; - } - - const textEl = select( - "./w:text", - asXpathNode(sdtPr), - true, - ) as unknown as XmlElement | undefined; - - if (textEl) { - const multiLineAttr = select( - "./@w:multiLine", - asXpathNode(textEl), - true, - ) as unknown as XmlAttr | undefined; - - return { - type: "plain", - multiLine: - multiLineAttr?.value === "1" || - multiLineAttr?.value === "true", - }; - } - - const richTextEl = select( - "./w:richText", - asXpathNode(sdtPr), - true, - ) as unknown as XmlElement | undefined; - - if (richTextEl) { - return { type: "rich", multiLine: true }; - } - - const otherTypeTags = [ - "w:date", - "w:dropDownList", - "w:comboBox", - "w:checkbox", - "w:citation", - "w:group", - "w:docPartObj", - "w:docPartList", - ]; - - for (const tag of otherTypeTags) { - const found = select( - `./${tag}`, - asXpathNode(sdtPr), - true, - ) as unknown as XmlElement | undefined; - - if (found) { - return { type: "other", multiLine: false }; - } - } - - return { type: "rich", multiLine: true }; -} - -function detectSdtStructure( - sdtContent: XmlElement, - select: xpath.XPathSelect, -): SdtStructure { - const directP = select( - "./w:p", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - return directP.length > 0 ? "block" : "inline"; -} - -function buildRunChildren( - doc: XmlDocument, - text: string, -): XmlElement[] { - const nodes: XmlElement[] = []; - const tokens = text.split(/(\t|\n)/); - - tokens.forEach((token) => { - if (token === "") return; - - if (token === "\t") { - nodes.push(doc.createElementNS(W_NS, "w:tab")); - } else if (token === "\n") { - nodes.push(doc.createElementNS(W_NS, "w:br")); - } else { - const t = doc.createElementNS(W_NS, "w:t"); - t.setAttribute("xml:space", "preserve"); - t.textContent = token; - nodes.push(t); - } - }); - - if (nodes.length === 0) { - const t = doc.createElementNS(W_NS, "w:t"); - t.setAttribute("xml:space", "preserve"); - t.textContent = ""; - nodes.push(t); - } - - return nodes; -} - -function buildRun( - doc: XmlDocument, - text: string, - rPr?: XmlElement, -): XmlElement { - const run = doc.createElementNS(W_NS, "w:r"); - - if (rPr) { - run.appendChild(rPr.cloneNode(true)); - } - - buildRunChildren(doc, text).forEach((node) => - run.appendChild(node), - ); - - return run; -} - -function insertInlineContent( - doc: XmlDocument, - sdtContent: XmlElement, - select: xpath.XPathSelect, - text: string, - allowLineBreak: boolean, -): void { - const safeText = allowLineBreak - ? text - : text.replace(/\n/g, " "); - - const existingRuns = select( - "./w:r", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - const firstRPr = - existingRuns.length > 0 - ? (select( - "./w:rPr", - asXpathNode(existingRuns[0]), - true, - ) as unknown as XmlElement | undefined) - : undefined; - - existingRuns.forEach((r) => { - if (r.parentNode) r.parentNode.removeChild(r); - }); - - sdtContent.appendChild( - buildRun(doc, safeText, firstRPr), - ); -} - -function insertBlockContent( - doc: XmlDocument, - sdtContent: XmlElement, - select: xpath.XPathSelect, - text: string, - allowMultiParagraph: boolean, -): void { - const allParagraphs = select( - "./w:p", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - if (allParagraphs.length === 0) { - // ป้องกัน template ที่มีโครงสร้างผิดปกติ - const p = doc.createElementNS(W_NS, "w:p"); - sdtContent.appendChild(p); - allParagraphs.push(p); - } - - const firstP = allParagraphs[0]; - - const firstPPr = select( - "./w:pPr", - asXpathNode(firstP), - true, - ) as unknown as XmlElement | undefined; - - const firstRun = select( - "./w:r", - asXpathNode(firstP), - true, - ) as unknown as XmlElement | undefined; - - const firstRPr = firstRun - ? (select( - "./w:rPr", - asXpathNode(firstRun), - true, - ) as unknown as XmlElement | undefined) - : undefined; - - allParagraphs.forEach((p) => { - if (p !== firstP && p.parentNode) { - p.parentNode.removeChild(p); - } - }); - - const existingRuns = select( - "./w:r", - asXpathNode(firstP), - ) as unknown as XmlElement[]; - - existingRuns.forEach((r) => { - if (r.parentNode) r.parentNode.removeChild(r); - }); - - if (!allowMultiParagraph) { - firstP.appendChild(buildRun(doc, text, firstRPr)); - return; - } - - text.split("\n").forEach((line, index) => { - let targetP: XmlElement; - - if (index === 0) { - targetP = firstP; - } else { - targetP = doc.createElementNS(W_NS, "w:p"); - - if (firstPPr) { - targetP.appendChild(firstPPr.cloneNode(true)); - } - - sdtContent.appendChild(targetP); - } - - const run = doc.createElementNS(W_NS, "w:r"); - - if (firstRPr) { - run.appendChild(firstRPr.cloneNode(true)); - } - - buildRunChildren(doc, line).forEach((node) => - run.appendChild(node), - ); - - targetP.appendChild(run); - }); -} - -function insertFallbackContent( - sdtContent: XmlElement, - select: xpath.XPathSelect, - text: string, -): void { - const textNodes = select( - ".//w:t", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - if (textNodes.length === 0) return; - - textNodes[0].textContent = text; - - for (let i = 1; i < textNodes.length; i++) { - textNodes[i].textContent = ""; - } -} - -export type TemplateData = Record; - -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 df6501c..c738afe 100644 --- a/app/api/template/render/route.ts +++ b/app/api/template/render/route.ts @@ -1,94 +1,71 @@ import { NextResponse } from "next/server"; -import { - templateContentControls, - type TemplateData, -} from "../lib-tmpl"; - +import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; 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")||"") - // ----------------------------- // ตรวจสอบ 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") + if (template.type !== DOCX_CONTENT_TYPE && + !template.name.toLowerCase().endsWith(".docx") && + template.size > MAX_UPLOAD_BYTES ) { return NextResponse.json( { - success: false, - error: 'Field "template" must be a .docx file.', + error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.', }, { 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 data: TemplateData; - + let templateData: DocumentTemplate; // JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500 // (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น) try { if (typeof dataJson === "string" && dataJson.length > 0) { // กรณีส่ง JSON ผ่าน form field - data = JSON.parse(dataJson) as TemplateData; + templateData = JSON.parse(dataJson) as DocumentTemplate; } else if (dataFile instanceof File) { 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(); - data = JSON.parse(text) as TemplateData; + templateData = JSON.parse(text) as DocumentTemplate; } else { return NextResponse.json( { - success: false, error: 'Missing JSON data. Send either "data" file or "dataJson" field.', }, @@ -98,24 +75,20 @@ export async function POST(request: Request) { } catch (error) { return NextResponse.json( { - success: false, - error: `Invalid JSON data (${ - error instanceof Error ? error.message : "parse error" - }).`, + error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error" + }).`, }, { status: 400 }, ); } - // JSON ต้องเป็น object if ( - data === null || - typeof data !== "object" || - Array.isArray(data) + templateData === null || + typeof templateData !== "object" || + Array.isArray(templateData) ) { return NextResponse.json( { - success: false, error: "JSON data must be an object.", }, { status: 400 }, @@ -126,31 +99,21 @@ export async function POST(request: Request) { // Template File → Buffer // ----------------------------- - const templateBuffer = Buffer.from( + let templateBuffer: Buffer = Buffer.from( await template.arrayBuffer(), ); - - // ----------------------------- - // ใช้ lib-tmpl.ts เดิม - // ----------------------------- - - const output = templateContentControls( - templateBuffer, - data, - ); + const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC); // ----------------------------- // ส่ง 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), }, }); @@ -159,7 +122,7 @@ export async function POST(request: Request) { return NextResponse.json( { - success: false, + error: error instanceof Error ? error.message @@ -168,4 +131,5 @@ 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 deleted file mode 100644 index 799c36f..0000000 --- a/app/api/template/test/T2.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "notname": "คำสั่งเรียก ให้ทำคำให้การ", - "nottype": "(ต. ๑)", - "blackno": "คดีหมายเลขดำที่", - "blackyear": "๑๐/๒๕๖๙", - "redno": "คดีหมายเลขแดงที่", - "redyear": "", - "court": "ศาลปกครองสุพรรณบุรี", - "curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", - "accuser": "นายธัญวิสิษฐ์ ศรแก้ว", - "acc1": "ผู้ฟ้องคดี", - "accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน", - "acc2": "ผู้ถูกฟ้องคดี", - "nottitle": "คำสั่งถึง", - "commto": "เจ้าคุณมด", - "groupname": "ผู้ถูกฟ้องคดีที่ ๑", - "detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้", - "pname": "นางนพเก้า เถื่อนชัย", - "courtname": "ศาลปกครองสุพรรณบุรี", - "courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี", - "courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐", - "courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓", - "courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”", - "sendto": "เจ้าคุณมด", - "addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -", - "remarkhead": "", - "remark": "", - "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี" -} \ No newline at end of file diff --git a/app/api/template/test/make-protected.mjs b/app/api/template/test/make-protected.mjs deleted file mode 100644 index c35f2b2..0000000 --- a/app/api/template/test/make-protected.mjs +++ /dev/null @@ -1,298 +0,0 @@ -// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties -// (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md -// -// ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx] -// ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด) -// -// สิ่งที่แก้ในไฟล์ docx: -// 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1" -// (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย -// จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้) -// 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย -// w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone" -// ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค) -// พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้ -// 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr -// 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml -// 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml -import PizzaZip from "pizzip"; -import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; -import xpath from "xpath"; -import { readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx"); -const outPath = process.argv[3] ?? inPath; - -// ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร) -const CUSTOM_PROPS = { - token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo", - "primary-key": "DOC-2026-000123", - owner: "u1001", -}; - -const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; -const CUSTOM_NS = - "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; -const VT_NS = - "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; -const REL_CUSTOM = - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; -const CT_CUSTOM = - "application/vnd.openxmlformats-officedocument.custom-properties+xml"; -const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; - -const parser = new DOMParser(); -const serializer = new XMLSerializer(); - -const parseXml = (text) => parser.parseFromString(text, "text/xml"); - -// xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี -// (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid) -const serialize = (doc) => { - const text = serializer.serializeToString(doc); - return text.startsWith("\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 deleted file mode 100644 index 919190b..0000000 --- a/app/api/template/test/note.md +++ /dev/null @@ -1,127 +0,0 @@ -# Docx Template -lib-tmpl.ts เป็นแชร์ไลบารีสำหรับการทำ Template ใช้ Content Control ใน MS Word -## วิธีการใช้ API -```sh -## Swagger UI — เปิดในเบราว์เซอร์ (ลองยิงจริงผ่านปุ่ม Try it out ได้เลย ไม่ต้อง login) -http://localhost:3000/docedit/api/swagger - -## template/test ไว้ทดสอบเท่านั้น -curl.exe http://localhost:3000/docedit/api/template/test - -## extract ค่าออกมาจาก content control -curl.exe -X POST http://localhost:3000/docedit/api/template/extract -F "file=@./output-T3.docx" - -## render เอกสาร (docx) = template(docx)+data(json) -### อาจจะเพิ่มการเอา template จาก url และ file(local) ยังไม่รู้ว่าทางนั้นจะส่งไฟล์แบบไหนมาให้ เอาแค่นี้ไว้ทดสอบก่อน -curl.exe -X POST http://localhost:3000/docedit/api/template/render -F "template=@./T3.docx" -F "data=@./T2.json" -o output.docx -### data เอาจาก string -curl -X POST http://localhost:3000/docedit/api/template/render \ - -F "template=@template.docx" \ - -F 'dataJson={"member_name":"นายสมชาย","member_id":"123456"}' \ - -o generated-document.docx - -``` -data(json) ตอนนี้อยู่ในรูปแบบ -```json -{ - "notname": "คำสั่งเรียก ให้ทำคำให้การ", - "nottype": "(ต. ๑)", - "blackno": "คดีหมายเลขดำที่", - "blackyear": "๑๐/๒๕๖๙", - ... -} -``` -data(json)น่าจะปรับเป็นแบบนี้เพื่อรองรับการทำ Token-Based Seamless Integration -หลักผ่าน API -- ส่วนของ cc จะไปกรอกใน Content Control -- uid,uname,fid,ott,expire สร้างเป็น token ฝังใน custom property ของเอกสาร -```json -{ - "uid":"id ผู้มีสิทธิ์แก้ไขเอกสาร", - "uname": "ชื่อผู้มีสิทธิ์แก้ไขเอกสาร สำหรับแสดงผล", - "fid":"file id จะได้เซฟข้อมูลกลับไปที่ Oracle ถูกตารางและหน่วยงาน", - "ott":"อายุของ one time token แบบระยะสั้น", - "expire": "อายุเอกสารแบบระยะยาวหลังเอา ott ไปแลก (optional)", - cc:{ - "notname": "คำสั่งเรียก ให้ทำคำให้การ", - "nottype": "(ต. ๑)", - "blackno": "คดีหมายเลขดำที่", - "blackyear": "๑๐/๒๕๖๙", - ... - } -} -``` -ที่ต้องทำเพิ่ม -- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต -- Backend จัดการ version -- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน - -# ทดลอง Restrict Edit + Custom Properties (2026-10-07) - -สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง - -```sh -node app/api/template/test/make-protected.mjs [in.docx] [out.docx] -# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS) -``` - -## Restrict Edit (เปิดผ่านโค้ด) - -- อยู่ที่ `word/settings.xml` → `` -- 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.docx b/app/api/template/test/output.docx deleted file mode 100644 index 2622682..0000000 Binary files a/app/api/template/test/output.docx and /dev/null differ diff --git a/app/api/template/test/test.http b/app/api/template/test/test.http deleted file mode 100644 index d615855..0000000 --- a/app/api/template/test/test.http +++ /dev/null @@ -1,43 +0,0 @@ -@baseUrl = http://localhost:3000/docedit/api - -### 1. Extract Template -POST {{baseUrl}}/template/extract -Content-Type: multipart/form-data; boundary=WebAppBoundary - ---WebAppBoundary -Content-Disposition: form-data; name="file"; filename="output-T3.docx" -Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document - -< ./output-T3.docx ---WebAppBoundary-- - -### 2. Render Template with JSON File -POST {{baseUrl}}/template/render -Content-Type: multipart/form-data; boundary=WebAppBoundary - ---WebAppBoundary -Content-Disposition: form-data; name="template"; filename="T3.docx" -Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document - -< ./T3.docx ---WebAppBoundary -Content-Disposition: form-data; name="data"; filename="T2.json" -Content-Type: application/json - -< ./T2.json ---WebAppBoundary-- - -### 3. Render Template with Raw JSON String -POST {{baseUrl}}/template/render -Content-Type: multipart/form-data; boundary=WebAppBoundary - ---WebAppBoundary -Content-Disposition: form-data; name="template"; filename="template.docx" -Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document - -< ./T3.docx ---WebAppBoundary -Content-Disposition: form-data; name="dataJson" - -{"notname": "คำสั่งเรียก ให้ทำคำให้การ","nottype": "(ต. ๑)","blackno": "คดีหมายเลขดำที่","blackyear": "๑๐/๒๕๖๙","redno": "คดีหมายเลขแดงที่","redyear": "","court": "ศาลปกครองสุพรรณบุรี","curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙","accuser": "นายธัญวิสิษฐ์ ศรแก้ว","acc1": "ผู้ฟ้องคดี","accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน","acc2": "ผู้ถูกฟ้องคดี","nottitle": "คำสั่งถึง","commto": "เจ้าคุณมด","groupname": "ผู้ถูกฟ้องคดีที่ ๑","detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้","pname": "นางนพเก้า เถื่อนชัย","courtname": "ศาลปกครองสุพรรณบุรี","courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี","courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐","courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓","courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”","sendto": "เจ้าคุณมด","addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -","remarkhead": "","remark": "","warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"} ---WebAppBoundary-- diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..5169a2d --- /dev/null +++ b/app/page.tsx @@ -0,0 +1,33 @@ +import { redirect } from "next/navigation"; + +// หน้า root (/docedit) — แทน redirects() เดิมใน next.config.mjs (ถอดออกลด hop +// ที่ Next สั่งเองตอน deploy หลัง IIS/proxy) +// ตามคำขอผู้ใช้ 2026-10-14: เปิด /docedit เปล่า ๆ → เข้า taskpane ของ wordaddin +// เลย (/wordaddin — middleware ไม่บังคับ login ที่ root) ยกเว้น**ลิงก์ e-Court** +// /docedit/?fileUrl=&fileId=&typeId= (N8) ที่ยังส่งต่อให้ /note เหมือนเดิม — +// redirect() ไม่แนบ searchParams ให้อัตโนมัติเหมือน redirects() จึงต้องส่งต่อเอง +// (redirect ของ next/navigation เติม basePath ให้เอง — invariant #12) +const ECOURT_PARAM_KEYS = ["fileUrl", "fileId", "typeId"] as const; + +export default function RootPage({ + searchParams, +}: { + searchParams: Record; +}) { + const entries = Object.entries(searchParams); + const isEcourtLink = entries.some(([key]) => + (ECOURT_PARAM_KEYS as readonly string[]).includes(key), + ); + if (!isEcourtLink) { + redirect("/wordaddin"); + } + const qs = new URLSearchParams(); + for (const [key, value] of entries) { + if (typeof value === "string") { + qs.set(key, value); + } else if (Array.isArray(value)) { + for (const v of value) qs.append(key, v); + } + } + redirect(`/note?${qs.toString()}`); +} diff --git a/app/tmpl/page.tsx b/app/tmpl/page.tsx new file mode 100644 index 0000000..0eaec44 --- /dev/null +++ b/app/tmpl/page.tsx @@ -0,0 +1,100 @@ +// 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/app/wordaddin/error.tsx b/app/wordaddin/error.tsx new file mode 100644 index 0000000..ac42382 --- /dev/null +++ b/app/wordaddin/error.tsx @@ -0,0 +1,37 @@ +"use client"; + +// [DBG-r3p1] error boundary ของ taskpane — แสดง error จริงแทนหน้า +// "Application error: a client-side exception has occurred" ของ Next +// (ไว้ใช้ debug ใน webview ของ Word ที่เปิด devtools ไม่ได้ — คงไว้ถาวร +// เพราะดีกว่าข้อความกลาง ๆ ของ Next เสมอ สำหรับ add-in ที่ผู้ใช้เปิด console ไม่ได้) +export default function WordAddinError({ + error, + reset, +}: { + error: Error & { digest?: string }; + reset: () => void; +}) { + return ( +
+

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

+

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

+
+        {error.message}
+        {error.digest ? `\n[digest ${error.digest}]` : ""}
+        {error.stack ? `\n\n${error.stack}` : ""}
+      
+ +
+ ); +} diff --git a/app/wordaddin/layout.tsx b/app/wordaddin/layout.tsx new file mode 100644 index 0000000..54a73c7 --- /dev/null +++ b/app/wordaddin/layout.tsx @@ -0,0 +1,10 @@ +import type { Metadata } from "next"; + +/** nested layout แรกของ repo — override ชื่อแท็บ browser ของ taskpane (ส่วนอื่นสืบทอดจาก root layout) */ +export const metadata: Metadata = { + title: "e-Court Documents Versioning", +}; + +export default function WordAddinLayout({ children }: { children: React.ReactNode }) { + return children; +} diff --git a/app/wordaddin/manifest.xml/route.ts b/app/wordaddin/manifest.xml/route.ts new file mode 100644 index 0000000..9b19dbd --- /dev/null +++ b/app/wordaddin/manifest.xml/route.ts @@ -0,0 +1,33 @@ +import fs from "node:fs"; +import path from "node:path"; + +import { requestOrigin } from "@/utils/request-origin"; + +// GET /wordaddin/manifest.xml — manifest ของ Word add-in สำหรับ sideload +// +// URL ใน manifest เป็น absolute https ตามข้อบังคับของ Office แต่ต้องต่างกันตาม +// environment — เดิมใช้ไฟล์ static ใน public/ ค่า dev (localhost:3000) แล้วให้ผู้ +// deploy rewrite มือเองทุกครั้ง (docs/WORDADDIN-DEV.md) ตอนนี้ serve ผ่าน route +// แทน: อ่าน template (public/wordaddin/manifest.template.xml — ค่า dev) แล้ว +// เขียนทับ origin ให้ตรงโฮสต์ที่ขอไฟล์ (dev/UAT/production ใช้ template +// เดียวกัน — โหลดจาก environment ไหน manifest ก็ชี้ environment นั้น) +// +// อ่านจาก public/ เพราะเป็นที่เดียวที่การันตีว่ามีใน standalone ของ Docker +// (runner stage COPY public — ไฟล์ app/ ต้นฉบับไม่ถูกก๊อปเข้า image) + +/** origin dev ที่ฝังใน template — สตริงเดียวที่ถูกแทนที่ทั้งไฟล์ (6 จุด) */ +const DEV_ORIGIN = "https://localhost:3000"; + +const TEMPLATE = path.join(process.cwd(), "public", "wordaddin", "manifest.template.xml"); + +export function GET(req: Request): Response { + const template = fs.readFileSync(TEMPLATE, "utf8"); + const manifest = template.replaceAll(DEV_ORIGIN, requestOrigin(req)); + return new Response(manifest, { + headers: { + "content-type": "application/xml; charset=utf-8", + // origin ขึ้นกับ header ของผู้ขอ — ห้ามแคกลามผู้ใช้เกินครั้งเดียวต่อ download + "cache-control": "no-store", + }, + }); +} diff --git a/app/wordaddin/page.tsx b/app/wordaddin/page.tsx new file mode 100644 index 0000000..b3509a3 --- /dev/null +++ b/app/wordaddin/page.tsx @@ -0,0 +1,7 @@ +import WordAddinGate from "@/components/wordaddin/WordAddinGate"; + +/** Taskpane ของ Word add-in (URL /docedit/wordaddin) — server component บาง ๆ ทั้งหมดอยู่ฝั่ง client + * auth/API เป็น mock ของ wordaddin เอง (ไม่ใช้ cookie oacCase — middleware ยกเว้น path นี้) */ +export default function WordAddinPage() { + return ; +} diff --git a/components/utils/DependentDropdown.tsx b/components/utils/DependentDropdown.tsx new file mode 100644 index 0000000..7331bd9 --- /dev/null +++ b/components/utils/DependentDropdown.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useState, ChangeEvent, useEffect } from 'react'; + +export interface DropdownData { + [key: string]: string[]; +} + +interface DependentDropdownProps { + data: DropdownData; + name1: string; // เพิ่มมาเพื่อใช้กับ Form Action + name2: string; // เพิ่มมาเพื่อใช้กับ Form Action + label1?: string; + label2?: string; + placeholder1?: string; + placeholder2?: string; + onSelectChange?: (mainValue: string, subValue: string) => void; +} + +export default function DependentDropdown({ + data, + name1, + name2, + label1 = 'เลือกตัวเลือกหลัก:', + label2 = 'เลือกตัวเลือกย่อย:', + placeholder1 = '-- กรุณาเลือก --', + placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --', + onSelectChange, +}: DependentDropdownProps) { + const [mainSelection, setMainSelection] = useState(''); + const [subSelection, setSubSelection] = useState(''); + + const handleMainChange = (e: ChangeEvent) => { + const value = e.target.value; + setMainSelection(value); + setSubSelection(''); + }; + + const handleSubChange = (e: ChangeEvent) => { + setSubSelection(e.target.value); + }; + + useEffect(() => { + if (onSelectChange) { + onSelectChange(mainSelection, subSelection); + } + }, [mainSelection, subSelection, onSelectChange]); + + const subOptions = mainSelection ? data[mainSelection] : []; + + return ( +
+ {/* Dropdown ที่ 1 */} +
+ + +
+ + {/* Dropdown ที่ 2 */} +
+ + +
+
+ ); +} diff --git a/components/wordaddin/ConfirmVersionDialog.tsx b/components/wordaddin/ConfirmVersionDialog.tsx new file mode 100644 index 0000000..e22e905 --- /dev/null +++ b/components/wordaddin/ConfirmVersionDialog.tsx @@ -0,0 +1,166 @@ +"use client"; + +import { useEffect, useRef, useState, type ReactNode } from "react"; +import { createPortal } from "react-dom"; + +import { Spinner } from "./Spinner"; + +/** + * FR-2 ขั้นตอนที่ 1 — dialog ยืนยัน "สร้าง version ใหม่ของเอกสารนี้หรือไม่?" พร้อมชื่อ commit + * message ทางเลือก (FR-13) — ใช้ร่วมกับทั้ง Save & Version และ Start Versioning + * FR-18 — มี onConfirmClose = มีปุ่มที่สอง [บันทึกเวอร์ชันและปิดเอกสาร] เป็น primary ตัวเดียว + * (ปุ่มยืนยันหลักกลายเป็น secondary) ส่งเฉพาะ platform ที่ปิดเอกสารได้ + * พอร์ตจาก ConfirmVersionDialog.tsx ต้นฉบับ — แทน Dialog ของ Fluent ด้วย portal + Genesis + * (pattern overlay เดียวกับ AnnotationSidebar) + */ + +const DEFAULT_TITLE = "สร้าง version ใหม่?"; +const DEFAULT_DESCRIPTION = "ระบบจะบันทึกไฟล์ปัจจุบันและอัปโหลดเป็น version ถัดไปของเอกสารนี้"; +const DEFAULT_CONFIRM_LABEL = "สร้าง version"; + +const BTN_SECONDARY = + "focus-ring rounded-md border border-line bg-white px-3 py-1.5 text-sm text-ink transition hover:border-primary hover:text-primary disabled:pointer-events-none disabled:opacity-50"; +const BTN_PRIMARY = + "focus-ring rounded-md bg-primary px-3 py-1.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50"; + +export function ConfirmVersionDialog({ + open, + busy, + title, + description, + confirmLabel, + onConfirm, + onConfirmClose, + onDismiss, +}: { + open: boolean; + /** กำลังอยู่ระหว่าง flow หลังกดยืนยันแล้ว — ปุ่มทั้งหมด disable + แสดง spinner + ปิดไม่ได้ */ + busy: boolean; + /** ปรับข้อความตอน reuse กับ flow อื่น (FR-13 — Start Versioning ใช้ dialog ตัวเดียวกัน) */ + title?: string; + description?: ReactNode; + confirmLabel?: string; + /** ได้รับ commit message ที่ผู้ใช้พิมพ์ (FR-13) — ว่างได้ */ + onConfirm: (message: string) => void; + /** FR-18 — submit ที่สอง "บันทึกเวอร์ชันและปิดเอกสาร" */ + onConfirmClose?: (message: string) => void; + onDismiss: () => void; +}) { + const [message, setMessage] = useState(""); + const dialogRef = useRef(null); + + // component ถูก mount ค้างไว้ตลอด (TaskpaneApp render ไว้นอก phase branch) — ต้องเคลียร์ค่าตอนปิด + // ไม่ใช่ตอน unmount ไม่งั้นข้อความรอบก่อนหลุดมารอบถัดไป + useEffect(() => { + if (!open) { + setMessage(""); + } + }, [open]); + + // ESC = ยกเลิก (เฉพาะไม่กำลัง busy — ตอน busy ห้ามปิดได้ทุกทางตาม spec) + // Tab กักไว้ใน dialog — สิ่งที่ Fluent Dialog เคยทำให้อัตโนมัติ (WCAG 2.1.2/2.4.3) + useEffect(() => { + if (!open) return; + const onKey = (event: KeyboardEvent) => { + if (event.key === "Escape" && !busy) onDismiss(); + if (event.key === "Tab") { + const focusables = dialogRef.current?.querySelectorAll( + "button:not([disabled]), textarea:not([disabled]), [href], input, select, [tabindex]:not([tabindex='-1'])", + ); + const first = focusables?.item(0); + const last = focusables?.item((focusables?.length ?? 0) - 1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [open, busy, onDismiss]); + + // โฟกัสเข้า dialog ตอนเปิด + คืน focus ให้ trigger เดิมตอนปิด (อีกส่วนที่ Fluent เคยทำให้) + useEffect(() => { + if (!open) return; + const previous = document.activeElement as HTMLElement | null; + dialogRef.current?.focus(); + return () => { + previous?.focus(); + }; + }, [open]); + + if (!open) return null; + + return createPortal( +
!busy && onDismiss()} + > +
event.stopPropagation()} + > +

{title ?? DEFAULT_TITLE}

+

{description ?? DEFAULT_DESCRIPTION}

+ +