// N8 (PLAN-ECOURT.md D1) — ลงทะเบียนเอกสารจากลิงก์ e-Court ลง DB แบบ metadata-only // (ไม่ดาวน์โหลด — เก็บแค่ title + external_id + URL ใน pdf_url; ตอนเปิดดู/export // ค่อยดึงไฟล์สดผ่าน lib/remote-file.ts) // server module ธรรมดา **ไม่ใช่ "use server"** — เรียกจาก server component ของหน้า "/" // ไม่เปิดเป็น endpoint ให้ client ยิงตรง ๆ import { randomUUID } from "node:crypto"; import { eq } from "drizzle-orm"; import { db } from "@/db"; import { files } from "@/db/schema"; import { ecourtExternalId, isEcourtExternalId } from "@/lib/ecourt-fid"; import { deleteStagedDoc, putStagedDoc, stagedSourceUrl, type StagedExt, } from "@/lib/staged-docs"; import { PdfPathError, readPublicPdf } from "@/lib/server-pdf"; import { ECOURT_DEFAULT_TITLE, isHttpUrl, normDocumentGroup, titleFromExternalUrl, validateExternalFileUrl, type ExternalFileUrlError, } from "@/utils/external-file"; import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind"; export type RegisterExternalFileResult = | { ok: true; fileId: string } | { ok: false; error: string }; const URL_ERROR_TEXT: Record = { invalid_url: "ลิงก์ไฟล์ไม่ถูกต้อง", not_https: "ลิงก์ไฟล์ต้องเป็น https เท่านั้น", has_credentials: "ลิงก์ไฟล์มีชื่อผู้ใช้/รหัสผ่านฝังอยู่ — ไม่รองรับ", host_not_allowed: "โฮสต์ของลิงก์ไฟล์ไม่อยู่ในรายการอนุญาต", }; /** path ในเครื่อง (ทดสอบใน dev เช่น /samples/…): decode %20, ห้าม .., นามสกุลผ่าน * whitelist และต้องมีไฟล์จริงใน public/ ผ่าน path guard เดียวกับ viewer/export */ function validateLocalPath( raw: string, ): { ok: true; path: string; ext: string } | { ok: false; error: string } { if (raw.includes("..")) return { ok: false, error: "เส้นทางไฟล์ไม่ถูกต้อง" }; let p = raw; try { p = decodeURIComponent(raw); } catch { // ชื่อ encode เสีย — ใช้ค่าดิบ } const dot = p.lastIndexOf("."); const ext = dot > 0 ? p.slice(dot).toLowerCase() : ""; if (!SAMPLE_FILE_EXTENSIONS.includes(ext)) { return { ok: false, error: `รองรับเฉพาะไฟล์ ${SAMPLE_FILE_EXTENSIONS.join(" / ")}` }; } try { readPublicPdf(p); } catch (err) { if (err instanceof PdfPathError || (err as NodeJS.ErrnoException)?.code === "ENOENT") { return { ok: false, error: "ไม่พบไฟล์นี้ในระบบ (path ต้องอยู่ใน public/ เช่น /samples/…)" }; } throw err; } return { ok: true, path: p, ext }; } export async function registerExternalFile( rawFileUrl: string, rawFileId?: string, rawTypeId?: string, opts?: { trusted?: boolean }, ): Promise { let trimmed = rawFileUrl.trim(); if (!trimmed) return { ok: false, error: "ไม่ได้ระบุลิงก์ไฟล์" }; // URL ที่ไม่ได้ขึ้นต้น scheme (เช่น "www.rd.usda.gov/…") เติม https:// ให้ก่อน validate // — path ในเครื่อง (ขึ้นต้น "/") ไม่แตะ if (!trimmed.startsWith("/") && !/^https?:\/\//i.test(trimmed)) { trimmed = `https://${trimmed}`; } const typeId = normDocumentGroup(rawTypeId) ?? undefined; // สองทาง: https ของ e-Court (ตามสัญญาลิงก์ — validate + allowlist) หรือ path ในเครื่อง // (ทดสอบ dev เช่น /samples/…) — ทั้งคู่เก็บลง pdf_url เหมือนกัน ต่างกันแค่ตอนเปิดดู // (รีโมตดึงสดผ่าน lib/remote-file.ts, ในเครื่องอ่านจาก public/) let sourceUrl: string; let title: string; let externalId: string; if (isHttpUrl(trimmed)) { const checked = validateExternalFileUrl(trimmed); if (!checked.ok) return { ok: false, error: URL_ERROR_TEXT[checked.error] }; sourceUrl = checked.url.href; title = titleFromExternalUrl(checked.url); const id = rawFileId?.trim(); externalId = id ? ecourtExternalId(id) : `url:${checked.url.href}`; } else if (trimmed.startsWith("/")) { const local = validateLocalPath(trimmed); if (!local.ok) return local; sourceUrl = local.path; const base = local.path.split("/").pop() ?? ""; title = base.slice(0, base.length - local.ext.length).trim().slice(0, 200) || "เอกสาร"; const id = rawFileId?.trim(); externalId = id ? ecourtExternalId(id) : `url:${local.path}`; } else { return { ok: false, error: "ลิงก์ไฟล์ต้องเป็น https หรือ path ในระบบที่ขึ้นต้นด้วย / เช่น /samples/…", }; } // แถว ecourt: (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO // route (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId // พิมพ์เองโดยผู้มี session ใด ๆ เคยใช้เขียน/refresh pdf_url ของเอกสารคนอื่นให้ชี้ // PDF ที่ตัวเองเลือก — ตอนนี้เปิดดูของที่รู้จักแล้วได้อย่างเดียว (แถว url:… ของ // samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง) · external_id เก็บ hash ของ fid // ผ่าน lib/ecourt-fid.ts (ไม่มี fid ดิบใน DB — 2026-11-04) const ecourtRow = isEcourtExternalId(externalId); const trusted = opts?.trusted === true; const [existing] = await db .select() .from(files) .where(eq(files.externalId, externalId)) .limit(1); if (existing) { if (ecourtRow && !trusted) return { ok: true, fileId: existing.id }; // fileId เดิมเจออีกครั้ง: refresh Temp link ใหม่ + ย้ายกลุ่มตาม typeId ล่าสุด // (ลิงก์ไม่ส่ง typeId มา = คงกลุ่มเดิม ห้ามเคลียร์ — PLAN-ECOURT.md การตัดสินใจ) const patch: { pdfUrl?: string; typeId?: string } = {}; if (existing.pdfUrl !== sourceUrl) patch.pdfUrl = sourceUrl; if (typeId && existing.typeId !== typeId) patch.typeId = typeId; if (Object.keys(patch).length > 0) { await db.update(files).set(patch).where(eq(files.id, existing.id)); } return { ok: true, fileId: existing.id }; } if (ecourtRow && !trusted) { return { ok: false, error: "เอกสารของ e-Court รหัสนี้ยังไม่เคยเปิดในระบบ — กรุณาเปิดผ่านลิงก์เอกสารของ e-Court", }; } const id = randomUUID(); try { await db.insert(files).values({ id, title, pdfUrl: sourceUrl, externalId, typeId: typeId ?? null, }); } catch (err) { // 23505: เปิด 2 แท็บ URL/id เดียวกันครั้งแรกพร้อมกัน — คืน row ของฝ่ายที่ชนะไปก่อน if ((err as { code?: string })?.code === "23505") { const [row] = await db .select() .from(files) .where(eq(files.externalId, externalId)) .limit(1); if (row) return { ok: true, fileId: row.id }; } throw err; } return { ok: true, fileId: id }; } /** N11 (PLAN-STAGED.md D3) — ลงทะเบียนเอกสาร base64 จาก e-Court: bytes เก็บใน RAM * staging (lib/staged-docs.ts) ใต้ id ของ row สุดท้ายที่คืน จึงไม่มีทางเกิด * "row ชี้ sentinel ที่ไม่มี bytes" · ผู้เรียกต้องผ่าน requireDocverApiKey + * magic-byte check (ext มาจากผลตรวจ) มาก่อนแล้ว — trusted by construction * · ลำดับ: resolve id ก่อน → stage ใต้ id นั้น → เขียน DB (ตรวจ cap เกิดก่อนแตะ DB — * DB พังหลัง stage = entry กำพร้าใน RAM ที่ TTL กินเอง ไม่มีอะไรต้องเก็บกวาด) * · insert ชน 23505 (คู่ขนาน fid เดียวกัน) = re-stage ใต้ id ผู้ชนะ + ลบของผู้แพ้ * (bytes สุดท้าย = request ที่เสร็จทีหลัง — last-write-wins ตาม semantics * "re-POST = เนื้อล่าสุด") · โยน error ของ DB ต่อให้ route ตอบ 500 · ไม่ publish * SSE / ไม่ revalidatePath (รายการไฟล์ไม่ sync ผ่าน SSE — carve-out invariant #4) */ export async function registerStagedFile( fid: string, bytes: Buffer, ext: StagedExt, opts?: { title?: string; typeId?: string }, ): Promise<{ fileId: string }> { const externalId = ecourtExternalId(fid); // typeId: ส่งมา = refresh ย้ายกลุ่ม, ไม่ส่ง = คงเดิมห้ามเคลียร์ (เกณฑ์เดียวกับ N8) const typeId = normDocumentGroup(opts?.typeId); const title = opts?.title ?? null; const [existing] = await db .select() .from(files) .where(eq(files.externalId, externalId)) .limit(1); let winnerId: string; // row ปัจจุบันของผู้ชนะ — มีค่าเฉพาะเมื่อเป็น row เดิม (insert ใหม่ครบทุกค่าแล้ว // ไม่ต้อง patch) let current: typeof files.$inferSelect | undefined; if (existing) { winnerId = existing.id; current = existing; putStagedDoc(winnerId, bytes, ext); // replace ช่องเดิม — re-POST = เนื้อล่าสุด } else { const candidateId = randomUUID(); putStagedDoc(candidateId, bytes, ext); try { await db.insert(files).values({ id: candidateId, title: title ?? ECOURT_DEFAULT_TITLE, pdfUrl: stagedSourceUrl(candidateId, ext), externalId, typeId, }); winnerId = candidateId; } catch (err) { if ((err as { code?: string })?.code !== "23505") throw err; // คู่ขนาน fid เดียวกันชนะไปก่อน — หา row ผู้ชนะ แล้ว staging ทับด้วย bytes // ของเรา (last-write-wins) + คืน RAM ของผู้แพ้ — finally ครอบทุกทางออกของ // branch นี้ (select/put รอบสองโยนก็ห้ามทิ้ง candidate ไว้กิน total cap จน // TTL หมด — code review 2026-11-05) try { const [row] = await db .select() .from(files) .where(eq(files.externalId, externalId)) .limit(1); if (!row) { // ผู้ชนะหายไปกลางคัน (แทบเป็นไปไม่ได้) — โยน error 23505 ต่อ (review ข้อ 3) throw err; } putStagedDoc(row.id, bytes, ext); winnerId = row.id; current = row; } finally { deleteStagedDoc(candidateId); } } } if (current) { const sentinel = stagedSourceUrl(winnerId, ext); const patch: { pdfUrl?: string; typeId?: string; title?: string } = {}; if (current.pdfUrl !== sentinel) patch.pdfUrl = sentinel; if (typeId !== null && current.typeId !== typeId) patch.typeId = typeId; // title ปลดล็อกเฉพาะค่าปัจจุบันยังเป็น default (ECOURT_DEFAULT_TITLE — ไม่ฝัง // fid) — ครั้งแรกที่ส่ง title จริงชนะแล้วล็อก (กันค้างชื่อ default ตลอดชีพเมื่อ // ครั้งแรกลืมส่ง) if (title !== null && current.title === ECOURT_DEFAULT_TITLE) patch.title = title; if (Object.keys(patch).length > 0) { await db.update(files).set(patch).where(eq(files.id, winnerId)); } } return { fileId: winnerId }; }