26 KiB
แผน N8 — เปิดเอกสารจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= (metadata-only + ดึงไฟล์สด)
อ้างอิง: PLAN-FEAT.md §7 N8 · สร้าง 2026-09-18 ตามข้อตกลงกับผู้ใช้ (สัญญาลิงก์
fileUrl+fileId, ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ, id เปลี่ยน = ไฟล์ใหม่) · อัปเดต 2026-09-22: เพิ่ม paramtypeIdจัดกลุ่มเอกสารที่เกี่ยวเนื่องกัน (ส่งต่อให้ N9docs/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)
- สัญญาลิงก์ =
fileUrlจำเป็น +fileIdแนะนำ — มี fileId ใช้เป็น dedupe key (external_id = "ecourt:<fileId>"), ไม่มี fallback เป็น"url:<href>"· เพิ่ม 2026-09-22:typeIdแนะนำ (ผู้ใช้เลือกชื่อนี้แทน caseId) — รหัสกลุ่มเอกสารที่เกี่ยวเนื่องกัน (เช่น คดีเดียวกัน) เอกสารที่ e-Court ส่งtypeIdเดียวกัน = กลุ่มเดียวกัน (แสดงผลใน N9) - ไม่เก็บสำเนาไฟล์ เอาแค่ชื่อ — ลง DB แค่ metadata (title จาก basename ของ URL + external_id + ตัว URL เก็บใน
pdf_url) ไม่ดาวน์โหลดเขียนลงpublic/uploads/ตอนเปิดดู/export ให้ server ดึงจาก e-Court สด ๆ ผ่าน route ของเรา (กัน CORS + คุม SSRF ที่จุดเดียว) - หลักจัดการเวอร์ชัน: 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[]— helperfirstParam): มี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?)validateExternalFileUrl→ fail คืนข้อความไทย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 เดิม- ไม่เจอ →
insert { id: randomUUID(), title: titleFromExternalUrl(url), pdfUrl: url.href, externalId, typeId? }—typeIdnormalize: trim → ว่าง = ไม่เก็บ (NULL), cap 200 ตัวอักษร (เทียบ title) - 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) + indexfiles_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 ด้วย envEXTERNAL_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 URLparse ได้ +https:เท่านั้น + ไม่มีuser:pass@+ host ผ่าน allowlist → ไม่ผ่านคืน error code (invalid_url/not_https/has_credentials/host_not_allowed)titleFromExternalUrl(url, fallbackId?)— basename ของ pathname แล้วdecodeURIComponent(try/catchURIError) ตัดนามสกุล cap 200 ตัวอักษร; URL ไม่มีชื่อไฟล์ (path ลงท้าย/) → fallbackfallbackIdหรือ"เอกสารจาก e-Court"externalIdFor(fileId?, url)—"ecourt:" + fileId(trim) หรือ"url:" + url.hrefsanitizeNextPath(s)— ใช้ได้เฉพาะขึ้นต้น/และไม่ใช่////\(กัน open-redirect ตอน login วนกลับ) — ไม่ผ่านคืนnullisHttpUrl(s)— absolutehttp(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-validateres.urlหลัง redirect ว่า host ยังอยู่ใน allowlist (กัน redirect chain เด้งไป host อื่น) → pre-checkContent-Length+ นับ byte ระหว่าง stream ด้วยReadableStreamreader ถ้าเกิน 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 — ไม่กระทบ); fallbackfetch(withBasePath(file.pdfUrl))ทำงานผ่าน bypass (อาจโดน CORS ของปลายทางแต่เป็นแค่ fallback) - รูปภาพ (N3) โหลด static
pdfUrlตรง ๆ — ไฟล์รีโมตต้องเปลี่ยนไปโหลดผ่านwithBasePath("/api/pdf?fileId=" + file.id)แทน (route คืน bytes รูป + content-type อยู่แล้ว) — ใช้isHttpUrlแยกทาง
D5 — Login ไม่ทำ query หาย (deep link ของคนยังไม่ login)
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 ?? "/")+ ส่ง propnextให้LoginForm→router.replace(next ?? "/")(middleware เห็น path ตัด basePath แล้ว, NextURL/router ใส่ basePath คืนเอง — invariant #12)
Edge cases
- URL ไม่มีนามสกุลไฟล์ →
fileKindFromUrldefault "pdf" (pdf.js ลองเปิด — เปิดไม่ได้ค่อยแสดง load error ตามข้อความเดิมของ viewer) - เนื้อไฟล์เพี้ยน/เป็น HTML error page — ไม่มีการตรวจ magic bytesตอน register (ไม่ได้ดาวน์โหลด): PDF →
fixPdfActualText/pdf.js fail → error ข้อความเดิมของ viewer/route, รูป →img.decode()/utif2fail ฝั่ง 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 เป็นความเสี่ยงที่รับไว้
- 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 เดี่ยว - เบา — else-branch ไม่มี guard ไฟล์เปล่า: ใน
fetchSourceBytesเมื่อres.bodyเป็น null (เช่น HTTP 204) จะคืน Buffer เปล่าโดยไม่ผ่าน checktotal === 0ของ path หลัก → bytes เปล่าไหลไป pipeline แล้วพังเป็น error กลาง ๆ ไม่ใช่ข้อความไทย - 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 อยู่แล้ว แต่ให้ตั้งชื่อไว้ชัด ๆ ว่าเป็นความเสี่ยงที่รับไว้ - เอกสาร — 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— dedupeexternal_id+ refreshpdf_url/type_id+ catch 23505) + schemafiles.external_id(UNIQUE nullable, migrationdrizzle/0002/0003idempotent) + entry point: หน้า/อ่าน?fileUrl=&fileId=&typeId=แล้วredirect("/annotate/<id>")+ กล่อง error ไทยrole="alert" - D2 ✅
utils/external-file.ts—externalHosts(envEXTERNAL_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เดี่ยว แสดงผลด้วยรหัสกลุ่มโดยตรง (migrationdrizzle/0004=DROP COLUMN IF EXISTS) · entry point ที่สอง/annotate?fileUrl=&fileId=&typeId=(app/annotate/page.tsx— การ์ดEcourtLinkFormพามาที่นี่ + แสดงลิงก์เต็มสด ๆ) ·registerExternalFileเติมhttps://ให้ URL ที่ไม่มี scheme · envEXTERNAL_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 testtests/utils/external-file.test.ts) → มี sessionredirect(next ?? "/")+ ส่ง propnextที่ 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ไม่มี defensegetSessionUserซ้อน 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— แผนแยก)