eCourt-note/scripts/make-ticket-docx.mjs
Suphonchai Phoonsawat cffc571da5 feat(addin): implement e-Court ticket auth (ticket_data + HmacSha256)
Replace the token/owner pass-through with the ticket flow from
docs/requirement/addin-workflow.md (full plan: docs/PLAN-ADDIN-AUTH.md):

- utils/docver-ticket.ts: shared pure ticket parse + hex digest helpers,
  client/server inject their own crypto (Web Crypto / node:crypto)
- lib/addin-ticket.ts + POST /api/documents/token second entry: body
  {ticket_data, HmacSha256} verified server-side (HMAC timing-safe + exp,
  secret ADDIN_TICKET_SECRET -> SECRET -> "super_secret"); x-api-key path
  unchanged
- wordaddin: ticketService (Web Crypto HMAC, localStorage session
  wordaddin-auth keyed by fid, same-origin token request), authService
  rewrite (7-step flow, silent renew), apiClient 401 renew-retry once,
  metadataService reads ticket_data/HmacSha256, no-access messages per
  cause
- scripts/make-ticket-docx.mjs: dev tool to stamp tickets onto any .docx
  for testing in real Word
- app/wordaddin/error.tsx: error boundary showing the real error + stack
  in the pane (webviews have no devtools)
- tests: docver-ticket + addin-ticket unit suites (21 cases) + temporary
  crash repro suite for the ongoing client-crash debug session
- docs sync: PLAN-DOCVER, WORDADDIN-DEV, addin-ui-flow, openapi (two
  request bodies for /token), CLAUDE.md, .env.example

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-27 11:21:15 +07:00

263 lines
11 KiB
JavaScript

#!/usr/bin/env node
// PLAN-ADDIN-AUTH.md §9 — dev tool stamp ticket ของ e-Court (custom properties
// `ticket_data` + `HmacSha256`) ลงไฟล์ .docx เพื่อทดสอบ auth ของ add-in ใน Word จริง
// โดยไม่ต้องผ่านกลุ่ม template (TMPL_PATH) — คำนวณ HMAC ตรงกับตัว stamp จริง
// (app/api/template/render/route.ts: key เรียงตามตัวอักษรหลังเติม exp →
// HMAC-SHA256(secret, ticket_data ดิบ) เป็น hex lowercase)
//
// ใช้: node scripts/make-ticket-docx.mjs <file.docx> [options]
// --fid <id> default DEMO_FID_001
// --uid <id> default oom
// --uname <name> default "สรวิชญ์"
// --tid <id> template id (optional — ไม่ส่ง = ไม่มี tid ใน ticket)
// --exp <unix|+sec> default +300 (5 นาที เท่า TICKET_SEC ของตัว stamp)
// --secret <secret> default "super_secret" (ต้องตรง env ฝั่ง server/add-in)
// --tracked [id] stamp SavedByAddIn + DocumentTrackingId ด้วย (ทดสอบ phase tracking)
// --in-place เขียนทับไฟล์เดิม (ปกติเขียน <ชื่อเดิม>.ticket.docx)
// --list แค่อ่าน custom properties ที่มีในไฟล์แล้วออกมาดู
//
// รันใน repo เท่านั้น (import pizzip จาก node_modules) — ไม่ใช่ postinstall
import { createHmac, randomUUID } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import PizZip from "pizzip";
const CUSTOM_PATH = "docProps/custom.xml";
const CT_PATH = "[Content_Types].xml";
const RELS_PATH = "_rels/.rels";
const CUSTOM_FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}";
/** ต้องตรง metadataService.ADDIN_SIGNATURE_GUID ฝั่ง add-in */
const ADDIN_SIGNATURE_GUID = "9B7A3C51-4E6F-4D2A-8B5C-1E0F2A3B4C5D";
function usage(code = 1) {
console.log(`ใช้: node scripts/make-ticket-docx.mjs <file.docx> [options]
--fid <id> --uid <id> --uname <name> --tid <id>
--exp <unix|+seconds> --secret <secret>
--tracked [trackingId] --in-place --list`);
process.exit(code);
}
function parseArgs(argv) {
const options = {
file: null,
fid: "DEMO_FID_001",
uid: "oom",
uname: "สรวิชญ์",
tid: null,
exp: "+300",
secret: "super_secret",
tracked: false,
trackingId: null,
inPlace: false,
list: false,
};
for (let i = 0; i < argv.length; i++) {
const arg = argv[i];
const next = () => {
const value = argv[++i];
if (value === undefined) usage();
return value;
};
switch (arg) {
case "--fid": options.fid = next(); break;
case "--uid": options.uid = next(); break;
case "--uname": options.uname = next(); break;
case "--tid": options.tid = next(); break;
case "--exp": options.exp = next(); break;
case "--secret": options.secret = next(); break;
case "--tracked":
options.tracked = true;
if (argv[i + 1] && !argv[i + 1].startsWith("--")) options.trackingId = next();
break;
case "--in-place": options.inPlace = true; break;
case "--list": options.list = true; break;
case "--help": case "-h": usage(0); break;
default:
if (arg.startsWith("--")) usage();
if (options.file !== null) usage();
options.file = arg;
}
}
if (options.file === null) usage();
return options;
}
function resolveExp(raw) {
if (raw.startsWith("+")) {
const seconds = Number(raw.slice(1));
if (!Number.isFinite(seconds) || seconds <= 0) throw new Error("--exp +<วินาที> ไม่ถูกต้อง");
return Math.floor(Date.now() / 1000) + Math.floor(seconds);
}
const value = Number(raw);
if (!Number.isInteger(value) || value <= 0) throw new Error("--exp ต้องเป็น unix วินาที หรือ +<วินาที>");
return value;
}
function escapeXml(value) {
return String(value)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&apos;");
}
/** ticket_data ตามรูปตัว stamp จริง — key เรียงตามตัวอักษรหลังเติม exp */
function buildTicketData(options) {
const ticket = { exp: resolveExp(options.exp), fid: options.fid, uid: options.uid, uname: options.uname };
if (options.tid !== null) ticket.tid = options.tid;
const sorted = Object.keys(ticket)
.sort()
.reduce((result, key) => {
result[key] = ticket[key];
return result;
}, {});
return JSON.stringify(sorted);
}
function unescapeXml(value) {
return String(value)
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&amp;/g, "&");
}
/** ถอด <Property name value> ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */
function readProperties(xml) {
const properties = [];
const regex = /<Property\s+[^>]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|<Property\s+[^>]*name="([^"]*)"[^>]*\/>/g;
let match;
while ((match = regex.exec(xml)) !== null) {
const name = match[1] ?? match[3];
const inner = match[2] ?? "";
const value = inner.replace(/<[^>]+>/g, "");
properties.push({ name: unescapeXml(name), value: unescapeXml(value.trim()) });
}
return properties;
}
function usedPids(xml) {
const pids = new Set([1]);
const regex = /pid="(\d+)"/g;
let match;
while ((match = regex.exec(xml)) !== null) pids.add(Number(match[1]));
return pids;
}
function propertyXml(name, pid, value) {
return ` <Property fmtid="${CUSTOM_FMTID}" pid="${pid}" name="${escapeXml(name)}"><vt:lpwstr>${escapeXml(value)}</vt:lpwstr></Property>\n`;
}
/** upsert property ลง custom.xml (สร้างไฟล์ใหม่ถ้ายังไม่มี) — คืน XML ที่พร้อมเขียนกลับ zip */
function upsertProperties(existingXml, entries) {
// เอา Property เก่าของชื่อที่จะเขียนทิ้งก่อน (replace แบบตัวเอง)
let xml = existingXml;
for (const entry of entries) {
const removal = new RegExp(
`\\s*<Property\\s+[^>]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)</Property>`,
"g",
);
xml = xml.replace(removal, "");
}
const pids = usedPids(xml);
let nextPid = 2;
const takePid = () => {
while (pids.has(nextPid)) nextPid++;
pids.add(nextPid);
return nextPid++;
};
const block = entries.map((entry) => propertyXml(entry.name, takePid(), entry.value)).join("");
const hasRoot = xml.includes("</Properties>");
if (hasRoot) {
xml = xml.replace(/<\/Properties>/, `${block}</Properties>`);
} else {
xml =
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\n` +
`<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/custom-properties" xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">\n${block}</Properties>`;
}
return xml;
}
/** ผูก docProps/custom.xml เข้ากับแพ็กเกจ — Word ไม่เห็น part ที่ไม่มี relationship/content-type */
function ensurePartRegistered(zip) {
const ct = zip.file(CT_PATH);
if (ct && !ct.asText().includes('PartName="/docProps/custom.xml"')) {
const updated = ct
.asText()
.replace(
/<\/Types>/,
` <Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.document-properties.custom+xml"/>\n</Types>`,
);
zip.file(CT_PATH, updated);
}
const rels = zip.file(RELS_PATH);
if (rels && !rels.asText().includes('Target="docProps/custom.xml"')) {
const match = rels.asText().match(/Id="rId(\d+)"/g);
const maxId = match ? Math.max(...match.map((item) => Number(item.match(/\d+/)[0]))) : 0;
const updated = rels
.asText()
.replace(
/<\/Relationships>/,
` <Relationship Id="rId${maxId + 1}" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties" Target="docProps/custom.xml"/>\n</Relationships>`,
);
zip.file(RELS_PATH, updated);
}
}
// ---------------------------------------------------------------------------
const options = parseArgs(process.argv.slice(2));
const source = readFileSync(options.file);
if (source.subarray(0, 2).toString("latin1") !== "PK") {
console.error(`✗ ${options.file} ไม่ใช่ไฟล์ .docx (zip)`);
process.exit(1);
}
const zip = new PizZip(source);
if (options.list) {
const custom = zip.file(CUSTOM_PATH);
if (!custom) {
console.log(`(ไม่มี ${CUSTOM_PATH} ในไฟล์นี้)`);
} else {
for (const property of readProperties(custom.asText())) {
console.log(`${property.name} = ${property.value}`);
}
}
process.exit(0);
}
const ticketData = buildTicketData(options);
const hmac = createHmac("sha256", options.secret).update(ticketData, "utf8").digest("hex");
if (ticketData.length > 255) {
console.warn(`⚠ ticket_data ยาว ${ticketData.length} ตัวอักษร — เกินขีดจำกัด ~255 ของ Word custom property อาจถูกตัด`);
}
const entries = [
{ name: "ticket_data", value: ticketData },
{ name: "HmacSha256", value: hmac },
];
if (options.tracked) {
entries.push({ name: "SavedByAddIn", value: ADDIN_SIGNATURE_GUID });
entries.push({ name: "DocumentTrackingId", value: options.trackingId ?? randomUUID() });
}
const existing = zip.file(CUSTOM_PATH);
zip.file(CUSTOM_PATH, upsertProperties(existing ? existing.asText() : "", entries));
ensurePartRegistered(zip);
const output = zip.generate({ type: "nodebuffer", compression: "DEFLATE" });
const target = options.inPlace
? options.file
: `${options.file.replace(/\.docx$/i, "")}.ticket.docx`;
writeFileSync(target, output);
console.log(`✓ stamp ticket ลง ${path.relative(process.cwd(), target)}${options.inPlace ? " (ทับไฟล์เดิม)" : ""}`);
console.log(` ticket_data : ${ticketData}`);
console.log(` HmacSha256 : ${hmac}`);
console.log(` secret : ${options.secret}`);
console.log(` exp : ${new Date(resolveExp(options.exp) * 1000).toLocaleString("th-TH")}`);