- 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>
69 lines
3.8 KiB
TypeScript
69 lines
3.8 KiB
TypeScript
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",
|
|
},
|
|
});
|
|
}
|