eCourt-note/app/note/dev/page.tsx
Your Name a02e983ba8 feat(n11): add base64 document staging from e-Court
เพิ่มทางเข้าเอกสารสำหรับ e-Court ที่ไม่มี URL ให้ดึง — POST /api/note/files/stage
(x-api-key DOCVER_API_KEY) ส่งเนื้อไฟล์ PDF/รูปเป็น base64 ระบบเก็บใน RAM staging
(TTL sliding 8 ชม. + LRU + cap 100MB/ไฟล์ 256MB รวม — ห้ามเขียน disk/DB/MinIO
แม้แต่ไบต์เดียวตามโจทย์) ส่วน annotation/bookmark เก็บถาวรตามระบบเดิม:

- lib/staged-docs.ts: Map บน globalThis + sentinel "staged:<uuid>.<ext>" ใน
  pdf_url (ext จาก magic bytes — ไม่ต้อง migration) + hasStagedDoc แยกจาก getStagedDoc
- lib/ecourt-fid.ts: external_id เก็บ HMAC-SHA256(pepper, fid) แทน fid ดิบ
  (env FID_HASH_PEPPER fail-closed) + แปลง row เก่าด้วย npm run migrate:ecourt-fid
- route stage: อ่าน body แบบ stream นับไบต์ + idle 60 วิ/total 10 นาที = 408 +
  concurrency default 1 = 429 + base64 strict + magic bytes กำหนด ext เอง
- registerStagedFile: ลำดับกัน race 23505 — stage ใต้ id ก่อน insert โดนชนแล้ว
  re-stage ใต้ id ผู้ชนะ + try/finally คืน RAM ผู้แพ้ทุกทางออก
- ทางอ่าน 4 จุด: /api/note/pdf + export route (410 gate ไทย), resolvePublicPdfPath
  guard PdfPathError, PDFViewer โหลดผ่าน route + objectURL (gate ครบทุกชนิดไฟล์)
- session route (D5): ลิงก์ fid-only ตัดสิน staging ตั้งแต่แลก token — มี bytes
  ได้ fileId / หมดอายุ fileError "พ้นช่วงจัดเตรียม" / token ออกทุกกรณี
- Swagger UI: endpoint ใหม่ + คู่มือ retry (408/429/5xx ยิงซ้ำได้ stage ซ้ำปลอดภัย)
- MockStageForm บน /note/dev ทดสอบ 3 ขั้นในหน้าเดียว (ต้องถือ API key จริง)

Verify ครบตาม docs/PLAN-STAGED.md: tsc สะอาด + vitest 59 เคสใหม่ผ่าน + E2E
browser จริง (textLayer ไทยเรียงถูก / export ฝัง annotation / restart ได้
fileError ตั้งแต่ bootstrap / RAM 100MB: peak WS +580MB commit จริง +92MB) —
ผ่าน code review รอบ 7 แก้ครบทุก finding

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-11-05 12:53:56 +07:00

340 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 MockStageForm from "@/components/MockStageForm";
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>
{/* จำลอง e-Court แบบ base64 staging (N11 — คำขอผู้ใช้ 2026-11-04): ให้ dev
ฝั่ง e-Court กดทดสอบ contract ทั้ง 3 ขั้นในหน้าเดียว — stage → mint →
ลิงก์ fid-only · ต้องถือ 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 — ส่งเนื้อไฟล์เข้า staging (base64)
</h3>
<p className="mt-1.5 text-sm text-ink-soft">
สำหรับเอกสารที่ e-Court <strong className="font-medium">ไม่มี URL ให้ดึง</strong> —
เลือกไฟล์แล้วกดปุ่มเดียว ระบบจะทำตาม contract จริงทั้ง 3 ขั้น:{" "}
<Code>POST /api/note/files/stage</Code> (เนื้อไฟล์เป็น base64) →{" "}
<Code>POST /api/note/auth/token</Code> ด้วย <Code>fid</Code> เดิม → ลิงก์{" "}
<Code>/note?t=…</Code> (ไม่มี fileUrl) ให้กดเข้าอ่านได้เลย · ส่ง{" "}
<Code>fid</Code> เดิมซ้ำ = แทนที่เนื้อล่าสุด (annotation เดิมอยู่ครบ)
</p>
<div className="mt-4">
<MockStageForm />
</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>
</>
);
}