"use server"; // N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ // เขียนไฟล์**ใหม่**ที่ public/note/uploads/. เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal) // ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/note") พอ (สเปก PLAN-FEAT.md §7 N4) import { randomUUID } from "node:crypto"; import { mkdir, unlink, writeFile } from "node:fs/promises"; import path from "node:path"; import { revalidatePath } from "next/cache"; import { db } from "@/db"; import { files } from "@/db/schema"; import { normDocumentGroup } from "@/utils/external-file"; import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind"; import { decodeUploadFilename } from "@/utils/upload-name"; const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs) const UPLOAD_DIR = path.join(process.cwd(), "public", "note", "uploads"); export type UploadFileResult = | { ok: true; fileId: string; title: string } | { ok: false; error: string }; /** นามสกุล (ตัวเล็ก มีจุด) จากชื่อไฟล์ที่ browser ส่ง — ต้องอยู่ใน whitelist */ function allowedExt(name: string): string | null { const dot = name.lastIndexOf("."); if (dot <= 0) return null; const ext = name.slice(dot).toLowerCase(); return SAMPLE_FILE_EXTENSIONS.includes(ext) ? ext : null; } /** title สำหรับแสดงในรายการ: ชื่อไฟล์เดิม ตัด path ที่ฝังมา จำกัดความยาว */ function sanitizeTitle(name: string): string { const base = name.split(/[\\/]/).pop() ?? name; return base.trim().slice(0, 200) || "ไฟล์ไม่มีชื่อ"; } /** ตรวจ magic bytes ตรงกับ extension — กันไฟล์เปลี่ยนชื่อนามสกุล/ไฟล์เสี้ยวจากการโอน * เข้าระบบแล้วไปพังทีหลังทั้ง viewer (ค้างโหลด) และ export (500) */ function matchesSignature(bytes: Buffer, ext: string): boolean { switch (ext) { case ".pdf": return bytes.subarray(0, 5).toString("latin1") === "%PDF-"; case ".jpg": case ".jpeg": return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff; case ".png": return ( bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47 ); case ".tif": case ".tiff": return ( (bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) || // II*\0 (bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a) // MM\0* ); default: return false; } } export async function uploadFile(formData: FormData): Promise { const file = formData.get("file"); if (!(file instanceof File)) { return { ok: false, error: "เลือกไฟล์ก่อนอัปโหลด" }; } if (file.size === 0) { return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" }; } // ชื่อไฟล์ที่ browser ส่งมาโดน undici ถอดรหัสเป็น latin1 (ชื่อไทยเพี้ยน) — // ถอดกลับก่อนใช้ทั้งตรวจ extension และตั้ง title const fileName = decodeUploadFilename(file.name); const ext = allowedExt(fileName); if (!ext) { return { ok: false, error: `รองรับเฉพาะไฟล์ ${SAMPLE_FILE_EXTENSIONS.join(" / ")}`, }; } if (file.size > MAX_UPLOAD_BYTES) { return { ok: false, error: "ไฟล์ใหญ่เกิน 100MB" }; } // uuid เป็นทั้งชื่อไฟล์บนดิสก์และ id ของ row — ไม่ใช้ชื่อผู้ใช้ตกแต่ง path เด็ดขาด const id = randomUUID(); const diskName = `${id}${ext}`; const bytes = Buffer.from(await file.arrayBuffer()); if (!matchesSignature(bytes, ext)) { return { ok: false, error: "เนื้อไฟล์ไม่ตรงกับนามสกุล (ไฟล์อาจเสียหรือถูกเปลี่ยนชื่อ) — ตรวจไฟล์แล้วลองใหม่", }; } await mkdir(UPLOAD_DIR, { recursive: true }); const diskPath = path.join(UPLOAD_DIR, diskName); await writeFile(diskPath, bytes); const title = sanitizeTitle(fileName); const pdfUrl = `/uploads/${diskName}`; const typeId = normDocumentGroup(formData.get("typeId")); try { await db.insert(files).values({ id, title, pdfUrl, typeId }); } catch (err) { // DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น) await unlink(diskPath).catch(() => undefined); throw err; } revalidatePath("/note"); return { ok: true, fileId: id, title }; }