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

16
utils/upload-name.ts Normal file
View file

@ -0,0 +1,16 @@
// N4 — ถอดรหัสชื่อไฟล์ที่ FormData ของ Server Action ส่งมา
// browser ใส่ชื่อไฟล์ (UTF-8) ลง header Content-Disposition ของ multipart body ตรง ๆ
// แต่ undici ของ Node (ตัว parse request.formData() ที่ Next.js ใช้) ถอดรหัส header
// เป็น latin1 ทำให้ชื่อที่ไม่ใช่ ASCII เพี้ยน ("คำร้อง.pdf" → "คำร้อง.pdf")
// — เข้ารหัสกลับเป็น byte แล้วถอดด้วย UTF-8 จะได้ชื่อเดิมคืน
// guard 2 ชั้นกันแตะชื่อที่ถูกต้องอยู่แล้ว: ชื่อที่ undici ถอดมามีแต่ตัวอักษร
// ช่วง U+0000–U+00FF เท่านั้น ดังนั้นไม่มีตัวไหนใน U+0080–U+00FF เลย = ASCII ล้วนหรือ
// ชื่อที่ส่งมาถูกต้อง (ไทยอยู่ U+0E00+) = ไม่แต้อยู่เดิม / ถอดแล้วได้ U+FFFD
// (ไบต์ไม่ประกอบเป็น UTF-8 ที่สมบูรณ์ — ชื่อเป็น latin1 จริง) = เก็บของเดิมไว้
const LATIN1_MOJIBAKE_RE = /[\u0080-ÿ]/;
export function decodeUploadFilename(name: string): string {
if (!LATIN1_MOJIBAKE_RE.test(name)) return name;
const fixed = Buffer.from(name, "latin1").toString("utf8");
return fixed.includes("<22>") ? name : fixed;
}