eCourt-note/utils/external-file.ts

105 lines
5.5 KiB
TypeScript
Raw Normal View History

// 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:<fileId>" เมื่อมี id ของ e-Court,
* ไม่มี fallback เป็น "url:<href>" (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;
}
genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00
/** รหัสกลุ่มเอกสาร (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;
}