feat(sso): switch note session from cookies to token
เปลี่ยน session ของแอป note จาก cookie คู่ oacCase/oacCaseSig มาเป็น access_token แบบ add-in ตามคำขอผู้ใช้ (2026-10-30): - POST /api/note/auth/session แลก credential ของลิงก์ e-Court (JWT ทาง A / ticket ทาง B) เป็น access_token purpose=login อายุ 8 ชม. พร้อมลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server (fileId จาก fid) - token เก็บ localStorage "note-auth" (lib/note-session.ts) + Bearer ผ่าน noteAuthFetch · Server Actions ทุกตัวรับ token เป็น param แรก และแกะ identity เอง (lib/note-token.ts) — ไม่เชื่อ userId จาก client - หน้า protected เป็น shell + client gate (NoteHome bootstrap ที่ /note, AnnotateView + GET /api/note/files/[id]/bootstrap) - ถอด: middleware.ts, lib/session.ts, utils/user.ts, action logout, GET /api/note/auth/e-court, entry /note/annotate?fileUrl= - ทาง ticket ปฏิเสธ secret default "super_secret" เฉพาะ production - GET /api/note/files/open: ลิงก์เปิดไฟล์ตัวอย่างแบบ /tmpl (path ใน เครื่อง /samples เท่านั้น) - หน้า /note/dev: ลิงก์ A Link Get(new) ที่ mint credential แล้วยิง เข้า flow SSO จริง (JWT/ticket สลับได้) + section Flow + เรียบเรียง ภาษาให้อ่านง่าย · หน้า /note เป็นหน้าข้อมูลระบบ - แก้ตาม code review: redo closure เรียก action ด้วย signature เก่า, bootstrap timeout + strip credential ที่ผิดรูป, แยก no-session ออก จาก network error, ห้ามอักขระ & = + % ใน identity, ตรวจ purpose ก่อน exp ของ login-token, audit log strip อักขระควบคุม - เอกสาร: docs/PLAN-SSO.md §0.5 (ฉบับ token), CLAUDE.md, .env.example ทดสอบ: tsc ผ่าน · vitest ผ่านทั้งชุด · ทดสอบจริงผ่าน Playwright (SSO → viewer → เปลี่ยนสี annotation → undo → redo ครบวงจร) Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
b1449ab668
commit
d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions
|
|
@ -34,15 +34,26 @@ import {
|
|||
MAX_BOOKMARK_NOTE_CHARS,
|
||||
MAX_BOOKMARK_TITLE_CHARS,
|
||||
} from "@/utils/bookmark-limits";
|
||||
import { noteUserFromToken } from "@/lib/note-token";
|
||||
import { rotatedRectAABB, stampRect } from "@/utils/geometry";
|
||||
|
||||
/** identity จาก access_token ที่ client แนบมาทุก action (ฉบับ token 2026-10-30
|
||||
* ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — เดิม action เชื่อ userId ที่ client ส่ง
|
||||
* มาตรง ๆ ซึ่งปลอมได้) · token ผิด/หมดอายุ = throw ให้ useOptimistic rollback
|
||||
* ตามกลไกเดิมของ action ที่ล้มเหลว */
|
||||
function requireUser(token: string): { userid: string; username: string } {
|
||||
const user = noteUserFromToken(token);
|
||||
if (user === null) {
|
||||
throw new Error("session หมดอายุหรือไม่ถูกต้อง — กรุณากลับ e-Court กดลิงก์เปิดเอกสารใหม่");
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
// ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
|
||||
|
||||
export type CreateAnnotationInput = {
|
||||
fileId: string;
|
||||
pageIndex: number;
|
||||
userId: string;
|
||||
userName: string;
|
||||
type: AnnotationType;
|
||||
data: AnnotationData;
|
||||
/** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน
|
||||
|
|
@ -53,9 +64,6 @@ export type CreateAnnotationInput = {
|
|||
export type CreateBookmarkInput = {
|
||||
fileId: string;
|
||||
pageIndex: number;
|
||||
userId: string;
|
||||
/** N6: ชื่อผู้บันทึก — แสดงในผลค้นหา bookmark */
|
||||
userName: string;
|
||||
title: string;
|
||||
/** % ของความสูงหน้า (0–100) */
|
||||
yOffset: number;
|
||||
|
|
@ -67,7 +75,6 @@ export type CreateBookmarkInput = {
|
|||
|
||||
export type UpdateBookmarkInput = {
|
||||
id: string;
|
||||
userId: string;
|
||||
/** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */
|
||||
title?: string;
|
||||
note?: string | null;
|
||||
|
|
@ -91,7 +98,6 @@ const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
|
|||
|
||||
// FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action
|
||||
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
|
||||
const MAX_USER_NAME_CHARS = 100;
|
||||
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply
|
||||
const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
|
||||
|
||||
|
|
@ -449,12 +455,14 @@ function revalidateFilePages(fileId: string) {
|
|||
revalidatePath(`/note/annotate/${fileId}/summary`);
|
||||
}
|
||||
|
||||
export async function createAnnotation(input: CreateAnnotationInput): Promise<AnnotationDTO> {
|
||||
export async function createAnnotation(
|
||||
token: string,
|
||||
input: CreateAnnotationInput,
|
||||
): Promise<AnnotationDTO> {
|
||||
const user = requireUser(token);
|
||||
const raw = input as unknown as Record<string, unknown> | null | undefined;
|
||||
const fileId = vUuid(raw?.fileId, "fileId");
|
||||
const pageIndex = vPageIndex(raw?.pageIndex);
|
||||
const userId = vString(raw?.userId, "userId");
|
||||
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
|
||||
if (typeof raw?.type !== "string" || !ANNOTATION_TYPES.includes(raw.type as AnnotationType)) {
|
||||
fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`);
|
||||
}
|
||||
|
|
@ -466,8 +474,8 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
|
|||
const values: typeof annotations.$inferInsert = {
|
||||
fileId,
|
||||
pageIndex,
|
||||
userId,
|
||||
userName,
|
||||
userId: user.userid,
|
||||
userName: user.username,
|
||||
type,
|
||||
data,
|
||||
};
|
||||
|
|
@ -484,9 +492,9 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
|
|||
* F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน —
|
||||
* annotation_id ไม่ FK (D8) จึงรอดจาก CASCADE และกู้คืนได้ภายหลัง
|
||||
*/
|
||||
export async function deleteAnnotation(id: string, userId: string): Promise<void> {
|
||||
export async function deleteAnnotation(token: string, id: string): Promise<void> {
|
||||
const uid = requireUser(token).userid;
|
||||
const annId = vUuid(id, "id");
|
||||
const uid = vString(userId, "userId");
|
||||
const removed = await db.transaction(async (tx) => {
|
||||
// FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ
|
||||
const [existing] = await tx
|
||||
|
|
@ -529,12 +537,12 @@ export async function deleteAnnotation(id: string, userId: string): Promise<void
|
|||
* - F19/A7: snapshot row เดิมเป็น version kind='update' **ก่อนเขียนทับ** ใน transaction เดียวกัน
|
||||
*/
|
||||
export async function updateAnnotation(
|
||||
token: string,
|
||||
id: string,
|
||||
userId: string,
|
||||
data: AnnotationData,
|
||||
): Promise<AnnotationDTO> {
|
||||
const uid = requireUser(token).userid;
|
||||
const annId = vUuid(id, "id");
|
||||
const uid = vString(userId, "userId");
|
||||
|
||||
const row = await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
|
|
@ -582,9 +590,11 @@ export async function updateAnnotation(
|
|||
* ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น
|
||||
*/
|
||||
export async function setAnnotationResolved(
|
||||
token: string,
|
||||
id: string,
|
||||
resolved: boolean,
|
||||
): Promise<AnnotationDTO> {
|
||||
requireUser(token);
|
||||
const annId = vUuid(id, "id");
|
||||
if (typeof resolved !== "boolean") {
|
||||
fieldError("resolved", "เป็น boolean");
|
||||
|
|
@ -614,16 +624,13 @@ export async function setAnnotationResolved(
|
|||
|
||||
export type CreateReplyInput = {
|
||||
annotationId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
message: string;
|
||||
};
|
||||
|
||||
export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
|
||||
export async function createReply(token: string, input: CreateReplyInput): Promise<ReplyDTO> {
|
||||
const user = requireUser(token);
|
||||
const raw = input as unknown as Record<string, unknown> | null | undefined;
|
||||
const annotationId = vUuid(raw?.annotationId, "annotationId");
|
||||
const userId = vString(raw?.userId, "userId");
|
||||
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
|
||||
const message = vString(raw?.message, "message", MAX_TEXT_CHARS);
|
||||
|
||||
const [parent] = await db
|
||||
|
|
@ -636,7 +643,7 @@ export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
|
|||
|
||||
const [row] = await db
|
||||
.insert(annotationReplies)
|
||||
.values({ annotationId, userId, userName, message })
|
||||
.values({ annotationId, userId: user.userid, userName: user.username, message })
|
||||
.returning();
|
||||
const dto = toReplyDTO(row);
|
||||
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
|
||||
|
|
@ -645,9 +652,9 @@ export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
|
|||
}
|
||||
|
||||
/** ลบได้เฉพาะ reply ของตัวเอง (userId ตรงกัน) เท่านั้น */
|
||||
export async function deleteReply(id: string, userId: string): Promise<void> {
|
||||
export async function deleteReply(token: string, id: string): Promise<void> {
|
||||
const uid = requireUser(token).userid;
|
||||
const replyId = vUuid(id, "id");
|
||||
const uid = vString(userId, "userId");
|
||||
const [existing] = await db
|
||||
.select({ annotationId: annotationReplies.annotationId })
|
||||
.from(annotationReplies)
|
||||
|
|
@ -751,12 +758,11 @@ async function isDescendant(tx: Tx, id: string, ancestorId: string): Promise<boo
|
|||
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)");
|
||||
}
|
||||
|
||||
export async function createBookmark(input: CreateBookmarkInput): Promise<BookmarkDTO> {
|
||||
export async function createBookmark(token: string, input: CreateBookmarkInput): Promise<BookmarkDTO> {
|
||||
const user = requireUser(token);
|
||||
const raw = input as unknown as Record<string, unknown> | null | undefined;
|
||||
const fileId = vUuid(raw?.fileId, "fileId");
|
||||
const pageIndex = vPageIndex(raw?.pageIndex);
|
||||
const userId = vString(raw?.userId, "userId");
|
||||
const userName = vString(raw?.userName, "userName", MAX_USER_NAME_CHARS);
|
||||
const title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS);
|
||||
if (
|
||||
typeof raw?.yOffset !== "number" ||
|
||||
|
|
@ -790,7 +796,16 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
|
|||
|
||||
const [inserted] = await tx
|
||||
.insert(bookmarks)
|
||||
.values({ fileId, pageIndex, userId, userName, title, yOffset, parentId, note })
|
||||
.values({
|
||||
fileId,
|
||||
pageIndex,
|
||||
userId: user.userid,
|
||||
userName: user.username,
|
||||
title,
|
||||
yOffset,
|
||||
parentId,
|
||||
note,
|
||||
})
|
||||
.returning();
|
||||
return inserted;
|
||||
});
|
||||
|
|
@ -804,10 +819,10 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
|
|||
* N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId)
|
||||
* ย้ายได้ตามเกณฑ์ความลึก (depth แม่ + subtree สูงรวมกัน ≤ BOOKMARK_MAX_DEPTH) และห้ามเข้า subtree ตัวเอง
|
||||
*/
|
||||
export async function updateBookmark(input: UpdateBookmarkInput): Promise<BookmarkDTO> {
|
||||
export async function updateBookmark(token: string, input: UpdateBookmarkInput): Promise<BookmarkDTO> {
|
||||
const uid = requireUser(token).userid;
|
||||
const raw = input as unknown as Record<string, unknown> | null | undefined;
|
||||
const bmId = vUuid(raw?.id, "id");
|
||||
const uid = vString(raw?.userId, "userId");
|
||||
const title = raw?.title !== undefined ? vString(raw.title, "title", MAX_BOOKMARK_TITLE_CHARS) : undefined;
|
||||
const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined;
|
||||
|
||||
|
|
@ -873,9 +888,9 @@ export async function updateBookmark(input: UpdateBookmarkInput): Promise<Bookma
|
|||
|
||||
/** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น
|
||||
* N2: ลบหัวข้อใหญ่ → sub ทั้งหมดขยับขึ้นเป็นระดับบนสุด (ไม่มีข้อมูลหาย) ใน tx เดียวกัน */
|
||||
export async function deleteBookmark(id: string, userId: string): Promise<void> {
|
||||
export async function deleteBookmark(token: string, id: string): Promise<void> {
|
||||
const uid = requireUser(token).userid;
|
||||
const bmId = vUuid(id, "id");
|
||||
const uid = vString(userId, "userId");
|
||||
const result = await db.transaction(async (tx) => {
|
||||
const [existing] = await tx
|
||||
.select({ id: bookmarks.id, fileId: bookmarks.fileId, userId: bookmarks.userId })
|
||||
|
|
@ -924,8 +939,10 @@ export async function deleteBookmark(id: string, userId: string): Promise<void>
|
|||
|
||||
/** ประวัติการเปลี่ยนแปลงของ annotation หนึ่งรายการ — ใหม่สุดก่อน */
|
||||
export async function listVersions(
|
||||
token: string,
|
||||
annotationId: string,
|
||||
): Promise<AnnotationVersionDTO[]> {
|
||||
requireUser(token);
|
||||
const annId = vUuid(annotationId, "annotationId");
|
||||
const rows = await db
|
||||
.select()
|
||||
|
|
@ -945,11 +962,11 @@ export async function listVersions(
|
|||
* - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี
|
||||
*/
|
||||
export async function restoreVersion(
|
||||
token: string,
|
||||
versionId: string,
|
||||
userId: string,
|
||||
): Promise<AnnotationDTO> {
|
||||
const uid = requireUser(token).userid;
|
||||
const vId = vUuid(versionId, "versionId");
|
||||
const uid = vString(userId, "userId");
|
||||
const [v] = await db
|
||||
.select()
|
||||
.from(annotationVersions)
|
||||
|
|
@ -1020,7 +1037,12 @@ export async function restoreVersion(
|
|||
* ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX)
|
||||
* และ `data->>'message'` (COMMENT); escape `%`/`_`/`\` ในคำค้นก่อนเอาไปใช้ใน pattern
|
||||
*/
|
||||
export async function searchAnnotations(fileId: string, q: string): Promise<AnnotationDTO[]> {
|
||||
export async function searchAnnotations(
|
||||
token: string,
|
||||
fileId: string,
|
||||
q: string,
|
||||
): Promise<AnnotationDTO[]> {
|
||||
requireUser(token);
|
||||
const fid = vUuid(fileId, "fileId");
|
||||
const query = vString(q, "q", MAX_QUERY_CHARS);
|
||||
const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`);
|
||||
|
|
@ -1092,7 +1114,8 @@ function dateBound(v: string, endOfDay: boolean, field: string): Date {
|
|||
* (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ รวมกันด้วย AND); คู่ช่วงกลับด้าน (from > to)
|
||||
* = swap เงียบ ๆ — read-only จึงไม่ publish event/ไม่ revalidatePath
|
||||
*/
|
||||
export async function searchBookmarks(input: SearchBookmarksInput): Promise<BookmarkDTO[]> {
|
||||
export async function searchBookmarks(token: string, input: SearchBookmarksInput): Promise<BookmarkDTO[]> {
|
||||
requireUser(token);
|
||||
const raw = input as unknown as Record<string, unknown> | null | undefined;
|
||||
const fid = vUuid(raw?.fileId, "fileId");
|
||||
const q = typeof raw?.q === "string" && raw.q.trim() !== ""
|
||||
|
|
|
|||
|
|
@ -1,41 +0,0 @@
|
|||
"use server";
|
||||
|
||||
// mock login e-Court: รับเฉพาะผู้ใช้ใน MOCK_USERS (user1–user5) + Demo1234
|
||||
// → ตั้ง cookie oacCase ตาม format จริง (identity ของแอปอ่านผ่าน lib/session.ts
|
||||
// — บังคับด้วย middleware.ts)
|
||||
import { cookies } from "next/headers";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { buildOacCaseCookie, MOCK_USERS, OAC_CASE_COOKIE } from "@/utils/user";
|
||||
|
||||
const MOCK_PASSWORD = "Demo1234";
|
||||
|
||||
export async function login(
|
||||
username: string,
|
||||
password: string,
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
// mock ตอบเร็วกว่าตา — หน่วงเล็กน้อยให้เห็นสถานะ "กำลังตรวจสอบ…"
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
const name = username.trim();
|
||||
// username มาจาก dropdown เลือกผิดไม่ได้ — เช็ครายชื่อฝั่ง server เป็น defense กัน hand-craft
|
||||
if (!MOCK_USERS.includes(name) || password !== MOCK_PASSWORD) {
|
||||
return { ok: false, error: "รหัสผ่านไม่ถูกต้อง" };
|
||||
}
|
||||
// u_userid ต่างกันต่อคน (= u_userlogin) — สิทธิ์ "แก้ไขได้เฉพาะของตัวเอง" (guard ด้วย
|
||||
// userId) ต้องแยกกันจริงทั้ง 5 คน ห้ามแชร์ค่าเดียว
|
||||
cookies().set(OAC_CASE_COOKIE, buildOacCaseCookie(name, name), {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 8 * 60 * 60,
|
||||
});
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
// ลบ cookie ด้วย path "/" เท่านั้น (ตรงกับตอน set) ไม่งั้น browser ไม่ลบให้
|
||||
cookies().set(OAC_CASE_COOKIE, "", { path: "/", maxAge: 0 });
|
||||
// redirect ใน action เอง — ให้ Next จัดการ navigation กลไกเดียว
|
||||
// (ถ้าให้ client router.replace เอง จะชนกับ re-render ของหน้ารายการที่เพิ่งเสีย session จน navigation ไม่เกิด)
|
||||
redirect("/note/login");
|
||||
}
|
||||
|
|
@ -1,113 +0,0 @@
|
|||
"use server";
|
||||
|
||||
// N4 — อัปโหลดไฟล์เอกสารแบบง่าย (PDF/jpg/jpeg/png/tif/tiff) ผ่านหน้ารายการ
|
||||
// เขียนไฟล์**ใหม่**ที่ public/note/uploads/<uuid>.<ext> เท่านั้น (ชื่อจาก randomUUID — กันชนกัน/กัน path traversal)
|
||||
// ไม่มี SSE event: รายการไฟล์ไม่ได้ sync ผ่าน SSE — revalidatePath("/note") พอ (สเปก 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";
|
||||
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", "note", "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 ไบต์) — เลือกไฟล์ที่สมบูรณ์" };
|
||||
}
|
||||
// ชื่อไฟล์ที่ browser ส่งมาโดน undici ถอดรหัสเป็น latin1 (ชื่อไทยเพี้ยน) —
|
||||
// ถอดกลับก่อนใช้ทั้งตรวจ extension และตั้ง title
|
||||
const fileName = decodeUploadFilename(file.name);
|
||||
const ext = allowedExt(fileName);
|
||||
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);
|
||||
|
||||
const title = sanitizeTitle(fileName);
|
||||
const pdfUrl = `/uploads/${diskName}`;
|
||||
const typeId = normDocumentGroup(formData.get("typeId"));
|
||||
try {
|
||||
await db.insert(files).values({ id, title, pdfUrl, typeId });
|
||||
} catch (err) {
|
||||
// DB fail หลังเขียนไฟล์ — เก็บกวาดไฟล์ก่อนโยนต่อ กันไฟล์กำพร้าไม่มี row ชี้ (ไม่มีกลไกเก็บอื่น)
|
||||
await unlink(diskPath).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
|
||||
revalidatePath("/note");
|
||||
return { ok: true, fileId: id, title };
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue