eCourt-note/app/actions/files.ts
Your Name c08aaab1ed fix(upload): decode mojibake of Thai filenames
อัปโหลดไฟล์ชื่อไทยแล้ว title ที่แสดงในรายการเพี้ยนเป็น latin1
mojibake ("คำร้องทดสอบอัปโหลด.pdf" → "คำรเน‰à¸­à¸‡…") — คำขอผู้ใช้ 2026-10-05

(1) สาเหตุ: browser ใส่ชื่อไฟล์ (UTF-8) ลง header Content-Disposition
    ของ multipart body ตรง ๆ แต่ undici ของ Node (ตัว parse
    request.formData() ที่ Next.js ใช้) ถอดรหัส header เป็น latin1
    ทำให้ file.name ใน Server Action เพี้ยนก่อนถึงโค้ด — เนื้อไฟล์
    ไม่เกี่ยว (ผ่าน arrayBuffer() ตรง ๆ) พิสูจน์ด้วย Playwright
    อัปโหลดจริงก่อนแก้ได้สตริง mojibake ตรงกับที่ผู้ใช้รายงาน

(2) แก้: utils/upload-name.ts ใหม่ — decodeUploadFilename เข้ารหัส
    ชื่อกลับเป็น byte แล้วถอดด้วย UTF-8 ให้คืนชื่อเดิม พร้อม guard
    2 ชั้นกันแตะชื่อที่ถูกต้องอยู่แล้ว: ไม่มีตัวอักษรช่วง
    U+0080–U+00FF เลย = ASCII ล้วน/ชื่อถูกอยู่แล้ว (ไทย ≥ U+0E00)
    ไม่แต้อยู่เดิม / ถอดแล้วได้ U+FFFD (ไบต์ไม่ประกอบ UTF-8 สมบูรณ์
    = ชื่อเป็น latin1 จริง) เก็บของเดิม — uploadFile เรียกครั้งเดียว
    ที่จุดเข้า ก่อนใช้ทั้ง allowedExt และ sanitizeTitle

(3) tests: tests/utils/upload-name.test.ts ใหม่ 5 เคส (ชื่อไทย
    mojibake → คืนเดิม / ไทยผสมตัวเลขวงเล็บ / ASCII ไม่เปลี่ยน /
    ชื่อถูกอยู่แล้วไม่ถูกแตะ / latin1 จริงเก็บของเดิม)

(4) code review 2026-10-05: ยอมรับความเสี่ยง false positive กรณีชื่อ
    latin1 จริงที่ byte คู่กันบังเอิญประกอบเป็น UTF-8 ครบ ("¢.pdf")
    — แคบมากเพราะ browser ส่งชื่อเป็น UTF-8 เสมอ และ heuristic นี้
    คือ workaround มาตรฐานของปัญหา undici (บีบ guard เพิ่มจะทำให้
    ชื่อ latin1-accent ที่โดน mojibake จริง ๆ กลับไม่ถูกซ่อม)

(5) docs sync: CLAUDE.md (tree entry) + rules/data-layer.md (กติกา
    uploadFile)

ผ่าน tsc + vitest + ยืนยันใน browser ว่าการ์ดแสดงชื่อไทยตรงเป๊ะ

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-05 11:30:56 +07:00

113 lines
5.4 KiB
TypeScript

"use server";
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก 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", "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<UploadFileResult> {
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("/");
return { ok: true, fileId: id, title };
}