// dev-only (NOTE_SSO_DEV_MINT=1 + ไม่ใช่ production) — mint ลิงก์ SSO จากหน้า /note/dev // ให้ทดสอบ flow จริงจาก browser ได้โดยไม่ต้องรัน script: คำนวณ token/ticket ฝั่ง // server ด้วย secret ของตัวเอง (ไม่มีค่าใดหลุดไปหน้า public) แล้วคืน URL เต็มเดียว // พร้อมคลิก — ปิดอยู่ = 404 เหมือนไม่มีเส้นนี้ · ไม่ import @/db · ห้าม log ค่าดิบ // · GET ?file=<ชื่อไฟล์ตัวอย่าง>&mode=jwt|ticket (2026-10-30 รอบสอง) — ลิงก์ "A Link // Get(new)" ของ /note/dev: mint แล้ว 302 เข้า /note?fileUrl=…&t=… ให้ bootstrap // แลก token ต่อเหมือนลิงก์จริงของ e-Court (identity dev คงที่ + fid=dev-<ไฟล์>) import { createHmac } from "node:crypto"; import { NextResponse } from "next/server"; import { addinTicketSecret } from "@/lib/addin-ticket"; import { docverError } from "@/lib/document-versioning"; import { issueDocverToken } from "@/lib/docver-token"; import { devMintEnabled, LOGIN_TOKEN_TTL, parseDevLinkBody } from "@/lib/note-auth"; import { withBasePath } from "@/utils/base-path"; export const runtime = "nodejs"; export const dynamic = "force-dynamic"; /** identity dev คงที่ของลิงก์ตัวอย่าง — ตรงกับค่า default ของฟอร์ม mint หน้าเดียวกัน */ const DEV_USER = { userid: "oom", username: "สรวิชญ์" }; export async function GET(request: Request): Promise { 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>((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 { 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>((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" }, }); }