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>
This commit is contained in:
parent
8ebe5f8644
commit
cffc571da5
21 changed files with 1725 additions and 124 deletions
263
scripts/make-ticket-docx.mjs
Normal file
263
scripts/make-ticket-docx.mjs
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
#!/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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
/** 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(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/&/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")}`);
|
||||
Loading…
Add table
Add a link
Reference in a new issue