// N8 (PLAN-ECOURT.md D2) — validation และตัวช่วยของลิงก์ไฟล์ภายนอก (pure module ไม่แตะ fs/db // เพื่อให้ middleware ใช้ร่วมได้ในอนาคต) + ตัวช่วยแยกไฟล์ในเครื่องกับไฟล์รีโมต export type ExternalFileUrlError = | "invalid_url" | "not_https" | "has_credentials" | "host_not_allowed"; /** Next 14: searchParams เป็น plain object ค่า string | string[] — เอาตัวแรกของแต่ละ key * (ใช้ร่วมระหว่าง entry point หน้า "/", "/annotate" และหน้า login — วางที่ pure module * เพื่อไม่ลาก db/fs ของ lib/external-import ตามเข้าหน้าที่ไม่แตะ DB) */ export function firstSearchParam(v: string | string[] | undefined): string | undefined { return Array.isArray(v) ? v[0] : v; } /** allowlist โฮสต์รีโมต — default "admincourt.go.th" ครอบทั้ง admcasedey. และ judgement-api. * (suffix match), override ด้วย env EXTERNAL_FILE_HOSTS (comma-separated) — * อ่านตอน call ไม่ใช่ตอน module load เพื่อ test ได้ */ export function externalHosts(): string[] { const raw = process.env.EXTERNAL_FILE_HOSTS; if (!raw) return ["admincourt.go.th"]; const list = raw .split(",") .map((h) => h.trim().toLowerCase()) .filter(Boolean); return list.length > 0 ? list : ["admincourt.go.th"]; } /** suffix match กันโฮสต์ปลอม: host === entry หรือ host.endsWith("." + entry) * (ไม่ match "admincourt.go.th.evil.com" และ "xadmincourt.go.th") */ export function isAllowedExternalHost(host: string): boolean { const h = host.toLowerCase(); return externalHosts().some((entry) => h === entry || h.endsWith("." + entry)); } export function validateExternalFileUrl( raw: string, ): { ok: true; url: URL } | { ok: false; error: ExternalFileUrlError } { let url: URL; try { url = new URL(raw.trim()); } catch { return { ok: false, error: "invalid_url" }; } if (url.protocol !== "https:") return { ok: false, error: "not_https" }; if (url.username || url.password) { return { ok: false, error: "has_credentials" }; } if (!isAllowedExternalHost(url.hostname)) { return { ok: false, error: "host_not_allowed" }; } return { ok: true, url }; } /** absolute http(s):// หรือไม่ — ใช้ทั้งฝั่ง server แยกว่า pdf_url เป็นรีโมต * และฝั่ง client เลือกทางโหลดรูปภาพ */ export function isHttpUrl(s: string): boolean { return /^https?:\/\//i.test(s.trim()); } /** title จาก basename ของ URL (decode แล้ว ตัดนามสกุล cap 200) — * path ลงท้าย "/" หรือ decode ไม่ได้/ไม่มีชื่อ ใช้ fallbackId หรือชื่อกลาง */ export function titleFromExternalUrl(url: URL, fallbackId?: string): string { const base = url.pathname.split("/").pop() ?? ""; let name = base; try { name = decodeURIComponent(base); } catch { // ชื่อ encode เสีย — ใช้ base ดิบ } const dot = name.lastIndexOf("."); if (dot > 0) name = name.slice(0, dot); name = name.trim().slice(0, 200); return name || fallbackId?.trim() || "เอกสารจาก e-Court"; } /** dedupe key ของเอกสารนำเข้า — "ecourt:" เมื่อมี id ของ e-Court, * ไม่มี fallback เป็น "url:" (id เปลี่ยน = ไฟล์ใหม่ ตามหลักของแผน) */ export function externalIdFor(fileId: string | undefined, url: URL): string { const id = fileId?.trim(); 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) */ export function normDocumentGroup(raw: unknown): string | null { if (typeof raw !== "string") return null; const v = raw.trim().slice(0, 200); return v || null; }