feat(n11): add base64 document staging from e-Court
เพิ่มทางเข้าเอกสารสำหรับ 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>
This commit is contained in:
parent
2e951f99f4
commit
a02e983ba8
34 changed files with 2396 additions and 62 deletions
|
|
@ -18,6 +18,7 @@ import {
|
|||
import { and, asc, eq, inArray } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { getStagedDoc, isStagedSource } from "@/lib/staged-docs";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
|
||||
import {
|
||||
|
|
@ -223,8 +224,22 @@ export async function GET(req: Request) {
|
|||
|
||||
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
|
||||
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
|
||||
// N11: pdf_url เป็น sentinel staged: → bytes จาก RAM — หมดอายุ/ถูก evict = 410 gate
|
||||
// ไทยเหมือน /api/note/pdf (ไม่ปล่อยเป็น 500 ดิบจาก readPublicPdf ที่หาไฟล์ไม่เจอ)
|
||||
let srcBytes: Buffer;
|
||||
if (isRemoteSource(file.pdfUrl)) {
|
||||
if (isStagedSource(file.pdfUrl)) {
|
||||
const staged = getStagedDoc(file.pdfUrl);
|
||||
if (staged === null) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" }),
|
||||
{
|
||||
status: 410,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
srcBytes = staged;
|
||||
} else if (isRemoteSource(file.pdfUrl)) {
|
||||
try {
|
||||
srcBytes = await fetchSourceBytes(file.pdfUrl);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -7,12 +7,17 @@
|
|||
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
|
||||
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
|
||||
import { NextResponse } from "next/server";
|
||||
import { eq } from "drizzle-orm";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
|
||||
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
|
||||
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { parseSessionExchangeBody } from "@/lib/note-auth";
|
||||
import { NOTE_SESSION_TTL } from "@/lib/note-token";
|
||||
import { hasStagedDoc, isStagedSource } from "@/lib/staged-docs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -81,6 +86,30 @@ export async function POST(request: Request): Promise<Response> {
|
|||
});
|
||||
if (result.ok) fileId = result.fileId;
|
||||
else fileError = result.error;
|
||||
} else {
|
||||
// N11 (PLAN-STAGED.md D5): ลิงก์ fid-only (เอกสาร staging — เนื้อส่งมาทาง POST
|
||||
// /api/note/files/stage แล้ว ลิงก์มีแค่ t=) — เช็คตั้งแต่ตอนแลก token เลย ไม่ปล่อย
|
||||
// เข้า viewer ไปเจอ 410 เอง · ใช้ hasStagedDoc (ไม่ยืด TTL ไม่ถือ Buffer — การอ่าน
|
||||
// จริงเกิดที่ viewer) · เอกสาร N8 ที่รู้จักแล้ว (pdf_url เป็น URL/path ปกติ) เข้าได้
|
||||
// ทางเดียวกัน · ผู้ใช้ได้ token ตามปกติทุกกรณี
|
||||
try {
|
||||
const [row] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(eq(files.externalId, ecourtExternalId(identity.fid)))
|
||||
.limit(1);
|
||||
if (!row) {
|
||||
fileError = "เอกสารยังไม่ถูกส่งเข้าระบบ — กลับ e-Court กดลิงก์ใหม่";
|
||||
} else if (isStagedSource(row.pdfUrl)) {
|
||||
if (hasStagedDoc(row.pdfUrl)) fileId = row.id;
|
||||
else fileError = "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่";
|
||||
} else {
|
||||
fileId = row.id;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("[sso] ค้นเอกสารของ fid ล้ม", err);
|
||||
fileError = "ค้นเอกสารไม่สำเร็จ — ลองอีกครั้ง";
|
||||
}
|
||||
}
|
||||
|
||||
const access_token = issueDocverToken(
|
||||
|
|
|
|||
9
app/api/note/external/documents/route.ts
vendored
9
app/api/note/external/documents/route.ts
vendored
|
|
@ -2,6 +2,7 @@ import { and, asc, eq } from "drizzle-orm";
|
|||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import { ecourtExternalId } from "@/lib/ecourt-fid";
|
||||
import { externalApiError, requireExternalApiKey } from "@/lib/external-api-auth";
|
||||
import { loadDocumentBundles, parseQueryBody } from "@/lib/external-api";
|
||||
import { requestOrigin } from "@/utils/request-origin";
|
||||
|
|
@ -47,17 +48,19 @@ export async function POST(req: Request) {
|
|||
if (query.mode === "file") {
|
||||
// ส่งทั้งคู่ = AND กรองทั้ง externalId และกลุ่มใน query เดียว เหลือเอกสารเดียว
|
||||
// (external_id unique — match ได้มากสุด 1 ฉบับ) ไม่ตรง = 404 โดยแยกสาเหตุ:
|
||||
// ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ
|
||||
// ไม่เคยเปิดในระบบ หรืออยู่คนละกลุ่มกับ typeId ที่ระบุ · external_id เก็บ hash
|
||||
// ของ fid (lib/ecourt-fid) — ผู้เรียกส่ง fid ดิบมา เรา hash ตอน query เหมือนเดิม
|
||||
const externalId = ecourtExternalId(query.fileId);
|
||||
const [file] = await db
|
||||
.select()
|
||||
.from(files)
|
||||
.where(
|
||||
query.typeId
|
||||
? and(
|
||||
eq(files.externalId, `ecourt:${query.fileId}`),
|
||||
eq(files.externalId, externalId),
|
||||
eq(files.typeId, query.typeId),
|
||||
)
|
||||
: eq(files.externalId, `ecourt:${query.fileId}`),
|
||||
: eq(files.externalId, externalId),
|
||||
)
|
||||
.limit(1);
|
||||
if (!file) {
|
||||
|
|
|
|||
291
app/api/note/files/stage/route.ts
Normal file
291
app/api/note/files/stage/route.ts
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
// N11 (PLAN-STAGED.md D1) — POST /api/note/files/stage: ผู้เรียก backend e-Court
|
||||
// (server-to-server ไม่มี CORS ไม่มี session ของแอป) ส่งเนื้อไฟล์ PDF/รูปเป็น
|
||||
// base64 ใน JSON body — เก็บใน RAM staging (lib/staged-docs.ts) เท่านั้น ห้ามเขียน
|
||||
// disk/DB/MinIO แม้แต่ไบต์เดียว (annotation/bookmark เก็บถาวรตามระบบเดิม — row
|
||||
// files ที่ registerStagedFile สร้างคือ FK anchor)
|
||||
// · auth ก่อนแตะอะไรทั้งสิ้น: requireDocverApiKey (env DOCVER_API_KEY — กลุ่มเดียว
|
||||
// กับทางเข้า mint POST /api/note/auth/token)
|
||||
// · อ่าน body แบบ stream นับไบต์ (Content-Length เป็น fast-path เท่านั้น — chunked
|
||||
// ไม่มี header) + idle 60 วิ / total 10 นาที → 408 + concurrency จำกัด
|
||||
// (STAGED_MAX_CONCURRENT default 1) → 429 — คืน slot/เคลียร์ timer ใน finally ทุกทางออก
|
||||
// · error ทั้งหก 401/400/408/413/429/500 เป็น JSON {"error": <ไทย>} (pattern docverError)
|
||||
// · ห้าม log bytes/key ดิบ — log ได้เฉพาะ fid + ขนาด
|
||||
// · carve-out invariant #4: route handler เขียนตาราง files ผ่าน registerStagedFile —
|
||||
// ไม่ publish SSE ไม่ revalidatePath (รายการไฟล์ไม่ sync ผ่าน SSE เหมือน N8/N4)
|
||||
import { requireDocverApiKey } from "@/lib/docver-token";
|
||||
import { registerStagedFile } from "@/lib/external-import";
|
||||
import {
|
||||
detectStagedExt,
|
||||
stagedMaxBodyBytes,
|
||||
stagedMaxFileBytes,
|
||||
StagedCapError,
|
||||
} from "@/lib/staged-docs";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const IDLE_TIMEOUT_MS = 60_000;
|
||||
const TOTAL_TIMEOUT_MS = 600_000;
|
||||
const RETRY_AFTER_SECONDS = "5";
|
||||
const SLOW_MSG = "อัปโหลดช้าเกินกำหนดหรือการเชื่อมต่อขาด — ลองส่งใหม่";
|
||||
const TOO_BIG_MSG = "ไฟล์ใหญ่เกินขนาดที่รองรับ (เนื้อไฟล์เกิน 100MB / base64 เกิน ~140MB)";
|
||||
|
||||
/** concurrency counter บน globalThis (pattern เดียวกับ lib/annotate-events.ts —
|
||||
* dev hot-reload จะ duplicate module-level state) — กัน peak RAM หลายสำเนาบวมเกิน
|
||||
* cap ตอนยิงพร้อมกัน */
|
||||
type StageGlobal = { __stagedInflight?: number };
|
||||
const g = globalThis as typeof globalThis & StageGlobal;
|
||||
|
||||
function stageMaxConcurrent(): number {
|
||||
const raw = process.env.STAGED_MAX_CONCURRENT?.trim();
|
||||
if (raw === undefined || raw === "") return 1;
|
||||
const n = Number(raw);
|
||||
return Number.isInteger(n) && n >= 1 ? n : 1;
|
||||
}
|
||||
|
||||
function stageError(
|
||||
message: string,
|
||||
status: number,
|
||||
headers?: Record<string, string>,
|
||||
): Response {
|
||||
return new Response(JSON.stringify({ error: message }), {
|
||||
status,
|
||||
headers: { "content-type": "application/json; charset=utf-8", ...headers },
|
||||
});
|
||||
}
|
||||
|
||||
/** Response 200 ของกลุ่มนี้ — ปัจจุบันมีแต่ POST (คืน {fileId}) */
|
||||
function stageJson(body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), {
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
|
||||
class StageReadError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: 400 | 408 | 413,
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
/** อ่าน body แบบ stream สะสมนับไบต์ + idle/total timeout + ผูก req.signal (client
|
||||
* ตัดการเชื่อมต่อ) — เกิน cap = 413 ก่อนแลกทั้งก้อน · timer ทั้งสองเคลียร์ใน
|
||||
* finally เสมอ (ลืมแล้ว timer ค้างหลัง request จบ โดยเฉพาะ Next dev hot-reload) */
|
||||
async function readBodyCapped(req: Request): Promise<Buffer> {
|
||||
const cap = stagedMaxBodyBytes();
|
||||
const declared = Number(req.headers.get("content-length"));
|
||||
if (Number.isFinite(declared) && declared > cap) {
|
||||
throw new StageReadError(TOO_BIG_MSG, 413);
|
||||
}
|
||||
if (req.body === null) {
|
||||
throw new StageReadError("ต้องส่ง body เป็น JSON object", 400);
|
||||
}
|
||||
if (req.signal.aborted) {
|
||||
throw new StageReadError(SLOW_MSG, 408);
|
||||
}
|
||||
|
||||
const reader = req.body.getReader();
|
||||
const chunks: Uint8Array[] = [];
|
||||
let received = 0;
|
||||
let idleTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let totalTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
// timer ทั้งสอง + abort ส่งสัญญาณ rejecting ผ่าน promise ที่ race กับ read()
|
||||
let timedOutReject: ((e: StageReadError) => void) | null = null;
|
||||
const timedOut = new Promise<never>((_, reject) => {
|
||||
timedOutReject = reject;
|
||||
});
|
||||
let abortReject: ((e: StageReadError) => void) | null = null;
|
||||
const aborted = new Promise<never>((_, reject) => {
|
||||
abortReject = reject;
|
||||
});
|
||||
const onAbort = () => abortReject?.(new StageReadError(SLOW_MSG, 408));
|
||||
req.signal.addEventListener("abort", onAbort, { once: true });
|
||||
totalTimer = setTimeout(
|
||||
() => timedOutReject?.(new StageReadError(SLOW_MSG, 408)),
|
||||
TOTAL_TIMEOUT_MS,
|
||||
);
|
||||
|
||||
try {
|
||||
for (;;) {
|
||||
idleTimer = setTimeout(
|
||||
() => timedOutReject?.(new StageReadError(SLOW_MSG, 408)),
|
||||
IDLE_TIMEOUT_MS,
|
||||
);
|
||||
const { done, value } = await Promise.race([reader.read(), timedOut, aborted]);
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = null;
|
||||
if (done) break;
|
||||
received += value.byteLength;
|
||||
if (received > cap) throw new StageReadError(TOO_BIG_MSG, 413);
|
||||
chunks.push(value);
|
||||
}
|
||||
return Buffer.concat(chunks);
|
||||
} finally {
|
||||
if (idleTimer !== null) clearTimeout(idleTimer);
|
||||
if (totalTimer !== null) clearTimeout(totalTimer);
|
||||
req.signal.removeEventListener("abort", onAbort);
|
||||
// ออกกลางทาง (timeout/abort/413/error) — ปล่อย stream ที่ยังไม่อ่าน (best-effort)
|
||||
void reader.cancel().catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
const STAGE_BODY_FIELDS = ["fid", "data", "typeId", "title"] as const;
|
||||
|
||||
type StageBody =
|
||||
| { ok: true; fid: string; data: string; typeId?: string; title?: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
/** body แบบ strict allowlist กัน typo เงียบ ๆ ตาม convention ของกลุ่ม (key อื่น =
|
||||
* 400) · fid เกณฑ์เดียวกับ parseLoginTokenBody (cap 200 + ห้ามอักขระ & = + %) */
|
||||
function parseStageBody(raw: unknown): StageBody {
|
||||
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||
return { ok: false, error: "body ต้องเป็น JSON object" };
|
||||
}
|
||||
const record = raw as Record<string, unknown>;
|
||||
for (const key of Object.keys(record)) {
|
||||
if (!(STAGE_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${STAGE_BODY_FIELDS.join(", ")})`,
|
||||
};
|
||||
}
|
||||
}
|
||||
const rawFid = record.fid;
|
||||
if (typeof rawFid !== "string" || rawFid.trim() === "") {
|
||||
return { ok: false, error: "ต้องระบุ fid (string)" };
|
||||
}
|
||||
const fid = rawFid.trim();
|
||||
if (fid.length > 200) return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
|
||||
if (/[&=+%]/.test(fid)) {
|
||||
return { ok: false, error: "fid มีอักขระที่ไม่รับ (& = + %)" };
|
||||
}
|
||||
const data = record.data;
|
||||
if (typeof data !== "string" || data === "") {
|
||||
return { ok: false, error: "ต้องระบุ data (base64 ของเนื้อไฟล์)" };
|
||||
}
|
||||
const typeId = record.typeId;
|
||||
if (typeId !== undefined && typeof typeId !== "string") {
|
||||
return { ok: false, error: "typeId ต้องเป็น string" };
|
||||
}
|
||||
const title = record.title;
|
||||
if (title !== undefined && typeof title !== "string") {
|
||||
return { ok: false, error: "title ต้องเป็น string" };
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
fid,
|
||||
data,
|
||||
...(typeId !== undefined ? { typeId } : {}),
|
||||
...(title !== undefined ? { title } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** base64 มาตรฐานต่อเนื่องเท่านั้น — ห้าม whitespace/line break และไม่รับ base64url
|
||||
* (-/_) → scan บน string ต้นทาง (ไม่สร้างสำเนาตัด whitespace) + len % 4 === 0 +
|
||||
* เทียบความยาวที่ควรได้จากสูตร len/4×3 − padding กับ Buffer.length หลัง decode
|
||||
* (Node Buffer.from(b64) ยืดหยุ่นเกิน ได้ garbage เงียบ ๆ — การเทียบความยาวเป็นตัวตัดสิน)
|
||||
* ⚠️ ห้ามกลับไปใช้ regex แบบ /^[A-Za-z0-9+/]+={0,2}$/ — quantifier ที่ต้องย้อน
|
||||
* กลับบน string ~140MB ทำให้ V8 ล้น stack (RangeError ใน dev server ที่รันนาน
|
||||
* 2026-11-04) — negated class scan ข้างล่างเดินเชิงเส้นไม่มี backtracking ปลอดภัย
|
||||
* ทุกขนาด/ทุกระดับ engine */
|
||||
function decodeBase64Strict(data: string): Buffer | { error: string } {
|
||||
if (data.length % 4 !== 0) {
|
||||
return { error: "data ต้องเป็น base64 ที่ความยาวลงตัวส่วน 4" };
|
||||
}
|
||||
const CONTIGUOUS_MSG =
|
||||
"data ต้องเป็น base64 มาตรฐานต่อเนื่อง (ห้าม whitespace/line break และไม่รับ base64url)";
|
||||
if (/[^A-Za-z0-9+/=]/.test(data)) {
|
||||
return { error: CONTIGUOUS_MSG };
|
||||
}
|
||||
// '=' ได้เฉพาะท้ายชุด 0–2 ตัว — อยู่กลางชุด/เกิน 2 = ไม่ใช่ base64 มาตรฐาน
|
||||
const firstEq = data.indexOf("=");
|
||||
let padding = 0;
|
||||
if (firstEq !== -1) {
|
||||
padding = data.length - firstEq;
|
||||
if (padding > 2 || data.slice(firstEq) !== "=".repeat(padding)) {
|
||||
return { error: CONTIGUOUS_MSG };
|
||||
}
|
||||
}
|
||||
const expected = (data.length / 4) * 3 - padding;
|
||||
const bytes = Buffer.from(data, "base64");
|
||||
if (bytes.length !== expected) {
|
||||
return { error: "data ไม่ใช่ base64 ที่ถูกต้อง" };
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
export async function POST(req: Request): Promise<Response> {
|
||||
const denied = requireDocverApiKey(req);
|
||||
if (denied) return denied;
|
||||
|
||||
// จับกั้นตั้งแต่ก่อนอ่าน body จนจบ — คืน slot ใน finally ทุกทางออก (สำเร็จ/error/
|
||||
// client ตัดการเชื่อมต่อ) ไม่งั้นไฟล์ใหญ่บนเน็ตช้ากิน slot นานเป็นนาที จน request
|
||||
// ถัดไป 429 ตลอด
|
||||
if ((g.__stagedInflight ?? 0) >= stageMaxConcurrent()) {
|
||||
return stageError("ระบบกำลังจัดเอกสารอื่นอยู่ — ลองใหม่อีกครั้ง", 429, {
|
||||
"retry-after": RETRY_AFTER_SECONDS,
|
||||
});
|
||||
}
|
||||
g.__stagedInflight = (g.__stagedInflight ?? 0) + 1;
|
||||
try {
|
||||
let bodyText: string;
|
||||
try {
|
||||
bodyText = (await readBodyCapped(req)).toString("utf8");
|
||||
} catch (err) {
|
||||
if (err instanceof StageReadError) return stageError(err.message, err.status);
|
||||
console.error("[stage] อ่าน body ไม่สำเร็จ", err);
|
||||
return stageError("อ่าน request ไม่สำเร็จ — ลองส่งใหม่", 500);
|
||||
}
|
||||
|
||||
let parsedJson: unknown;
|
||||
try {
|
||||
parsedJson = JSON.parse(bodyText);
|
||||
} catch {
|
||||
return stageError("body ต้องเป็น JSON object", 400);
|
||||
}
|
||||
const parsed = parseStageBody(parsedJson);
|
||||
if (!parsed.ok) return stageError(parsed.error, 400);
|
||||
|
||||
const decoded = decodeBase64Strict(parsed.data);
|
||||
if ("error" in decoded) return stageError(decoded.error, 400);
|
||||
const bytes = decoded;
|
||||
if (bytes.length > stagedMaxFileBytes()) {
|
||||
return stageError(TOO_BIG_MSG, 413);
|
||||
}
|
||||
|
||||
// ตรวจ magic bytes กำหนดชนิดเอง ไม่เชื่อ client ประกาศ — ext ของ sentinel มาจาก
|
||||
// ผลตรวจนี้
|
||||
const ext = detectStagedExt(bytes);
|
||||
if (ext === null) {
|
||||
return stageError("ไฟล์ไม่ใช่ PDF หรือรูปภาพที่รองรับ", 400);
|
||||
}
|
||||
|
||||
const title = parsed.title?.trim().slice(0, 200) || undefined;
|
||||
const typeId = parsed.typeId?.trim() || undefined;
|
||||
try {
|
||||
const { fileId } = await registerStagedFile(parsed.fid, bytes, ext, {
|
||||
title,
|
||||
typeId,
|
||||
});
|
||||
// audit: fid + ขนาด + ext เท่านั้น (strip อักขระควบคุมกันฝัง \n ปลอมบรรทัด log —
|
||||
// pattern เดียวกับ session route)
|
||||
const auditFid = parsed.fid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
||||
console.log(
|
||||
`[stage] e-Court stage ok: fid=${auditFid} size=${bytes.length} ext=${ext}`,
|
||||
);
|
||||
return stageJson({ fileId });
|
||||
} catch (err) {
|
||||
if (err instanceof StagedCapError) return stageError(err.message, 413);
|
||||
// ห้าม log bytes ดิบ — log เฉพาะ fid + ขนาด
|
||||
console.error(
|
||||
`[stage] stage ล้ม fid=${parsed.fid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "")} size=${bytes.length}`,
|
||||
err,
|
||||
);
|
||||
return stageError("จัดเอกสารไม่สำเร็จ — ลองอีกครั้ง", 500);
|
||||
}
|
||||
} finally {
|
||||
g.__stagedInflight = Math.max(0, (g.__stagedInflight ?? 0) - 1);
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { db } from "@/db";
|
|||
import { files } from "@/db/schema";
|
||||
import { fixPdfActualText } from "@/lib/pdf-actual-text";
|
||||
import { fetchSourceBytes, isRemoteSource, RemoteFileError } from "@/lib/remote-file";
|
||||
import { getStagedDoc, isStagedSource } from "@/lib/staged-docs";
|
||||
import { PdfPathError, readPublicPdf } from "@/lib/server-pdf";
|
||||
import { fileKindFromUrl, imageContentTypeFromUrl } from "@/utils/file-kind";
|
||||
|
||||
|
|
@ -26,9 +27,25 @@ export async function GET(req: Request) {
|
|||
if (!file) return new Response("ไม่พบไฟล์นี้", { status: 404 });
|
||||
|
||||
// Guard path traversal: pdfUrl จาก DB ต้อง resolve แล้วอยู่ใน public/ เท่านั้น (§5)
|
||||
// N8: pdf_url เป็นรีโมต (ลิงก์ e-Court) → ดึงสดจาก allowlist แทนการอ่านดิสก์
|
||||
// N11: pdf_url เป็น sentinel staged: → bytes จาก RAM (ไม่มีไฟล์บนดิสก์/URL ให้ดึง) —
|
||||
// หมดอายุ/ถูก evict/ext ไม่ตรง = 410 gate ให้ผู้ใช้กลับ e-Court กดลิงก์ใหม่
|
||||
// (annotation/bookmark ใน DB ยังอยู่ครบ) · Buffer ที่คืนเป็น reference ของ entry —
|
||||
// ปลอดภัยต่อการอ่านพร้อมกันหลาย request เพราะ fixPdfActualText/pdf-lib สร้างชุด
|
||||
// bytes ใหม่เอง ไม่ mutate ต้นทาง (ตรวจแล้ว lib/pdf-actual-text.ts)
|
||||
let srcBytes: Buffer;
|
||||
if (isRemoteSource(file.pdfUrl)) {
|
||||
if (isStagedSource(file.pdfUrl)) {
|
||||
const staged = getStagedDoc(file.pdfUrl);
|
||||
if (staged === null) {
|
||||
return new Response(
|
||||
JSON.stringify({ error: "เอกสารพ้นช่วงจัดเตรียม — กลับ e-Court กดลิงก์ใหม่" }),
|
||||
{
|
||||
status: 410,
|
||||
headers: { "content-type": "application/json; charset=utf-8" },
|
||||
},
|
||||
);
|
||||
}
|
||||
srcBytes = staged;
|
||||
} else if (isRemoteSource(file.pdfUrl)) {
|
||||
try {
|
||||
srcBytes = await fetchSourceBytes(file.pdfUrl);
|
||||
} catch (err) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { notFound } from "next/navigation";
|
|||
import AppNav from "@/components/AppNav";
|
||||
import DevLoginLinkForm from "@/components/DevLoginLinkForm";
|
||||
import MockEcourtForm from "@/components/MockEcourtForm";
|
||||
import MockStageForm from "@/components/MockStageForm";
|
||||
import { devMintEnabled } from "@/lib/note-auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
|
@ -149,6 +150,26 @@ export default async function DevToolsPage({
|
|||
<MockEcourtForm files={Object.values(SAMPLE_DOCS).flat()} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* จำลอง e-Court แบบ base64 staging (N11 — คำขอผู้ใช้ 2026-11-04): ให้ dev
|
||||
ฝั่ง e-Court กดทดสอบ contract ทั้ง 3 ขั้นในหน้าเดียว — stage → mint →
|
||||
ลิงก์ fid-only · ต้องถือ DOCVER_API_KEY จริงจึงไม่ใช่ประตูหลัง */}
|
||||
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6">
|
||||
<h3 className="font-display text-subhead text-primary">
|
||||
จำลอง e-Court — ส่งเนื้อไฟล์เข้า staging (base64)
|
||||
</h3>
|
||||
<p className="mt-1.5 text-sm text-ink-soft">
|
||||
สำหรับเอกสารที่ e-Court <strong className="font-medium">ไม่มี URL ให้ดึง</strong> —
|
||||
เลือกไฟล์แล้วกดปุ่มเดียว ระบบจะทำตาม contract จริงทั้ง 3 ขั้น:{" "}
|
||||
<Code>POST /api/note/files/stage</Code> (เนื้อไฟล์เป็น base64) →{" "}
|
||||
<Code>POST /api/note/auth/token</Code> ด้วย <Code>fid</Code> เดิม → ลิงก์{" "}
|
||||
<Code>/note?t=…</Code> (ไม่มี fileUrl) ให้กดเข้าอ่านได้เลย · ส่ง{" "}
|
||||
<Code>fid</Code> เดิมซ้ำ = แทนที่เนื้อล่าสุด (annotation เดิมอยู่ครบ)
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<MockStageForm />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Flow ─────────────────────────────────────────────────────────── */}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue