เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage (x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging (TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม: - lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc - lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid - route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 + concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง - registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก - ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์) - session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี - Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย) - MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง) Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้ fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) — ผ่าน code review รอบ 7 แก้ครบทุก finding Co-Authored-By: Claude Code <noreply@anthropic.com>
94 lines
5 KiB
TypeScript
94 lines
5 KiB
TypeScript
// 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());
|
|
}
|
|
|
|
/** default title ของเอกสารจาก e-Court — ไม่ฝัง fid ดิบ (2026-11-04: external_id
|
|
* เก็บเป็น hash แล้ว title ก็ต้องไม่เป็นที่รั่วของ fid) · ใช้ร่วมกันทั้ง N8
|
|
* (fallback ของ titleFromExternalUrl) และ N11 (insert + เงื่อนไขปลดล็อก title
|
|
* ของ registerStagedFile — เทียบจากค่าคงที่ตัวนี้) */
|
|
export const ECOURT_DEFAULT_TITLE = "เอกสารจาก e-Court";
|
|
|
|
/** title จาก basename ของ URL (decode แล้ว ตัดนามสกุล cap 200) —
|
|
* path ลงท้าย "/" หรือ decode ไม่ได้/ไม่มีชื่อ ใช้ค่า default กลาง */
|
|
export function titleFromExternalUrl(url: URL): 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 || ECOURT_DEFAULT_TITLE;
|
|
}
|
|
|
|
/** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — ใช้ที่เดียวคือ N8
|
|
* `registerExternalFile` (ลิงก์ e-Court — ทั้ง insert ครั้งแรกและ update เมื่อลิงก์
|
|
* รอบใหม่ส่ง typeId มา): 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;
|
|
}
|