From c08aaab1eda112f4c7bcc531730e154cba7c395b Mon Sep 17 00:00:00 2001 From: Your Name Date: Mon, 5 Oct 2026 11:30:56 +0700 Subject: [PATCH] fix(upload): decode mojibake of Thai filenames MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit อัปโหลดไฟล์ชื่อไทยแล้ว 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 --- .claude/rules/data-layer.md | 2 +- CLAUDE.md | 1 + app/actions/files.ts | 8 ++++++-- tests/utils/upload-name.test.ts | 36 +++++++++++++++++++++++++++++++++ utils/upload-name.ts | 16 +++++++++++++++ 5 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 tests/utils/upload-name.test.ts create mode 100644 utils/upload-name.ts diff --git a/.claude/rules/data-layer.md b/.claude/rules/data-layer.md index 0f258aa..a6fb346 100644 --- a/.claude/rules/data-layer.md +++ b/.claude/rules/data-layer.md @@ -20,6 +20,6 @@ paths: - ตาราง `annotation_versions` (F19/A7): `annotation_id` เป็น uuid **ไม่ FK** (D8 — snapshot ตอน delete ต้องรอดจาก CASCADE ของแม่) + `file_id` FK CASCADE + `page_index` (restore ต้องรู้หน้า) + `kind` text `'update' | 'delete'` + index `(file_id)`; `updateAnnotation`/`deleteAnnotation` snapshot row เดิม **ก่อนเขียน/ลบ ใน transaction เดียวกัน**; `restoreVersion(versionId, userId)` — **เจ้าของเท่านั้น (D9)**: guard `userId` ทั้ง row ปัจจุบัน (ต้นทางยังอยู่) และ snapshot (ต้นทางถูกลบ) / ต้นทางยังอยู่ = **เขียนกลับ row เดิม** (snapshot ปัจจุบันก่อน) + publish update event / ต้นทางถูกลบ = สร้างใหม่ id ใหม่ + publish create event (ห้ามสร้างสำเนาใหม่ทุกกรณี — เคยทำให้เกิดรายการซ้ำบนหน้า) - ห้ามเพิ่มตาราง/column ที่ไม่มีในแผน (ดู out of scope §9) โดยไม่อัปเดต `docs/PLAN.md` ก่อน - `files.pdf_url` เก็บ path **รูปภาพ** (jpg/jpeg/png/tif/tiff) ใน `public/` ได้เหมือน PDF (N3) — **ไม่มี column kind**: ชนิดไฟล์ derive จาก extension ผ่าน `utils/file-kind.ts` เท่านั้น (จะเพิ่ม column ต้องแก้แผนก่อน); TIFF หลายเฟรม = `pageIndex` ต่อเฟรม (1 เฟรม = 1 หน้า) -- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` +- Server Action `uploadFile` (`app/actions/files.ts`, N4): validate extension (whitelist `SAMPLE_FILE_EXTENSIONS`) + ≤ 100MB ก่อนเขียน — ชื่อไฟล์ต้องผ่าน `decodeUploadFilename` ของ `utils/upload-name.ts` ก่อนใช้ (ทั้ง extension และ title): undici ถอด `Content-Disposition` เป็น latin1 ทำชื่อ UTF-8 เพี้ยน (ชื่อไทยกลายเป็น "คำ…" — 2026-10-05) — บันทึกเป็นไฟล์**ใหม่** `public/uploads/.` เท่านั้น (ชื่อจาก randomUUID — ห้ามใช้ชื่อผู้ใช้ตกแต่ง path), insert row (รวม `type_id` จากช่องกลุ่มทางเลือกของฟอร์ม — 2026-09-23) แล้ว `revalidatePath("/")` — ไม่ publish SSE event (รายการไฟล์ไม่ได้ sync ผ่าน SSE); seed-samples จัดการเฉพาะ `/samples/%` ไม่แตะ `/uploads/` - `files.type_id` (N9 — รหัสกลุ่มเอกสาร จากลิงก์ e-Court `?typeId=` หรือช่อง "กลุ่ม typeId" ของฟอร์มอัปโหลด N4): **เขียนสองที่** — (1) `registerExternalFile` ของ N8 (insert ครั้งแรก + update เมื่อลิงก์รอบใหม่ส่ง `typeId` มา — ลิงก์ไม่ส่ง = คงเดิม ห้ามเคลียร์) (2) `uploadFile` ของ N4 ช่องทางเลือกในฟอร์มอัปโหลด — insert ตอนสร้าง row ใหม่เท่านั้น ไม่มีหน้าทางแก้กลุ่มของไฟล์เดิม; normalize ผ่าน `normDocumentGroup` ของ `utils/external-file.ts` ที่เดียว (trim → ว่าง = NULL, cap 200 — responsive audit 2026-09-24 รวมจากเดิมที่เป็นสองสำเนา mirroring กัน) — N9 อ่านอย่างเดียว (หน้า `/` จัด section + `/groups/[typeId]`) ไม่มี Server Action/SSE ใหม่; กลุ่ม = ค่า distinct ของ `type_id` ณ ตอน query — ไม่มีตารางกลุ่ม - API ภายนอก (N10 — `app/api/external/documents` เส้นเดียว **POST + JSON body** `{fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}}` — ส่งทั้งคู่ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข): **read-only ทั้งหมด** — ไม่เขียน DB ไม่ publish SSE event ไม่ `revalidatePath` (snapshot ณ ตอนเรียก ต่าง context กับ Server Action ใน invariant #4); auth ผ่าน `requireExternalApiKey` ของ `lib/external-api-auth.ts` เป็นอย่างแรกก่อนแตะ DB เสมอ (header `x-api-key` เทียบ env `EXTERNAL_API_KEY` แบบ timing-safe) — **env ว่าง/ไม่ตั้ง = fail closed 401 ทุก request** ไม่มี mode เปิดฟรี; ผู้เรียกคือ backend e-Court (server-to-server) จึงไม่ใส่ CORS headers; validation ของ body (`parseQueryBody`) + การ query/mapper (`loadDocumentBundles` — bulk `inArray` ทุกไฟล์ในชุด query เดียว ไม่ยิงต่อไฟล์) รวมอยู่ `lib/external-api.ts` จุดเดียว ห้ามแยกสำเนา; route ครอบ try/catch ทุก DB call — query ล้ม = log ฝั่ง server + **500 JSON ตาม contract** ของ endpoint (ไม่ปล่อยเป็น 500 ของ framework); `lib/external-api-auth.ts` ห้าม import module ที่ลาก `@/db` มา (unit test ต้องรันได้ไม่ต้องตั้ง DATABASE_URL) diff --git a/CLAUDE.md b/CLAUDE.md index fa7662b..d5b2f9f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -53,6 +53,7 @@ utils/user.ts ← parse/build ค่า cookie oacCase → OacUser {user utils/external-file.ts ← N8 validation ลิงก์ไฟล์รีโมต (allowlist "admincourt.go.th" + env EXTERNAL_FILE_HOSTS, suffix match, https เท่านั้น ห้าม user:pass@) + titleFromExternalUrl/externalIdFor/isHttpUrl + normDocumentGroup (N9 normalize รหัสกลุ่ม — ใช้ร่วม N8/N4, pure) utils/base-path.ts ← withBasePath() เติม basePath /docedit/note ให้ URL ฝั่ง browser ที่ Next ไม่เติมให้เอง (ค่าจาก next.config.mjs ผ่าน env — invariant #12) utils/file-kind.ts ← derive ชนิดไฟล์ pdf/image จาก extension ของ path (N3 — ใช้ร่วม viewer/export/หน้ารายการ/seed) +utils/upload-name.ts ← N4 ถอดรหัสชื่อไฟล์จาก FormData ของ Server Action กลับเป็น UTF-8 ก่อนใช้ (undici ถอด header Content-Disposition เป็น latin1 ทำชื่อไทยเพี้ยน "คำ…" — encode กลับ byte แล้วถอด UTF-8; guard: ไม่มีตัว U+0080–U+00FF = ไม่แตะ, ถอดแล้วได้ U+FFFD = เก็บของเดิม, 2026-10-05) utils/tiff-client.ts ← decode TIFF ทุกเฟรม → PNG blob URL ฝั่ง client (N3) utils/image-to-pdf.ts ← สร้าง PDF ใหม่จากไฟล์รูป embedJpg/embedPng + EXIF rotate + TIFF→PNG (N3, export route) hooks/useAnnotationRealtime.ts ← EventSource ต่อ SSE stream + dedupe ด้วย id diff --git a/app/actions/files.ts b/app/actions/files.ts index 40b013d..72d63b9 100644 --- a/app/actions/files.ts +++ b/app/actions/files.ts @@ -13,6 +13,7 @@ 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"); @@ -67,7 +68,10 @@ export async function uploadFile(formData: FormData): Promise if (file.size === 0) { return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" }; } - const ext = allowedExt(file.name); + // ชื่อไฟล์ที่ browser ส่งมาโดน undici ถอดรหัสเป็น latin1 (ชื่อไทยเพี้ยน) — + // ถอดกลับก่อนใช้ทั้งตรวจ extension และตั้ง title + const fileName = decodeUploadFilename(file.name); + const ext = allowedExt(fileName); if (!ext) { return { ok: false, @@ -93,7 +97,7 @@ export async function uploadFile(formData: FormData): Promise const diskPath = path.join(UPLOAD_DIR, diskName); await writeFile(diskPath, bytes); - const title = sanitizeTitle(file.name); + const title = sanitizeTitle(fileName); const pdfUrl = `/uploads/${diskName}`; const typeId = normDocumentGroup(formData.get("typeId")); try { diff --git a/tests/utils/upload-name.test.ts b/tests/utils/upload-name.test.ts new file mode 100644 index 0000000..2f0b9ab --- /dev/null +++ b/tests/utils/upload-name.test.ts @@ -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"); + }); +}); diff --git a/utils/upload-name.ts b/utils/upload-name.ts new file mode 100644 index 0000000..f503a5d --- /dev/null +++ b/utils/upload-name.ts @@ -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("�") ? name : fixed; +}