#!/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 [options] // --fid default DEMO_FID_001 // --uid default oom // --uname default "สรวิชญ์" // --tid template id (optional — ไม่ส่ง = ไม่มี tid ใน ticket) // --exp default +300 (5 นาที เท่า TICKET_SEC ของตัว stamp) // --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 [options] --fid --uid --uname --tid --exp --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, "'"); } /** 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, "&"); } /** ถอด ทั้งชุดจาก custom.xml — ไว้ --list และหา pid ที่ใช้แล้ว */ function readProperties(xml) { const properties = []; const regex = /]*name="([^"]*)"[^>]*>([\s\S]*?)<\/Property>|]*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 ` ${escapeXml(value)}\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*]*name="${escapeXml(entry.name).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"[^>]*>([\\s\\S]*?)`, "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(""); if (hasRoot) { xml = xml.replace(/<\/Properties>/, `${block}`); } else { xml = `\n` + `\n${block}`; } 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>/, ` \n`, ); 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>/, ` \n`, ); 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")}`);