Merge branch 'dev' into working

# Conflicts:
#	README.md
This commit is contained in:
Suphonchai Phoonsawat 2026-11-03 13:34:57 +07:00
commit 02add92e3c
57 changed files with 3211 additions and 1363 deletions

View file

@ -10,8 +10,8 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note
DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit
# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template # สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template
EXTERNAL_API_KEY='secret-api-key-1234' EXTERNAL_API_KEY='secret-api-key-1234'
# ที่เก็บไฟล์เทมเพลต(docx) # ที่เก็บไฟล์เทมเพลต(docx) ค่า default คือ /app/public/tmpl
TMPL_PATH='./public/tmpl' #TMPL_PATH='./public/tmpl'
# Sign/Hash ที่ใช้ฝังในเอกสาร(docx) # Sign/Hash ที่ใช้ฝังในเอกสาร(docx)
SECRET="super_secret" SECRET="super_secret"
@ -36,23 +36,45 @@ MINIO_SECRET_KEY=minioadmin
# DOCVER_LINK_TTL_SECONDS=900 # DOCVER_LINK_TTL_SECONDS=900
# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key) # docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key)
# และ POST /api/note/auth/token (เส้น mint login-token ของแอป note — PLAN-SSO.md)
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง) # (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
# GET /api/documents/latest (2026-10-31) รับ DOCVER_API_KEY นี้ หรือ EXTERNAL_API_KEY # GET /api/documents/latest (2026-10-31) รับ DOCVER_API_KEY นี้ หรือ EXTERNAL_API_KEY
# (ด้านบน) ตัวใดตัวหนึ่งตรง = ผ่าน — ทั้งคู่ไม่ตั้ง = 401 fail closed เช่นกัน # (ด้านบน) ตัวใดตัวหนึ่งตรง = ผ่าน — ทั้งคู่ไม่ตั้ง = 401 fail closed เช่นกัน
DOCVER_API_KEY= DOCVER_API_KEY=
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer) # secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret # + login-token ของ SSO (แยกสิทธิ์ด้วย claim purpose — PLAN-SSO.md D4)
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 รวมถึงเข้า annotate
# ไม่ได้ (ทางเข้า session เดียวคือลิงก์ SSO) — dev ต้องมีค่านี้ใน .env เสมอ
# ⚠️ ก่อน production: rotate ค่าจริง (ค่า hex เดิมเคยถูก commit ไว้ใน comment — ถือว่าหลุด)
DOCVER_TOKEN_SECRET= DOCVER_TOKEN_SECRET=
# DOCVER_TOKEN_TTL_SECONDS=3600 # DOCVER_TOKEN_TTL_SECONDS=3600
# (ถอดแล้ว 2026-10-30) NOTE_SESSION_SECRET ของรอบ cookie oacCaseSig — แอป note
# เปลี่ยน session เป็น token แบบ add-in แล้ว (access_token purpose=login จาก
# POST /api/note/auth/session เก็บใน localStorage "note-auth") จึงใช้
# DOCVER_TOKEN_SECRET ตัวเดียวทั้งระบบ: เซ็น login-token ของลิงก์ + เซ็น/ตรวจ
# session token ฝั่ง Server Actions และ API ของแอป (lib/note-token.ts)
# dev-only (ทางเลือก): เปิดฟอร์ม "สร้างลิงก์ทดสอบ" บนหน้า /note + route
# POST /api/note/auth/dev-link — mint ลิงก์ SSO จากหน้าเว็บเพื่อทดสอบ flow จริง
# ⚠️ ตัวช่วยทดสอบเท่านั้น ไม่ใช่ทางเข้าจริง: ปิด = route ตอบ 404 + ฟอร์มหายจากหน้า
# และไม่ทำงานใน production build เด็ดขาด (NODE_ENV=production) — dev server ที่แชร์
# บน LAN เปิดไว้ = คนในวงแชร์เครือข่าย mint ลิงก์เข้าระบบได้ จึงควรเปิดเฉพาะตอนทดสอบ
# NOTE_SSO_DEV_MINT=1
# add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token # add-in ticket auth (2026-10-27, docs/PLAN-ADDIN-AUTH.md): add-in ใน Word ขอ token
# ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp # ที่ POST /api/documents/token ด้วย body {ticket_data, HmacSha256} ที่ e-Court stamp
# มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง # มากับไฟล์ — secret ต้องตรงกับตัว stamp ของ e-Court ทั้งสองฝั่ง
# ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET # ฝั่ง server ตรวจ HMAC ของ ticket: ADDIN_TICKET_SECRET (ไม่ตั้ง = ใช้ env SECRET
# ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement) # ของกลุ่ม template → ยังไม่ตั้งอีก = "super_secret" ตาม requirement)
# ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้ # ⚠️ production ต้องตั้งค่าจริง — ค่า default รู้กันหมด = ใครก็ปลอม ticket ได้
# (ใช้ร่วมกับทาง ticket ของ SSO POST /api/note/auth/session ด้วย — PLAN-SSO.md D12)
# 2026-10-30: ทาง ticket ของ SSO (POST /api/note/auth/session) ปฏิเสธค่า default
# นี้เฉพาะใน production — dev ใช้ default ทดสอบโหมด ticket ได้เลย (เครื่องมือ dev
# อย่าง make-login-link/make-ticket-docx ก็ default ค่าเดียวกัน)
# ADDIN_TICKET_SECRET=super_secret # ADDIN_TICKET_SECRET=super_secret
# ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง # ฝั่ง add-in (inline ลง bundle ตอน build — ใช้เป็นแค่ UX gate ตัดเคสไฟล์ถูกแก้ก่อนยิง
# network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน) # network ส่วนความมั่นคงจริงอยู่ที่ฝั่ง server ข้างบน · ตามคำขอผู้ใช้ 2026-10-29
# แก้ add-in เป็นงานแยก — ยังคงไว้ตาม PLAN-ADDIN-AUTH.md D3)
# NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret # NEXT_PUBLIC_ADDIN_TICKET_SECRET=super_secret

File diff suppressed because one or more lines are too long

View file

@ -65,9 +65,13 @@ MINIO_SECRET_KEY=minioadmin
npm run dev npm run dev
``` ```
<<<<<<< HEAD
คำสั่งเดียวจบ (ทุกขั้น idempotent รันซ้ำได้): `docker compose up -d --wait` (สตาร์ท container ให้ถ้ายังไม่รัน รอ healthy) → `scripts/migrate.mjs` (สร้าง database ถ้ายังไม่มี + apply ไฟล์ `drizzle/*.sql` ที่ยังไม่ได้ apply — ชุดเดียวกับที่ production ใช้ตอน container start) → `scripts/seed-samples.mjs` (sync ไฟล์ตัวอย่างจาก `public/note/samples/` ลงตาราง `files` — รองรับ `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff`; ไฟล์ที่หายจาก disk จะถูกลบ row พร้อม annotation ที่ผูกอยู่ CASCADE) → `next dev` คำสั่งเดียวจบ (ทุกขั้น idempotent รันซ้ำได้): `docker compose up -d --wait` (สตาร์ท container ให้ถ้ายังไม่รัน รอ healthy) → `scripts/migrate.mjs` (สร้าง database ถ้ายังไม่มี + apply ไฟล์ `drizzle/*.sql` ที่ยังไม่ได้ apply — ชุดเดียวกับที่ production ใช้ตอน container start) → `scripts/seed-samples.mjs` (sync ไฟล์ตัวอย่างจาก `public/note/samples/` ลงตาราง `files` — รองรับ `.pdf` และรูปภาพ `.jpg/.jpeg/.png/.tif/.tiff`; ไฟล์ที่หายจาก disk จะถูกลบ row พร้อม annotation ที่ผูกอยู่ CASCADE) → `next dev`
เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/note/uploads/`) เปิด http://localhost:3000/docedit/note — คลิกไฟล์เพื่อเข้าหน้า annotate หรืออัปโหลดไฟล์ใหม่ (PDF/รูปภาพ ≤ 100MB) ผ่านฟอร์มบนหน้ารายการ (N4 — ไฟล์เก็บที่ `public/note/uploads/`)
=======
เปิด http://localhost:3000/docedit/note — หน้าแรก (landing) · ผู้ใช้จริงเข้าผ่านลิงก์เปิดเอกสารของ e-Court (dev mint ลิงก์ทดสอบเองด้วย `node scripts/make-login-link.mjs` — ดู `docs/PLAN-SSO.md`) ส่วนเครื่องมือทดสอบ (เปิดเอกสารตัวอย่าง + คู่มือทางเข้า SSO + ฟอร์ม mint ลิงก์) อยู่ที่ http://localhost:3000/docedit/note/dev
>>>>>>> dev
**คำสั่งเสริม** **คำสั่งเสริม**
@ -176,11 +180,12 @@ utils/user.ts parse/build cookie oacCase (mock login e-Court)
hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id hooks/useAnnotationRealtime.ts EventSource รับ SSE event + dedupe ด้วย id
app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว) app/actions/annotations.ts Server Actions CRUD + publish event (ทางเขียนทางเดียว)
app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=) app/api/note/annotations/stream/route.ts SSE endpoint (GET ?fileId=)
app/api/documents/[docId]/versions/ API จัด version เอกสาร (POST/GET + download-link) app/api/documents/ API จัด version เอกสาร (token + versions + download-link)
app/note/page.tsx รายการไฟล์ + ฟอร์มอัปโหลด (N4) app/note/page.tsx หน้าแรก landing (วิธีเข้าใช้ + ลิงก์ไปหน้าเครื่องมือทดสอบ)
app/note/dev/page.tsx เครื่องมือทดสอบ: เปิดเอกสารตัวอย่าง + คู่มือ SSO + mint ลิงก์
app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer app/note/annotate/[id]/page.tsx โหลด file + initial data → PDFViewer
components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar components/pdf/ PDFViewer (root + virtualization), InkLayer, HighlightToolbar
components/sidebar/ AnnotationSidebar components/sidebar/ AnnotationSidebar
public/note/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples public/note/samples/ ไฟล์ตัวอย่าง (PDF + รูปภาพ) — seed ด้วย npm run seed-samples
public/note/uploads/ ไฟล์ที่อัปโหลดผ่านหน้ารายการ (N4) public/note/uploads/ ไฟล์อัปโหลดสมัย N4 (ฟอร์มถอดแล้ว — เหลือแค่ route เสิร์ฟไฟล์เก่า)
``` ```

View file

@ -34,15 +34,26 @@ import {
MAX_BOOKMARK_NOTE_CHARS, MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS, MAX_BOOKMARK_TITLE_CHARS,
} from "@/utils/bookmark-limits"; } from "@/utils/bookmark-limits";
import { noteUserFromToken } from "@/lib/note-token";
import { rotatedRectAABB, stampRect } from "@/utils/geometry"; 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) ──────────────────────────────── // ─── Input types (client ใช้ตอนเรียก action) ────────────────────────────────
export type CreateAnnotationInput = { export type CreateAnnotationInput = {
fileId: string; fileId: string;
pageIndex: number; pageIndex: number;
userId: string;
userName: string;
type: AnnotationType; type: AnnotationType;
data: AnnotationData; data: AnnotationData;
/** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน /** id ที่ client สร้าง (crypto.randomUUID) ของ optimistic row — ให้ server ใช้ id เดียวกัน
@ -53,9 +64,6 @@ export type CreateAnnotationInput = {
export type CreateBookmarkInput = { export type CreateBookmarkInput = {
fileId: string; fileId: string;
pageIndex: number; pageIndex: number;
userId: string;
/** N6: ชื่อผู้บันทึก — แสดงในผลค้นหา bookmark */
userName: string;
title: string; title: string;
/** % ของความสูงหน้า (0–100) */ /** % ของความสูงหน้า (0–100) */
yOffset: number; yOffset: number;
@ -67,7 +75,6 @@ export type CreateBookmarkInput = {
export type UpdateBookmarkInput = { export type UpdateBookmarkInput = {
id: string; id: string;
userId: string;
/** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */ /** แก้เฉพาะ field ที่ส่งมา (undefined = ไม่แตะ) */
title?: string; title?: string;
note?: string | null; note?: string | null;
@ -91,7 +98,6 @@ const SHAPE_KINDS: readonly string[] = ["rect", "ellipse", "line", "arrow"];
// FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action // FIX-4: ความยาวสูงสุดของ string input ต่อ field — กันเรียก action
// ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ // ตรง ๆ ด้วยข้อความขนาดหลาย MB จน JSONB บวมและ SSE event ใหญ่ผิดปกติ
const MAX_USER_NAME_CHARS = 100;
const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply const MAX_TEXT_CHARS = 5_000; // message/text/note/label ของ annotation + reply
const MAX_QUERY_CHARS = 200; // คำค้นหา (F14) const MAX_QUERY_CHARS = 200; // คำค้นหา (F14)
@ -449,12 +455,14 @@ function revalidateFilePages(fileId: string) {
revalidatePath(`/note/annotate/${fileId}/summary`); 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 raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId"); const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex); 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)) { if (typeof raw?.type !== "string" || !ANNOTATION_TYPES.includes(raw.type as AnnotationType)) {
fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`); fieldError("type", `เป็นหนึ่งใน ${ANNOTATION_TYPES.join(" | ")}`);
} }
@ -466,8 +474,8 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
const values: typeof annotations.$inferInsert = { const values: typeof annotations.$inferInsert = {
fileId, fileId,
pageIndex, pageIndex,
userId, userId: user.userid,
userName, userName: user.username,
type, type,
data, data,
}; };
@ -484,9 +492,9 @@ export async function createAnnotation(input: CreateAnnotationInput): Promise<An
* F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน — * F19/A7: snapshot row เป็น version kind='delete' **ก่อนลบ** ใน transaction เดียวกัน —
* annotation_id ไม่ FK (D8) จึงรอดจาก CASCADE และกู้คืนได้ภายหลัง * 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 annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const removed = await db.transaction(async (tx) => { const removed = await db.transaction(async (tx) => {
// FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ // FOR UPDATE กันสองแท็บลบพร้อมกัน → snapshot 'delete' เกิด 2 แถวซ้ำ
const [existing] = await tx 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 เดียวกัน * - F19/A7: snapshot row เดิมเป็น version kind='update' **ก่อนเขียนทับ** ใน transaction เดียวกัน
*/ */
export async function updateAnnotation( export async function updateAnnotation(
token: string,
id: string, id: string,
userId: string,
data: AnnotationData, data: AnnotationData,
): Promise<AnnotationDTO> { ): Promise<AnnotationDTO> {
const uid = requireUser(token).userid;
const annId = vUuid(id, "id"); const annId = vUuid(id, "id");
const uid = vString(userId, "userId");
const row = await db.transaction(async (tx) => { const row = await db.transaction(async (tx) => {
const [existing] = await tx const [existing] = await tx
@ -582,9 +590,11 @@ export async function updateAnnotation(
* ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น * ต่างจาก delete ที่จำกัดเจ้าของ) — publish update event ให้ทุก client เห็น
*/ */
export async function setAnnotationResolved( export async function setAnnotationResolved(
token: string,
id: string, id: string,
resolved: boolean, resolved: boolean,
): Promise<AnnotationDTO> { ): Promise<AnnotationDTO> {
requireUser(token);
const annId = vUuid(id, "id"); const annId = vUuid(id, "id");
if (typeof resolved !== "boolean") { if (typeof resolved !== "boolean") {
fieldError("resolved", "เป็น boolean"); fieldError("resolved", "เป็น boolean");
@ -614,16 +624,13 @@ export async function setAnnotationResolved(
export type CreateReplyInput = { export type CreateReplyInput = {
annotationId: string; annotationId: string;
userId: string;
userName: string;
message: 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 raw = input as unknown as Record<string, unknown> | null | undefined;
const annotationId = vUuid(raw?.annotationId, "annotationId"); 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 message = vString(raw?.message, "message", MAX_TEXT_CHARS);
const [parent] = await db const [parent] = await db
@ -636,7 +643,7 @@ export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
const [row] = await db const [row] = await db
.insert(annotationReplies) .insert(annotationReplies)
.values({ annotationId, userId, userName, message }) .values({ annotationId, userId: user.userid, userName: user.username, message })
.returning(); .returning();
const dto = toReplyDTO(row); const dto = toReplyDTO(row);
publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto }); publishAnnotateEvent(parent.fileId, { table: "replies", action: "create", row: dto });
@ -645,9 +652,9 @@ export async function createReply(input: CreateReplyInput): Promise<ReplyDTO> {
} }
/** ลบได้เฉพาะ reply ของตัวเอง (userId ตรงกัน) เท่านั้น */ /** ลบได้เฉพาะ 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 replyId = vUuid(id, "id");
const uid = vString(userId, "userId");
const [existing] = await db const [existing] = await db
.select({ annotationId: annotationReplies.annotationId }) .select({ annotationId: annotationReplies.annotationId })
.from(annotationReplies) .from(annotationReplies)
@ -751,12 +758,11 @@ async function isDescendant(tx: Tx, id: string, ancestorId: string): Promise<boo
throw new Error("โครงสร้าง bookmark ผิดปกติ (parentId วนซ้ำ)"); 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 raw = input as unknown as Record<string, unknown> | null | undefined;
const fileId = vUuid(raw?.fileId, "fileId"); const fileId = vUuid(raw?.fileId, "fileId");
const pageIndex = vPageIndex(raw?.pageIndex); 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); const title = vString(raw?.title, "title", MAX_BOOKMARK_TITLE_CHARS);
if ( if (
typeof raw?.yOffset !== "number" || typeof raw?.yOffset !== "number" ||
@ -790,7 +796,16 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
const [inserted] = await tx const [inserted] = await tx
.insert(bookmarks) .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(); .returning();
return inserted; return inserted;
}); });
@ -804,10 +819,10 @@ export async function createBookmark(input: CreateBookmarkInput): Promise<Bookma
* N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId) * N2: แก้ไข bookmark ของตัวเอง — ชื่อ/โน้ต/ย้ายหัวข้อ (parentId)
* ย้ายได้ตามเกณฑ์ความลึก (depth แม่ + subtree สูงรวมกัน ≤ BOOKMARK_MAX_DEPTH) และห้ามเข้า subtree ตัวเอง * ย้ายได้ตามเกณฑ์ความลึก (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 raw = input as unknown as Record<string, unknown> | null | undefined;
const bmId = vUuid(raw?.id, "id"); 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 title = raw?.title !== undefined ? vString(raw.title, "title", MAX_BOOKMARK_TITLE_CHARS) : undefined;
const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined; const note = raw?.note !== undefined ? vBookmarkNote(raw.note) : undefined;
@ -873,9 +888,9 @@ export async function updateBookmark(input: UpdateBookmarkInput): Promise<Bookma
/** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น /** ลบได้เฉพาะ bookmark ของตัวเอง (userId ตรงกัน) เท่านั้น
* N2: ลบหัวข้อใหญ่ → sub ทั้งหมดขยับขึ้นเป็นระดับบนสุด (ไม่มีข้อมูลหาย) ใน tx เดียวกัน */ * 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 bmId = vUuid(id, "id");
const uid = vString(userId, "userId");
const result = await db.transaction(async (tx) => { const result = await db.transaction(async (tx) => {
const [existing] = await tx const [existing] = await tx
.select({ id: bookmarks.id, fileId: bookmarks.fileId, userId: bookmarks.userId }) .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 หนึ่งรายการ — ใหม่สุดก่อน */ /** ประวัติการเปลี่ยนแปลงของ annotation หนึ่งรายการ — ใหม่สุดก่อน */
export async function listVersions( export async function listVersions(
token: string,
annotationId: string, annotationId: string,
): Promise<AnnotationVersionDTO[]> { ): Promise<AnnotationVersionDTO[]> {
requireUser(token);
const annId = vUuid(annotationId, "annotationId"); const annId = vUuid(annotationId, "annotationId");
const rows = await db const rows = await db
.select() .select()
@ -945,11 +962,11 @@ export async function listVersions(
* - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี * - เจ้าของเท่านั้น (D9): ตรวจ userId ของ row ปัจจุบัน / ของ snapshot ตามกรณี
*/ */
export async function restoreVersion( export async function restoreVersion(
token: string,
versionId: string, versionId: string,
userId: string,
): Promise<AnnotationDTO> { ): Promise<AnnotationDTO> {
const uid = requireUser(token).userid;
const vId = vUuid(versionId, "versionId"); const vId = vUuid(versionId, "versionId");
const uid = vString(userId, "userId");
const [v] = await db const [v] = await db
.select() .select()
.from(annotationVersions) .from(annotationVersions)
@ -994,6 +1011,9 @@ export async function restoreVersion(
if (v.userId !== uid) { if (v.userId !== uid) {
throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น"); throw new Error("กู้คืนไม่ได้: เป็นรายการของผู้ใช้อื่น");
} }
// validate สมมาตรกับสาขา "ต้นทางยังอยู่" — snapshot อาจเขียนสมัย validator หนาแน่น
// น้อยกว่าปัจจุบัน ห้ามไหลกลับเข้า annotations + SSE โดยไม่โดนตรวจ (invariant #8)
const validated = validateAnnotationData(v.type, v.data);
const [row] = await tx const [row] = await tx
.insert(annotations) .insert(annotations)
.values({ .values({
@ -1002,7 +1022,7 @@ export async function restoreVersion(
userId: v.userId, userId: v.userId,
userName: v.userName, userName: v.userName,
type: v.type, type: v.type,
data: v.data, data: validated,
}) })
.returning(); .returning();
return { action: "create" as const, row }; return { action: "create" as const, row };
@ -1020,7 +1040,12 @@ export async function restoreVersion(
* ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX) * ค้นหาคำในหมายเหตุทั้งไฟล์ — `ilike` บน `data->>'text'` (HIGHLIGHT และ TEXTBOX)
* และ `data->>'message'` (COMMENT); escape `%`/`_`/`\` ในคำค้นก่อนเอาไปใช้ใน pattern * และ `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 fid = vUuid(fileId, "fileId");
const query = vString(q, "q", MAX_QUERY_CHARS); const query = vString(q, "q", MAX_QUERY_CHARS);
const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`); const esc = query.replace(/[\\%_]/g, (c) => `\\${c}`);
@ -1092,7 +1117,8 @@ function dateBound(v: string, endOfDay: boolean, field: string): Date {
* (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ รวมกันด้วย AND); คู่ช่วงกลับด้าน (from > to) * (คำค้น/ช่วงหน้า/ผู้บันทึก/ช่วงวันที่ รวมกันด้วย AND); คู่ช่วงกลับด้าน (from > to)
* = swap เงียบ ๆ — read-only จึงไม่ publish event/ไม่ revalidatePath * = 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 raw = input as unknown as Record<string, unknown> | null | undefined;
const fid = vUuid(raw?.fileId, "fileId"); const fid = vUuid(raw?.fileId, "fileId");
const q = typeof raw?.q === "string" && raw.q.trim() !== "" 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 };
}

View file

@ -92,9 +92,6 @@ async function postWithTicket(request: Request): Promise<Response> {
async function issueToken(claims: DocverTokenClaims): Promise<Response> { async function issueToken(claims: DocverTokenClaims): Promise<Response> {
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") { if (secret === undefined || secret === "") {
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
} }

View file

@ -0,0 +1,134 @@
// เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว (2026-11-02 — ใช้ได้ทุก environment
// รวม production ที่ตั้ง env ไว้, เดิม gate ด้วย NODE_ENV ด้วย) — 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 ทุกขั้นตอน)
// Location relative เหมือน files/open — origin ของ request หลัง reverse proxy
// (Host ถูกเขียนทับเป็น upstream) ไม่น่าเชื่อพอจะประกอบ absolute URL
return new NextResponse(null, {
status: 302,
headers: { location: `${withBasePath("")}?${params.toString()}`, "cache-control": "no-store" },
});
}
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" },
});
}

View 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 } : {}),
});
}

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

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

View file

@ -0,0 +1,43 @@
// 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)
// Location เป็น relative โดยเจตนา: browser resolve เทียบ origin ที่ตัวเองยิงมาเอง
// ส่วน NextResponse.redirect(new URL(path, request.url)) จะประกอบ absolute URL จาก
// Host ที่ reverse proxy ส่งมา (nginx default เขียนทับเป็น upstream เช่น 0.0.0.0:3000)
// แล้วพา browser หลุดโฮสต์จริง (prod 2026-11-02)
const redirectTo = (path: string, query = ""): NextResponse => {
return new NextResponse(null, {
status: 302,
headers: { location: `${withBasePath(path)}${query}`, "cache-control": "no-store" },
});
};
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}`);
}

View file

@ -1,46 +1,79 @@
// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework // ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework
// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ // โดย ผ่าน Form(POST) หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx // จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx
// import { headers } from "next/headers";
import { NextRequest, NextResponse } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import path from 'path'; import path from 'path';
import fs from 'fs/promises'; import fs from 'fs/promises';
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY||'';
const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??""; const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl";
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??""; const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || '';
const TMPL_PATH = process.env.TMPL_PATH??"";
if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) {
console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH')
}
// console.log("download-docx env",DOCEDIT_BASE_URL,EXTERNAL_API_KEY,TMPL_PATH)
async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>> async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise<NextResponse<unknown>>
{ {
// console.log("processRequest get:",fid,tid,uid,uname) //กัน path traversal attack
// console.log(process.cwd()) const TID = path.basename(tid)
const filePath = path.join(TMPL_PATH,`${fid}.json`); const FID = path.basename(fid)
try { const ticketObj = {fid:FID,tid:TID,uid,uname}
if (!fid||!tid||!uid||!uname) { const ticket_data = JSON.stringify({fid:FID,tid:TID,uid,uname})
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 }); const filePath = path.join(TMPL_PATH,`${FID}.json`);
} // สำหรับการ demo ในแอปเดียวเท่านั้น สามารถคำนวนหา base path ได้ด้วยวิธีนี้
// const host = headers().get("host"); // e.g., localhost:3000
// const proto = headers().get("x-forwarded-proto") || "http";
// const baseUrl = process.env.NEXT_PUBLIC_BASE_PATH || ""
// const fullUrl = `${proto}://${host}${baseUrl}`;
// const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL || fullUrl;
if(!DOCEDIT_BASE_URL || !EXTERNAL_API_KEY) {
return NextResponse.json({ error: 'Server must set Environment variable DOCEDIT_BASE_URL or EXTERNAL_API_KEY' }, { status: 500 });
}
if (!FID||!TID||!uid||!uname) {
return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 });
}
try { //ตรวจสอบ docx เวอร์ชั่นล่าสุดก่อนถ้าไม่พบค่อยเอาจากฐานข้อมูลมาสร้าง raw Json
const params = new URLSearchParams({type:'docx',ticket_data});
const lastestDocxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/document/latest?${params.toString()}`, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'x-api-key': EXTERNAL_API_KEY,
}
});
if(!lastestDocxResponse.ok && lastestDocxResponse.status !== 404){//เกิดปัญหาที่ไม่ใช่ Not Found
const errorText = await lastestDocxResponse.text();
console.error('download-docx:Invalid latest docx Response:', errorText);
return NextResponse.json(
{error:`Error from docedit latest: Expected docx stream but received ${lastestDocxResponse.status} ${lastestDocxResponse.statusText}`},
{ status: 502 }
);
}
} catch (error) {
console.error(error)
return NextResponse.json({error:'Internal Server Error'}, { status: 500 });
}
console.log('No version found, generating docx from template and JSON data...');
try { // ไม่พบเวอร์ชั้นให้สร้าง docx จาก template และ json(ฐานข้อมูล)
// อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล // อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล
const rawJson = await fs.readFile(filePath, 'utf-8'); const rawJson = await fs.readFile(filePath, 'utf-8');
const jsonData = JSON.parse(rawJson); const jsonData = JSON.parse(rawJson);
if (!jsonData || typeof jsonData !== 'object' ) { if (!jsonData || typeof jsonData !== 'object' ) {
return NextResponse.json({error:'Invalid data format'}, { status: 400 }); return NextResponse.json({error:'Invalid data format'}, { status: 400 });
} }
/////// !! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend !!//
/////// ไม่มี session return 401, ถ้าเจอแต่ไม่มีสิทธิ์ให้ return 403 , อ่านจากฐานข้อมูล ถ้าไม่พบข้อมูลให้ return 404,
//!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!! // ข้อมูลจำเป็นต้องมี jsonData.info.ticket_data ถ้าไม่มีให้สร้างขึ้นมาเอง
// ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data
if(!Object.hasOwn(jsonData, 'info')) if(!Object.hasOwn(jsonData, 'info'))
jsonData['info']={} jsonData['info']={}
jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname}) jsonData.info['ticket_data'] = JSON.stringify(ticketObj)
// sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้ // sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้
//ยิงไปหาระบบ Docedit เพื่อเอา Stream //ยิงไปหาระบบ Docedit เพื่อเอา Stream
// console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`)
const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, { const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, {
method: 'POST', method: 'POST',
headers: { headers: {
@ -52,7 +85,6 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr
// เช็ค Content-Type และ Status ก่อน // เช็ค Content-Type และ Status ก่อน
const contentType = docxResponse.headers.get('content-type') || ''; const contentType = docxResponse.headers.get('content-type') || '';
if (!docxResponse.ok || contentType.includes('text/html')) { if (!docxResponse.ok || contentType.includes('text/html')) {
const errorText = await docxResponse.text(); const errorText = await docxResponse.text();
console.error('download-docx:Invalid docx Response:', errorText); console.error('download-docx:Invalid docx Response:', errorText);
@ -65,9 +97,8 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr
// 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ // 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ
const stream = docxResponse.body; const stream = docxResponse.body;
// 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์ // 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์
const download_file = `${fid}_${uid}_xxx` const download_file = `${FID}_${uid}_xxx`
return new NextResponse(stream as unknown as BodyInit, { return new NextResponse(stream as unknown as BodyInit, {
status: 200, status: 200,
headers: { headers: {
@ -78,7 +109,7 @@ async function processRequest(fid: string,tid:string,uid:string,uname:string):Pr
}, },
}); });
} catch (error:any) { } catch (error:any) {
if ( error.code === 'ENOENT') { if ( error.code === 'ENOENT') {// ไฟล์ JSON ไม่พบ ถ้าใช้ฐานข้อมูลจริงให้ return 404 แทน
return NextResponse.json( return NextResponse.json(
{ error: `Data not found: ${filePath}`}, { error: `Data not found: ${filePath}`},
{ status: 404 } { status: 404 }

View file

@ -6,25 +6,25 @@ import { NextRequest, NextResponse } from 'next/server';
import {readFile} from 'fs/promises'; import {readFile} from 'fs/promises';
import path from 'path'; import path from 'path';
import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl";
const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234'; const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234';
const TMPL_PATH = process.env.TMPL_PATH || ''
const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||'' const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||''
const SECRET = process.env.SECRET || '' const SECRET = process.env.SECRET || 'super_secret'
const TICKET_SEC = Number(process.env.TICKET_SEC||'300') const TMPL_PATH = process.env.TMPL_PATH || "./public/tmpl";
const TICKET_SEC = Number(process.env.TICKET_SEC||'120')
if (!EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET)
console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX")
// console.log("generate env:",EXTERNAL_API_KEY,TMPL_PATH,PRIVATE_KEY_HEX,SECRET)
export async function POST(request: NextRequest) { export async function POST(request: NextRequest) {
if (!EXTERNAL_API_KEY || !PRIVATE_KEY_HEX || !SECRET){
return NextResponse.json(
{ error: 'Server Error: require EXTERNAL_API_KEY PRIVATE_KEY_HEX and SECRET' },
{ status: 500 }
);
}
// 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key) // 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key)
const apiKey = request.headers.get('x-api-key'); const apiKey = request.headers.get('x-api-key')||'';
if (!apiKey || apiKey !== EXTERNAL_API_KEY) { if (!apiKey || apiKey !== EXTERNAL_API_KEY) {
return NextResponse.json( return NextResponse.json(
{ error: 'Unauthorized: Invalid API Key' }, { error: 'Unauthorized: Invalid API Key ' },
{ status: 401 } { status: 401 }
); );
} }
@ -61,7 +61,7 @@ export async function POST(request: NextRequest) {
//เปิดไฟล์ template //เปิดไฟล์ template
const tmplPath = path.join(TMPL_PATH, `${tid}.docx`); const tmplPath = path.join(TMPL_PATH, `${path.basename(tid)}.docx`);
let templateBuffer: Buffer = await readFile(tmplPath) let templateBuffer: Buffer = await readFile(tmplPath)
const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC); const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC);

View file

@ -1,25 +1,19 @@
import { asc, eq, inArray } from "drizzle-orm"; import { notFound } from "next/navigation";
import { notFound, redirect } from "next/navigation";
import { db } from "@/db"; import AnnotateView from "@/components/pdf/AnnotateView";
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";
const UUID_RE = const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[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"; 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({ export default async function AnnotatePage({
params, params,
}: { }: {
@ -27,48 +21,5 @@ export default async function AnnotatePage({
}) { }) {
const { id } = params; const { id } = params;
if (!UUID_RE.test(id)) notFound(); if (!UUID_RE.test(id)) notFound();
return <AnnotateView fileId={id} />;
// 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}
/>
</>
);
} }

View file

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

319
app/note/dev/page.tsx Normal file
View file

@ -0,0 +1,319 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import AppNav from "@/components/AppNav";
import DevLoginLinkForm from "@/components/DevLoginLinkForm";
import MockEcourtForm from "@/components/MockEcourtForm";
import { devMintEnabled } from "@/lib/note-auth";
export const dynamic = "force-dynamic";
/**
* /note/dev — เครื่องมือทดสอบ (ย้ายมาจากหน้าแรก 2026-10-30 ตอน /note เปลี่ยนเป็น
* landing เรียบ): เปิดเอกสารตัวอย่าง (D14) + คู่มือทางเข้า SSO 2 แบบ + ตัวช่วย mint
* ลิงก์ทดสอบ · **เปิดด้วย env NOTE_SSO_DEV_MINT=1 อย่างเดียว — 0/ไม่ตั้ง = 404
* ทั้งหน้า** (คำขอผู้ใช้ 2026-11-02, ใช้ได้ทุก environment รวม production ที่ตั้ง env
* ไว้) · ตัวอย่างลิงก์เป็น "รูปแบบ + ขั้นตอน" เท่านั้น ไม่มี secret ในหน้า
* · ไม่ 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>;
}) {
// ทางเข้าหน้านี้คุมด้วย env ตัวเดียวกับ dev-link (2026-11-02) — ปิด = 404
// เหมือนไม่มีหน้านี้ ไม่เหลือแค่ข้อความแนะนำ
if (!devMintEnabled()) notFound();
// โหมด 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">
ลิงก์ด้านล่างเปิดเอกสารด้วย session ที่มีอยู่เท่านั้น — ยังไม่มี session
ใช้ฟอร์ม &quot;จำลอง e-Court&quot; ด้านล่างแทน · dev server ที่ต้องการ mint
ให้อัตโนมัติ (ไม่ต้องใส่ key) เพิ่ม <Code>NOTE_SSO_DEV_MINT=1</Code>{" "}
แล้วรีสตาร์ท
</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>
{/* จำลอง e-Court ด้วย API key — แสดงทุก environment (คำขอผู้ใช้ 2026-11-02:
ใช้ทางเข้าจริงเหมือน e-Court ต้องถือ DOCVER_API_KEY จึงไม่เป็นประตูหลัง) */}
<div className="mt-5 max-w-xl rounded-xl border border-line bg-white p-6">
<h3 className="font-display text-subhead text-primary">
จำลอง e-Court — ขอ token ด้วย API key
</h3>
<p className="mt-1.5 text-sm text-ink-soft">
ใส่ API key (<Code>DOCVER_API_KEY</Code>) เพื่อขอ token ผ่าน{" "}
<Code>POST /api/note/auth/token</Code> แบบเดียวกับที่ e-Court ทำจริง
แล้วระบบจะพาเข้าอ่านเอกสารที่เลือกเลย — ได้ session 8 ชม.
(ใช้ได้ทั้ง dev และ production)
</p>
<div className="mt-4">
<MockEcourtForm files={Object.values(SAMPLE_DOCS).flat()} />
</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 />
ดูรวมทั้งหมดได้ที่หน้า &quot;สรุปหมายเหตุ&quot; และ &quot;เอกสารในกลุ่ม&quot;
<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">
กรอกชื่อผู้ใช้แล้วกดสร้าง — ระบบจะสร้างลิงก์ให้จากคีย์ของตัวเอง
(ค่าลับไม่โผล่ที่หน้าเว็บ) ได้ลิงก์มาแล้วกด &quot;เปิดลิงก์&quot;
เพื่อทดสอบเข้าจริงทั้งสองแบบ
</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>
</>
);
}

View file

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

View file

@ -1,236 +1,13 @@
import Link from "next/link"; import NoteHome from "@/components/NoteHome";
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";
export const dynamic = "force-dynamic"; export const dynamic = "force-dynamic";
type FileRow = typeof files.$inferSelect; /**
* หน้าแรก = ข้อมูลเกี่ยวกับระบบ + bootstrap ของลิงก์ e-Court (2026-10-30 — เนื้อหา
/** badge ชนิดไฟล์ต่อรายการ (N3) */ * ทั้งหมดอยู่ใน client component NoteHome: ระบบทำอะไรได้ / วิธีเข้าใช้ และตัว
function kindBadge(pdfUrl: string): string { * แลก token ของลิงก์ e-Court ที่ชี้ตรงมา /note) · public — คนไม่มี session อ่าน
switch (imageFormatFromUrl(pdfUrl)) { * ข้อมูลระบบได้ตาม D15 เดิม ส่วนเครื่องมือทดสอบอยู่ /note/dev
case "jpeg": */
return "JPG"; export default function HomePage() {
case "png": return <NoteHome />;
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&apos;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>
</>
);
} }

View file

@ -7,17 +7,19 @@ import {
} from "@/utils/file-kind"; } from "@/utils/file-kind";
import { PdfPathError, resolvePublicPdfPath } from "@/lib/server-pdf"; 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) จะไล่ // ไม่ใช่ static file ของ Next: production server (next start / standalone) จะไล่
// รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น // รายชื่อไฟล์ใน public/ เก็บไว้ใน Set **ครั้งเดียวตอน startup** เท่านั้น
// (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่ action // (next/dist/server/lib/router-utils/filesystem.js — setupFsCheck) ไฟล์ที่เขียนลง
// เขียนลง public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404) // public/note/uploads/ หลัง server รันแล้วจึง GET ตรง ๆ ไม่ได้ (404)
// ฝั่ง next dev ไม่ผ่าน branch นั้น เลยทำงานได้ — อาการคลาสสิก "local ได้ production พัง" // ฝั่ง 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 เขียน) // ชื่อไฟล์ต้องเป็น uuid + extension ใน whitelist เท่านั้น (ตรงรูปที่ตัวอัปโหลดเดิม
// — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง: อักขระ // เขียน) — ชื่ออื่น 404 กลับไป ไม่รู้จักก็ไม่เปิด (กัน path traversal โดยแบบชื่อเอง:
// นอก hex/ขีด/จุด ผ่าน regex นี้ไม่ได้) // อักขระนอก hex/ขีด/จุด ผ่าน regex นี้ไม่ได้)
const UUID_RE = const UUID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;

View file

@ -1,19 +1,32 @@
"use client";
// Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง // Genesis (docs/DESIGN.md) Navigation: sticky แถบบน 56px + backdrop-blur + เส้นแบ่ง
// ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา (แอปหน้าเดียวต่อ context จึงไม่มี // ล่าง 1px — โลโก้ซ้าย / ผู้ใช้ + ออกจากระบบ ขวา ใช้ที่หน้าแรก /note และหน้า
// ลิงก์กลางจอ) ใช้เฉพาะหน้ารายการ /note — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม // เครื่องมือทดสอบ /note/dev — หน้ากลุ่ม /note/groups/[typeId] เลิกใช้ตาม
// redesign 2026-09-24 (ไม่มี navbar เหลือ back link) — แทน SessionBar เดิมที่แยก // redesign 2026-09-24 (ไม่มี navbar เหลือ back link)
// เป็นแถบของตัวเองกลางหน้า //
// ฉบับ token 2026-10-30: เป็น client component อ่าน session จาก localStorage
// ("note-auth") เอง — มี = ชื่อผู้ใช้ + ปุ่มออกจากระบบ (ล้าง token), ไม่มี =
// hint "เข้าผ่านลิงก์ e-Court" (ไม่มีปุ่ม logout — ไม่มี session ให้ล้าง) ·
// อ่านหลัง mount กัน hydration mismatch (server ไม่รู้ localStorage)
import Link from "next/link"; import Link from "next/link";
import { useEffect, useState } from "react";
import LogoutButton from "./LogoutButton"; import LogoutButton from "./LogoutButton";
import { readNoteSession, type NoteSession } from "@/lib/note-session";
export default function AppNav() {
const [session, setSession] = useState<NoteSession | null>(null);
useEffect(() => {
setSession(readNoteSession());
}, []);
export default function AppNav({ userName }: { userName: string }) {
return ( return (
// appnav: hook ของกฎ coarse 44px ให้ปุ่มในแถบนี้ (globals.css — อย่าถอด) // appnav: hook ของกฎ coarse 44px ให้ปุ่มในแถบนี้ (globals.css — อย่าถอด)
<header className="appnav sticky top-0 z-40 h-14 border-b border-line bg-white/80 backdrop-blur"> <header className="appnav sticky top-0 z-40 h-14 border-b border-line bg-white/80 backdrop-blur">
<div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-3 px-4 sm:px-6"> <div className="mx-auto flex h-full max-w-7xl items-center justify-between gap-3 px-4 sm:px-6">
{/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ {/* ชื่อแบรนด์เป็น ink ตามกฎ "น้ำเงินเฉพาะของ interactive" — tile JN น้ำเงินคือ
แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้ารายการ */} แบรนด์มาร์ก (สืบต่อจากหน้าสรุป/login ดั้งเดิม) ทั้งแถบเป็นลิงก์กลับหน้าหลัก */}
<Link <Link
href="/note" href="/note"
className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md" className="focus-ring flex min-w-0 items-center gap-2.5 rounded-md"
@ -27,9 +40,9 @@ export default function AppNav({ userName }: { userName: string }) {
</Link> </Link>
<div className="flex min-w-0 items-center gap-3"> <div className="flex min-w-0 items-center gap-3">
<p className="hidden min-w-0 truncate text-sm text-ink-soft sm:block"> <p className="hidden min-w-0 truncate text-sm text-ink-soft sm:block">
{userName} {session?.username ?? "เข้าผ่านลิงก์ e-Court"}
</p> </p>
<LogoutButton /> {session !== null && <LogoutButton onCleared={() => setSession(null)} />}
</div> </div>
</div> </div>
</header> </header>

View file

@ -9,6 +9,9 @@ import { useEffect } from "react";
* app/note/annotate/[id]/page.tsx */ * app/note/annotate/[id]/page.tsx */
export default function DebugConsole() { export default function DebugConsole() {
useEffect(() => { useEffect(() => {
// production build ไม่โหลด CDN เด็ดขาด (ระบบ intranet + token อยู่ใน origin
// เดียวกับ script นี้) — ใช้ได้เฉพาะ dev server ที่เครื่องทดสอบเปิดให้ LAN
if (process.env.NODE_ENV === "production") return;
if (!new URLSearchParams(window.location.search).has("debug")) return; if (!new URLSearchParams(window.location.search).has("debug")) return;
// React strict mode รัน effect สองรอบ — กัน append script ซ้ำ // React strict mode รัน effect สองรอบ — กัน append script ซ้ำ
if (document.getElementById("eruda-loader")) return; if (document.getElementById("eruda-loader")) return;

View file

@ -0,0 +1,160 @@
"use client";
import { useState, type FormEvent } from "react";
import { withBasePath } from "@/utils/base-path";
/**
* ฟอร์ม mint ลิงก์ SSO ทดสอบบนหน้า /note/dev (dev-only — route เปิดด้วย env
* NOTE_SSO_DEV_MINT=1 เท่านั้น, คำขอผู้ใช้ 2026-10-29 "ใช้ได้ใน note") — server
* คำนวณ token/ticket ด้วย secret ของตัวเองแล้วคืน URL เต็ม ค่าลับไม่ผ่านหน้า
* เบราว์เซอร์ · ได้ลิงก์มา = คลิกเข้าจริงตาม flow ของทางนั้น (A/B)
*/
export default function DevLoginLinkForm() {
const [mode, setMode] = useState<"jwt" | "ticket">("jwt");
const [userid, setUserid] = useState("oom");
const [username, setUsername] = useState("สรวิชญ์");
const [fid, setFid] = useState("F123");
const [ttl, setTtl] = useState(300);
const [fileUrl, setFileUrl] = useState("/samples/01012-672427-1f-690609-0000840223.pdf");
const [typeId, setTypeId] = useState("");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [url, setUrl] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
setUrl(null);
setCopied(false);
try {
const res = await fetch(withBasePath("/api/note/auth/dev-link"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
mode,
userid,
username,
fid,
ttl_seconds: ttl,
...(fileUrl.trim() !== "" ? { fileUrl: fileUrl.trim() } : {}),
...(typeId.trim() !== "" ? { typeId: typeId.trim() } : {}),
}),
});
if (!res.ok) {
const data = (await res.json().catch(() => null)) as { error?: string } | null;
throw new Error(data?.error ?? `HTTP ${res.status}`);
}
const data = (await res.json()) as { url: string };
setUrl(data.url);
} catch (err) {
setError(err instanceof Error ? err.message : "สร้างลิงก์ไม่สำเร็จ");
} finally {
setBusy(false);
}
};
const inputCls =
"genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint";
const labelCls = "text-xs font-medium text-ink-soft";
return (
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-mode">ทางเข้า</label>
<select
id="devlink-mode"
value={mode}
onChange={(e) => setMode(e.target.value === "ticket" ? "ticket" : "jwt")}
className={inputCls}
>
<option value="jwt">ทาง A — JWT</option>
<option value="ticket">ทาง B — ticket</option>
</select>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-ttl">อายุ (วินาที 60–600)</label>
<input
id="devlink-ttl"
type="number"
min={60}
max={600}
value={ttl}
onChange={(e) => setTtl(Number(e.target.value))}
className={inputCls}
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-uid">userid / uid</label>
<input id="devlink-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-uname">username / uname</label>
<input id="devlink-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-fid">fid (fileId ของ e-Court)</label>
<input id="devlink-fid" value={fid} onChange={(e) => setFid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="devlink-typeid">typeId (ทางเลือก)</label>
<input id="devlink-typeid" value={typeId} onChange={(e) => setTypeId(e.target.value)} className={inputCls} placeholder="ไม่กรอก = ไม่มี" />
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className={labelCls} htmlFor="devlink-fileurl">fileUrl (ไม่กรอกก็ได้ — ไฟล์ตัวอย่าง /samples/… หรือลิงก์ https ที่อนุญาต)</label>
<input id="devlink-fileurl" value={fileUrl} onChange={(e) => setFileUrl(e.target.value)} className={inputCls} placeholder="ไม่กรอก = เข้าหน้าแรกอย่างเดียว" />
</div>
<div className="sm:col-span-2">
<button
type="submit"
disabled={busy}
className="focus-ring inline-flex h-9 items-center rounded-md border border-line bg-white px-4 text-sm font-medium text-ink transition hover:-translate-y-px hover:border-ink-faint disabled:pointer-events-none disabled:opacity-50"
>
{busy ? "กำลังสร้าง…" : "สร้างลิงก์ทดสอบ"}
</button>
</div>
{error && (
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
{error}
</p>
)}
{url && (
<div className="sm:col-span-2 rounded-lg border border-line bg-canvas p-3">
<p className="text-xs font-medium text-ink-soft">
ลิงก์พร้อมใช้ (อายุ {ttl} วินาที) — กดเพื่อทดสอบเข้าจริง:
</p>
<div className="mt-2 flex flex-wrap items-center gap-2">
<a
href={url}
className="focus-ring inline-flex h-8 items-center rounded-md bg-primary px-3 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover"
>
เปิดลิงก์
</a>
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(url);
setCopied(true);
} catch {
/* clipboard โดน block — คัดลอกจากกล่องข้อความด้านล่างเอง */
}
}}
className="focus-ring inline-flex h-8 items-center rounded-md border border-line bg-white px-3 text-xs font-medium text-ink-soft transition hover:border-ink-faint"
>
{copied ? "คัดลอกแล้ว" : "คัดลอก URL"}
</button>
</div>
<p className="mt-2 break-all rounded border border-line bg-white px-2.5 py-2 text-[12px] leading-relaxed text-ink-soft">
{url}
</p>
</div>
)}
</form>
);
}

View file

@ -1,38 +0,0 @@
"use client";
// Genesis (docs/DESIGN.md) — กริดจุดอันตรกิริยา: ลวดลายตกแต่งชิ้นเดียวที่ design
// system อนุญาต ("the interactive dot grid is the only decorative element") ใช้เป็น
// พื้นหลังหน้า login — จุดเทาถี่ 24px + ไฮไลต์น้ำเงินจางตามตำแหน่ง pointer
// ⚠️ ต้องเป็น pointer-events-none เสมอ: ตัว layer เป็น absolute inset-0 ครอบพื้นที่
// ทั้งหน้า ส่วนการ์ด login เป็น static element — ตัว positioned จะชนะ hit-test ทับ
// การ์ดจนคลิก/focus ช่องกรอกไม่ได้ (bug report 2026-09-23) จึงฟัง pointermove ที่
// window แทนของตัวเอง — ไฮไลต์ยังตามเมาส์ได้แม้อยู่เหนือการ์ด + อัปเดตผ่าน CSS
// variable บน style ตรง ๆ ไม่ setState กัน re-render ทุก mousemove
// mask ไล่จางขอบให้กลืนกับพื้น canvas แทนการตัดเส้นชัด
import { useEffect, useRef } from "react";
export default function DotGrid() {
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
const onPointerMove = (e: PointerEvent) => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
el.style.setProperty("--dot-mx", `${e.clientX - rect.left}px`);
el.style.setProperty("--dot-my", `${e.clientY - rect.top}px`);
};
window.addEventListener("pointermove", onPointerMove, { passive: true });
return () => window.removeEventListener("pointermove", onPointerMove);
}, []);
return (
<div
ref={ref}
aria-hidden
className="pointer-events-none absolute inset-0 -z-10 [background-image:radial-gradient(circle,rgba(10,10,10,0.10)_1px,transparent_1px)] [background-size:24px_24px] [-webkit-mask-image:radial-gradient(72%_58%_at_50%_42%,black,transparent)] [mask-image:radial-gradient(72%_58%_at_50%_42%,black,transparent)]"
>
<div className="absolute inset-0 [background-image:radial-gradient(280px_circle_at_var(--dot-mx,50%)_var(--dot-my,36%),rgba(0,51,161,0.07),transparent_72%)]" />
</div>
);
}

View file

@ -1,118 +0,0 @@
"use client";
// N8 — การ์ดจำลองลิงก์ e-Court บนหน้ารายการ: กรอก fileUrl (+ fileId + typeId) แล้วกด
// "เปิดเอกสาร" จะพาไป /annotate?fileUrl=&fileId=&typeId= ซึ่งลงทะเบียนเอกสาร
// (metadata-only) แล้ว redirect เข้าหน้า viewer ให้เอง — ใช้ทดสอบในเครื่องก่อนมี
// ลิงก์จริงจาก e-Court (รับทั้ง path ในระบบเช่น /samples/… และ URL ของโฮสต์ที่อนุญาต
// — ไม่มี scheme นำหน้าระบบเติม https:// ให้)
// แถบลิงก์เต็มข้างล่างแสดง URL จริงที่ปุ่มจะพาไป (absolute — copy ไปเปิด/ส่งต่อได้) ตามที่พิมพ์สด ๆ
// Genesis: การ์ด surface 12px radius ขอบ line ใน section "เปิดเอกสารจากลิงก์ e-Court"
// (แยกจากส่วนเอกสาร) / ปุ่มเป็น secondary ขอบฟ้า / input ผ่าน .genesis-input
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { withBasePath } from "@/utils/base-path";
const inputClass =
"genesis-input mt-1 w-full text-base transition aria-invalid:border-error";
export default function EcourtLinkForm() {
const router = useRouter();
const [fileUrl, setFileUrl] = useState("");
const [fileId, setFileId] = useState("");
const [typeId, setTypeId] = useState("");
const [error, setError] = useState<string | null>(null);
// origin เอาตอน client mount เท่านั้น — SSR ไม่มี window (กัน hydration mismatch)
const [origin, setOrigin] = useState("");
useEffect(() => {
setOrigin(window.location.origin);
}, []);
// query ชุดเดียวกันทั้ง preview และตอนกดปุ่ม — สิ่งที่เห็นคือสิ่งที่ถูกเปิดเป๊ะ
const params = new URLSearchParams();
if (fileUrl.trim()) params.set("fileUrl", fileUrl.trim());
if (fileId.trim()) params.set("fileId", fileId.trim());
if (typeId.trim()) params.set("typeId", typeId.trim());
const queryString = params.toString();
const fullLink = origin
? `${origin}${withBasePath("/annotate")}${queryString ? `?${queryString}` : ""}`
: "";
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (!fileUrl.trim()) {
setError("กรอกลิงก์ไฟล์ก่อน");
return;
}
setError(null);
// router.push เติม basePath ให้เอง (invariant #12 — ต่างจาก fetch/plain href)
router.push(`/note/annotate?${queryString}`);
};
return (
<form onSubmit={onSubmit} className="flex flex-col rounded-xl border border-line bg-white p-5">
{/* ไม่มีหัวการ์ดซ้ำ — h2 "เปิดเอกสารจากลิงก์ e-Court" ของ section ในหน้ารายการ
เป็นหัวเดียวพอแล้ว (responsive audit 2026-09-24: เดิมซ้ำสองชั้นติดกัน) */}
<div className="grid gap-x-4 gap-y-3 sm:grid-cols-2">
<div className="sm:col-span-2">
<label htmlFor="ecourt-file-url" className="block text-sm font-medium text-ink">
ลิงก์ไฟล์
</label>
<input
type="text"
id="ecourt-file-url"
name="fileUrl"
required
value={fileUrl}
onChange={(e) => setFileUrl(e.target.value)}
placeholder="เช่น /samples/ชื่อไฟล์.pdf หรือ https://…"
className={inputClass}
/>
</div>
<div>
<label htmlFor="ecourt-file-id" className="block text-sm font-medium text-ink">
รหัสเอกสาร fileId
</label>
<input
type="text"
id="ecourt-file-id"
name="fileId"
value={fileId}
onChange={(e) => setFileId(e.target.value)}
className={inputClass}
/>
</div>
<div>
<label htmlFor="ecourt-type-id" className="block text-sm font-medium text-ink">
กลุ่ม typeId
</label>
<input
type="text"
id="ecourt-type-id"
name="typeId"
value={typeId}
onChange={(e) => setTypeId(e.target.value)}
placeholder="เช่น 1"
className={inputClass}
/>
</div>
</div>
<button
type="submit"
className="focus-ring mt-4 w-full rounded-md border border-primary/30 px-4 py-2 text-sm font-medium text-primary transition hover:-translate-y-px hover:border-primary hover:bg-primary/5"
>
เปิดเอกสาร
</button>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
กดแล้วระบบลงทะเบียนเอกสาร (ถ้ายังไม่มี) แล้วพาเข้าหน้าอ่าน/มาร์กเอกสารทันที —
ไฟล์รีโมตรับเฉพาะ https ของโฮสต์ที่อนุญาต
</p>
<p className="mt-3 break-all rounded-lg bg-ink/[0.04] px-3 py-2.5 text-xs text-ink-soft">
<span className="font-medium text-ink">ลิงก์เต็ม: </span>
{fullLink || "…"}
</p>
{error && <p className="mt-2 text-xs text-error-ink">{error}</p>}
</form>
);
}

View file

@ -1,143 +0,0 @@
"use client";
// ฟอร์ม login (mock e-Court) — เรียก server action login แล้ว router.replace(next ?? "/note")
// (pattern ตาม UploadFileForm: useTransition + error state + ข้อความ fallback ตอน transport fail)
// next = path วนกลับหลัง login (N8 D5 — ผ่าน sanitizeNextPath จากหน้า login มาแล้ว)
// Genesis (docs/DESIGN.md): การ์ด surface 12px radius ขอบ line ไม่มีเงาระดับ static /
// input ผ่าน .genesis-input / ปุ่ม primary hover ขึ้น 1px + glow ฟ้า (shadow-btn-glow)
import { useRef, useState, useTransition } from "react";
import { useRouter } from "next/navigation";
import { login } from "@/app/actions/auth";
import Select from "@/components/Select";
import { MOCK_USERS } from "@/utils/user";
// text input ของแอป — text-base (16px) เสมอ: กัน iOS auto-zoom ตอน focus บน iPad
// (ทับ text-sm ของ .genesis-input — utility อยู่หลัง @layer components ชนะเสมอ)
// error ตาม spec Inputs: ขอบเปลี่ยนเป็นแดง (ring ยังเป็นฟ้าตาม focus ring หลัก)
const INPUT_CLASS =
"genesis-input mt-1 block w-full text-base transition aria-invalid:border-error";
// dropdown ผู้ใช้ — เหมือน INPUT_CLASS แต่ไม่มี mt-1 (Select ครอบ div.relative: มาร์จินที่ wrapper ข้างนอกแทน)
const SELECT_CLASS = "genesis-input block w-full text-base transition";
export default function LoginForm({ next }: { next: string | null }) {
const router = useRouter();
const passwordRef = useRef<HTMLInputElement | null>(null);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setError(null);
const usernameInput = form.elements.namedItem("username") as HTMLSelectElement;
const passwordInput = form.elements.namedItem("password") as HTMLInputElement;
startTransition(async () => {
try {
const result = await login(usernameInput.value, passwordInput.value);
if (result.ok) {
router.replace(next ?? "/note");
} else {
setError(result.error);
// คง username ไว้ (โอกาสพลาดอยู่ที่ password) — ล้างแล้วโฟกัสให้พิมพ์ทับได้ทันที
passwordInput.value = "";
passwordRef.current?.focus();
passwordRef.current?.select();
}
} catch {
setError("เข้าสู่ระบบไม่สำเร็จ — ลองใหม่อีกครั้ง");
}
});
};
return (
<div className="w-full max-w-sm rounded-xl border border-line bg-white p-8">
{/* brand lockup เดียวกับ nav หน้ารายการ — tile JN น้ำเงินคือแบรนด์มาร์กของระบบ
(หน้าสรุปแถบน้ำเงิน 2026-09-17 สืบต่อจาก tile นี้) ชื่อแบรนด์เป็น ink ตามกฎ
"blue เฉพาะของ interactive" ของ Genesis */}
<div className="flex items-center gap-3">
<div className="flex h-11 w-11 shrink-0 items-center justify-center rounded-lg bg-primary text-base font-bold text-white">
JN
</div>
<div className="min-w-0">
<p className="font-display text-lg font-bold leading-tight text-ink">
Judge&apos;s Note
</p>
<p className="mt-0.5 text-xs text-ink-soft">
ระบบ PDF Annotation สำหรับ e-Court
</p>
</div>
</div>
<h1 className="font-display mt-8 text-subhead text-ink">เข้าสู่ระบบ</h1>
<form onSubmit={onSubmit} className="mt-5">
<div>
<label htmlFor="username" className="block text-sm font-medium text-ink">
ชื่อผู้ใช้
</label>
<div className="mt-1">
<Select
id="username"
name="username"
autoComplete="username"
required
autoFocus
disabled={pending}
defaultValue=""
className={SELECT_CLASS}
>
{/* placeholder บังคับเลือก — ไม่ preselect คนแรก กันผู้ทดสอบ login ผิดคนโดยไม่ตั้งใจ */}
<option value="" disabled>
เลือกผู้ใช้
</option>
{MOCK_USERS.map((u) => (
<option key={u} value={u}>
{u}
</option>
))}
</Select>
</div>
</div>
<div className="mt-4">
<label htmlFor="password" className="block text-sm font-medium text-ink">
รหัสผ่าน
</label>
<input
id="password"
name="password"
type="password"
autoComplete="current-password"
required
disabled={pending}
aria-invalid={!!error || undefined}
ref={passwordRef}
className={INPUT_CLASS}
/>
</div>
<button
type="submit"
disabled={pending}
className="focus-ring mt-6 w-full rounded-md bg-primary px-4 py-2.5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow disabled:pointer-events-none disabled:opacity-50"
>
{pending ? "กำลังตรวจสอบ…" : "เข้าสู่ระบบ"}
</button>
{/* รหัสผ่านทดสอบ — รายชื่อเห็นใน dropdown แล้ว เหลือบอกแค่รหัสผ่าน (mock ไม่มีระบบสมัคร)
กล่อง metadata 8px radius ตาม Genesis (พื้นเทาโปร่งจาง ไม่ใช่การเน้น) */}
<p className="mt-5 rounded-lg bg-ink/[0.04] px-3 py-2.5 text-xs leading-relaxed text-ink-soft">
รหัสผ่านทดสอบ:{" "}
<span className="font-semibold text-ink">Demo1234</span>
</p>
{error && (
<p role="alert" className="mt-3 text-xs text-error-ink">
{error}
</p>
)}
</form>
</div>
);
}

View file

@ -1,29 +1,26 @@
"use client"; "use client";
// ปุ่มออกจากระบบ — server action logout ลบ cookie oacCase แล้ว redirect /login เอง // ปุ่มออกจากระบบ (ฉบับ token 2026-10-30) — session เป็น access_token ใน
import { useTransition } from "react"; // localStorage ไม่ได้อยู่บน server แล้ว การออกจากระบบจึงแค่ล้าง "note-auth"
// แล้วแจ้ง parent ให้ซ่อนปุ่ม/ชื่อผู้ใช้ (ไม่มี server action logout ให้เรียก)
import { LogOut } from "lucide-react"; import { LogOut } from "lucide-react";
import { logout } from "@/app/actions/auth"; import { clearNoteSession } from "@/lib/note-session";
export default function LogoutButton() {
const [pending, startTransition] = useTransition();
export default function LogoutButton({ onCleared }: { onCleared?: () => void }) {
const onLogout = () => { const onLogout = () => {
startTransition(async () => { clearNoteSession();
await logout(); onCleared?.();
});
}; };
return ( return (
<button <button
type="button" type="button"
onClick={onLogout} onClick={onLogout}
disabled={pending} className="focus-ring flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-3 text-xs font-medium text-ink-soft transition hover:-translate-y-px hover:border-ink-faint hover:text-ink"
className="focus-ring flex h-8 shrink-0 items-center gap-1.5 rounded-md border border-line px-3 text-xs font-medium text-ink-soft transition hover:-translate-y-px hover:border-ink-faint hover:text-ink disabled:pointer-events-none disabled:opacity-50"
> >
<LogOut className="h-3.5 w-3.5" aria-hidden /> <LogOut className="h-3.5 w-3.5" aria-hidden />
{pending ? "กำลังออก…" : "ออกจากระบบ"} ออกจากระบบ
</button> </button>
); );
} }

View file

@ -0,0 +1,114 @@
"use client";
import { useState, type FormEvent } from "react";
import { withBasePath } from "@/utils/base-path";
/**
* ฟอร์ม "จำลอง e-Court" บนหน้า /note/dev สำหรับตอน mint อัตโนมัติปิด
* (production เสมอตาม devMintEnabled) — ผู้ทดสอบใส่ DOCVER_API_KEY แล้วยิง
* POST /api/note/auth/token (เส้นเดียวกับที่ backend e-Court ใช้จริง) ได้
* access_token มาแล้วพาเข้า /note?fileUrl=…&t=… ให้ NoteHome แลกเป็น session
* ต่อ — ไม่เป็นประตูหลังเพราะต้องถือ key ที่ถูกต้องอยู่แล้ว
* (คำขอผู้ใช้ 2026-11-02: กดเข้าเอกสารจากหน้า /note/dev บน production ให้ได้)
*/
export default function MockEcourtForm({ files }: { files: string[] }) {
const [apiKey, setApiKey] = useState("");
const [userid, setUserid] = useState("oom");
const [username, setUsername] = useState("สรวิชญ์");
const [file, setFile] = useState(files[0] ?? "");
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const onSubmit = async (e: FormEvent) => {
e.preventDefault();
setBusy(true);
setError(null);
try {
const res = await fetch(withBasePath("/api/note/auth/token"), {
method: "POST",
headers: { "x-api-key": apiKey, "content-type": "application/json" },
// fid ผูกไฟล์ที่เลือกแบบเดียวกับ dev-link (dev-<file>) — แต่ละไฟล์
// ตัวอย่างมี row ecourt: ของตัวเอง ไม่ปนกัน
body: JSON.stringify({
userid: userid.trim(),
username: username.trim(),
fid: `dev-${file}`,
ttl_seconds: 600,
}),
});
const data = (await res.json().catch(() => null)) as
| { access_token?: string; error?: string }
| null;
if (!res.ok || data?.access_token === undefined) {
throw new Error(data?.error ?? `ขอ token ไม่สำเร็จ (HTTP ${res.status})`);
}
// withBasePath("") = /docedit/note — ห้ามส่ง "/note" เข้า withBasePath (D11)
const params = new URLSearchParams({ fileUrl: `/samples/${file}`, t: data.access_token });
window.location.href = `${withBasePath("")}?${params.toString()}`;
} catch (err) {
setError(err instanceof Error ? err.message : "ขอ token ไม่สำเร็จ");
setBusy(false);
}
};
const inputCls =
"genesis-input h-9 px-3 py-1.5 disabled:bg-canvas disabled:text-ink-faint";
const labelCls = "text-xs font-medium text-ink-soft";
return (
<form onSubmit={onSubmit} className="grid gap-3 sm:grid-cols-2">
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className={labelCls} htmlFor="mock-key">
API key (env DOCVER_API_KEY ของเครื่องนี้)
</label>
<input
id="mock-key"
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
className={inputCls}
autoComplete="off"
required
/>
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="mock-uid">userid</label>
<input id="mock-uid" value={userid} onChange={(e) => setUserid(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5">
<label className={labelCls} htmlFor="mock-uname">username</label>
<input id="mock-uname" value={username} onChange={(e) => setUsername(e.target.value)} className={inputCls} required />
</div>
<div className="flex flex-col gap-1.5 sm:col-span-2">
<label className={labelCls} htmlFor="mock-file">เอกสารตัวอย่าง</label>
<select
id="mock-file"
value={file}
onChange={(e) => setFile(e.target.value)}
className={inputCls}
>
{files.map((f) => (
<option key={f} value={f}>{f}</option>
))}
</select>
</div>
<div className="sm:col-span-2">
<button
type="submit"
disabled={busy}
className="focus-ring inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover disabled:pointer-events-none disabled:opacity-50"
>
{busy ? "กำลังเข้าระบบ…" : "ขอ token และเปิดเอกสาร"}
</button>
</div>
{error && (
<p role="alert" className="sm:col-span-2 rounded-lg border border-error/25 bg-error/5 p-3 text-sm text-error-ink">
{error}
</p>
)}
</form>
);
}

290
components/NoteHome.tsx Normal file
View file

@ -0,0 +1,290 @@
"use client";
// หน้าแรก /note — ข้อมูลเกี่ยวกับระบบ (2026-10-30 ตามคำขอผู้ใช้: ระบบทำอะไรได้ /
// วิธีเข้าใช้) + bootstrap ของลิงก์ e-Court (contract ปลายทางลิงก์ยังชี้ที่นี่):
// มี credential ใน URL (t หรือ ticket_data+HmacSha256 — จากลิงก์ที่ e-Court ชี้ตรงมา
// ที่ /note) = แลกเป็น access_token ที่ POST /api/note/auth/session แล้วเก็บ
// localStorage "note-auth" (แบบ add-in) → ตัด credential ออกจาก URL ทันที → มี fileId
// = พาเข้า viewer / ไม่มี = แสดงหน้าข้อมูล · ไม่มี credential = หน้า public ธรรมดา
// (แบนเนอร์ ?sso= ของลิงก์เก่า + fileError ยังรองรับ)
import Link from "next/link";
import { useRouter } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import {
Bookmark,
ExternalLink,
FileText,
Highlighter,
MessagesSquare,
} from "lucide-react";
import AppNav from "@/components/AppNav";
import { parseSsoLinkParams, ssoBanner, type SsoBanner } from "@/lib/note-auth";
import { saveNoteSession } from "@/lib/note-session";
import { withBasePath } from "@/utils/base-path";
type Phase = "boot" | "exchanging" | "ready";
type SessionResponse = {
access_token?: string;
token_type?: string;
expires_in?: number;
userid?: string;
username?: string;
fileId?: string;
fileError?: string;
reason?: string;
};
export default function NoteHome() {
const router = useRouter();
const [phase, setPhase] = useState<Phase>("boot");
const [banner, setBanner] = useState<SsoBanner | null>(null);
const [fileError, setFileError] = useState<string | null>(null);
// StrictMode dev รัน effect สองครั้ง — bootstrap ต้องเกิดครั้งเดียวจริง ๆ
const bootedRef = useRef(false);
useEffect(() => {
if (bootedRef.current) return;
bootedRef.current = true;
const url = new URL(window.location.href);
const stripParams = () => {
url.search = "";
window.history.replaceState(null, "", url.toString());
};
const fail = (reason: "expired" | "invalid" | "error") => {
setBanner(ssoBanner(reason));
setPhase("ready");
};
const parsed = parseSsoLinkParams(url);
const hasCredentialKey =
url.searchParams.get("t") !== null ||
url.searchParams.get("ticket_data") !== null ||
url.searchParams.get("HmacSha256") !== null;
if (parsed.ok) {
setPhase("exchanging");
const body: Record<string, string> = {};
if (parsed.credential.kind === "jwt") {
body.t = parsed.credential.token;
} else {
body.ticket_data = parsed.credential.ticketData;
body.HmacSha256 = parsed.credential.hmacHex;
}
if (parsed.fileUrl !== undefined) body.fileUrl = parsed.fileUrl;
if (parsed.typeId !== undefined) body.typeId = parsed.typeId;
// credential ต้องออกจาก URL ทันทีที่รู้ผล — แต่ strip ก่อนยิงไม่ได้ (reload
// กลางคัน = ลิงก์ตาย) จึงกันค้างด้วย timeout: เครือข่ายนิ่งเกิน 45 วิ = ตัดจบ
const controller = new AbortController();
const timeoutId = window.setTimeout(() => controller.abort(), 45_000);
void (async () => {
try {
const res = await fetch(withBasePath("/api/note/auth/session"), {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(body),
signal: controller.signal,
});
const data = (await res.json().catch(() => null)) as SessionResponse | null;
stripParams();
if (
!res.ok ||
data === null ||
data.access_token === undefined ||
data.userid === undefined ||
data.username === undefined ||
data.expires_in === undefined
) {
fail(
res.status === 401 && data?.reason === "expired"
? "expired"
: res.status === 401
? "invalid"
: "error",
);
return;
}
const { access_token, userid, username, expires_in, fileId, fileError } = data;
saveNoteSession({
token: access_token,
userid,
username,
expMs: Date.now() + expires_in * 1000,
});
if (fileError !== undefined) setFileError(fileError);
if (fileId !== undefined) {
// เข้า viewer เลย — คงสถานะ exchanging ให้ spinner อยู่จนหน้า viewer มาแทน
router.replace(`/note/annotate/${fileId}`);
return;
}
setPhase("ready");
} catch {
stripParams();
fail("error");
} finally {
window.clearTimeout(timeoutId);
}
})();
return;
}
if (hasCredentialKey) {
// มี credential ใน URL แต่รูปไม่ผ่าน (ticket ครึ่งเดียว/ยาวเกิน cap/สองชุดพร้อม
// กัน) — ตัดทิ้ง + แจ้งผู้ใช้ ไม่ปล่อยค่าเน่าค้างอยู่ใน history (D11)
stripParams();
fail("invalid");
return;
}
// ไม่มี credential — หน้าข้อมูลระบบปกติ (แบนเนอร์ ?sso= ของลิงก์เก่า ·
// fileError จาก GET /api/note/files/open ตอนเปิดไฟล์ตัวอย่างไม่สำเร็จ)
setBanner(ssoBanner(url.searchParams.get("sso") ?? undefined));
const openFileError = url.searchParams.get("fileError");
if (openFileError !== null && openFileError !== "") setFileError(openFileError);
setPhase("ready");
}, [router]);
return (
<>
<AppNav />
<main className="mx-auto max-w-4xl px-4 pb-16 pt-12 sm:px-6">
{phase !== "ready" ? (
<p className="mt-2 text-body text-ink-soft" role="status">
{phase === "exchanging"
? "กำลังเข้าสู่ระบบผ่านลิงก์ของ e-Court…"
: "กำลังโหลด…"}
</p>
) : (
<>
{/* ชื่อระบบ Latin ล้วน → General Sans tracking แน่นตาม Genesis display type */}
<h1 className="font-display text-3xl tracking-tight text-ink sm:text-headline">
Judge&apos;s Note
</h1>
<p className="mt-2 text-body text-ink-soft">
ระบบอ่านและทำเครื่องหมายเอกสารคดีของ e-Court — มาร์กข้อความ วาดเส้น
ใส่ความเห็น คั่นหน้า และสรุปหมายเหตุทั้งคดีได้ในที่เดียว
โดยไฟล์ต้นฉบับไม่ถูกแก้ไข
</p>
{banner && (
<div
role="status"
className={`mt-6 rounded-lg border p-4 text-sm ${
banner.intent === "error"
? "border-error/25 bg-error/5 text-error-ink"
: "border-warning/30 bg-warning/5 text-warning-ink"
}`}
>
{banner.text}
</div>
)}
{fileError && (
<div
role="alert"
className="mt-6 rounded-lg border border-error/25 bg-error/5 p-4 text-sm text-error-ink"
>
เปิดเอกสารจากลิงก์ไม่สำเร็จ: {fileError}
</div>
)}
{/* ── ระบบนี้ทำอะไรได้ ────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
ระบบนี้ทำอะไรได้
</h2>
</div>
<div className="mt-5 grid gap-5 sm:grid-cols-2">
{[
{
icon: Highlighter,
title: "มาร์กและวาดบนเอกสาร",
detail:
"มาร์กข้อความไฮไลต์ได้หลายแบบ วาดเส้นด้วยปากกา วาดรูปทรง " +
"ใส่กล่องข้อความและตรายาง รวมถึงปิดบังข้อมูลที่ละเอียดอ่อน " +
"— ย้าย แก้ไข ย่อ-ขยายได้หลังวาง พร้อมปุ่มเรียกคืน (undo/redo)",
},
{
icon: MessagesSquare,
title: "ความเห็นและทำงานร่วมกัน",
detail:
"หมุดความเห็นพร้อมตอบกลับเป็นเธรด ทำเครื่องหมาย “จัดการแล้ว” " +
"เมื่อเสร็จ และเห็นการเปลี่ยนแปลงของทุกคนแบบสด ๆ " +
"ในไฟล์เดียวกันได้ทันที",
},
{
icon: Bookmark,
title: "คั่นหน้าและสรุปคดี",
detail:
"คั่นหน้าเป็นหัวข้อซ้อนกันได้ 3 ระดับพร้อมโน้ตย่อย ค้นหาทั้งไฟล์ " +
"แล้วดูรวมทั้งหมดที่หน้า “สรุปหมายเหตุ” — พิมพ์ออกมาเป็น " +
"เอกสารสรุปของคดีได้",
},
{
icon: FileText,
title: "เอกสารจาก e-Court",
detail:
"เปิด PDF และรูปภาพ (รวมไฟล์ TIFF หลายหน้า) จากลิงก์ของ e-Court " +
"จัดกลุ่มตามคดี และส่งออกเป็น PDF ที่ฝังมาร์กทุกอย่างไว้ในตัว " +
"— ไฟล์ต้นฉบับต้นทางไม่ถูกแตะต้อง",
},
].map(({ icon: Icon, title, detail }) => (
<div
key={title}
className="flex gap-4 rounded-xl border border-line bg-white p-6"
>
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<Icon className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">{title}</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
{detail}
</p>
</div>
</div>
))}
</div>
</section>
{/* ── วิธีเข้าใช้งาน ─────────────────────────────────────────── */}
<section className="mt-10">
<div className="border-b border-line pb-3">
<h2 className="font-display text-section-title text-ink">
วิธีเข้าใช้งาน
</h2>
</div>
<div className="mt-5 flex max-w-xl gap-4 rounded-xl border border-line bg-white p-6">
<span className="flex h-10 w-10 shrink-0 items-center justify-center rounded-lg bg-primary/[0.08] text-primary">
<ExternalLink className="h-5 w-5" aria-hidden />
</span>
<div>
<h3 className="font-display text-subhead text-ink">
เข้าใช้งานผ่านลิงก์ของ e-Court
</h3>
<p className="mt-1.5 text-sm leading-relaxed text-ink-soft">
ระบบนี้ไม่มีหน้าล็อกอิน — เปิดเอกสารจากระบบ e-Court
แล้วระบบพาเข้าหน้าอ่านเอกสารโดยอัตโนมัติ ใช้งานต่อได้ 8 ชม.
ต่อการเข้าหนึ่งครั้ง
</p>
</div>
</div>
<p className="mt-8 text-sm text-ink-soft">
ผู้พัฒนา/ผู้ทดสอบ:{" "}
<Link
href="/note/dev"
className="focus-ring -m-1 inline-block rounded-md px-1 py-0.5 font-medium text-primary transition hover:underline"
>
เครื่องมือทดสอบทางเข้าและเอกสารตัวอย่าง →
</Link>
</p>
</section>
</>
)}
</main>
</>
);
}

View file

@ -1,88 +0,0 @@
"use client";
// N4 — ฟอร์มอัปโหลดไฟล์เอกสารแบบง่ายบนหน้ารายการ (PDF/รูป รับพร้อมกัน)
// เสร็จแล้วรายการโผล่เองจาก revalidatePath("/note") ของ action
// Genesis: หน้าตาเป็นการ์ด surface 12px radius ขอบ line (ใต้หัวข้อของ section
// "เอกสาร" — ไฟล์ที่อัปโหลดเข้ารายการนั้น) + ปุ่ม primary เดียวของ section
import { useState, useTransition } from "react";
import { Upload } from "lucide-react";
import { uploadFile } from "@/app/actions/files";
const ACCEPT = ".pdf,.jpg,.jpeg,.png,.tif,.tiff";
/** ขนาดสูงสุดตาม validator ของ uploadFile (app/actions/files.ts) — เช็คฝั่ง client
* ก่อนอัปโหลด (M5, UX audit 2026-09-16): ไฟล์สแกนคดีที่เกินไม่ต้องรอส่งจนจบ
* บนเน็ตช้าก่อนเจอ error ของ server */
const MAX_BYTES = 100 * 1024 * 1024;
export default function UploadFileForm() {
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const onSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
setError(null);
const input = form.elements.namedItem("file") as HTMLInputElement | null;
const file = input?.files?.[0];
if (file && file.size > MAX_BYTES) {
setError("ไฟล์ใหญ่เกิน 100MB — เลือกไฟล์เล็กกว่า");
return;
}
startTransition(async () => {
try {
const result = await uploadFile(new FormData(form));
if (result.ok) {
form.reset();
} else {
setError(result.error);
}
} catch {
// action fail ที่ระดับ transport (เช่น body ใหญ่เกิน bodySizeLimit ของ next.config)
setError("อัปโหลดไม่สำเร็จ — ลองใหม่อีกครั้ง");
}
});
};
return (
<form onSubmit={onSubmit} className="flex flex-col rounded-xl border border-line bg-white p-5">
<div className="flex items-center gap-2">
<Upload aria-hidden className="h-4 w-4 shrink-0 text-ink-faint" />
<p className="text-sm font-semibold text-ink">อัปโหลดไฟล์</p>
</div>
<input
type="file"
name="file"
accept={ACCEPT}
required
disabled={pending}
className="mt-4 block w-full text-sm text-ink-soft file:mr-3 file:cursor-pointer file:rounded-md file:border-0 file:bg-ink/[0.05] file:px-4 file:py-2 file:text-sm file:font-medium file:text-ink"
/>
{/* กลุ่ม typeId ทางเลือก (N9) — ช่องเดียวกับลิงก์ e-Court, ไม่กรอก = "ไม่มีกลุ่ม";
uncontrolled ส่งไปกับ FormData เหมือนช่องไฟล์ (normalize ที่ action) */}
<label htmlFor="upload-type-id" className="mt-3 block text-sm font-medium text-ink">
กลุ่ม typeId
</label>
<input
type="text"
id="upload-type-id"
name="typeId"
disabled={pending}
placeholder="ไม่กรอก = ไม่มีกลุ่ม"
className="genesis-input mt-1 w-full text-base"
/>
<button
type="submit"
disabled={pending}
className="focus-ring mt-4 w-full 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 disabled:pointer-events-none disabled:opacity-50"
>
{pending ? "กำลังอัปโหลด…" : "อัปโหลด"}
</button>
<p className="mt-3 text-xs leading-relaxed text-ink-soft">
รองรับ PDF, JPG, PNG, TIFF — ไม่เกิน 100MB ต่อไฟล์
</p>
{error && <p className="mt-2 text-xs text-error-ink">{error}</p>}
</form>
);
}

View file

@ -0,0 +1,151 @@
"use client";
// client gate ของหน้า /note/annotate/[id] (ฉบับ token 2026-10-30) — เดิมเป็น server
// component อ่าน identity จาก cookie ผ่าน getSessionUser + query DB ตอน render:
// ตอนนี้หน้าเป็น shell public ฝั่ง server (แค่ตรวจ UUID) ส่วนข้อมูลเริ่มต้น
// (file + annotations + bookmarks + replies) ดึงผ่าน GET /api/note/files/[id]/
// bootstrap พร้อม Bearer หลังเห็น token ใน localStorage — ไม่มี/หมดอายุ = หน้า
// gate "กลับ e-Court กดลิงก์ใหม่" (คู่ขนาน sso=required ของรอบ middleware)
import Link from "next/link";
import { useEffect, useState } from "react";
import DebugConsole from "@/components/DebugConsole"; // TEMP-DEBUG (ลบพร้อมไฟล์)
import { SSO_REQUIRED_BANNER } from "@/lib/note-auth";
import {
clearNoteSession,
noteAuthFetch,
readNoteSession,
type NoteSession,
} from "@/lib/note-session";
import type { AnnotationDTO, BookmarkDTO, ReplyDTO } from "@/db/schema";
import PDFViewer from "@/components/pdf/PDFViewer";
type BootstrapData = {
file: { id: string; title: string; pdfUrl: string };
annotations: AnnotationDTO[];
bookmarks: BookmarkDTO[];
replies: ReplyDTO[];
user: { userId: string; userName: string };
};
type Phase = "boot" | "loading" | "no-session" | "error" | "ready";
const BTN_CLS =
"focus-ring inline-flex h-10 items-center rounded-md bg-primary px-5 text-sm font-medium text-white transition hover:-translate-y-px hover:bg-primary-hover hover:shadow-btn-glow";
function GateScreen({ title }: { title: string }) {
return (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
role="status"
className="rounded-lg border border-warning/30 bg-warning/5 p-4 text-sm text-warning-ink"
>
{SSO_REQUIRED_BANNER.text}
</div>
<p className="mt-3 text-sm text-ink-soft">{title}</p>
<Link href="/note" className={`${BTN_CLS} mt-4`}>
กลับหน้าหลัก
</Link>
</main>
);
}
export default function AnnotateView({ fileId }: { fileId: string }) {
const [phase, setPhase] = useState<Phase>("boot");
const [data, setData] = useState<BootstrapData | null>(null);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const session: NoteSession | null = readNoteSession();
if (session === null) {
setPhase("no-session");
return;
}
setToken(session.token);
setPhase("loading");
let cancelled = false;
void (async () => {
try {
const res = await noteAuthFetch(`/api/note/files/${fileId}/bootstrap`);
if (res.status === 401) {
// token หมดอายุ/ถูกเพิกถิอกลางวัน — ล้างทิ้งแล้วเป็น gate เหมือนไม่เคยเข้า
clearNoteSession();
if (!cancelled) setPhase("no-session");
return;
}
const body = (await res.json().catch(() => null)) as
| BootstrapData
| { error?: string }
| null;
if (!res.ok || body === null || !("file" in body)) {
if (!cancelled) {
setError(
(body as { error?: string } | null)?.error ??
`โหลดเอกสารไม่สำเร็จ (HTTP ${res.status})`,
);
setPhase("error");
}
return;
}
if (!cancelled) {
setData(body);
setPhase("ready");
}
} catch (err) {
if (cancelled) return;
if (err instanceof Error && err.message === "no-session") {
// session หมดพอดีระหว่างต้น effect กับตอน fetch — เป็น gate ไม่ใช่ network error
clearNoteSession();
setPhase("no-session");
return;
}
setError("โหลดเอกสารไม่สำเร็จ — ตรวจการเชื่อมต่อแล้วลองใหม่อีกครั้ง");
setPhase("error");
}
})();
return () => {
cancelled = true;
};
}, [fileId]);
return (
<>
{/* TEMP-DEBUG: อ่าน log บนจอ iPad ผ่าน Eruda เมื่อเปิดหน้าพร้อม ?debug */}
<DebugConsole />
{phase === "no-session" && (
<GateScreen title="ยังไม่มี session ของแอปนี้บนเบราว์เซอร์ หรือ session หมดอายุแล้ว (อายุ 8 ชม.)" />
)}
{phase === "error" && (
<main className="mx-auto max-w-2xl px-4 py-10 sm:px-8">
<div
role="alert"
className="rounded-lg border border-error/25 bg-error/10 p-4 text-sm text-error-ink"
>
{error}
</div>
<Link href="/note" className={`${BTN_CLS} mt-4`}>
กลับหน้าหลัก
</Link>
</main>
)}
{(phase === "boot" || phase === "loading") && (
<main className="flex min-h-dvh items-center justify-center">
<p className="text-sm text-ink-soft" role="status">
กำลังโหลดเอกสาร…
</p>
</main>
)}
{phase === "ready" && data !== null && token !== null && (
<PDFViewer
file={data.file}
initialAnnotations={data.annotations}
initialBookmarks={data.bookmarks}
initialReplies={data.replies}
user={data.user}
token={token}
/>
)}
</>
);
}

View file

@ -113,7 +113,6 @@ import {
type Rect, type Rect,
type Size, type Size,
} from "@/utils/coordinates"; } from "@/utils/coordinates";
import type { OacUser } from "@/utils/user";
import { import {
MAX_BOOKMARK_NOTE_CHARS, MAX_BOOKMARK_NOTE_CHARS,
MAX_BOOKMARK_TITLE_CHARS, MAX_BOOKMARK_TITLE_CHARS,
@ -1315,13 +1314,17 @@ export default function PDFViewer({
initialBookmarks, initialBookmarks,
initialReplies, initialReplies,
user, user,
token,
}: { }: {
file: { id: string; title: string; pdfUrl: string }; file: { id: string; title: string; pdfUrl: string };
initialAnnotations: AnnotationDTO[]; initialAnnotations: AnnotationDTO[];
initialBookmarks: BookmarkDTO[]; initialBookmarks: BookmarkDTO[];
initialReplies: ReplyDTO[]; initialReplies: ReplyDTO[];
/** identity จาก cookie oacCase (หน้า annotate การันตีให้ — ไม่มีช่วง null หลัง mount) */ /** identity จาก access_token ของ session (AnnotateView การันตีให้พร้อมกับ token
user: OacUser; * — ไม่มีช่วง null หลัง mount · ฉบับ token 2026-10-30 แทน cookie oacCase) */
user: { userId: string; userName: string };
/** access_token ส่งเข้าทุก Server Action — identity แกะฝั่ง server */
token: string;
}) { }) {
const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null); const [pdfjs, setPdfjs] = useState<typeof import("pdfjs-dist") | null>(null);
const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null); const [doc, setDoc] = useState<import("pdfjs-dist").PDFDocumentProxy | null>(null);
@ -1332,6 +1335,7 @@ export default function PDFViewer({
// F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่ // F8: Export PDF ดาวน์โหลดในแท็บเดิม (fetch → blob → <a download>) ไม่เปิดแท็บใหม่
const [exporting, setExporting] = useState(false); const [exporting, setExporting] = useState(false);
const [exportError, setExportError] = useState<string | null>(null); const [exportError, setExportError] = useState<string | null>(null);
const exportErrorTimerRef = useRef<number | null>(null);
/** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch /** H1 (UX audit 2026-09-16): error ล่าสุดของการบันทึก/แก้ไข/ลบ annotation — เดิม catch
* ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic) * ทุกตัวมีแต่ console.error ผู้ใช้เห็นรายการหายเงียบ ๆ (React rollback optimistic)
* โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */ * โดยไม่รู้เลยว่าบันทึกไม่สำเร็จ */
@ -1445,7 +1449,14 @@ export default function PDFViewer({
redo: redoHistory, redo: redoHistory,
canUndo, canUndo,
canRedo, canRedo,
} = useHistory(); } = useHistory({
onError: (_err, kind) =>
showActionError(
kind === "undo"
? "เรียกคืนไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง"
: "ทำซ้ำไม่สำเร็จ — ตรวจสอบการเชื่อมต่อแล้วลองอีกครั้ง",
),
});
// Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition // Optimistic update (invariant #9): ใช้ useOptimistic ภายใน startTransition
// React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว // React ถอด optimistic row ออกอัตโนมัติเมื่อ action เสร็จ + rollback เองเมื่อล้มเหลว
@ -1687,7 +1698,13 @@ export default function PDFViewer({
} catch (err) { } catch (err) {
console.error("export PDF ไม่สำเร็จ", err); console.error("export PDF ไม่สำเร็จ", err);
setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)"); setExportError("Export PDF ไม่สำเร็จ — ลองอีกครั้ง (ไฟล์อาจใหญ่เกินหรือต้นฉบับเสียหาย)");
window.setTimeout(() => setExportError(null), 6000); // track handle เหมือน actionErrorTimerRef — กัน export พังสองรอบติดกันแล้ว
// timer รอบแรกเคลียร์ error ของรอบหลังก่อนเวลา
if (exportErrorTimerRef.current !== null) window.clearTimeout(exportErrorTimerRef.current);
exportErrorTimerRef.current = window.setTimeout(() => {
exportErrorTimerRef.current = null;
setExportError(null);
}, 6000);
} finally { } finally {
setExporting(false); setExporting(false);
} }
@ -2152,7 +2169,7 @@ export default function PDFViewer({
type: AnnotationType, type: AnnotationType,
data: AnnotationData, data: AnnotationData,
) { ) {
if (!user) return;
const now = new Date().toISOString(); const now = new Date().toISOString();
const temp: AnnotationDTO = { const temp: AnnotationDTO = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
@ -2172,19 +2189,17 @@ export default function PDFViewer({
await startTransition(async () => { await startTransition(async () => {
applyAnnotation({ kind: "add", row: temp }); applyAnnotation({ kind: "add", row: temp });
try { try {
const saved = await createAnnotation({ const saved = await createAnnotation(token, {
id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย) id: temp.id, // ใช้ id เดียวกับ optimistic row — กัน duplicate ชั่วขณะ (row โผล่แล้วหาย)
fileId: file.id, fileId: file.id,
pageIndex, pageIndex,
userId: user.userId,
userName: user.userName,
type, type,
data, data,
}); });
if (pending.cancelled) { if (pending.cancelled) {
// ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย // ถูกสั่งลบไปแล้วระหว่างรอ — ลบ row จริงที่ server แล้วจบเลย
// ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ) // ไม่ append เข้า state ไม่ลง history (optimistic row ถูกถอดเมื่อ transition จบ)
await deleteAnnotation(saved.id, user.userId); await deleteAnnotation(token, saved.id);
return; return;
} }
setAnnotations((prev) => setAnnotations((prev) =>
@ -2205,15 +2220,13 @@ export default function PDFViewer({
let currentId = saved.id; let currentId = saved.id;
pushHistory({ pushHistory({
undo: async () => { undo: async () => {
await deleteAnnotation(currentId, user.userId); await deleteAnnotation(token, currentId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId)); setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
}, },
redo: async () => { redo: async () => {
const re = await createAnnotation({ const re = await createAnnotation(token, {
fileId: file.id, fileId: file.id,
pageIndex, pageIndex,
userId: user.userId,
userName: user.userName,
type, type,
data, data,
}); });
@ -2233,7 +2246,7 @@ export default function PDFViewer({
} }
async function handleDeleteAnnotation(id: string) { async function handleDeleteAnnotation(id: string) {
if (!user) return;
// FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง // FIX-2: id นี้เป็นรายการที่ create ยังรอ network อยู่ — mark ให้ฝั่ง create ลบต่อเอง
// เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก // เมื่อ action กลับมา (id ชั่วคราวยังไม่มีใน DB) — ไม่ dispatch remove แยกต่างหาก
// เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา) // เพราะ transition ใหม่จบทันทีแล้ว optimistic remove ถูก revert (row กระพริบกลับมา)
@ -2251,18 +2264,16 @@ export default function PDFViewer({
await startTransition(async () => { await startTransition(async () => {
applyAnnotation({ kind: "remove", id }); applyAnnotation({ kind: "remove", id });
try { try {
await deleteAnnotation(id, user.userId); await deleteAnnotation(token, id);
setAnnotations((prev) => prev.filter((a) => a.id !== id)); setAnnotations((prev) => prev.filter((a) => a.id !== id));
if (target) { if (target) {
// F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น // F2: undo ของ delete = สร้างใหม่จาก data เดิม (id ใหม่); redo = ลบ id ที่สร้างใหม่นั้น
let currentId: string | null = null; let currentId: string | null = null;
pushHistory({ pushHistory({
undo: async () => { undo: async () => {
const re = await createAnnotation({ const re = await createAnnotation(token, {
fileId: file.id, fileId: file.id,
pageIndex: target.pageIndex, pageIndex: target.pageIndex,
userId: target.userId,
userName: target.userName,
type: target.type, type: target.type,
data: target.data, data: target.data,
}); });
@ -2273,7 +2284,7 @@ export default function PDFViewer({
}, },
redo: async () => { redo: async () => {
if (!currentId) return; if (!currentId) return;
await deleteAnnotation(currentId, user.userId); await deleteAnnotation(token, currentId);
setAnnotations((prev) => prev.filter((a) => a.id !== currentId)); setAnnotations((prev) => prev.filter((a) => a.id !== currentId));
}, },
}); });
@ -2300,7 +2311,7 @@ export default function PDFViewer({
* await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */ * await ครั้งแรกเท่านั้น ไม่รอ action จบ ใช้ตัวนั้นหน่วงคิวแล้ว action เรียงไม่ได้) */
function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise<void> { function applyUpdate(id: string, data: AnnotationData, before: AnnotationData): Promise<void> {
return new Promise((resolve) => { return new Promise((resolve) => {
if (!user) return resolve();
const current = annotations.find((a) => a.id === id); const current = annotations.find((a) => a.id === id);
if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9) if (!current || current.userId !== user.userId) return resolve(); // ของคนอื่นแก้ไม่ได้ (D9)
bumpPreviewClear(); bumpPreviewClear();
@ -2310,15 +2321,15 @@ export default function PDFViewer({
kind: "update", kind: "update",
row: { ...current, data, updatedAt: new Date().toISOString() }, row: { ...current, data, updatedAt: new Date().toISOString() },
}); });
const saved = await updateAnnotation(id, user.userId, data); const saved = await updateAnnotation(token, id, data);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a))); setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
pushHistory({ pushHistory({
undo: async () => { undo: async () => {
const up = await updateAnnotation(id, user.userId, before); const up = await updateAnnotation(token, id, before);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a))); setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
}, },
redo: async () => { redo: async () => {
const up = await updateAnnotation(id, user.userId, data); const up = await updateAnnotation(token, id, data);
setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a))); setAnnotations((prev) => prev.map((a) => (a.id === up.id ? up : a)));
}, },
}); });
@ -2392,7 +2403,7 @@ export default function PDFViewer({
}, },
}); });
try { try {
const saved = await setAnnotationResolved(id, resolved); const saved = await setAnnotationResolved(token, id, resolved);
setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a))); setAnnotations((prev) => prev.map((a) => (a.id === saved.id ? saved : a)));
} catch (err) { } catch (err) {
console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง console.error("ตั้งสถานะไม่สำเร็จ", err); // React rollback optimistic ให้เอง
@ -2407,12 +2418,10 @@ export default function PDFViewer({
annotationId: string, annotationId: string,
message: string, message: string,
): Promise<boolean> { ): Promise<boolean> {
if (!user) return false;
try { try {
const saved = await createReply({ const saved = await createReply(token, {
annotationId, annotationId,
userId: user.userId,
userName: user.userName,
message, message,
}); });
setReplies((prev) => setReplies((prev) =>
@ -2427,9 +2436,9 @@ export default function PDFViewer({
} }
async function handleDeleteReply(id: string) { async function handleDeleteReply(id: string) {
if (!user) return;
try { try {
await deleteReply(id, user.userId); await deleteReply(token, id);
setReplies((prev) => prev.filter((r) => r.id !== id)); setReplies((prev) => prev.filter((r) => r.id !== id));
} catch (err) { } catch (err) {
console.error("ลบคำตอบไม่สำเร็จ", err); console.error("ลบคำตอบไม่สำเร็จ", err);
@ -2466,8 +2475,12 @@ export default function PDFViewer({
function bookmarkDepthOf(b: BookmarkDTO): number { function bookmarkDepthOf(b: BookmarkDTO): number {
let depth = 1; let depth = 1;
let cur: BookmarkDTO | undefined = b; let cur: BookmarkDTO | undefined = b;
while (cur?.parentId && depth <= 100) { while (cur !== undefined && depth <= 100) {
const parent = optimisticBookmarks.find((x) => x.id === cur!.parentId); // annotate ชัดกัน TS7022 — inference ของ cur วนอ้าง parent ที่อ้าง
// optimisticBookmarks ซึ่งยัง infer ไม่เสร็จ (function ถูกใช้ก่อนประกาศ)
const pid: string | null = cur.parentId;
if (!pid) break;
const parent: BookmarkDTO | undefined = optimisticBookmarks.find((x) => x.id === pid);
if (!parent) break; if (!parent) break;
depth += 1; depth += 1;
cur = parent; cur = parent;
@ -2564,15 +2577,13 @@ export default function PDFViewer({
async function handleSaveBookmark() { async function handleSaveBookmark() {
const c = bookmarkComposer; const c = bookmarkComposer;
if (!c || !user || bookmarkPendingRef.current) return; if (!c || bookmarkPendingRef.current) return;
bookmarkPendingRef.current = true; bookmarkPendingRef.current = true;
setBookmarkSaving(true); setBookmarkSaving(true);
try { try {
const saved = await createBookmark({ const saved = await createBookmark(token, {
fileId: file.id, fileId: file.id,
pageIndex: c.pageIndex, pageIndex: c.pageIndex,
userId: user.userId,
userName: user.userName,
title: c.title.trim() || `หน้า ${c.pageIndex + 1}`, title: c.title.trim() || `หน้า ${c.pageIndex + 1}`,
yOffset: c.yOffset, yOffset: c.yOffset,
parentId: c.parentId, parentId: c.parentId,
@ -2597,16 +2608,14 @@ export default function PDFViewer({
/** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */ /** ปุ่ม + ที่หัวข้อใน sidebar → bookmark หน้าที่ดูอยู่เข้าใต้หัวข้อนั้นทันที (ไม่เปิด popup) */
async function handleAddSubBookmark(parentId: string) { async function handleAddSubBookmark(parentId: string) {
if (!user || bookmarkPendingRef.current) return; if (bookmarkPendingRef.current) return;
const pos = currentViewPosition(); const pos = currentViewPosition();
if (!pos) return; if (!pos) return;
bookmarkPendingRef.current = true; bookmarkPendingRef.current = true;
try { try {
const saved = await createBookmark({ const saved = await createBookmark(token, {
fileId: file.id, fileId: file.id,
pageIndex: pos.pageIndex, pageIndex: pos.pageIndex,
userId: user.userId,
userName: user.userName,
title: `หน้า ${pos.pageIndex + 1}`, title: `หน้า ${pos.pageIndex + 1}`,
yOffset: pos.yOffset, yOffset: pos.yOffset,
parentId, parentId,
@ -2626,9 +2635,9 @@ export default function PDFViewer({
id: string, id: string,
patch: { title?: string; note?: string | null; parentId?: string | null }, patch: { title?: string; note?: string | null; parentId?: string | null },
) { ) {
if (!user) return;
try { try {
const saved = await updateBookmark({ id, userId: user.userId, ...patch }); const saved = await updateBookmark(token, { id, ...patch });
setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b))); setBookmarks((prev) => prev.map((b) => (b.id === saved.id ? saved : b)));
} catch (err) { } catch (err) {
console.error("แก้ไข bookmark ไม่สำเร็จ", err); console.error("แก้ไข bookmark ไม่สำเร็จ", err);
@ -2636,11 +2645,11 @@ export default function PDFViewer({
} }
async function handleDeleteBookmark(id: string) { async function handleDeleteBookmark(id: string) {
if (!user) return;
await startTransition(async () => { await startTransition(async () => {
applyBookmark({ kind: "remove", id }); applyBookmark({ kind: "remove", id });
try { try {
await deleteBookmark(id, user.userId); await deleteBookmark(token, id);
setBookmarks((prev) => prev.filter((b) => b.id !== id)); setBookmarks((prev) => prev.filter((b) => b.id !== id));
} catch (err) { } catch (err) {
console.error("ลบ bookmark ไม่สำเร็จ", err); console.error("ลบ bookmark ไม่สำเร็จ", err);
@ -3283,6 +3292,7 @@ export default function PDFViewer({
bookmarks={optimisticBookmarks} bookmarks={optimisticBookmarks}
fileId={file.id} fileId={file.id}
userId={user?.userId ?? ""} userId={user?.userId ?? ""}
token={token}
userFilter={userFilter} userFilter={userFilter}
users={users} users={users}
onUserFilterChange={setUserFilter} onUserFilterChange={setUserFilter}
@ -4483,7 +4493,7 @@ export default function PDFViewer({
void handleDeleteReply(id); void handleDeleteReply(id);
}} }}
onStrokeEnd={(points, widths) => { onStrokeEnd={(points, widths) => {
if (!user) return;
void handleCreateAnnotation(i, "DRAW", { void handleCreateAnnotation(i, "DRAW", {
strokeColor: drawStyle.color, strokeColor: drawStyle.color,
strokeWidth: drawStyle.strokeWidthPct, strokeWidth: drawStyle.strokeWidthPct,
@ -4494,11 +4504,11 @@ export default function PDFViewer({
}); });
}} }}
onShapeEnd={(data) => { onShapeEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "SHAPE", data); void handleCreateAnnotation(i, "SHAPE", data);
}} }}
onRedactionEnd={(data) => { onRedactionEnd={(data) => {
if (!user) return;
void handleCreateAnnotation(i, "REDACTION", data); void handleCreateAnnotation(i, "REDACTION", data);
}} }}
onTextLayerRendered={handleTextLayerRendered} onTextLayerRendered={handleTextLayerRendered}

View file

@ -285,6 +285,7 @@ export default function AnnotationSidebar({
bookmarks, bookmarks,
fileId, fileId,
userId, userId,
token,
userFilter, userFilter,
users, users,
onUserFilterChange, onUserFilterChange,
@ -303,6 +304,9 @@ export default function AnnotationSidebar({
/** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */ /** ไฟล์ที่เปิดอยู่ — ใช้ค้นหาหมายเหตุทั้งไฟล์ (F14) */
fileId: string; fileId: string;
userId: string; userId: string;
/** access_token ของ session — ส่งเข้าทุก action ที่แผงนี้เรียกเอง (ค้นหา/ประวัติ/
* กู้คืน · ฉบับ token 2026-10-30 identity แกะฝั่ง server) */
token: string;
/** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */ /** F4: "all" | "mine" | <userId> — กรองฝั่ง client ล้วน */
userFilter: string; userFilter: string;
users: { userId: string; userName: string }[]; users: { userId: string; userName: string }[];
@ -363,7 +367,7 @@ export default function AnnotationSidebar({
if (!q || !fileId) return; if (!q || !fileId) return;
setSearching(true); setSearching(true);
try { try {
setSearchResults(await searchAnnotations(fileId, q)); setSearchResults(await searchAnnotations(token, fileId, q));
} catch (err) { } catch (err) {
console.error("ค้นหาไม่สำเร็จ", err); console.error("ค้นหาไม่สำเร็จ", err);
setSearchResults([]); setSearchResults([]);
@ -394,7 +398,7 @@ export default function AnnotationSidebar({
setVersions(null); setVersions(null);
setHistoryError(null); setHistoryError(null);
try { try {
setVersions(await listVersions(a.id)); setVersions(await listVersions(token, a.id));
} catch (err) { } catch (err) {
setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ"); setHistoryError(err instanceof Error ? err.message : "โหลดประวัติไม่สำเร็จ");
} }
@ -405,7 +409,7 @@ export default function AnnotationSidebar({
async function handleRestore(versionId: string) { async function handleRestore(versionId: string) {
setRestoringId(versionId); setRestoringId(versionId);
try { try {
await restoreVersion(versionId, userId); await restoreVersion(token, versionId);
setHistoryFor(null); setHistoryFor(null);
} catch (err) { } catch (err) {
setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ"); setHistoryError(err instanceof Error ? err.message : "กู้คืนไม่สำเร็จ");
@ -698,7 +702,7 @@ export default function AnnotationSidebar({
try { try {
const q = bmQuery.trim() || undefined; const q = bmQuery.trim() || undefined;
setBmResults( setBmResults(
await searchBookmarks({ await searchBookmarks(token, {
fileId, fileId,
q, q,
pageFrom, pageFrom,

View file

@ -52,13 +52,13 @@ export default function DependentDropdown({
<div className="space-y-4 w-full"> <div className="space-y-4 w-full">
{/* Dropdown ที่ 1 */} {/* Dropdown ที่ 1 */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label1}</label> <label className="text-sm font-medium text-ink">{label1}</label>
<select <select
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้ name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={mainSelection} value={mainSelection}
onChange={handleMainChange} onChange={handleMainChange}
required required
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm" className="genesis-input w-full"
> >
<option value="">{placeholder1}</option> <option value="">{placeholder1}</option>
{Object.keys(data).map((key) => ( {Object.keys(data).map((key) => (
@ -69,14 +69,14 @@ export default function DependentDropdown({
{/* Dropdown ที่ 2 */} {/* Dropdown ที่ 2 */}
<div className="flex flex-col gap-1.5"> <div className="flex flex-col gap-1.5">
<label className="text-sm font-medium text-gray-700">{label2}</label> <label className="text-sm font-medium text-ink">{label2}</label>
<select <select
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้ name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
value={subSelection} value={subSelection}
onChange={handleSubChange} onChange={handleSubChange}
disabled={!mainSelection} disabled={!mainSelection}
required required
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm" className="genesis-input w-full disabled:bg-canvas disabled:text-ink-faint"
> >
<option value=""> <option value="">
{mainSelection ? '-- กรุณาเลือก --' : placeholder2} {mainSelection ? '-- กรุณาเลือก --' : placeholder2}

314
docs/PLAN-SSO.md Normal file
View file

@ -0,0 +1,314 @@
# PLAN-SSO — Login จากลิงก์ e-Court ด้วย login-link token
> วันที่ตกลงแผน: 2026-10-28 · ปรับแผน 2026-10-29 (ถอดหน้า login ทั้งหน้า + หน้าแรก `/note` เป็นหน้าเลือกเอกสาร — D14/D15 · แยกเส้น mint login token ออกจากกลุ่ม docver — D1) · ปรับแผน 2026-10-29 รอบสอง (เซ็น session cookie HMAC ตาม code review ข้อ "oacCase ไม่มีลายเซ็น" — D16) · ปรับแผน 2026-10-30 (แยกหน้าแรกออกเป็นสองหน้า: `/note` = landing เรียบ ส่วน D14 เครื่องมือทั้งหมด + คู่มือ SSO ย้ายไป **`/note/dev`** — public เหมือนเดิม) · **ปรับแผน 2026-10-30 รอบสอง (ใหญ่): เลิก cookie ทั้งระบบ เปลี่ยน session เป็น access_token แบบ add-in — ดู §0.5 ซึ่งแทนที่ D2/D3/D15/D16 และส่วนที่เหลือของแผนที่พูดถึง cookie** · สถานะ: **implement แล้ว (ฉบับ token) ยกเว้น D13** (ผู้ใช้สั่งไม่แตะของ add-in — การถอด client HMAC + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` เลื่อนเป็นงานแยก: ทาง ticket (B) จึงยังแบกความเสี่ยง secret ร่วมอยู่ใน bundle ของ add-in ตามที่ D13 อธิบาย ส่วนตัว verify ทาง B ฝั่ง server (lib/addin-ticket.ts) ใช้ได้ตามปกติ) · ต่อยอด: `docs/PLAN-ECOURT.md` (N8) + `docs/PLAN-DOCVER.md` §auth
## 0. บริบท/เป้าหมาย
แอป note (annotate) ปัจจุบัน login ด้วย mock (`MOCK_USERS` + `Demo1234`) ตั้ง cookie `oacCase` —
แผนนี้เพิ่ม**ทางเข้าจากลิงก์ e-Court 2 รูปแบบพร้อมกัน** (D12): **(A) JWT** — e-Court mint ผ่าน
ทางเข้า `x-api-key` ของ **`POST /api/note/auth/token` — เส้น mint ใหม่เอกสิทธิ์ของแผนนี้ แยกจาก
`POST /api/documents/token` ของกลุ่ม docver (contract docver ไม่ถูกแต้ ใช้ lib เดียวกัน)** ฝังเป็น param `t=` · **(B) ticket**
— `ticket_data` + `HmacSha256` ชุดเดียวกับที่ stamp ลง .docx ของ add-in สำหรับลิงก์ที่ e-Court
สร้างแบบ offline ไม่ต้องยิง API ตอน render → route ของเรา verify → set cookie → ไหลเข้า N8 flow
เดิม (registerExternalFile → viewer) · **mock login ถอดออกทั้งเส้น** (MOCK_USERS + Demo1234) —
ลิงก์ e-Court เป็นทางเข้าเดียว ส่วน dev/ทดสอบ mint ลิงก์เองผ่านสคริปต์ (D10) · **ไม่มีหน้า
login เหลืออยู่อีกต่อไป** — `/note/login` ถอดทั้งหน้า ปลายทาง error ของ SSO กับ redirect ของ
middleware รวมเป็น**หน้าแรก `/note` ที่เปลี่ยนเป็นหน้าเลือกเอกสารแบบ dropdown 2 ระดับ**
(คล้าย `app/tmpl/page.tsx`) และเป็น public (ปรับแผน 2026-10-29 — D14/D15)
เหตุผลที่ **แนะนำให้ e-Court ใช้ทาง JWT (A) เป็นหลัก** และถือ ticket (B) เป็นทางรอง (2026-10-28):
- secret ตัวเซ็น JWT (`DOCVER_TOKEN_SECRET`) เป็น **server-only โดยโครงสร้าง** — e-Court โดนแฮก
ก็ปลอม identity ใส่เราไม่ได้ (ต่างจาก HMAC secret ร่วมของ ticket: เซิร์ฟเวอร์คู่กรณีใดโดนแฮก
ก็ปลอม ticket ได้)
- ฝั่ง e-Court **ไม่มี crypto contract ใหม่** — ทาง A ใช้ x-api-key ตัวเดิม (`DOCVER_API_KEY`)
ยิงเส้น mint ใหม่ ส่วนทาง B ใช้ตัว stamp เดียวกับ .docx ของ add-in
- ticket (B) เก็บไว้เพราะ mint ได้**แบบ offline** — ครอบคลุมเคสที่ e-Court สร้างลิงก์ล่วงหน้า/
ไม่ยิง API ตอน render · แลกมาด้วยเงื่อนไขบังคับ **D13** (ตัด secret ออกจาก bundle ของ add-in
ก่อน — ไม่งั้นใครแกะ bundle ก็ปลอม ticket เข้า login ได้)
**ผลพลอยได้:** ไม่มีเงื่อนไขค้างกับทีม e-Court อีกต่อไป — ทางไหนที่เขาทำได้ตามบริบทหน้านั้น ๆ
ก็ใช้ได้ (แนะนำ A เมื่อ mint real-time ได้, B เมื่อสร้างลิงก์ล่วงหน้า) · ลิงก์ทั้งสองแบบ
credential อายุสั้น ฝังอีเมล/แชท/เก็บลง DB ไม่ได้อยู่ดี
## 0.5 ฉบับ token แบบ add-in (2026-10-30 รอบสอง — คำขอผู้ใช้ "note ใช้แบบ add-in")
**เลิก cookie ทั้งระบบ** (cookie คู่ `oacCase`/`oacCaseSig` + `NOTE_SESSION_SECRET` + middleware + link proof ที่เคยทำรอบเดียวกันวันนั้น — ถอดหมด) session ของแอป note เปลี่ยนเป็น **access_token ใน localStorage แบบ `wordaddin/services/authService`**:
```
ลิงก์ e-Court ชี้ตรงมา /note?fileUrl=&typeId=&t=… (หรือ ticket_data+HmacSha256)
→ NoteHome (client bootstrap หน้าแรก) POST /api/note/auth/session
→ server verify credential (ทาง A JWT purpose=login / ทาง B ticket + fail-closed
เมื่อ secret เป็นค่า default "super_secret")
→ ลงทะเบียนเอกสาร N8 แบบ trusted เลย (fid จาก credential — ไม่ต้องมี link proof)
→ คืน {access_token (purpose=login อายุ 8 ชม. = NOTE_SESSION_TTL), userid, username, fileId?}
→ client เก็บใน localStorage "note-auth" + ตัด credential ออกจาก URL ทันที
→ มี fileId = router.replace /note/annotate/<rowId>
```
- **การเขียนทุกครั้งผ่าน Server Action ที่รับ `token` เป็น param แรก** — identity แกะฝั่ง server ด้วย `lib/note-token.ts` (`noteUserFromToken` → `verifyDocverLoginToken` + env `DOCVER_TOKEN_SECRET` ตัวเดียวกับ login-token ของลิงก์) — แทนที่จะเชื่อ `userId` จาก client ตรง ๆ แบบเดิม (ปิดรูปเดิมด้วย)
- **หน้า protected เป็น shell public ฝั่ง server + client gate**: `/note/annotate/[id]` → `AnnotateView` ตรวจ token → ดึงข้อมูลเริ่มต้นจาก `GET /api/note/files/[id]/bootstrap` (Bearer) → PDFViewer (prop `token`) · ไม่มี/หมดอายุ = หน้า gate "กลับ e-Court กดลิงก์ใหม่" (แทน `sso=required` ของ middleware ที่ถอด)
- **middleware.ts / lib/session.ts / utils/user.ts / app/actions/auth.ts / GET /api/note/auth/e-court / app/note/annotate/page.tsx (entry ?fileUrl=) — ถอดทั้งไฟล์** · logout = ปุ่ม client ล้าง localStorage
- ทางเปิดไฟล์แบบไม่มี credential (samples หน้า /note/dev) ย้ายไป `GET /api/note/files/open?fileUrl=/samples/…` (ลิงก์ "A Link Get(new)" แบบ /tmpl — path ในเครื่องเท่านั้น, `registerExternalFile` trusted:false แถว `url:…`)
- **คง status quo เส้น content ที่ browser เปิดแบบไม่ผ่าน fetch()** (ตามคำตอบผู้ใช้ 2026-10-30): SSE (EventSource) / `/api/note/pdf` + `<img>` N3 / export PDF — ไม่บังคับ Bearer (เดิมก็ไม่มี auth) · หน้าสรุป/หน้ากลุ่มยังเป็น server component จึงกลายเป็น public ไปด้วย — ยอมรับชั่วคราวตามมาตรฐานเดียวกัน (จะเปลี่ยนเป็น client+API เมื่อเรียกข้อเกิน)
- **contract กับ e-Court เปลี่ยนเฉพาะปลายทางลิงก์**: `{BASE}/note?…` แทน `{BASE}/api/note/auth/e-court?…` (mint ทาง A และ ticket ทาง B ไม่เปลี่ยน) — ส่วนที่เหลือของแผน (§1 D-rows, §3.1, §6, §8) ให้อ่านผ่านเลนส์ของ §0.5 นี้
## 1. ข้อตกลงที่ตัดสินใจแล้ว (2026-10-28)
| # | เรื่อง | ข้อตกลง |
|---|---|---|
| D1 | ตัวพา identity | 2 รูปแบบ (D12): **(A)** JWT จากทางเข้า `x-api-key` ของ **`POST /api/note/auth/token` — เส้น mint ใหม่ของแผนนี้ แยกจาก `/api/documents/token` ของกลุ่ม docver** (คำขอผู้ใช้ 2026-10-29 — ไม่แต้ contract docver ใช้ lib `docver-token` เดิม) ฝังเป็น param เดียว `t=` · **(B)** `ticket_data`+`HmacSha256` (ชุดเดียวกับตัว stamp .docx ของ add-in) |
| D2 | จุดลงจอ | **route handler `GET /api/note/auth/e-court`** — set cookie ได้ตามกติกา Next (page render ทำไม่ได้) + middleware ยกเว้น `api/note` อยู่แล้ว (route นี้เองไม่ต้องแต้ middleware — ชุดที่แต้ตาม D15 เป็นเรื่องหน้าแรก public คนละเรื่อง) |
| D3 | session | แปลงเป็น cookie `oacCase` รูปเดิมผ่าน `buildOacCaseCookie(userid, username)` (identity ไหลใน cookie เดิม: `userid`→u_userid, `username`→u_userlogin) **+ คู่ `oacCaseSig` ตาม D16 เสมอ** — cookie เดิมลอยตัวไม่มีลายเซ็น = ใครก็ปลอม `u_userid` ข้าม SSO ได้ทั้งหมด · `lib/session.ts` ต้องแต้แล้ว (verify signature — ดู D16) |
| D4 | แยกสิทธิ์ token | เพิ่ม claim `purpose: "docver" \| "login"` เซ็นด้วย secret เดียวกัน (`DOCVER_TOKEN_SECRET`) — **mint login token ได้จาก `POST /api/note/auth/token` เท่านั้น** ส่วน `/api/documents/token` ไม่รับ/ไม่ออก `purpose` อีก (ส่ง field มา = 400 ตาม allowlist เดิม) · `requireDocverBearer` รับเฉพาะ `docver` (undefined = docver เพื่อ token เดิมในมือ user ตอน roll ไม่ตาย), landing route รับเฉพาะ `login` — กัน token ใน URL ถูกจับไปใช้กับ API versioning (scope escalation) |
| D5 | TTL ของ login token | body `ttl_seconds` ทางเลือกช่วง 60–600 default 300 (ของเส้น mint ใหม่ — เป็น login-token เอกสิทธิ์ ไม่ต้องส่ง `purpose` ใน body) — credential ใน URL ต้องอายุสั้น · ทางเข้า docver TTL ตาม env เดิมไม่เปลี่ยน |
| D6 | อายุ session | cookie 8 ชม. เหมือน mock login — token สั้นเฉพาะตอน "ขอเข้า" (แบบ SSO code flow) · ไม่มี revoke (เท่า status quo ปัจจุบัน) |
| D7 | mock login | **ถอดออกทั้งเส้น** (MOCK_USERS + Demo1234 + LoginForm + action `login`) — ลิงก์ e-Court คือทางเข้าเดียวของ production · **หน้า `/note/login` ถอดทั้งหน้า ไม่เหลือหน้าข้อมูล** (ปรับแผน 2026-10-29 — ปลายทางข้อความต่าง ๆ ย้ายไปแบนเนอร์ของหน้าแรก D14) · dev/ทดสอบ mint ลิงก์ด้วยสคริปต์ D10 |
| D8 | token ไม่ผ่าน | ไม่ตอบ 401 เปล่า (user คลิกลิงก์มาเอง) — **302 ไป `/note?sso=expired\|invalid\|error`** หน้าแรกแสดงแบนเนอร์ตามสาเหตุ (บอกผู้ใช้กลับ e-Court กดเปิดใหม่) — ค่า `sso=required` ตัวที่สี่มาจาก middleware ตอนเด้งคนไม่มี session กลับหน้าแรก (D15) |
| D9 | login-CSRF ผ่าน img/embed | route เช็ค `Sec-Fetch-Dest: document` — header ขาด (browser เก่า) = ยอมผ่าน · กันเคสเว็บอื่นฝัง `<img src=…?t=…>` ดัน browser ของเหยื่อ login เป็นเจ้าของ token |
| D10 | เครื่องมือทดสอบ | `scripts/make-login-link.mjs` mint token ออฟไลน์ (node:crypto HS256 จาก `DOCVER_TOKEN_SECRET`) แล้วพิมพ์ลิงก์เต็ม — pattern เดียวกับ `make-ticket-docx.mjs` |
| D11 | fid | route ใช้ fid จาก credential เป็น `fileId` ของ N8 **เสมอ** (ค่าที่เซ็นแล้ว มาจาก token/ticket ไม่ใช่ param) — ลิงก์ไม่ต้องส่ง `fileId` และ param นี้ถ้าส่งมาก็ถูกแทนที่ กัน mismatch ระหว่างค่าเซ็นกับค่าปลอมแปลงได้ |
| D12 | สองทางเข้าพร้อมกัน | route แยกตาม param: มี `t` = ทาง JWT · ไม่มี `t` แต่มี `ticket_data`+`HmacSha256` ครบ = ทาง ticket · **มีทั้งสองชุด = `sso=invalid`** (fail closed กันกำกวม) · ทั้งสองทางจบที่ cookie + redirect เดียวกัน · ทาง ticket ไม่ใช้ `tid` (template ต้นทาง — ไม่เกี่ยวกับ login) |
| D13 | เงื่อนไขบังคับของทาง ticket | **ตัด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก add-in ก่อนเปิดทาง ticket** — secret ตัวเดียวกันจะกลายเป็นจุดอ่อนของ login โดยตรง (แกะ bundle = ปลอม ticket เข้าได้) · add-in เหลือ parse + exp เป็น UX gate, การตรวจ "ไฟล์ถูกแก้" เกิดตอน server ตอบ 401 (แทนที่ D3 ของ PLAN-ADDIN-AUTH.md) · โบนัส: ทางเข้า ticket ที่ `/token` ของ add-in ก็แข็งขึ้นตาม |
| D14 | หน้าแรก `/note` ใหม่ (2026-10-29) | เปลี่ยนจากรายการไฟล์แบบการ์ด เป็น**หน้าเลือกเอกสารแบบ dropdown 2 ระดับ** คล้าย `app/tmpl/page.tsx` (กลุ่ม → ไฟล์) + ปุ่ม "เปิดเอกสาร" เข้า annotate — ถอดรายการการ์ด N9 + ฟอร์มอัปโหลด N4 + การ์ดจำลองลิงก์ N8 ออกจากหน้า · N8 `?fileUrl=` ยังทำงานที่หน้านี้ **เฉพาะเมื่อมี session ที่ verify แล้ว** (ไม่มี = redirect `/note?sso=required` โดยไม่แตะ DB — ดู §1.1) · รายละเอียด §1.1 |
| D15 | บังคับ session (2026-10-29) | `/note` **public** — middleware ยกเว้น `note` เพิ่ม (คนไม่มี cookie เห็นหน้าเลือกเอกสารได้) ส่วนหน้าอื่น (annotate/summary/groups) ยังบังคับ cookie — ไม่มี = redirect `/note?sso=required` (ไม่มี `next` แล้ว: ไม่มี login ให้วนกลับ ผู้ใช้ต้องกลับ e-Court คลิกลิงก์ใหม่) · action `logout` คงไว้ ปลายทางเปลี่ยนเป็น `/note` |
| D16 | เซ็น session cookie (2026-10-29 รอบสอง — code review ข้อ 3) | `oacCase` เดิม**ไม่มีลายเซ็น** (`parseOacCase` อ่าน query string เปล่า — ใครก็ปลอม `u_userid` เป็นคนอื่นได้ token/ticket ทั้งแผนจะไม่เพิ่มความปลอดภัยจริง) → เก็บรูป `oacCase` เดิมไว้ + **cookie ที่สอง `oacCaseSig`** = `<exp epoch วินาที>.<HMAC-SHA256 hex>` เซ็นครอบ **payload เต็มของ `oacCase` + `.` + exp** (แก้ exp เฉย ๆ ไม่ได้) ด้วย secret ใหม่ `NOTE_SESSION_SECRET` — **แยกจาก `DOCVER_TOKEN_SECRET`** (ไม่แตะของกลุ่ม documents: session ของแอป note ไม่ผูก secret กลุ่ม docver หมุนพร้อมกันไม่ได้ โดยเฉพาะตอน pre-production ที่ต้อง rotate DOCVER อยู่ด้วย) · ไม่ตั้ง/ว่าง = **fail closed** (verify ไม่ผ่านทุกกรณี = ไม่มี session, SSO route ตอบ `sso=error` — ไม่มี default ที่รู้กันหมด) · ตรวจ**ทุกจุดที่อ่าน identity**: middleware (async + Web Crypto — edge runtime ไม่มี node:crypto) + `getSessionUser()` (เป็น async + verify หน้า caller `await`) · cookie ทั้งสอง httpOnly/sameSite lax/path `/`/maxAge 8h + **`secure` เมื่อ production** · exp ฝังในของที่เซ็น = อายุจริงวัดฝั่ง server (maxAge เป็นแค่ cleanup ฝั่ง browser) |
### 1.1 หน้าแรก `/note` ใหม่ (D14)
> **ปรับแผน 2026-10-30 (คำขอผู้ใช้ "ออกแบบ /note ใหม่")** — เนื้อหาทั้งหมดของ §1.1 นี้ย้ายไปหน้า **`/note/dev` "เครื่องมือทดสอบ"** (dropdown เปิดเอกสารตัวอย่าง + คู่มือทางเข้า SSO 2 ทาง + ฟอร์ม mint ลิงก์ + สคริปต์) ซึ่ง **public เหมือนหน้าแรก** (matcher เพิ่มยกเว้น `note/dev` — ต้อง mint ลิงก์ได้ตอนยังไม่มี session) ส่วน `/note` เหลือ **landing เรียบ**: ชื่อระบบ + การ์ด "เข้าใช้งานผ่านลิงก์ของ e-Court" + ลิงก์ไป `/note/dev` · **สัญญาลิงก์ไม่เปลี่ยน**: แบนเนอร์ `?sso=` 4 ค่า + N8 `?fileUrl=` (เช็ค session ก่อนเสมอ) ยังอยู่ที่ `/note` ทั้งหมด — redirect ของ SSO route/middleware ไม่ต้องแต้
- **โครงหน้าเหมือน `app/tmpl/page.tsx`**: dropdown หลัก = กลุ่มเอกสาร → dropdown รอง = ไฟล์ในกลุ่ม (reuse `components/utils/DependentDropdown.tsx` — ปรับ class ให้เป็น Genesis: ตัวเดิมใช้ raw `gray-*` ซึ่งห้ามตาม DESIGN.md) + ปุ่ม "เปิดเอกสาร" · ไม่มีส่วนอื่นของหน้าเดิมเหลืออยู่
- **รายการเอกสาร fixed ในโค้ด 2 กลุ่ม × 2 ไฟล์** — จาก PDF ตัวอย่างที่มีอยู่จริงของแอป note (`public/note/samples/`):
```ts
const SAMPLE_DOCS: DropdownData = {
"คำสั่งศาล — คดี 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",
],
};
```
- **ปุ่ม "เปิดเอกสาร"** → `/note/annotate?fileUrl=/samples/<ชื่อไฟล์>` (entry ที่สองของ N8 ที่มีอยู่แล้ว `app/note/annotate/page.tsx` — dedupe ผ่าน external_id เอง) — หน้า `/note` **ไม่ต้อง query DB เลย**
- **N8 `?fileUrl=` ทำงานก่อน render dropdown — เฉพาะเมื่อมี session ที่ผ่าน `await getSessionUser()` (verify ตาม D16)**: SSO route redirect มา `/note?fileUrl=…&fileId=…&typeId=…` พร้อม cookie คู่ใหม่เสมอ → registerExternalFile → redirect viewer · **มี `?fileUrl=` แต่ไม่มี session = redirect `/note?sso=required` ทิ้ง param ทั้งหมด — ห้าม register ก่อนตรวจ** (หน้านี้ public ตาม D15 แต่ N8 เขียน DB: ถ้าไม่เช็ค ใครก็เปิด `/note?fileUrl=…&fileId=<ของคนอื่น>` ได้แม้ถูกเด้งตอนเข้า viewer — สร้าง row ขยะ + refresh `pdf_url`/`typeId` ของเอกสารที่มีอยู่ให้ชี้ไป PDF ที่ตัวเองเลือก · ปิด gap ตามคำถามรอบทดสอบ 2026-10-29) · เข้าเองโดยไม่มี query = เห็น dropdown
- **แบนเนอร์ `?sso=` (4 ค่า — D8/D15)** แสดงบนหน้านี้ · AppNav แสดงชื่อผู้ใช้เมื่อมี session ไม่มี = hint "เข้าผ่านลิงก์ e-Court" แทน
- **ส่วนคู่มือทางเข้า SSO 2 แบบ + flow แต่ละแบบ** (คำขอผู้ใช้ 2026-10-29 รอบสอง ให้เหมือน `app/tmpl/page.tsx`): การ์ดทาง A (JWT — curl mint → ประกอบลิงก์ `t=` → คลิก → session → viewer) + การ์ดทาง B (ticket → ประกอบลิงก์ `ticket_data`+`HmacSha256` → คลิก → session) + กล่อง dev สั่ง `scripts/make-login-link.mjs` — ตัวอย่างเป็น "รูปแบบ + ขั้นตอน" เท่านั้น **ห้าม mint จริงจากหน้า** (หน้า public — เอา `x-api-key` ลงหน้า = ใครก็ mint เป็นทุกคน)
- **ฟอร์ม "สร้างลิงก์ทดสอบ" บนหน้าแรก** (คำขอผู้ใช้ 2026-10-29 รอบสาม "ใช้ได้ใน note" — dev-only): `POST /api/note/auth/dev-link` mint ฝั่ง server (secret ไม่ผ่านหน้าเบราว์เซอร์) คืน URL เต็มให้กด "เปิดลิงก์" — เปิดด้วย env `NOTE_SSO_DEV_MINT=1` **และ `NODE_ENV !== "production"` เท่านั้น** (ปิด = route 404 + ฟอร์มหายจากหน้า · dev server บน LAN เปิดค้าง = คนในวงแชร์ mint ได้ จึงเปิดเฉพาะตอนทดสอบ) · identity/ttl ใช้เกณฑ์เดียวกับเส้น mint จริง (`parseDevLinkBody` ตัด field ส่วนเสริมแล้ว delegate ให้ `parseLoginTokenBody`) · โหมด jwt ใช้ `DOCVER_TOKEN_SECRET` โหมด ticket ใช้ `addinTicketSecret()` และ ticket_data เรียง key ตามตัว stamp จริง
- **ถอดตามหน้าเดิม**: `UploadFileForm` + action `uploadFile` (N4 หมดทางเข้า UI — เอกสารเข้าระบบทางลิงก์ e-Court กับ samples เท่านั้น) + `EcourtLinkForm` (ทางทดสอบ N8 เหลือสคริปต์ D10 อย่างเดียว) · หน้า `/note/groups/[typeId]` **คงไว้ตามเดิม** (ยังเข้าได้จาก deep link/URL — แค่ลิงก์ "เอกสารในกลุ่ม" จากหน้าแรกหายไป)
## 2. Contract ให้ทีม e-Court
**ขั้น 1 (ทาง A) — mint token (server-to-server ของ backend e-Court):**
```
POST {BASE}/api/note/auth/token
x-api-key: <DOCVER_API_KEY ที่ใช้อยู่>
content-type: application/json
{"userid":"<u_userid>","username":"<u_userlogin>","fid":"<fileId ของ e-Court>","ttl_seconds":300}
→ 200 {"access_token":"…","token_type":"Bearer","expires_in":300}
```
(เส้นนี้เป็น login-token เอกสิทธิ์ — ไม่ต้องส่ง `purpose` ใน body · แยกจาก `/api/documents/token` ของกลุ่ม docver ไม่ปนกัน)
**ขั้น 2 — ประกอบลิงก์ให้ผู้ใช้คลิก (เลือกทางใดทางหนึ่ง — ห้ามส่งสองชุดพร้อมกัน):**
ทาง A (แนะนำ — เมื่อ mint แบบ real-time ได้):
```
{BASE}/note?fileUrl=<https ของไฟล์ — urlencode>&typeId=<กลุ่ม ทางเลือก>&t=<access_token>
```
ทาง B (ลิงก์ที่สร้างล่วงหน้า/แบบ offline — stamp ด้วยตัวเดียวกับ .docx, format ตาม PLAN-ADDIN-AUTH.md §2):
```
{BASE}/note?fileUrl=…&typeId=…&ticket_data=<JSON urlencode>&HmacSha256=<hex 64 ตัว>
```
กติกา:
- ทาง A: mint ตอน render หน้า/ปุ่มให้ user ปัจจุบันเท่านั้น — **ห้ามเก็บ token ลง DB / ฝังอีเมล /
แชท** (อายุ 5 นาที เก็บไว้ก็ตาย) · ทาง B: exp ของ ticket แนะนำ ≤ 600 วิเช่นกัน (credential
นอนอยู่ใน URL — ยิ่งสั้นยิ่งดี)
- `userid`/`username` (A) หรือ `uid`/`uname` (B) = ค่าเดียวกับ `u_userid`/`u_userlogin` เดิม
(สิทธิ์เจ้าของ annotation ของแอป note ผูกกับ userid — ส่งผิดคน = ข้อมูลผูกคนผิด)
- `fileUrl` ต้องผ่าน allowlist เดิมของ N8 (โฮสต์ `admincourt.go.th` + `EXTERNAL_FILE_HOSTS`)
- token ทาง A ใช้ได้กับ**การเข้าแอป note เท่านั้น** (purpose=login) — ยิง API กลุ่ม docver ไม่ได้
## 3. ฝั่ง server — endpoint ที่เปลี่ยน/เพิ่ม
### 3.1 `POST /api/note/auth/token` (ใหม่ — mint login token ทางเข้า x-api-key, ไม่ import `@/db`)
| เคส | พฤติกรรม |
|---|---|
| `x-api-key` ถูกต้อง + body `{userid, username, fid, ttl_seconds?}` (allowlist 4 field เท่านั้น) | ออก JWT claims `{userid, username, fid, template:null, purpose:"login"}` TTL ตาม `ttl_seconds` (60–600 default 300) → `{"access_token","token_type":"Bearer","expires_in"}` |
| `ttl_seconds` นอกช่วง / field ที่ไม่รู้จัก (รวมส่ง `purpose` มา) | 400 |
| `x-api-key` ไม่ใส่/ผิด | 401 ข้อความเดียวกันทั้งสองกรณี (กัน enumerate — pattern `requireDocverApiKey`) |
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | 500 |
- reuse จาก lib เดิมทั้งหมด: `requireDocverApiKey` (เทียบ `DOCVER_API_KEY`) + `issueDocverToken` (ส่ง purpose="login" + ttl เข้าไป) — **ห้าม log ค่า key/body ดิบ**
- **`POST /api/documents/token` ของกลุ่ม docver ไม่ถูกแต้จากแผนนี้แล้ว** — contract เดิมคงเดิมทั้งหมด (ส่ง `purpose`/`ttl_seconds` มา = 400 ตาม allowlist เดิม) เหลือแค่ระดับ lib: `issueDocverToken` default แนบ `purpose:"docver"` ให้ token ใหม่โดย route docver ไม่ต้องแต้ และ `requireDocverBearer` (ทุก endpoint กลุ่ม docver) reject `purpose === "login"` — 401 ข้อความเดิม (กันเอา login token ไปยิง API versioning — D4)
### 3.2 `POST /api/note/auth/session` (ฉบับ token 2026-10-30 — แทน GET e-court ที่ถอดแล้ว)
body: `{t} | {ticket_data, HmacSha256}` + optional `{fileUrl, typeId}` (allowlist 5 field — `parseSessionExchangeBody`)
| เคส | พฤติกรรม |
|---|---|
| `t` ถูกต้อง (signature + exp + purpose=login) | 200 `{access_token, token_type:"Bearer", expires_in: 28800, userid, username, …}` — token คือ login-token ที่ `issueDocverToken` ออกใหม่อายุ `NOTE_SESSION_TTL` (8 ชม.) · มี `fileUrl` = registerExternalFile **trusted** (fileId จาก fid ที่เซ็น) แล้วเพิ่ม `fileId` ใน response (ล้ม = ยัง 200 พร้อม `fileError` ให้หน้าแรกแสดง — session ได้ก่อนเหมือนสมัย cookie) |
| `ticket_data`+`HmacSha256` ครบ (ไม่มี `t`) และ `verifyAddinTicket` ผ่าน | เหมือนแถวบน — uid/uname/fid จาก ticket (`tid` ไม่ใช้) |
| credential ผิด/หมดอายุ | 401 `{error, reason: expired\|invalid}` — client เปลี่ยนเป็นแบนเนอร์ตามเหตุ |
| ทาง ticket แต่ `addinTicketSecret()` resolve ได้ default `"super_secret"` **(เฉพาะ production — dev ยอมรับ default ให้ทดสอบได้)** | log ฝั่ง server + 500 — เส้นนี้ mint session ทั้งแอป ค่า default รู้กันทั้ง repo ใครก็ปลอม ticket ได้ (กลุ่ม docver/add-in ยังใช้ default ตาม requirement — คนละประตู, review 2026-10-30 รอบสอง) |
| `DOCVER_TOKEN_SECRET` ไม่ตั้ง | log ฝั่ง server + 500 (server พัง ไม่ใช่ความผิด user) |
| body ผิด / สอง credential พร้อมกัน (D12) | 400 `{error}` |
- audit: log **เฉพาะ** `userid` (strip อักขระควบคุม) + เวลาตอนสำเร็จ — ห้าม log token/body ดิบ · `Cache-Control: no-store`
## 4. โมดูล — `lib/note-auth.ts` (pure ใช้ร่วม client ได้) + `lib/note-token.ts` (server)
```ts
// lib/note-auth.ts — pure ไม่ import @/db/node:crypto (client component ใช้ร่วมได้)
export function parseSsoLinkParams(url: URL): SsoLinkParams;
// ใช้ที่ NoteHome ตอน bootstrap + test — allowlist 5 param + cap + สอง credential พร้อมกัน = ok:false (D12)
export function parseSessionExchangeBody(raw: unknown): SessionExchangeBody; // body ของ §3.2 — เกณฑ์เดียวกัน
export function parseLoginTokenBody(raw: unknown): LoginTokenBody; // §3.1 (allowlist 4 field + ห้าม & = + % ใน identity)
export function devMintEnabled(): boolean; // NOTE_SSO_DEV_MINT=1 + ไม่ใช่ production
export function parseDevLinkBody(raw: unknown): DevLinkBody;
export function ssoBanner(reason: string | undefined): SsoBanner | null; // ข้อความแบนเนอร์ 4 ค่า (D8)
// lib/note-token.ts — server (import lib/docver-token ซึ่งใช้ node:crypto — ห้าม client แตะ)
export const NOTE_SESSION_TTL = 8 * 60 * 60; // อายุ session token วินาที (8 ชม.)
export function noteUserFromToken(token: string | null | undefined): { userid: string; username: string } | null;
// verifyDocverLoginToken + env DOCVER_TOKEN_SECRET — invalid/expired/secret ไม่ตั้ง = null (fail closed)
export function noteUserFromRequest(request: Request): … | null; // อ่าน Authorization: Bearer ของ API route
// lib/note-session.ts — client (localStorage "note-auth": {token, userid, username, expMs})
readNoteSession() / saveNoteSession() / clearNoteSession() / noteAuthFetch(path, init) // แนบ Bearer อัตโนมัติ
```
## 5. Flow รวม (ฉบับ token)
```
ผู้ใช้คลิกลิงก์ใน e-Court → browser ไป /note?fileUrl=&typeId=&t=… (หรือ ticket)
→ NoteHome (client bootstrap) POST /api/note/auth/session {credential + fileUrl + typeId}
→ server: verify (A: verifyDocverLoginToken / B: verifyAddinTicket + fail-closed default secret)
→ registerExternalFile(fileUrl, fid, typeId, {trusted:true}) → fileId
→ 200 {access_token (8 ชม.), userid, username, fileId?}
→ client: saveNoteSession (localStorage "note-auth") + strip credential ออกจาก URL
→ มี fileId = /note/annotate/<rowId> → AnnotateView (client gate)
→ GET /api/note/files/<id>/bootstrap (Bearer) → PDFViewer (prop token)
→ ทุก Server Action รับ token เป็น param แรก — identity แกะฝั่ง server เสมอ
```
- ผู้ใช้มี session token เก่าอยู่ในเครื่อง → **ลิงก์ชนะ** (bootstrap เขียนทับใหม่เสมอ — e-Court
เป็นผู้ยืนยันตัวตนปัจจุบัน) · token หมดอายุกลางวัน = action โยน error/หน้า 401 → กลับ e-Court
กดลิงก์ใหม่ (ไม่มี renew — เว็บไม่มี ticket ฝังไฟล์แบบ add-in)
- ไม่มี credential ใน URL → `/note` = landing public (แบนเนอร์ `?sso=` ของลิงก์เก่ายังรองรับ)
- samples หน้า `/note/dev` → `GET /api/note/files/open?fileUrl=/samples/…` (ลิงก์แบบ /tmpl, trusted:false — แถว `url:…` เท่านั้น)
## 6. ไฟล์ที่สร้าง/แต้/ถอด
**สร้าง**
- `app/api/note/auth/token/route.ts` — §3.1 mint login token (runtime nodejs, force-dynamic)
- `app/api/note/auth/e-court/route.ts` — §3.2 (runtime nodejs, force-dynamic)
- `lib/note-auth.ts` — §4 (รวม `signOacSession`/`verifyOacSession` ของ D16) + `tests/lib/note-auth.test.ts` (รวมเคส: คู่ถูกต้องผ่าน · แก้ payload 1 ตัวอักษร/แก้ exp เฉย ๆ ไม่ผ่าน · exp อดีตไม่ผ่าน · ไม่มี secret = false)
- `scripts/make-login-link.mjs` — `node scripts/make-login-link.mjs --uid oom --uname สรวิชญ์ --fid F123 --fileUrl https://… --typeId T12 [--ttl 300] [--mode jwt|ticket] [--secret <ตาม mode: DOCVER_TOKEN_SECRET | ADDIN_TICKET_SECRET>]` → พิมพ์ลิงก์เต็ม (jwt = node:crypto HS256 ตรงตาม `lib/docver-token.ts` · ticket = HMAC ตรงตาม `lib/addin-ticket.ts` โดย exp มาจาก `--ttl` default 300)
- `tests/lib/docver-token.test.ts` — เพิ่มเคส purpose/ttl ของ `issueDocverToken` (default "docver" + ส่ง "login") + `requireDocverBearer`-reject-login (เคสเดิมไม่พัง)
- `app/note/dev/page.tsx` — **(ปรับแผน 2026-10-30)** หน้า "เครื่องมือทดสอบ": ย้ายเนื้อหา D14 ทั้งหมด (dropdown เปิดเอกสารตัวอย่าง + คู่มือทางเข้า SSO 2 ทาง + ฟอร์ม mint + คู่มือสคริปต์) ออกจากหน้าแรก — public เหมือนเดิม (matcher เพิ่ม `note/dev` — ต้อง mint ลิงก์ได้ตอนยังไม่มี session)
**แต้**
- `lib/docver-token.ts` — `DocverTokenClaims.purpose?` + `issueDocverToken` รับ purpose/ttl ทางเข้า param (default `purpose:"docver"` — route docver ไม่ต้องแต้), `requireDocverBearer` ปฏิเสธ login-token · `parseTokenRequestBody` **ไม่แต้** (route docver ไม่รับ `purpose`/`ttl_seconds`)
- `app/api/documents/token/route.ts` — **แต้จุดเดียว: ลบค่า hex ที่ comment ค้าง (บรรทัด ~78)** (hygiene ก่อน rotate secret — contract ไม่แต้)
- `app/note/page.tsx` — **rework เป็นหน้าเลือกเอกสาร D14** (dropdown 2 ระดับ + ปุ่ม "เปิดเอกสาร" + แบนเนอร์ `?sso=` + N8 `?fileUrl=` ก่อน render **โดยเช็ค `await getSessionUser()` ก่อน — มี `?fileUrl=` แต่ไม่มี session = redirect `/note?sso=required` ไม่แตะ registerExternalFile** — หน้านี้ public แล้ว `await getSessionUser()` เป็น optional เฉพาะส่วนแสดงชื่อ: มี = แสดงชื่อ ไม่มี = hint, **ไม่ redirect แบบไม่มีเงื่อนไขอีกต่อไป**) · **ปรับแผน 2026-10-30: rework อีกครั้งเป็น landing เรียบ — เนื้อหา D14 ทั้งหมดย้ายไป `app/note/dev/page.tsx` ส่วนแบนเนอร์ `?sso=` + N8 `?fileUrl=` + เช็ค session ก่อนแตะ DB คงอยู่ที่หน้านี้ทั้งหมด (สัญญาลิงก์ไม่เปลี่ยน)**
- `middleware.ts` — matcher ยกเว้น `note` เพิ่ม (หน้าแรก public) + redirect คนไม่มี cookie จาก `/note/login?next=…` เป็น `/note?sso=required` (ถอด `next` ทิ้ง) + **ตรวจ `oacCaseSig` ด้วย `verifyOacSession`** (middleware เป็น async — parse โครงสร้างเดิม + await verify คู่, ไม่ผ่าน = เหมือนไม่มี session) · **ปรับแผน 2026-10-30: matcher เพิ่มยกเว้น `note/dev`**
- `lib/session.ts` — `getSessionUser()` เป็น async + verify `oacCaseSig` (ไม่ผ่าน = `null`)
- `app/note/annotate/[id]/page.tsx` — `await getSessionUser()` ตาม signature ใหม่
- `app/actions/auth.ts` — action `logout` เคลียร์**ทั้งสอง cookie** (`oacCase` + `oacCaseSig`) + ปลายทาง redirect เปลี่ยนจากหน้า login เป็น `/note`
- `utils/user.ts` — เพิ่ม `OAC_SIG_COOKIE` + `SESSION_MAX_AGE` (§4)
- **add-in (D13):** `wordaddin/services/authService.ts` ถอดการเรียก `verifyTicketHmacWeb` (เหลือ parse + exp เป็น UX gate) · `wordaddin/services/ticketService.ts` ถอด `verifyTicketHmacWeb`/`hmacSha256HexWeb` · `wordaddin/config.ts` ถอด `ticketSecret` · `components/wordaddin/TaskpaneApp.tsx` ข้อความ no-access ปรับ (ไม่มีสาเหตุ HMAC ฝั่ง client — รวมเป็น "ticket ไม่ถูกต้อง" ตอน server 401) · `docs/PLAN-ADDIN-AUTH.md` (D3/D8 ถูกแทนที่) + `docs/WORDADDIN-DEV.md`
- `lib/openapi.ts` — เพิ่ม path ใหม่ `POST /api/note/auth/token` (field ตาม §3.1) · รายการ `/api/documents/token` **คงเดิมไม่แต้**
- `.env.example` — เช็คว่ามี `DOCVER_TOKEN_SECRET` + `NOTE_SESSION_SECRET` (ค่า dev อะไรก็ได้) — dev ไม่มีสองค่านี้ = เข้า annotate ไม่ได้ (ทางเข้า session เดียวคือลิงก์ SSO ส่วนหน้าแรก public)
- `CLAUDE.md` — สถานะ auth ของแอป note (mock login ถอด, SSO 2 ทางเข้า, **ไม่มีหน้า login · `/note` public + หน้าเลือกเอกสารใหม่ · N4 ถอด**) + add-in ถอด client HMAC (D13) + invariant #4 ไม่เกี่ยว (route นี้ไม่แตะ DB ไม่ publish SSE)
**ถอด** (mock login ทั้งเส้น + หน้า login + UI เดิมของหน้าแรก — D7/D14)
- `app/note/login/page.tsx` — **ทั้งหน้า** (ไม่ rework เป็นหน้าข้อมูล — ปรับแผน 2026-10-29)
- `components/LoginForm.tsx` + การเรียก action `login` ของหน้า login
- `components/DotGrid.tsx` (กริดจุด decorative ของหน้า login — ไม่มีผู้ใช้ที่อื่น จึงตายตาม)
- action `login` + `MOCK_PASSWORD` ใน `app/actions/auth.ts` (**action `logout` คงไว้** — ล้าง cookie แล้วไป `/note`)
- `MOCK_USERS` ใน `utils/user.ts` + comment "ใช้ร่วมกันสองที่" ที่ตายตาม (`OAC_CASE_COOKIE`/`parseOacCase`/`buildOacCaseCookie`/`SESSION_MAX_AGE` คงไว้ทั้งหมด — auth route ใช้อยู่)
- `components/UploadFileForm.tsx` + action `uploadFile` ใน `app/actions/files.ts` (N4 — ไม่มีทางเข้า UI อีกตาม D14)
- `components/EcourtLinkForm.tsx` (ทางทดสอบ N8 เหลือสคริปต์ D10)
- `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ใน `.env.example` และทุกจุดอ้าง (D13 — secret ออกจาก bundle)
## 7. Env
| env | default | หมายเหตุ |
|---|---|---|
| `DOCVER_API_KEY` / `DOCVER_TOKEN_SECRET` | — | ใช้ตัวที่มีอยู่ **ไม่มี env ใหม่ของกลุ่มนี้** (env ใหม่เดียวของแผน = `NOTE_SESSION_SECRET` ของ D16 ซึ่งเป็นของแอป note ไม่ผูกกับกลุ่ม docver) — `DOCVER_API_KEY` ยิงได้ทั้ง `/api/documents/token` (docver) และ `/api/note/auth/token` (mint login), `DOCVER_TOKEN_SECRET` เซ็น token ทั้งสองชนิด (แยกกันด้วย claim `purpose` — D4) · dev ต้องมี `DOCVER_TOKEN_SECRET` ใน `.env` ไม่งั้นเข้า annotate ไม่ได้ (mock login ไม่มีแล้ว ทางเข้า session เดียวคือลิงก์ — หน้าแรก `/note` public ตาม D15) · **ก่อน production: rotate `DOCVER_TOKEN_SECRET`** (ค่า hex หนึ่งชุดเคยถูก commit ไว้ใน comment ของ token route — ถือว่าหลุดแล้ว) |
| `NOTE_SESSION_SECRET` | — | **ถอดแล้ว 2026-10-30 (ฉบับ token)** — เคยเป็น HMAC ของ `oacCaseSig` รอบ cookie; ตอนนี้ session ใช้ `DOCVER_TOKEN_SECRET` ตัวเดียว (เซ็น login-token ของลิงก์ + เซ็น/ตรวจ session token ผ่าน `lib/note-token.ts`) |
| `ADDIN_TICKET_SECRET` (→ `SECRET` → `"super_secret"`) | ตาม chain เดิม | ใช้กับทาง ticket (B) — หลัง D13 ค่านี้เหลืออยู่แค่ e-Court (ตัว stamp) + server เรา ("ต้องตรงกันทั้งสาม" เหลือสองฝั่ง) · production ต้องเป็นค่าจริง (default รู้กันหมด = ปลอม ticket ได้) · ห้าม log ค่าดิบ · **2026-10-30: ทาง ticket ของ SSO ปฏิเสธค่า default `"super_secret"` เฉพาะ production — dev ใช้ default ทดสอบได้** |
## 8. Edge cases
- **มี cookie คนอื่นอยู่แล้ว** → ลิงก์ชนะเสมอ (เขียนทับ) — กติกาเดียว ไม่มี branch
- **`fileUrl` ไม่ผ่าน allowlist** → token ผ่านแต่ N8 จะตอบ error เดิมของหน้า `/note` (พฤติกรรมเดิม — ไม่ duplicate validation ที่ route)
- **browser เก่าไม่ส่ง `Sec-Fetch-Dest`** → ยอมผ่าน (ยอมรับ — login-CSRF เป็นความเสี่ยงรองใน intranet)
- **`t` ยาวผิดปกติ / param ปนมา** → allowlist ตัดทิ้ง/ปฏิเสธ (fail closed)
- **ผู้ใช้ refresh หน้า auth route ซ้ำ** (token ยังอยู่ใน history) → verify ใหม่ set cookie ใหม่ — idempotent · พ้น TTL แล้ว = เข้า `sso=expired` (มี session เดิมอยู่ก็ไม่เป็นไร — middleware ไม่ได้ดักคนมี cookie)
- **webview ไม่รับ cookie** — n/a (browser ปกติ)
- **cookie หมดอายุกลางงาน** → middleware redirect `/note?sso=required` → ผู้ใช้กลับ e-Court คลิกลิงก์ใหม่
- **dev/Playwright ที่เคย login ผ่านฟอร์ม mock** → เปลี่ยนเป็น mint ลิงก์ด้วยสคริปต์แล้วเปิด URL นั้นก่อนเข้าหน้าอื่น (แทน step "เลือก user + Demo1234" เดิมในทุกสคริปต์ทดสอบ)
- **ticket exp ยาวผิดปกติ (เช่น 1 ชม.)** — server ตรวจแค่ "ยังไม่หมด" ไม่บังคับ cap (ฝั่ง mint เป็น e-Court) — กติกา ≤600 วิ อยู่ที่ §2 · **มี credential สองชุดใน URL พร้อมกัน** = invalid ตาม D12
- **log** — route นี้และ token route ห้าม log query string/token/secret ดิบ (ตัวแปร env logger ปัจจุบัน = console — บังคับใน code review)
- **คนไม่มี session กด "เปิดเอกสาร" บนหน้าแรก** → middleware เด้งกลับ `/note?sso=required` พร้อมแบนเนอร์ "กรุณาเข้าสู่ระบบผ่านลิงก์ e-Court" (D15 — ปุ่มเดียวกันคนมี session ใช้ต่อเข้า viewer ได้ปกติ)
- **ไฟล์ samples หายจาก disk** (ยังไม่ `npm run seed-samples`) → กด "เปิดเอกสาร" แล้ว N8 ตอบ error เดิมที่หน้า annotate (ไม่ duplicate validation ที่หน้าแรก)
- **เปิด `/note?fileUrl=…&fileId=…&typeId=…` เองโดยไม่มี session** → redirect `/note?sso=required` **โดยไม่เขียน DB ใด ๆ** (ไม่มี row ใหม่ — `pdf_url`/`type_id` ของ fileId ที่มีอยู่ไม่ถูก refresh ด้วยมือแปลกปลอม) · entry ที่สอง `/note/annotate?fileUrl=` ปลอดภัยอยู่แล้วเพราะอยู่ใต้ middleware ที่ยังบังคับ session
- **`?sso=` ค่าอื่นที่ไม่รู้จัก** → ไม่แสดงแบนเนอร์ render หน้าปกติ (กัน URL ปลอมขู่)
- **cookie คู่ไม่ครบ** (มี `oacCase` ไม่มี `oacCaseSig` หรือกลับกัน — D16) = ไม่มี session ทุกกรณี — รวมเคส cookie เดิมที่ตกค้างจาก mock login ก่อน deploy: ถือว่าไม่มี session ทุกคน เข้าใหม่ผ่านลิงก์ e-Court ครั้งเดียว (เกิดครั้งเดียวตอน roll แผนนี้)
- **ปลอม/แก้ cookie** (D16) — เซ็นครอบ payload เต็ม + exp: แก้ `u_userid` แก้ `exp` หรือเซ็นเองโดยไม่รู้ secret = verify ไม่ผ่านทั้งหมด (จุดตรวจ: middleware + `getSessionUser` — ครบทุกจุดที่อ่าน identity อยู่แล้ว)
- **rotate `NOTE_SESSION_SECRET`** → session ทั้งหมดตายทันที (เท่า force logout ทั้งระบบ — ไม่มีข้อมูลเสียหาย ผู้ใช้กลับ e-Court กดลิงก์ใหม่)
- **exp ใน `oacCaseSig` หมดกลางงาน** → เหมือน cookie หมดอายุปัจจุบัน: middleware เด้ง `/note?sso=required` — exp กับ maxAge ตั้งพร้อมกัน 8 ชม. เท่ากัน ในทางปฏิบัติแทบไม่เจอต่าง (ต่างแค่กรณี browser ค้าง cookie ไว้/ถูกปรับ maxAge เอง ซึ่ง exp ฝั่ง server ชนะ)
## 9. Verify criteria
1. `npx vitest run` ผ่านทั้งชุด (เพิ่ม `tests/lib/note-auth.test.ts` + เคสใหม่ของ `docver-token` — เคสเดิมไม่พัง)
2. `npx tsc --noEmit` ผ่าน
3. curl regression:
- (ก) mint: `x-api-key` + `{userid,username,fid,ttl_seconds:300}` ที่ **`/api/note/auth/token`** → ได้ token ที่ decode แล้วมี `purpose:"login"` · ส่ง `purpose` มาด้วย = 400
- (ข) `GET /api/note/auth/e-court?fileUrl=…&t=<login token>` → 302 + `Set-Cookie oacCase` + Location เป็น `/note?…` **ไม่มี `t`** + `Cache-Control: no-store`
- (ค) รอให้ TTL หมด (ตั้ง 60 วิ) → 302 `/note?sso=expired`
- (ง) เอา token purpose=docver มาใช้ที่ route → `sso=invalid` · เอา login token ไป `GET /api/documents/versions` → 401
- (จ) `ttl_seconds` 601 ที่เส้น mint ใหม่ → 400 · **regression docver: mint `/api/documents/token` แบบเดิม (ไม่มี purpose) ยังผ่าน + ส่ง `purpose:"login"` ไปที่นั่น = 400**
- (ฉ) action `login` ไม่ export อีกต่อไป · `/note/login` **404** · แบนเนอร์ `sso=` ครบ 4 ค่าแสดงบน `/note` (expired/invalid/error จาก auth route · required จาก middleware)
- (ญ) ทาง ticket: mint ด้วย `make-login-link --mode ticket` → ผ่านเหมือน (ข) · แก้ HMAC 1 ตัวอักษร → `sso=invalid` · exp อดีต → `sso=expired` · ส่ง `t` และ ticket มาพร้อมกัน → `sso=invalid`
- (ฐ) regression add-in หลัง D13: ชุด test wordaddin ผ่าน + sideload ทดสอบใน Word จริง (ไฟล์ ticket ถูกต้อง → ใช้ได้เหมือนเดิม · แก้ ticket_data 1 ตัวอักษร → no-access "ticket ไม่ถูกต้องหรือหมดอายุ" จาก server)
- (ฎ) หน้าแรกใหม่ D14/D15 (ปรับตาม 2026-10-30): ไม่มี session เปิด `/note` = เห็น landing เรียบ และ `/note/dev` = เห็น dropdown 2 ระดับ (public ทั้งคู่ ไม่โดน redirect) · เลือกกลุ่ม → ไฟล์ แล้วกด "เปิดเอกสาร" บน `/note/dev` โดยไม่มี session = เด้งกลับ `/note?sso=required` มีแบนเนอร์ · มี session = ลง viewer ของไฟล์ที่เลือก · middleware ไม่จับ `/note` และ `/note/dev` แต่ยังจับ `/note/annotate/…` · **ไม่มี session เปิด `/note?fileUrl=…&fileId=<ที่มีอยู่แล้ว>` ตรง ๆ = redirect `sso=required` และ DB ไม่เปลี่ยน** (ไม่มี row ใหม่, `pdf_url`/`type_id` เดิม — เทียบก่อน/หลัง)
- (ฏ) D16 session signing: ผ่านลิงก์จริงแล้วตั้ง cookie เดียวแบบเดิม (`oacCase` ไม่มี `oacCaseSig` — คัดลอกค่าจาก DevTools ใส่เอง) → เข้า annotate ไม่ได้ redirect `sso=required` · มีคู่ครบแต่แก้ `u_userid` ใน `oacCase` 1 ตัวอักษร → ไม่ผ่านเช่นกัน · แก้เฉพาะ `exp` ใน `oacCaseSig` ยืดอายุออก → ไม่ผ่าน (exp ถูกเซ็น) · response ของ auth route มี `Set-Cookie` **สองตัว** + `Secure` เมื่อรัน production (ตรวจจาก response header) · `NOTE_SESSION_SECRET` ว่าง → auth route ตอบ `sso=error` และทุกหน้ามองเป็นไม่มี session · `getSessionUser` async แล้ว `npx tsc --noEmit` ผ่าน (call site ครบ)
4. ทดสอบเบราว์เซอร์ด้วย `scripts/make-login-link.mjs`: วางลิงก์ → ลง viewer ชื่อผู้ใช้ = `uname` · เปิดลิงก์ uid คนละคนต่อ → session สลับตามลิงก์ · ลิงก์หมดอายุ → กลับหน้าแรกพร้อมแบนเนอร์ "ลิงก์หมดอายุ — กลับ e-Court กดเปิดใหม่" · เข้า `/note` ตรง ๆ ไม่มี session → landing เรียบ (public ไม่มีแบนเนอร์) · ที่ `/note/dev` กดปุ่ม "เปิดเอกสาร" → แบนเนอร์ "กรุณาเข้าสู่ระบบผ่าน e-Court" (`sso=required`)
5. รัน `plan-reviewer` + `code-reviewer` ตามกติกา `.claude/`
## 10. ลำดับงาน
0. **add-in D13** — ถอด client HMAC check + `NEXT_PUBLIC_ADDIN_TICKET_SECRET` ออกจาก bundle (+ ปรับ tests/docs) → verify 9.3 (ฐ)
1. `lib/docver-token.ts` (purpose + ttl) + tests → verify 9.1 บางส่วน
2. `lib/note-auth.ts` + tests (รวม `signOacSession`/`verifyOacSession` ของ D16)
3. `app/api/note/auth/token/route.ts` (mint) → verify 9.3 (ก)(จ) แล้ว `app/api/note/auth/e-court/route.ts` → verify 9.3 (ข–ง)
4. ถอด mock login ทั้งเส้น (`LoginForm`/action `login`/`MOCK_USERS` + **หน้า `/note/login` ทั้งหน้า**) + rework หน้าแรก `/note` ใหม่ (D14) + middleware (D15) + งาน session signing ของ D16 (`utils/user.ts`/`lib/session.ts`/logout/หน้า caller `await`) → verify 9.3 (ฉ)(ฎ)(ฏ) + 9.4
5. `scripts/make-login-link.mjs` → verify 9.4
6. `lib/openapi.ts` + `.env.example` + `CLAUDE.md` sync → verify 9.1–9.5
## 11. Out of scope
- ~~แก้ secret รั่ยใน bundle ของ add-in~~ — ย้ายเข้าแผนนี้เป็น **D13** แล้ว (ทาง ticket ทำให้ secret ตัวนี้กลายเป็นจุดอ่อนของ login โดยตรง จึงตัดออกก่อนเปิดทาง ticket)
- revoke/refresh token, logout ระดับ e-Court, roles/สิทธิ์จริง, ผูก session กับ device
- OAuth/OIDC เต็มรูปแบบ (e-Court เป็น IdP) — ไปทางนี้เมื่อมีหลายแอปของศาลต้อง SSO
- ย้ายไปลายเซ็น asymmetric (Ed25519) — upgrade path หากวันหนึ่งต้องการ verifier ถือแค่ public key

View file

@ -12,11 +12,18 @@ export type HistoryEntry = {
redo: () => Promise<void>; redo: () => Promise<void>;
}; };
export function useHistory() { export function useHistory(options?: {
/** เรียกเมื่อ undo/redo ล้มเหลว (เน็ตสะดุด/session หมดอายุ ฯลฯ) — entry ถูกข้าม
* ไปแล้วโดย hook ผู้ใช้ควรเห็น toast ของ caller ไม่ใช่เงียบหายไปเฉย ๆ */
onError?: (err: unknown, kind: "undo" | "redo") => void;
}) {
// source of truth เป็น ref — state ที่เปลี่ยนแค่ไว้ trigger re-render ปุ่ม disabled // source of truth เป็น ref — state ที่เปลี่ยนแค่ไว้ trigger re-render ปุ่ม disabled
const undoRef = useRef<HistoryEntry[]>([]); const undoRef = useRef<HistoryEntry[]>([]);
const redoRef = useRef<HistoryEntry[]>([]); const redoRef = useRef<HistoryEntry[]>([]);
const [, bump] = useState(0); const [, bump] = useState(0);
// เก็บ callback ล่าสุดของ caller ใน ref — callback ที่ส่งเข้ามาเปลี่ยนทุก render ได้
const onErrorRef = useRef(options?.onError);
onErrorRef.current = options?.onError;
/** บันทึก action ที่สำเร็จ — action ใหม่เคลียร์ redo stack เสมอ */ /** บันทึก action ที่สำเร็จ — action ใหม่เคลียร์ redo stack เสมอ */
const push = useCallback((entry: HistoryEntry) => { const push = useCallback((entry: HistoryEntry) => {
@ -34,6 +41,7 @@ export function useHistory() {
} catch (err) { } catch (err) {
// เช่น undo ของ create แต่ row ถูกลบไปแล้วโดยแท็บอื่น → ข้าม ไม่ crash (§5) // เช่น undo ของ create แต่ row ถูกลบไปแล้วโดยแท็บอื่น → ข้าม ไม่ crash (§5)
console.error("undo ไม่สำเร็จ — ข้ามรายการนี้", err); console.error("undo ไม่สำเร็จ — ข้ามรายการนี้", err);
onErrorRef.current?.(err, "undo");
} }
bump((n) => n + 1); bump((n) => n + 1);
}, []); }, []);
@ -46,6 +54,7 @@ export function useHistory() {
undoRef.current.push(entry); undoRef.current.push(entry);
} catch (err) { } catch (err) {
console.error("redo ไม่สำเร็จ — ข้ามรายการนี้", err); console.error("redo ไม่สำเร็จ — ข้ามรายการนี้", err);
onErrorRef.current?.(err, "redo");
} }
bump((n) => n + 1); bump((n) => n + 1);
}, []); }, []);

View file

@ -21,6 +21,10 @@ export type DocverTokenClaims = {
username: string; username: string;
fid: string; fid: string;
template: string | null; template: string | null;
/** แยกสิทธิ์ของ token (PLAN-SSO.md D4): "docver" = ใช้กับ /api/documents/* ·
* "login" = ใช้กับ SSO route ของแอป note เท่านั้น · undefined = docver
* (token เก่าก่อนมี claim นี้ — verify ยอมรับเพื่อ token ในมือ user ตอน roll ไม่ตาย) */
purpose?: "docver" | "login";
}; };
function base64url(input: string): string { function base64url(input: string): string {
@ -49,16 +53,19 @@ function decodeJsonSegment(segment: string): Record<string, unknown> | null {
} }
} }
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */ /** ออก JWT HS256 (base64url 3 segment) — payload = claims + purpose + iat/exp (วินาที)
* purpose default "docver" — route ของกลุ่ม docver ไม่ต้องส่ง (PLAN-SSO.md D4),
* เส้น mint login-token ของแอป note ส่ง "login" */
export function issueDocverToken( export function issueDocverToken(
claims: DocverTokenClaims, claims: DocverTokenClaims,
secret: string, secret: string,
ttlSeconds: number, ttlSeconds: number,
purpose: "docver" | "login" = "docver",
): string { ): string {
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" })); const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
const iat = Math.floor(Date.now() / 1000); const iat = Math.floor(Date.now() / 1000);
const payload = base64url( const payload = base64url(
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }), JSON.stringify({ ...claims, purpose, iat, exp: iat + ttlSeconds }),
); );
const signature = createHmac("sha256", secret) const signature = createHmac("sha256", secret)
.update(`${header}.${payload}`) .update(`${header}.${payload}`)
@ -76,22 +83,30 @@ function claimsFromPayload(
if (typeof username !== "string" || username === "") return null; if (typeof username !== "string" || username === "") return null;
if (typeof fid !== "string" || fid === "") return null; if (typeof fid !== "string" || fid === "") return null;
if (template !== null && typeof template !== "string") return null; if (template !== null && typeof template !== "string") return null;
return { userid, username, fid, template }; // purpose ไม่บังคับ (token เก่าไม่มี = docver) แต่ค่าอื่นนอกสองค่านี้ = invalid;
// คืนเฉพาะเมื่อเป็น "login" เพื่อไม่ให้ claims ของ token docver เปลี่ยนรูปจากเดิม
const purpose = payload.purpose;
if (purpose !== undefined && purpose !== "docver" && purpose !== "login") {
return null;
}
const claims: DocverTokenClaims = { userid, username, fid, template };
if (purpose === "login") claims.purpose = purpose;
return claims;
} }
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") → /** ตรวจรูป + alg บังคับ HS256 (กัน alg-confusion เช่น "none") + เทียบ signature
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) → * แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) — ใช้ร่วมโดย
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */ * verifyDocverToken และ verifyDocverLoginToken · คืน payload ที่ decode แล้ว */
export function verifyDocverToken( function verifyTokenSignature(
token: string, token: string,
secret: string, secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } { ): Record<string, unknown> | null {
const segments = token.split("."); const segments = token.split(".");
if (segments.length !== 3) return { ok: false }; if (segments.length !== 3) return null;
const [headerSegment, payloadSegment, signatureSegment] = segments; const [headerSegment, payloadSegment, signatureSegment] = segments;
const header = decodeJsonSegment(headerSegment); const header = decodeJsonSegment(headerSegment);
if (header === null || header.alg !== "HS256") return { ok: false }; if (header === null || header.alg !== "HS256") return null;
const expected = createHmac("sha256", secret) const expected = createHmac("sha256", secret)
.update(`${headerSegment}.${payloadSegment}`) .update(`${headerSegment}.${payloadSegment}`)
@ -103,9 +118,19 @@ export function verifyDocverToken(
.update(Buffer.from(signatureSegment, "base64url")) .update(Buffer.from(signatureSegment, "base64url"))
.digest(); .digest();
const expectedHash = createHash("sha256").update(expected).digest(); const expectedHash = createHash("sha256").update(expected).digest();
if (!timingSafeEqual(provided, expectedHash)) return { ok: false }; if (!timingSafeEqual(provided, expectedHash)) return null;
const payload = decodeJsonSegment(payloadSegment); return decodeJsonSegment(payloadSegment);
}
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
export function verifyDocverToken(
token: string,
secret: string,
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
const payload = verifyTokenSignature(token, secret);
if (payload === null) return { ok: false }; if (payload === null) return { ok: false };
const exp = payload.exp; const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false }; if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
@ -116,14 +141,37 @@ export function verifyDocverToken(
return { ok: true, claims }; return { ok: true, claims };
} }
/** ตรวจ login-token ของ SSO (PLAN-SSO.md §3.2) — ต่างจาก verifyDocverToken:
* แยก "หมดอายุ" (signature ถูกแต่ exp เลยมาแล้ว → route ตอบ sso=expired) ออกจาก
* "ผิดทุกกรณีอื่น" (→ sso=invalid) และรับเฉพาะ purpose === "login" (D4 — token
* ของกลุ่ม docver เอามาเข้า SSO ไม่ได้) · ตัดสินชนิด token (purpose) ก่อนอายุ —
* token docver หมดอายุก็เป็น invalid ไม่ใช่ expired (มิฉะนั้นแบนเนอร์แนะนำ
* "กดลิงก์ใหม่" ทั้งที่ mint ใหม่ก็ได้ token ผิดชนิดเหมือนเดิม) */
export function verifyDocverLoginToken(
token: string,
secret: string,
nowSec: number = Math.floor(Date.now() / 1000),
): { ok: true; claims: DocverTokenClaims } | { ok: false; reason: "invalid" | "expired" } {
const payload = verifyTokenSignature(token, secret);
if (payload === null) return { ok: false, reason: "invalid" };
const claims = claimsFromPayload(payload);
if (claims === null) return { ok: false, reason: "invalid" };
if (claims.purpose !== "login") return { ok: false, reason: "invalid" };
const exp = payload.exp;
if (typeof exp !== "number" || !Number.isFinite(exp)) {
return { ok: false, reason: "invalid" };
}
if (exp <= nowSec) return { ok: false, reason: "expired" };
return { ok: true, claims };
}
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่ /** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้ * env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */ * คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
export function requireDocverApiKey(request: Request): Response | null { export function requireDocverApiKey(request: Request): Response | null {
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
const envKey = process.env.DOCVER_API_KEY?.trim(); const envKey = process.env.DOCVER_API_KEY?.trim();
// const envKey =
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
if (envKey === undefined || envKey === "") { if (envKey === undefined || envKey === "") {
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
} }
@ -177,8 +225,6 @@ export function requireDocverBearer(
const denied = () => const denied = () =>
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const; ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
// const secret =
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
if (secret === undefined || secret === "") return denied(); if (secret === undefined || secret === "") return denied();
const header = request.headers.get("authorization"); const header = request.headers.get("authorization");
if (header === null) return denied(); if (header === null) return denied();
@ -186,6 +232,9 @@ export function requireDocverBearer(
if (match === null) return denied(); if (match === null) return denied();
const verified = verifyDocverToken(match[1], secret); const verified = verifyDocverToken(match[1], secret);
if (!verified.ok) return denied(); if (!verified.ok) return denied();
// D4 (PLAN-SSO.md): login-token เป็นของ SSO ของแอป note เท่านั้น — เอามายิง
// API กลุ่ม docver ไม่ได้ (scope escalation) ตอบ 401 เดียวกับ token เน่า
if (verified.claims.purpose === "login") return denied();
return { ok: true, claims: verified.claims }; return { ok: true, claims: verified.claims };
} }

View file

@ -62,6 +62,7 @@ export async function registerExternalFile(
rawFileUrl: string, rawFileUrl: string,
rawFileId?: string, rawFileId?: string,
rawTypeId?: string, rawTypeId?: string,
opts?: { trusted?: boolean },
): Promise<RegisterExternalFileResult> { ): Promise<RegisterExternalFileResult> {
let trimmed = rawFileUrl.trim(); let trimmed = rawFileUrl.trim();
if (!trimmed) return { ok: false, error: "ไม่ได้ระบุลิงก์ไฟล์" }; if (!trimmed) return { ok: false, error: "ไม่ได้ระบุลิงก์ไฟล์" };
@ -101,12 +102,21 @@ export async function registerExternalFile(
}; };
} }
// แถว ecourt:<fileId> (เอกสารของ e-Court) สร้าง/แก้ได้เฉพาะลิงก์ที่ผ่าน SSO route
// (proof cookie ที่เซ็นครอบ param ชุดนี้ — review 2026-10-30): /note?fileUrl&fileId
// พิมพ์เองโดยผู้มี session ใด ๆ เคยใช้เขียน/refresh pdf_url ของเอกสารคนอื่นให้ชี้
// PDF ที่ตัวเองเลือก — ตอนนี้เปิดดูของที่รู้จักแล้วได้อย่างเดียว (แถว url:… ของ
// samples/dev ไม่เกี่ยว — key คือ URL ตัวมันเอง)
const ecourtRow = externalId.startsWith("ecourt:");
const trusted = opts?.trusted === true;
const [existing] = await db const [existing] = await db
.select() .select()
.from(files) .from(files)
.where(eq(files.externalId, externalId)) .where(eq(files.externalId, externalId))
.limit(1); .limit(1);
if (existing) { if (existing) {
if (ecourtRow && !trusted) return { ok: true, fileId: existing.id };
// fileId เดิมเจออีกครั้ง: refresh Temp link ใหม่ + ย้ายกลุ่มตาม typeId ล่าสุด // fileId เดิมเจออีกครั้ง: refresh Temp link ใหม่ + ย้ายกลุ่มตาม typeId ล่าสุด
// (ลิงก์ไม่ส่ง typeId มา = คงกลุ่มเดิม ห้ามเคลียร์ — PLAN-ECOURT.md การตัดสินใจ) // (ลิงก์ไม่ส่ง typeId มา = คงกลุ่มเดิม ห้ามเคลียร์ — PLAN-ECOURT.md การตัดสินใจ)
const patch: { pdfUrl?: string; typeId?: string } = {}; const patch: { pdfUrl?: string; typeId?: string } = {};
@ -118,6 +128,13 @@ export async function registerExternalFile(
return { ok: true, fileId: existing.id }; return { ok: true, fileId: existing.id };
} }
if (ecourtRow && !trusted) {
return {
ok: false,
error: "เอกสารของ e-Court รหัสนี้ยังไม่เคยเปิดในระบบ — กรุณาเปิดผ่านลิงก์เอกสารของ e-Court",
};
}
const id = randomUUID(); const id = randomUUID();
try { try {
await db.insert(files).values({ await db.insert(files).values({

View file

@ -1366,12 +1366,12 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT
// Apply Dynamic Content // Apply Dynamic Content
if (templateData?.dc && typeof templateData?.dc === "object") { if (templateData?.dc && typeof templateData?.dc === "object") {
console.log("Apply Dynamic Content") // console.log("Apply Dynamic Content")
templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc)) templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc))
} }
// Apply Content Control // Apply Content Control
if (templateData?.cc && typeof templateData?.cc === "object") { if (templateData?.cc && typeof templateData?.cc === "object") {
console.log("Apply Content Control") // console.log("Apply Content Control")
templateBuffer = templateContentControls(templateBuffer, templateData.cc); templateBuffer = templateContentControls(templateBuffer, templateData.cc);
} }
return templateBuffer return templateBuffer

View file

@ -3,8 +3,17 @@ import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify,
/** /**
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object * คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
*/ */
/**
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign/hash) ข้อมูลด้วย HMAC-SHA256 secret
* @param data ข้อความดิบที่ต้องการเซ็น (string หรือ object)
* @param secretKey คีย์ลับในรูปแบบ string
* @returns ลายเซ็น (Signature/hash) ในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
*/
export function generateHmacSha256(data: string | object, secretKey: string): string { export function generateHmacSha256(data: string | object, secretKey: string): string {
const payload = typeof data === 'string' ? data : JSON.stringify(data); const payload = typeof data === 'string' ? data:JSON.stringify(sortObjectKeys(data as Record<string, unknown>));
return createHmac('sha256', secretKey) return createHmac('sha256', secretKey)
.update(payload, 'utf8') .update(payload, 'utf8')
.digest('hex'); .digest('hex');
@ -13,6 +22,16 @@ export function generateHmacSha256(data: string | object, secretKey: string): st
/** /**
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks) * ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
*/ */
/**
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
* @param data ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
* @param secretKey secret ที่นำมาใช้ตรวจสอบ
* @param expectedHmac ลายเซ็นในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
*/
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean { export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
const calculatedHmac = generateHmacSha256(data, secretKey); const calculatedHmac = generateHmacSha256(data, secretKey);
const a = Buffer.from(calculatedHmac, 'hex'); const a = Buffer.from(calculatedHmac, 'hex');

343
lib/note-auth.ts Normal file
View file

@ -0,0 +1,343 @@
// PLAN-SSO.md §4 — ส่วนกลางของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
// ผู้ใช้ "note ใช้แบบ add-in" — เลิก cookie ของรอบ D15/D16): ลิงก์ e-Court พา
// browser มา `/note` พร้อม credential (`t` หรือ `ticket_data`+`HmacSha256`) →
// client แลกเป็น access_token ที่ POST /api/note/auth/session → เก็บใน
// localStorage ("note-auth") แล้วแนบ Authorization: Bearer กับทุก API และส่ง
// token เข้าทุก Server Action (identity แกะฝั่ง server — ห้ามเชื่อ userId จาก
// client ตรง ๆ)
//
// pure module — ห้าม import อะไรที่ลาก @/db หรือ node:crypto มาด้วย (client
// component ใช้ร่วมได้ — unit test tests/lib/note-auth.test.ts รันได้ปล่าว ๆ)
/** สาเหตุที่เข้าทางลิงก์ไม่สำเร็จ — "required" ใช้กับหน้า gate ฝั่ง client ตอน
* ไม่มี token ใน localStorage (คู่ขนาน sso=required ของรอบ middleware) */
export type SsoFailReason = "expired" | "invalid" | "error" | "required";
/** ตัวพา identity ในลิงก์ SSO — สองทางพร้อมกัน (มีทั้ง `t` และ ticket) = fail
* closed ตอน parse แล้ว (D12) */
export type SsoCredential =
| { kind: "jwt"; token: string }
| { kind: "ticket"; ticketData: string; hmacHex: string };
export type SsoLinkParams =
| { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string }
| { ok: false };
/** allowlist param ของ landing + cap ความยาว (t ตาม JWT ที่มี claims ไทย
* ยาวได้หลักพัน · ticket_data/hmac ตาม cap ของ parseTicketTokenBody) */
const SSO_PARAM_LIMITS = {
t: 8192,
ticket_data: 2048,
HmacSha256: 128,
fileUrl: 2048,
typeId: 200,
} as const;
/**
* แกะ query ของ URL ที่ลิงก์ e-Court ชี้มา (ใช้ทั้งฝั่ง client ของหน้า /note
* ตอน bootstrap และใน test) — allowlist 5 param เป๊ะ (D12): มี `t` = ทาง JWT ·
* ไม่มี `t` แต่มี `ticket_data`+`HmacSha256` ครบ = ทาง ticket · มีทั้งสองชุด /
* มีครึ่งเดียว / ไม่มีเลย / ยาวเกิน cap = ok:false
*/
export function parseSsoLinkParams(url: URL): SsoLinkParams {
const read = (name: keyof typeof SSO_PARAM_LIMITS): string =>
url.searchParams.get(name) ?? "";
const t = read("t");
const ticketData = read("ticket_data");
const hmacHex = read("HmacSha256");
let credential: SsoCredential;
if (t !== "") {
// มี ticket ปนมาพร้อม t แม้แต่ตัวเดียว = กำกวม → fail closed (D12)
if (ticketData !== "" || hmacHex !== "") return { ok: false };
if (t.length > SSO_PARAM_LIMITS.t) return { ok: false };
credential = { kind: "jwt", token: t };
} else if (ticketData !== "" && hmacHex !== "") {
if (ticketData.length > SSO_PARAM_LIMITS.ticket_data) return { ok: false };
if (hmacHex.length > SSO_PARAM_LIMITS.HmacSha256) return { ok: false };
credential = { kind: "ticket", ticketData, hmacHex };
} else {
// ไม่มีชุดไหนเลย หรือมี ticket ครึ่งเดียว
return { ok: false };
}
const params: { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string } = {
ok: true,
credential,
};
const fileUrl = read("fileUrl");
if (fileUrl !== "") {
if (fileUrl.length > SSO_PARAM_LIMITS.fileUrl) return { ok: false };
params.fileUrl = fileUrl;
}
const typeId = read("typeId");
if (typeId !== "") {
if (typeId.length > SSO_PARAM_LIMITS.typeId) return { ok: false };
params.typeId = typeId;
}
return params;
}
// ---------------------------------------------------------------------------
// body ของ POST /api/note/auth/session (แลก credential → access_token)
// ---------------------------------------------------------------------------
const SESSION_EXCHANGE_LIMITS = SSO_PARAM_LIMITS;
const SESSION_EXCHANGE_FIELDS = ["t", "ticket_data", "HmacSha256", "fileUrl", "typeId"] as const;
export type SessionExchangeBody =
| { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string }
| { ok: false; error: string };
/** body ของเส้นแลก token — เกณฑ์เดียวกับ parseSsoLinkParams (allowlist 5 field
* + cap ยาว + สอง credential พร้อมกัน = invalid ตาม D12) แค่รับทาง JSON body */
export function parseSessionExchangeBody(raw: unknown): SessionExchangeBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(SESSION_EXCHANGE_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${SESSION_EXCHANGE_FIELDS.join(", ")})`,
};
}
}
const str = (name: keyof typeof SESSION_EXCHANGE_LIMITS): string => {
const value = record[name];
return typeof value === "string" ? value : "";
};
const t = str("t");
const ticketData = str("ticket_data");
const hmacHex = str("HmacSha256");
let credential: SsoCredential;
if (t !== "") {
if (ticketData !== "" || hmacHex !== "") {
return { ok: false, error: "ส่ง credential ได้ทางเดียว (t หรือ ticket_data+HmacSha256)" };
}
if (t.length > SESSION_EXCHANGE_LIMITS.t) return { ok: false, error: "t ยาวเกินกำหนด" };
credential = { kind: "jwt", token: t };
} else if (ticketData !== "" && hmacHex !== "") {
if (ticketData.length > SESSION_EXCHANGE_LIMITS.ticket_data) {
return { ok: false, error: "ticket_data ยาวเกินกำหนด" };
}
if (hmacHex.length > SESSION_EXCHANGE_LIMITS.HmacSha256) {
return { ok: false, error: "HmacSha256 ยาวเกินกำหนด" };
}
credential = { kind: "ticket", ticketData, hmacHex };
} else {
return { ok: false, error: "ต้องระบุ t หรือ ticket_data+HmacSha256" };
}
const parsed: { ok: true; credential: SsoCredential; fileUrl?: string; typeId?: string } = {
ok: true,
credential,
};
const fileUrl = str("fileUrl");
if (fileUrl !== "") {
if (fileUrl.length > SESSION_EXCHANGE_LIMITS.fileUrl) {
return { ok: false, error: "fileUrl ยาวเกินกำหนด" };
}
parsed.fileUrl = fileUrl;
}
const typeId = str("typeId");
if (typeId !== "") {
if (typeId.length > SESSION_EXCHANGE_LIMITS.typeId) {
return { ok: false, error: "typeId ยาวเกินกำหนด" };
}
parsed.typeId = typeId;
}
return parsed;
}
// ---------------------------------------------------------------------------
// body ของ POST /api/note/auth/token (เส้น mint login-token — D1/D5)
// ---------------------------------------------------------------------------
/** ttl_seconds ทางเลือก — ช่วง 60–600 วินาที default 300 (credential ใน URL
* ต้องอายุสั้น) */
export const LOGIN_TOKEN_TTL = { min: 60, max: 600, default: 300 } as const;
const LOGIN_TOKEN_BODY_FIELDS = ["userid", "username", "fid", "ttl_seconds"] as const;
/** อักขระที่ identity (userid/username) ใช้ไม่ได้ — ค่าไหลลง DB เป็น user_id/
* user_name ของ annotation/bookmark (แสดงผล UI + เงื่อนไขค้นหา): & = + % เป็น
* ตัวที่เคยทำ identity เพี้ยนตอนแพ็กใน payload แบบ query string ของ cookie
* (รอบก่อนเลิก cookie 2026-10-30) — กันไว้เพราะเป็นชุดอักขระที่ตีความต่างกัน
* ตาม context ง่าย (ทาง ticket ใช้เกณฑ์เดียวกันที่ parseDocverTicket) */
const UNSAFE_IDENTITY_RE = /[&=+%]/;
export type LoginTokenBody =
| { ok: true; claims: { userid: string; username: string; fid: string }; ttlSeconds: number }
| { ok: false; error: string };
/** strict allowlist 4 field (ส่ง `purpose` มา = 400 — เส้นนี้เป็น login-token
* เอกสิทธิ์) · cap ความยาว identity ตาม parseTokenRequestBody ของกลุ่ม docver */
export function parseLoginTokenBody(raw: unknown): LoginTokenBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(LOGIN_TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${LOGIN_TOKEN_BODY_FIELDS.join(", ")})`,
};
}
}
const rawUserid = record.userid;
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
return { ok: false, error: "ต้องระบุ userid (string)" };
}
const userid = rawUserid.trim();
if (userid.length > 100) return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
if (UNSAFE_IDENTITY_RE.test(userid)) {
return { ok: false, error: "userid มีอักขระ & = + % ที่รองรับไม่ได้" };
}
const rawUsername = record.username;
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
return { ok: false, error: "ต้องระบุ username (string)" };
}
const username = rawUsername.trim();
if (username.length > 200) return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
if (UNSAFE_IDENTITY_RE.test(username)) {
return { ok: false, error: "username มีอักขระ & = + % ที่รองรับไม่ได้" };
}
const rawFid = record.fid;
if (typeof rawFid !== "string" || rawFid.trim() === "") {
return { ok: false, error: "ต้องระบุ fid (string)" };
}
const fid = rawFid.trim();
if (fid.length > 200) return { ok: false, error: "fid ยาวเกิน 200 ตัวอักษร" };
let ttlSeconds: number = LOGIN_TOKEN_TTL.default;
const rawTtl = record.ttl_seconds;
if (rawTtl !== undefined) {
if (
typeof rawTtl !== "number" ||
!Number.isInteger(rawTtl) ||
rawTtl < LOGIN_TOKEN_TTL.min ||
rawTtl > LOGIN_TOKEN_TTL.max
) {
return {
ok: false,
error: `ttl_seconds ต้องเป็นจำนวนเต็ม ${LOGIN_TOKEN_TTL.min}–${LOGIN_TOKEN_TTL.max}`,
};
}
ttlSeconds = rawTtl;
}
return { ok: true, claims: { userid, username, fid }, ttlSeconds };
}
// ---------------------------------------------------------------------------
// dev-link — mint ลิงก์ SSO จากหน้า /note/dev (คำขอผู้ใช้ 2026-10-29 "ใช้ได้ใน note"
// — ย้ายตามหน้า 2026-10-30) · 2026-11-02 คำขอผู้ใช้: เปิดด้วย env
// NOTE_SSO_DEV_MINT=1 อย่างเดียว — ใช้ได้ทุก environment (รวม production ที่ตั้ง
// env ไว้ เช่น เครื่องทดสอบ) 0/ไม่ตั้ง = หน้า /note/dev และ route dev-link เป็น 404
// — ความเสี่ยง mint จากหน้า public ตกอยู่ที่ผู้ตั้ง env บนเครื่องนั้น ไม่ใช่ทางเข้าจริง (D7)
// ---------------------------------------------------------------------------
/** เปิดหน้า /note/dev + ตัวช่วย mint ลิงก์ทดสอบหรือไม่ — env NOTE_SSO_DEV_MINT === "1"
* ตัวเดียว (ไม่ดู NODE_ENV — 2026-11-02) แต่ละเครื่องคุมด้วยการตั้ง/ถอด env เอง */
export function devMintEnabled(): boolean {
return process.env.NOTE_SSO_DEV_MINT?.trim() === "1";
}
const DEV_LINK_BODY_FIELDS = ["userid", "username", "fid", "ttl_seconds", "fileUrl", "typeId", "mode"] as const;
export type DevLinkBody =
| {
ok: true;
claims: { userid: string; username: string; fid: string };
ttlSeconds: number;
fileUrl?: string;
typeId?: string;
mode: "jwt" | "ticket";
}
| { ok: false; error: string };
/** body ของ POST /api/note/auth/dev-link — identity/ttl ใช้เกณฑ์เดียวกับ
* parseLoginTokenBody (ตัด field ส่วนเสริมออกก่อนส่งต่อ ให้เกณฑ์/ข้อความเดียวกัน) */
export function parseDevLinkBody(raw: unknown): DevLinkBody {
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
return { ok: false, error: "body ต้องเป็น JSON object" };
}
const record = raw as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(DEV_LINK_BODY_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${DEV_LINK_BODY_FIELDS.join(", ")})`,
};
}
}
const { fileUrl, typeId, mode, ...identity } = record;
const base = parseLoginTokenBody(identity);
if (!base.ok) return base;
const result: DevLinkBody = { ...base, mode: "jwt" };
if (fileUrl !== undefined) {
if (typeof fileUrl !== "string" || fileUrl.trim() === "" || fileUrl.length > 2048) {
return { ok: false, error: "fileUrl ต้องเป็น string (1–2048)" };
}
result.fileUrl = fileUrl.trim();
}
if (typeId !== undefined) {
if (typeof typeId !== "string" || typeId.trim() === "" || typeId.length > 200) {
return { ok: false, error: "typeId ต้องเป็น string (1–200)" };
}
result.typeId = typeId.trim();
}
if (mode !== undefined) {
if (mode !== "jwt" && mode !== "ticket") {
return { ok: false, error: "mode ต้องเป็น jwt หรือ ticket" };
}
result.mode = mode;
}
return result;
}
// ---------------------------------------------------------------------------
// แบนเนอร์สาเหตุ ?sso= / สถานะ gate ฝั่ง client (D8/D15) — ค่าอื่น = null (ไม่แสดง)
// ---------------------------------------------------------------------------
export type SsoBanner = { intent: "warning" | "error"; text: string };
/** ข้อความ gate คงที่ของหน้า annotate — คู่ขนาน `ssoBanner("required")` ให้หน้า
* gate ฝั่ง client ใช้ตรง ๆ โดยไม่ต้อง non-null assertion */
export const SSO_REQUIRED_BANNER: SsoBanner = {
intent: "warning",
text: "กรุณาเข้าสู่ระบบผ่านลิงก์เปิดเอกสารของ e-Court แล้วจึงใช้งานส่วนนี้",
};
export function ssoBanner(reason: string | undefined): SsoBanner | null {
switch (reason) {
case "expired":
return {
intent: "warning",
text: "ลิงก์เข้าสู่ระบบหมดอายุ — กลับไปที่ e-Court แล้วกดเปิดเอกสารใหม่อีกครั้ง",
};
case "invalid":
return {
intent: "warning",
text: "ลิงก์เข้าสู่ระบบไม่ถูกต้อง — กลับไปที่ e-Court แล้วกดเปิดเอกสารใหม่อีกครั้ง",
};
case "error":
return {
intent: "error",
text: "ระบบยังไม่พร้อมให้เข้าสู่ระบบ (ข้อผิดพลาดฝั่งเซิร์ฟเวอร์) — ลองอีกครั้งภายหลัง หากยังไม่สำเร็จโปรดติดต่อผู้ดูแลระบบ",
};
case "required":
return SSO_REQUIRED_BANNER;
default:
return null;
}
}

74
lib/note-session.ts Normal file
View file

@ -0,0 +1,74 @@
// session ของแอป note ฝั่ง client (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
// แบบ add-in" — pattern เดียวกับ wordaddin/services/authService): access_token
// + identity เก็บใน localStorage ("note-auth") หลังแลกจาก POST /api/note/auth/
// session · การเรียก API ของแอปผ่าน noteAuthFetch แนบ Authorization: Bearer
// อัตโนมัติ · token หมดอายุแล้วถือว่าไม่มี session (ล้างทิ้ง) — localStorage อาจ
// โยน (private mode/quota) ทุกจุดจึง try/catch
//
// client-only — ห้าม import จาก server component/module ฝั่ง server
import { withBasePath } from "@/utils/base-path";
export type NoteSession = {
token: string;
userid: string;
username: string;
/** exp epoch มิลลิวินาที — พ้นเวลานี้ถือว่าไม่มี session */
expMs: number;
};
const STORAGE_KEY = "note-auth";
export function readNoteSession(): NoteSession | null {
try {
const raw = window.localStorage.getItem(STORAGE_KEY);
if (raw === null) return null;
const parsed = JSON.parse(raw) as Partial<NoteSession> | null;
if (
parsed === null ||
typeof parsed !== "object" ||
typeof parsed.token !== "string" ||
parsed.token === "" ||
typeof parsed.userid !== "string" ||
parsed.userid === "" ||
typeof parsed.username !== "string" ||
typeof parsed.expMs !== "number"
) {
window.localStorage.removeItem(STORAGE_KEY);
return null;
}
if (parsed.expMs <= Date.now()) {
window.localStorage.removeItem(STORAGE_KEY);
return null;
}
return parsed as NoteSession;
} catch {
return null;
}
}
export function saveNoteSession(session: NoteSession): void {
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(session));
} catch {
// private mode/quota — เงียบไว้: ผู้ใช้เข้าผ่านลิงก์ e-Court ใหม่ได้เสมอ
}
}
export function clearNoteSession(): void {
try {
window.localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
}
/** fetch ของแอปพร้อม Bearer — path เป็น route API ของแอป ("/api/note/…")
* ไม่มี session = throw "no-session" ให้ caller เปลี่ยนเป็นหน้า gate เอง */
export async function noteAuthFetch(path: string, init: RequestInit = {}): Promise<Response> {
const session = readNoteSession();
if (session === null) throw new Error("no-session");
return fetch(withBasePath(path), {
...init,
headers: { ...(init.headers ?? {}), authorization: `Bearer ${session.token}` },
});
}

32
lib/note-token.ts Normal file
View file

@ -0,0 +1,32 @@
// session ของแอป note ฝั่ง server (ฉบับ token 2026-10-30 ตามคำขอผู้ใช้ "note ใช้
// แบบ add-in") — แกะ identity จาก access_token (purpose="login" ออกที่
// POST /api/note/auth/session อายุ NOTE_SESSION_TTL) ให้ Server Actions และ API
// ของแอปใช้ร่วม: token ผิด/หมดอายุ/secret ไม่ตั้ง = null เสมอ (fail closed)
// · ห้าม log ค่า token ดิบ
//
// แยกจาก lib/note-auth.ts (pure ใช้ร่วมกับ client ได้) เพราะต้อง import
// lib/docver-token ซึ่งใช้ node:crypto — ห้ามให้ client component import ไฟล์นี้
import { verifyDocverLoginToken } from "@/lib/docver-token";
/** อายุ access_token ของ session note (วินาที) — 8 ชม. เท่า session สมัย cookie:
* เว็บไม่มี ticket ฝังไฟล์แบบ add-in ให้ renew อัตโนมัติ หมดแล้วผู้ใช้กลับ
* e-Court กดลิงก์ใหม่ */
export const NOTE_SESSION_TTL = 8 * 60 * 60;
export type NoteUserIdentity = { userid: string; username: string };
/** identity จาก token — null = ไม่ผ่านทุกกรณี (ไม่ตั้ง secret รวม) */
export function noteUserFromToken(token: string | null | undefined): NoteUserIdentity | null {
if (typeof token !== "string" || token === "") return null;
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
if (secret === undefined || secret === "") return null;
const verified = verifyDocverLoginToken(token, secret);
return verified.ok ? { userid: verified.claims.userid, username: verified.claims.username } : null;
}
/** อ่าน `Authorization: Bearer <token>` ของ request → identity (route ใช้) */
export function noteUserFromRequest(request: Request): NoteUserIdentity | null {
const header = request.headers.get("authorization");
if (header === null || !header.startsWith("Bearer ")) return null;
return noteUserFromToken(header.slice("Bearer ".length).trim());
}

View file

@ -55,8 +55,9 @@ export const openApiSpec = {
{ {
name: "Judge's Note", name: "Judge's Note",
description: description:
"API ภายนอก (N10) — backend e-Court เรียกแบบ server-to-server ของ (Judge's Note)" + "API ภายนอกของแอป Judge's Note — N10: backend e-Court เรียกแบบ " +
"ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้", "server-to-server ดึง annotations/bookmarks ของเอกสารที่เคยเปิดในระบบนี้ · " +
"SSO: เส้น mint login-token สำหรับประกอบลิงก์เข้าสู่ระบบ (PLAN-SSO.md)",
}, },
], ],
paths: { paths: {
@ -362,7 +363,9 @@ export const openApiSpec = {
tags: ["template"], tags: ["template"],
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง", summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง",
description: description:
"รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", "รับค่า fid, tid, uid, uname จาก Query Parameter "+
"ตรวจสอบรุ่นล่าสุดใน /api/document/latest ถ้าไม่พบ ต้องหาจากฐานข้อมูลแล้วส่งไปประมวลผลต่อที่ /api/template/generate "+
"เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนาเท่านั้น ไม่มีการตรวจสอบ user session ต้องนำออกก่อน production",
parameters: [ parameters: [
{ {
name: "fid", name: "fid",
@ -461,9 +464,10 @@ export const openApiSpec = {
tags: ["template"], tags: ["template"],
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง", summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง",
description: description:
"รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded อ่านข้อมูล JSON และประมวลผลสร้างไฟล์ DOCX stream คืนกลับไป "+ "รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded "+
"จะเรียก generate ในเบื้องหลัง ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", "ตรวจสอบรุ่นล่าสุดใน /api/document/latest ถ้าไม่พบ ต้องหาจากฐานข้อมูลแล้วส่งไปประมวลผลต่อที่ /api/template/generate "+
requestBody: { "เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนาเท่านั้น ไม่มีการตรวจสอบ user session ต้องนำออกก่อน production",
requestBody: {
required: true, required: true,
content: { content: {
"multipart/form-data": { "multipart/form-data": {
@ -615,6 +619,74 @@ export const openApiSpec = {
}, },
}, },
}, },
"/api/note/auth/token": {
post: {
tags: ["Judge's Note"],
summary: "mint login-token สำหรับลิงก์ SSO เข้าแอป note (PLAN-SSO.md)",
description:
"ออก `access_token` ชนิด `purpose=login` ไว้ฝังเป็น param เดียว `t=` ของลิงก์ " +
"`/note?fileUrl=…&t=<access_token>` ที่ให้ผู้ใช้คลิกจากหน้า e-Court " +
"(ทางเข้า session ทางเดียวของแอป note — หน้าแรกแลก credential เป็น session " +
"token ที่ POST /api/note/auth/session แบบ add-in)\n\n" +
"- token ใช้ได้กับ**การเข้าแอป note เท่านั้น** — เอาไปยิง `/api/documents/*` " +
"ของกลุ่ม docver ไม่ได้ (401)\n" +
"- แนะนำให้ mint แบบ real-time ตอน render หน้า/ปุ่มให้ user ปัจจุบันเท่านั้น — " +
"**ห้ามเก็บ token ลง DB / ฝังอีเมล / แชท** (อายุสั้นตาม ttl_seconds)\n" +
"- `userid`/`username` = ค่าเดียวกับ `u_userid`/`u_userlogin` ของ e-Court " +
"(สิทธิ์เจ้าของ annotation ของแอป note ผูกกับ userid — ส่งผิดคน = ข้อมูลผูกคนผิด)\n" +
"- `fileUrl` ของลิงก์ปลายทางต้องผ่าน allowlist ของ N8 (admincourt.go.th + `EXTERNAL_FILE_HOSTS`)",
security: [{ ApiKeyAuth: [] }],
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
required: ["userid", "username", "fid"],
properties: {
userid: { type: "string", description: "u_userid ของ e-Court (1–100 ตัวอักษร)" },
username: { type: "string", description: "u_userlogin ของ e-Court (1–200 ตัวอักษร)" },
fid: { type: "string", description: "fileId ของ e-Court — ใช้เป็น fileId ของ N8 เสมอ (1–200 ตัวอักษร)" },
ttl_seconds: {
type: "integer",
minimum: 60,
maximum: 600,
default: 300,
description: "อายุ token ทางเลือกช่วง 60–600 วินาที (credential อยู่ใน URL ต้องอายุสั้น)",
},
},
},
examples: {
"mint ให้ผู้ใช้ oom เปิดไฟล์ F123": {
value: { userid: "oom", username: "สรวิชญ์", fid: "F123", ttl_seconds: 300 },
},
},
},
},
},
responses: {
200: {
description: "login-token พร้อมฝังเป็น param t ของลิงก์ e-court",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/TokenResponse" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description:
"ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY — ข้อความเดียวกันทุกสาเหตุ)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
"/api/documents/token": { "/api/documents/token": {
post: { post: {
tags: ["documents"], tags: ["documents"],

View file

@ -1,8 +0,0 @@
// อ่าน identity จาก cookie oacCase ฝั่ง server (mock login e-Court) — เรียกจากทุก page
import { cookies } from "next/headers";
import { OAC_CASE_COOKIE, parseOacCase, type OacUser } from "@/utils/user";
export function getSessionUser(): OacUser | null {
return parseOacCase(cookies().get(OAC_CASE_COOKIE)?.value);
}

View file

@ -1,42 +0,0 @@
import { NextResponse, type NextRequest } from "next/server";
import { OAC_CASE_COOKIE, parseOacCase } from "@/utils/user";
// บังคับ login (mock e-Court) ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /login
// (parse จริง ไม่ใช่แค่มี cookie — cookie ขยะ/ครึ่ง ๆ ถือว่ายังไม่เข้าสู่ระบบ)
export function middleware(req: NextRequest) {
const user = parseOacCase(req.cookies.get(OAC_CASE_COOKIE)?.value);
if (!user) {
const url = req.nextUrl.clone();
// N8 D5: เก็บ path+query ไว้วนกลับหลัง login (deep link จาก e-Court ไม่หาย)
// — ไม่ใส่กรณี next เป็น "/" ไปเฉย ๆ ไม่ให้ /note/login มี query เกินจำเป็น
// (path ตรงนี้ตัด basePath แล้ว จึงขึ้นต้น /note/… เสมอ / NextURL ใส่ basePath คืนตอน
// redirect เอง — invariant #12)
const next = url.pathname + url.search;
url.pathname = "/note/login";
url.search = "";
if (next !== "/") url.searchParams.set("next", next);
return NextResponse.redirect(url);
}
return NextResponse.next();
}
// ยกเว้น: หน้า login เอง, API/SSE/export (อยู่ใต้ api/note/**), API template
// (api/template/** — เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย ไม่แตะ DB
// ไม่มีข้อมูลรับ), API เอกสาร versioning (api/documents/** — กลุ่ม docver
// stand-alone ของ wordaddin: ไม่มี session ผู้เรียกบอก userId/userName เอง —
// docs/PLAN-DOCVER.md), หน้า Swagger UI (api/swagger — document ของทุกกลุ่ม),
// taskpane ของ wordaddin (wordaddin/** — รันใน webview ของ Word ไม่มี cookie
// oacCase และมี auth ของตัวเองแยกจากแอป note), ไฟล์อัปโหลด, Next internal,
// และ static ใน public/note/ (pdf.worker, samples, asset Swagger UI)
// + public/ ราก (fonts, favicon)
// — matcher ทำงานบน path หลังตัด basePath (/docedit): หน้า/ไฟล์ของแอปขึ้นต้น note/
// ส่วน API ขึ้นต้น api/note/ (URL /docedit/api/note/…)
// — root "/" ไม่ถูก match (ใช้ .+ ไม่ใช่ .*) เพราะเป็น redirect สู่ taskpane
// wordaddin ซึ่ง public (คำขอผู้ใช้ 2026-10-14) — ลิงก์ e-Court N8 ที่ root
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
export const config = {
matcher: [
"/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)",
],
};

View file

@ -9,7 +9,7 @@ const nextConfig = {
// สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js // สำหรับ Docker (docker/web.Dockerfile) — build แล้วได้ .next/standalone รันด้วย node server.js
output: "standalone", output: "standalone",
// serve ใต้ /docedit + route ทั้งหมดอยู่ใต้ app/note/** — <Link>/next/navigation เติม basePath // serve ใต้ /docedit + route ทั้งหมดอยู่ใต้ app/note/** — <Link>/next/navigation เติม basePath
// ให้เอง แต่ต้องเขียน path รวม /note (เช่น href="/note/login") ส่วน fetch/EventSource/ // ให้เอง แต่ต้องเขียน path รวม /note (เช่น href="/note/annotate") ส่วน fetch/EventSource/
// plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts — เติม base + /note) // plain <a href>/workerSrc ต้องผ่าน withBasePath() (utils/base-path.ts — เติม base + /note)
basePath, basePath,
// expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้ // expose ให้โค้ดใช้ (inline ตอน build) — ค่าจริงประกาศที่เดียวที่ไฟล์นี้
@ -33,12 +33,6 @@ const nextConfig = {
}, },
]; ];
}, },
// N4 อัปโหลดไฟล์ผ่าน Server Action — default 1MB ไม่พอสำหรับ PDF/สแกนศาล (ต้องตรงกับ MAX_UPLOAD_BYTES ใน app/actions/files.ts)
experimental: {
serverActions: {
bodySizeLimit: "100mb",
},
},
}; };
export default nextConfig; export default nextConfig;

View file

@ -15,7 +15,7 @@
<a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/> <a href="/docedit/api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/>
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2> <h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
Template: <a href="../tmpl/T_X.docx">T_X.docx</a>, &nbsp; Template: <a href="/docedit/tmpl/T_X.docx">T_X.docx</a>, &nbsp;
<a href="/docedit/tmpl/T12_X.docx">T12_X.docx</a><br/> <a href="/docedit/tmpl/T12_X.docx">T12_X.docx</a><br/>
Data: <a href="/docedit/tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>, &nbsp; Data: <a href="/docedit/tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>, &nbsp;
<a href="/docedit/tmpl/T_no_warning.json">T_no_warning.json</a>, &nbsp; <a href="/docedit/tmpl/T_no_warning.json">T_no_warning.json</a>, &nbsp;
@ -23,6 +23,5 @@
<a href="/docedit/tmpl/T12_b2_IN.json">T12_b2_IN.json</a>, &nbsp; <a href="/docedit/tmpl/T12_b2_IN.json">T12_b2_IN.json</a>, &nbsp;
<a href="/docedit/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a> <a href="/docedit/tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a>
</body> </body>
</html> </html>

163
scripts/make-login-link.mjs Normal file
View file

@ -0,0 +1,163 @@
#!/usr/bin/env node
// PLAN-SSO.md §6 D10 — dev tool mint ลิงก์ SSO ของ e-Court ให้แอป note ออฟไลน์
// (แทน mock login ที่ถอดแล้ว) แล้วพิมพ์ลิงก์เต็มพร้อมวางใน browser — pattern
// เดียวกับ scripts/make-ticket-docx.mjs
//
// ใช้: node scripts/make-login-link.mjs [options]
// --uid <id> default oom (claim userid / ticket uid)
// --uname <name> default "สรวิชญ์" (claim username / ticket uname)
// --fid <fileId> default DEMO_FID_001 (fileId ของ e-Court — D11)
// --fileUrl <url> path ในเครื่อง (/samples/…) หรือ https ของ allowlist N8 (optional)
// --typeId <id> กลุ่มเอกสาร N9 (optional)
// --ttl <seconds> default 300 (ทาง jwt = exp ของ token · ทาง ticket = exp ของ ticket)
// --mode jwt|ticket default jwt (ทาง A แนะนำ — ทาง B ใช้ตัว stamp เดียวกับ .docx)
// --secret <secret> ทาง jwt: DOCVER_TOKEN_SECRET · ทาง ticket: ADDIN_TICKET_SECRET
// → SECRET → "super_secret" (default ตามลำดับ อ่านจาก env/.env ก่อน)
// --base <url> base URL ของแอป (default env DOCEDIT_BASE_URL หรือ
// http://localhost:3000/docedit)
//
// รันใน repo เท่านั้น — ไม่ใช่ postinstall
import { createHmac } from "node:crypto";
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
/** อ่าน .env (KEY=VALUE บรรทัดละคู่ — พอสำหรับค่า dev ไม่ใช่ parser เต็ม) */
function loadDotEnv() {
const file = path.join(process.cwd(), ".env");
if (!existsSync(file)) return {};
const values = {};
for (const line of readFileSync(file, "utf8").split(/\r?\n/)) {
const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
if (match === null) continue;
let value = match[2];
if (
(value.startsWith('"') && value.endsWith('"')) ||
(value.startsWith("'") && value.endsWith("'"))
) {
value = value.slice(1, -1);
}
values[match[1]] = value;
}
return values;
}
function usage(code = 1) {
console.log(`ใช้: node scripts/make-login-link.mjs [options]
--uid <id> --uname <name> --fid <fileId> --fileUrl <url> --typeId <id>
--ttl <seconds> --mode jwt|ticket --secret <secret> --base <url>`);
process.exit(code);
}
function parseArgs(argv) {
const options = {
uid: "oom",
uname: "สรวิชญ์",
fid: "DEMO_FID_001",
fileUrl: null,
typeId: null,
ttl: 300,
mode: "jwt",
secret: null,
base: null,
};
for (let i = 0; i < argv.length; i++) {
const next = () => {
const value = argv[++i];
if (value === undefined) usage();
return value;
};
switch (argv[i]) {
case "--uid": options.uid = next(); break;
case "--uname": options.uname = next(); break;
case "--fid": options.fid = next(); break;
case "--fileUrl": options.fileUrl = next(); break;
case "--typeId": options.typeId = next(); break;
case "--ttl": {
const value = Number(next());
if (!Number.isInteger(value) || value <= 0) throw new Error("--ttl ต้องเป็นจำนวนวินาทีบวก");
options.ttl = value;
break;
}
case "--mode": {
const value = next();
if (value !== "jwt" && value !== "ticket") throw new Error("--mode ต้องเป็น jwt หรือ ticket");
options.mode = value;
break;
}
case "--secret": options.secret = next(); break;
case "--base": options.base = next(); break;
case "--help": case "-h": usage(0); break;
default: usage();
}
}
return options;
}
const envFile = loadDotEnv();
const env = (name) => process.env[name] ?? envFile[name] ?? "";
const options = parseArgs(process.argv.slice(2));
const base = (options.base || env("DOCEDIT_BASE_URL") || "http://localhost:3000/docedit").replace(/\/+$/, "");
const exp = Math.floor(Date.now() / 1000) + options.ttl;
/** query ร่วมของทั้งสองทาง (เฉพาะ param ที่มีจริง) */
const sharedParams = new URLSearchParams();
if (options.fileUrl !== null) sharedParams.set("fileUrl", options.fileUrl);
if (options.typeId !== null) sharedParams.set("typeId", options.typeId);
let link;
let secretUsed;
if (options.mode === "jwt") {
// ตรงตาม lib/docver-token.ts issueDocverToken(…, purpose:"login")
secretUsed = options.secret ?? env("DOCVER_TOKEN_SECRET");
if (secretUsed === "") {
console.error("✗ ไม่มี secret — ตั้ง DOCVER_TOKEN_SECRET ใน .env หรือส่ง --secret");
process.exit(1);
}
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString("base64url");
const payload = Buffer.from(
JSON.stringify({
userid: options.uid,
username: options.uname,
fid: options.fid,
template: null,
purpose: "login",
iat: Math.floor(Date.now() / 1000),
exp,
}),
"utf8",
).toString("base64url");
const signature = createHmac("sha256", secretUsed)
.update(`${header}.${payload}`)
.digest("base64url");
const params = new URLSearchParams(sharedParams);
params.set("t", `${header}.${payload}.${signature}`);
// ปลายทาง = หน้าแรก /note ที่ client bootstrap แลก credential เป็น token
// (ฉบับ token 2026-10-30 — ไม่มี route e-court ที่ set cookie ให้ชี้แล้ว)
link = `${base}/note?${params.toString()}`;
} else {
// ตรงตามตัว stamp จริง (make-ticket-docx.mjs / lib/addin-ticket.ts):
// ticket_data key เรียงตามตัวอักษรหลังเติม exp → HMAC hex lowercase บน string ดิบ
secretUsed = options.secret ?? (env("ADDIN_TICKET_SECRET") || env("SECRET") || "super_secret");
const ticket = { exp, fid: options.fid, uid: options.uid, uname: options.uname };
const ticketData = JSON.stringify(
Object.keys(ticket).sort().reduce((result, key) => {
result[key] = ticket[key];
return result;
}, {}),
);
const hmac = createHmac("sha256", secretUsed).update(ticketData, "utf8").digest("hex");
const params = new URLSearchParams(sharedParams);
params.set("ticket_data", ticketData);
params.set("HmacSha256", hmac);
link = `${base}/note?${params.toString()}`;
}
console.log(`✓ ลิงก์ SSO (${options.mode}) อายุ ${options.ttl} วินาที — วางใน browser:`);
console.log();
console.log(link);
console.log();
console.log(` userid/uid : ${options.uid} → หลังแลก token ระบบพาเข้าหน้าอ่านเอกสารของ fid ${options.fid}`);
console.log(` secret : ${".".repeat(Math.max(secretUsed.length - 2, 0))}${secretUsed.slice(-2)} (ไม่พิมพ์เต็ม)`);
console.log(` exp : ${new Date(exp * 1000).toLocaleString("th-TH")}`);

View file

@ -7,6 +7,7 @@ import {
requireDocumentApiKey, requireDocumentApiKey,
requireDocverApiKey, requireDocverApiKey,
requireDocverBearer, requireDocverBearer,
verifyDocverLoginToken,
verifyDocverToken, verifyDocverToken,
type DocverTokenClaims, type DocverTokenClaims,
} from "@/lib/docver-token"; } from "@/lib/docver-token";
@ -372,3 +373,87 @@ describe("parseTokenRequestBody", () => {
).toBe(false); ).toBe(false);
}); });
}); });
// ─── purpose claim ของ PLAN-SSO.md (D4/D5) ───────────────────────────────────
describe("issueDocverToken purpose / requireDocverBearer-reject-login", () => {
it("default = purpose docver (payload มีค่าชัด แต่ claims ที่ verify ได้เหมือนเดิม)", () => {
const token = issueDocverToken(CLAIMS, SECRET, 60);
const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString("utf8"));
expect(payload.purpose).toBe("docver");
const verified = verifyDocverToken(token, SECRET);
ok(verified);
expect(verified.claims).toEqual(CLAIMS); // ไม่มี purpose ปนออกมา (ความหมาย = docver)
});
it("issue ด้วย purpose login → claims.purpose = login", () => {
const verified = verifyDocverToken(issueDocverToken(CLAIMS, SECRET, 60, "login"), SECRET);
ok(verified);
expect(verified.claims.purpose).toBe("login");
});
it("purpose ค่าอื่น (แม้เซ็นถูก) = invalid", () => {
const now = Math.floor(Date.now() / 1000) + 60;
const bad = signPayload({ ...CLAIMS, purpose: "admin", iat: 0, exp: now });
expect(verifyDocverToken(bad, SECRET).ok).toBe(false);
});
it("requireDocverBearer ปฏิเสธ login-token (scope escalation — 401 เดียวกับ token เน่า)", async () => {
process.env.DOCVER_TOKEN_SECRET = SECRET;
const auth = requireDocverBearer(
reqWithBearer(`Bearer ${issueDocverToken(CLAIMS, SECRET, 60, "login")}`),
);
expect(auth.ok).toBe(false);
if (!auth.ok) {
expect(auth.response.status).toBe(401);
expect(await bodyOf(auth.response)).toEqual({ error: "โทเคนไม่ถูกต้องหรือหมดอายุ" });
}
});
});
describe("verifyDocverLoginToken (SSO §3.2)", () => {
it("login-token ที่ยังไม่หมดอายุ = ได้ claims", () => {
const verified = verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, 60, "login"), SECRET);
ok(verified);
expect(verified.claims.fid).toBe(CLAIMS.fid);
expect(verified.claims.purpose).toBe("login");
});
it("signature ถูกแต่ exp เลยมาแล้ว = reason expired (แยกจาก invalid ให้ route ตอบ sso=expired)", () => {
const verified = verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, -10, "login"), SECRET);
expect(verified).toEqual({ ok: false, reason: "expired" });
});
it("token docver (ไม่มี purpose) / purpose=docver = reason invalid (D4)", () => {
const noPurpose = signPayload({ ...CLAIMS, iat: 0, exp: Math.floor(Date.now() / 1000) + 60 });
expect(verifyDocverLoginToken(noPurpose, SECRET)).toEqual({ ok: false, reason: "invalid" });
expect(verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, 60), SECRET)).toEqual({
ok: false,
reason: "invalid",
});
});
it("token docver ที่หมดอายุ = invalid ไม่ใช่ expired (ตัดสินชนิดก่อนอายุ — 2026-10-30)", () => {
expect(verifyDocverLoginToken(issueDocverToken(CLAIMS, SECRET, -10), SECRET)).toEqual({
ok: false,
reason: "invalid",
});
const expiredNoPurpose = signPayload({ ...CLAIMS, iat: 0, exp: 1 });
expect(verifyDocverLoginToken(expiredNoPurpose, SECRET)).toEqual({ ok: false, reason: "invalid" });
});
it("ผิดรูป/secret ผิด/tampered = reason invalid", () => {
expect(verifyDocverLoginToken("garbage", SECRET)).toEqual({ ok: false, reason: "invalid" });
expect(verifyDocverLoginToken(issueDocverToken(CLAIMS, "other", 60, "login"), SECRET)).toEqual({
ok: false,
reason: "invalid",
});
const token = issueDocverToken(CLAIMS, SECRET, 60, "login");
const [h, , s] = token.split(".");
const tampered = Buffer.from(
JSON.stringify({ ...CLAIMS, userid: "999", purpose: "login", iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }),
"utf8",
).toString("base64url");
expect(verifyDocverLoginToken(`${h}.${tampered}.${s}`, SECRET)).toEqual({ ok: false, reason: "invalid" });
});
});

204
tests/lib/note-auth.test.ts Normal file
View file

@ -0,0 +1,204 @@
import { describe, expect, it } from "vitest";
import {
devMintEnabled,
parseDevLinkBody,
parseLoginTokenBody,
parseSessionExchangeBody,
parseSsoLinkParams,
ssoBanner,
} from "@/lib/note-auth";
// PLAN-SSO.md §4 — pure module ของ SSO ของแอป note (ฉบับ token 2026-10-30 ตามคำขอ
// ผู้ใช้ "note ใช้แบบ add-in": parse ลิงก์ / body แลก token / แบนเนอร์) — ไฟล์นี้
// ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ node:crypto มาด้วย จึงรันได้โดยไม่ต้อง
// ตั้ง DATABASE_URL
describe("parseSsoLinkParams (D12)", () => {
const url = (query: string): URL => new URL(`https://x.go.th/docedit/note?${query}`);
it("มี t เดี่ยว = ทาง jwt + ส่ง fileUrl/typeId ผ่าน", () => {
const parsed = parseSsoLinkParams(url("fileUrl=https%3A%2F%2Ffile.example%2Fa.pdf&typeId=T12&t=abc.def.ghi"));
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.credential).toEqual({ kind: "jwt", token: "abc.def.ghi" });
expect(parsed.fileUrl).toBe("https://file.example/a.pdf");
expect(parsed.typeId).toBe("T12");
}
});
it("มี ticket ครบสองค่า (ไม่มี t) = ทาง ticket", () => {
const ticketData = encodeURIComponent('{"exp":1,"fid":"f","uid":"u","uname":"n"}');
const parsed = parseSsoLinkParams(url(`ticket_data=${ticketData}&HmacSha256=${"a".repeat(64)}`));
expect(parsed.ok).toBe(true);
if (parsed.ok) {
expect(parsed.credential).toEqual({
kind: "ticket",
ticketData: '{"exp":1,"fid":"f","uid":"u","uname":"n"}',
hmacHex: "a".repeat(64),
});
expect(parsed.fileUrl).toBeUndefined();
}
});
it("มีทั้ง t และ ticket (แม้เหลือ HmacSha256 ตัวเดียว) = ok:false (fail closed)", () => {
expect(parseSsoLinkParams(url(`t=a.b.c&ticket_data=%7B%7D&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url(`t=a.b.c&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
});
it("ticket ครึ่งเดียว / ไม่มีชุดไหนเลย = ok:false", () => {
expect(parseSsoLinkParams(url("ticket_data=%7B%7D")).ok).toBe(false);
expect(parseSsoLinkParams(url(`HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url("fileUrl=https%3A%2F%2Fx")).ok).toBe(false);
expect(parseSsoLinkParams(url("")).ok).toBe(false);
});
it("ค่ายาวเกิน cap = ok:false", () => {
expect(parseSsoLinkParams(url(`t=${"a".repeat(8193)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url(`ticket_data=${"a".repeat(2049)}&HmacSha256=${"a".repeat(64)}`)).ok).toBe(false);
expect(parseSsoLinkParams(url(`t=x.y.z&fileUrl=${"a".repeat(2049)}`)).ok).toBe(false);
});
});
describe("parseSessionExchangeBody (POST /api/note/auth/session)", () => {
it("t เดี่ยว + fileUrl/typeId ผ่าน — ได้ credential ทาง jwt", () => {
const parsed = parseSessionExchangeBody({
t: "a.b.c",
fileUrl: "https://f.example/a.pdf",
typeId: "T12",
});
expect(parsed).toEqual({
ok: true,
credential: { kind: "jwt", token: "a.b.c" },
fileUrl: "https://f.example/a.pdf",
typeId: "T12",
});
});
it("ticket ครบสองค่า = ทาง ticket · ครึ่งเดียว/ไม่มีเลย = 400", () => {
const ticket = JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n" });
expect(parseSessionExchangeBody({ ticket_data: ticket, HmacSha256: "a".repeat(64) })).toEqual({
ok: true,
credential: { kind: "ticket", ticketData: ticket, hmacHex: "a".repeat(64) },
});
expect(parseSessionExchangeBody({ ticket_data: ticket }).ok).toBe(false);
expect(parseSessionExchangeBody({ HmacSha256: "a".repeat(64) }).ok).toBe(false);
expect(parseSessionExchangeBody({}).ok).toBe(false);
});
it("สอง credential พร้อมกัน = 400 (D12) · field อื่น/ผิด type = 400 · cap ยาว = 400", () => {
expect(parseSessionExchangeBody({ t: "a.b.c", HmacSha256: "a".repeat(64) }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: "a.b.c", oops: 1 }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: 123 }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: "a".repeat(8193) }).ok).toBe(false);
expect(parseSessionExchangeBody({ t: "a.b.c", fileUrl: "a".repeat(2049) }).ok).toBe(false);
expect(parseSessionExchangeBody(null).ok).toBe(false);
expect(parseSessionExchangeBody("text").ok).toBe(false);
expect(parseSessionExchangeBody([1, 2]).ok).toBe(false);
});
});
describe("parseLoginTokenBody (mint route §3.1)", () => {
it("ครบ 3 field = ได้ claims + ttl default 300 · ttl ในช่วงใช้ค่าที่ส่ง", () => {
const full = parseLoginTokenBody({ userid: "123", username: "สรวิชญ์", fid: "F1" });
expect(full).toEqual({ ok: true, claims: { userid: "123", username: "สรวิชญ์", fid: "F1" }, ttlSeconds: 300 });
const withTtl = parseLoginTokenBody({ userid: "123", username: "n", fid: "F1", ttl_seconds: 60 });
expect(withTtl).toEqual({ ok: true, claims: { userid: "123", username: "n", fid: "F1" }, ttlSeconds: 60 });
});
it("ttl_seconds นอกช่วง 60–600 / ไม่ใช่ integer = 400", () => {
for (const bad of [59, 601, 0, -1, 300.5, "300", null]) {
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", ttl_seconds: bad }).ok).toBe(false);
}
});
it("ส่ง purpose หรือ field อื่นมา = 400 (login-token เอกสิทธิ์ — D4)", () => {
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", purpose: "docver" }).ok).toBe(false);
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: "F", template: "T" }).ok).toBe(false);
});
it("ไม่ใช่ object / ขาด field / identity ว่าง = 400", () => {
expect(parseLoginTokenBody(null).ok).toBe(false);
expect(parseLoginTokenBody("text").ok).toBe(false);
expect(parseLoginTokenBody({ username: "n", fid: "F" }).ok).toBe(false);
expect(parseLoginTokenBody({ userid: " ", username: "n", fid: "F" }).ok).toBe(false);
expect(parseLoginTokenBody({ userid: "1", username: "n", fid: 3 }).ok).toBe(false);
});
it("identity มี & = + % = 400 — อักขระที่ตีความต่างกันตาม context ง่าย (2026-10-30)", () => {
for (const bad of ["a&b", "a=b", "สมหญิง+สุข", "a%41"]) {
expect(parseLoginTokenBody({ userid: bad, username: "n", fid: "F" }).ok, bad).toBe(false);
expect(parseLoginTokenBody({ userid: "1", username: bad, fid: "F" }).ok, bad).toBe(false);
}
// อักขระอื่นที่ใช้ได้ปกติ (ไทย/ขีด/เว้นวรรค) = ผ่าน
expect(parseLoginTokenBody({ userid: "1234-5", username: "สรวิชญ์ ญาติ", fid: "F" }).ok).toBe(true);
});
});
describe("ssoBanner (D8/D15)", () => {
it("ครบ 4 ค่ามีข้อความ · ค่าอื่น/undefined = null (ไม่แสดง)", () => {
for (const reason of ["expired", "invalid", "error", "required"]) {
const banner = ssoBanner(reason);
expect(banner).not.toBeNull();
expect(["warning", "error"]).toContain(banner!.intent);
expect(banner!.text.length).toBeGreaterThan(0);
}
expect(ssoBanner("weird")).toBeNull();
expect(ssoBanner(undefined)).toBeNull();
});
});
describe("parseDevLinkBody / devMintEnabled (dev-link)", () => {
it("ครบ field = ได้ claims + ttl ตามเกณฑ์เดียวกับ mint · mode default jwt · field ส่วนเสริมผ่านเข้ามาได้", () => {
const full = parseDevLinkBody({
userid: "oom",
username: "สรวิชญ์",
fid: "F123",
ttl_seconds: 120,
fileUrl: "/samples/a.pdf",
typeId: "T9",
mode: "ticket",
});
expect(full).toEqual({
ok: true,
claims: { userid: "oom", username: "สรวิชญ์", fid: "F123" },
ttlSeconds: 120,
fileUrl: "/samples/a.pdf",
typeId: "T9",
mode: "ticket",
});
const minimal = parseDevLinkBody({ userid: "u", username: "n", fid: "F" });
expect(minimal.ok).toBe(true);
if (minimal.ok) {
expect(minimal.mode).toBe("jwt");
expect(minimal.fileUrl).toBeUndefined();
expect(minimal.ttlSeconds).toBe(300);
}
});
it("identity/ttl ผิดเกณฑ์ = 400 ข้อความเดียวกับ parseLoginTokenBody · mode/fileUrl/typeId ผิดรูป = 400", () => {
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", ttl_seconds: 601 }).ok).toBe(false);
expect(parseDevLinkBody({ userid: " ", username: "n", fid: "F" }).ok).toBe(false);
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", mode: "basic" }).ok).toBe(false);
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", fileUrl: "" }).ok).toBe(false);
expect(parseDevLinkBody({ userid: "u", username: "n", fid: "F", oops: 1 }).ok).toBe(false);
});
it("devMintEnabled: ต้องเป็น 1 เป๊ะ — ค่าอื่น = false · ใช้ได้ทุก NODE_ENV (2026-11-02)", () => {
const orig = process.env.NODE_ENV;
const writable = process.env as Record<string, string | undefined>;
process.env.NOTE_SSO_DEV_MINT = "1";
writable.NODE_ENV = "development";
expect(devMintEnabled()).toBe(true);
// production ที่ตั้ง env ไว้ก็เปิด (เครื่องทดสอบ) — gate เหลือ env ตัวเดียว
writable.NODE_ENV = "production";
expect(devMintEnabled()).toBe(true);
writable.NODE_ENV = orig ?? "test";
for (const value of [undefined, "", "0", "true", " yes "]) {
if (value === undefined) delete process.env.NOTE_SSO_DEV_MINT;
else process.env.NOTE_SSO_DEV_MINT = value;
expect(devMintEnabled(), String(value)).toBe(false);
}
delete process.env.NOTE_SSO_DEV_MINT;
});
});

View file

@ -82,6 +82,13 @@ describe("parseDocverTicket", () => {
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false); expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false);
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false); expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false);
}); });
it("uid/uname มี & = + % = ไม่ผ่าน (round-trip ผ่าน cookie oacCase ไม่ได้ — 2026-10-30)", () => {
for (const bad of ["a&b", "a=b", "a+b", "a%41"]) {
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: bad, uname: "n" })).ok, bad).toBe(false);
expect(parseDocverTicket(JSON.stringify({ exp: 1, fid: "f", uid: "u", uname: bad })).ok, bad).toBe(false);
}
});
}); });
describe("isTicketExpired", () => { describe("isTicketExpired", () => {

View file

@ -1,28 +0,0 @@
import { describe, expect, it } from "vitest";
import { sanitizeNextPath } from "@/utils/external-file";
// N8 D5 — path วนกลับหลัง login ต้องเป็น path ใน app เท่านั้น (กัน open-redirect)
describe("sanitizeNextPath", () => {
it("path ใน app (มี query ก็ได้) ผ่านตรง ๆ", () => {
expect(sanitizeNextPath("/")).toBe("/");
expect(sanitizeNextPath("/annotate")).toBe("/annotate");
expect(sanitizeNextPath("/annotate/abc-123")).toBe("/annotate/abc-123");
expect(sanitizeNextPath("/?fileUrl=https://x&fileId=1")).toBe(
"/?fileUrl=https://x&fileId=1",
);
});
it("absolute URL / protocol-relative / backslash → null กัน open-redirect", () => {
expect(sanitizeNextPath("//evil.com")).toBeNull();
expect(sanitizeNextPath("/\\evil.com")).toBeNull(); // browser ตีความ \ เป็น /
expect(sanitizeNextPath("https://evil.com")).toBeNull();
expect(sanitizeNextPath("http://evil.com/path")).toBeNull();
expect(sanitizeNextPath("/\\//evil.com")).toBeNull();
});
it("ค่าว่าง/ไม่ขึ้นต้น / ขาด ๆ เกิน ๆ → null", () => {
expect(sanitizeNextPath("")).toBeNull();
expect(sanitizeNextPath("annotate")).toBeNull(); // ไม่มี / นำหน้า
expect(sanitizeNextPath(" /annotate")).toBeNull(); // space นำหน้าไม่ผ่าน startsWith
});
});

View file

@ -1,58 +0,0 @@
import { describe, expect, it } from "vitest";
import {
buildOacCaseCookie,
OAC_CASE_COOKIE,
parseOacCase,
} from "@/utils/user";
/** ตัวอย่าง cookie จริงจากระบบ e-Court (เก็บใน test เพื่อล็อก format ของจริง) */
const REAL_OAC_CASE =
"u_userid=235821&u_userlogin=tu_2&u_courtid=4&u_password=&u_AdminStatus=&u_perCardNo=5000000000006&u_perCardNoEncript=1800650010005002003&u_userType=4&u_CidAuthen=NNNENNNNNNNNNNNNNNNN&u_hSecId=NNNNNNNNNNNNNNNNNNNN&u_username=&u_courtName=&u_AuditID=1995227&u_PsGroupNum=1&u_PsGroup=11&u_PsGroupName=";
describe("parseOacCase", () => {
it("cookie e-Court จริง → identity จาก u_userid/u_userlogin", () => {
expect(parseOacCase(REAL_OAC_CASE)).toEqual({
userId: "235821",
userName: "tu_2",
});
});
it("ค่าจาก buildOacCaseCookie อ่านกลับได้ (round-trip ค่า mock login จริง user1)", () => {
expect(parseOacCase(buildOacCaseCookie("user1", "user1"))).toEqual({
userId: "user1",
userName: "user1",
});
});
it("ค่าเสีย/ว่าง/ขาด field → null (ถือว่ายังไม่เข้าสู่ระบบ)", () => {
expect(parseOacCase(null)).toBeNull();
expect(parseOacCase(undefined)).toBeNull();
expect(parseOacCase("")).toBeNull();
expect(parseOacCase("garbage")).toBeNull();
expect(parseOacCase("u_userlogin=user_x")).toBeNull(); // ขาด u_userid
expect(parseOacCase("u_userid=123&u_userlogin=")).toBeNull(); // u_userlogin ว่าง
});
it("ค่ารอบด้วย space ถูก trim", () => {
expect(parseOacCase("u_userid= 123 &u_userlogin= tu_2 ")).toEqual({
userId: "123",
userName: "tu_2",
});
});
it("ชื่อ cookie ตรงกับระบบ e-Court", () => {
expect(OAC_CASE_COOKIE).toBe("oacCase");
});
});
describe("buildOacCaseCookie", () => {
it("แทนเฉพาะ u_userid/u_userlogin — ฟิลด์อื่นคงค่าตัวอย่างจริงครบทุก field", () => {
const fields = Object.fromEntries(new URLSearchParams(buildOacCaseCookie("xxxxxx", "user_x")));
expect(fields["u_userid"]).toBe("xxxxxx");
expect(fields["u_userlogin"]).toBe("user_x");
expect(fields["u_courtid"]).toBe("4");
expect(fields["u_perCardNo"]).toBe("5000000000006");
expect(fields["u_AuditID"]).toBe("1995227");
expect(Object.keys(fields)).toHaveLength(16);
});
});

View file

@ -38,6 +38,14 @@ const TICKET_LIMITS = {
/** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */ /** allowlist ของ ticket_data — field อื่นปนมา = reject (กัน typo เงียบ ๆ ตาม convention ของ repo) */
const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const; const TICKET_FIELDS = ["exp", "fid", "tid", "uid", "uname"] as const;
/** อักขระที่ uid/uname ใช้ไม่ได้ — & = + % เป็นชุดที่ตีความต่างกันตาม context
* ง่าย (query string/URL decode/ฟอร์ม) และค่า identity นี้ไหลลง DB เป็น
* user_id/user_name ของ annotation/bookmark (แสดง UI + เงื่อนไขค้นหา) —
* เคยทำ identity เพี้ยนในรอบ cookie (payload ถูก parse ผ่าน URLSearchParams —
* ถอดแล้ว 2026-10-30) จึงกันไว้ตั้งแต่แกะ ticket (เกณฑ์เดียวกับ
* parseLoginTokenBody ของ lib/note-auth.ts) */
const UNSAFE_IDENTITY_RE = /[&=+%]/;
export type DocverTicketParseResult = export type DocverTicketParseResult =
| { ok: true; ticket: DocverTicket } | { ok: true; ticket: DocverTicket }
| { ok: false; error: string }; | { ok: false; error: string };
@ -82,8 +90,14 @@ export function parseDocverTicket(raw: string): DocverTicketParseResult {
const uid = field("uid"); const uid = field("uid");
if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" }; if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" };
if (UNSAFE_IDENTITY_RE.test(uid)) {
return { ok: false, error: "ticket_data ของ uid มีอักขระ & = + % ที่รองรับไม่ได้" };
}
const uname = field("uname"); const uname = field("uname");
if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" }; if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" };
if (UNSAFE_IDENTITY_RE.test(uname)) {
return { ok: false, error: "ticket_data ของ uname มีอักขระ & = + % ที่รองรับไม่ได้" };
}
const fid = field("fid"); const fid = field("fid");
if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" }; if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" };

View file

@ -82,21 +82,12 @@ export function externalIdFor(fileId: string | undefined, url: URL): string {
return id ? `ecourt:${id}` : `url:${url.href}`; return id ? `ecourt:${id}` : `url:${url.href}`;
} }
/** path วนกลับหลัง login (N8 D5) — ต้องขึ้นต้น "/" และไม่ใช่ "//" หรือ "/\" /** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — ใช้ที่เดียวคือ N8
* (กัน open-redirect: browser ตีความ "//evil.com" และ "/\evil.com" เป็น absolute * `registerExternalFile` (ลิงก์ e-Court — ทั้ง insert ครั้งแรกและ update เมื่อลิงก์
* URL ไป host อื่น) — ค่า next มาจาก query ที่ใครก็แก้ได้ ต้อง sanitize ก่อนใช้เสมอ; * รอบใหม่ส่ง typeId มา): trim → ว่าง = ไม่มีกลุ่ม (null), cap 200 ตัวอักษร
* ไม่ผ่านคืน null ให้ caller ใช้ "/" */ * (เทียบ title) ค่าที่ไม่ใช่ string (เช่น File จาก FormData) = null
export function sanitizeNextPath(s: string): string | null { * (responsive audit 2026-09-24: เดิมเป็นสองสำเนา mirroring กัน — normGroup ของ
if (!s.startsWith("/") || s.startsWith("//") || s.startsWith("/\\")) return null; * external-import + normTypeId ของ files.ts — เสี่ยง drift) */
return s;
}
/** รหัสกลุ่มเอกสาร (N9 `type_id`) normalize — single source ใช้ร่วมทั้ง N8
* `registerExternalFile` (ลิงก์ e-Court) และ N4 `uploadFile` (ช่องกลุ่มของฟอร์ม
* อัปโหลด): trim → ว่าง = ไม่มีกลุ่ม (null), cap 200 ตัวอักษร (เทียบ title)
* ค่าที่ไม่ใช่ string (เช่น File จาก FormData) = null (responsive audit 2026-09-24:
* เดิมเป็นสองสำเนา mirroring กัน — normGroup ของ external-import + normTypeId
* ของ files.ts — เสี่ยง drift) */
export function normDocumentGroup(raw: unknown): string | null { export function normDocumentGroup(raw: unknown): string | null {
if (typeof raw !== "string") return null; if (typeof raw !== "string") return null;
const v = raw.trim().slice(0, 200); const v = raw.trim().slice(0, 200);

View file

@ -1,53 +0,0 @@
// identity ของแอป: mock login e-Court หน้า /login — อ่านจาก cookie "oacCase"
// (userId = u_userid, userName = u_userlogin) แทน localStorage เดิม
// ไฟล์นี้เป็น pure module — middleware (edge) และ client import ร่วมได้ ห้ามใส่ next/headers
export type OacUser = { userId: string; userName: string };
export const OAC_CASE_COOKIE = "oacCase";
/** ผู้ใช้ mock หน้า /login (dropdown) — LoginForm (client) + action login (server)
* ใช้ร่วมกันผ่าน module นี้กันสองที่ drift; รหัสผ่านกลาง Demo1234 อยู่ใน action */
export const MOCK_USERS: readonly string[] = [
"user1",
"user2",
"user3",
"user4",
"user5",
];
/** แยกค่า query string ของ cookie oacCase → identity
* ขาด/ว่าง u_userid หรือ u_userlogin = ถือว่ายังไม่เข้าสู่ระบบ (null)
* หมายเหตุ: Next (cookies().get / req.cookies.get) decode ค่า cookie กลับมาให้แล้ว
* จึงส่งให้ URLSearchParams ตรง ๆ — ห้าม decodeURIComponent ซ้ำ */
export function parseOacCase(raw: string | null | undefined): OacUser | null {
if (!raw) return null;
const params = new URLSearchParams(raw);
const userId = params.get("u_userid")?.trim();
const userName = params.get("u_userlogin")?.trim();
if (!userId || !userName) return null;
return { userId, userName };
}
/** ค่า cookie oacCase ตาม format จริงของ e-Court (ฟิลด์อื่นคงค่าจากตัวอย่างจริง)
* mock login แทนเฉพาะ u_userid/u_userlogin */
export function buildOacCaseCookie(userId: string, userLogin: string): string {
return [
`u_userid=${userId}`,
`u_userlogin=${userLogin}`,
"u_courtid=4",
"u_password=",
"u_AdminStatus=",
"u_perCardNo=5000000000006",
"u_perCardNoEncript=1800650010005002003",
"u_userType=4",
"u_CidAuthen=NNNENNNNNNNNNNNNNNNN",
"u_hSecId=NNNNNNNNNNNNNNNNNNNN",
"u_username=",
"u_courtName=",
"u_AuditID=1995227",
"u_PsGroupNum=1",
"u_PsGroup=11",
"u_PsGroupName=",
].join("&");
}