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() !== ""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue