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>
This commit is contained in:
Your Name 2026-10-05 11:30:56 +07:00
parent e4ecc026b0
commit c08aaab1ed
5 changed files with 60 additions and 3 deletions

View file

@ -0,0 +1,36 @@
import { Buffer } from "node:buffer";
import { describe, expect, it } from "vitest";
import { decodeUploadFilename } from "@/utils/upload-name";
/** จำลองสิ่งที่ undici ทำกับชื่อไฟล์: ถอดรหัส UTF-8 bytes เป็น latin1 (mojibake) */
function mojibake(name: string): string {
return Buffer.from(name, "utf8").toString("latin1");
}
describe("decodeUploadFilename", () => {
it("ชื่อไทยที่ undici ถอดเป็น latin1 → คืนชื่อเดิม", () => {
expect(decodeUploadFilename(mojibake("คำร้องทดสอบอัปโหลด.pdf"))).toBe(
"คำร้องทดสอบอัปโหลด.pdf",
);
});
it("ชื่อไทยผสมตัวเลข/วงเล็บ/ช่องว่าง", () => {
expect(decodeUploadFilename(mojibake("คดีดำที่ 1234 (สำเนา).pdf"))).toBe(
"คดีดำที่ 1234 (สำเนา).pdf",
);
});
it("ชื่ออังกฤษล้วนไม่เปลี่ยน (รอบ latin1⇄utf8 เป็น identity)", () => {
expect(decodeUploadFilename("scan-2026_final.PDF")).toBe("scan-2026_final.PDF");
});
it("ชื่อที่ส่งมาถูกต้องอยู่แล้ว (ไทย ≥ U+0E00 ไม่มีตัวช่วง latin1) ไม่ถูกแตะ", () => {
expect(decodeUploadFilename("คำร้อง.pdf")).toBe("คำร้อง.pdf");
});
it("ชื่อ latin1 จริง (byte เดี่ยวประกอบเป็น UTF-8 ไม่ได้) เก็บของเดิม", () => {
// "café" ที่ sender เข้ารหัส latin1 จริง: é = byte 0xE9 เดี่ยว → ถอด UTF-8 ได้ U+FFFD
expect(decodeUploadFilename("café.pdf")).toBe("café.pdf");
});
});