fix(deploy): อัปโหลด/เปิดไฟล์ใน Docker ไม่ได้ — 3 จุดรวมกัน

- app/uploads/[name]/route.ts: Next production (next start/standalone) อ่านรายชื่อ
  public/ เก็บใน Set ครั้งเดียวตอน startup เท่านั้น ไฟล์ที่ uploadFile เขียน
  ตอน runtime GET /uploads/* แล้ว 404 (dev ไม่ผ่าน branch นี้ = local ผ่าน
  Docker พัง) → เสิร์ฟผ่าน route handler แทน: path guard lib/server-pdf +
  whitelist ext + content-type จาก utils/file-kind + cache immutable
- drizzle/0001: bookmarks.user_name (N6) + backfill ตาม PLAN-BOOKMARK.md —
  image ใช้ migrate.mjs จากไฟล์ SQL ไม่ใช่ drizzle push ของ dev ทำให้ schema
  ตกรุ่นจนหน้า annotate 500 ทั้งหน้า; แก้ schema.ts ครั้งต่อไปต้อง drizzle-kit
  generate ทุกครั้ง (กติกาใหม่ใน .claude/rules/data-layer.md)
- docker/web.Dockerfile: mkdir public/uploads + chown nextjs — volume ที่
  compose mount ทับได้ ownership จาก image (ไม่งั้น root เป็นเจ้าของ = EACCES)
  + .dockerignore กัน public/uploads ของเครื่อง dev ติดไปใน image
- docker-compose.test.yml: compose ทดสอบบนเครื่อง (port 6704 + volume uploads)

verify บน Docker จริง: migration apply บน DB เดิมได้, annotate 500→200,
Playwright อัปโหลด JPG+PDF→เปิด viewer ได้ 7/7, tsc ผ่าน, vitest 120/120

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Your Name 2026-09-16 15:57:06 +07:00
parent 21b9958600
commit a4cac15e42
9 changed files with 571 additions and 1 deletions

View file

@ -0,0 +1,69 @@
import { readFile } from "node:fs/promises";
import {
fileKindFromUrl,
imageContentTypeFromUrl,
SAMPLE_FILE_EXTENSIONS,
} from "@/utils/file-kind";
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/uploads/<uuid>.<ext>`) ผ่าน route handler
// ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action
// เขียนลง public/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/pdf (§5)
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
// นอก hex/ขีด/จุด ผ่าน regex นี้ไม่ได้)
const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
export const dynamic = "force-dynamic";
export async function GET(
_req: Request,
{ params }: { params: { name: string } }
) {
const dot = params.name.lastIndexOf(".");
if (dot <= 0) return new Response(null, { status: 404 });
const [id, ext] = [
params.name.slice(0, dot),
params.name.slice(dot).toLowerCase(),
];
if (!UUID_RE.test(id)) return new Response(null, { status: 404 });
// ext ต้องอยู่ใน whitelist เดียวกับ uploadFile — ชื่ออื่น 404 ไม่เสิร์ฟ
if (!SAMPLE_FILE_EXTENSIONS.includes(ext)) {
return new Response(null, { status: 404 });
}
// helper เหล่านี้คาดชื่อไฟล์เต็ม (จุดต้องอยู่หลังอักขระตัวแรก) — ส่งชื่อเต็ม ไม่ใช่ bare ext
const contentType =
fileKindFromUrl(params.name) === "image"
? imageContentTypeFromUrl(params.name)
: "application/pdf";
let bytes: Buffer;
try {
// guard: resolve แล้วต้องอยู่ใน public/ เท่านั้น (รูปแบบชื่อผ่าน regex อยู่แล้ว
// — ชั้นนี้กันไว้เผื่อมีคนเรียก helper นี้ซ้ำในอนาคตด้วย input อื่น)
const diskPath = resolvePublicPdfPath(`/uploads/${id}${ext}`);
bytes = await readFile(diskPath);
} catch (err) {
if (err instanceof PdfPathError) return new Response(null, { status: 404 });
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
return new Response(null, { status: 404 });
}
throw err;
}
return new Response(new Uint8Array(bytes), {
headers: {
"Content-Type": contentType,
// ชื่อไฟล์เป็น uuid ต่อการอัปโหลดหนึ่งครั้ง ไม่มีการเขียนทับ — ค่าเดียวกันตลอดชีวิตไฟล์
"Cache-Control": "public, max-age=31536000, immutable",
},
});
}