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 };
|
||||
}
|
||||
|
|
@ -92,9 +92,6 @@ async function postWithTicket(request: Request): Promise<Response> {
|
|||
async function issueToken(claims: DocverTokenClaims): Promise<Response> {
|
||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
// const secret =
|
||||
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||
|
||||
if (secret === undefined || secret === "") {
|
||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||
}
|
||||
|
|
|
|||
130
app/api/note/auth/dev-link/route.ts
Normal file
130
app/api/note/auth/dev-link/route.ts
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
// dev-only (NOTE_SSO_DEV_MINT=1 + ไม่ใช่ production) — mint ลิงก์ SSO จากหน้า /note/dev
|
||||
// ให้ทดสอบ flow จริงจาก browser ได้โดยไม่ต้องรัน script: คำนวณ token/ticket ฝั่ง
|
||||
// server ด้วย secret ของตัวเอง (ไม่มีค่าใดหลุดไปหน้า public) แล้วคืน URL เต็มเดียว
|
||||
// พร้อมคลิก — ปิดอยู่ = 404 เหมือนไม่มีเส้นนี้ · ไม่ import @/db · ห้าม log ค่าดิบ
|
||||
// · GET ?file=<ชื่อไฟล์ตัวอย่าง>&mode=jwt|ticket (2026-10-30 รอบสอง) — ลิงก์ "A Link
|
||||
// Get(new)" ของ /note/dev: mint แล้ว 302 เข้า /note?fileUrl=…&t=… ให้ bootstrap
|
||||
// แลก token ต่อเหมือนลิงก์จริงของ e-Court (identity dev คงที่ + fid=dev-<ไฟล์>)
|
||||
import { createHmac } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { addinTicketSecret } from "@/lib/addin-ticket";
|
||||
import { docverError } from "@/lib/document-versioning";
|
||||
import { issueDocverToken } from "@/lib/docver-token";
|
||||
import { devMintEnabled, LOGIN_TOKEN_TTL, parseDevLinkBody } from "@/lib/note-auth";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** identity dev คงที่ของลิงก์ตัวอย่าง — ตรงกับค่า default ของฟอร์ม mint หน้าเดียวกัน */
|
||||
const DEV_USER = { userid: "oom", username: "สรวิชญ์" };
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
if (!devMintEnabled()) return new Response(null, { status: 404 });
|
||||
const url = new URL(request.url);
|
||||
const file = url.searchParams.get("file") ?? "";
|
||||
const mode = url.searchParams.get("mode") === "ticket" ? "ticket" : "jwt";
|
||||
// ชื่อไฟล์ตัวอย่างเท่านั้น — ห้าม path segment/.. (การมีไฟล์จริงตรวจตอน register
|
||||
// ใน exchange ของ /api/note/auth/session อยู่แล้ว)
|
||||
if (
|
||||
file === "" || file.length > 200 ||
|
||||
file.includes("/") || file.includes("\\") || file.includes("..")
|
||||
) {
|
||||
return docverError("file ต้องเป็นชื่อไฟล์ตัวอย่าง (ไม่มี path)", 400);
|
||||
}
|
||||
// fid คงที่ต่อไฟล์ → แต่ละตัวอย่างมี row ecourt: ของตัวเอง (annotation ไม่ปนกัน
|
||||
// ข้ามไฟล์ และคลิกรอบหลังเจอ row เดิม) — นี่คือสิ่งที่ลิงก์จริงของ e-Court ทำเช่นกัน
|
||||
const fid = `dev-${file}`;
|
||||
const params = new URLSearchParams({ fileUrl: `/samples/${file}` });
|
||||
|
||||
if (mode === "jwt") {
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
if (secret === undefined || secret === "") {
|
||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||
}
|
||||
params.set(
|
||||
"t",
|
||||
issueDocverToken({ ...DEV_USER, fid, template: null }, secret, LOGIN_TOKEN_TTL.default, "login"),
|
||||
);
|
||||
} else {
|
||||
// ตรงตามตัว stamp จริง: key เรียงตามตัวอักษรหลังเติม exp → HMAC hex บน string ดิบ
|
||||
const ticket = { exp: Math.floor(Date.now() / 1000) + LOGIN_TOKEN_TTL.default, fid, uid: DEV_USER.userid, uname: DEV_USER.username };
|
||||
const ticketData = JSON.stringify(
|
||||
Object.keys(ticket).sort().reduce<Record<string, unknown>>((result, key) => {
|
||||
result[key] = ticket[key as keyof typeof ticket];
|
||||
return result;
|
||||
}, {}),
|
||||
);
|
||||
params.set("ticket_data", ticketData);
|
||||
params.set("HmacSha256", createHmac("sha256", addinTicketSecret()).update(ticketData, "utf8").digest("hex"));
|
||||
}
|
||||
|
||||
// ปลายทาง = หน้าแรก /note พร้อม credential + fileUrl เหมือนลิงก์จริง — ให้ bootstrap
|
||||
// ของ NoteHome แลกเป็น session token แล้วพาเข้า viewer (เห็น URL ทุกขั้นตอน)
|
||||
const res = NextResponse.redirect(new URL(`${withBasePath("")}?${params.toString()}`, url), 302);
|
||||
res.headers.set("cache-control", "no-store");
|
||||
return res;
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
if (!devMintEnabled()) return new Response(null, { status: 404 });
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseDevLinkBody(raw);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
|
||||
const params = new URLSearchParams();
|
||||
if (parsed.fileUrl !== undefined) params.set("fileUrl", parsed.fileUrl);
|
||||
if (parsed.typeId !== undefined) params.set("typeId", parsed.typeId);
|
||||
|
||||
if (parsed.mode === "jwt") {
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
if (secret === undefined || secret === "") {
|
||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||
}
|
||||
params.set(
|
||||
"t",
|
||||
issueDocverToken(
|
||||
{ userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null },
|
||||
secret,
|
||||
parsed.ttlSeconds,
|
||||
"login",
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// ตรงตามตัว stamp จริง: key เรียงตามตัวอักษรหลังเติม exp → HMAC hex บน string ดิบ
|
||||
const ticket = {
|
||||
exp: Math.floor(Date.now() / 1000) + parsed.ttlSeconds,
|
||||
fid: parsed.claims.fid,
|
||||
uid: parsed.claims.userid,
|
||||
uname: parsed.claims.username,
|
||||
};
|
||||
const ticketData = JSON.stringify(
|
||||
Object.keys(ticket)
|
||||
.sort()
|
||||
.reduce<Record<string, unknown>>((result, key) => {
|
||||
result[key] = ticket[key as keyof typeof ticket];
|
||||
return result;
|
||||
}, {}),
|
||||
);
|
||||
params.set("ticket_data", ticketData);
|
||||
params.set("HmacSha256", createHmac("sha256", addinTicketSecret()).update(ticketData, "utf8").digest("hex"));
|
||||
}
|
||||
|
||||
// origin ตรงของ request (ไม่ใช่ requestOrigin ที่บังคับ https โฮสต์ที่ไม่ใช่
|
||||
// localhost) — เครื่องมือนี้ dev-only และ dev server แชร์บน LAN เป็น http ธรรมดา
|
||||
// ลิงก์ที่คืนต้องคลิกได้จากที่ผู้ใช้เปิดหน้านั้นอยู่
|
||||
// ปลายทาง = หน้าแรก /note ที่ client bootstrap แลก credential เป็น token
|
||||
// (ฉบับ token 2026-10-30 — ไม่มี route e-court ที่ set cookie ให้ชี้แล้ว)
|
||||
const origin = new URL(request.url).origin;
|
||||
const url = `${origin}${withBasePath("")}?${params.toString()}`;
|
||||
return new Response(JSON.stringify({ url, ttlSeconds: parsed.ttlSeconds }), {
|
||||
headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
|
||||
});
|
||||
}
|
||||
105
app/api/note/auth/session/route.ts
Normal file
105
app/api/note/auth/session/route.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
// POST /api/note/auth/session — แลก credential ของลิงก์ e-Court เป็น access_token
|
||||
// (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้แบบ add-in" — แทน GET e-court ที่
|
||||
// set cookie คู่ oacCase ซึ่งถอดแล้ว): body {t | ticket_data+HmacSha256, fileUrl?,
|
||||
// typeId?} → verify → ลงทะเบียนเอกสาร N8 แบบ trusted ฝั่ง server เลย (fileId จาก
|
||||
// fid ที่เซ็นมา — ไม่ต้องมี link proof อีก) → ออก login-token อายุ NOTE_SESSION_TTL
|
||||
// (8 ชม.) ให้ client เก็บ localStorage "note-auth"
|
||||
// · 400 body ผิด · 401 {error, reason: expired|invalid} · 500 secret ไม่ตั้ง
|
||||
// · audit log เฉพาะ userid (strip อักขระควบคุม) — ห้าม log token/query ดิบ
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { addinTicketSecret, verifyAddinTicket } from "@/lib/addin-ticket";
|
||||
import { issueDocverToken, verifyDocverLoginToken } from "@/lib/docver-token";
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { parseSessionExchangeBody } from "@/lib/note-auth";
|
||||
import { NOTE_SESSION_TTL } from "@/lib/note-token";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
function json(body: unknown, status = 200): NextResponse {
|
||||
return NextResponse.json(body, { status, headers: { "cache-control": "no-store" } });
|
||||
}
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
raw = null;
|
||||
}
|
||||
const parsed = parseSessionExchangeBody(raw);
|
||||
if (!parsed.ok) return json({ error: parsed.error }, 400);
|
||||
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
if (secret === undefined || secret === "") {
|
||||
console.error("[sso] DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า");
|
||||
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
|
||||
}
|
||||
|
||||
let identity: { userid: string; username: string; fid: string };
|
||||
if (parsed.credential.kind === "jwt") {
|
||||
const verified = verifyDocverLoginToken(parsed.credential.token, secret);
|
||||
if (!verified.ok) {
|
||||
return json(
|
||||
{ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: verified.reason },
|
||||
401,
|
||||
);
|
||||
}
|
||||
identity = verified.claims;
|
||||
} else {
|
||||
// ค่า default "super_secret" ของ addinTicketSecret() เป็นที่รู้กันทั้ง repo/
|
||||
// .env.example — ใครก็ปลอม ticket ของ uid ใครก็ได้ จึงปฏิเสธใน production
|
||||
// (fail-closed — กลุ่ม docver/add-in อนุญาต default ตาม requirement อยู่แล้ว
|
||||
// คนละประตู) · **dev ยอมรับค่า default** เพื่อให้ทดสอบทาง ticket รอบเครื่องได้
|
||||
// โดยไม่ต้องตั้ง env ใหม่ — เครื่องมือ dev (make-login-link/make-ticket-docx/
|
||||
// dev-link) ก็ default ค่าเดียวกัน (review 2026-10-30 รอบสอง)
|
||||
if (process.env.NODE_ENV === "production" && addinTicketSecret() === "super_secret") {
|
||||
console.error("[sso] ticket secret ยังเป็นค่า default (ADDIN_TICKET_SECRET/SECRET) — ปฏิเสธทาง ticket");
|
||||
return json({ error: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ" }, 500);
|
||||
}
|
||||
const verified = verifyAddinTicket(
|
||||
parsed.credential.ticketData,
|
||||
parsed.credential.hmacHex,
|
||||
addinTicketSecret(),
|
||||
);
|
||||
if (!verified.ok) {
|
||||
return json({ error: "ลิงก์เข้าสู่ระบบหมดอายุหรือไม่ถูกต้อง", reason: "invalid" }, 401);
|
||||
}
|
||||
identity = { userid: verified.ticket.uid, username: verified.ticket.uname, fid: verified.ticket.fid };
|
||||
}
|
||||
|
||||
// N8: ลงทะเบียนเอกสารตอนแลก token เลย — ฝั่ง server เชื่อ fid จาก credential
|
||||
// ที่ verify แล้วเท่านั้น (trusted) ส่วน ?fileUrl พิมพ์เองบนหน้าเว็บไม่มีทาง
|
||||
// เข้ามาถึงจุดนี้ · ล้ม = ยังออก token ให้ + fileError ให้หน้าแรกแสดง (คู่ขนาน
|
||||
// พฤติกรรมสมัย cookie: session ตั้งก่อน แล้วค่อยแจ้ง error ของไฟล์)
|
||||
let fileId: string | undefined;
|
||||
let fileError: string | undefined;
|
||||
if (parsed.fileUrl !== undefined) {
|
||||
const result = await registerExternalFile(parsed.fileUrl, identity.fid, parsed.typeId, {
|
||||
trusted: true,
|
||||
});
|
||||
if (result.ok) fileId = result.fileId;
|
||||
else fileError = result.error;
|
||||
}
|
||||
|
||||
const access_token = issueDocverToken(
|
||||
{ userid: identity.userid, username: identity.username, fid: identity.fid, template: null },
|
||||
secret,
|
||||
NOTE_SESSION_TTL,
|
||||
"login",
|
||||
);
|
||||
// audit: เฉพาะ userid + เวลา — ห้าม log token/query ดิบ · strip อักขระควบคุม
|
||||
// ก่อนพิมพ์ (claims ไม่จำกัด charset — กันฝัง \n ปลอมบรรทัด log)
|
||||
const auditId = identity.userid.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
|
||||
console.log(`[sso] e-Court login ok: userid=${auditId}`);
|
||||
return json({
|
||||
access_token,
|
||||
token_type: "Bearer",
|
||||
expires_in: NOTE_SESSION_TTL,
|
||||
userid: identity.userid,
|
||||
username: identity.username,
|
||||
...(fileId !== undefined ? { fileId } : {}),
|
||||
...(fileError !== undefined ? { fileError } : {}),
|
||||
});
|
||||
}
|
||||
48
app/api/note/auth/token/route.ts
Normal file
48
app/api/note/auth/token/route.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
// PLAN-SSO.md §3.1 — POST /api/note/auth/token: เส้น mint login-token ของแอป note
|
||||
// (ทางเข้า x-api-key ตัวเดียวกับ docver แต่แยกเส้นจาก /api/documents/token — D1
|
||||
// contract docver ไม่ถูกแต้) — สำหรับ backend e-Court ประกอบลิงก์
|
||||
// `/note?…&t=<access_token>` ให้ผู้ใช้คลิก (ฉบับ token 2026-10-30: หน้าแรกเป็น
|
||||
// client bootstrap แลก credential เป็น session token ที่ /api/note/auth/session)
|
||||
// token ที่ได้เป็น purpose="login" ใช้ได้กับการเข้าแอป note เท่านั้น (D4)
|
||||
// ไม่ import @/db · ห้าม log ค่า key/body ดิบ
|
||||
import { docverError } from "@/lib/document-versioning";
|
||||
import { issueDocverToken, requireDocverApiKey } from "@/lib/docver-token";
|
||||
import { parseLoginTokenBody } from "@/lib/note-auth";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function POST(request: Request): Promise<Response> {
|
||||
const apiKeyFail = requireDocverApiKey(request);
|
||||
if (apiKeyFail !== null) return apiKeyFail;
|
||||
|
||||
let raw: unknown;
|
||||
try {
|
||||
raw = await request.json();
|
||||
} catch {
|
||||
return docverError("body ต้องเป็น JSON", 400);
|
||||
}
|
||||
const parsed = parseLoginTokenBody(raw);
|
||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||
|
||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||
if (secret === undefined || secret === "") {
|
||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||
}
|
||||
|
||||
const accessToken = issueDocverToken(
|
||||
{ userid: parsed.claims.userid, username: parsed.claims.username, fid: parsed.claims.fid, template: null },
|
||||
secret,
|
||||
parsed.ttlSeconds,
|
||||
"login",
|
||||
);
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: accessToken,
|
||||
token_type: "Bearer",
|
||||
expires_in: parsed.ttlSeconds,
|
||||
}),
|
||||
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||
);
|
||||
}
|
||||
81
app/api/note/files/[id]/bootstrap/route.ts
Normal file
81
app/api/note/files/[id]/bootstrap/route.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// GET /api/note/files/[id]/bootstrap — ข้อมูลเริ่มต้นของ viewer (file + annotations
|
||||
// + bookmarks + replies เป็น DTO) ให้ client gate ของหน้า /note/annotate/[id]
|
||||
// ดึงหลังมี access_token (ฉบับ token 2026-10-30 — เดิมหน้านี้เป็น server component
|
||||
// อ่าน cookie ผ่าน getSessionUser) · Bearer บังคับ (401) · ไม่ publish SSE ·
|
||||
// query ชุดเดียวกับหน้าเดิมตอนเป็น server component
|
||||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotationReplies,
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
toReplyDTO,
|
||||
} from "@/db/schema";
|
||||
import { noteUserFromRequest } from "@/lib/note-token";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
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 async function GET(
|
||||
request: Request,
|
||||
{ params }: { params: { id: string } },
|
||||
): Promise<Response> {
|
||||
const user = noteUserFromRequest(request);
|
||||
if (user === null) {
|
||||
return NextResponse.json(
|
||||
{ error: "session หมดอายุ — กรุณากลับ e-Court กดลิงก์เปิดเอกสารใหม่" },
|
||||
{ status: 401, headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) {
|
||||
return NextResponse.json({ error: "ไม่พบไฟล์นี้" }, { status: 404 });
|
||||
}
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) {
|
||||
return NextResponse.json({ error: "ไม่พบไฟล์นี้" }, { status: 404 });
|
||||
}
|
||||
|
||||
const [annotationRows, bookmarkRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, id))
|
||||
.orderBy(asc(annotations.createdAt)),
|
||||
db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.fileId, id))
|
||||
.orderBy(asc(bookmarks.createdAt)),
|
||||
]);
|
||||
|
||||
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
|
||||
const annotationIds = annotationRows.map((r) => r.id);
|
||||
const replyRows = annotationIds.length
|
||||
? await db
|
||||
.select()
|
||||
.from(annotationReplies)
|
||||
.where(inArray(annotationReplies.annotationId, annotationIds))
|
||||
.orderBy(asc(annotationReplies.createdAt))
|
||||
: [];
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
file: { id: file.id, title: file.title, pdfUrl: file.pdfUrl },
|
||||
annotations: annotationRows.map(toAnnotationDTO),
|
||||
bookmarks: bookmarkRows.map(toBookmarkDTO),
|
||||
replies: replyRows.map(toReplyDTO),
|
||||
user: { userId: user.userid, userName: user.username },
|
||||
},
|
||||
{ headers: { "cache-control": "no-store" } },
|
||||
);
|
||||
}
|
||||
38
app/api/note/files/open/route.ts
Normal file
38
app/api/note/files/open/route.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// GET /api/note/files/open?fileUrl=/samples/… — ทางเปิดเอกสารตัวอย่างของหน้า /note/dev
|
||||
// แบบ "A Link Get(new)" ของ /tmpl (คำขอผู้ใช้ 2026-10-30): ลิงก์เปล่า <Link> ยิง GET ได้เลย
|
||||
// ไม่มี Bearer (browser navigation ใส่ header ไม่ได้) จึง**รับเฉพาะ path ในเครื่องที่ขึ้นต้น
|
||||
// "/" เท่านั้น** — registerExternalFile ตรวจว่ามีไฟล์จริงใน public/ ผ่าน path guard เดิม
|
||||
// (สร้างแถว url:… เท่านั้น — แถว ecourt: เกิดไม่ได้เพราะไม่รับ fileId และ path รีโมตโดนเด็ดขาด)
|
||||
// แล้ว 302 เข้า viewer · ไฟล์ไม่ผ่าน = 302 /note?fileError=… ให้หน้าแรกแสดงสาเหตุ
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(request: Request): Promise<Response> {
|
||||
const url = new URL(request.url);
|
||||
// withBasePath("") = หน้าแรก /docedit/note · withBasePath("/annotate/x") = viewer —
|
||||
// อย่าส่ง "/note" เข้า withBasePath (มันเติม app path ให้อีกชั้นเป็น /note/note — D11)
|
||||
const redirectTo = (path: string, query = ""): NextResponse => {
|
||||
const res = NextResponse.redirect(new URL(`${withBasePath(path)}${query}`, url), 302);
|
||||
res.headers.set("cache-control", "no-store");
|
||||
return res;
|
||||
};
|
||||
|
||||
const fileUrl = url.searchParams.get("fileUrl") ?? "";
|
||||
if (!fileUrl.startsWith("/") || fileUrl.includes("..") || fileUrl.length > 2048) {
|
||||
return redirectTo(
|
||||
"",
|
||||
`?fileError=${encodeURIComponent("ลิงก์เปิดเอกสารตัวอย่างต้องเป็น path ในเครื่อง เช่น /samples/…")}`,
|
||||
);
|
||||
}
|
||||
|
||||
const result = await registerExternalFile(fileUrl, undefined, undefined, { trusted: false });
|
||||
if (!result.ok) {
|
||||
return redirectTo("", `?fileError=${encodeURIComponent(result.error)}`);
|
||||
}
|
||||
return redirectTo(`/annotate/${result.fileId}`);
|
||||
}
|
||||
|
|
@ -1,25 +1,19 @@
|
|||
import { asc, eq, inArray } from "drizzle-orm";
|
||||
import { notFound, redirect } from "next/navigation";
|
||||
import { notFound } from "next/navigation";
|
||||
|
||||
import { db } from "@/db";
|
||||
import {
|
||||
annotationReplies,
|
||||
annotations,
|
||||
bookmarks,
|
||||
files,
|
||||
toAnnotationDTO,
|
||||
toBookmarkDTO,
|
||||
toReplyDTO,
|
||||
} from "@/db/schema";
|
||||
import PDFViewer from "@/components/pdf/PDFViewer";
|
||||
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import AnnotateView from "@/components/pdf/AnnotateView";
|
||||
|
||||
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";
|
||||
|
||||
/**
|
||||
* หน้าอ่านเอกสาร (ฉบับ token 2026-10-30) — shell บาง ๆ ฝั่ง server (ตรวจ UUID
|
||||
* แล้วส่งต่อให้ client gate): AnnotateView ตรวจ access_token ใน localStorage →
|
||||
* ดึงข้อมูลเริ่มต้นผ่าน GET /api/note/files/[id]/bootstrap พร้อม Bearer →
|
||||
* PDFViewer · ไม่มี session = หน้า gate "กลับ e-Court กดลิงก์ใหม่" (แทน redirect
|
||||
* sso=required ของรอบ middleware ที่ถอดแล้ว)
|
||||
*/
|
||||
export default async function AnnotatePage({
|
||||
params,
|
||||
}: {
|
||||
|
|
@ -27,48 +21,5 @@ export default async function AnnotatePage({
|
|||
}) {
|
||||
const { id } = params;
|
||||
if (!UUID_RE.test(id)) notFound();
|
||||
|
||||
// defense ซ้อน middleware — identity จาก cookie oacCase ส่งเข้า viewer ตั้งแต่ first paint
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
const [file] = await db.select().from(files).where(eq(files.id, id));
|
||||
if (!file) notFound();
|
||||
|
||||
const [annotationRows, bookmarkRows] = await Promise.all([
|
||||
db
|
||||
.select()
|
||||
.from(annotations)
|
||||
.where(eq(annotations.fileId, id))
|
||||
.orderBy(asc(annotations.createdAt)),
|
||||
db
|
||||
.select()
|
||||
.from(bookmarks)
|
||||
.where(eq(bookmarks.fileId, id))
|
||||
.orderBy(asc(bookmarks.createdAt)),
|
||||
]);
|
||||
|
||||
// replies ของทุก comment ในไฟล์นี้ (F9) — แม่ถูก CASCADE ไปแล้ว row ก็หายตาม
|
||||
const annotationIds = annotationRows.map((r) => r.id);
|
||||
const replyRows = annotationIds.length
|
||||
? await db
|
||||
.select()
|
||||
.from(annotationReplies)
|
||||
.where(inArray(annotationReplies.annotationId, annotationIds))
|
||||
.orderBy(asc(annotationReplies.createdAt))
|
||||
: [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */}
|
||||
<DebugConsole />
|
||||
<PDFViewer
|
||||
file={{ id: file.id, title: file.title, pdfUrl: file.pdfUrl }}
|
||||
initialAnnotations={annotationRows.map(toAnnotationDTO)}
|
||||
initialBookmarks={bookmarkRows.map(toBookmarkDTO)}
|
||||
initialReplies={replyRows.map(toReplyDTO)}
|
||||
user={user}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
return <AnnotateView fileId={id} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,63 +0,0 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { firstSearchParam } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* N8 entry point ที่สอง (ตามคำขอผู้ใช้ 2026-09-22): เข้า `/note/annotate?fileUrl=&fileId=&typeId=`
|
||||
* โดยตรง — ลงทะเบียนเอกสารแล้ว redirect ต่อไป `/note/annotate/<id>` ของตัว viewer จริง
|
||||
* (entry เดิมที่หน้า "/note" ยังใช้ได้คู่กัน — สัญญาลิงก์รับทั้ง /note?fileUrl= และ /note/annotate?fileUrl=)
|
||||
*/
|
||||
export default async function AnnotateEntryPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
// redirect โยน NEXT_REDIRECT — เรียกนอก try/catch เสมอ
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div
|
||||
role="alert"
|
||||
className="rounded-lg border border-error/30 bg-error/10 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {result.error}
|
||||
</div>
|
||||
<Link
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
// ไม่มี fileUrl เลย — ไม่มีอะไรให้เปิด (route จริงของ viewer อยู่ที่ /note/annotate/[id])
|
||||
return (
|
||||
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
|
||||
<div className="rounded-xl border border-line bg-white p-6 text-sm text-ink-soft">
|
||||
ไม่ได้ระบุลิงก์เอกสาร — ใช้ <code className="rounded bg-ink/[0.05] px-1">
|
||||
/annotate?fileUrl=…&fileId=…&typeId=…
|
||||
</code>{" "}
|
||||
หรือเลือกไฟล์จากหน้ารายการ
|
||||
</div>
|
||||
<Link
|
||||
href="/note"
|
||||
className="focus-ring mt-4 inline-block rounded-md bg-primary px-4 py-2 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow"
|
||||
>
|
||||
กลับหน้ารายการ
|
||||
</Link>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
297
app/note/dev/page.tsx
Normal file
297
app/note/dev/page.tsx
Normal file
|
|
@ -0,0 +1,297 @@
|
|||
import Link from "next/link";
|
||||
|
||||
import AppNav from "@/components/AppNav";
|
||||
import DevLoginLinkForm from "@/components/DevLoginLinkForm";
|
||||
import { devMintEnabled } from "@/lib/note-auth";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* /note/dev — เครื่องมือทดสอบ (ย้ายมาจากหน้าแรก 2026-10-30 ตอน /note เปลี่ยนเป็น
|
||||
* landing เรียบ): เปิดเอกสารตัวอย่าง (D14) + คู่มือทางเข้า SSO 2 แบบ + ตัวช่วย mint
|
||||
* ลิงก์ทดสอบ · **public** (ต้อง mint ลิงก์ได้ตอนยังไม่มี session ไม่งั้นตายก่อนเริ่ม
|
||||
* — ฉบับ token 2026-10-30: ไม่มี middleware แล้ว ทุกหน้าเป็น shell public ส่วน
|
||||
* session ตรวจฝั่ง client ผ่าน localStorage) · ตัวอย่างลิงก์เป็น "รูปแบบ + ขั้นตอน"
|
||||
* เท่านั้น ไม่ mint จริงจากหน้า (secret ต้องอยู่ฝั่ง server) · ไม่ query DB
|
||||
* · แบนเนอร์ ?sso= และ bootstrap ของ N8 ?fileUrl= อยู่ที่หน้าแรก /note
|
||||
*/
|
||||
|
||||
/** รายการเอกสาร fixed ในโค้ด (D14) — จาก PDF ตัวอย่างที่มีอยู่จริงของแอป
|
||||
* (public/note/samples/ — ยังไม่ seed กดเปิดแล้ว N8 จะตอบ error เองที่หน้าแรก) */
|
||||
const SAMPLE_DOCS: Record<string, string[]> = {
|
||||
"คำสั่งศาล — คดี 01012-672427": [
|
||||
"01012-672427-1f-690609-0000840223.pdf",
|
||||
"01012-672427-4f-690609-0000840227.pdf",
|
||||
],
|
||||
"คำสั่งศาล — ตัวอย่าง": [
|
||||
"court_command_11_123 1.pdf",
|
||||
"court_command_1_2547 1.pdf",
|
||||
],
|
||||
};
|
||||
|
||||
/** code inline สไตล์เดียวกันทั้งหน้า */
|
||||
function Code({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<code className="rounded bg-ink/[0.05] px-1 py-0.5 text-[13px] text-ink">
|
||||
{children}
|
||||
</code>
|
||||
);
|
||||
}
|
||||
|
||||
/** บล็อกคำสั่ง/URL ตัวอย่าง — อ่านง่ายบนจอแคบ (scroll แนวนอนได้) */
|
||||
function CodeBlock({ children }: { children: string }) {
|
||||
return (
|
||||
<pre className="mt-2 overflow-x-auto rounded-lg border border-line bg-canvas px-3 py-2.5 text-[12.5px] leading-relaxed text-ink">
|
||||
{children}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function DevToolsPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// โหมด credential ของลิงก์ตัวอย่าง — jwt (default) | ticket สลับผ่าน ?sampleMode=
|
||||
const sampleMode = searchParams.sampleMode === "ticket" ? "ticket" : "jwt";
|
||||
const mintOn = devMintEnabled();
|
||||
return (
|
||||
<>
|
||||
<AppNav />
|
||||
<main className="mx-auto max-w-4xl px-4 pb-16 pt-10 sm:px-6">
|
||||
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
|
||||
เครื่องมือทดสอบ
|
||||
</h1>
|
||||
<p className="mt-2 text-body text-ink-soft">
|
||||
หน้านี้สำหรับผู้พัฒนาและผู้ทดสอบ — ลองเปิดเอกสารตัวอย่าง
|
||||
และดูวิธีเข้าระบบผ่านลิงก์ของ e-Court
|
||||
</p>
|
||||
|
||||
{/* ── เปิดเอกสารตัวอย่าง (D14) — A Link Get(new) แบบ /tmpl ─────────── */}
|
||||
<section className="mt-10">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">เปิดเอกสารตัวอย่าง</h2>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-ink-soft">
|
||||
กดชื่อไฟล์แล้วระบบจะสร้างลิงก์แบบเดียวกับที่ e-Court ส่งมาให้
|
||||
(แบบ{sampleMode === "jwt" ? " JWT" : " ticket"}) แล้วพาเข้าไปอ่านเอกสารนั้น
|
||||
ทันที — ดูแถบที่อยู่ของ browser จะเห็นการทำงานทีละขั้นจริง ๆ
|
||||
ตั้งแต่เข้าระบบจนถึงหน้าอ่านเอกสาร
|
||||
</p>
|
||||
{mintOn ? (
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2 text-sm">
|
||||
<span className="text-ink-soft">รูปแบบลิงก์:</span>
|
||||
{(["jwt", "ticket"] as const).map((m) => (
|
||||
<Link
|
||||
key={m}
|
||||
href={m === "jwt" ? "/note/dev" : "/note/dev?sampleMode=ticket"}
|
||||
aria-current={sampleMode === m ? "page" : undefined}
|
||||
className={`focus-ring rounded-md border px-2.5 py-1 font-medium transition ${
|
||||
sampleMode === m
|
||||
? "border-primary bg-primary/[0.08] text-primary"
|
||||
: "border-line bg-white text-ink-soft hover:border-ink-faint"
|
||||
}`}
|
||||
>
|
||||
{m === "jwt" ? "JWT (ทาง A)" : "ticket (ทาง B)"}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
|
||||
ตอนนี้ลิงก์ด้านล่างเปิดเอกสารแบบธรรมดา (ไม่ได้จำลองการเข้าระบบ) —
|
||||
เพิ่ม <Code>NOTE_SSO_DEV_MINT=1</Code> ในไฟล์ <Code>.env</Code> แล้วรีสตาร์ท
|
||||
dev server จะกดแล้วผ่านขั้นตอนเข้าระบบเหมือนของจริงทุกอย่าง
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6">
|
||||
<h3 className="font-display text-subhead text-primary">A Link Get(new)</h3>
|
||||
{Object.entries(SAMPLE_DOCS).map(([group, files]) => (
|
||||
<div key={group} className="mt-3">
|
||||
<p className="text-xs font-medium tracking-wide text-ink-faint">{group}</p>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{files.map((file) => (
|
||||
<li key={file}>
|
||||
<Link
|
||||
href={
|
||||
mintOn
|
||||
? `/api/note/auth/dev-link?file=${encodeURIComponent(file)}&mode=${sampleMode}`
|
||||
: `/api/note/files/open?fileUrl=${encodeURIComponent(`/samples/${file}`)}`
|
||||
}
|
||||
className="focus-ring rounded text-primary transition hover:underline"
|
||||
>
|
||||
{file}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── Flow ─────────────────────────────────────────────────────────── */}
|
||||
<section className="mt-10">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">Flow</h2>
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6 text-center text-sm font-medium leading-relaxed text-ink">
|
||||
e-Court สร้างลิงก์เข้าใช้ (มีตัวยืนยันตัวตน + ที่อยู่ไฟล์)
|
||||
<br />↓<br />
|
||||
ผู้ใช้กดลิงก์ → ระบบตรวจแล้วออก session ให้ (ใช้ได้ 8 ชม.)
|
||||
<br />↓<br />
|
||||
เข้าหน้าอ่านเอกสาร — มาร์กข้อความ วาดเส้น คอมเมนต์ คั่นหน้า
|
||||
<br />↓<br />
|
||||
บันทึกทันทีทุกครั้งที่แก้ คนอื่นที่เปิดไฟล์เดียวกันเห็นสด ๆ
|
||||
<br />↓<br />
|
||||
ดูรวมทั้งหมดได้ที่หน้า "สรุปหมายเหตุ" และ "เอกสารในกลุ่ม"
|
||||
<br />↓<br />
|
||||
ระบบอื่น (เช่น e-Court) ดึงข้อมูลไปใช้ต่อได้ผ่าน API
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* ── ทางเข้าระบบผ่านลิงก์ e-Court (คู่มือ 2 ทาง ตามแผน SSO) ────────── */}
|
||||
<section className="mt-12">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">
|
||||
ทางเข้าระบบผ่านลิงก์ e-Court (SSO)
|
||||
</h2>
|
||||
</div>
|
||||
<p className="mt-3 text-sm text-ink-soft">
|
||||
แอปนี้ไม่มีหน้าล็อกอิน — ผู้ใช้เข้าได้ทางลิงก์ที่ e-Court สร้างให้อย่างเดียว
|
||||
ลิงก์มี 2 แบบ (ใช้แบบใดแบบหนึ่ง — ห้ามใส่ครบทั้งสองแบบในลิงก์เดียว)
|
||||
</p>
|
||||
|
||||
<div className="mt-5 grid gap-5 lg:grid-cols-2">
|
||||
{/* ทาง A — JWT */}
|
||||
<article className="flex flex-col rounded-xl border border-line bg-white p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-display text-subhead text-ink">ทาง A — ขอ token (JWT)</h3>
|
||||
<span className="rounded bg-primary/[0.08] px-1.5 py-0.5 text-[11px] font-medium text-primary">
|
||||
แนะนำ
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-ink-soft">
|
||||
เหมาะเมื่อสร้างลิงก์ให้ผู้ใช้ได้ตอนนั้นเลย (ตอนเปิดหน้า) — คีย์ที่ใช้เซ็น
|
||||
อยู่ฝั่ง server เท่านั้น ปลอมตัวเป็นผู้อื่นไม่ได้
|
||||
</p>
|
||||
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
||||
<li>
|
||||
ฝั่ง e-Court เรียก <Code>POST /api/note/auth/token</Code> พร้อมรหัส{" "}
|
||||
<Code>x-api-key</Code> (ค่าจาก env <Code>DOCVER_API_KEY</Code>) และข้อมูล:
|
||||
</li>
|
||||
</ol>
|
||||
<CodeBlock>{`POST {BASE}/api/note/auth/token
|
||||
x-api-key: <DOCVER_API_KEY>
|
||||
|
||||
{"userid":"<u_userid>","username":"<u_userlogin>",
|
||||
"fid":"<fileId>","ttl_seconds":300}`}</CodeBlock>
|
||||
<ol start={2} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
||||
<li>
|
||||
ได้ <Code>access_token</Code> กลับมา (อายุ 1–10 นาที) — นำไปใส่ในลิงก์
|
||||
เป็นพารามิเตอร์ <Code>t</Code> ให้ผู้ใช้กด:
|
||||
</li>
|
||||
</ol>
|
||||
<CodeBlock>{`{BASE}/note
|
||||
?fileUrl=<urlencode>&typeId=<กลุ่ม ทางเลือก>&t=<access_token>`}</CodeBlock>
|
||||
<ol start={3} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
||||
<li>
|
||||
ผู้ใช้กดลิงก์ → ระบบตรวจแล้วออก session ให้ (เก็บในเครื่องของผู้ใช้
|
||||
อายุ 8 ชม.) → พาเข้าหน้าอ่านเอกสาร และตัดค่า token ออกจากลิงก์ทันที
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-4 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
|
||||
ข้อควรระวัง: สร้าง token ตอนแสดงหน้าให้ผู้ใช้คนนั้น ๆ เท่านั้น และ{" "}
|
||||
<strong className="font-medium text-ink-soft">
|
||||
ห้ามเก็บ token ไว้ในที่อื่น
|
||||
</strong>{" "}
|
||||
(อายุสั้นมาก) · token นี้ใช้เข้าแอปนี้อย่างเดียว เอาไปเรียก API
|
||||
กลุ่ม documents ไม่ได้
|
||||
</p>
|
||||
</article>
|
||||
|
||||
{/* ทาง B — ticket */}
|
||||
<article className="flex flex-col rounded-xl border border-line bg-white p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-display text-subhead text-ink">ทาง B — ticket</h3>
|
||||
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
|
||||
สร้างล่วงหน้า
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1.5 text-sm text-ink-soft">
|
||||
เหมาะเมื่อสร้างลิงก์เตรียมไว้ล่วงหน้าได้ ไม่ต้องเรียก API ตอนแสดงหน้า —
|
||||
ใช้คู่ <Code>ticket_data</Code> + <Code>HmacSha256</Code> ชุดเดียวกับที่ฝัง
|
||||
ไว้ในไฟล์ .docx ของ add-in
|
||||
</p>
|
||||
<ol className="mt-4 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
||||
<li>
|
||||
e-Court เตรียม <Code>ticket_data</Code> (JSON มี{" "}
|
||||
<Code>exp/fid/uid/uname</Code>) แล้วเซ็น <Code>HmacSha256</Code> ด้วยคีย์{" "}
|
||||
<Code>ADDIN_TICKET_SECRET</Code>
|
||||
</li>
|
||||
<li>นำมาใส่ในลิงก์ให้ผู้ใช้กด:</li>
|
||||
</ol>
|
||||
<CodeBlock>{`{BASE}/note
|
||||
?fileUrl=<urlencode>&typeId=<ทางเลือก>
|
||||
&ticket_data=<JSON urlencode>&HmacSha256=<hex 64 ตัว>`}</CodeBlock>
|
||||
<ol start={3} className="mt-3 list-decimal space-y-1.5 pl-5 text-sm text-ink">
|
||||
<li>
|
||||
ผู้ใช้กดลิงก์ → ระบบตรวจลายเซ็นและวันหมดอายุ → ออก session 8 ชม. →
|
||||
พาเข้าหน้าอ่านเอกสาร
|
||||
</li>
|
||||
</ol>
|
||||
<p className="mt-4 rounded-lg border border-line bg-canvas p-3 text-xs leading-relaxed text-ink-soft">
|
||||
ข้อควรระวัง: ตั้ง <Code>exp</Code> สั้น ๆ (ไม่เกิน ~10 นาที เพราะค่าเหล่านี้
|
||||
อยู่ในลิงก์) · ค่าผู้ใช้ (<Code>userid/username</Code> ทาง A,{" "}
|
||||
<Code>uid/uname</Code> ทาง B) ต้องเป็นคนเดียวกับ{" "}
|
||||
<Code>u_userid/u_userlogin</Code> ของ e-Court — ใส่ผิดคน = ข้อมูล
|
||||
ผูกกับคนผิด
|
||||
</p>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
{/* dev tool — ฟอร์ม mint ลิงก์ (เปิดด้วย env NOTE_SSO_DEV_MINT=1 เท่านั้น:
|
||||
ตัวช่วยทดสอบ ไม่ใช่ทางเข้าจริง และตายเสมอใน production build) */}
|
||||
{devMintEnabled() && (
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-5">
|
||||
<h4 className="font-display text-sm font-semibold text-ink">
|
||||
สร้างลิงก์ทดสอบเอง (dev)
|
||||
</h4>
|
||||
<p className="mt-1.5 text-sm text-ink-soft">
|
||||
กรอกชื่อผู้ใช้แล้วกดสร้าง — ระบบจะสร้างลิงก์ให้จากคีย์ของตัวเอง
|
||||
(ค่าลับไม่โผล่ที่หน้าเว็บ) ได้ลิงก์มาแล้วกด "เปิดลิงก์"
|
||||
เพื่อทดสอบเข้าจริงทั้งสองแบบ
|
||||
</p>
|
||||
<div className="mt-4">
|
||||
<DevLoginLinkForm />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* dev tool — สคริปต์มือ (ใช้ได้เสมอ ไม่ขึ้นกับ env) */}
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-5">
|
||||
<h4 className="font-display text-sm font-semibold text-ink">
|
||||
ทดสอบบนเครื่อง (dev)
|
||||
</h4>
|
||||
<p className="mt-1.5 text-sm text-ink-soft">
|
||||
สร้างลิงก์เองบนเครื่องโดยไม่ต้องรอ e-Court — สคริปต์อ่านค่าจากไฟล์{" "}
|
||||
<Code>.env</Code> แล้วพิมพ์ลิงก์เต็มให้ก๊อปไปเปิดใน browser:
|
||||
</p>
|
||||
<CodeBlock>{`# ทาง A (default)
|
||||
node scripts/make-login-link.mjs --uid oom --uname "สรวิชญ์" --fid F123 \\
|
||||
--fileUrl /samples/01012-672427-1f-690609-0000840223.pdf \\
|
||||
--base http://localhost:3000/docedit
|
||||
|
||||
# ทาง B
|
||||
node scripts/make-login-link.mjs --mode ticket --uid oom --uname "สรวิชญ์" --fid F123 \\
|
||||
--fileUrl /samples/01012-672427-1f-690609-0000840223.pdf \\
|
||||
--base http://localhost:3000/docedit`}</CodeBlock>
|
||||
<p className="mt-3 text-xs text-ink-soft">
|
||||
ถ้าลิงก์หมดอายุหรือไม่ถูกต้อง ระบบจะพากลับหน้าแรกพร้อมข้อความบอกสาเหตุ —
|
||||
สร้างลิงก์ใหม่แล้วกดอีกครั้งได้เลย
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
import type { Metadata } from "next";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import DotGrid from "@/components/DotGrid";
|
||||
import LoginForm from "@/components/LoginForm";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { firstSearchParam, sanitizeNextPath } from "@/utils/external-file";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export const metadata: Metadata = { title: "เข้าสู่ระบบ | Judge's Note" };
|
||||
|
||||
export default function LoginPage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// N8 D5: path วนกลับหลัง login (middleware แนบมาตอน redirect เข้าหน้านี้) —
|
||||
// ค่ามาจาก query ที่ใครก็แก้ได้ ต้อง sanitize กัน open-redirect ก่อนใช้เสมอ
|
||||
const rawNext = firstSearchParam(searchParams.next);
|
||||
const next = rawNext !== undefined ? sanitizeNextPath(rawNext) : null;
|
||||
|
||||
// เข้าสู่ระบบอยู่แล้ว (cookie ครบ) → ไม่ต้องเห็นฟอร์มอีก
|
||||
const user = getSessionUser();
|
||||
if (user) redirect(next ?? "/note");
|
||||
|
||||
return (
|
||||
// Genesis: กริดจุดอันตรกิริยา ( decorative element ชิ้นเดียวที่อนุญาต) เป็นพื้นหลัง
|
||||
// หน้า login — DotGrid อยู่ก่อนการ์ดใน DOM จึงถูกทับโดยการ์ดเอง ไม่ต้องเล่น z-index
|
||||
<main className="relative flex min-h-dvh items-center justify-center px-4 py-10">
|
||||
<DotGrid />
|
||||
<LoginForm next={next} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,236 +1,13 @@
|
|||
import Link from "next/link";
|
||||
import { redirect } from "next/navigation";
|
||||
import { asc } from "drizzle-orm";
|
||||
import { FileImage, FileText, Folder } from "lucide-react";
|
||||
|
||||
import { db } from "@/db";
|
||||
import { files } from "@/db/schema";
|
||||
import AppNav from "@/components/AppNav";
|
||||
import EcourtLinkForm from "@/components/EcourtLinkForm";
|
||||
import UploadFileForm from "@/components/UploadFileForm";
|
||||
import { imageFormatFromUrl } from "@/utils/file-kind";
|
||||
import { formatThaiDate } from "@/utils/date-format";
|
||||
import { getSessionUser } from "@/lib/session";
|
||||
import { registerExternalFile } from "@/lib/external-import";
|
||||
import { firstSearchParam } from "@/utils/external-file";
|
||||
import NoteHome from "@/components/NoteHome";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
type FileRow = typeof files.$inferSelect;
|
||||
|
||||
/** badge ชนิดไฟล์ต่อรายการ (N3) */
|
||||
function kindBadge(pdfUrl: string): string {
|
||||
switch (imageFormatFromUrl(pdfUrl)) {
|
||||
case "jpeg":
|
||||
return "JPG";
|
||||
case "png":
|
||||
return "PNG";
|
||||
case "tiff":
|
||||
return "TIFF";
|
||||
default:
|
||||
return "PDF";
|
||||
}
|
||||
}
|
||||
|
||||
/** การ์ดไฟล์ของหน้ารายการ (Genesis kit-card: surface 12px radius ขอบ line ไม่มีเงา
|
||||
* static — hover ยก -2px + shadow-card-hover; tile ไอคอนเป็นกลาง น้ำเงินสงวนให้ของ
|
||||
* interactive ตาม Do&Don't ของ design system) ใช้ร่วมทุก section (N9) */
|
||||
function FileCard({ file }: { file: FileRow }) {
|
||||
return (
|
||||
<li className="flex flex-col rounded-xl border border-line bg-white p-5 transition duration-200 hover:-translate-y-0.5 hover:shadow-card-hover">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-ink/[0.04]">
|
||||
{imageFormatFromUrl(file.pdfUrl) ? (
|
||||
<FileImage aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
) : (
|
||||
<FileText aria-hidden className="h-5 w-5 text-ink-soft" />
|
||||
)}
|
||||
</div>
|
||||
<span className="rounded bg-ink/[0.05] px-1.5 py-0.5 text-[11px] font-medium text-ink-soft">
|
||||
{kindBadge(file.pdfUrl)}
|
||||
</span>
|
||||
</div>
|
||||
<span className="mt-4 block break-all line-clamp-2 text-body font-medium text-ink">
|
||||
{file.title}
|
||||
</span>
|
||||
{/* วันที่เป็นข้อมูลที่ผู้ใช้อ่านจริง — ink-soft (ผ่าน 4.5:1); ink-faint เก็บไว้
|
||||
ให้ placeholder/disabled เท่านั้น (UX review M2 2026-09-23) */}
|
||||
<span className="mt-1 block text-caption text-ink-soft">
|
||||
{formatThaiDate(file.createdAt, true)}
|
||||
</span>
|
||||
{/* ปุ่มเข้า annotate แทนลิงก์กางเต็มการ์ด (M4) ตาม feedback — เป็น secondary
|
||||
(หนึ่ง primary ต่อ section ตาม Genesis; การ์ดทั้งกริดคือ passive surface) */}
|
||||
<div className="mt-5 flex flex-1 items-end justify-end">
|
||||
<Link
|
||||
href={`/note/annotate/${file.id}`}
|
||||
className="focus-ring inline-flex h-8 items-center rounded-md border border-line px-3 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint"
|
||||
>
|
||||
Annotate
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
export default async function HomePage({
|
||||
searchParams,
|
||||
}: {
|
||||
searchParams: Record<string, string | string[] | undefined>;
|
||||
}) {
|
||||
// defense ซ้อน middleware — หน้านี้อ่าน cookie อยู่ดีจึงกันซ้ำไม่ให้ render รายการให้คนไม่มี session
|
||||
const user = getSessionUser();
|
||||
if (!user) redirect("/note/login");
|
||||
|
||||
// N8: เข้าจากลิงก์ e-Court ?fileUrl=&fileId=&typeId= — ลงทะเบียนเอกสาร (metadata-only
|
||||
// dedupe ด้วย external_id) แล้วพาเข้า viewer เลย — redirect() โยน NEXT_REDIRECT
|
||||
// ต้องเรียกนอก try/catch เสมอ; หน้านี้ force-dynamic จึงไม่ต้อง revalidatePath
|
||||
// (entry ที่สองอยู่ที่ /note/annotate?fileUrl=… — app/note/annotate/page.tsx)
|
||||
let registerError: string | null = null;
|
||||
const fileUrl = firstSearchParam(searchParams.fileUrl);
|
||||
if (fileUrl) {
|
||||
const result = await registerExternalFile(
|
||||
fileUrl,
|
||||
firstSearchParam(searchParams.fileId),
|
||||
firstSearchParam(searchParams.typeId),
|
||||
);
|
||||
if (result.ok) redirect(`/note/annotate/${result.fileId}`);
|
||||
registerError = result.error;
|
||||
}
|
||||
|
||||
const rows = await db.select().from(files).orderBy(asc(files.createdAt));
|
||||
|
||||
// N9: จัดกลุ่มตาม type_id — การ์ดในกลุ่มเรียง createdAt เหมือนเดิม (ตาม query ที่ asc)
|
||||
// ส่วนลำดับกลุ่มเรียงตาม createdAt ของไฟล์ล่าสุดในกลุ่ม (กลุ่มที่มี activity ใหม่อยู่บน)
|
||||
// ไฟล์ type_id NULL (seed/อัปโหลด N4/ลิงก์ไม่มี typeId) = section "ไม่มีกลุ่ม" ท้ายสุด
|
||||
const grouped = new Map<string, FileRow[]>();
|
||||
const ungrouped: FileRow[] = [];
|
||||
for (const f of rows) {
|
||||
if (f.typeId) {
|
||||
const list = grouped.get(f.typeId);
|
||||
if (list) list.push(f);
|
||||
else grouped.set(f.typeId, [f]);
|
||||
} else {
|
||||
ungrouped.push(f);
|
||||
}
|
||||
}
|
||||
const sections = Array.from(grouped.entries()).sort(
|
||||
(a, b) =>
|
||||
b[1][b[1].length - 1].createdAt.getTime() -
|
||||
a[1][a[1].length - 1].createdAt.getTime(),
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppNav userName={user.userName} />
|
||||
{/* listing-page: hook ของกฎ coarse 44px ใน globals.css (อย่าถอด — เหมือน
|
||||
summary-page ของหน้าสรุป) */}
|
||||
<main className="listing-page mx-auto max-w-7xl px-4 pb-16 pt-10 sm:px-6">
|
||||
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
|
||||
<h1 className="font-display text-4xl tracking-tight text-ink sm:text-headline">
|
||||
Judge's Note
|
||||
</h1>
|
||||
<p className="mt-2 text-body text-ink-soft">
|
||||
ระบบ PDF Annotation สำหรับ e-Court
|
||||
</p>
|
||||
|
||||
{registerError && (
|
||||
<div
|
||||
role="alert"
|
||||
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
|
||||
>
|
||||
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {registerError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* การ์ดจำลองลิงก์ e-Court (N8) อยู่เหนือรายการเอกสาร — เป็นทางเข้าเปิดเอกสาร
|
||||
จากระบบ e-Court ไม่ใช่การเพิ่มไฟล์เข้ารายการ (คุมความกว้างเดียวกับ
|
||||
ฟอร์มอัปโหลด) */}
|
||||
<section className="mt-12">
|
||||
<div className="border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">
|
||||
เปิดเอกสารจากลิงก์ e-Court
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-5 max-w-xl">
|
||||
<EcourtLinkForm />
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="mt-12">
|
||||
<div className="flex items-baseline justify-between gap-3 border-b border-line pb-3">
|
||||
<h2 className="font-display text-section-title text-ink">เอกสาร</h2>
|
||||
{rows.length > 0 && (
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{rows.length} ไฟล์
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* อัปโหลดไฟล์ (N4) อยู่กับรายการเอกสาร — ไฟล์ที่อัปโหลดเข้ารายการนี้เอง
|
||||
(คุมความกว้างไม่ให้ฟอร์มกินเต็มความกว้างของกริดรายการ) */}
|
||||
<div className="mt-5 max-w-xl">
|
||||
<UploadFileForm />
|
||||
</div>
|
||||
|
||||
{rows.length === 0 ? (
|
||||
<div className="mt-5 rounded-xl border border-dashed border-line bg-white/60 p-12 text-center">
|
||||
<FileText aria-hidden className="mx-auto h-8 w-8 text-ink-faint" />
|
||||
<p className="mt-3 text-sm font-medium text-ink-soft">
|
||||
ยังไม่มีไฟล์ในระบบ
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-ink-soft">
|
||||
รัน /sync-db เพื่อ seed ไฟล์ตัวอย่าง
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{sections.map(([typeId, list]) => (
|
||||
<section key={typeId} className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<Folder
|
||||
aria-hidden
|
||||
className="h-4 w-4 shrink-0 text-ink-soft"
|
||||
/>
|
||||
<h3 className="font-display min-w-0 break-all text-subhead text-ink">
|
||||
{typeId}
|
||||
</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{list.length} ไฟล์
|
||||
</span>
|
||||
{/* Next Link เติม basePath ให้เอง (invariant #12) */}
|
||||
<Link
|
||||
href={`/note/groups/${encodeURIComponent(typeId)}`}
|
||||
className="focus-ring ml-auto shrink-0 rounded text-sm font-medium text-primary hover:underline"
|
||||
>
|
||||
เอกสารในกลุ่ม
|
||||
</Link>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{list.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
{ungrouped.length > 0 && (
|
||||
<section className="mt-8">
|
||||
<div className="flex flex-wrap items-center gap-x-2.5 gap-y-1">
|
||||
<h3 className="font-display text-subhead text-ink">ไม่มีกลุ่ม</h3>
|
||||
<span className="text-caption tabular-nums text-ink-soft">
|
||||
{ungrouped.length} ไฟล์
|
||||
</span>
|
||||
</div>
|
||||
<ul className="mt-4 grid gap-5 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{ungrouped.map((f) => (
|
||||
<FileCard key={f.id} file={f} />
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
/**
|
||||
* หน้าแรก = ข้อมูลเกี่ยวกับระบบ + bootstrap ของลิงก์ e-Court (2026-10-30 — เนื้อหา
|
||||
* ทั้งหมดอยู่ใน client component NoteHome: ระบบทำอะไรได้ / วิธีเข้าใช้ และตัว
|
||||
* แลก token ของลิงก์ e-Court ที่ชี้ตรงมา /note) · public — คนไม่มี session อ่าน
|
||||
* ข้อมูลระบบได้ตาม D15 เดิม ส่วนเครื่องมือทดสอบอยู่ /note/dev
|
||||
*/
|
||||
export default function HomePage() {
|
||||
return <NoteHome />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,17 +7,19 @@ import {
|
|||
} from "@/utils/file-kind";
|
||||
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf";
|
||||
|
||||
// N4 — เสิร์ฟไฟล์ที่อัปโหลด (`public/note/uploads/<uuid>.<ext>`) ผ่าน route handler
|
||||
// N4 — เสิร์ฟไฟล์ที่เคยอัปโหลด (`public/note/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/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่เขียนลง
|
||||
// public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
|
||||
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง"
|
||||
// อ่านอย่างเดียว (invariant #1) + path guard ของ lib/server-pdf เหมือน /api/note/pdf (§5)
|
||||
// (ทางเข้า UI ของ N4 ถอดแล้วตาม PLAN-SSO.md D14 — route นี้เหลือไว้เสิร์ฟไฟล์เก่า
|
||||
// ที่ row ใน DB ชี้อยู่) อ่านอย่างเดียว (invariant #1) + path guard ของ
|
||||
// lib/server-pdf เหมือน /api/note/pdf (§5)
|
||||
|
||||
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ uploadFile เขียน)
|
||||
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ
|
||||
// นอก hex/ขีด/จุด ผ่าน regex นี้ไม่ได้)
|
||||
// ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ตัวอัปโหลดเดิม
|
||||
// เขียน) — ชื่ออื่น 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;
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue