// PLAN-DOCVER.md §1 — GET /api/documents/latest?type=docx|pdf&ticket_data= // คืนไฟล์ของ version ล่าสุดให้ backend e-Court (server-to-server): // - type=pdf → ไบต์ pdf ตรง ๆ (version ล่าสุดไม่มี pdf = 404) // - type=docx → stamp ticket ใหม่ (exp + HmacSha256 + Ed25519 เมื่อมี private key) // ลงสำเนาในหน่วยความจำก่อนคืน — ไม่เขียนกลับ MinIO (non-destructive) // - auth: header x-api-key เทียบ env DOCVER_API_KEY หรือ EXTERNAL_API_KEY // (ต่างจาก /token ที่รับ DOCVER_API_KEY อย่างเดียว) · docId = fid ใน ticket_data // - secret ของ HMAC = addinTicketSecret() ให้ตรงฝั่ง verify ของ add-in / // POST /api/documents/token ทาง ticket · TTL = env TICKET_SEC (default 300) // carve-out invariant #4: route handler ของกลุ่ม docver — ไม่ publish SSE // ไม่ revalidatePath (ไม่มีหน้า note ใดอ่านตารางของกลุ่มนี้) import { addinTicketSecret } from "@/lib/addin-ticket"; import { contentDispositionValue, docverError, DOCX_CONTENT_TYPE, isDocxBytes, isPdfBytes, parseLatestTypeParam, PDF_CONTENT_TYPE, } from "@/lib/document-versioning"; import { requireDocumentApiKey } from "@/lib/docver-token"; import { findLatestVersionForDoc, getVersionObjectBytes, } from "@/lib/docver-store"; import { buildStampedTicketData, stampTicketProps } from "@/lib/lib-tmpl"; import { parseTicketDataParam } from "@/utils/docver-ticket"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; const GENERIC_500 = "เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง"; export async function GET(request: Request) { const auth = requireDocumentApiKey(request); if (auth !== null) return auth; let searchParams: URLSearchParams; try { searchParams = new URL(request.url).searchParams; } catch { return docverError("URL ของ request ไม่ถูกต้อง", 400); } const type = parseLatestTypeParam(searchParams.get("type")); if (!type.ok) return docverError(type.error, 400); const ticket = parseTicketDataParam(searchParams.get("ticket_data")); if (!ticket.ok) return docverError(ticket.error, 400); // เติม exp + เรียง key + เช็คความยาวก่อนแตะ MinIO — Word ตัดทอนค่า custom // property ที่ 255 ตัวอักษร คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ ปฏิเสธไปเลยดีกว่า let ticketData: string | null = null; if (type.type === "docx") { const ttl = Number(process.env.TICKET_SEC); const ttlSeconds = Number.isFinite(ttl) && ttl > 0 ? ttl : 300; ticketData = buildStampedTicketData(ticket.ticket, ttlSeconds); if (ticketData.length > 255) { return docverError( "ticket_data ยาวเกิน 255 ตัวอักษร (ข้อจำกัดของ Word custom property)", 400, ); } } let row; try { row = await findLatestVersionForDoc(ticket.ticket.fid); } catch (err) { console.error("docver: ค้น version ล่าสุดล้ม", err); return docverError(GENERIC_500, 500); } if (!row) { return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404); } if (type.type === "pdf" && row.pdfObjectKey === null) { return docverError("version นี้ไม่มีสำเนา PDF", 404); } let bytes: Buffer; try { bytes = await getVersionObjectBytes(row.id, type.type); } catch (err) { console.error("docver: ดึง object จาก MinIO ล้ม", err); return docverError(GENERIC_500, 500); } // object เขียนผ่านกลุ่มนี้เองตอน upload (ผ่าน magic bytes แล้ว) — ไม่ตรงตอนนี้ // = ความผิดปกติของพื้นที่เก็บ ไม่ใช่ความผิดของผู้เรียก จึงเป็น 500 if (type.type === "docx" ? !isDocxBytes(bytes) : !isPdfBytes(bytes)) { console.error(`docver: object ของ version ${row.id} magic bytes ไม่ตรงชนิดไฟล์`); return docverError(GENERIC_500, 500); } if (type.type === "docx") { // ticketData ถูกตั้งค่าแล้วเสมอเมื่อ type=docx (ด้านบน) — ห้ามกลืน error: // ไฟล์ไม่มี stamp = add-in เปิดไม่ได้ ต้องตอบ 500 ไม่ใช่คืนไฟล์เสียเงียบ ๆ try { bytes = stampTicketProps(bytes, ticketData!, { secret: addinTicketSecret(), privateKeyHex: process.env.PRIVATE_KEY_HEX?.trim() || null, }).buffer; } catch (err) { console.error("docver: stamp ticket ล้ม", err); return docverError(GENERIC_500, 500); } } return new Response( // Uint8Array ใหม่ (ก็อปปี้) ตามที่ BodyInit รับ — pattern เดียวกับ route pdf/export new Uint8Array(bytes), { headers: { "content-type": type.type === "docx" ? DOCX_CONTENT_TYPE : PDF_CONTENT_TYPE, "content-disposition": contentDispositionValue(row.fileName, type.type), "cache-control": "no-store, max-age=0", }, }, ); }