diff --git a/.env.example b/.env.example index 0aa4f54..ff8e0b0 100644 --- a/.env.example +++ b/.env.example @@ -36,21 +36,43 @@ MINIO_SECRET_KEY=minioadmin # DOCVER_LINK_TTL_SECONDS=900 # docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key) +# และ POST /api/note/auth/token (เส้น mint login-token ของแอป note — PLAN-SSO.md) # ไม่ตั้ง/ว่าง = ปฏิเสธทุก 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 +# + login-token ของ SSO (แยกสิทธิ์ด้วย claim purpose — PLAN-SSO.md D4) +# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 รวมถึงเข้า annotate +# ไม่ได้ (ทางเข้า session เดียวคือลิงก์ SSO) — dev ต้องมีค่านี้ใน .env เสมอ +# ⚠️ ก่อน production: rotate ค่าจริง (ค่า hex เดิมเคยถูก commit ไว้ใน comment — ถือว่าหลุด) DOCVER_TOKEN_SECRET= # DOCVER_TOKEN_TTL_SECONDS=3600 +# (ถอดแล้ว 2026-10-30) NOTE_SESSION_SECRET ของรอบ cookie oacCaseSig — แอป note +# เปลี่ยน session เป็น token แบบ add-in แล้ว (access_token purpose=login จาก +# POST /api/note/auth/session เก็บใน localStorage "note-auth") จึงใช้ +# DOCVER_TOKEN_SECRET ตัวเดียวทั้งระบบ: เซ็น login-token ของลิงก์ + เซ็น/ตรวจ +# session token ฝั่ง Server Actions และ API ของแอป (lib/note-token.ts) + +# dev-only (ทางเลือก): เปิดฟอร์ม "สร้างลิงก์ทดสอบ" บนหน้า /note + route +# POST /api/note/auth/dev-link — mint ลิงก์ SSO จากหน้าเว็บเพื่อทดสอบ flow จริง +# ⚠️ ตัวช่วยทดสอบเท่านั้น ไม่ใช่ทางเข้าจริง: ปิด = route ตอบ 404 + ฟอร์มหายจากหน้า +# และไม่ทำงานใน production build เด็ดขาด (NODE_ENV=production) — dev server ที่แชร์ +# บน LAN เปิดไว้ = คนในวงแชร์เครือข่าย mint ลิงก์เข้าระบบได้ จึงควรเปิดเฉพาะตอนทดสอบ +# NOTE_SSO_DEV_MINT=1 + # 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 ได้ +# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/note/auth/session ด้วย — PLAN-SSO.md D12) +# 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default +# นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev +# อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน) # ADDIN_TICKET_SECRET=super_secret # ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง -# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน) +# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน · ตามคำขอผู้ใช้ 2026-10-29 +# แก้ add-in เป็นงานแยก — ยังคงไว้ตาม PLAN-ADDIN-AUTH.md D3) # NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret diff --git a/CLAUDE.md b/CLAUDE.md index 38926f8..3ad7ab9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,14 +14,14 @@ - 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`) และ **upload/download เป็นของจริงแล้ว** (ปลด mock 2026-10-27 — `versioningService` ยิง `/api/documents/*` ผ่าน Bearer same-origin; mock เหลือเฉพาะ `?addindemo`) (spec UI: `docs/addin-ui-flow.md`, คู่มือ dev/sideload: `docs/WORDADDIN-DEV.md`); Office.js **self-host** ที่ `public/wordaddin/office-js/` ผ่าน postinstall `scripts/fetch-office-js.mjs` — gitignore (ดาวน์โหลดฉบับล่าสุดจาก CDN ของ Microsoft ตอนติดตั้ง ตัวระบบ deploy ใช้ intranet ล้วน; แพ็กเกจ npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 ก่อน WordApi 1.4/1.5 — runtime ตัวนั้นไม่มี `Document.close` ใช้ไม่ได้) - Tailwind CSS + design system **Genesis** (`docs/DESIGN.md`, 2026-09-23) — palette tokens ใน `tailwind.config.ts` (`primary` เดิม + `primary-hover`/`canvas`/`line`/`ink`/`ink-soft`/`ink-faint`/`success`/`warning`/`error` + ระดับข้อความ `success-ink`/`warning-ink`/`error-ink` ≥5:1 สำหรับข้อความ/ไอคอนสถานะ — token 500 ใช้กับ border/bg tint เท่านั้น) ห้ามใช้ raw palette shades ของ Tailwind (`slate-*`/`amber-*`/`emerald-*`/`red-*` ฯลฯ) อีก; ฟอนต์ display = **General Sans** (self-host จาก Fontshare ผ่าน `next/font/local` — `app/fonts/`, variable weight 200–700, ใช้ intranet ได้), body = **DM Sans** (`next/font/google`), ไทย fallback **Sarabun** ตามลำดับใน stack (Latin ก่อนไทย — `fontFamily`/globals.css); รัศมี: ปุ่ม/input 6px (รวมปุ่ม action เล็กใน popup/composer/dialog — คู่ ยกเลิก/บันทึก ต้องได้รัศมีเดียวกัน), menu/list row ใน dropdown/panel 4px, panel/dropdown 8px, การ์ด 12px; เงาเฉพาะ hover (`shadow-card-hover`/`shadow-btn-glow` — static ไม่มีเงา ยกเว้นชั้น floating: dropdown/popover `shadow-lg` และ pin ลอยบนเอกสาร ดู §App-specific exceptions ของ DESIGN.md); focus indicator = outline น้ำเงินทึบ 2px + offset ผ่าน class `.focus-ring`/`.genesis-input` ใน globals.css (+ สำเนา `FOCUS_RING_CLS` ใน PDFViewer/AnnotationSidebar/AnnotationSelectionLayer/ToolControls — แก้ต้องแก้พร้อมกัน; halo alpha จางเกิน WCAG 1.4.11); น้ำเงินเฉพาะของ interactive (แบรนด์มาร์ก tile JN/แถบหัวหน้าสรุปยกเว้น) -- 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) +- **SSO จากลิงก์ e-Court — ทางเข้า session ทางเดียว (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ทั้งระบบ, `docs/PLAN-SSO.md` §0.5)**: ลิงก์ 2 รูปแบบ **(A) JWT** — e-Court mint ผ่าน `POST /api/note/auth/token` (ทางเข้า `x-api-key` ตัวเดิม `DOCVER_API_KEY`) ฝังเป็น param `t=` และ **(B) ticket** — `ticket_data`+`HmacSha256` ชุดเดียวกับตัว stamp .docx ของ add-in → ทั้งคู่ชี้ตรงมา **`/note`** ที่ `NoteHome` (client bootstrap) แลก credential เป็น **access_token (purpose=login อายุ 8 ชม. = `NOTE_SESSION_TTL`) ที่ `POST /api/note/auth/session`** → เก็บใน localStorage `note-auth` (แบบ `wordaddin/services/authService`) + ตัด credential ออกจาก URL ทันที + ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก fid ที่เซ็น) · identity ทุก action/API แกะจาก token ผ่าน `lib/note-token.ts` (env `DOCVER_TOKEN_SECRET` ตัวเดียว — cookie คู่ oacCase/`NOTE_SESSION_SECRET`/middleware/link proof ของรอบ 2026-10-29 **ถอดหมด**) · หน้า protected เป็น shell public + client gate (`AnnotateView` — ไม่มี token = หน้า "กลับ e-Court กดลิงก์ใหม่") ดึงข้อมูลผ่าน `GET /api/note/files/[id]/bootstrap` (Bearer) · เส้นที่ browser เปิดแบบไม่ผ่าน fetch() (SSE/pdf/`` N3/export) คงไม่มี auth ตาม status quo · dev/ทดสอบ mint ลิงก์เองด้วย `scripts/make-login-link.mjs` (roles ยัง out of scope) ## Invariants ที่ต้องรักษา (ห้ามละเมิด) 1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม) 2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด 3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน -4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName, templateId และ docId (จาก claim fid) แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath` +4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ทุก action รับ `token` (access_token ของ session) เป็น param แรกและแกะ identity เองผ่าน `requireUser`/`lib/note-token.ts` — ห้ามรับ userId/userName จาก client (2026-10-30)** — **ยกเว้น `registerExternalFile` (N8)** ที่เรียกจาก route handler ไม่ใช่ Server Action: `POST /api/note/auth/session` (trusted — ตอนแลก credential ของลิงก์ e-Court, fileId จาก fid ที่เซ็น) กับ `GET /api/note/files/open` (path ในเครื่อง `/samples/…` เท่านั้น + trusted:false — ลิงก์ A Link Get(new) ของหน้า /note/dev; ไม่รับ fileId แถว `ecourt:` เกิดไม่ได้) (รายการไฟล์ไม่ได้ 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 จะทำให้ความหนาเส้นบิดเบี้ยว) @@ -29,7 +29,7 @@ 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` ซ้อน — เช่นเดียวกับ 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) +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/annotate"`, `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) ## โครงสร้างไฟล์ (ตามแผน) @@ -40,20 +40,24 @@ scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min. scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile) scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน) scripts/make-ticket-docx.mjs ← dev tool รันมือ (ไม่ใช่ postinstall): stamp ticket ขอ e-Court (`ticket_data` + `HmacSha256`) ลงไฟล์ .docx ใด ๆ ผ่าน pizzip เพื่อทดสอบ ticket auth ของ add-in ใน Word จริงโดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง (key เรียงตามตัวอักษรหลังเติม exp + เซ็นบน string ดิบ hex lowercase) · ตัวเลือก --fid/--uid/--uname/--tid/--exp /--secret/--tracked/--in-place/--list (PLAN-ADDIN-AUTH.md §9) +scripts/make-login-link.mjs ← dev tool รันมือ (ไม่ใช่ postinstall) ของ SSO (PLAN-SSO.md D10): mint ลิงก์ e-Court เข้าแอป note ออฟไลน์แล้วพิมพ์ลิงก์เต็มให้วางใน browser (แทน mock login ที่ถอดแล้ว) — `--mode jwt` (default: HS256 JWT purpose="login" ตรงตาม lib/docver-token.ts, secret อ่าน DOCVER_TOKEN_SECRET จาก env/.env) หรือ `--mode ticket` (ticket_data + HMAC ตรงตามตัว stamp, secret ADDIN_TICKET_SECRET → SECRET → "super_secret") · ตัวเลือก --uid/--uname/--fid/--fileUrl/--typeId/--ttl /--secret/--base (default env DOCEDIT_BASE_URL) scripts/install-wordaddin.{mjs,sh,ps1} ← dev tool ติดตั้ง/ถอด (sideload) Word add-in บนเครื่องทดสอบ Windows + macOS — 3 รูปแบบกลไกเดียวกัน (Windows = registry `Wef\Developer`, macOS = โฟลเดอร์ wef ของ Word — กลไกเดียวกับ office-addin-dev-settings): .mjs = ใน repo (`npm run addin:install`, zero-dependency), .sh (macOS) / .ps1 (Windows) = **ไฟล์ส่งต่อให้เครื่องที่ไม่มี Node/repo** (.ps1 ต้องมี BOM — PS 5.1 อ่าน UTF-8 ไร้ BOM เป็น ANSI) — โหลด manifest จาก server ปัจจุบัน (default `https://ecourt-note.chamomind.com/docedit/wordaddin/manifest.xml`, `--url`/`-Url` ชี้ dev ได้) + `--remove`/`-Remove` ถอด db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false) -middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — ไม่มี login: auth เป็น e-Court pass-through จาก token/owner ในไฟล์), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ)) +middleware.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — เดิมบังคับ session ทุกหน้าผ่าน cookie คู่; ตอนนี้ทุกหน้าเป็น shell public ส่วน session ตรวจฝั่ง client (token ใน localStorage) และ API/actions ตรวจ Bearer เอง 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) -lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId — เรียกระหว่าง render หน้า /note และ /note/annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) +lib/note-auth.ts ← SSO ของแอป note ส่วนกลาง — pure (PLAN-SSO.md §4 ฉบับ token 2026-10-30: ไม่ import @/db/node:crypto ให้ client component ใช้ร่วมได้): parseSsoLinkParams (bootstrap ของ NoteHome — allowlist t/ticket_data/HmacSha256/fileUrl/typeId + cap ยาว, สองชุดพร้อมกัน = invalid ตาม D12) + parseSessionExchangeBody (body ของ POST /api/note/auth/session — เกณฑ์เดียวกัน) + parseLoginTokenBody (body ของเส้น mint: allowlist userid/username/fid/ttl_seconds 60–600 default 300 + ห้ามอักขระ & = + % ใน identity) + devMintEnabled/parseDevLinkBody (dev-link) + ssoBanner (แบนเนอร์ 4 ค่า) — unit test tests/lib/note-auth.test.ts +lib/note-token.ts ← session ฝั่ง server ของแอป note (2026-10-30): NOTE_SESSION_TTL (8 ชม.) + noteUserFromToken/noteUserFromRequest (verifyDocverLoginToken + env DOCVER_TOKEN_SECRET — ใช้ใน requireUser ของ actions และ API ของแอป; import lib/docver-token ซึ่งใช้ node:crypto จึง**ห้ามให้ client แตะ**) +lib/note-session.ts ← session ฝั่ง client (2026-10-30 แบบ add-in): localStorage "note-auth" {token, userid, username, expMs} — readNoteSession/saveNoteSession/clearNoteSession (หมดอายุ = ล้างคืน null, localStorage โยน = try/catch ทุกจุด) + noteAuthFetch แนบ Authorization: Bearer อัตโนมัติ + +lib/external-import.ts ← N8 registerExternalFile — ลงทะเบียนเอกสารจากลิงก์ e-Court metadata-only (dedupe external_id + refresh pdf_url/typeId **เฉพาะแถว `url:…` เสมอ / แถว `ecourt:` เฉพาะเมื่อ opts.trusted — link proof จาก SSO route, review 2026-10-30: ผู้มี session พิมพ์ `?fileUrl&fileId` เองเปิดดูของที่รู้จักแล้วได้อย่างเดียว สร้างแถวใหม่/refresh ไม่ได้ (เคยใช้ repoint pdf_url ของเอกสารคนอื่น)** — เรียกระหว่าง render หน้า /note และ /note/annotate ไม่ใช่ Server Action, ดู carve-out ใน invariant #4; `firstSearchParam` อยู่ที่ `utils/external-file.ts` แทน) 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 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) -lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) +lib/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) — 2026-10-29 (PLAN-SSO.md D4): claims มี `purpose?: "docver"|"login"` (undefined = docver — token เก่าตอน roll ไม่ตาย), `issueDocverToken` รับ purpose ทาง param (default "docver"), `requireDocverBearer` ปฏิเสธ login-token (กัน scope escalation), **`verifyDocverLoginToken`** แยก reason expired/invalid + บังคับ purpose=login สำหรับ SSO route lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicketHmac (HMAC timing-safe บน ticket_data ดิบ + parse **ไม่ตรวจ exp** — route ทางเข้า ticket ใช้ตัวนี้แล้วตัดสิน exp เอง: ticket หมดอายุยังออก token ต่อเมื่อ fid นั้นเคยมี version โดย uid เดียวกัน ผ่าน `hasVersionByUser` ของ lib/docver-store.ts — requirement ขั้น 7 แก้บั๊ก 2026-10-28, DB พัง = fail closed 401) + verifyAddinTicket (HMAC+exp เต็มชุด — ผู้เรียกเดิม) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md) @@ -65,10 +69,10 @@ utils/geometry.ts ← bbox/translate/scale/hitTestPath ของ annotation utils/annotation-display.ts ← label/ข้อความย่อต่อชนิด + TYPE_SHORT/TYPE_ORDER ชื่อย่อ/ลำดับชนิด (บรรทัดสรุปหน้าของหน้าสรุป + แผงตัวกรอง explorer — ย้ายจาก 2 ที่ซ้ำกันมารวมที่นี่ 2026-09-24) + ค่า default TEXTBOX/STAMP + describeAnnotationChange สรุปสิ่งที่เปลี่ยนของ version history F19 (sidebar/สรุป/groups/CSV ใช้ร่วม) utils/date-format.ts ← formatThaiDate วันที่ไทยแบบเต็ม "18 กันยายน พ.ศ. 2569" (+เวลา "… 16:33:00 น.") — ทุกจุดที่แสดงวันที่ใน UI ใช้ร่วม (เว้น export CSV ที่ lock Asia/Bangkok) utils/pdf-export.ts ← แปลงพิกัดจอ⇄PDF (/Rotate) + wrapText + hexToRgb (export route) -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/user.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — เดิม parse/build cookie oacCase + OAC_SIG_COOKIE/SESSION_MAX_AGE ของรอบ cookie +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 `registerExternalFile`, pure; ~~sanitizeNextPath~~ ถอดแล้วพร้อมหน้า login 2026-10-29) 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/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 + ห้ามอักขระ & = + % ใน uid/uname — เกณฑ์เดียวกับ parseLoginTokenBody: อักขระที่ตีความต่างกันตาม context ง่าย, 2026-10-30) + 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) @@ -76,8 +80,12 @@ utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์ hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id hooks/useHistory.ts ← undo/redo stack ฝั่ง client (F2) app/actions/annotations.ts ← Server Actions CRUD + validation + publish event -app/actions/auth.ts ← Server Action login (mock user_x/Demo1234 → ตั้ง cookie oacCase) / logout (ลบ cookie) -app/actions/files.ts ← Server Action uploadFile — validate + เขียนไฟล์ใหม่ที่ public/note/uploads/ + ช่องกลุ่ม typeId ทางเลือก normalize เหมือน N8 (N4) +app/actions/auth.ts ← **ถอดทั้งไฟล์ 2026-10-30 (ฉบับ token)** — logout เป็นปุ่ม client ล้าง localStorage (components/LogoutButton) ไม่มี server action ให้เรียก +app/api/note/auth/token/route.ts ← **SSO เส้น mint login-token** (PLAN-SSO.md §3.1 — x-api-key ผ่าน requireDocverApiKey ตัวเดิม + body allowlist userid/username/fid/ttl_seconds (60–600 default 300) → issueDocverToken purpose="login"; ไม่ import @/db) +app/api/note/auth/session/route.ts ← **แลก credential ของลิงก์ e-Court เป็น access_token** (PLAN-SSO.md §3.2 ฉบับ token 2026-10-30 — แทน GET e-court ที่ set cookie ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?, typeId?} → verify (JWT: verifyDocverLoginToken แยก expired/invalid / ticket: verifyAddinTicket + **ปฏิเสธค่า default "super_secret" เฉพาะ production** — ค่ารู้กันทั้ง repo = ปลอม session ได้ (dev ยอมรับ default ให้ทดสอบได้; กลุ่ม docver/add-in ใช้ default ได้ตาม requirement) → registerExternalFile trusted (fid จาก credential) → คืน {access_token (8 ชม.), userid, username, fileId?, fileError?} · audit log เฉพาะ userid (strip อักขระควบคุม) ห้าม log token ดิบ · ไม่ publish SSE +app/api/note/files/[id]/bootstrap/route.ts ← ข้อมูลเริ่มต้นของ viewer (file + annotations + bookmarks + replies + user เป็น DTO) ให้ AnnotateView ดึงหลังมี token — Bearer บังคับ (401), query ชุดเดียวกับหน้า annotate สมัย server component +app/api/note/files/open/route.ts ← **GET** /api/note/files/open?fileUrl=/samples/… — ทางเปิดเอกสารตัวอย่างของหน้า /note/dev แบบ "A Link Get(new)" ของ /tmpl (2026-10-30): ลิงก์เปล่ายิง GET ไม่มี Bearer (navigation ใส่ header ไม่ได้) จึง**รับเฉพาะ path ในเครื่องขึ้นต้น "/" เท่านั้น** (https เด็ดขาด + ห้าม ..) → registerExternalFile trusted:false (แถว url:… เท่านั้น — ไม่รับ fileId แถว ecourt: เกิดไม่ได้) → 302 เข้า viewer · ผิด = 302 /note?fileError=… ให้หน้าแรกแสดงสาเหตุ +app/api/note/auth/dev-link/route.ts ← **dev-only mint ลิงก์ทดสอบจากหน้า /note** (คำขอผู้ใช้ 2026-10-29 — เปิดด้วย env `NOTE_SSO_DEV_MINT=1` **และ NODE_ENV≠production** เท่านั้น ตาม `devMintEnabled` ของ lib/note-auth; ปิด = 404 + ฟอร์มหายจากหน้า): **POST** parseDevLinkBody (delegate เกณฑ์ identity/ttl ให้ parseLoginTokenBody) → jwt ใช้ DOCVER_TOKEN_SECRET / ticket ใช้ addinTicketSecret + ticket_data เรียง key ตามตัว stamp → คืน `{url}` เต็มจาก origin ของ request — secret ไม่ผ่านหน้าเบราว์เซอร์ · **GET ?file=<ไฟล์ตัวอย่าง>&mode=jwt|ticket (2026-10-30 รอบสอง)** — ลิงก์ "A Link Get(new)" ของหน้า /note/dev: mint credential (identity dev คงที่ oom/สรวิชญ์ + `fid=dev-<ไฟล์>` ให้แต่ละตัวอย่างมี row ecourt: ของตัวเอง) แล้ว 302 เข้า `/note?fileUrl=…&t=…` ให้ bootstrap แลก token ต่อ — flow เดียวกับลิงก์จริงของ e-Court ทุกขั้น · ไม่ใช่ทางเข้าจริง (แผน D7) · dev server บน LAN เปิดค้าง = คนในวง mint ได้ app/api/note/annotations/stream/route.ts ← SSE endpoint (GET ?fileId=) app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation (pdf-lib, F8) 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 ณ ตอนเรียก) @@ -85,10 +93,10 @@ app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (202 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/page.tsx ← **หน้าแรก = ข้อมูลเกี่ยวกับระบบ + client bootstrap ของลิงก์ e-Court** (2026-10-30 รอบสอง ตามคำขอผู้ใช้ — server shell บาง ๆ เนื้อหาทั้งหมดอยู่ใน `components/NoteHome.tsx`): "ระบบนี้ทำอะไรได้" (การ์ด 4 ใบ: มาร์ก/วาด, ความเห็น/ทำงานร่วม, คั่นหน้า/สรุป, เอกสารจาก e-Court) + วิธีเข้าใช้ (ลิงก์ e-Court, session 8 ชม.) + ลิงก์ /note/dev · มี credential ใน URL (t/ticket) = แลก token ที่ POST /api/note/auth/session → เก็บ localStorage → ตัด credential ทิ้ง → มี fileId พาเข้า viewer · แบนเนอร์ sso/fileError · public +app/note/dev/page.tsx ← **เครื่องมือทดสอบ** (ย้ายมาจากหน้าแรก 2026-10-30): เปิดเอกสารตัวอย่าง (PLAN-SSO.md D14 2026-10-29 — dropdown 2 ระดับกลุ่ม→ไฟล์ รายการ fixed จาก public/note/samples/ ไม่ query DB + รายการลิงก์ GET แบบ /tmpl "A Link Get(new)" ชี้ `/api/note/files/open?fileUrl=/samples/…` + section Flow ของแอป — 2026-10-30 รอบสอง) + **คู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (การ์ดทาง A JWT/ทาง B ticket + กล่อง dev `make-login-link` — ตัวอย่างเป็นรูปแบบ+ขั้นตอนเท่านั้น **ห้าม mint จริงจากหน้า public**) + ฟอร์ม mint ลิงก์ `DevLoginLinkForm` (เปิดด้วย env `NOTE_SSO_DEV_MINT=1` เท่านั้น) · **public เหมือนหน้าแรก** (ต้อง mint ลิงก์ตอนยังไม่มี session — ฉบับ token 2026-10-30 ไม่มี middleware ทุกหน้าเป็น shell public อยู่แล้ว) · แบนเนอร์ `?sso=`/`?fileUrl=` ไม่อยู่ที่หน้านี้ (คงอยู่ที่ `/note` ตาม contract) 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]/page.tsx ← shell บาง ๆ (ตรวจ UUID → `components/pdf/AnnotateView`) — ฉบับ token 2026-10-30: client gate ตรวจ token ใน localStorage แล้วดึง initial data ผ่าน GET /api/note/files/[id]/bootstrap (Bearer) → PDFViewer (prop token); ไม่มี token/401 = หน้า "กลับ e-Court กดลิงก์ใหม่" 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 เท่านั้น) @@ -96,30 +104,29 @@ components/wordaddin/WordAddinGate.tsx ← client gate: ฉีด script office. 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 หน้า) +components/pdf/AnnotateView.tsx ← client gate ของหน้า annotate (2026-10-30): ตรวจ token (readNoteSession) → noteAuthFetch bootstrap API → PDFViewer · ไม่มี token/401 = หน้า gate + ล้าง session · TEMP-DEBUG DebugConsole ย้ายมาอยู่ที่นี่ +pdf/PDFViewer.tsx ← client root (tool state, zoom, selection, lazy render หน้า) — prop `token` (2026-10-30): ส่งเข้าทุก action call + ส่งต่อให้ AnnotationSidebar components/pdf/InkLayer.tsx ← SVG freehand + รูปทรง + redaction components/pdf/TextboxStampLayer.tsx ← overlay TEXTBOX + STAMP (F17/F18) components/pdf/AnnotationSelectionLayer.tsx ← กรอบ selection + handles ย้าย/ปรับขนาด (F1) + แถบสี/ความหนา + ปุ่มลบ components/sidebar/AnnotationSidebar.tsx ← + โน้ตคำอธิบาย (note) inline ต่อรายการ (PLAN-FEAT.md §7 N1) components/AnnotationTypeIcon.tsx ← ไอคอนตามชนิด annotation (ย้ายจาก sidebar) — ไม่มี "use client" ให้ server component ใช้ร่วมได้ (sidebar + หน้าสรุป) -components/UploadFileForm.tsx ← ฟอร์มอัปโหลดไฟล์บนหน้ารายการ + ช่องกลุ่ม typeId ทางเลือก ไม่กรอก = ไม่มีกลุ่ม (N4) -components/EcourtLinkForm.tsx ← การ์ดจำลองลิงก์ e-Court บนหน้ารายการ (N8 — กรอก fileUrl/fileId/typeId กด "เปิดเอกสาร" แล้ว router.push ไป /note?fileUrl=&fileId=&typeId= ให้หน้า /note ลงทะเบียน+redirect เอง — ใช้ทดสอบก่อนมีลิงก์จริงจาก e-Court) +components/NoteHome.tsx ← client root ของหน้าแรก /note (2026-10-30) — bootstrap ลิงก์ e-Court: parseSsoLinkParams จาก URL ปัจจุบัน → POST /api/note/auth/session (แลก token + trusted register) → saveNoteSession + history.replaceState ตัด credential → router.replace เข้า viewer/แสดง landing · แบนเนอร์ sso 4 ค่า + fileError แสดงที่นี่ +components/DocumentPicker.tsx ← **ถอดทั้งไฟล์ 2026-10-30 (รอบสอง)** — section เปิดเอกสารตัวอย่างของ /note/dev เปลี่ยนเป็นรายการลิงก์ GET ตรง ๆ แบบ /tmpl (ชี้ /api/note/files/open เอง) ไม่ต้องมี component ครอบ components/groups/GroupExplorer.tsx ← N9 client root ของหน้า /groups/[typeId] (redesign 2026-09-24) — state ตัวกรองก้อนเดียว `FacetFilterState` (first=fileId/`kinds: Set<"bm"|"ann">` ชนิดรายการ + `types: Set` ชนิดของ Annotation — **ทั้งคู่ Set ว่าง = แสดงทั้งหมด (default), ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** คำขอผู้ใช้ 2026-09-24/ผู้บันทึก) + กรองใน memory ด้วย useMemo + facet count/static users derive เอง + layout 2 คอลัมน์ (มือถือ < lg ตัวกรองหุบเป็น `
` แบบหน้าสรุป, desktop aside sticky 15rem scroll ภายใน) — render `FacetFilterPanel` สองที่แชร์ state เดียว (`firstGroup` = ไฟล์) + re-export `GroupListItem` = `ExplorerItem` components/explorer/FacetFilterPanel.tsx ← แผงตัวกรอง facet **กลางใช้ร่วม 2 หน้า** — ย้ายมาจาก FilterControls ของหน้ากลุ่ม 2026-09-24 ให้หน้าสรุปใช้ร่วม (มิติแรกส่งเข้าทาง prop `firstGroup`: groups = "ไฟล์", สรุป = "หน้า" พร้อม `optionsScroll` ให้ scroll ภายในกลุ่ม max-h-64 กันหลายสิบหน้าดันกลุ่มอื่นออกจอ; อีก 3 กลุ่ม ชนิดรายการ/ชนิดของ Annotation/ผู้บันทึก เหมือนกันทุกหน้า) — **tree menu 2 ระดับสไตล์ Quasar docs** (q-tree--dense no-connectors — design รอบสอง 2026-09-24 ตามคำขอผู้ใช้, ธีม Genesis พื้นสว่าง) — **ชนิดรายการ (Bookmark/Annotation) เป็น checkbox คู่ที่ไม่เลือก = แสดงทั้งหมด (default) ติ๊กอะไรเห็นเฉพาะสิ่งนั้น** (direct selection ตามคำขอผู้ใช้ 2026-09-24 รอบสอง — แบบเดิมเปิดครบทั้งคู่ต้องปิดเอง = สับสน; เคยย้ายไป segmented บนหัว content แล้วย้ายกลับตามคำขอผู้ใช้) = parent `
` หุบ/กางอิสระ + chevron หมุน 90°, leaf = ` +
+ + {error && ( +

+ {error} +

+ )} + + {url && ( +
+

+ ลิงก์พร้อมใช้ (อายุ {ttl} วินาที) — กดเพื่อทดสอบเข้าจริง: +

+
+ + เปิดลิงก์ + + +
+

+ {url} +

+
+ )} + + ); +} diff --git a/components/DotGrid.tsx b/components/DotGrid.tsx deleted file mode 100644 index 3e40ddf..0000000 --- a/components/DotGrid.tsx +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -// Genesis (docs/DESIGN.md) — กริดจุดอันตรกิริยา: ลวดลายตกแต่งชิ้นเดียวที่ design -// system อนุญาต ("the interactive dot grid is the only decorative element") ใช้เป็น -// พื้นหลังหน้า login — จุดเทาถี่ 24px + ไฮไลต์น้ำเงินจางตามตำแหน่ง pointer -// ⚠️ ต้องเป็น pointer-events-none เสมอ: ตัว layer เป็น absolute inset-0 ครอบพื้นที่ -// ทั้งหน้า ส่วนการ์ด login เป็น static element — ตัว positioned จะชนะ hit-test ทับ -// การ์ดจนคลิก/focus ช่องกรอกไม่ได้ (bug report 2026-09-23) จึงฟัง pointermove ที่ -// window แทนของตัวเอง — ไฮไลต์ยังตามเมาส์ได้แม้อยู่เหนือการ์ด + อัปเดตผ่าน CSS -// variable บน style ตรง ๆ ไม่ setState กัน re-render ทุก mousemove -// mask ไล่จางขอบให้กลืนกับพื้น canvas แทนการตัดเส้นชัด -import { useEffect, useRef } from "react"; - -export default function DotGrid() { - const ref = useRef(null); - - useEffect(() => { - const onPointerMove = (e: PointerEvent) => { - const el = ref.current; - if (!el) return; - const rect = el.getBoundingClientRect(); - el.style.setProperty("--dot-mx", `${e.clientX - rect.left}px`); - el.style.setProperty("--dot-my", `${e.clientY - rect.top}px`); - }; - window.addEventListener("pointermove", onPointerMove, { passive: true }); - return () => window.removeEventListener("pointermove", onPointerMove); - }, []); - - return ( -
-
-
- ); -} diff --git a/components/EcourtLinkForm.tsx b/components/EcourtLinkForm.tsx deleted file mode 100644 index 77cbcb9..0000000 --- a/components/EcourtLinkForm.tsx +++ /dev/null @@ -1,118 +0,0 @@ -"use client"; - -// N8 — การ์ดจำลองลิงก์ e-Court บนหน้ารายการ: กรอก fileUrl (+ fileId + typeId) แล้วกด -// "เปิดเอกสาร" จะพาไป /annotate?fileUrl=&fileId=&typeId= ซึ่งลงทะเบียนเอกสาร -// (metadata-only) แล้ว redirect เข้าหน้า viewer ให้เอง — ใช้ทดสอบในเครื่องก่อนมี -// ลิงก์จริงจาก e-Court (รับทั้ง path ในระบบเช่น /samples/… และ URL ของโฮสต์ที่อนุญาต -// — ไม่มี scheme นำหน้าระบบเติม https:// ให้) -// แถบลิงก์เต็มข้างล่างแสดง URL จริงที่ปุ่มจะพาไป (absolute — copy ไปเปิด/ส่งต่อได้) ตามที่พิมพ์สด ๆ -// Genesis: การ์ด surface 12px radius ขอบ line ใน section "เปิดเอกสารจากลิงก์ e-Court" -// (แยกจากส่วนเอกสาร) / ปุ่มเป็น secondary ขอบฟ้า / input ผ่าน .genesis-input -import { useRouter } from "next/navigation"; -import { useEffect, useState } from "react"; - -import { withBasePath } from "@/utils/base-path"; - -const inputClass = - "genesis-input mt-1 w-full text-base transition aria-invalid:border-error"; - -export default function EcourtLinkForm() { - const router = useRouter(); - const [fileUrl, setFileUrl] = useState(""); - const [fileId, setFileId] = useState(""); - const [typeId, setTypeId] = useState(""); - const [error, setError] = useState(null); - // origin เอาตอน client mount เท่านั้น — SSR ไม่มี window (กัน hydration mismatch) - const [origin, setOrigin] = useState(""); - - useEffect(() => { - setOrigin(window.location.origin); - }, []); - - // query ชุดเดียวกันทั้ง preview และตอนกดปุ่ม — สิ่งที่เห็นคือสิ่งที่ถูกเปิดเป๊ะ - const params = new URLSearchParams(); - if (fileUrl.trim()) params.set("fileUrl", fileUrl.trim()); - if (fileId.trim()) params.set("fileId", fileId.trim()); - if (typeId.trim()) params.set("typeId", typeId.trim()); - const queryString = params.toString(); - const fullLink = origin - ? `${origin}${withBasePath("/annotate")}${queryString ? `?${queryString}` : ""}` - : ""; - - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); - if (!fileUrl.trim()) { - setError("กรอกลิงก์ไฟล์ก่อน"); - return; - } - setError(null); - // router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href) - router.push(`/note/annotate?${queryString}`); - }; - - return ( -
- {/* ไม่มีหัวการ์ดซ้ำ — h2 "เปิดเอกสารจากลิงก์ e-Court" ของ section ในหน้ารายการ - เป็นหัวเดียวพอแล้ว (responsive audit 2026-09-24: เดิมซ้ำสองชั้นติดกัน) */} -
-
- - setFileUrl(e.target.value)} - placeholder="เช่น /samples/ชื่อไฟล์.pdf หรือ https://…" - className={inputClass} - /> -
-
- - setFileId(e.target.value)} - className={inputClass} - /> -
-
- - setTypeId(e.target.value)} - placeholder="เช่น 1" - className={inputClass} - /> -
-
- -

- กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที — - ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต -

-

- ลิงก์เต็ม: - {fullLink || "…"} -

- {error &&

{error}

} -
- ); -} diff --git a/components/LoginForm.tsx b/components/LoginForm.tsx deleted file mode 100644 index 40ee6c8..0000000 --- a/components/LoginForm.tsx +++ /dev/null @@ -1,143 +0,0 @@ -"use client"; - -// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note") -// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail) -// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว) -// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static / -// input ผ่าน .genesis-input / ปุ่ม primary hover ขึ้น 1px + glow ฟ้า (shadow-btn-glow) -import { useRef, useState, useTransition } from "react"; -import { useRouter } from "next/navigation"; - -import { login } from "@/app/actions/auth"; -import Select from "@/components/Select"; -import { MOCK_USERS } from "@/utils/user"; - -// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad -// (ทับ text-sm ของ .genesis-input — utility อยู่หลัง @layer components ชนะเสมอ) -// error ตาม spec Inputs: ขอบเปลี่ยนเป็นแดง (ring ยังเป็นฟ้าตาม focus ring หลัก) -const INPUT_CLASS = - "genesis-input mt-1 block w-full text-base transition aria-invalid:border-error"; -// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน) -const SELECT_CLASS = "genesis-input block w-full text-base transition"; - -export default function LoginForm({ next }: { next: string | null }) { - const router = useRouter(); - const passwordRef = useRef(null); - const [error, setError] = useState(null); - const [pending, startTransition] = useTransition(); - - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - setError(null); - const usernameInput = form.elements.namedItem("username") as HTMLSelectElement; - const passwordInput = form.elements.namedItem("password") as HTMLInputElement; - startTransition(async () => { - try { - const result = await login(usernameInput.value, passwordInput.value); - if (result.ok) { - router.replace(next ?? "/note"); - } else { - setError(result.error); - // คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที - passwordInput.value = ""; - passwordRef.current?.focus(); - passwordRef.current?.select(); - } - } catch { - setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง"); - } - }); - }; - - return ( -
- {/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ - (หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ - "blue เฉพาะของ interactive" ของ Genesis */} -
-
- JN -
-
-

- Judge's Note -

-

- ระบบ PDF Annotation สำหรับ e-Court -

-
-
- -

เข้าสู่ระบบ

- -
-
- -
- -
-
- -
- - -
- - - - {/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร) - กล่อง metadata 8px radius ตาม Genesis (พื้นเทาโปร่งจาง ไม่ใช่การเน้น) */} -

- รหัสผ่านทดสอบ:{" "} - Demo1234 -

- - {error && ( -

- {error} -

- )} -
-
- ); -} diff --git a/components/LogoutButton.tsx b/components/LogoutButton.tsx index eb0bfc1..acc42d9 100644 --- a/components/LogoutButton.tsx +++ b/components/LogoutButton.tsx @@ -1,29 +1,26 @@ "use client"; -// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง -import { useTransition } from "react"; +// ปุ่มออกจากระบบ (ฉบับ token 2026-10-30) — session เป็น access_token ใน +// localStorage ไม่ได้อยู่บน server แล้ว การออกจากระบบจึงแค่ล้าง "note-auth" +// แล้วแจ้ง parent ให้ซ่อนปุ่ม/ชื่อผู้ใช้ (ไม่มี server action logout ให้เรียก) import { LogOut } from "lucide-react"; -import { logout } from "@/app/actions/auth"; - -export default function LogoutButton() { - const [pending, startTransition] = useTransition(); +import { clearNoteSession } from "@/lib/note-session"; +export default function LogoutButton({ onCleared }: { onCleared?: () => void }) { const onLogout = () => { - startTransition(async () => { - await logout(); - }); + clearNoteSession(); + onCleared?.(); }; return ( ); } diff --git a/components/NoteHome.tsx b/components/NoteHome.tsx new file mode 100644 index 0000000..1906a3e --- /dev/null +++ b/components/NoteHome.tsx @@ -0,0 +1,294 @@ +"use client"; + +// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ / +// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่): +// มี credential ใน URL (t หรือ ticket_data+HmacSha256 — จากลิงก์ที่ e-Court ชี้ตรงมา +// ที่ /note) = แลกเป็น access_token ที่ POST /api/note/auth/session แล้วเก็บ +// localStorage "note-auth" (แบบ add-in) → ตัด credential ออกจาก URL ทันที → มี fileId +// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา +// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ) +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { useEffect, useRef, useState } from "react"; +import { + Bookmark, + ExternalLink, + FileText, + Highlighter, + MessagesSquare, +} from "lucide-react"; + +import AppNav from "@/components/AppNav"; +import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth"; +import { readNoteSession, saveNoteSession, type NoteSession } from "@/lib/note-session"; +import { withBasePath } from "@/utils/base-path"; + +type Phase = "boot" | "exchanging" | "ready"; + +type SessionResponse = { + access_token?: string; + token_type?: string; + expires_in?: number; + userid?: string; + username?: string; + fileId?: string; + fileError?: string; + reason?: string; +}; + +export default function NoteHome() { + const router = useRouter(); + const [phase, setPhase] = useState("boot"); + const [banner, setBanner] = useState(null); + const [fileError, setFileError] = useState(null); + const [session, setSession] = useState(null); + // StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ + const bootedRef = useRef(false); + + useEffect(() => { + if (bootedRef.current) return; + bootedRef.current = true; + + const url = new URL(window.location.href); + const stripParams = () => { + url.search = ""; + window.history.replaceState(null, "", url.toString()); + }; + const fail = (reason: "expired" | "invalid" | "error") => { + setBanner(ssoBanner(reason)); + setSession(readNoteSession()); + setPhase("ready"); + }; + const parsed = parseSsoLinkParams(url); + const hasCredentialKey = + url.searchParams.get("t") !== null || + url.searchParams.get("ticket_data") !== null || + url.searchParams.get("HmacSha256") !== null; + + if (parsed.ok) { + setPhase("exchanging"); + const body: Record = {}; + if (parsed.credential.kind === "jwt") { + body.t = parsed.credential.token; + } else { + body.ticket_data = parsed.credential.ticketData; + body.HmacSha256 = parsed.credential.hmacHex; + } + if (parsed.fileUrl !== undefined) body.fileUrl = parsed.fileUrl; + if (parsed.typeId !== undefined) body.typeId = parsed.typeId; + // credential ต้องออกจาก URL ทันทีที่รู้ผล — แต่ strip ก่อนยิงไม่ได้ (reload + // กลางคัน = ลิงก์ตาย) จึงกันค้างด้วย timeout: เครือข่ายนิ่งเกิน 45 วิ = ตัดจบ + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), 45_000); + void (async () => { + try { + const res = await fetch(withBasePath("/api/note/auth/session"), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: controller.signal, + }); + const data = (await res.json().catch(() => null)) as SessionResponse | null; + stripParams(); + if ( + !res.ok || + data === null || + data.access_token === undefined || + data.userid === undefined || + data.username === undefined || + data.expires_in === undefined + ) { + fail( + res.status === 401 && data?.reason === "expired" + ? "expired" + : res.status === 401 + ? "invalid" + : "error", + ); + return; + } + const { access_token, userid, username, expires_in, fileId, fileError } = data; + saveNoteSession({ + token: access_token, + userid, + username, + expMs: Date.now() + expires_in * 1000, + }); + if (fileError !== undefined) setFileError(fileError); + if (fileId !== undefined) { + // เข้า viewer เลย — คงสถานะ exchanging ให้ spinner อยู่จนหน้า viewer มาแทน + router.replace(`/note/annotate/${fileId}`); + return; + } + setSession(readNoteSession()); + setPhase("ready"); + } catch { + stripParams(); + fail("error"); + } finally { + window.clearTimeout(timeoutId); + } + })(); + return; + } + + if (hasCredentialKey) { + // มี credential ใน URL แต่รูปไม่ผ่าน (ticket ครึ่งเดียว/ยาวเกิน cap/สองชุดพร้อม + // กัน) — ตัดทิ้ง + แจ้งผู้ใช้ ไม่ปล่อยค่าเน่าค้างอยู่ใน history (D11) + stripParams(); + fail("invalid"); + return; + } + + // ไม่มี credential — หน้าข้อมูลระบบปกติ (แบนเนอร์ ?sso= ของลิงก์เก่า · + // fileError จาก GET /api/note/files/open ตอนเปิดไฟล์ตัวอย่างไม่สำเร็จ) + setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined)); + const openFileError = url.searchParams.get("fileError"); + if (openFileError !== null && openFileError !== "") setFileError(openFileError); + setSession(readNoteSession()); + setPhase("ready"); + }, [router]); + + return ( + <> + +
+ {phase !== "ready" ? ( +

+ {phase === "exchanging" + ? "กำลังเข้าสู่ระบบผ่านลิงก์ของ e-Court…" + : "กำลังโหลด…"} +

+ ) : ( + <> + {/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */} +

+ Judge's Note +

+

+ ระบบอ่านและทำเครื่องหมายเอกสารคดีของ e-Court — มาร์กข้อความ วาดเส้น + ใส่ความเห็น คั่นหน้า และสรุปหมายเหตุทั้งคดีได้ในที่เดียว + โดยไฟล์ต้นฉบับไม่ถูกแก้ไข +

+ + {banner && ( +
+ {banner.text} +
+ )} + + {fileError && ( +
+ เปิดเอกสารจากลิงก์ไม่สำเร็จ: {fileError} +
+ )} + + {/* ── ระบบนี้ทำอะไรได้ ────────────────────────────────────────── */} +
+
+

+ ระบบนี้ทำอะไรได้ +

+
+
+ {[ + { + icon: Highlighter, + title: "มาร์กและวาดบนเอกสาร", + detail: + "มาร์กข้อความไฮไลต์ได้หลายแบบ วาดเส้นด้วยปากกา วาดรูปทรง " + + "ใส่กล่องข้อความและตรายาง รวมถึงปิดบังข้อมูลที่ละเอียดอ่อน " + + "— ย้าย แก้ไข ย่อ-ขยายได้หลังวาง พร้อมปุ่มเรียกคืน (undo/redo)", + }, + { + icon: MessagesSquare, + title: "ความเห็นและทำงานร่วมกัน", + detail: + "หมุดความเห็นพร้อมตอบกลับเป็นเธรด ทำเครื่องหมาย “จัดการแล้ว” " + + "เมื่อเสร็จ และเห็นการเปลี่ยนแปลงของทุกคนแบบสด ๆ " + + "ในไฟล์เดียวกันได้ทันที", + }, + { + icon: Bookmark, + title: "คั่นหน้าและสรุปคดี", + detail: + "คั่นหน้าเป็นหัวข้อซ้อนกันได้ 3 ระดับพร้อมโน้ตย่อย ค้นหาทั้งไฟล์ " + + "แล้วดูรวมทั้งหมดที่หน้า “สรุปหมายเหตุ” — พิมพ์ออกมาเป็น " + + "เอกสารสรุปของคดีได้", + }, + { + icon: FileText, + title: "เอกสารจาก e-Court", + detail: + "เปิด PDF และรูปภาพ (รวมไฟล์ TIFF หลายหน้า) จากลิงก์ของ e-Court " + + "จัดกลุ่มตามคดี และส่งออกเป็น PDF ที่ฝังมาร์กทุกอย่างไว้ในตัว " + + "— ไฟล์ต้นฉบับต้นทางไม่ถูกแตะต้อง", + }, + ].map(({ icon: Icon, title, detail }) => ( +
+ + + +
+

{title}

+

+ {detail} +

+
+
+ ))} +
+
+ + {/* ── วิธีเข้าใช้งาน ─────────────────────────────────────────── */} +
+
+

+ วิธีเข้าใช้งาน +

+
+
+ + + +
+

+ เข้าใช้งานผ่านลิงก์ของ e-Court +

+

+ ระบบนี้ไม่มีหน้าล็อกอิน — เปิดเอกสารจากระบบ e-Court + แล้วระบบพาเข้าหน้าอ่านเอกสารโดยอัตโนมัติ ใช้งานต่อได้ 8 ชม. + ต่อการเข้าหนึ่งครั้ง +

+
+
+ +

+ ผู้พัฒนา/ผู้ทดสอบ:{" "} + + เครื่องมือทดสอบทางเข้าและเอกสารตัวอย่าง → + +

+
+ + )} +
+ + ); +} diff --git a/components/UploadFileForm.tsx b/components/UploadFileForm.tsx deleted file mode 100644 index c496328..0000000 --- a/components/UploadFileForm.tsx +++ /dev/null @@ -1,88 +0,0 @@ -"use client"; - -// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน) -// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/note") ของ action -// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section -// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section -import { useState, useTransition } from "react"; -import { Upload } from "lucide-react"; - -import { uploadFile } from "@/app/actions/files"; - -const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff"; - -/** ขนาดสูงสุดตาม validator ของ uploadFile (app/actions/files.ts) — เช็คฝั่ง client - * ก่อนอัปโหลด (M5, UX audit 2026-09-16): ไฟล์สแกนคดีที่เกินไม่ต้องรอส่งจนจบ - * บนเน็ตช้าก่อนเจอ error ของ server */ -const MAX_BYTES = 100 * 1024 * 1024; - -export default function UploadFileForm() { - const [error, setError] = useState(null); - const [pending, startTransition] = useTransition(); - - const onSubmit = (e: React.FormEvent) => { - e.preventDefault(); - const form = e.currentTarget; - setError(null); - const input = form.elements.namedItem("file") as HTMLInputElement | null; - const file = input?.files?.[0]; - if (file && file.size > MAX_BYTES) { - setError("ไฟล์ใหญ่เกิน 100MB — เลือกไฟล์เล็กกว่า"); - return; - } - startTransition(async () => { - try { - const result = await uploadFile(new FormData(form)); - if (result.ok) { - form.reset(); - } else { - setError(result.error); - } - } catch { - // action fail ที่ระดับ transport (เช่น body ใหญ่เกิน bodySizeLimit ของ next.config) - setError("อัปโหลดไม่สำเร็จ — ลองใหม่อีกครั้ง"); - } - }); - }; - - return ( -
-
- -

อัปโหลดไฟล์

-
- - {/* กลุ่ม typeId ทางเลือก (N9) — ช่องเดียวกับลิงก์ e-Court, ไม่กรอก = "ไม่มีกลุ่ม"; - uncontrolled ส่งไปกับ FormData เหมือนช่องไฟล์ (normalize ที่ action) */} - - - -

- รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์ -

- {error &&

{error}

} -
- ); -} diff --git a/components/pdf/AnnotateView.tsx b/components/pdf/AnnotateView.tsx new file mode 100644 index 0000000..6be9b32 --- /dev/null +++ b/components/pdf/AnnotateView.tsx @@ -0,0 +1,151 @@ +"use client"; + +// client gate ของหน้า /note/annotate/[id] (ฉบับ token 2026-10-30) — เดิมเป็น server +// component อ่าน identity จาก cookie ผ่าน getSessionUser + query DB ตอน render: +// ตอนนี้หน้าเป็น shell public ฝั่ง server (แค่ตรวจ UUID) ส่วนข้อมูลเริ่มต้น +// (file + annotations + bookmarks + replies) ดึงผ่าน GET /api/note/files/[id]/ +// bootstrap พร้อม Bearer หลังเห็น token ใน localStorage — ไม่มี/หมดอายุ = หน้า +// gate "กลับ e-Court กดลิงก์ใหม่" (คู่ขนาน sso=required ของรอบ middleware) +import Link from "next/link"; +import { useEffect, useState } from "react"; + +import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์) +import { ssoBanner } from "@/lib/note-auth"; +import { + clearNoteSession, + noteAuthFetch, + readNoteSession, + type NoteSession, +} from "@/lib/note-session"; +import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema"; +import PDFViewer from "@/components/pdf/PDFViewer"; + +type BootstrapData = { + file: { id: string; title: string; pdfUrl: string }; + annotations: AnnotationDTO[]; + bookmarks: BookmarkDTO[]; + replies: ReplyDTO[]; + user: { userId: string; userName: string }; +}; + +type Phase = "boot" | "loading" | "no-session" | "error" | "ready"; + +const BTN_CLS = + "focus-ring inline-flex h-10 items-center rounded-md bg-primary px-5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"; + +function GateScreen({ title }: { title: string }) { + return ( +
+
+ {ssoBanner("required")!.text} +
+

{title}

+ + กลับหน้าหลัก + +
+ ); +} + +export default function AnnotateView({ fileId }: { fileId: string }) { + const [phase, setPhase] = useState("boot"); + const [data, setData] = useState(null); + const [token, setToken] = useState(null); + const [error, setError] = useState(null); + + useEffect(() => { + const session: NoteSession | null = readNoteSession(); + if (session === null) { + setPhase("no-session"); + return; + } + setToken(session.token); + setPhase("loading"); + let cancelled = false; + void (async () => { + try { + const res = await noteAuthFetch(`/api/note/files/${fileId}/bootstrap`); + if (res.status === 401) { + // token หมดอายุ/ถูกเพิกถิอกลางวัน — ล้างทิ้งแล้วเป็น gate เหมือนไม่เคยเข้า + clearNoteSession(); + if (!cancelled) setPhase("no-session"); + return; + } + const body = (await res.json().catch(() => null)) as + | BootstrapData + | { error?: string } + | null; + if (!res.ok || body === null || !("file" in body)) { + if (!cancelled) { + setError( + (body as { error?: string } | null)?.error ?? + `โหลดเอกสารไม่สำเร็จ (HTTP ${res.status})`, + ); + setPhase("error"); + } + return; + } + if (!cancelled) { + setData(body); + setPhase("ready"); + } + } catch (err) { + if (cancelled) return; + if (err instanceof Error && err.message === "no-session") { + // session หมดพอดีระหว่างต้น effect กับตอน fetch — เป็น gate ไม่ใช่ network error + clearNoteSession(); + setPhase("no-session"); + return; + } + setError("โหลดเอกสารไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วลองใหม่อีกครั้ง"); + setPhase("error"); + } + })(); + return () => { + cancelled = true; + }; + }, [fileId]); + + return ( + <> + {/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */} + + {phase === "no-session" && ( + + )} + {phase === "error" && ( +
+
+ {error} +
+ + กลับหน้าหลัก + +
+ )} + {(phase === "boot" || phase === "loading") && ( +
+

+ กำลังโหลดเอกสาร… +

+
+ )} + {phase === "ready" && data !== null && token !== null && ( + + )} + + ); +} diff --git a/components/pdf/PDFViewer.tsx b/components/pdf/PDFViewer.tsx index 305d2cf..75c66cf 100644 --- a/components/pdf/PDFViewer.tsx +++ b/components/pdf/PDFViewer.tsx @@ -113,7 +113,6 @@ import { type Rect, type Size, } from "@/utils/coordinates"; -import type { OacUser } from "@/utils/user"; import { MAX_BOOKMARK_NOTE_CHARS, MAX_BOOKMARK_TITLE_CHARS, @@ -1315,13 +1314,17 @@ export default function PDFViewer({ initialBookmarks, initialReplies, user, + token, }: { file: { id: string; title: string; pdfUrl: string }; initialAnnotations: AnnotationDTO[]; initialBookmarks: BookmarkDTO[]; initialReplies: ReplyDTO[]; - /** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */ - user: OacUser; + /** identity จาก access_token ของ session (AnnotateView การันตีให้พร้อมกับ token + * — ไม่มีช่วง null หลัง mount · ฉบับ token 2026-10-30 แทน cookie oacCase) */ + user: { userId: string; userName: string }; + /** access_token ส่งเข้าทุก Server Action — identity แกะฝั่ง server */ + token: string; }) { const [pdfjs, setPdfjs] = useState(null); const [doc, setDoc] = useState(null); @@ -2152,7 +2155,7 @@ export default function PDFViewer({ type: AnnotationType, data: AnnotationData, ) { - if (!user) return; + const now = new Date().toISOString(); const temp: AnnotationDTO = { id: crypto.randomUUID(), @@ -2172,19 +2175,17 @@ export default function PDFViewer({ await startTransition(async () => { applyAnnotation({ kind: "add", row: temp }); try { - const saved = await createAnnotation({ + const saved = await createAnnotation(token, { id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย) fileId: file.id, pageIndex, - userId: user.userId, - userName: user.userName, type, data, }); if (pending.cancelled) { // ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย // ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ) - await deleteAnnotation(saved.id, user.userId); + await deleteAnnotation(token, saved.id); return; } setAnnotations((prev) => @@ -2205,15 +2206,13 @@ export default function PDFViewer({ let currentId = saved.id; pushHistory({ undo: async () => { - await deleteAnnotation(currentId, user.userId); + await deleteAnnotation(token, currentId); setAnnotations((prev) => prev.filter((a) => a.id !== currentId)); }, redo: async () => { - const re = await createAnnotation({ + const re = await createAnnotation(token, { fileId: file.id, pageIndex, - userId: user.userId, - userName: user.userName, type, data, }); @@ -2233,7 +2232,7 @@ export default function PDFViewer({ } async function handleDeleteAnnotation(id: string) { - if (!user) return; + // FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง // เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก // เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา) @@ -2251,18 +2250,16 @@ export default function PDFViewer({ await startTransition(async () => { applyAnnotation({ kind: "remove", id }); try { - await deleteAnnotation(id, user.userId); + await deleteAnnotation(token, id); setAnnotations((prev) => prev.filter((a) => a.id !== id)); if (target) { // F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น let currentId: string | null = null; pushHistory({ undo: async () => { - const re = await createAnnotation({ + const re = await createAnnotation(token, { fileId: file.id, pageIndex: target.pageIndex, - userId: target.userId, - userName: target.userName, type: target.type, data: target.data, }); @@ -2273,7 +2270,7 @@ export default function PDFViewer({ }, redo: async () => { if (!currentId) return; - await deleteAnnotation(currentId, user.userId); + await deleteAnnotation(token, currentId); setAnnotations((prev) => prev.filter((a) => a.id !== currentId)); }, }); @@ -2300,7 +2297,7 @@ export default function PDFViewer({ * await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */ function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise { return new Promise((resolve) => { - if (!user) return resolve(); + const current = annotations.find((a) => a.id === id); if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9) bumpPreviewClear(); @@ -2310,15 +2307,15 @@ export default function PDFViewer({ kind: "update", row: { ...current, data, updatedAt: new Date().toISOString() }, }); - const saved = await updateAnnotation(id, user.userId, data); + const saved = await updateAnnotation(token, id, data); setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a))); pushHistory({ undo: async () => { - const up = await updateAnnotation(id, user.userId, before); + const up = await updateAnnotation(token, id, before); setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a))); }, redo: async () => { - const up = await updateAnnotation(id, user.userId, data); + const up = await updateAnnotation(token, id, data); setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a))); }, }); @@ -2392,7 +2389,7 @@ export default function PDFViewer({ }, }); try { - const saved = await setAnnotationResolved(id, resolved); + const saved = await setAnnotationResolved(token, id, resolved); setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a))); } catch (err) { console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง @@ -2407,12 +2404,10 @@ export default function PDFViewer({ annotationId: string, message: string, ): Promise { - if (!user) return false; + try { - const saved = await createReply({ + const saved = await createReply(token, { annotationId, - userId: user.userId, - userName: user.userName, message, }); setReplies((prev) => @@ -2427,9 +2422,9 @@ export default function PDFViewer({ } async function handleDeleteReply(id: string) { - if (!user) return; + try { - await deleteReply(id, user.userId); + await deleteReply(token, id); setReplies((prev) => prev.filter((r) => r.id !== id)); } catch (err) { console.error("ลบคำตอบไม่สำเร็จ", err); @@ -2564,15 +2559,13 @@ export default function PDFViewer({ async function handleSaveBookmark() { const c = bookmarkComposer; - if (!c || !user || bookmarkPendingRef.current) return; + if (!c || bookmarkPendingRef.current) return; bookmarkPendingRef.current = true; setBookmarkSaving(true); try { - const saved = await createBookmark({ + const saved = await createBookmark(token, { fileId: file.id, pageIndex: c.pageIndex, - userId: user.userId, - userName: user.userName, title: c.title.trim() || `หน้า ${c.pageIndex + 1}`, yOffset: c.yOffset, parentId: c.parentId, @@ -2597,16 +2590,14 @@ export default function PDFViewer({ /** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */ async function handleAddSubBookmark(parentId: string) { - if (!user || bookmarkPendingRef.current) return; + if (bookmarkPendingRef.current) return; const pos = currentViewPosition(); if (!pos) return; bookmarkPendingRef.current = true; try { - const saved = await createBookmark({ + const saved = await createBookmark(token, { fileId: file.id, pageIndex: pos.pageIndex, - userId: user.userId, - userName: user.userName, title: `หน้า ${pos.pageIndex + 1}`, yOffset: pos.yOffset, parentId, @@ -2626,9 +2617,9 @@ export default function PDFViewer({ id: string, patch: { title?: string; note?: string | null; parentId?: string | null }, ) { - if (!user) return; + try { - const saved = await updateBookmark({ id, userId: user.userId, ...patch }); + const saved = await updateBookmark(token, { id, ...patch }); setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b))); } catch (err) { console.error("แก้ไข bookmark ไม่สำเร็จ", err); @@ -2636,11 +2627,11 @@ export default function PDFViewer({ } async function handleDeleteBookmark(id: string) { - if (!user) return; + await startTransition(async () => { applyBookmark({ kind: "remove", id }); try { - await deleteBookmark(id, user.userId); + await deleteBookmark(token, id); setBookmarks((prev) => prev.filter((b) => b.id !== id)); } catch (err) { console.error("ลบ bookmark ไม่สำเร็จ", err); @@ -3283,6 +3274,7 @@ export default function PDFViewer({ bookmarks={optimisticBookmarks} fileId={file.id} userId={user?.userId ?? ""} + token={token} userFilter={userFilter} users={users} onUserFilterChange={setUserFilter} @@ -4483,7 +4475,7 @@ export default function PDFViewer({ void handleDeleteReply(id); }} onStrokeEnd={(points, widths) => { - if (!user) return; + void handleCreateAnnotation(i, "DRAW", { strokeColor: drawStyle.color, strokeWidth: drawStyle.strokeWidthPct, @@ -4494,11 +4486,11 @@ export default function PDFViewer({ }); }} onShapeEnd={(data) => { - if (!user) return; + void handleCreateAnnotation(i, "SHAPE", data); }} onRedactionEnd={(data) => { - if (!user) return; + void handleCreateAnnotation(i, "REDACTION", data); }} onTextLayerRendered={handleTextLayerRendered} diff --git a/components/sidebar/AnnotationSidebar.tsx b/components/sidebar/AnnotationSidebar.tsx index f74f2d0..0b1a901 100644 --- a/components/sidebar/AnnotationSidebar.tsx +++ b/components/sidebar/AnnotationSidebar.tsx @@ -285,6 +285,7 @@ export default function AnnotationSidebar({ bookmarks, fileId, userId, + token, userFilter, users, onUserFilterChange, @@ -303,6 +304,9 @@ export default function AnnotationSidebar({ /** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */ fileId: string; userId: string; + /** access_token ของ session — ส่งเข้าทุก action ที่แผงนี้เรียกเอง (ค้นหา/ประวัติ/ + * กู้คืน · ฉบับ token 2026-10-30 identity แกะฝั่ง server) */ + token: string; /** F4: "all" | "mine" | — กรองฝั่ง client ล้วน */ userFilter: string; users: { userId: string; userName: string }[]; @@ -363,7 +367,7 @@ export default function AnnotationSidebar({ if (!q || !fileId) return; setSearching(true); try { - setSearchResults(await searchAnnotations(fileId, q)); + setSearchResults(await searchAnnotations(token, fileId, q)); } catch (err) { console.error("ค้นหาไม่สำเร็จ", err); setSearchResults([]); @@ -394,7 +398,7 @@ export default function AnnotationSidebar({ setVersions(null); setHistoryError(null); try { - setVersions(await listVersions(a.id)); + setVersions(await listVersions(token, a.id)); } catch (err) { setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ"); } @@ -405,7 +409,7 @@ export default function AnnotationSidebar({ async function handleRestore(versionId: string) { setRestoringId(versionId); try { - await restoreVersion(versionId, userId); + await restoreVersion(token, versionId); setHistoryFor(null); } catch (err) { setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ"); @@ -698,7 +702,7 @@ export default function AnnotationSidebar({ try { const q = bmQuery.trim() || undefined; setBmResults( - await searchBookmarks({ + await searchBookmarks(token, { fileId, q, pageFrom, diff --git a/components/utils/DependentDropdown.tsx b/components/utils/DependentDropdown.tsx index 7331bd9..e376112 100644 --- a/components/utils/DependentDropdown.tsx +++ b/components/utils/DependentDropdown.tsx @@ -52,13 +52,13 @@ export default function DependentDropdown({
{/* Dropdown ที่ 1 */}
- + /workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts — เติม base + /note) basePath, // expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้ @@ -33,12 +33,6 @@ const nextConfig = { }, ]; }, - // N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts) - experimental: { - serverActions: { - bodySizeLimit: "100mb", - }, - }, }; export default nextConfig; diff --git a/scripts/make-login-link.mjs b/scripts/make-login-link.mjs new file mode 100644 index 0000000..90e9000 --- /dev/null +++ b/scripts/make-login-link.mjs @@ -0,0 +1,163 @@ +#!/usr/bin/env node +// PLAN-SSO.md §6 D10 — dev tool mint ลิงก์ SSO ของ e-Court ให้แอป note ออฟไลน์ +// (แทน mock login ที่ถอดแล้ว) แล้วพิมพ์ลิงก์เต็มพร้อมวางใน browser — pattern +// เดียวกับ scripts/make-ticket-docx.mjs +// +// ใช้: node scripts/make-login-link.mjs [options] +// --uid default oom (claim userid / ticket uid) +// --uname default "สรวิชญ์" (claim username / ticket uname) +// --fid default DEMO_FID_001 (fileId ของ e-Court — D11) +// --fileUrl path ในเครื่อง (/samples/…) หรือ https ของ allowlist N8 (optional) +// --typeId กลุ่มเอกสาร N9 (optional) +// --ttl default 300 (ทาง jwt = exp ของ token · ทาง ticket = exp ของ ticket) +// --mode jwt|ticket default jwt (ทาง A แนะนำ — ทาง B ใช้ตัว stamp เดียวกับ .docx) +// --secret ทาง jwt: DOCVER_TOKEN_SECRET · ทาง ticket: ADDIN_TICKET_SECRET +// → SECRET → "super_secret" (default ตามลำดับ อ่านจาก env/.env ก่อน) +// --base base URL ของแอป (default env DOCEDIT_BASE_URL หรือ +// http://localhost:3000/docedit) +// +// รันใน repo เท่านั้น — ไม่ใช่ postinstall + +import { createHmac } from "node:crypto"; +import { existsSync, readFileSync } from "node:fs"; +import path from "node:path"; + +/** อ่าน .env (KEY=VALUE บรรทัดละคู่ — พอสำหรับค่า dev ไม่ใช่ parser เต็ม) */ +function loadDotEnv() { + const file = path.join(process.cwd(), ".env"); + if (!existsSync(file)) return {}; + const values = {}; + for (const line of readFileSync(file, "utf8").split(/\r?\n/)) { + const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line); + if (match === null) continue; + let value = match[2]; + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + value = value.slice(1, -1); + } + values[match[1]] = value; + } + return values; +} + +function usage(code = 1) { + console.log(`ใช้: node scripts/make-login-link.mjs [options] + --uid --uname --fid --fileUrl --typeId + --ttl --mode jwt|ticket --secret --base `); + process.exit(code); +} + +function parseArgs(argv) { + const options = { + uid: "oom", + uname: "สรวิชญ์", + fid: "DEMO_FID_001", + fileUrl: null, + typeId: null, + ttl: 300, + mode: "jwt", + secret: null, + base: null, + }; + for (let i = 0; i < argv.length; i++) { + const next = () => { + const value = argv[++i]; + if (value === undefined) usage(); + return value; + }; + switch (argv[i]) { + case "--uid": options.uid = next(); break; + case "--uname": options.uname = next(); break; + case "--fid": options.fid = next(); break; + case "--fileUrl": options.fileUrl = next(); break; + case "--typeId": options.typeId = next(); break; + case "--ttl": { + const value = Number(next()); + if (!Number.isInteger(value) || value <= 0) throw new Error("--ttl ต้องเป็นจำนวนวินาทีบวก"); + options.ttl = value; + break; + } + case "--mode": { + const value = next(); + if (value !== "jwt" && value !== "ticket") throw new Error("--mode ต้องเป็น jwt หรือ ticket"); + options.mode = value; + break; + } + case "--secret": options.secret = next(); break; + case "--base": options.base = next(); break; + case "--help": case "-h": usage(0); break; + default: usage(); + } + } + return options; +} + +const envFile = loadDotEnv(); +const env = (name) => process.env[name] ?? envFile[name] ?? ""; +const options = parseArgs(process.argv.slice(2)); + +const base = (options.base || env("DOCEDIT_BASE_URL") || "http://localhost:3000/docedit").replace(/\/+$/, ""); +const exp = Math.floor(Date.now() / 1000) + options.ttl; + +/** query ร่วมของทั้งสองทาง (เฉพาะ param ที่มีจริง) */ +const sharedParams = new URLSearchParams(); +if (options.fileUrl !== null) sharedParams.set("fileUrl", options.fileUrl); +if (options.typeId !== null) sharedParams.set("typeId", options.typeId); + +let link; +let secretUsed; +if (options.mode === "jwt") { + // ตรงตาม lib/docver-token.ts issueDocverToken(…, purpose:"login") + secretUsed = options.secret ?? env("DOCVER_TOKEN_SECRET"); + if (secretUsed === "") { + console.error("✗ ไม่มี secret — ตั้ง DOCVER_TOKEN_SECRET ใน .env หรือส่ง --secret"); + process.exit(1); + } + const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString("base64url"); + const payload = Buffer.from( + JSON.stringify({ + userid: options.uid, + username: options.uname, + fid: options.fid, + template: null, + purpose: "login", + iat: Math.floor(Date.now() / 1000), + exp, + }), + "utf8", + ).toString("base64url"); + const signature = createHmac("sha256", secretUsed) + .update(`${header}.${payload}`) + .digest("base64url"); + const params = new URLSearchParams(sharedParams); + params.set("t", `${header}.${payload}.${signature}`); + // ปลายทาง = หน้าแรก /note ที่ client bootstrap แลก credential เป็น token + // (ฉบับ token 2026-10-30 — ไม่มี route e-court ที่ set cookie ให้ชี้แล้ว) + link = `${base}/note?${params.toString()}`; +} else { + // ตรงตามตัว stamp จริง (make-ticket-docx.mjs / lib/addin-ticket.ts): + // ticket_data key เรียงตามตัวอักษรหลังเติม exp → HMAC hex lowercase บน string ดิบ + secretUsed = options.secret ?? (env("ADDIN_TICKET_SECRET") || env("SECRET") || "super_secret"); + const ticket = { exp, fid: options.fid, uid: options.uid, uname: options.uname }; + const ticketData = JSON.stringify( + Object.keys(ticket).sort().reduce((result, key) => { + result[key] = ticket[key]; + return result; + }, {}), + ); + const hmac = createHmac("sha256", secretUsed).update(ticketData, "utf8").digest("hex"); + const params = new URLSearchParams(sharedParams); + params.set("ticket_data", ticketData); + params.set("HmacSha256", hmac); + link = `${base}/note?${params.toString()}`; +} + +console.log(`✓ ลิงก์ SSO (${options.mode}) อายุ ${options.ttl} วินาที — วางใน browser:`); +console.log(); +console.log(link); +console.log(); +console.log(` userid/uid : ${options.uid} → หลังแลก token ระบบพาเข้าหน้าอ่านเอกสารของ fid ${options.fid}`); +console.log(` secret : ${".".repeat(Math.max(secretUsed.length - 2, 0))}${secretUsed.slice(-2)} (ไม่พิมพ์เต็ม)`); +console.log(` exp : ${new Date(exp * 1000).toLocaleString("th-TH")}`); diff --git a/tests/lib/docver-token.test.ts b/tests/lib/docver-token.test.ts index 6b2a5ab..25e7770 100644 --- a/tests/lib/docver-token.test.ts +++ b/tests/lib/docver-token.test.ts @@ -6,6 +6,7 @@ import { parseTokenRequestBody, requireDocverApiKey, requireDocverBearer, + verifyDocverLoginToken, verifyDocverToken, type DocverTokenClaims, } from "@/lib/docver-token"; @@ -314,3 +315,87 @@ describe("parseTokenRequestBody", () => { ).toBe(false); }); }); + +// ─── purpose claim ของ PLAN-SSO.md (D4/D5) ─────────────────────────────────── + +describe("issueDocverToken purpose / requireDocverBearer-reject-login", () => { + it("default = purpose docver (payload มีค่าชัด แต่ claims ที่ verify ได้เหมือนเดิม)", () => { + const token = issueDocverToken(CLAIMS, SECRET, 60); + const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8")); + expect(payload.purpose).toBe("docver"); + const verified = verifyDocverToken(token, SECRET); + ok(verified); + expect(verified.claims).toEqual(CLAIMS); // ไม่มี purpose ปนออกมา (ความหมาย = docver) + }); + + it("issue ด้วย purpose login → claims.purpose = login", () => { + const verified = verifyDocverToken(issueDocverToken(CLAIMS, SECRET, 60, "login"), SECRET); + ok(verified); + expect(verified.claims.purpose).toBe("login"); + }); + + it("purpose ค่าอื่น (แม้เซ็นถูก) = invalid", () => { + const now = Math.floor(Date.now() / 1000) + 60; + const bad = signPayload({ ...CLAIMS, purpose: "admin", iat: 0, exp: now }); + expect(verifyDocverToken(bad, SECRET).ok).toBe(false); + }); + + it("requireDocverBearer ปฏิเสธ login-token (scope escalation — 401 เดียวกับ token เน่า)", async () => { + process.env.DOCVER_TOKEN_SECRET = SECRET; + const auth = requireDocverBearer( + reqWithBearer(`Bearer ${issueDocverToken(CLAIMS, SECRET, 60, "login")}`), + ); + expect(auth.ok).toBe(false); + if (!auth.ok) { + expect(auth.response.status).toBe(401); + expect(await bodyOf(auth.response)).toEqual({ error: "โทเคนไม่ถูกต้องหรือหมดอายุ" }); + } + }); +}); + +describe("verifyDocverLoginToken (SSO §3.2)", () => { + it("login-token ที่ยังไม่หมดอายุ = ได้ claims", () => { + const verified = verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, 60, "login"), SECRET); + ok(verified); + expect(verified.claims.fid).toBe(CLAIMS.fid); + expect(verified.claims.purpose).toBe("login"); + }); + + it("signature ถูกแต่ exp เลยมาแล้ว = reason expired (แยกจาก invalid ให้ route ตอบ sso=expired)", () => { + const verified = verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, -10, "login"), SECRET); + expect(verified).toEqual({ ok: false, reason: "expired" }); + }); + + it("token docver (ไม่มี purpose) / purpose=docver = reason invalid (D4)", () => { + const noPurpose = signPayload({ ...CLAIMS, iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }); + expect(verifyDocverLoginToken(noPurpose, SECRET)).toEqual({ ok: false, reason: "invalid" }); + expect(verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, 60), SECRET)).toEqual({ + ok: false, + reason: "invalid", + }); + }); + + it("token docver ที่หมดอายุ = invalid ไม่ใช่ expired (ตัดสินชนิดก่อนอายุ — 2026-10-30)", () => { + expect(verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, -10), SECRET)).toEqual({ + ok: false, + reason: "invalid", + }); + const expiredNoPurpose = signPayload({ ...CLAIMS, iat: 0, exp: 1 }); + expect(verifyDocverLoginToken(expiredNoPurpose, SECRET)).toEqual({ ok: false, reason: "invalid" }); + }); + + it("ผิดรูป/secret ผิด/tampered = reason invalid", () => { + expect(verifyDocverLoginToken("garbage", SECRET)).toEqual({ ok: false, reason: "invalid" }); + expect(verifyDocverLoginToken(issueDocverToken(CLAIMS, "other", 60, "login"), SECRET)).toEqual({ + ok: false, + reason: "invalid", + }); + const token = issueDocverToken(CLAIMS, SECRET, 60, "login"); + const [h, , s] = token.split("."); + const tampered = Buffer.from( + JSON.stringify({ ...CLAIMS, userid: "999", purpose: "login", iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }), + "utf8", + ).toString("base64url"); + expect(verifyDocverLoginToken(`${h}.${tampered}.${s}`, SECRET)).toEqual({ ok: false, reason: "invalid" }); + }); +}); diff --git a/tests/lib/note-auth.test.ts b/tests/lib/note-auth.test.ts new file mode 100644 index 0000000..fd13f45 --- /dev/null +++ b/tests/lib/note-auth.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; + +import { + devMintEnabled, + parseDevLinkBody, + parseLoginTokenBody, + parseSessionExchangeBody, + parseSsoLinkParams, + ssoBanner, +} from "@/lib/note-auth"; + +// PLAN-SSO.md §4 — pure module ของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ +// ผู้ใช้ "note ใช้แบบ add-in": parse ลิงก์ / body แลก token / แบนเนอร์) — ไฟล์นี้ +// ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ node:crypto มาด้วย จึงรันได้โดยไม่ต้อง +// ตั้ง DATABASE_URL + +describe("parseSsoLinkParams (D12)", () => { + const url = (query: string): URL => new URL(`https://x.go.th/docedit/note?${query}`); + + it("มี t เดี่ยว = ทาง jwt + ส่ง fileUrl/typeId ผ่าน", () => { + const parsed = parseSsoLinkParams(url("fileUrl=https%3A%2F%2Ffile.example%2Fa.pdf&typeId=T12&t=abc.def.ghi")); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.credential).toEqual({ kind: "jwt", token: "abc.def.ghi" }); + expect(parsed.fileUrl).toBe("https://file.example/a.pdf"); + expect(parsed.typeId).toBe("T12"); + } + }); + + it("มี ticket ครบสองค่า (ไม่มี t) = ทาง ticket", () => { + const ticketData = encodeURIComponent('{"exp":1,"fid":"f","uid":"u","uname":"n"}'); + const parsed = parseSsoLinkParams(url(`ticket_data=${ticketData}&HmacSha256=${"a".repeat(64)}`)); + expect(parsed.ok).toBe(true); + if (parsed.ok) { + expect(parsed.credential).toEqual({ + kind: "ticket", + ticketData: '{"exp":1,"fid":"f","uid":"u","uname":"n"}', + hmacHex: "a".repeat(64), + }); + expect(parsed.fileUrl).toBeUndefined(); + } + }); + + it("มีทั้ง t และ ticket (แม้เหลือ HmacSha256 ตัวเดียว) = ok:false (fail closed)", () => { + expect(parseSsoLinkParams(url(`t=a.b.c&ticket_data=%7B%7D&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false); + expect(parseSsoLinkParams(url(`t=a.b.c&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false); + }); + + it("ticket ครึ่งเดียว / ไม่มีชุดไหนเลย = ok:false", () => { + expect(parseSsoLinkParams(url("ticket_data=%7B%7D")).ok).toBe(false); + expect(parseSsoLinkParams(url(`HmacSha256=${"a".repeat(64)}`)).ok).toBe(false); + expect(parseSsoLinkParams(url("fileUrl=https%3A%2F%2Fx")).ok).toBe(false); + expect(parseSsoLinkParams(url("")).ok).toBe(false); + }); + + it("ค่ายาวเกิน cap = ok:false", () => { + expect(parseSsoLinkParams(url(`t=${"a".repeat(8193)}`)).ok).toBe(false); + expect(parseSsoLinkParams(url(`ticket_data=${"a".repeat(2049)}&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false); + expect(parseSsoLinkParams(url(`t=x.y.z&fileUrl=${"a".repeat(2049)}`)).ok).toBe(false); + }); +}); + +describe("parseSessionExchangeBody (POST /api/note/auth/session)", () => { + it("t เดี่ยว + fileUrl/typeId ผ่าน — ได้ credential ทาง jwt", () => { + const parsed = parseSessionExchangeBody({ + t: "a.b.c", + fileUrl: "https://f.example/a.pdf", + typeId: "T12", + }); + expect(parsed).toEqual({ + ok: true, + credential: { kind: "jwt", token: "a.b.c" }, + fileUrl: "https://f.example/a.pdf", + typeId: "T12", + }); + }); + + it("ticket ครบสองค่า = ทาง ticket · ครึ่งเดียว/ไม่มีเลย = 400", () => { + const ticket = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" }); + expect(parseSessionExchangeBody({ ticket_data: ticket, HmacSha256: "a".repeat(64) })).toEqual({ + ok: true, + credential: { kind: "ticket", ticketData: ticket, hmacHex: "a".repeat(64) }, + }); + expect(parseSessionExchangeBody({ ticket_data: ticket }).ok).toBe(false); + expect(parseSessionExchangeBody({ HmacSha256: "a".repeat(64) }).ok).toBe(false); + expect(parseSessionExchangeBody({}).ok).toBe(false); + }); + + it("สอง credential พร้อมกัน = 400 (D12) · field อื่น/ผิด type = 400 · cap ยาว = 400", () => { + expect(parseSessionExchangeBody({ t: "a.b.c", HmacSha256: "a".repeat(64) }).ok).toBe(false); + expect(parseSessionExchangeBody({ t: "a.b.c", oops: 1 }).ok).toBe(false); + expect(parseSessionExchangeBody({ t: 123 }).ok).toBe(false); + expect(parseSessionExchangeBody({ t: "a".repeat(8193) }).ok).toBe(false); + expect(parseSessionExchangeBody({ t: "a.b.c", fileUrl: "a".repeat(2049) }).ok).toBe(false); + expect(parseSessionExchangeBody(null).ok).toBe(false); + expect(parseSessionExchangeBody("text").ok).toBe(false); + expect(parseSessionExchangeBody([1, 2]).ok).toBe(false); + }); +}); + +describe("parseLoginTokenBody (mint route §3.1)", () => { + it("ครบ 3 field = ได้ claims + ttl default 300 · ttl ในช่วงใช้ค่าที่ส่ง", () => { + const full = parseLoginTokenBody({ userid: "123", username: "สรวิชญ์", fid: "F1" }); + expect(full).toEqual({ ok: true, claims: { userid: "123", username: "สรวิชญ์", fid: "F1" }, ttlSeconds: 300 }); + const withTtl = parseLoginTokenBody({ userid: "123", username: "n", fid: "F1", ttl_seconds: 60 }); + expect(withTtl).toEqual({ ok: true, claims: { userid: "123", username: "n", fid: "F1" }, ttlSeconds: 60 }); + }); + + it("ttl_seconds นอกช่วง 60–600 / ไม่ใช่ integer = 400", () => { + for (const bad of [59, 601, 0, -1, 300.5, "300", null]) { + expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", ttl_seconds: bad }).ok).toBe(false); + } + }); + + it("ส่ง purpose หรือ field อื่นมา = 400 (login-token เอกสิทธิ์ — D4)", () => { + expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", purpose: "docver" }).ok).toBe(false); + expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", template: "T" }).ok).toBe(false); + }); + + it("ไม่ใช่ object / ขาด field / identity ว่าง = 400", () => { + expect(parseLoginTokenBody(null).ok).toBe(false); + expect(parseLoginTokenBody("text").ok).toBe(false); + expect(parseLoginTokenBody({ username: "n", fid: "F" }).ok).toBe(false); + expect(parseLoginTokenBody({ userid: " ", username: "n", fid: "F" }).ok).toBe(false); + expect(parseLoginTokenBody({ userid: "1", username: "n", fid: 3 }).ok).toBe(false); + }); + + it("identity มี & = + % = 400 — อักขระที่ตีความต่างกันตาม context ง่าย (2026-10-30)", () => { + for (const bad of ["a&b", "a=b", "สมหญิง+สุข", "a%41"]) { + expect(parseLoginTokenBody({ userid: bad, username: "n", fid: "F" }).ok, bad).toBe(false); + expect(parseLoginTokenBody({ userid: "1", username: bad, fid: "F" }).ok, bad).toBe(false); + } + // อักขระอื่นที่ใช้ได้ปกติ (ไทย/ขีด/เว้นวรรค) = ผ่าน + expect(parseLoginTokenBody({ userid: "1234-5", username: "สรวิชญ์ ญาติ", fid: "F" }).ok).toBe(true); + }); +}); + +describe("ssoBanner (D8/D15)", () => { + it("ครบ 4 ค่ามีข้อความ · ค่าอื่น/undefined = null (ไม่แสดง)", () => { + for (const reason of ["expired", "invalid", "error", "required"]) { + const banner = ssoBanner(reason); + expect(banner).not.toBeNull(); + expect(["warning", "error"]).toContain(banner!.intent); + expect(banner!.text.length).toBeGreaterThan(0); + } + expect(ssoBanner("weird")).toBeNull(); + expect(ssoBanner(undefined)).toBeNull(); + }); +}); + +describe("parseDevLinkBody / devMintEnabled (dev-link)", () => { + it("ครบ field = ได้ claims + ttl ตามเกณฑ์เดียวกับ mint · mode default jwt · field ส่วนเสริมผ่านเข้ามาได้", () => { + const full = parseDevLinkBody({ + userid: "oom", + username: "สรวิชญ์", + fid: "F123", + ttl_seconds: 120, + fileUrl: "/samples/a.pdf", + typeId: "T9", + mode: "ticket", + }); + expect(full).toEqual({ + ok: true, + claims: { userid: "oom", username: "สรวิชญ์", fid: "F123" }, + ttlSeconds: 120, + fileUrl: "/samples/a.pdf", + typeId: "T9", + mode: "ticket", + }); + const minimal = parseDevLinkBody({ userid: "u", username: "n", fid: "F" }); + expect(minimal.ok).toBe(true); + if (minimal.ok) { + expect(minimal.mode).toBe("jwt"); + expect(minimal.fileUrl).toBeUndefined(); + expect(minimal.ttlSeconds).toBe(300); + } + }); + + it("identity/ttl ผิดเกณฑ์ = 400 ข้อความเดียวกับ parseLoginTokenBody · mode/fileUrl/typeId ผิดรูป = 400", () => { + expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", ttl_seconds: 601 }).ok).toBe(false); + expect(parseDevLinkBody({ userid: " ", username: "n", fid: "F" }).ok).toBe(false); + expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", mode: "basic" }).ok).toBe(false); + expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", fileUrl: "" }).ok).toBe(false); + expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", oops: 1 }).ok).toBe(false); + }); + + it("devMintEnabled: ต้องเป็น 1 เป๊ะและไม่ใช่ production — ค่าอื่น/production = false", () => { + const orig = process.env.NODE_ENV; + const writable = process.env as Record; + process.env.NOTE_SSO_DEV_MINT = "1"; + writable.NODE_ENV = "development"; + expect(devMintEnabled()).toBe(true); + writable.NODE_ENV = "production"; + expect(devMintEnabled()).toBe(false); + writable.NODE_ENV = orig ?? "test"; + for (const value of [undefined, "", "0", "true", " yes "]) { + if (value === undefined) delete process.env.NOTE_SSO_DEV_MINT; + else process.env.NOTE_SSO_DEV_MINT = value; + expect(devMintEnabled(), String(value)).toBe(false); + } + delete process.env.NOTE_SSO_DEV_MINT; + }); +}); diff --git a/tests/utils/docver-ticket.test.ts b/tests/utils/docver-ticket.test.ts index bdde305..00084a3 100644 --- a/tests/utils/docver-ticket.test.ts +++ b/tests/utils/docver-ticket.test.ts @@ -81,6 +81,13 @@ describe("parseDocverTicket", () => { expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false); expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false); }); + + it("uid/uname มี & = + % = ไม่ผ่าน (round-trip ผ่าน cookie oacCase ไม่ได้ — 2026-10-30)", () => { + for (const bad of ["a&b", "a=b", "a+b", "a%41"]) { + expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: bad, uname: "n" })).ok, bad).toBe(false); + expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: bad })).ok, bad).toBe(false); + } + }); }); describe("isTicketExpired", () => { diff --git a/tests/utils/external-file.test.ts b/tests/utils/external-file.test.ts deleted file mode 100644 index eb47391..0000000 --- a/tests/utils/external-file.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { sanitizeNextPath } from "@/utils/external-file"; - -// N8 D5 — path วนกลับหลัง login ต้องเป็น path ใน app เท่านั้น (กัน open-redirect) -describe("sanitizeNextPath", () => { - it("path ใน app (มี query ก็ได้) ผ่านตรง ๆ", () => { - expect(sanitizeNextPath("/")).toBe("/"); - expect(sanitizeNextPath("/annotate")).toBe("/annotate"); - expect(sanitizeNextPath("/annotate/abc-123")).toBe("/annotate/abc-123"); - expect(sanitizeNextPath("/?fileUrl=https://x&fileId=1")).toBe( - "/?fileUrl=https://x&fileId=1", - ); - }); - - it("absolute URL / protocol-relative / backslash → null กัน open-redirect", () => { - expect(sanitizeNextPath("//evil.com")).toBeNull(); - expect(sanitizeNextPath("/\\evil.com")).toBeNull(); // browser ตีความ \ เป็น / - expect(sanitizeNextPath("https://evil.com")).toBeNull(); - expect(sanitizeNextPath("http://evil.com/path")).toBeNull(); - expect(sanitizeNextPath("/\\//evil.com")).toBeNull(); - }); - - it("ค่าว่าง/ไม่ขึ้นต้น / ขาด ๆ เกิน ๆ → null", () => { - expect(sanitizeNextPath("")).toBeNull(); - expect(sanitizeNextPath("annotate")).toBeNull(); // ไม่มี / นำหน้า - expect(sanitizeNextPath(" /annotate")).toBeNull(); // space นำหน้าไม่ผ่าน startsWith - }); -}); diff --git a/tests/utils/user.test.ts b/tests/utils/user.test.ts deleted file mode 100644 index 3da2828..0000000 --- a/tests/utils/user.test.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - buildOacCaseCookie, - OAC_CASE_COOKIE, - parseOacCase, -} from "@/utils/user"; - -/** ตัวอย่าง cookie จริงจากระบบ e-Court (เก็บใน test เพื่อล็อก format ของจริง) */ -const REAL_OAC_CASE = - "u_userid=235821&u_userlogin=tu_2&u_courtid=4&u_password=&u_AdminStatus=&u_perCardNo=5000000000006&u_perCardNoEncript=1800650010005002003&u_userType=4&u_CidAuthen=NNNENNNNNNNNNNNNNNNN&u_hSecId=NNNNNNNNNNNNNNNNNNNN&u_username=&u_courtName=&u_AuditID=1995227&u_PsGroupNum=1&u_PsGroup=11&u_PsGroupName="; - -describe("parseOacCase", () => { - it("cookie e-Court จริง → identity จาก u_userid/u_userlogin", () => { - expect(parseOacCase(REAL_OAC_CASE)).toEqual({ - userId: "235821", - userName: "tu_2", - }); - }); - - it("ค่าจาก buildOacCaseCookie อ่านกลับได้ (round-trip ค่า mock login จริง user1)", () => { - expect(parseOacCase(buildOacCaseCookie("user1", "user1"))).toEqual({ - userId: "user1", - userName: "user1", - }); - }); - - it("ค่าเสีย/ว่าง/ขาด field → null (ถือว่ายังไม่เข้าสู่ระบบ)", () => { - expect(parseOacCase(null)).toBeNull(); - expect(parseOacCase(undefined)).toBeNull(); - expect(parseOacCase("")).toBeNull(); - expect(parseOacCase("garbage")).toBeNull(); - expect(parseOacCase("u_userlogin=user_x")).toBeNull(); // ขาด u_userid - expect(parseOacCase("u_userid=123&u_userlogin=")).toBeNull(); // u_userlogin ว่าง - }); - - it("ค่ารอบด้วย space ถูก trim", () => { - expect(parseOacCase("u_userid= 123 &u_userlogin= tu_2 ")).toEqual({ - userId: "123", - userName: "tu_2", - }); - }); - - it("ชื่อ cookie ตรงกับระบบ e-Court", () => { - expect(OAC_CASE_COOKIE).toBe("oacCase"); - }); -}); - -describe("buildOacCaseCookie", () => { - it("แทนเฉพาะ u_userid/u_userlogin — ฟิลด์อื่นคงค่าตัวอย่างจริงครบทุก field", () => { - const fields = Object.fromEntries(new URLSearchParams(buildOacCaseCookie("xxxxxx", "user_x"))); - expect(fields["u_userid"]).toBe("xxxxxx"); - expect(fields["u_userlogin"]).toBe("user_x"); - expect(fields["u_courtid"]).toBe("4"); - expect(fields["u_perCardNo"]).toBe("5000000000006"); - expect(fields["u_AuditID"]).toBe("1995227"); - expect(Object.keys(fields)).toHaveLength(16); - }); -}); diff --git a/utils/docver-ticket.ts b/utils/docver-ticket.ts index 14130c6..0ebb67b 100644 --- a/utils/docver-ticket.ts +++ b/utils/docver-ticket.ts @@ -38,6 +38,14 @@ const TICKET_LIMITS = { /** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */ const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const; +/** อักขระที่ uid/uname ใช้ไม่ได้ — & = + % เป็นชุดที่ตีความต่างกันตาม context + * ง่าย (query string/URL decode/ฟอร์ม) และค่า identity นี้ไหลลง DB เป็น + * user_id/user_name ของ annotation/bookmark (แสดง UI + เงื่อนไขค้นหา) — + * เคยทำ identity เพี้ยนในรอบ cookie (payload ถูก parse ผ่าน URLSearchParams — + * ถอดแล้ว 2026-10-30) จึงกันไว้ตั้งแต่แกะ ticket (เกณฑ์เดียวกับ + * parseLoginTokenBody ของ lib/note-auth.ts) */ +const UNSAFE_IDENTITY_RE = /[&=+%]/; + export type DocverTicketParseResult = | { ok: true; ticket: DocverTicket } | { ok: false; error: string }; @@ -82,8 +90,14 @@ export function parseDocverTicket(raw: string): DocverTicketParseResult { const uid = field("uid"); if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" }; + if (UNSAFE_IDENTITY_RE.test(uid)) { + return { ok: false, error: "ticket_data ของ uid มีอักขระ & = + % ที่รองรับไม่ได้" }; + } const uname = field("uname"); if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" }; + if (UNSAFE_IDENTITY_RE.test(uname)) { + return { ok: false, error: "ticket_data ของ uname มีอักขระ & = + % ที่รองรับไม่ได้" }; + } const fid = field("fid"); if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" }; diff --git a/utils/external-file.ts b/utils/external-file.ts index f5f86dd..6da410a 100644 --- a/utils/external-file.ts +++ b/utils/external-file.ts @@ -82,21 +82,12 @@ export function externalIdFor(fileId: string | undefined, url: URL): string { return id ? `ecourt:${id}` : `url:${url.href}`; } -/** path วนกลับหลัง login (N8 D5) — ต้องขึ้นต้น "/" และไม่ใช่ "//" หรือ "/\" - * (กัน open-redirect: browser ตีความ "//evil.com" และ "/\evil.com" เป็น absolute - * URL ไป host อื่น) — ค่า next มาจาก query ที่ใครก็แก้ได้ ต้อง sanitize ก่อนใช้เสมอ; - * ไม่ผ่านคืน null ให้ caller ใช้ "/" */ -export function sanitizeNextPath(s: string): string | null { - if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) return null; - return s; -} - -/** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — single source ใช้ร่วมทั้ง N8 - * `registerExternalFile` (ลิงก์ e-Court) และ N4 `uploadFile` (ช่องกลุ่มของฟอร์ม - * อัปโหลด): trim → ว่าง = ไม่มีกลุ่ม (null), cap 200 ตัวอักษร (เทียบ title) - * ค่าที่ไม่ใช่ string (เช่น File จาก FormData) = null (responsive audit 2026-09-24: - * เดิมเป็นสองสำเนา mirroring กัน — normGroup ของ external-import + normTypeId - * ของ files.ts — เสี่ยง drift) */ +/** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — ใช้ที่เดียวคือ N8 + * `registerExternalFile` (ลิงก์ e-Court — ทั้ง insert ครั้งแรกและ update เมื่อลิงก์ + * รอบใหม่ส่ง typeId มา): trim → ว่าง = ไม่มีกลุ่ม (null), cap 200 ตัวอักษร + * (เทียบ title) ค่าที่ไม่ใช่ string (เช่น File จาก FormData) = null + * (responsive audit 2026-09-24: เดิมเป็นสองสำเนา mirroring กัน — normGroup ของ + * external-import + normTypeId ของ files.ts — เสี่ยง drift) */ export function normDocumentGroup(raw: unknown): string | null { if (typeof raw !== "string") return null; const v = raw.trim().slice(0, 200); diff --git a/utils/user.ts b/utils/user.ts deleted file mode 100644 index a173b20..0000000 --- a/utils/user.ts +++ /dev/null @@ -1,53 +0,0 @@ -// identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase" -// (userId = u_userid, userName = u_userlogin) แทน localStorage เดิม -// ไฟล์นี้เป็น pure module — middleware (edge) และ client import ร่วมได้ ห้ามใส่ next/headers - -export type OacUser = { userId: string; userName: string }; - -export const OAC_CASE_COOKIE = "oacCase"; - -/** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server) - * ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */ -export const MOCK_USERS: readonly string[] = [ - "user1", - "user2", - "user3", - "user4", - "user5", -]; - -/** แยกค่า query string ของ cookie oacCase → identity - * ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null) - * หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว - * จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */ -export function parseOacCase(raw: string | null | undefined): OacUser | null { - if (!raw) return null; - const params = new URLSearchParams(raw); - const userId = params.get("u_userid")?.trim(); - const userName = params.get("u_userlogin")?.trim(); - if (!userId || !userName) return null; - return { userId, userName }; -} - -/** ค่า cookie oacCase ตาม format จริงของ e-Court (ฟิลด์อื่นคงค่าจากตัวอย่างจริง) - * mock login แทนเฉพาะ u_userid/u_userlogin */ -export function buildOacCaseCookie(userId: string, userLogin: string): string { - return [ - `u_userid=${userId}`, - `u_userlogin=${userLogin}`, - "u_courtid=4", - "u_password=", - "u_AdminStatus=", - "u_perCardNo=5000000000006", - "u_perCardNoEncript=1800650010005002003", - "u_userType=4", - "u_CidAuthen=NNNENNNNNNNNNNNNNNNN", - "u_hSecId=NNNNNNNNNNNNNNNNNNNN", - "u_username=", - "u_courtName=", - "u_AuditID=1995227", - "u_PsGroupNum=1", - "u_PsGroup=11", - "u_PsGroupName=", - ].join("&"); -}