// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning) // pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่ // ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน // // ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้ // โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts) import { decodeUploadFilename } from "@/utils/upload-name"; /** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ * (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */ export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024; /** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */ export const DOCVER_MESSAGE_MAX = 2048; export const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; export const PDF_CONTENT_TYPE = "application/pdf"; /** รหัสเอกสารจากผู้เรียก — URL-path-safe ล้วน (ไม่มี / .. ช่องว่าง ไทย) กันถูก * ฝังลง path/log แปลก ๆ และตัดขนาดให้จำกัด */ const DOC_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/; const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ */ const FORM_FIELDS = [ "file", "pdf", "userId", "userName", "clientVersionId", "fields", "message", "templateId", "fileName", ] as const; export type DocverError = { ok: false; error: string }; /** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10 * (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */ export function docverError(message: string, status: number): Response { return new Response(JSON.stringify({ error: message }), { status, headers: { "content-type": "application/json; charset=utf-8" }, }); } /** ตรวจ docId จาก path param — trim แล้วว่าง/เกิน 200/มีตัวอักษรนอก charset = ไม่ผ่าน * (+ ปฏิเสธ "." / ".." เป็นพิเศษแม้อยู่ใน charset — path-traversal specials) */ export function parseDocId(raw: string): | { ok: true; docId: string } | DocverError { const docId = raw.trim(); if (docId === "." || docId === ".." || !DOC_ID_RE.test(docId)) { return { ok: false, error: "รหัสเอกสาร (docId) ต้องยาว 1–200 ตัวอักษร และใช้ได้เฉพาะ A-Z a-z 0-9 . _ : -", }; } return { ok: true, docId }; } /** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ * matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */ export function isDocxBytes(bytes: Buffer): boolean { return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04"; } export function isPdfBytes(bytes: Buffer): boolean { return bytes.subarray(0, 5).toString("latin1") === "%PDF-"; } /** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */ export function parseVersionIdParam(raw: string): | { ok: true; versionId: string } | DocverError { const versionId = raw.trim().toLowerCase(); if (!UUID_RE.test(versionId)) { return { ok: false, error: "versionId ต้องเป็น UUID" }; } return { ok: true, versionId }; } /** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */ export function parseDownloadFormat(raw: string | null): | { ok: true; format: "docx" | "pdf" } | DocverError { const format = raw?.trim() ?? ""; if (format === "") return { ok: true, format: "docx" }; if (format === "docx" || format === "pdf") return { ok: true, format }; return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" }; } function trimOrNull(value: string): string | null { const trimmed = value.trim(); return trimmed === "" ? null : trimmed; } /** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */ function formString(form: FormData, field: string): | { ok: true; value: string | null } | DocverError { const entry = form.get(field); if (entry === null) return { ok: true, value: null }; if (typeof entry !== "string") { return { ok: false, error: `field '${field}' ต้องเป็น string` }; } return { ok: true, value: trimOrNull(entry) }; } /** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 — * ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */ export function sanitizeDisplayFileName(raw: string | null): string | null { if (raw === null) return null; const base = raw.split(/[\\/]/).pop() ?? raw; return trimOrNull(base)?.slice(0, 200) ?? null; } /** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1 * ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */ export function sanitizeFilePartName(raw: string): string | null { return sanitizeDisplayFileName(decodeUploadFilename(raw)); } /** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — * `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */ export function parseFieldsJson(raw: string): | { ok: true; fields: Record | null } | DocverError { let parsed: unknown; try { parsed = JSON.parse(raw); } catch { return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' }; } if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' }; } const record = parsed as Record; const keys = Object.keys(record); if (keys.length > 100) { return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' }; } for (const key of keys) { if (key.length > 200) { return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' }; } const value = record[key]; if (typeof value !== "string") { return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' }; } if (value.length > 10_000) { return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' }; } } return { ok: true, fields: keys.length === 0 ? null : (parsed as Record) }; } export type ParsedVersionUploadForm = { userId: string; userName: string; clientVersionId: string | null; message: string | null; templateId: string | null; /** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง * เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */ fileName: string | null; fields: Record | null; }; /** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน * size ก่อน buffer) — userId/userName บังคับเพราะระบบ stand-alone ไม่มี session */ export function parseVersionUploadForm(form: FormData): | { ok: true; form: ParsedVersionUploadForm } | DocverError { // Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง // downlevelIteration — array ธรรมดาไม่ต้อง) for (const key of Array.from(form.keys())) { if (!(FORM_FIELDS as readonly string[]).includes(key)) { return { ok: false, error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`, }; } } const userId = formString(form, "userId"); if (!userId.ok) return userId; if (userId.value === null) { return { ok: false, error: "ต้องระบุ userId (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" }; } if (userId.value.length > 100) { return { ok: false, error: "userId ยาวเกิน 100 ตัวอักษร" }; } const userName = formString(form, "userName"); if (!userName.ok) return userName; if (userName.value === null) { return { ok: false, error: "ต้องระบุ userName (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" }; } if (userName.value.length > 200) { return { ok: false, error: "userName ยาวเกิน 200 ตัวอักษร" }; } const clientVersionId = formString(form, "clientVersionId"); if (!clientVersionId.ok) return clientVersionId; if (clientVersionId.value && clientVersionId.value.length > 100) { return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" }; } const templateId = formString(form, "templateId"); if (!templateId.ok) return templateId; if (templateId.value && templateId.value.length > 100) { return { ok: false, error: "templateId ยาวเกิน 100 ตัวอักษร" }; } const message = formString(form, "message"); if (!message.ok) return message; if (message.value && message.value.length > DOCVER_MESSAGE_MAX) { return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` }; } const rawFields = formString(form, "fields"); if (!rawFields.ok) return rawFields; let fields: Record | null = null; if (rawFields.value !== null) { const parsed = parseFieldsJson(rawFields.value); if (!parsed.ok) return parsed; fields = parsed.fields; } const rawFileName = formString(form, "fileName"); if (!rawFileName.ok) return rawFileName; return { ok: true, form: { userId: userId.value, userName: userName.value, clientVersionId: clientVersionId.value, message: message.value, templateId: templateId.value, fileName: sanitizeDisplayFileName(rawFileName.value), fields, }, }; } /** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง * ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/" * "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */ const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const; export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string { const ext = `.${kind}`; const ascii = (displayName ?? "") .replace(/[^\x20-\x7E]/g, "") .replace(/["\\]/g, "") .replace(/\s+/g, " ") .trim(); let asciiName: string = DEFAULT_FILE_NAMES[kind]; if (/[A-Za-z0-9]/.test(ascii)) { asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext; } const display = displayName?.trim(); if (display) { return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`; } return `attachment; filename="${asciiName}"`; }