This commit is contained in:
oom 2026-11-02 10:00:19 +07:00
commit c9646eb02a
24 changed files with 1185 additions and 196 deletions

View file

@ -1,4 +1,4 @@
import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils';
import { generateHmacSha256, signEd25519, sortObjectKeys, verifyEd25519, verifyHmacSha256 } from '@/lib/lib-utils';
import createReport from 'docx-templates';
import PizzaZip from "pizzip";
import {
@ -1217,6 +1217,71 @@ export function getDocProps(
}
//**************************** document snapshot *******************************************
/** DocPropsResult หลังทำ JSON-safe (Date → ISO string) — ชนิดของ `properties` ใน
* DocumentExtractSnapshot ทั้งฝั่ง response ของ /api/template/extract และ column
* `fields` jsonb ของ document_versions (ค่าข้าม server→client เป็น string เสมอ) */
export interface DocPropsJson {
standard?: Record<string, string | undefined>;
extended?: Record<string, number | string | undefined>;
custom?: Record<string, string | number | boolean | null>;
}
/** รูป return ของ /api/template/extract — ใช้ร่วม 2 ที่: route นั้นคืนทั้งก้อนตรง ๆ
* และ POST /api/documents/versions เก็บทั้งก้อนเป็น fields ของ version (2026-10-30) */
export interface DocumentExtractSnapshot {
/** ค่า Content Control `{ [tag]: text }` */
cc: ExtractedData;
/** ข้อความใน bookmark `{ [name]: text }` */
bookmark: ExtractedData;
/** document properties (standard/extended/custom) */
properties: DocPropsJson;
/** ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (ฟอร์แมตไทย) + HmacSha256/Ed25519 เฉพาะเมื่อ
* ผู้เรียกส่ง secret/publicKeyHex มาให้ใช้ตรวจ */
verify: Record<string, unknown>;
}
/** แกะข้อมูลจาก docx ทั้งชุด `{cc, bookmark, properties, verify}` — logic ย้ายมาจาก
* app/api/template/extract/route.ts แบบเดิม (แถวต่อแถว) เพื่อให้ return ของ extract
* กับ fields ของ version ที่สร้างใหม่ตรงกันเป๊ะโดยการก่อสร้าง
* · secret/publicKeyHex = ของผู้เรียกเพื่อ "ทดสอบ verify" (extract) หรือ secret ของ
* add-in ticket ฝั่ง server (ตอนสร้าง version) — ไม่ส่ง = ไม่มี key นั้นใน verify
* · ticket_data ที่ไม่ใช่ JSON จะ throw ผู้เรียกจัดการเอง (extract = 500 / versions = 400) */
export function extractDocumentSnapshot(
docxBuffer: Buffer,
options: { secret?: string; publicKeyHex?: string } = {},
): DocumentExtractSnapshot {
const cc = extractContentControls(docxBuffer);
const bookmark = extractBookmark(docxBuffer);
// JSON-safe ทันที (Date → ISO string): response ของ extract กับ jsonb ของ version ได้
// ค่าชนิดเดียวกัน (NextResponse.json / drizzle serialize Date เป็น ISO อยู่แล้ว —
// ผลลัพธ์ JSON ไม่ต่างจากเดิม เปลี่ยนแค่ชนิดฝั่ง TS)
const properties = JSON.parse(JSON.stringify(getDocProps(docxBuffer))) as DocPropsJson;
const verify: Record<string, unknown> = {};
const ticket_data = properties.custom?.ticket_data;
const HmacSha256 = String(properties.custom?.HmacSha256);
const Ed25519 = String(properties.custom?.Ed25519);
if (ticket_data && typeof ticket_data === "string") {
const ticketDataObj = JSON.parse(ticket_data);
const unix_timestamp = Number(ticketDataObj.exp) * 1000;
if (unix_timestamp) {
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" });
}
if (options.secret && HmacSha256) {
verify['HmacSha256'] = verifyHmacSha256(ticket_data, options.secret, HmacSha256);
}
if (options.publicKeyHex && Ed25519) {
verify["Ed25519"] = verifyEd25519(ticket_data, Ed25519, options.publicKeyHex);
}
}
return { cc, bookmark, properties, verify };
}
//**************************** dynamic content *******************************************
// sdtLocked(ห้ามลบ CC), contentLocked(ลบได้ห้ามแก้), sdtContentLocked(ห้ามทุกอย่าง)
type LockType = 'none' | 'sdtLocked' | 'contentLocked' | 'sdtContentLocked';
@ -1311,4 +1376,60 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT
}
return templateBuffer
}
// ─── ticket stamp ของ GET /api/documents/latest (กลุ่ม docver) ────────────────
// recipe เดียวกับ applyTemplate ด้านบนแต่แยกเป็น fn เองเพราะที่มาของ ticket
// ต่างกัน: ของ route นั้นคือ query param จากผู้เรียก (ไม่ใช่ templateData.info)
// และห้ามกลืน error แบบ applyTemplate — การ stamp คือตัวประสงค์ของ endpoint
// ล้ม = throw ให้ route ตอบ 500 (คืนไฟล์ไม่มี stamp เงียบ ๆ = add-in เปิดไม่ได้)
/** เติม exp ใหม่ (now + ttlSeconds) + เรียง key ตามตัวอักษรแล้วคืน JSON string
* ที่จะ stamp — HMAC/ลายเซ็นต้องคำนวณบน string ดิบนี้เป๊ะ ๆ (ห้าม trim/normalize)
* · ตัด key ที่ค่า null/undefined ทิ้งก่อน เพราะ parser ฝั่ง redeem
* (parseDocverTicket ของ utils/docver-ticket.ts) ไม่ยอม tid:null (ต้องเป็น
* string หรือไม่มี key เลย) — ตัวรับเข้าของ route ใช้ null แทน "ไม่มี" */
export function buildStampedTicketData(
ticket: Record<string, unknown>,
ttlSeconds: number,
nowMs: number = Date.now(),
): string {
const withoutNulls = Object.fromEntries(
Object.entries(ticket).filter(([, value]) => value !== null && value !== undefined),
);
// หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms)
const expireTimestamp = Math.floor(nowMs / 1000) + ttlSeconds;
const fullObj = { ...withoutNulls, exp: expireTimestamp };
const sortedObj = sortObjectKeys(fullObj);
return JSON.stringify(sortedObj);
}
export type StampTicketOptions = {
/** secret ของ HMAC — ฝั่ง caller ของกลุ่ม docver ใช้ addinTicketSecret() เพื่อ
* ตรงกับฝั่ง verify ของ add-in / POST /api/documents/token ทาง ticket */
secret: string;
/** hex ของ private key (seed 32 หรือ 64 byte) — ไม่ตั้ง = ไม่ stamp Ed25519
* (chain ของ add-in verify แค่ HmacSha256 — Ed25519 เป็นข้อมูลเสริม) */
privateKeyHex?: string | null;
};
/** stamp custom properties `ticket_data`/`HmacSha256`(+`Ed25519`) ลงสำเนาในหน่วย
* ความจำของ .docx — setCustomDocProps คืน Buffer ใหม่ ไม่แตะไฟล์ต้นทาง
* (non-destructive) และ upsert ตามชื่อ จึงเขียนทับ stamp เดิมของ template ได้ ·
* ล้มทุกกรณี = throw */
export function stampTicketProps(
docxBuffer: Buffer,
ticketData: string,
options: StampTicketOptions,
): { buffer: Buffer; hmacSha256: string; ed25519: string | null } {
const hmacSha256 = generateHmacSha256(ticketData, options.secret);
const privateKeyHex = options.privateKeyHex?.trim() || null;
const ed25519 = privateKeyHex !== null ? signEd25519(ticketData, privateKeyHex) : null;
const buffer = setCustomDocProps(
docxBuffer,
ed25519 === null
? { ticket_data: ticketData, HmacSha256: hmacSha256 }
: { ticket_data: ticketData, HmacSha256: hmacSha256, Ed25519: ed25519 },
);
return { buffer, hmacSha256, ed25519 };
}