ecourt+groups: N8 เปิดเอกสารจากลิงก์ ?fileUrl=&fileId=&typeId= (metadata-only + allowlist + ดึงไฟล์สด + login วนกลับ + การ์ดจำลองลิงก์ + path dev /samples) + N9 กลุ่มเอกสารตาม typeId — หน้ารายการจัด section ต่อกลุ่ม + "ไม่มีกลุ่ม" ท้ายสุด + หน้า /groups/[typeId] พร้อมหมายเหตุ "อาจไม่ครบทุกฉบับ" + docs sync ครบ (2026-09-23)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-23 10:44:14 +07:00
parent 0aef3ff028
commit 329ae8e956
31 changed files with 2766 additions and 155 deletions

148
lib/external-import.ts Normal file
View file

@ -0,0 +1,148 @@
// 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 { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
import {
externalIdFor,
isHttpUrl,
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<ExternalFileUrlError, string> = {
invalid_url: "ลิงก์ไฟล์ไม่ถูกต้อง",
not_https: "ลิงก์ไฟล์ต้องเป็น https เท่านั้น",
has_credentials: "ลิงก์ไฟล์มีชื่อผู้ใช้/รหัสผ่านฝังอยู่ — ไม่รองรับ",
host_not_allowed: "โฮสต์ของลิงก์ไฟล์ไม่อยู่ในรายการอนุญาต",
};
/** รหัสกลุ่ม normalize: trim → ว่าง = ไม่มี, cap 200 ตัวอักษร (เทียบ title) */
function normGroup(s: string | undefined): string | undefined {
const v = (s ?? "").trim().slice(0, 200);
return v || undefined;
}
/** 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,
): Promise<RegisterExternalFileResult> {
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 = normGroup(rawTypeId);
// สองทาง: 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, rawFileId);
externalId = externalIdFor(rawFileId, checked.url);
} 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 ? `ecourt:${id}` : `url:${local.path}`;
} else {
return {
ok: false,
error: "ลิงก์ไฟล์ต้องเป็น https หรือ path ในระบบที่ขึ้นต้นด้วย / เช่น /samples/…",
};
}
const [existing] = await db
.select()
.from(files)
.where(eq(files.externalId, externalId))
.limit(1);
if (existing) {
// 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 };
}
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 };
}

61
lib/remote-file.ts Normal file
View file

@ -0,0 +1,61 @@
// N8 (PLAN-ECOURT.md D3) — ดึงไบต์ไฟล์รีโมตจากโฮสต์ที่ allowlist ไว้ (server-only)
// ใช้ 2 จุด: /api/pdf (viewer) และ /api/annotations/export — กัน CORS ฝั่ง browser
// และคุม SSRF ให้เหลือจุดเดียว (validate ซ้ำอีกชั้น — defense in depth)
import { isHttpUrl, validateExternalFileUrl } from "@/utils/external-file";
const MAX_REMOTE_BYTES = 100 * 1024 * 1024; // เท่า limit อัปโหลด N4
const FETCH_TIMEOUT_MS = 60_000;
export class RemoteFileError extends Error {}
export function isRemoteSource(pdfUrl: string): boolean {
return isHttpUrl(pdfUrl);
}
export async function fetchSourceBytes(url: string): Promise<Buffer> {
const checked = validateExternalFileUrl(url);
if (!checked.ok) throw new RemoteFileError("ลิงก์ไฟล์รีโมตไม่ผ่านการตรวจสอบ");
let res: Response;
try {
res = await fetch(checked.url, {
redirect: "follow",
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
});
} catch {
throw new RemoteFileError("ดึงไฟล์จากต้นทางไม่สำเร็จ (เครือข่ายล่มหรือหมดเวลา)");
}
if (!res.ok) {
throw new RemoteFileError(`ดึงไฟล์จากต้นทางไม่สำเร็จ (HTTP ${res.status})`);
}
// hardening (plan-reviewer): re-validate หลัง redirect ด้วยชุดเต็ม ไม่ใช่แค่ hostname
// — กัน host ใน allowlist เด้งไป http:// หรือ host อื่นกลาง redirect chain
const afterRedirect = validateExternalFileUrl(res.url);
if (!afterRedirect.ok) {
throw new RemoteFileError("ลิงก์ถูกเปลี่ยนทางไปปลายทางที่ไม่อนุญาต");
}
const declared = Number(res.headers.get("content-length"));
if (Number.isFinite(declared) && declared > MAX_REMOTE_BYTES) {
throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB");
}
// hardening (plan-reviewer): ไม่มี body (เช่น HTTP 204) = ไฟล์เปล่า — ห้ามปล่อย
// Buffer ว่างไหลไป pipeline กลาง ๆ
if (!res.body) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)");
const reader = res.body.getReader();
const chunks: Buffer[] = [];
let total = 0;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
total += value.byteLength;
if (total > MAX_REMOTE_BYTES) {
await reader.cancel().catch(() => undefined);
throw new RemoteFileError("ไฟล์ใหญ่เกิน 100MB");
}
chunks.push(Buffer.from(value));
}
if (total === 0) throw new RemoteFileError("ไฟล์ว่าง (0 ไบต์)");
return Buffer.concat(chunks);
}