Merge branch 'forgejo-dev' into working
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Resolve CLAUDE.md: คำอธิบาย lib/document-versioning.ts เอาของ forgejo-dev (field `fields` ถอดออก 2026-10-30) ส่วน lib/docver-token.ts เอาของ working (purpose login / verifyDocverLoginToken — โค้ดฝั่ง forgejo-dev มีครบแล้ว แค่คำอธิบายล้าหลัง) Verify: npx tsc --noEmit ผ่าน · vitest 23 files / 338 tests ผ่าน Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
commit
a288e5643c
31 changed files with 1445 additions and 278 deletions
|
|
@ -25,12 +25,13 @@ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|||
|
||||
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
|
||||
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
|
||||
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
|
||||
* ผ่าน lib/docver-token.ts · `fields` ถอดออก 2026-10-30 — server แกะจากไฟล์ .docx
|
||||
* ด้วย extractContentControls ของ lib/lib-tmpl.ts ตัวเดียวกับ /api/template/extract
|
||||
* ส่ง field เก่ามา = 400 ตาม convention) */
|
||||
const FORM_FIELDS = [
|
||||
"file",
|
||||
"pdf",
|
||||
"clientVersionId",
|
||||
"fields",
|
||||
"message",
|
||||
"fileName",
|
||||
] as const;
|
||||
|
|
@ -77,6 +78,17 @@ export function parseDownloadFormat(raw: string | null):
|
|||
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
|
||||
}
|
||||
|
||||
/** ?type= ของ GET /api/documents/latest — บังคับระบุต่างจาก format ของ
|
||||
* download-link (ไม่มีค่า default): type=docx เดินไปทาง stamping ซึ่งเปลี่ยน
|
||||
* ไฟล์ที่คืน จึงอย่าเดาแทนผู้เรียก */
|
||||
export function parseLatestTypeParam(raw: string | null):
|
||||
| { ok: true; type: "docx" | "pdf" }
|
||||
| DocverError {
|
||||
const type = raw?.trim() ?? "";
|
||||
if (type === "docx" || type === "pdf") return { ok: true, type };
|
||||
return { ok: false, error: "type รับค่า docx หรือ pdf เท่านั้น" };
|
||||
}
|
||||
|
||||
function trimOrNull(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed === "" ? null : trimmed;
|
||||
|
|
@ -108,39 +120,10 @@ export function sanitizeFilePartName(raw: string): string | null {
|
|||
return sanitizeDisplayFileName(decodeUploadFilename(raw));
|
||||
}
|
||||
|
||||
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
|
||||
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
|
||||
export function parseFieldsJson(raw: string):
|
||||
| { ok: true; fields: Record<string, string> | null }
|
||||
| DocverError {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
||||
}
|
||||
const record = parsed as Record<string, unknown>;
|
||||
const keys = Object.keys(record);
|
||||
// เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้:
|
||||
// เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb
|
||||
// ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ)
|
||||
if (keys.length > 1_000) {
|
||||
return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' };
|
||||
}
|
||||
for (const key of keys) {
|
||||
if (key.length > 200) {
|
||||
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
|
||||
}
|
||||
const value = record[key];
|
||||
if (typeof value !== "string") {
|
||||
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
|
||||
}
|
||||
}
|
||||
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
||||
}
|
||||
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } — ถอดออกแล้ว
|
||||
* (2026-10-30): form field "fields" ยกเลิก ไม่รับจากผู้เรียกอีก — fields ของ version
|
||||
* แกะจากไฟล์ .docx ที่อัปโหลดฝั่ง server ด้วย extractContentControls ของ
|
||||
* lib/lib-tmpl.ts (ตัวเดียวกับ /api/template/extract) ที่ route ของ POST versions */
|
||||
|
||||
export type ParsedVersionUploadForm = {
|
||||
clientVersionId: string | null;
|
||||
|
|
@ -148,7 +131,6 @@ export type ParsedVersionUploadForm = {
|
|||
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
||||
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
||||
fileName: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
};
|
||||
|
||||
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
||||
|
|
@ -180,15 +162,6 @@ export function parseVersionUploadForm(form: FormData):
|
|||
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
|
||||
}
|
||||
|
||||
const rawFields = formString(form, "fields");
|
||||
if (!rawFields.ok) return rawFields;
|
||||
let fields: Record<string, string> | null = null;
|
||||
if (rawFields.value !== null) {
|
||||
const parsed = parseFieldsJson(rawFields.value);
|
||||
if (!parsed.ok) return parsed;
|
||||
fields = parsed.fields;
|
||||
}
|
||||
|
||||
const rawFileName = formString(form, "fileName");
|
||||
if (!rawFileName.ok) return rawFileName;
|
||||
|
||||
|
|
@ -198,7 +171,6 @@ export function parseVersionUploadForm(form: FormData):
|
|||
clientVersionId: clientVersionId.value,
|
||||
message: message.value,
|
||||
fileName: sanitizeDisplayFileName(rawFileName.value),
|
||||
fields,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,10 +13,12 @@ import {
|
|||
documents,
|
||||
documentVersions,
|
||||
type DocumentRow,
|
||||
type DocumentVersionFields,
|
||||
type DocumentVersionRow,
|
||||
} from "@/db/schema";
|
||||
import {
|
||||
contentDispositionValue,
|
||||
DOCVER_MAX_UPLOAD_BYTES,
|
||||
DOCX_CONTENT_TYPE,
|
||||
PDF_CONTENT_TYPE,
|
||||
} from "@/lib/document-versioning";
|
||||
|
|
@ -176,6 +178,35 @@ export async function presignVersionUrl(
|
|||
};
|
||||
}
|
||||
|
||||
/** อ่านไบต์ของ object หนึ่งกลับมาเป็น Buffer — ใช้กับ GET /api/documents/latest
|
||||
* ที่ต้องคืนไฟล์ผ่าน response เอง (ไม่ใช่ presigned URL) · อ่านอย่างเดียว ไม่เขียน
|
||||
* กลับ: การ stamp ticket ของ route ทำบนสำเนอในหน่วยความจำ · กันขนาดเกิน cap ของ
|
||||
* กลุ่ม (object เขียนผ่านกลุ่มนี้เองจึงไม่ควรเกิน — เกิน = ข้อมูลแปลกปลอม อ่านต่อไม่ได้)
|
||||
* · ล้มทุกกรณี = throw ให้ route catch เป็น 500 */
|
||||
export async function getVersionObjectBytes(
|
||||
versionId: string,
|
||||
format: "docx" | "pdf",
|
||||
): Promise<Buffer> {
|
||||
const { client, config } = getClient();
|
||||
const key = versionObjectKeys(versionId)[format];
|
||||
const stream = await client.getObject(config.bucket, key);
|
||||
return await new Promise<Buffer>((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let total = 0;
|
||||
stream.on("data", (chunk: Buffer) => {
|
||||
total += chunk.length;
|
||||
if (total > DOCVER_MAX_UPLOAD_BYTES) {
|
||||
stream.destroy();
|
||||
reject(new Error(`docver: object ${key} ใหญ่เกิน ${DOCVER_MAX_UPLOAD_BYTES} bytes`));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
stream.on("end", () => resolve(Buffer.concat(chunks)));
|
||||
stream.on("error", reject);
|
||||
});
|
||||
}
|
||||
|
||||
// ─── DB ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
|
||||
|
|
@ -206,6 +237,21 @@ export async function findVersionForDoc(
|
|||
return row?.version ?? null;
|
||||
}
|
||||
|
||||
/** version ล่าสุดของเอกสาร (docId) — join แบบ findVersionForDoc + เรียงเลข version
|
||||
* จากมากไปน้อยแล้วเอาตัวแรก · ใช้กับ GET /api/documents/latest */
|
||||
export async function findLatestVersionForDoc(
|
||||
docId: string,
|
||||
): Promise<DocumentVersionRow | null> {
|
||||
const [row] = await db
|
||||
.select({ version: documentVersions })
|
||||
.from(documentVersions)
|
||||
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
|
||||
.where(eq(documents.docId, docId))
|
||||
.orderBy(desc(documentVersions.versionNumber))
|
||||
.limit(1);
|
||||
return row?.version ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน
|
||||
* "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7:
|
||||
|
|
@ -229,7 +275,8 @@ export type InsertVersionInput = {
|
|||
clientVersionId: string | null;
|
||||
contentHash: string;
|
||||
message: string | null;
|
||||
fields: Record<string, string> | null;
|
||||
/** ก้อนเดียวกับ return ของ /api/template/extract (ดู DocumentVersionFields ใน db/schema.ts) */
|
||||
fields: DocumentVersionFields | null;
|
||||
templateId: string | null;
|
||||
fileName: string | null;
|
||||
docxObjectKey: string;
|
||||
|
|
|
|||
|
|
@ -186,6 +186,32 @@ export function requireDocverApiKey(request: Request): Response | null {
|
|||
: docverError("API key ไม่ถูกต้อง", 401);
|
||||
}
|
||||
|
||||
/** auth ของ GET /api/documents/latest — x-api-key เทียบ env DOCVER_API_KEY
|
||||
* หรือ EXTERNAL_API_KEY (ตัวใดตัวหนึ่งตรง = ผ่าน — ผู้เรียกเป็น backend e-Court
|
||||
* ที่ถือ key สาย N10 หรือสาย docver ก็ได้ ต่างจาก /token ที่รับ DOCVER_API_KEY
|
||||
* อย่างเดียว) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ข้อความ 401 ของ "ไม่ใส่
|
||||
* key" กับ "key ผิด" เป็นอันเดียวกันเหมือน requireDocverApiKey
|
||||
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
||||
export function requireDocumentApiKey(request: Request): Response | null {
|
||||
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
||||
const envKeys = [process.env.DOCVER_API_KEY, process.env.EXTERNAL_API_KEY]
|
||||
.map((value) => value?.trim())
|
||||
.filter((value): value is string => value !== undefined && value !== "");
|
||||
if (envKeys.length === 0) {
|
||||
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
||||
}
|
||||
const provided = request.headers.get("x-api-key");
|
||||
if (provided === null || provided === "") {
|
||||
return docverError("API key ไม่ถูกต้อง", 401);
|
||||
}
|
||||
const providedHash = createHash("sha256").update(provided).digest();
|
||||
const matched = envKeys.some((envKey) => {
|
||||
const envHash = createHash("sha256").update(envKey).digest();
|
||||
return timingSafeEqual(providedHash, envHash);
|
||||
});
|
||||
return matched ? null : docverError("API key ไม่ถูกต้อง", 401);
|
||||
}
|
||||
|
||||
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
|
||||
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
|
||||
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
|
||||
|
|
|
|||
127
lib/lib-tmpl.ts
127
lib/lib-tmpl.ts
|
|
@ -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';
|
||||
|
|
@ -1301,14 +1366,70 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT
|
|||
|
||||
// Apply Dynamic Content
|
||||
if (templateData?.dc && typeof templateData?.dc === "object") {
|
||||
console.log("Apply Dynamic Content")
|
||||
// console.log("Apply Dynamic Content")
|
||||
templateBuffer = Buffer.from(await templateDynamicContents(templateBuffer, templateData.dc))
|
||||
}
|
||||
// Apply Content Control
|
||||
if (templateData?.cc && typeof templateData?.cc === "object") {
|
||||
console.log("Apply Content Control")
|
||||
// console.log("Apply Content Control")
|
||||
templateBuffer = templateContentControls(templateBuffer, templateData.cc);
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
|
@ -3,8 +3,17 @@ import { createHmac, createPrivateKey, createPublicKey, timingSafeEqual, verify,
|
|||
/**
|
||||
* คำนวณ HMAC-SHA256 จาก string หรือ JSON Object
|
||||
*/
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับลงนามดิจิทัล (Sign/hash) ข้อมูลด้วย HMAC-SHA256 secret
|
||||
* @param data ข้อความดิบที่ต้องการเซ็น (string หรือ object)
|
||||
* @param secretKey คีย์ลับในรูปแบบ string
|
||||
* @returns ลายเซ็น (Signature/hash) ในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||
*/
|
||||
|
||||
|
||||
export function generateHmacSha256(data: string | object, secretKey: string): string {
|
||||
const payload = typeof data === 'string' ? data : JSON.stringify(data);
|
||||
const payload = typeof data === 'string' ? data:JSON.stringify(sortObjectKeys(data as Record<string, unknown>));
|
||||
return createHmac('sha256', secretKey)
|
||||
.update(payload, 'utf8')
|
||||
.digest('hex');
|
||||
|
|
@ -13,6 +22,16 @@ export function generateHmacSha256(data: string | object, secretKey: string): st
|
|||
/**
|
||||
* ตรวจสอบความถูกต้องของ HMAC (ใช้ timingSafeEqual เพื่อป้องกัน Timing Attacks)
|
||||
*/
|
||||
|
||||
/**
|
||||
* ฟังก์ชันสำหรับตรวจสอบลายเซ็น (Verify) ด้วย Ed25519 Public Key (Hex)
|
||||
* @param data ข้อความดิบที่ใช้ในการเซ็น (string หรือ Buffer)
|
||||
* @param secretKey secret ที่นำมาใช้ตรวจสอบ
|
||||
* @param expectedHmac ลายเซ็นในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
|
||||
*/
|
||||
|
||||
|
||||
export function verifyHmacSha256(data: string | object, secretKey: string, expectedHmac: string): boolean {
|
||||
const calculatedHmac = generateHmacSha256(data, secretKey);
|
||||
const a = Buffer.from(calculatedHmac, 'hex');
|
||||
|
|
|
|||
161
lib/openapi.ts
161
lib/openapi.ts
|
|
@ -363,7 +363,9 @@ export const openApiSpec = {
|
|||
tags: ["template"],
|
||||
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง",
|
||||
description:
|
||||
"รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production",
|
||||
"รับค่า fid, tid, uid, uname จาก Query Parameter "+
|
||||
"ตรวจสอบรุ่นล่าสุดใน /api/document/latest ถ้าไม่พบ ต้องหาจากฐานข้อมูลแล้วส่งไปประมวลผลต่อที่ /api/template/generate "+
|
||||
"เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนาเท่านั้น ไม่มีการตรวจสอบ user session ต้องนำออกก่อน production",
|
||||
parameters: [
|
||||
{
|
||||
name: "fid",
|
||||
|
|
@ -462,9 +464,10 @@ export const openApiSpec = {
|
|||
tags: ["template"],
|
||||
summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน HTML Form / Multipart Form Data (POST) เป็นตัวอย่างเท่านั้น จะถูกเอาออกในภายหลัง",
|
||||
description:
|
||||
"รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded อ่านข้อมูล JSON และประมวลผลสร้างไฟล์ DOCX stream คืนกลับไป "+
|
||||
"จะเรียก generate ในเบื้องหลัง ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production",
|
||||
requestBody: {
|
||||
"รับค่า fid, tid, uid, uname จาก Multipart Form Data หรือ Form-urlencoded "+
|
||||
"ตรวจสอบรุ่นล่าสุดใน /api/document/latest ถ้าไม่พบ ต้องหาจากฐานข้อมูลแล้วส่งไปประมวลผลต่อที่ /api/template/generate "+
|
||||
"เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนาเท่านั้น ไม่มีการตรวจสอบ user session ต้องนำออกก่อน production",
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
"multipart/form-data": {
|
||||
|
|
@ -774,7 +777,11 @@ export const openApiSpec = {
|
|||
"- `versionNumber` ใน response คือเลขจริงที่ server จ่าย (ปลอดภัยแม้อัปโหลดพร้อมกัน " +
|
||||
"หลาย client) — ถ้าต่างจากที่ client คาดไว้ ให้ใช้เลขจาก response เสมอ\n" +
|
||||
"- ผู้อัปโหลด (userId/userName) และ templateId อ่านจาก token — " +
|
||||
"**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**",
|
||||
"**ห้ามส่ง field เหล่านี้ใน form (ส่งมา = 400)**\n" +
|
||||
"- `fields` ของ version ถูก server แกะจากไฟล์ .docx ที่อัปโหลดเอง เก็บ**ทั้งก้อนเหมือน " +
|
||||
"return ของ `/api/template/extract`** (`{cc, bookmark, properties, verify}` — ผ่าน helper " +
|
||||
"ตัวเดียวกัน) โดย `verify.HmacSha256` ตรวจด้วย secret ของ ticket ฝั่ง server — " +
|
||||
"**ห้ามส่ง field `fields` ใน form (ส่งมา = 400)**",
|
||||
security: [{ DocverBearer: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
|
|
@ -799,13 +806,6 @@ export const openApiSpec = {
|
|||
type: "string",
|
||||
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
|
||||
},
|
||||
fields: {
|
||||
type: "string",
|
||||
description:
|
||||
'JSON string ของค่า Content Control **ทุกตัว** ในเอกสาร รูปแบบ ' +
|
||||
'`{ [tag]: ข้อความ }` (add-in อ่านมาตอนบันทึก version) ' +
|
||||
'เช่น {"blackno":"คดีหมายเลขดำที่","detail1":"…"}',
|
||||
},
|
||||
message: {
|
||||
type: "string",
|
||||
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
|
||||
|
|
@ -930,6 +930,100 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
"/api/documents/latest": {
|
||||
get: {
|
||||
tags: ["documents"],
|
||||
summary:
|
||||
"ดาวน์โหลดไฟล์ของ version ล่าสุด (docx พร้อม stamp ticket ใหม่ / pdf ตรง ๆ)",
|
||||
description:
|
||||
"ให้ backend e-Court ดึงไฟล์ **version ล่าสุด** ของเอกสารกลับไปเปิดให้ผู้ใช้ " +
|
||||
"(server-to-server — ไม่ใช้ Bearer token)\n\n" +
|
||||
"- **auth**: header `x-api-key` เทียบ env `DOCVER_API_KEY` **หรือ** " +
|
||||
"`EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง (ต่างจาก /token ที่รับ DOCVER_API_KEY " +
|
||||
"อย่างเดียว) · ทั้งคู่ไม่ตั้ง = 401 fail closed\n" +
|
||||
"- เอกสารถูกระบุด้วย `fid` **ใน ticket_data** (ไม่ใช่ token)\n" +
|
||||
"- `type=pdf` → คืนไบต์ pdf ตรง ๆ (version ล่าสุดไม่มีสำเนา PDF = 404)\n" +
|
||||
"- `type=docx` → server เติม `exp` ใหม่ (now + `TICKET_SEC` default 300 วิ) + " +
|
||||
"คำนวณ `HmacSha256` (secret ตาม chain `ADDIN_TICKET_SECRET` → `SECRET` → " +
|
||||
"`\"super_secret\"` ให้ตรงฝั่ง verify ของ add-in) และ `Ed25519` (เฉพาะเมื่อ " +
|
||||
"`PRIVATE_KEY_HEX` ตั้ง) แล้วเขียน custom properties ลง**สำเนาในหน่วยความจำ** " +
|
||||
"ก่อนคืนไฟล์ — object ใน MinIO ไม่ถูกแต่ (non-destructive) และเขียนทับ stamp " +
|
||||
"เดิมของ template ได้\n" +
|
||||
"- **ticket_data บังคับ `fid` + `uid` + `uname`** (`tid` optional · `exp` ถ้าส่ง " +
|
||||
"ต้องเป็นจำนวนเต็ม แต่จะถูกเติมใหม่เสมอ) เพราะ parser ฝั่ง add-in บังคับทั้งสาม — " +
|
||||
"⚠️ `uid` ต้องเป็น*รหัส*ผู้ใช้ (ไม่ใช่ชื่อ) และต้องส่ง `uname` (ชื่อแสดงผล)\n" +
|
||||
"- ticket_data หลังเติม exp ยาวเกิน 255 ตัวอักษร = 400 (ข้อจำกัด custom property " +
|
||||
"ของ Word)",
|
||||
security: [{ ApiKeyAuth: [] }],
|
||||
parameters: [
|
||||
{
|
||||
name: "type",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string", enum: ["docx", "pdf"] },
|
||||
description: "ชนิดไฟล์ที่จะดาวน์โหลด (บังคับระบุ — ไม่มีค่า default)",
|
||||
},
|
||||
{
|
||||
name: "ticket_data",
|
||||
in: "query",
|
||||
required: true,
|
||||
schema: { type: "string" },
|
||||
description:
|
||||
"JSON string ของ ticket เช่น " +
|
||||
"`{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}` " +
|
||||
"(ต้อง URL-encode — ใช้ URLSearchParams ของฝั่งเรียกก็ได้)",
|
||||
example:
|
||||
"{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}",
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: {
|
||||
description:
|
||||
"ไบต์ของไฟล์ (docx = ผ่านการ stamp ticket ใหม่ · pdf = ตรงจาก version ล่าสุด)",
|
||||
headers: {
|
||||
"Content-Disposition": {
|
||||
description: "attachment พร้อมชื่อไฟล์ของ version (ชื่อไทยผ่าน filename*)",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
"Cache-Control": {
|
||||
description: "no-store — ไฟล์ผูกกับ ticket/exp ของ request นั้น",
|
||||
schema: { type: "string" },
|
||||
},
|
||||
},
|
||||
content: {
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
"application/pdf": {
|
||||
schema: { type: "string", format: "binary" },
|
||||
},
|
||||
},
|
||||
},
|
||||
400: { $ref: "#/components/responses/BadRequest" },
|
||||
401: {
|
||||
description:
|
||||
"x-api-key ไม่ตรงทั้ง DOCVER_API_KEY และ EXTERNAL_API_KEY หรือทั้งคู่ไม่ตั้งค่า " +
|
||||
"(ไม่ใส่ header กับ key ผิดได้ข้อความเดียวกัน)",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
404: {
|
||||
description:
|
||||
"ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version) หรือ type=pdf แต่ version " +
|
||||
"ล่าสุดไม่มีสำเนา PDF",
|
||||
content: {
|
||||
"application/json": {
|
||||
schema: { $ref: "#/components/schemas/Error" },
|
||||
},
|
||||
},
|
||||
},
|
||||
500: { $ref: "#/components/responses/ServerError" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
components: {
|
||||
securitySchemes: {
|
||||
|
|
@ -940,7 +1034,8 @@ export const openApiSpec = {
|
|||
description:
|
||||
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
|
||||
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
|
||||
"request ของกลุ่มที่ key ตรง)",
|
||||
"request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " +
|
||||
"(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
|
||||
},
|
||||
DocverBearer: {
|
||||
type: "http",
|
||||
|
|
@ -1235,6 +1330,40 @@ export const openApiSpec = {
|
|||
},
|
||||
},
|
||||
},
|
||||
ExtractSnapshot: {
|
||||
type: "object",
|
||||
description:
|
||||
"ก้อนข้อมูลแกะจากไฟล์ .docx — รูปเดียวกับ return ของ POST /api/template/extract " +
|
||||
"(ใช้ helper ตัวเดียวกันใน lib/lib-tmpl.ts)",
|
||||
properties: {
|
||||
cc: {
|
||||
type: "object",
|
||||
description: "ค่า Content Control ทุกตัว รูปแบบ `{ [tag]: ข้อความ }`",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
bookmark: {
|
||||
type: "object",
|
||||
description: "ข้อความใน bookmark รูปแบบ `{ [ชื่อ bookmark]: ข้อความ }`",
|
||||
additionalProperties: { type: "string" },
|
||||
},
|
||||
properties: {
|
||||
type: "object",
|
||||
description: "document properties — standard (core) / extended (app) / custom",
|
||||
properties: {
|
||||
standard: { type: "object", additionalProperties: { type: "string" } },
|
||||
extended: { type: "object", additionalProperties: { type: "string" } },
|
||||
custom: { type: "object", additionalProperties: {} },
|
||||
},
|
||||
},
|
||||
verify: {
|
||||
type: "object",
|
||||
description:
|
||||
"ผลตรวจ ticket ที่ stamp มากับไฟล์ — exp (วันที่ไทย +07:00) และ HmacSha256 " +
|
||||
"(boolean — ตรวจด้วย secret ของ ticket ฝั่ง server)",
|
||||
additionalProperties: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
VersionRecord: {
|
||||
type: "object",
|
||||
description: "รายการ version หนึ่งตัว (GET /versions คืนเป็น array ใหม่ → เก่า)",
|
||||
|
|
@ -1249,11 +1378,11 @@ export const openApiSpec = {
|
|||
description: "SHA-256 hex ของไฟล์ .docx (คำนวณฝั่ง server เสมอ)",
|
||||
},
|
||||
fields: {
|
||||
type: "object",
|
||||
nullable: true,
|
||||
description:
|
||||
"ค่า Content Control ทุกตัวตอนอัปโหลด รูปแบบ `{ [tag]: ข้อความ }` (ไม่ส่ง = null)",
|
||||
additionalProperties: { type: "string" },
|
||||
"snapshot ตอนอัปโหลด — **ก้อนเดียวกับ return ของ /api/template/extract** (server " +
|
||||
"แกะจากไฟล์ .docx เอง; null = version เก่าก่อน 2026-10-30)",
|
||||
allOf: [{ $ref: "#/components/schemas/ExtractSnapshot" }],
|
||||
},
|
||||
message: { type: "string", nullable: true, description: "commit message" },
|
||||
hasPdf: { type: "boolean" },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue