264 lines
11 KiB
JavaScript
264 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, "&")
|
||
|
|
.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")}`);
|