eCourt-note/docs/PLAN-ECOURT.md

26 KiB
Raw Permalink Blame History

แผน N8 — เปิดเอกสารจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= (metadata-only + ดึงไฟล์สด)

อ้างอิง: PLAN-FEAT.md §7 N8 · สร้าง 2026-09-18 ตามข้อตกลงกับผู้ใช้ (สัญญาลิงก์ fileUrl+fileId, ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ, id เปลี่ยน = ไฟล์ใหม่) · อัปเดต 2026-09-22: เพิ่ม param typeId จัดกลุ่มเอกสารที่เกี่ยวเนื่องกัน (ส่งต่อให้ N9 docs/PLAN-DOCSET.md) + แก้สถานะการ implement ตามที่ตรวจพบจริง (ด้านล่าง)

Context

ระบบ e-Court (admcasedey.admincourt.go.th) จะลิงก์เข้ามาที่ app รูปแบบ:

https://ecourt.chamomind.com/docedit/docedit/note?fileUrl=<URL-encoded URL ไฟล์ PDF>&fileId=<id เอกสาร e-Court>&typeId=<รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน>

ตัวอย่าง URL ไฟล์: https://admcasedey.admincourt.go.th/admCaseEfiling/sysDocument/Attach/Temp/9/tmp_efile_tu_2_64373_0_2026-09-01_13-55-31_s.pdf

ก่อนหน้านี้ไม่มีหน้าไหนอ่าน searchParams เลย (ตรวจแล้ว) — หน้า / เมิน query ทิ้งจึงดู "ว่างเปล่า" และสถาปัตยกรรมเดิมรองรับเฉพาะไฟล์ในเครื่อง (files.pdf_url = path ใน public/ + path guard ใน lib/server-pdf.ts)

การตัดสินใจ (ยืนยันกับผู้ใช้แล้ว 2026-09-18)

  1. สัญญาลิงก์ = fileUrl จำเป็น + fileId แนะนำ — มี fileId ใช้เป็น dedupe key (external_id = "ecourt:<fileId>"), ไม่มี fallback เป็น "url:<href>" · เพิ่ม 2026-09-22: typeId แนะนำ (ผู้ใช้เลือกชื่อนี้แทน caseId) — รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน (เช่น คดีเดียวกัน) เอกสารที่ e-Court ส่ง typeId เดียวกัน = กลุ่มเดียวกัน (แสดงผลใน N9)
  2. ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ — ลง DB แค่ metadata (title จาก basename ของ URL + external_id + ตัว URL เก็บใน pdf_url) ไม่ดาวน์โหลดเขียนลง public/uploads/ ตอนเปิดดู/export ให้ server ดึงจาก e-Court สด ๆ ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว)
  3. หลักจัดการเวอร์ชัน: id เปลี่ยน = ไฟล์ใหม่
สถานการณ์ ผลลัพธ์
fileId เดิม + URL เปลี่ยน (Temp link อายุใหม่) row เดิม — ได้ annotation/bookmark เดิมครบ + pdf_url ถูก refresh เป็น URL ใหม่อัตโนมัติ
fileId เปลี่ยน (e-Court ออก id ใหม่ต่อเวอร์ชัน) row ใหม่ = เอกสารใหม่เริ่ม blank ไม่สืบทอดของเวอร์ชันก่อน (ไฟล์เก่ายังอยู่ในรายการ เปิดย้อนดูได้) — กัน annotation เก่าไปวาดตกตำแหน่งคลาดบนเนื้อหาที่ขยับ (พิกัดเป็น % ของหน้า)

Design

D1 — Entry point + สร้าง/หา row

  • app/page.tsx รับ searchParams (Next 14 = plain object ค่า string | string[] — helper firstParam): มี fileUrl → เรียก registerExternalFile → สำเร็จ redirect("/annotate/<id>") (เรียกนอก try/catch เสมอ — redirect() โยน NEXT_REDIRECT) / ไม่สำเร็จ render กล่อง error ไทย role="alert" เหนือ section "เพิ่มเอกสาร" (หน้ารายการยังแสดง ไม่ blank)
  • lib/external-import.ts — server module ธรรมดา ไม่ใช่ "use server" action (เรียกจาก server component; ไม่เปิดเป็น endpoint ให้ client ยิง): registerExternalFile(rawFileUrl, rawFileId?)
    1. validateExternalFileUrl → fail คืนข้อความไทย
    2. externalIdFor(fileId, url) → select ... where external_id = key limit 1 → เจอ: ถ้า pdf_url ≠ URL ใหม่ → update files set pdf_url (refresh Temp link) + ถ้าลิงก์ครั้งนี้มี typeId → update files set type_id ตามค่าล่าสุดด้วย (e-Court เปลี่ยน typeId = ย้ายกลุ่มตามค่าล่าสุด; ลิงก์ไม่ส่ง typeId มา = คงค่าเดิม ห้ามเคลียร์) แล้วคืน id เดิม
    3. ไม่เจอ → insert { id: randomUUID(), title: titleFromExternalUrl(url), pdfUrl: url.href, externalId, typeId? } — typeId normalize: trim → ว่าง = ไม่เก็บ (NULL), cap 200 ตัวอักษร (เทียบ title)
    4. catch postgres 23505 (insert ชน unique จาก 2 แท็บเปิดพร้อมกันครั้งแรก) → select ใหม่คืน row เดิม (ไม่มีไฟล์บนดิสก์ = ไม่มี cleanup)
  • ไม่ revalidatePath — เรียกระหว่าง render ของ server component ไม่ใช่ use case ที่รองรับ และไม่จำเป็น (/ เป็น force-dynamic + redirect ออกทันที) ไม่มี SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE — เหมือน N4)
  • Schema: files เพิ่ม external_id text NULL UNIQUE + type_id text NULL (N9) + index files_type_id_idx — unique บน nullable ยอมหลาย NULL → row seed/อัปโหลดเดิม (external_id = NULL) ไม่ชนกัน

D2 — Validation + allowlist (pure: utils/external-file.ts)

  • externalHosts() — default ["admincourt.go.th"] (ครอบทั้ง admcasedey. และ judgement-api.), override ด้วย env EXTERNAL_FILE_HOSTS (comma-separated) — อ่านตอน call ไม่ใช่ module load เพื่อ test ได้
  • isAllowedExternalHost(host) — suffix match: host === entry || host.endsWith("." + entry) (กัน admincourt.go.th.evil.com และ xadmincourt.go.th)
  • validateExternalFileUrl(raw) — new URL parse ได้ + https: เท่านั้น + ไม่มี user:pass@ + host ผ่าน allowlist → ไม่ผ่านคืน error code (invalid_url / not_https / has_credentials / host_not_allowed)
  • titleFromExternalUrl(url, fallbackId?) — basename ของ pathname แล้ว decodeURIComponent (try/catch URIError) ตัดนามสกุล cap 200 ตัวอักษร; URL ไม่มีชื่อไฟล์ (path ลงท้าย /) → fallback fallbackId หรือ "เอกสารจาก e-Court"
  • externalIdFor(fileId?, url) — "ecourt:" + fileId (trim) หรือ "url:" + url.href
  • sanitizeNextPath(s) — ใช้ได้เฉพาะขึ้นต้น / และไม่ใช่ // / /\ (กัน open-redirect ตอน login วนกลับ) — ไม่ผ่านคืน null
  • isHttpUrl(s) — absolute http(s):// หรือไม่ (ใช้ทั้งฝั่ง server เช็ค pdf_url เป็นรีโมต และฝั่ง client เลือกทางโหลดรูป)

D3 — ดึงไฟล์รีโมต (server-only: lib/remote-file.ts)

  • isRemoteSource(pdfUrl) = isHttpUrl(pdfUrl)
  • fetchSourceBytes(url) — validate ซ้ำอีกชั้น (defense in depth) → fetch(url, { redirect: "follow", signal: AbortSignal.timeout(60_000) }) → ไม่ใช่ 2xx = RemoteFileError → re-validate res.url หลัง redirect ว่า host ยังอยู่ใน allowlist (กัน redirect chain เด้งไป host อื่น) → pre-check Content-Length + นับ byte ระหว่าง stream ด้วย ReadableStream reader ถ้าเกิน 100MB (เท่า limit อัปโหลด N4) ยกเลิกแล้ว error → คืน Buffer
  • จุดใช้ 2 ที่ (pattern เดียวกัน เปลี่ยนเฉพาะแหล่ง bytes ก่อนเข้า pipeline เดิม):
    • app/api/pdf/route.ts — isRemoteSource(file.pdfUrl) ? await fetchSourceBytes(...) : readPublicPdf(file.pdfUrl) แล้ว branch รูป (imageContentTypeFromUrl)/PDF (fixPdfActualText) ทำงานต่อเหมือนเดิม
    • app/api/annotations/export/route.ts (F8) — จุดเดียวกันก่อน imageDocFromBytes/PDFDocument.load
  • export CSV (F16) / bookmarks JSON (N7) / หน้าสรุป (F15) ไม่อ่าน bytes อยู่แล้ว → ไม่แตะ

D4 — Viewer กับ absolute URL ใน pdf_url

  • utils/base-path.ts — withBasePath คืนค่าเดิมเมื่อเป็น absolute ^https?:// (ไม่งั้น prefix ทับจนพัง: /docedit/notehttps://...)
  • PDF branch ของ viewer โหลดผ่าน /api/pdf?fileId= อยู่แล้ว (same-origin — ไม่กระทบ); fallback fetch(withBasePath(file.pdfUrl)) ทำงานผ่าน bypass (อาจโดน CORS ของปลายทางแต่เป็นแค่ fallback)
  • รูปภาพ (N3) โหลด static pdfUrl ตรง ๆ — ไฟล์รีโมตต้องเปลี่ยนไปโหลดผ่าน withBasePath("/api/pdf?fileId=" + file.id) แทน (route คืน bytes รูป + content-type อยู่แล้ว) — ใช้ isHttpUrl แยกทาง
  • middleware.ts เดิม url.search = "" ตอน redirect /login → query หายทั้งหมด — แก้: เก็บ next = pathname + search ก่อนล้าง แล้ว url.searchParams.set("next", next) (เฉพาะ next !== "/")
  • app/login/page.tsx รับ searchParams → sanitizeNextPath → if (user) redirect(next ?? "/") + ส่ง prop next ให้ LoginForm → router.replace(next ?? "/") (middleware เห็น path ตัด basePath แล้ว, NextURL/router ใส่ basePath คืนเอง — invariant #12)

Edge cases

  • URL ไม่มีนามสกุลไฟล์ → fileKindFromUrl default "pdf" (pdf.js ลองเปิด — เปิดไม่ได้ค่อยแสดง load error ตามข้อความเดิมของ viewer)
  • เนื้อไฟล์เพี้ยน/เป็น HTML error page — ไม่มีการตรวจ magic bytesตอน register (ไม่ได้ดาวน์โหลด): PDF → fixPdfActualText/pdf.js fail → error ข้อความเดิมของ viewer/route, รูป → img.decode()/utif2 fail ฝั่ง client — ยอมรับได้เพราะแหล่งเชื่อถือได้ (allowlist)
  • Temp link หมดอายุ — เปิด viewer ไม่ได้ (annotation ยังอยู่ครบ) → เข้าใหม่ผ่านลิงก์ e-Court แล้ว pdf_url ถูก refresh กลับมาใช้ได้
  • e-Court แก้เนื้อไฟล์ (URL เดิม/id เดิม) — ได้เนื้อล่าสุดเสมอ; ถ้าเลย์เอาต์เลื่อน annotation เก่า (พิกัด %) อาจคลาดตำแหน่ง — ยอมรับตามท่า "ไม่เก็บสำเนา"
  • Request คู่ขนาน URL/id เดียวกันครั้งแรก — unique external_id + catch 23505 → select ใหม่คืน row เดิม
  • e-Court ไม่ encode ค่า query — ค่าที่มี & ข้างใน URL จะถูกตัด (parse ตาม standard); ตัวอย่างลิงก์จริงใช้ ?/- ข้างใน path ไม่มี & จึงผ่านแบบไม่ encode ได้อยู่แล้ว — แนะนำฝั่ง e-Court encode เสมอ
  • หน้ารายการ / — ไฟล์นำเข้าแสดงเหมือนไฟล์อัปโหลด (badge ชนิดจาก extension ของ URL), กด Annotate = เปิดผ่าน route ดึงสดเหมือนกัน
  • ลิงก์ครั้งหลังไม่มี typeId — ไฟล์คงกลุ่มเดิม (update เฉพาะเมื่อลิงก์ส่งมา) — ย้ายกลุ่มเกิดเฉพาะเมื่อ e-Court ส่ง typeId คนละค่ามากับลิงก์รอบใหม่

รอดำเนินการ (plan-reviewer 2026-09-18 — ผู้ใช้สั่งยังไม่ต้องแก้โค้ด บันทึกไว้ในแผนก่อน)

อัปเดต 2026-09-22 (หลัง implement รอบใหม่): ข้อ 1, 2 และ 4 ทำแล้ว — เหลือข้อ 3 เป็นความเสี่ยงที่รับไว้

  1. hardening — re-check หลัง redirect ไม่ครบชุด: lib/remote-file.ts ตรวจ res.url หลัง redirect: "follow" แค่ hostname (isAllowedExternalHost) ยังไม่เช็ค protocol — ถ้า host ใน allowlist redirect ไป http:// (host เดิม) จะได้ bytes ผ่าน plaintext ทั้งที่ต้นทางบังคับ https → แก้โดยรัน validateExternalFileUrl(res.url) เต็มชุดแทนการเช็ค hostname เดี่ยว
  2. เบา — else-branch ไม่มี guard ไฟล์เปล่า: ใน fetchSourceBytes เมื่อ res.body เป็น null (เช่น HTTP 204) จะคืน Buffer เปล่าโดยไม่ผ่าน check total === 0 ของ path หลัก → bytes เปล่าไหลไป pipeline แล้วพังเป็น error กลาง ๆ ไม่ใช่ข้อความไทย
  3. known risk — /api/pdf + /api/annotations/export เป็น proxy แบบไม่ต้อง login: routes ถูก middleware ยกเว้น (api) อยู่แล้ว — ใครไม่มี session แต่รู้ fileId (uuid จริงใน DB) ดึง bytes เอกสารคดีผ่าน server เราได้ (ไม่ใช่ SSRF อิสระ: URL มาจาก DB + allowlist) — auth จริงเป็น out of scope ของ N8 อยู่แล้ว แต่ให้ตั้งชื่อไว้ชัด ๆ ว่าเป็นความเสี่ยงที่รับไว้
  4. เอกสาร — invariant #4 ใน CLAUDE.md ยังไม่มี carve-out: registerExternalFile เขียน DB นอก Server Action + ไม่ revalidatePath (เหตุผล: เรียกระหว่าง render ของ server component + redirect ออกทันที + หน้า / force-dynamic — สมเหตุสมผล) — เหลือ sync ตัวอักษรของ invariant ให้ตรงกับ data-layer.md/แผนนี้

สถานะการ implement (ตรวจซ้ำ 2026-09-22 — ยกเลิกบันทึกเดิม): เอกสารฉบับก่อนของแผนนี้อ้างว่า implement และทดสอบผ่านแล้ว (tsc ผ่าน, vitest 153/153, ทดสอบ dev server ครบทุกเคส) — ตรวจสอบจริงพบว่าโค้ดไม่มีอยู่ใน repo/DB เลย: ไม่มี lib/external-import.ts / utils/external-file.ts / lib/remote-file.ts ใน branch ใด (ตรวจ git log --all -S "external-import" ว่างเปล่า), db/schema.ts และ DB จริง (\d files) ไม่มี column external_id, vitest ปัจจุบัน 139 tests — ผู้ใช้ยืนยัน (2026-09-22) ว่ายังไม่เคย implement / โค้ดหาย → ถือว่าแผนนี้ยังไม่ถูก implement ทั้งหมด ต้องทำใหม่ตั้งแต่ต้นตามดีไซน์ด้านบน และพับ 4 ข้อรอดำเนินการด้านบนเข้าไปทำพร้อมกันตอน implement

ผลตรวจดีไซน์ที่ยังใช้ได้จากรอบ 2026-09-18: invariant #1 (ไม่เขียนไฟล์), #12 (withBasePath + bypass), SSRF guard หลัก (allowlist/https/credentials/size/timeout), สถานะ 23505, middleware matcher "/" (root ขอ basePath ทะลุ middleware จริง — แก้ถูกจุด ไม่มี path เดิมหลุด/โดนเพิ่ม), sanitizeNextPath กัน open-redirect

Implement รอบใหม่ (2026-09-22 บ่าย — ตามคำขอผู้ใช้กลางงาน "กดแล้วไปหน้า ?fileUrl=&fileId=&typeId"): ลงมือจริงแล้วใน branch นี้:

  • D1 ✅ lib/external-import.ts (registerExternalFile — dedupe external_id + refresh pdf_url/type_id + catch 23505) + schema files.external_id (UNIQUE nullable, migration drizzle/0002/0003 idempotent) + entry point: หน้า / อ่าน ?fileUrl=&fileId=&typeId= แล้ว redirect("/annotate/<id>") + กล่อง error ไทย role="alert"
  • D2 ✅ utils/external-file.ts — externalHosts (env EXTERNAL_FILE_HOSTS อ่านตอน call) / suffix-match allowlist / https+credentials / titleFromExternalUrl / externalIdFor / isHttpUrl
  • D3 ✅ lib/remote-file.ts + branch รีโมตใน /api/pdf และ /api/annotations/export — รวม hardening ข้อ 1–2 ของ plan-reviewer (re-validate ชุดเต็มหลัง redirect + guard body เปล่า/0 ไบต์)
  • D4 ✅ withBasePath ผ่าน absolute http(s) ไปเฉย ๆ + รูปรีโมตของ viewer โหลดผ่าน /api/pdf?fileId=
  • เกินแผน (ตามคำขอผู้ใช้): registerExternalFile รับ path ในเครื่อง (/samples/… /uploads/… — decode + whitelist + path guard ว่าไฟล์มีจริง) เพิ่มจาก https เพื่อทดสอบ grouping ใน dev + การ์ดจำลองลิงก์ components/EcourtLinkForm.tsx บนหน้ารายการ (กรอก 3 ช่อง กด "เปิดเอกสาร" แล้ว router.push("/annotate?fileUrl=&fileId=&typeId=") + แถบแสดงลิงก์เต็ม absolute ตามที่พิมพ์สด ๆ)
  • ปรับต่อเนื่อง (2026-09-22): ถอด column type_name ออกจาก files ตามคำขอผู้ใช้ — สัญญาลิงก์เหลือ typeId เดี่ยว แสดงผลด้วยรหัสกลุ่มโดยตรง (migration drizzle/0004 = DROP COLUMN IF EXISTS) · entry point ที่สอง /annotate?fileUrl=&fileId=&typeId= (app/annotate/page.tsx — การ์ด EcourtLinkForm พามาที่นี่ + แสดงลิงก์เต็มสด ๆ) · registerExternalFile เติม https:// ให้ URL ที่ไม่มี scheme · env EXTERNAL_FILE_HOSTS ใช้เพิ่มโฮสต์ทดสอบใน dev (ตอนนี้ .env มี rd.usda.gov, pdfobject.com)
  • D5 ✅ (2026-09-22 เย็น — ปิดท้ายแผน): login วนกลับพร้อม query — middleware.ts เก็บ next = pathname + search แนบไป /login (เฉพาะ next !== "/") · app/login/page.tsx รับ searchParams.next → sanitizeNextPath (utils/external-file.ts — ขึ้นต้น / เท่านั้น และไม่ใช่ ////\ กัน open-redirect, unit test tests/utils/external-file.test.ts) → มี session redirect(next ?? "/") + ส่ง prop next ที่ sanitize แล้วให้ LoginForm → router.replace(next ?? "/") · sync สเปกลง PLAN-FEAT.md §7 N8 แล้ว
  • Verify: tsc ผ่าน + Playwright ผ่านครบ (login → กรอกการ์ด → redirect เข้า /annotate/<uuid> → canvas render จริง / ลิงก์รอบสอง typeId ต่างค่าได้ id เดิม = dedupe+ย้ายกลุ่ม / https://evil.com = กล่อง error "ไม่อยู่ในรายการอนุญาต" / D5: ยังไม่ login เปิด /annotate?fileUrl=… → ได้ /login?next=<path+query ครบ> → login ผ่านแล้วกลับมาลงทะเบียนเข้า viewer + canvas render, session ครบ + เปิด /login?next=//evil.com → จบที่ / ของ app ไม่ออกนอกโฮสต์) + ทดสอบแล้วลบ row ทดสอบตาม external_id เป๊ะ
  • Gap-analysis รอบ 2 (plan-reviewer 2026-09-22 เย็น — หลัง D5 + sync แผน): D1–D5 ผ่านทุกข้อ · invariant #1/#4/#12 ไม่ถูกละเมิด · hardening ข้อ 1–2 และ 4 ยืนยันทำแล้ว ข้อ 3 ยังเป็น known risk ตามที่รับไว้ · แก้ตามรายงานแล้ว 2 ข้อ: (4) firstSearchParam ย้ายจาก lib/external-import.ts → utils/external-file.ts (pure — เดิม import จาก module ที่ลาก db/fs ตามเข้าหน้า login) และ (5) ตัวอักษร carve-out invariant #4 + รายการ lib/external-import.ts ในโครงสร้างไฟล์ของ CLAUDE.md ครบทั้ง entry หน้า / และ /annotate · บันทึกไว้พิจารณา (ยังไม่แก้): (1) app/annotate/page.tsx ไม่มี defense getSessionUser ซ้อน middleware เหมือนหน้า / และ viewer (ปลอดภัยตราบใดที่ matcher ของ middleware ยังจับ path นี้) (2) runtime error จาก DB ตอน registerExternalFile (connection ล่ม / rethrow หลัง 23505) ทะลุออกจาก render เป็น 500 ทั้งหน้า — โค้ดจัดการเฉพาะ validation fail (แยก catch ที่ไม่ใช่ NEXT_REDIRECT ได้) (3) validateLocalPath เช็ค .. บนค่า raw ก่อน decodeURIComponent (%2e%2e%2f ผ่านการเช็ค — ไม่ใช่ช่องโหว่ที่ใช้งานได้เพราะ path guard ของ resolvePublicPdfPath ดักทับอยู่ แต่ลำดับที่ถูกคือ decode ก่อนแล้วเช็คบนค่า decoded)

Out of scope ของ N8

  • auth/credential ต่อ e-Court (ถ้า admcasedey ต้องมี session จริง ต้องกลับมาเพิ่มการส่งต่อ token/cookie)
  • ระบบหลายเวอร์ชันต่อเอกสาร + คัดลอก/สืบทอด annotation ข้ามเวอร์ชัน (id เปลี่ยน = ไฟล์ใหม่ ตามตารางด้านบน)
  • หน้าจัดการ/ลบ/แก้ชื่อไฟล์นำเข้าใน UI, แคชสำเนาไฟล์รีโมตลงดิสก์, ไฟล์รีโมตจากโฮสต์นอก allowlist
  • หน้าจัดการกลุ่ม/แก้ typeId ด้วยมือใน UI ของเรา (กลุ่มมาจากลิงก์ e-Court อย่างเดียว — N9)
  • API ดึงข้อมูลกลับให้ e-Court (N10 docs/PLAN-API.md — แผนแยก)