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:
parent
21b9958600
commit
a4cac15e42
9 changed files with 571 additions and 1 deletions
69
app/uploads/[name]/route.ts
Normal file
69
app/uploads/[name]/route.ts
Normal 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",
|
||||
},
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue