เพิ่มทางเข้าเอกสารสำหรับ 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>
257 lines
13 KiB
TypeScript
257 lines
13 KiB
TypeScript
// 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<ExternalFileUrlError, string> = {
|
|
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<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 = 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:<hash ของ fid> (เอกสารของ 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 };
|
|
}
|