2026-09-15 14:12:51 +07:00
|
|
|
"use server";
|
|
|
|
|
|
|
|
|
|
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
|
|
|
|
|
// เขียนไฟล์**ใหม่**ที่ public/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
|
|
|
|
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/") พอ (สเปก PLAN-FEAT.md §7 N4)
|
|
|
|
|
import { randomUUID } from "node:crypto";
|
|
|
|
|
import { mkdir, unlink, writeFile } from "node:fs/promises";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
|
|
|
|
|
import { revalidatePath } from "next/cache";
|
|
|
|
|
|
|
|
|
|
import { db } from "@/db";
|
|
|
|
|
import { files } from "@/db/schema";
|
genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00
|
|
|
import { normDocumentGroup } from "@/utils/external-file";
|
2026-09-15 14:12:51 +07:00
|
|
|
import { SAMPLE_FILE_EXTENSIONS } from "@/utils/file-kind";
|
2026-10-05 11:30:56 +07:00
|
|
|
import { decodeUploadFilename } from "@/utils/upload-name";
|
2026-09-15 14:12:51 +07:00
|
|
|
|
|
|
|
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB (ตาม bodySizeLimit ใน next.config.mjs)
|
|
|
|
|
const UPLOAD_DIR = path.join(process.cwd(), "public", "uploads");
|
|
|
|
|
|
|
|
|
|
export type UploadFileResult =
|
|
|
|
|
| { ok: true; fileId: string; title: string }
|
|
|
|
|
| { ok: false; error: string };
|
|
|
|
|
|
|
|
|
|
/** นามสกุล (ตัวเล็ก มีจุด) จากชื่อไฟล์ที่ browser ส่ง — ต้องอยู่ใน whitelist */
|
|
|
|
|
function allowedExt(name: string): string | null {
|
|
|
|
|
const dot = name.lastIndexOf(".");
|
|
|
|
|
if (dot <= 0) return null;
|
|
|
|
|
const ext = name.slice(dot).toLowerCase();
|
|
|
|
|
return SAMPLE_FILE_EXTENSIONS.includes(ext) ? ext : null;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** title สำหรับแสดงในรายการ: ชื่อไฟล์เดิม ตัด path ที่ฝังมา จำกัดความยาว */
|
|
|
|
|
function sanitizeTitle(name: string): string {
|
|
|
|
|
const base = name.split(/[\\/]/).pop() ?? name;
|
|
|
|
|
return base.trim().slice(0, 200) || "ไฟล์ไม่มีชื่อ";
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** ตรวจ magic bytes ตรงกับ extension — กันไฟล์เปลี่ยนชื่อนามสกุล/ไฟล์เสี้ยวจากการโอน
|
|
|
|
|
* เข้าระบบแล้วไปพังทีหลังทั้ง viewer (ค้างโหลด) และ export (500) */
|
|
|
|
|
function matchesSignature(bytes: Buffer, ext: string): boolean {
|
|
|
|
|
switch (ext) {
|
|
|
|
|
case ".pdf":
|
|
|
|
|
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
|
|
|
|
case ".jpg":
|
|
|
|
|
case ".jpeg":
|
|
|
|
|
return bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff;
|
|
|
|
|
case ".png":
|
|
|
|
|
return (
|
|
|
|
|
bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47
|
|
|
|
|
);
|
|
|
|
|
case ".tif":
|
|
|
|
|
case ".tiff":
|
|
|
|
|
return (
|
|
|
|
|
(bytes[0] === 0x49 && bytes[1] === 0x49 && bytes[2] === 0x2a && bytes[3] === 0x00) || // II*\0
|
|
|
|
|
(bytes[0] === 0x4d && bytes[1] === 0x4d && bytes[2] === 0x00 && bytes[3] === 0x2a) // MM\0*
|
|
|
|
|
);
|
|
|
|
|
default:
|
|
|
|
|
return false;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export async function uploadFile(formData: FormData): Promise<UploadFileResult> {
|
|
|
|
|
const file = formData.get("file");
|
|
|
|
|
if (!(file instanceof File)) {
|
|
|
|
|
return { ok: false, error: "เลือกไฟล์ก่อนอัปโหลด" };
|
|
|
|
|
}
|
|
|
|
|
if (file.size === 0) {
|
|
|
|
|
return { ok: false, error: "ไฟล์ว่าง (0 ไบต์) — เลือกไฟล์ที่สมบูรณ์" };
|
|
|
|
|
}
|
2026-10-05 11:30:56 +07:00
|
|
|
// ชื่อไฟล์ที่ browser ส่งมาโดน undici ถอดรหัสเป็น latin1 (ชื่อไทยเพี้ยน) —
|
|
|
|
|
// ถอดกลับก่อนใช้ทั้งตรวจ extension และตั้ง title
|
|
|
|
|
const fileName = decodeUploadFilename(file.name);
|
|
|
|
|
const ext = allowedExt(fileName);
|
2026-09-15 14:12:51 +07:00
|
|
|
if (!ext) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: `รองรับเฉพาะไฟล์ ${SAMPLE_FILE_EXTENSIONS.join(" / ")}`,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
|
|
|
return { ok: false, error: "ไฟล์ใหญ่เกิน 100MB" };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// uuid เป็นทั้งชื่อไฟล์บนดิสก์และ id ของ row — ไม่ใช้ชื่อผู้ใช้ตกแต่ง path เด็ดขาด
|
|
|
|
|
const id = randomUUID();
|
|
|
|
|
const diskName = `${id}${ext}`;
|
|
|
|
|
const bytes = Buffer.from(await file.arrayBuffer());
|
|
|
|
|
if (!matchesSignature(bytes, ext)) {
|
|
|
|
|
return {
|
|
|
|
|
ok: false,
|
|
|
|
|
error: "เนื้อไฟล์ไม่ตรงกับนามสกุล (ไฟล์อาจเสียหรือถูกเปลี่ยนชื่อ) — ตรวจไฟล์แล้วลองใหม่",
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await mkdir(UPLOAD_DIR, { recursive: true });
|
|
|
|
|
const diskPath = path.join(UPLOAD_DIR, diskName);
|
|
|
|
|
await writeFile(diskPath, bytes);
|
|
|
|
|
|
2026-10-05 11:30:56 +07:00
|
|
|
const title = sanitizeTitle(fileName);
|
2026-09-15 14:12:51 +07:00
|
|
|
const pdfUrl = `/uploads/${diskName}`;
|
genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00
|
|
|
const typeId = normDocumentGroup(formData.get("typeId"));
|
2026-09-15 14:12:51 +07:00
|
|
|
try {
|
genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00
|
|
|
await db.insert(files).values({ id, title, pdfUrl, typeId });
|
2026-09-15 14:12:51 +07:00
|
|
|
} catch (err) {
|
|
|
|
|
// DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น)
|
|
|
|
|
await unlink(diskPath).catch(() => undefined);
|
|
|
|
|
throw err;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
revalidatePath("/");
|
|
|
|
|
return { ok: true, fileId: id, title };
|
|
|
|
|
}
|