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:
Your Name 2026-10-30 18:05:36 +07:00
parent b1449ab668
commit d37bc2a3f0
49 changed files with 2921 additions and 1307 deletions

View file

@ -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() !== ""

View file

@ -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");
}

View file

@ -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 };
}