diff --git a/.env.example b/.env.example index d9988b6..7af635b 100644 --- a/.env.example +++ b/.env.example @@ -6,6 +6,22 @@ DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:54322/ecourt-note # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # EXTERNAL_API_KEY= +# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template +DOCEDIT_BASE_URL=http://192.168.2.51:3000/docedit +# สำหรับตัวอย่างระบบนอกเรียกใช้ API ของ template +EXTERNAL_API_KEY='secret-api-key-1234' +# ที่เก็บไฟล์เทมเพลต(docx) +TMPL_PATH='./public/tmpl' + +# Sign/Hash ที่ใช้ฝังในเอกสาร(docx) +SECRET="super_secret" +PRIVATE_KEY_HEX="8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1" +PUBLIC_KEY_HEX="66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546" +# Optional เวลาหมดอายุที่ฝังไว้ในข้อมูล (ticket_data) +TICKET_SEC=300 + + + # MinIO ของ API เอกสาร versioning (docs/PLAN-DOCVER.md §6) — ค่านี้ตรง docker-compose.yml # (MINIO_ENDPOINT/MINIO_ACCESS_KEY/MINIO_SECRET_KEY ไม่ตั้ง = ทุก endpoint ของกลุ่มนี้ตอบ 500) # หมายเหตุ: พอร์ตโฮสต์เป็น 19000 เพราะ 9000/9001 ชนกับโปรเจกต์อื่นในเครื่อง dev diff --git a/app/api/documents/token/route.ts b/app/api/documents/token/route.ts index 7593e4f..07cd544 100644 --- a/app/api/documents/token/route.ts +++ b/app/api/documents/token/route.ts @@ -29,11 +29,15 @@ export async function POST(request: Request) { // ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret) const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); + // const secret = + // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; + if (secret === undefined || secret === "") { return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500); } const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS); - const ttlSeconds = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; + const ttlSeconds = + Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600; const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds); return new Response( diff --git a/app/api/template/download-docx/route.ts b/app/api/template/download-docx/route.ts new file mode 100644 index 0000000..13f8725 --- /dev/null +++ b/app/api/template/download-docx/route.ts @@ -0,0 +1,108 @@ +// ตัวอย่างรองรับค่าจาก Frontend ที่เป็น Vanilla JavaScript และ Classic Framework +// โดย ผ่าน Form หรือ การกดลิงค์(GET) เปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ +// จะมีข้อมูล info.ticket_data ที่สมบูรณ์แล้ว({fid,tid,uid,uname})แล้วเเรียก /api/template/generate เพื่อสร้างเอกสาร docx +import { NextRequest, NextResponse } from 'next/server'; +import path from 'path'; +import fs from 'fs/promises'; + +const DOCEDIT_BASE_URL = process.env.DOCEDIT_BASE_URL??""; +const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY??""; +const TMPL_PATH = process.env.TMPL_PATH??""; +if (!DOCEDIT_BASE_URL||!EXTERNAL_API_KEY||!TMPL_PATH) { + console.error('Missing variable DOCEDIT_BASE_URL, EXTERNAL_API_KEY or TMPL_PATH') +} + + +async function processRequest(fid: string,tid:string,uid:string,uname:string):Promise> +{ + // console.log("processRequest get:",fid,tid,uid,uname) + // console.log(process.cwd()) + const filePath = path.join(TMPL_PATH,`${fid}.json`); + try { + if (!fid||!tid||!uid||!uname) { + return NextResponse.json({ error: 'Missing fid, tid, uid or uname' }, { status: 400 }); + } + + // อ่านไฟล์ JSON จำลองการอ่านจากฐานข้อมูล + const rawJson = await fs.readFile(filePath, 'utf-8'); + const jsonData = JSON.parse(rawJson); + if (!jsonData || typeof jsonData !== 'object' ) { + return NextResponse.json({error:'Invalid data format'}, { status: 400 }); + } + + //!! ตรวจสอบสิทธิ์ผู้ใช้ uid ว่ามีสิทธิ์เข้าถึงไฟล์ fid หรือไม่เพื่อกันการการยิง API ตรงๆ ไม่ผ่าน Frontend!! + + // ข้อมูลจำเป็นต้องมี prop นี้เสมอถ้าไม่มีให้สร้าง jsonData.info.ticket_data + if(!Object.hasOwn(jsonData, 'info')) + jsonData['info']={} + jsonData.info['ticket_data'] = JSON.stringify({fid,tid,uid,uname}) + // sign เองตรงนี้ให้อยู่ในรูปแบบ data.info={ticket_data,HmacSha256,Ed25519} หรือว่างไว้ให้ docedit สร้างให้ + + //ยิงไปหาระบบ Docedit เพื่อเอา Stream + // console.log(`fetch ${DOCEDIT_BASE_URL}/api/template/generate`) + const docxResponse = await fetch(`${DOCEDIT_BASE_URL}/api/template/generate`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'x-api-key': EXTERNAL_API_KEY, + }, + body: JSON.stringify(jsonData), + }); + + // เช็ค Content-Type และ Status ก่อน + const contentType = docxResponse.headers.get('content-type') || ''; + + if (!docxResponse.ok || contentType.includes('text/html')) { + const errorText = await docxResponse.text(); + console.error('download-docx:Invalid docx Response:', errorText); + + return NextResponse.json( + {error:`Error from docedit generate: Expected docx stream but received ${contentType} (Status ${docxResponse.status})`}, + { status: 502 } + ); + } + + // 2. ดึง ReadableStream โดยตรงจาก Response ของระบบ + const stream = docxResponse.body; + + // 3. ส่ง Binary Stream กลับไปยัง Browser พร้อม Header กำหนดประเภทไฟล์ + const download_file = `${fid}_${uid}_xxx` + return new NextResponse(stream as unknown as BodyInit, { + status: 200, + headers: { + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'Content-Disposition': `attachment; filename="${encodeURIComponent(download_file)}.docx"`, + 'Cache-Control': 'no-store, max-age=0', + }, + }); + } catch (error:any) { + if ( error.code === 'ENOENT') { + return NextResponse.json( + { error: `Data not found: ${filePath}`}, + { status: 404 } + ); + } + console.error(error) + return NextResponse.json({error:'Internal Server Error'}, { status: 500 }); + } +} + +export async function GET(request: NextRequest) +{ + const searchParams = request.nextUrl.searchParams; + const fid = searchParams.get('fid') || ''; + const tid = searchParams.get('tid') || ''; + const uid = searchParams.get('uid') || ''; + const uname = searchParams.get('uname') || ''; + return await processRequest(fid,tid,uid,uname) +} + +export async function POST(request: NextRequest) { + const formData = await request.formData(); + const fid = String(formData.get('fid')) + const tid = String(formData.get('tid')) + const uid = String(formData.get('uid')) + const uname = String(formData.get('uname')) + return await processRequest(fid,tid,uid,uname) +} \ No newline at end of file diff --git a/app/api/template/extract/route.ts b/app/api/template/extract/route.ts index e723f35..12108b7 100644 --- a/app/api/template/extract/route.ts +++ b/app/api/template/extract/route.ts @@ -1,8 +1,7 @@ import { NextResponse } from "next/server"; import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl"; -import { verifyDataEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; - -export const runtime = "nodejs"; +import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils"; +// export const runtime = "nodejs"; const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; @@ -15,27 +14,14 @@ export async function POST(request: Request) { try { const formData = await request.formData(); const file = formData.get("file"); - const TEST_SECRET = String(formData.get("secret"))??""; - const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key"))??""; + const TEST_SECRET = String(formData.get("secret") ?? ""); + const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? ""); - - if (!(file instanceof File)) { - return NextResponse.json( - { - success: false, - error: 'Missing DOCX file in field "file".', - }, - { status: 400 }, - ); - } - - if ( - file.type !== DOCX_CONTENT_TYPE && - !file.name.toLowerCase().endsWith(".docx") + if (!(file instanceof File) || + (file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") ) ) { return NextResponse.json( { - success: false, error: 'Field "file" must be a .docx file.', }, { status: 400 }, @@ -45,7 +31,6 @@ export async function POST(request: Request) { if (file.size > MAX_UPLOAD_BYTES) { return NextResponse.json( { - success: false, error: 'Field "file" exceeds the 100MB size limit.', }, { status: 400 }, @@ -62,35 +47,30 @@ export async function POST(request: Request) { const bookmark = extractBookmark(buffer) const properties = getDocProps(buffer) let verify: Record ={} - const ticket_data = String(properties?.custom?.ticket_data) + const ticket_data = properties?.custom?.ticket_data const HmacSha256 = String(properties?.custom?.HmacSha256) const Ed25519 = String(properties?.custom?.Ed25519) - - if(ticket_data){ + + 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(TEST_SECRET && HmacSha256){ - verify['verifyHmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) + verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256) } if(TEST_PUBLIC_KEY_HEX&& Ed25519){ - verify["Ed25519"]=verifyDataEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) + verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX) } } - let returnData : Record = { - success: true, - cc,bookmark,properties,verify - } - + + let returnData : Record = {cc,bookmark,properties,verify} return NextResponse.json(returnData); } catch (error) { console.error(error); - return NextResponse.json( { - success: false, error: error instanceof Error ? error.message diff --git a/app/api/template/generate/route.ts b/app/api/template/generate/route.ts new file mode 100644 index 0000000..1a057ee --- /dev/null +++ b/app/api/template/generate/route.ts @@ -0,0 +1,101 @@ +// สร้าง docx จาก json +// - json.info.ticket_data จะมี tid ให้โหลดไฟล์ template นี้มาใช้ +// - สร้าง signature/hash จาก json.info.ticket_data + +import { NextRequest, NextResponse } from 'next/server'; +import {readFile} from 'fs/promises'; +import path from 'path'; +import {DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; +const EXTERNAL_API_KEY = process.env.EXTERNAL_API_KEY || 'secret-api-key-1234'; +const TMPL_PATH = process.env.TMPL_PATH || '' +const PRIVATE_KEY_HEX = process.env.PRIVATE_KEY_HEX ||'' +const SECRET = process.env.SECRET || '' +const TICKET_SEC = Number(process.env.TICKET_SEC||'300') + +if (EXTERNAL_API_KEY || !TMPL_PATH || !PRIVATE_KEY_HEX || !SECRET) + console.log("api/template/generate: require EXTERNAL_API_KEY, TMPL_PATH and PRIVATE_KEY_HEX") + +export async function POST(request: NextRequest) { + // 1. ตรวจสอบ API Key จาก HTTP Header (x-api-key) + const apiKey = request.headers.get('x-api-key'); + + if (!apiKey || apiKey !== EXTERNAL_API_KEY) { + return NextResponse.json( + { error: 'Unauthorized: Invalid API Key' }, + { status: 401 } + ); + } + + try { + // รับข้อมูล JSON Payload + const templateData:DocumentTemplate = (await request.json()); + console.log(templateData) + // ต้องมี templateData.info.ticket_data เป็น json string เสมอ + if (!templateData || !templateData.info || + !templateData.info.ticket_data || + typeof templateData.info.ticket_data !== "string" + ) { + return NextResponse.json( + { + error: "template/generate json.info.ticket_data not avaliable", + }, + { status: 400 }, + ); + } + const ticketObj = JSON.parse(templateData.info.ticket_data) + const tid = String(ticketObj.tid) + const fid = String(ticketObj.fid) + const uid = String(ticketObj.uid) + if(!tid||!fid||!uid) + { + return NextResponse.json( + { + error: "template/generate require json.info.ticket_data.(fid|tid|uid)", + }, + { status: 400 }, + ); + } + + + //เปิดไฟล์ template + const tmplPath = path.join(TMPL_PATH, `${tid}.docx`); + let templateBuffer: Buffer = await readFile(tmplPath) + + const output = await applyTemplate(templateBuffer, templateData, SECRET, PRIVATE_KEY_HEX, TICKET_SEC); + + // สร้าง ReadableStream ของไฟล์ .docx + // ในโค้ดจริง ส่วนนี้จะดึง Binary Buffer จาก Library เช่น docx, docxtemplater หรืออ่านจาก File System + // const encoder = new TextEncoder(); + + const docxStream = new ReadableStream({ + start(controller) { + controller.enqueue(output); + controller.close(); + }, + }); + + // 4. ส่ง Return เป็น Response พร้อม Stream และ Headers กำหนด MIME type ของ MS Word + const dlFileName = encodeURIComponent(`${tid}_${fid}_${uid}XX`) + return new NextResponse(docxStream, { + status: 200, + headers: { + 'Content-Type': + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'Content-Disposition': `attachment; filename="${dlFileName}.docx"`, + 'Cache-Control': 'no-store, max-age=0', + }, + }); + } catch (error:any) { + if ( error.code === 'ENOENT') { + return NextResponse.json( + { error: 'NotFound: Invalid template ID or incorrect storage path'}, + { status: 404 } + ); + } + console.error('Error generating docx:', error); + return NextResponse.json( + { error: 'Failed to generate document payload'}, + { status: 500 } + ); + } +} \ No newline at end of file diff --git a/app/api/template/lib-tmpl.ts b/app/api/template/lib-tmpl.ts deleted file mode 100644 index 2c15f52..0000000 --- a/app/api/template/lib-tmpl.ts +++ /dev/null @@ -1,518 +0,0 @@ -import PizzaZip from "pizzip"; -import { - DOMParser, - XMLSerializer, - type Document as XmlDocument, - type Element as XmlElement, - type Node as XmlNode, - type Attr as XmlAttr, -} from "@xmldom/xmldom"; -import xpath from "xpath"; - -export interface ExtractedData { - [tagName: string]: string; -} - -const W_NS = - "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; - -const asXpathNode = (node: XmlNode): globalThis.Node => - node as unknown as globalThis.Node; - -function createSelector() { - return xpath.useNamespaces({ w: W_NS }); -} - -/** - * อ่าน Content Control (w:sdt) จาก DOCX Buffer - * ปัจจุบันอ่านจาก word/document.xml ตาม logic ของโค้ดต้นฉบับ - */ -export function extractContentControls( - docxBuffer: Buffer, -): ExtractedData { - const zip = new PizzaZip(docxBuffer); - const docXmlFile = zip.file("word/document.xml"); - - if (!docXmlFile) { - throw new Error("Invalid docx file: word/document.xml not found."); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); - const select = createSelector(); - const result: ExtractedData = {}; - - const sdtNodes = select( - "//w:sdt", - asXpathNode(doc), - ) as unknown as XmlElement[]; - - sdtNodes.forEach((sdt) => { - const tagAttr = select( - "./w:sdtPr/w:tag/@w:val", - asXpathNode(sdt), - true, - ) as unknown as XmlAttr | undefined; - - const tagName = tagAttr?.value; - - if (!tagName) return; - - const sdtContent = select( - "./w:sdtContent", - asXpathNode(sdt), - true, - ) as unknown as XmlElement | undefined; - - if (!sdtContent) { - result[tagName] = ""; - return; - } - - const nodes = select( - ".//w:p | .//w:t | .//w:tab | .//w:br | .//w:noBreakHyphen | .//w:softHyphen | .//w:sym", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - let extractedText = ""; - - nodes.forEach((node) => { - switch (node.nodeName) { - case "w:p": - if ( - extractedText.length > 0 && - !extractedText.endsWith("\n") - ) { - extractedText += "\n"; - } - break; - - case "w:t": - extractedText += node.textContent || ""; - break; - - case "w:tab": - extractedText += "\t"; - break; - - case "w:br": - extractedText += "\n"; - break; - - case "w:noBreakHyphen": - extractedText += "\u2011"; - break; - - case "w:softHyphen": - extractedText += "\u00ad"; - break; - - case "w:sym": { - const charAttr = node.getAttribute("w:char"); - if (charAttr) { - const charCode = Number.parseInt(charAttr, 16); - extractedText += String.fromCharCode(charCode); - } - break; - } - } - }); - - result[tagName] = extractedText; - }); - - return result; -} - -type SdtControlType = "plain" | "rich" | "other"; -type SdtStructure = "block" | "inline"; - -function detectSdtControlType( - sdt: XmlElement, - select: xpath.XPathSelect, -): { type: SdtControlType; multiLine: boolean } { - const sdtPr = select( - "./w:sdtPr", - asXpathNode(sdt), - true, - ) as unknown as XmlElement | undefined; - - if (!sdtPr) { - return { type: "other", multiLine: false }; - } - - const textEl = select( - "./w:text", - asXpathNode(sdtPr), - true, - ) as unknown as XmlElement | undefined; - - if (textEl) { - const multiLineAttr = select( - "./@w:multiLine", - asXpathNode(textEl), - true, - ) as unknown as XmlAttr | undefined; - - return { - type: "plain", - multiLine: - multiLineAttr?.value === "1" || - multiLineAttr?.value === "true", - }; - } - - const richTextEl = select( - "./w:richText", - asXpathNode(sdtPr), - true, - ) as unknown as XmlElement | undefined; - - if (richTextEl) { - return { type: "rich", multiLine: true }; - } - - const otherTypeTags = [ - "w:date", - "w:dropDownList", - "w:comboBox", - "w:checkbox", - "w:citation", - "w:group", - "w:docPartObj", - "w:docPartList", - ]; - - for (const tag of otherTypeTags) { - const found = select( - `./${tag}`, - asXpathNode(sdtPr), - true, - ) as unknown as XmlElement | undefined; - - if (found) { - return { type: "other", multiLine: false }; - } - } - - return { type: "rich", multiLine: true }; -} - -function detectSdtStructure( - sdtContent: XmlElement, - select: xpath.XPathSelect, -): SdtStructure { - const directP = select( - "./w:p", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - return directP.length > 0 ? "block" : "inline"; -} - -function buildRunChildren( - doc: XmlDocument, - text: string, -): XmlElement[] { - const nodes: XmlElement[] = []; - const tokens = text.split(/(\t|\n)/); - - tokens.forEach((token) => { - if (token === "") return; - - if (token === "\t") { - nodes.push(doc.createElementNS(W_NS, "w:tab")); - } else if (token === "\n") { - nodes.push(doc.createElementNS(W_NS, "w:br")); - } else { - const t = doc.createElementNS(W_NS, "w:t"); - t.setAttribute("xml:space", "preserve"); - t.textContent = token; - nodes.push(t); - } - }); - - if (nodes.length === 0) { - const t = doc.createElementNS(W_NS, "w:t"); - t.setAttribute("xml:space", "preserve"); - t.textContent = ""; - nodes.push(t); - } - - return nodes; -} - -function buildRun( - doc: XmlDocument, - text: string, - rPr?: XmlElement, -): XmlElement { - const run = doc.createElementNS(W_NS, "w:r"); - - if (rPr) { - run.appendChild(rPr.cloneNode(true)); - } - - buildRunChildren(doc, text).forEach((node) => - run.appendChild(node), - ); - - return run; -} - -function insertInlineContent( - doc: XmlDocument, - sdtContent: XmlElement, - select: xpath.XPathSelect, - text: string, - allowLineBreak: boolean, -): void { - const safeText = allowLineBreak - ? text - : text.replace(/\n/g, " "); - - const existingRuns = select( - "./w:r", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - const firstRPr = - existingRuns.length > 0 - ? (select( - "./w:rPr", - asXpathNode(existingRuns[0]), - true, - ) as unknown as XmlElement | undefined) - : undefined; - - existingRuns.forEach((r) => { - if (r.parentNode) r.parentNode.removeChild(r); - }); - - sdtContent.appendChild( - buildRun(doc, safeText, firstRPr), - ); -} - -function insertBlockContent( - doc: XmlDocument, - sdtContent: XmlElement, - select: xpath.XPathSelect, - text: string, - allowMultiParagraph: boolean, -): void { - const allParagraphs = select( - "./w:p", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - if (allParagraphs.length === 0) { - // ป้องกัน template ที่มีโครงสร้างผิดปกติ - const p = doc.createElementNS(W_NS, "w:p"); - sdtContent.appendChild(p); - allParagraphs.push(p); - } - - const firstP = allParagraphs[0]; - - const firstPPr = select( - "./w:pPr", - asXpathNode(firstP), - true, - ) as unknown as XmlElement | undefined; - - const firstRun = select( - "./w:r", - asXpathNode(firstP), - true, - ) as unknown as XmlElement | undefined; - - const firstRPr = firstRun - ? (select( - "./w:rPr", - asXpathNode(firstRun), - true, - ) as unknown as XmlElement | undefined) - : undefined; - - allParagraphs.forEach((p) => { - if (p !== firstP && p.parentNode) { - p.parentNode.removeChild(p); - } - }); - - const existingRuns = select( - "./w:r", - asXpathNode(firstP), - ) as unknown as XmlElement[]; - - existingRuns.forEach((r) => { - if (r.parentNode) r.parentNode.removeChild(r); - }); - - if (!allowMultiParagraph) { - firstP.appendChild(buildRun(doc, text, firstRPr)); - return; - } - - text.split("\n").forEach((line, index) => { - let targetP: XmlElement; - - if (index === 0) { - targetP = firstP; - } else { - targetP = doc.createElementNS(W_NS, "w:p"); - - if (firstPPr) { - targetP.appendChild(firstPPr.cloneNode(true)); - } - - sdtContent.appendChild(targetP); - } - - const run = doc.createElementNS(W_NS, "w:r"); - - if (firstRPr) { - run.appendChild(firstRPr.cloneNode(true)); - } - - buildRunChildren(doc, line).forEach((node) => - run.appendChild(node), - ); - - targetP.appendChild(run); - }); -} - -function insertFallbackContent( - sdtContent: XmlElement, - select: xpath.XPathSelect, - text: string, -): void { - const textNodes = select( - ".//w:t", - asXpathNode(sdtContent), - ) as unknown as XmlElement[]; - - if (textNodes.length === 0) return; - - textNodes[0].textContent = text; - - for (let i = 1; i < textNodes.length; i++) { - textNodes[i].textContent = ""; - } -} - -export type TemplateData = Record; - -function normalizeValue(value: unknown): string { - if (value === null || value === undefined) { - return ""; - } - - if (typeof value === "string") { - return value; - } - - if ( - typeof value === "number" || - typeof value === "boolean" || - typeof value === "bigint" - ) { - return String(value); - } - - // รองรับ JSON ที่ส่ง array/object มา แต่ Content Control ต้องการ text - return JSON.stringify(value); -} - -/** - * นำ JSON data ไปแทนค่า Content Control ใน DOCX template - * คืนค่าเป็น Buffer ของ DOCX ที่สร้างเสร็จแล้ว - */ -export function templateContentControls( - templateBuffer: Buffer, - data: TemplateData, -): Buffer { - const zip = new PizzaZip(templateBuffer); - const docXmlFile = zip.file("word/document.xml"); - - if (!docXmlFile) { - throw new Error("Invalid docx file: word/document.xml not found."); - } - - const parser = new DOMParser(); - const doc = parser.parseFromString(docXmlFile.asText(), "text/xml"); - const select = createSelector(); - - const sdtNodes = select( - "//w:sdt", - asXpathNode(doc), - ) as unknown as XmlElement[]; - - sdtNodes.forEach((sdt) => { - const tagAttr = select( - "./w:sdtPr/w:tag/@w:val", - asXpathNode(sdt), - true, - ) as unknown as XmlAttr | undefined; - - const tagName = tagAttr?.value; - - if (!tagName || !Object.prototype.hasOwnProperty.call(data, tagName)) { - return; - } - - const newValue = normalizeValue(data[tagName]); - - const sdtContent = select( - "./w:sdtContent", - asXpathNode(sdt), - true, - ) as unknown as XmlElement | undefined; - - if (!sdtContent) return; - - const { type: controlType, multiLine } = - detectSdtControlType(sdt, select); - - const structure = detectSdtStructure(sdtContent, select); - - if (controlType === "other") { - insertFallbackContent(sdtContent, select, newValue); - return; - } - - if (structure === "inline") { - const allowLineBreak = - controlType === "plain" ? multiLine : true; - - insertInlineContent( - doc, - sdtContent, - select, - newValue, - allowLineBreak, - ); - } else { - insertBlockContent( - doc, - sdtContent, - select, - newValue, - controlType === "rich", - ); - } - }); - - const serializer = new XMLSerializer(); - zip.file( - "word/document.xml", - serializer.serializeToString(doc), - ); - - return zip.generate({ - type: "nodebuffer", - compression: "DEFLATE", - }); -} diff --git a/app/api/template/render/route.ts b/app/api/template/render/route.ts index 8f04c9f..c738afe 100644 --- a/app/api/template/render/route.ts +++ b/app/api/template/render/route.ts @@ -1,73 +1,49 @@ import { NextResponse } from "next/server"; -import { DocumentTemplate, templateContentControls, setCustomDocProps, getDocProps, templateDynamicContents } from "@/lib/lib-tmpl"; -import { generateHmacSha256, verifyHmacSha256, signDataEd25519, verifyDataEd25519, sortObjectKeys } from '@/lib/lib-utils'; - +import { DocumentTemplate,applyTemplate} from "@/lib/lib-tmpl"; export const runtime = "nodejs"; + const DOCX_CONTENT_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"; - // cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้ // ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions) const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB +const TICKET_SEC = Number(process.env.TICKET_SEC||'300') export async function POST(request: Request) { try { const formData = await request.formData(); - const template = formData.get("template"); const dataFile = formData.get("data"); const dataJson = formData.get("dataJson"); - const TEST_SECRET = (typeof formData.get("secret") === "string") ? - String(formData.get("secret")) : "super_secret"; - const TEST_PRIVATE_KEY_HEX = (typeof formData.get("private_key") === "string") ? - String(formData.get("private_key")) : "8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1"; + const TEST_SECRET = String(formData.get("secret")||"") + const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"") - console.log(`secret:${TEST_SECRET}`) - console.log(`private:${TEST_PRIVATE_KEY_HEX}`) - // ----------------------------- // ตรวจสอบ Template - // ----------------------------- - if (!(template instanceof File)) { return NextResponse.json( { - success: false, error: 'Missing DOCX file in field "template".', }, { status: 400 }, ); } - if ( - template.type !== DOCX_CONTENT_TYPE && - !template.name.toLowerCase().endsWith(".docx") + if (template.type !== DOCX_CONTENT_TYPE && + !template.name.toLowerCase().endsWith(".docx") && + template.size > MAX_UPLOAD_BYTES ) { return NextResponse.json( { - success: false, - error: 'Field "template" must be a .docx file.', + error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.', }, { status: 400 }, ); } - if (template.size > MAX_UPLOAD_BYTES) { - return NextResponse.json( - { - success: false, - error: 'Field "template" exceeds the 100MB size limit.', - }, - { status: 400 }, - ); - } - // ----------------------------- // อ่าน JSON - // ----------------------------- - let templateData: DocumentTemplate; - // JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500 // (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น) try { @@ -78,13 +54,11 @@ export async function POST(request: Request) { if (dataFile.size > MAX_UPLOAD_BYTES) { return NextResponse.json( { - success: false, error: 'Field "data" exceeds the 100MB size limit.', }, { status: 400 }, ); } - // กรณี upload data.json const text = await dataFile.text(); @@ -92,7 +66,6 @@ export async function POST(request: Request) { } else { return NextResponse.json( { - success: false, error: 'Missing JSON data. Send either "data" file or "dataJson" field.', }, @@ -102,14 +75,12 @@ export async function POST(request: Request) { } catch (error) { return NextResponse.json( { - success: false, error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error" }).`, }, { status: 400 }, ); } - // JSON ต้องเป็น object if ( templateData === null || @@ -118,7 +89,6 @@ export async function POST(request: Request) { ) { return NextResponse.json( { - success: false, error: "JSON data must be an object.", }, { status: 400 }, @@ -132,62 +102,18 @@ export async function POST(request: Request) { let templateBuffer: Buffer = Buffer.from( await template.arrayBuffer(), ); - - //Calculate hash - const originalTicket_data = templateData?.info?.ticket_data //expect valid json string - // console.log("templateData",templateData?.info?.ticket_data) - if (originalTicket_data && typeof originalTicket_data === 'string') { - try { - - const ttlSeconds: number = 300 - const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data - if (originalTicketObj && typeof originalTicketObj === 'object') { - console.log("Found valid ticket data: hash it") - // คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ) - // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) - const expireTimestamp = Math.floor(Date.now() / 1000) + ttlSeconds; - const fullObj = { ...originalTicketObj, exp: expireTimestamp }; - const sortedObj = sortObjectKeys(fullObj); - - // แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash - const ticket_data = JSON.stringify(sortedObj); - // console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data) - const HmacSha256 = generateHmacSha256(ticket_data, TEST_SECRET) - - const Ed25519 = signDataEd25519(ticket_data, TEST_PRIVATE_KEY_HEX) - templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 }) - } - } catch (error) { - console.error("Set custom property fail",error); - - } - } - - // Apply Dynamic Content - if (templateData?.dc && typeof templateData?.dc === "object") { - 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") - templateBuffer = templateContentControls(templateBuffer, templateData.cc); - } - - const output = templateBuffer; + const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX,TICKET_SEC); // ----------------------------- // ส่ง DOCX กลับ // ----------------------------- - + if(templateData?.info?.ticket_data) return new NextResponse(output as BodyInit, { status: 200, headers: { "Content-Type": DOCX_CONTENT_TYPE, - "Content-Disposition": 'attachment; filename="generated-document.docx"', - "Content-Length": String(output.length), }, }); @@ -196,7 +122,7 @@ export async function POST(request: Request) { return NextResponse.json( { - success: false, + error: error instanceof Error ? error.message @@ -205,4 +131,5 @@ export async function POST(request: Request) { { status: 500 }, ); } -} \ No newline at end of file +} + diff --git a/app/api/template/test/T2.json b/app/api/template/test/T2.json deleted file mode 100644 index 799c36f..0000000 --- a/app/api/template/test/T2.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "notname": "คำสั่งเรียก ให้ทำคำให้การ", - "nottype": "(ต. ๑)", - "blackno": "คดีหมายเลขดำที่", - "blackyear": "๑๐/๒๕๖๙", - "redno": "คดีหมายเลขแดงที่", - "redyear": "", - "court": "ศาลปกครองสุพรรณบุรี", - "curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", - "accuser": "นายธัญวิสิษฐ์ ศรแก้ว", - "acc1": "ผู้ฟ้องคดี", - "accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน", - "acc2": "ผู้ถูกฟ้องคดี", - "nottitle": "คำสั่งถึง", - "commto": "เจ้าคุณมด", - "groupname": "ผู้ถูกฟ้องคดีที่ ๑", - "detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้", - "pname": "นางนพเก้า เถื่อนชัย", - "courtname": "ศาลปกครองสุพรรณบุรี", - "courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี", - "courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐", - "courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓", - "courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”", - "sendto": "เจ้าคุณมด", - "addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -", - "remarkhead": "", - "remark": "", - "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี" -} \ No newline at end of file diff --git a/app/api/template/test/T3.docx b/app/api/template/test/T3.docx deleted file mode 100644 index 7573a45..0000000 Binary files a/app/api/template/test/T3.docx and /dev/null differ diff --git a/app/api/template/test/Test.docx b/app/api/template/test/Test.docx deleted file mode 100644 index f078f00..0000000 Binary files a/app/api/template/test/Test.docx and /dev/null differ diff --git a/app/api/template/test/make-protected.mjs b/app/api/template/test/make-protected.mjs deleted file mode 100644 index c35f2b2..0000000 --- a/app/api/template/test/make-protected.mjs +++ /dev/null @@ -1,298 +0,0 @@ -// ทดลอง: เปิด Restrict Edit (readOnly + enforcement) และฝัง custom properties -// (token / primary-key / owner) ลงไฟล์ docx — ตามแนวทาง Token-Based ใน note.md -// -// ใช้: node app/api/template/test/make-protected.mjs [in.docx] [out.docx] -// ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ผล render ล่าสุด) -// -// สิ่งที่แก้ในไฟล์ docx: -// 1. word/settings.xml — w:documentProtection edit="readOnly" enforcement="1" -// (เอกสารทั้งฉบับอ่านอย่างเดียว — ทดสอบแล้ว: readOnly ล็อค CC ด้วย -// จึงต้องคู่กับข้อ 2 ถึงจะแก้ใน CC ได้) -// 2. word/document.xml ฯลฯ — ครอบ w:sdt ทุกตัวที่มี w:tag ด้วย -// w:permStart(w:edGrp="everyone") / w:permEnd — กลไก "Exceptions: Everyone" -// ของแผง Restrict Editing ใน Word (บริเวณใน perm แก้ได้ นอกนั้นล็อค) -// พร้อมเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี กันลบช่องตอนอยู่ในโหมดนี้ -// 3. docProps/custom.xml — สร้างใหม่ เก็บค่า custom properties แบบ vt:lpwstr -// 4. [Content_Types].xml — เพิ่ม Override สำหรับ custom.xml -// 5. _rels/.rels — เพิ่ม Relationship ชี้ docProps/custom.xml -import PizzaZip from "pizzip"; -import { DOMParser, XMLSerializer } from "@xmldom/xmldom"; -import xpath from "xpath"; -import { readFileSync, writeFileSync } from "node:fs"; -import { fileURLToPath } from "node:url"; -import { dirname, join } from "node:path"; - -const __dirname = dirname(fileURLToPath(import.meta.url)); -const inPath = process.argv[2] ?? join(__dirname, "output-T3.docx"); -const outPath = process.argv[3] ?? inPath; - -// ตัวอย่างข้อมูลที่จะฝัง (ใน production จะมาจาก backend ตอน render เอกสาร) -const CUSTOM_PROPS = { - token: "eyJhbGciOiJIUzI1NiJ9.eyJ1aWQiOiJ1MTAwMSIsImZpZCI6IkRPTi0yMDI2LTAwMDEyMyIsImV4cCI6MTc5NjI0NzIwMH0.demo", - "primary-key": "DOC-2026-000123", - owner: "u1001", -}; - -const W_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; -const CUSTOM_NS = - "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; -const VT_NS = - "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; -const REL_CUSTOM = - "http://schemas.openxmlformats.org/officeDocument/2006/relationships/custom-properties"; -const CT_CUSTOM = - "application/vnd.openxmlformats-officedocument.custom-properties+xml"; -const FMTID = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"; - -const parser = new DOMParser(); -const serializer = new XMLSerializer(); - -const parseXml = (text) => parser.parseFromString(text, "text/xml"); - -// xmldom serializer อาจคง xml declaration ไว้แล้ว — เติมเฉพาะเมื่อยังไม่มี -// (ถ้า prepend ซ้ำจะได้ declaration สองอัน ไฟล์ XML invalid) -const serialize = (doc) => { - const text = serializer.serializeToString(doc); - return text.startsWith("\r\n' + text; -}; - -// ครอบ w:sdt ที่มี w:tag ทุกตัวด้วย w:permStart/w:permEnd (edGrp="everyone") -// และเติม w:lock="sdtLocked" ให้ CC ที่ยังไม่มี — คืนจำนวน CC ที่ครอบ -function unlockContentControls(zip) { - const select = xpath.useNamespaces({ w: W_NS }); - const asNode = (n) => n; - const parts = Object.keys(zip.files).filter((n) => - /^word\/(document|header\d*|footer\d*)\.xml$/.test(n), - ); - - // id ของ perm ต้องไม่ซ้ำในเอกสาร — เริ่มสูงกว่าค่าสูงสุดที่มีอยู่ (จากทุก part) - let nextId = 999; - for (const part of parts) { - const ids = select( - "//w:permStart/@w:id | //w:permEnd/@w:id", - asNode(parseXml(zip.file(part).asText())), - ); - for (const id of ids) { - const n = Number.parseInt(id.value, 10); - if (Number.isFinite(n) && n >= nextId) nextId = n; - } - } - - let wrapped = 0; - let locked = 0; - for (const part of parts) { - const doc = parseXml(zip.file(part).asText()); - - // เฉพาะ sdt ระดับบนสุด (ไม่อยู่ใน sdt อื่น) มี w:tag = ช่องข้อมูลของ template - // และยังไม่ถูกครอบด้วย permStart อยู่แล้ว (กัน double-wrap ตอนรันซ้ำ) - const sdts = select( - "//w:sdt[./w:sdtPr/w:tag][not(ancestor::w:sdt)][not(preceding-sibling::*[1][self::w:permStart])]", - asNode(doc), - ); - if (sdts.length === 0) continue; - - for (const sdt of sdts) { - // เติม lock ให้ CC ที่ยังไม่มี — กันลบช่องตอนเปิดช่องให้แก้ผ่าน permission - const sdtPr = select("./w:sdtPr", sdt)[0]; - const hasLock = select("./w:lock", sdtPr).length > 0; - if (!hasLock) { - const lock = doc.createElementNS(W_NS, "w:lock"); - lock.setAttribute("w:val", "sdtLocked"); - // w:lock ต้องอยู่หลัง w:id ตามลำดับ schema ของ sdtPr - const idEl = select("./w:id", sdtPr)[0]; - if (idEl && idEl.nextSibling) { - sdtPr.insertBefore(lock, idEl.nextSibling); - } else { - sdtPr.appendChild(lock); - } - locked++; - } - - const start = doc.createElementNS(W_NS, "w:permStart"); - start.setAttribute("w:id", String(nextId)); - start.setAttribute("w:edGrp", "everyone"); - const end = doc.createElementNS(W_NS, "w:permEnd"); - end.setAttribute("w:id", String(nextId)); - nextId++; - - sdt.parentNode.insertBefore(start, sdt); - sdt.parentNode.insertBefore(end, sdt.nextSibling); - wrapped++; - } - - zip.file(part, serialize(doc)); - } - return { wrapped, locked }; -} - -function enableReadOnlyProtection(zip) { - const file = zip.file("word/settings.xml"); - if (!file) throw new Error("word/settings.xml not found"); - - const doc = parseXml(file.asText()); - const prots = doc.getElementsByTagNameNS(W_NS, "documentProtection"); - const prot = prots.length > 0 ? prots[0] : null; - - if (prot) { - prot.setAttribute("w:edit", "readOnly"); - prot.setAttribute("w:enforcement", "1"); - } else { - // T3 มี element นี้อยู่แล้ว แต่รองรับไฟล์ที่ไม่มี: แทรกเป็น child แรกของ w:settings - const el = doc.createElementNS(W_NS, "w:documentProtection"); - el.setAttribute("w:edit", "readOnly"); - el.setAttribute("w:enforcement", "1"); - doc.documentElement.insertBefore(el, doc.documentElement.firstChild); - } - - zip.file("word/settings.xml", serialize(doc)); -} - -function setCustomProperties(zip, props) { - // --- 1. docProps/custom.xml : สร้างใหม่ หรือ merge กับของเดิม --- - const existing = zip.file("docProps/custom.xml"); - let doc; - if (existing) { - doc = parseXml(existing.asText()); - // ลบ property ชื่อซ้ำออกก่อน (เขียนทับค่าใหม่) - const all = Array.from(doc.getElementsByTagNameNS(CUSTOM_NS, "property")); - for (const p of all) { - if (Object.prototype.hasOwnProperty.call(props, p.getAttribute("name"))) { - p.parentNode.removeChild(p); - } - } - } else { - doc = parseXml( - `` + - ``, - ); - } - - // pid ต้อง >= 2 และไม่ซ้ำ — เริ่มจากค่าสูงสุดที่มีอยู่ +1 - const usedPids = Array.from( - doc.getElementsByTagNameNS(CUSTOM_NS, "property"), - ).map((p) => Number.parseInt(p.getAttribute("pid"), 10) || 1); - let nextPid = Math.max(1, ...usedPids) + 1; - - for (const [name, value] of Object.entries(props)) { - const prop = doc.createElementNS(CUSTOM_NS, "property"); - prop.setAttribute("fmtid", FMTID); - prop.setAttribute("pid", String(nextPid++)); - prop.setAttribute("name", name); - const val = doc.createElementNS(VT_NS, "vt:lpwstr"); - val.textContent = value; - prop.appendChild(val); - doc.documentElement.appendChild(prop); - } - zip.file("docProps/custom.xml", serialize(doc)); - - // --- 2. [Content_Types].xml : เพิ่ม Override (ครั้งเดียว) --- - const ct = parseXml(zip.file("[Content_Types].xml").asText()); - const hasCt = Array.from(ct.getElementsByTagName("Override")).some( - (o) => o.getAttribute("PartName") === "/docProps/custom.xml", - ); - if (!hasCt) { - const el = ct.createElement("Override"); - el.setAttribute("PartName", "/docProps/custom.xml"); - el.setAttribute("ContentType", CT_CUSTOM); - ct.documentElement.appendChild(el); - zip.file("[Content_Types].xml", serialize(ct)); - } - - // --- 3. _rels/.rels : เพิ่ม Relationship (ครั้งเดียว) --- - const rels = parseXml(zip.file("_rels/.rels").asText()); - const relNodes = Array.from(rels.getElementsByTagName("Relationship")); - if (!relNodes.some((r) => r.getAttribute("Type") === REL_CUSTOM)) { - const usedIds = new Set(relNodes.map((r) => r.getAttribute("Id"))); - let n = 1; - while (usedIds.has(`rId${n}`)) n++; - const el = rels.createElement("Relationship"); - el.setAttribute("Id", `rId${n}`); - el.setAttribute("Type", REL_CUSTOM); - el.setAttribute("Target", "docProps/custom.xml"); - rels.documentElement.appendChild(el); - zip.file("_rels/.rels", serialize(rels)); - } -} - -// อ่านกลับมาตรวจทุกอย่างหลังเขียนไฟล์ -function verify(path) { - const zip = new PizzaZip(readFileSync(path)); - - const settings = zip.file("word/settings.xml")?.asText() ?? ""; - const prot = settings.match(/]*\/>/)?.[0]; - const enforced = - prot?.includes('w:edit="readOnly"') && prot?.includes('w:enforcement="1"'); - - const customXml = zip.file("docProps/custom.xml")?.asText(); - const propsBack = {}; - if (customXml) { - const doc = parseXml(customXml); - for (const p of Array.from( - doc.getElementsByTagNameNS(CUSTOM_NS, "property"), - )) { - propsBack[p.getAttribute("name")] = p.textContent; - } - } - - const rels = zip.file("_rels/.rels")?.asText() ?? ""; - const ct = zip.file("[Content_Types].xml")?.asText() ?? ""; - - const docXml = zip.file("word/document.xml")?.asText() ?? ""; - const sdtPrs = docXml.match(/[\s\S]*?<\/w:sdtPr>/g) ?? []; - const plain = sdtPrs.filter((p) => / / `${k}="${v.length > 40 ? v.slice(0, 40) + "…" : v}"`) - .join(", ") || "(ว่าง)", - ); - console.log( - `relationship : ${rels.includes(REL_CUSTOM) ? "✓" : "✗ ไม่พบ"} | content-type override: ${ct.includes(CT_CUSTOM) ? "✓" : "✗ ไม่พบ"}`, - ); - console.log(`content controls : ${sdtPrs.length} ตัว (plain=${plain}, rich=${rich}, มี tag=${tagged})`); - console.log( - `permission ranges : permStart=${permStarts} / permEnd=${permEnds} (ต้องเท่ากับมี tag=${tagged}) ` + - (permStarts === tagged && permEnds === tagged ? "✓" : "✗"), - ); - console.log( - `CC ไม่มี lock : ${missingLock} ตัว (ต้องเป็น 0 — กันลบช่อง) ${missingLock === 0 ? "✓" : "✗"}`, - ); - - return ( - enforced && - !!customXml && - permStarts === tagged && - permEnds === tagged && - missingLock === 0 - ); -} - -const zip = new PizzaZip(readFileSync(inPath)); -enableReadOnlyProtection(zip); -const { wrapped, locked } = unlockContentControls(zip); -setCustomProperties(zip, CUSTOM_PROPS); - -if (locked > 0) { - console.log(`เติม w:lock="sdtLocked" ให้ CC อีก ${locked} ตัวที่ยังไม่มี`); -} -console.log(`ครอบ CC ด้วย permission range: ${wrapped} ตัว`); - -const out = zip.generate({ type: "nodebuffer", compression: "DEFLATE" }); -writeFileSync(outPath, out); -console.log(`เขียนไฟล์แล้ว: ${outPath} (${out.length.toLocaleString()} bytes)`); - -const ok = verify(outPath); -process.exit(ok ? 0 : 1); diff --git a/app/api/template/test/note.md b/app/api/template/test/note.md deleted file mode 100644 index 919190b..0000000 --- a/app/api/template/test/note.md +++ /dev/null @@ -1,127 +0,0 @@ -# Docx Template -lib-tmpl.ts เป็นแชร์ไลบารีสำหรับการทำ Template ใช้ Content Control ใน MS Word -## วิธีการใช้ API -```sh -## Swagger UI — เปิดในเบราว์เซอร์ (ลองยิงจริงผ่านปุ่ม Try it out ได้เลย ไม่ต้อง login) -http://localhost:3000/docedit/api/swagger - -## template/test ไว้ทดสอบเท่านั้น -curl.exe http://localhost:3000/docedit/api/template/test - -## extract ค่าออกมาจาก content control -curl.exe -X POST http://localhost:3000/docedit/api/template/extract -F "file=@./output-T3.docx" - -## render เอกสาร (docx) = template(docx)+data(json) -### อาจจะเพิ่มการเอา template จาก url และ file(local) ยังไม่รู้ว่าทางนั้นจะส่งไฟล์แบบไหนมาให้ เอาแค่นี้ไว้ทดสอบก่อน -curl.exe -X POST http://localhost:3000/docedit/api/template/render -F "template=@./T3.docx" -F "data=@./T2.json" -o output.docx -### data เอาจาก string -curl -X POST http://localhost:3000/docedit/api/template/render \ - -F "template=@template.docx" \ - -F 'dataJson={"member_name":"นายสมชาย","member_id":"123456"}' \ - -o generated-document.docx - -``` -data(json) ตอนนี้อยู่ในรูปแบบ -```json -{ - "notname": "คำสั่งเรียก ให้ทำคำให้การ", - "nottype": "(ต. ๑)", - "blackno": "คดีหมายเลขดำที่", - "blackyear": "๑๐/๒๕๖๙", - ... -} -``` -data(json)น่าจะปรับเป็นแบบนี้เพื่อรองรับการทำ Token-Based Seamless Integration -หลักผ่าน API -- ส่วนของ cc จะไปกรอกใน Content Control -- uid,uname,fid,ott,expire สร้างเป็น token ฝังใน custom property ของเอกสาร -```json -{ - "uid":"id ผู้มีสิทธิ์แก้ไขเอกสาร", - "uname": "ชื่อผู้มีสิทธิ์แก้ไขเอกสาร สำหรับแสดงผล", - "fid":"file id จะได้เซฟข้อมูลกลับไปที่ Oracle ถูกตารางและหน่วยงาน", - "ott":"อายุของ one time token แบบระยะสั้น", - "expire": "อายุเอกสารแบบระยะยาวหลังเอา ott ไปแลก (optional)", - cc:{ - "notname": "คำสั่งเรียก ให้ทำคำให้การ", - "nottype": "(ต. ๑)", - "blackno": "คดีหมายเลขดำที่", - "blackyear": "๑๐/๒๕๖๙", - ... - } -} -``` -ที่ต้องทำเพิ่ม -- render ปัจจุบันมีไว้ให้ทางศาลทดสอบ อันที่ใช้จริงหลังกดสร้าง Template เพื่อ Download จะสร้างเอกสารเวอร์ชั่นแรกใน object storage หรือถ้ามีอยู่แล้ว จะเป็นการอัปเดต -- Backend จัดการ version -- อัปเดตข้อมูลและเอกสารกลับระบบเดิม ส่วนนี้ยังไม่ชัดเจนอาจจะทำ mock api เตรียมไว้ก่อน - -# ทดลอง Restrict Edit + Custom Properties (2026-10-07) - -สคริปต์ `make-protected.mjs` — อ่าน docx → เปิด protection + ฝัง custom properties → เขียนไฟล์ → อ่านกลับ verify ทุกครั้ง - -```sh -node app/api/template/test/make-protected.mjs [in.docx] [out.docx] -# ไม่ส่ง argument = อ่านและเขียนทับ output-T3.docx (ค่าตัวอย่างฝังอยู่ในตัวสคริปต์ CUSTOM_PROPS) -``` - -## Restrict Edit (เปิดผ่านโค้ด) - -- อยู่ที่ `word/settings.xml` → `` -- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่ -- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป -- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `` … ``** — คือกลไก "Exceptions (optional): Everyone" ที่แผง Restrict Editing ของ Word ใช้เวลาเราเลือกบริเวณแล้วติ๊ก Everyone (บริเวณใน permission range แก้ได้ นอกนั้นล็อค) — แทรกที่ระดับเดียวกับ sdt (inline ใน `w:p` หรือ block ใน `w:body` ก็ valid ตาม grammar) -- สคริปต์เติม `w:lock="sdtLocked"` ให้ CC ที่ยังไม่มีด้วย (T3 ขาด acc1, nottitle) — พอช่องถูกเปิดให้แก้ผ่าน permission แล้ว ช่องที่ไม่มี lock จะถูก**ลบ**ได้ตามไปด้วย -- ไม่ใส่ password → ผู้ใช้ปลดเองได้จาก Review → Restrict Editing → Stop Protection — ตั้งใจไว้แบบนี้เพราะ concept คือ token ใน custom property เป็นตัวกำกับสิทธิ์จริง ส่วน protection เป็นแค่เกราะกันแก้พลาด (ถ้าจะใส่ password ต้องคำนวณ legacy hash: SHA-1 ของ password+salt วนตาม `w:cryptSpinCount` ตาม ECMA-376 — ยังไม่ทำ) - -## ฝังข้อมูลใน Property เอกสาร (token / primary-key / owner) - -ทางเลือกที่ศึกษา: -1. **`docProps/custom.xml` (custom properties) — ที่เลือกใช้** เห็นได้ใน Word: File → Info → Properties → Advanced Properties → แท็บ Custom; add-in ฝั่ง Word (Office.js) อ่าน/เขียนได้ผ่าน `Document.properties.customProperties` (WordApi 1.3+) - - ไฟล์ที่ไม่มี part นี้อยู่ (กรณีปกติของ docx) ต้องทำครบ 3 อย่างพร้อมกัน ไม่งั้น Word ถือว่าไฟล์เสีย: - 1. สร้าง `docProps/custom.xml` — property แต่ละตัว: `fmtid="{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"` คงที่ + `pid` ≥ 2 ไม่ซ้ำ + `name` + ค่า string ใน `vt:lpwstr` - 2. `[Content_Types].xml` เพิ่ม `` - 3. `_rels/.rels` เพิ่ม Relationship ชนิด `.../relationships/custom-properties` Target `docProps/custom.xml` -2. `docProps/core.xml` (core properties: title/subject/creator/description) — แก้ง่าย เห็นในหน้า Properties ปกติ เหมาะกับข้อมูลที่แสดงต่อผู้ใช้ ไม่ใช่ token -3. `w:docVar` ใน `word/settings.xml` (document variables) — ไม่โผล่ใน UI เลย อ่านได้ผ่าน VBA/VSTO (`Document.Variables`) แต่ Word JS API ไม่มีทางตรง — ไม่เลือก - -gotcha ที่เจอ: `XMLSerializer` ของ @xmldom/xmldom คง `` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้) - -## extract บอก alignment รายช่อง — key `@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)** - -- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม -- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `@align` (ค่า w:jc เช่น `thaiDistribute`) เฉพาะช่องที่ต่างจาก default ของเอกสาร โดย resolve: `w:pPr/w:jc` ตรงของย่อหน้า → style ที่ใช้ (ไล่ `w:pStyle` → `w:basedOn`) → `docDefaults` ("left"/"start" นับเป็นไม่ตั้ง) — บริบทย่อหน้าของช่อง: CC block = `w:p` แรกใน `w:sdtContent` / CC inline = `w:p` ที่ครอบ sdt -- ผลทดสอบตอน implement: direct pPr ชนะ style ✓ inline/block จับได้ทั้งคู่ ✓ และ T3 จัดช่อง 8 ตัวผ่าน style อยู่แล้ว (nottype/blackyear/redyear/acc1/acc2/groupname/detail1/pname — ชิดขวา/กลาง) — ช่องเหล่านี้จะโผล่ `@align` ตามจริง - -## จัด paragraph แบบ Thai Distributed — ตัดสินใจ: จัดเองใน Word ตอนทำ template (2026-10-07) - -- ค่า OOXML คือ `` ใน `w:pPr` (ใน Word UI: Paragraph → Alignment → Thai Distributed, ทางลัด Ctrl+Shift+J) — ต่างจาก `distribute` ธรรมดาตรงที่จัดกระจายช่องว่างระหว่างคำเป็นหลัก ไม่บีบยืดตัวอักษร/สระ-วรรณยุกต์ -- **ไม่ตั้งผ่านโค้ด** — ผู้ออกแบบ template จัดเองใน Word ตอนทำ template ทางเดียวกับที่ตั้งฟอนต์/ย่อหน้าอื่น ๆ (เคยทดลองตั้ง default ทั้งเอกสารผ่าน `styles.xml` → `w:docDefaults/w:pPrDefault/w:pPr` แล้วถอดออกตามคำขอ) -- วิธีจัดเองใน Word: - - รายย่อหน้า/ราย CC: คลิกในย่อหน้า → `Ctrl+Shift+J` หรือ Home → Paragraph launcher → Alignment เลือก Thai Distributed - - ทั้งเอกสาร: คลิกขวา style Normal → Modify → Format → Paragraph → Thai Distributed - - จัด "เฉพาะช่อง CC" ได้เฉพาะ CC แบบ block (มี `w:p` ใน `w:sdtContent` ของตัวเอง เช่น `detail1`) — CC แบบ inline อยู่ในบรรทัดเดียวกับข้อความอื่น การจัดมีผลทั้งบรรทัด -- จุดสำคัญต่อ render pipeline: `insertBlockContent` ของ lib-tmpl clone `w:pPr` ของย่อหน้าแรกให้ทุกบรรทัดที่แทนค่า — จัดไว้ใน CC ตั้งแต่ต้นค่าจะสืบทอดไปทุกบรรทัดที่ render ใส่ข้อมูลอัตโนมัติ ไม่ต้องทำอะไรฝั่ง render -- (อ้างอิงเชิงเทคนิคกรณีต้องตั้งผ่านโค้ดภายหลัง: `w:jc` ใน `w:pPr` เป็น fixed sequence — ต้องอยู่หลัง `w:autoSpaceDN`/`w:spacing`/`w:ind` และก่อน `w:textDirection`/`w:rPr`/`w:sectPr`) - -## ผล verify (อัตโนมัติทุกครั้งที่รันสคริปต์) - -- `documentProtection` เปิดใช้ (readOnly + enforcement=1) ✓ -- `docProps/custom.xml` มี + relationship + content-type override ครบ ✓ -- Content Control 27 ตัวครบเดิม (plain 26 = มี `w:text`, rich 1 = `detail1`) ✓ -- permission ranges: permStart/permEnd ครบตามจำนวน CC ที่มี tag (27/27) ✓ -- ทุก CC มี `w:lock="sdtLocked"` (กันลบช่อง) ✓ -- `extractContentControls` (lib-tmpl) อ่านคืนได้ทุก tag ตรงกับ output.docx ก่อน protect 100% ✓ - -## ผลทดสอบใน Word จริง (2026-10-07 รอบ 1) - -- ✅ แถบเหลือง protection ขึ้น / แก้นอก CC ไม่ได้ / ลบ CC ไม่ได้ -- ❌ B5: **CC แก้ไม่ได้** เพราะ readOnly ล็อคทั้งเอกสาร → แก้ด้วย permStart/permEnd ตามด้านบนแล้ว (รอบ 2) -- ✅ custom properties ครบ / ปลด protection ได้แบบไม่มี password - -## เหลือตรวจใน Word จริง (รอบ 2) - -- เปิด `output-T3.docx` → พิมพ์/แก้ในช่อง CC ต้องได้แล้ว (B5) -- ลบ CC ต้องไม่ได้ทุกตัว (รวม acc1, nottitle ที่เติม lock ให้) -- ขั้น E เดิม: F12 เซฟสำเนา → เปิดใหม่ → properties ยังอยู่ครบ -- (ผ่านแล้วทั้งหมด → ย้าย logic ไปเป็นฟังก์ชันใน lib-tmpl.ts แล้วต่อเข้า render pipeline) diff --git a/app/api/template/test/output-T3.docx b/app/api/template/test/output-T3.docx deleted file mode 100644 index c7619be..0000000 Binary files a/app/api/template/test/output-T3.docx and /dev/null differ diff --git a/app/api/template/test/output.docx b/app/api/template/test/output.docx deleted file mode 100644 index 2622682..0000000 Binary files a/app/api/template/test/output.docx and /dev/null differ diff --git a/app/api/template/test/test.http b/app/api/template/test/test.http deleted file mode 100644 index d615855..0000000 --- a/app/api/template/test/test.http +++ /dev/null @@ -1,43 +0,0 @@ -@baseUrl = http://localhost:3000/docedit/api - -### 1. Extract Template -POST {{baseUrl}}/template/extract -Content-Type: multipart/form-data; boundary=WebAppBoundary - ---WebAppBoundary -Content-Disposition: form-data; name="file"; filename="output-T3.docx" -Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document - -< ./output-T3.docx ---WebAppBoundary-- - -### 2. Render Template with JSON File -POST {{baseUrl}}/template/render -Content-Type: multipart/form-data; boundary=WebAppBoundary - ---WebAppBoundary -Content-Disposition: form-data; name="template"; filename="T3.docx" -Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document - -< ./T3.docx ---WebAppBoundary -Content-Disposition: form-data; name="data"; filename="T2.json" -Content-Type: application/json - -< ./T2.json ---WebAppBoundary-- - -### 3. Render Template with Raw JSON String -POST {{baseUrl}}/template/render -Content-Type: multipart/form-data; boundary=WebAppBoundary - ---WebAppBoundary -Content-Disposition: form-data; name="template"; filename="template.docx" -Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document - -< ./T3.docx ---WebAppBoundary -Content-Disposition: form-data; name="dataJson" - -{"notname": "คำสั่งเรียก ให้ทำคำให้การ","nottype": "(ต. ๑)","blackno": "คดีหมายเลขดำที่","blackyear": "๑๐/๒๕๖๙","redno": "คดีหมายเลขแดงที่","redyear": "","court": "ศาลปกครองสุพรรณบุรี","curdate": "วันที่ ๗ เดือน สิงหาคม พุทธศักราช ๒๕๖๙","accuser": "นายธัญวิสิษฐ์ ศรแก้ว","acc1": "ผู้ฟ้องคดี","accused": "เจ้าคุณมด ที่ ๑ กับพวกรวม ๓ คน","acc2": "ผู้ถูกฟ้องคดี","nottitle": "คำสั่งถึง","commto": "เจ้าคุณมด","groupname": "ผู้ถูกฟ้องคดีที่ ๑","detail1": "ด้วยผู้ฟ้องคดีได้ยื่นฟ้องผู้ถูกฟ้องคดีต่อศาล มีข้อความตามที่ปรากฏ ในระบบงานคดีปกครองอิเล็กทรอนิกส์\nฉะนั้น ให้ ท่านทำคำให้การแก้คำฟ้อง พร้อมด้วยพยานหลักฐาน ๑ ชุด ต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่งนี้","pname": "นางนพเก้า เถื่อนชัย","courtname": "ศาลปกครองสุพรรณบุรี","courtno": "๓๓๓ หมู่ ๖ ตำบลสนามชัย อำเภอเมืองสุพรรณบุรี","courttam": "จังหวัดสุพรรณบุรี ๗๒๐๐๐","courttel": "กลุ่มออกหมาย โทรศัพท์ ๐๓๕ ๔๓๒ ๓๓๓ ต่อ ๑๐๐๑,๒๒๐๓","courttel2": "สอบถามติดตามคดี ขอคำปรึกษาแนะนำ โทรสายด่วน “๑๓๕๕”","sendto": "เจ้าคุณมด","addrno": "เลขที่ - หมู่ที่ - ตรอก/ซอย - ถนน - ตำบล/แขวง - อำเภอ/เขต - จังหวัด - โทรศัพท์ -","remarkhead": "","remark": "","warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"} ---WebAppBoundary-- diff --git a/app/tmpl/page.tsx b/app/tmpl/page.tsx new file mode 100644 index 0000000..0eaec44 --- /dev/null +++ b/app/tmpl/page.tsx @@ -0,0 +1,100 @@ +// app/page.tsx (ระบบ B) +'use client'; +import type { ChangeEvent } from 'react'; +// import { createDownloadFormAction } from './actions'; +import DependentDropdown, { DropdownData } from '@/components/utils/DependentDropdown'; + + + +export default function HomePage() { + const templateData: DropdownData = { + T_X: ['T_no_warning', 'TF_no_warning_P2', 'TS_P2_IN'], + T12_X: ['T12_b2_IN', 'T12_FD155_3s-long'], + }; + const handleTmplSelect = (template: string, data: string) => { + //console.log('Location Selected:', { template, data }); + }; + const handleInput = (e: ChangeEvent)=>{ + // console.log(e.target) + return true; + } + return ( +
+
+

ตัวอย่างการใช้ สร้างเอกสารจาก Template

+
+ ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET) + เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์ + ต้องทำให้ ASP.NET มีฟังก์ชั่นเหมือน /docedit/api/template/download-docx +
+
+
+

Form POST

+ +
+
+ uid: +
+
+ uname: +
+ + +
+ +
+

Template & Data

+ Template: T_X.docx,   + T12_X.docx
+ Data: TF_no_warning_P2.json,   + T_no_warning.json,   + TS_P2_IN.json
+ T12_b2_IN.json,   + T12_FD155_3s-long.json +
+
+

Flow

+
+ หน้านี้(เลือกไฟล์)
+ ↓
+ download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)
+ ↓
+ generate(เปิดเทมเพลต,สร้าง docx)
+ ↓
+ ดาว์นโหลด docx เปิด MS Word อัตโนมัติ
+ ↓
+ Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx
+ ↓
+ Versioning(คุมเวอร์ชั่น)
+ ↓
+ ecourt(อัปเดตฐานข้อมูลและไฟล์)
+ +
+ +
+ +
+
+ ); +} \ No newline at end of file diff --git a/components/utils/DependentDropdown.tsx b/components/utils/DependentDropdown.tsx new file mode 100644 index 0000000..7331bd9 --- /dev/null +++ b/components/utils/DependentDropdown.tsx @@ -0,0 +1,91 @@ +'use client'; + +import { useState, ChangeEvent, useEffect } from 'react'; + +export interface DropdownData { + [key: string]: string[]; +} + +interface DependentDropdownProps { + data: DropdownData; + name1: string; // เพิ่มมาเพื่อใช้กับ Form Action + name2: string; // เพิ่มมาเพื่อใช้กับ Form Action + label1?: string; + label2?: string; + placeholder1?: string; + placeholder2?: string; + onSelectChange?: (mainValue: string, subValue: string) => void; +} + +export default function DependentDropdown({ + data, + name1, + name2, + label1 = 'เลือกตัวเลือกหลัก:', + label2 = 'เลือกตัวเลือกย่อย:', + placeholder1 = '-- กรุณาเลือก --', + placeholder2 = '-- กรุณาเลือกตัวเลือกหลักก่อน --', + onSelectChange, +}: DependentDropdownProps) { + const [mainSelection, setMainSelection] = useState(''); + const [subSelection, setSubSelection] = useState(''); + + const handleMainChange = (e: ChangeEvent) => { + const value = e.target.value; + setMainSelection(value); + setSubSelection(''); + }; + + const handleSubChange = (e: ChangeEvent) => { + setSubSelection(e.target.value); + }; + + useEffect(() => { + if (onSelectChange) { + onSelectChange(mainSelection, subSelection); + } + }, [mainSelection, subSelection, onSelectChange]); + + const subOptions = mainSelection ? data[mainSelection] : []; + + return ( +
+ {/* Dropdown ที่ 1 */} +
+ + +
+ + {/* Dropdown ที่ 2 */} +
+ + +
+
+ ); +} diff --git a/docker/web.Dockerfile b/docker/web.Dockerfile index 38f1602..84788ea 100644 --- a/docker/web.Dockerfile +++ b/docker/web.Dockerfile @@ -23,8 +23,20 @@ ENV NEXT_TELEMETRY_DISABLED=1 # db/index.ts throw ถ้าไม่มี DATABASE_URL ตอน next build เก็บ page data — # ใส่ค่า dummy (postgres.js เชื่อมแบบ lazy ไม่มี query ตอน build) ตัวจริงฉีดตอน docker run ENV DATABASE_URL=postgresql://placeholder:placeholder@127.0.0.1:5432/placeholder +ENV MINIO_ENDPOINT=127.0.0.1 +ENV MINIO_PORT=19000 +ENV MINIO_USE_SSL=false +ENV MINIO_ACCESS_KEY=minioadmin +ENV MINIO_SECRET_KEY=minioadmin +ENV DOCVER_API_KEY=2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54 +ENV DOCVER_TOKEN_SECRET=487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e +ENV DOCVER_TOKEN_TTL_SECONDS=604800 +ENV MINIO_BUCKET=docver +ENV DOCVER_LINK_TTL_SECONDS=900 + RUN npm run build + # ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ─── FROM node:24-alpine AS runner WORKDIR /app diff --git a/lib/docver-token.ts b/lib/docver-token.ts index 2f58ae7..92bfc41 100644 --- a/lib/docver-token.ts +++ b/lib/docver-token.ts @@ -36,7 +36,11 @@ function decodeJsonSegment(segment: string): Record | null { } try { const parsed = JSON.parse(text) as unknown; - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { + if ( + parsed === null || + typeof parsed !== "object" || + Array.isArray(parsed) + ) { return null; } return parsed as Record; @@ -64,7 +68,9 @@ export function issueDocverToken( /** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid * (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */ -function claimsFromPayload(payload: Record): DocverTokenClaims | null { +function claimsFromPayload( + payload: Record, +): DocverTokenClaims | null { const { userid, username, fid, template } = payload; if (typeof userid !== "string" || userid === "") return null; if (typeof username !== "string" || username === "") return null; @@ -116,6 +122,8 @@ export function verifyDocverToken( export function requireDocverApiKey(request: Request): Response | null { // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file const envKey = process.env.DOCVER_API_KEY?.trim(); + // const envKey = + // "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54"; if (envKey === undefined || envKey === "") { return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); } @@ -137,11 +145,14 @@ const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือ /** บังคับ Authorization: Bearer ของกลุ่ม docver — เรียกเป็นอย่างแรกของ * ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี * token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */ -export function requireDocverBearer(request: Request): - | { ok: true; claims: DocverTokenClaims } - | { ok: false; response: Response } { - const denied = () => ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const; +export function requireDocverBearer( + request: Request, +): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } { + const denied = () => + ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const; const secret = process.env.DOCVER_TOKEN_SECRET?.trim(); + // const secret = + // "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e"; if (secret === undefined || secret === "") return denied(); const header = request.headers.get("authorization"); if (header === null) return denied(); @@ -156,9 +167,9 @@ export function requireDocverBearer(request: Request): * convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */ const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const; -export function parseTokenRequestBody(raw: unknown): - | { ok: true; claims: DocverTokenClaims } - | DocverError { +export function parseTokenRequestBody( + raw: unknown, +): { ok: true; claims: DocverTokenClaims } | DocverError { if (raw === null || typeof raw !== "object" || Array.isArray(raw)) { return { ok: false, error: "body ต้องเป็น JSON object" }; } diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index 4bf4b70..bb293d0 100755 --- a/lib/lib-tmpl.ts +++ b/lib/lib-tmpl.ts @@ -1,3 +1,4 @@ +import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils'; import createReport from 'docx-templates'; import PizzaZip from "pizzip"; import { @@ -1261,3 +1262,53 @@ export const templateDynamicContents = async (template: Buffer, data: Record> +{ + //Calculate hash + const originalTicket_data = templateData?.info?.ticket_data //expect valid json string + // console.log("templateData",templateData?.info?.ticket_data) + if (originalTicket_data && typeof originalTicket_data === 'string') { + try { + const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data + if (originalTicketObj && typeof originalTicketObj === 'object') { + console.log("Found valid ticket data: hash it") + // คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ) + // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) + const expireTimestamp = Math.floor(Date.now() / 1000) + ttl; + const fullObj = { ...originalTicketObj, exp: expireTimestamp }; + const sortedObj = sortObjectKeys(fullObj); + + // แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash + const ticket_data = JSON.stringify(sortedObj); + // console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data) + const HmacSha256 = generateHmacSha256(ticket_data, secret) + + const Ed25519 = signEd25519(ticket_data, private_key) + templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 }) + } + } catch (error) { //just skip + console.error("Set custom property fail",error); + } + } + + // Apply Dynamic Content + if (templateData?.dc && typeof templateData?.dc === "object") { + 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") + templateBuffer = templateContentControls(templateBuffer, templateData.cc); + } + return templateBuffer + +} \ No newline at end of file diff --git a/lib/lib-utils.ts b/lib/lib-utils.ts index f17a247..312d3cf 100644 --- a/lib/lib-utils.ts +++ b/lib/lib-utils.ts @@ -50,7 +50,7 @@ console.log('Is Signature Valid?:', isValid); // true * @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร) */ -export function signDataEd25519(message: string | Buffer, privateKeyHex: string): string { +export function signEd25519(message: string | Buffer, privateKeyHex: string): string { const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex'); // ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา) const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer; @@ -76,7 +76,7 @@ export function signDataEd25519(message: string | Buffer, privateKeyHex: string) * @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร) * @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน) */ -export function verifyDataEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean { +export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean { try { const rawPublicKey = Buffer.from(publicKeyHex, 'hex'); diff --git a/lib/openapi.ts b/lib/openapi.ts index a21749e..a407cdf 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -32,13 +32,12 @@ export const openApiSpec = { { name: "template", description: - "Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " + - "แล้วได้เอกสารที่กรอกค่าแล้วกลับมา", + "Next Generation of Template : เป็นเทมเพลต MS Word(docx) รองรับรูปแบบข้อมูลที่ซับซ้อนได้ ", }, { name: "documents", description: - "จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " + + "จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " + "version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " + "ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " + "DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " + @@ -75,67 +74,15 @@ export const openApiSpec = { }, }, }, - "/api/template/extract": { - post: { - tags: ["template"], - summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx", - description: - "อ่าน w:sdt (Content Control) ทุกตัวใน word/document.xml — คืน map " + - "ชื่อแท็ก (w:tag) → ข้อความปัจจุบันในนั้น (ใช้สำรวจว่า template มีแท็กอะไรบ้างก่อนเรียก render)", - requestBody: { - required: true, - content: { - "multipart/form-data": { - schema: { - type: "object", - required: ["file"], - properties: { - file: { - type: "string", - format: "binary", - description: "ไฟล์ .docx ที่มี Content Control", - }, - secret: { - type: "string", - description: - "ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property" , - example: - "super_secret", - }, - public_key: { - type: "string", - description: - "Public key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", - example: - "66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546", - }, - }, - }, - }, - }, - }, - responses: { - 200: { - description: "รายการแท็กพร้อมข้อความ", - content: { - "application/json": { - schema: { $ref: "#/components/schemas/ExtractResponse" }, - }, - }, - }, - 400: { $ref: "#/components/responses/BadRequest" }, - 500: { $ref: "#/components/responses/ServerError" }, - }, - }, - }, "/api/template/render": { post: { tags: ["template"], - summary: "สร้างเอกสารจาก template (.docx) + data (JSON)", + summary: "สร้างเอกสารจาก template (.docx) + data (JSON) ใช้สำหรับนักพัฒนาเท่านั้น", description: "แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " + '(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' + - 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)', + 'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)
'+ + 'สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ private key เองไม่ใช้ของระบบของระบบ', requestBody: { required: true, content: { @@ -194,6 +141,410 @@ export const openApiSpec = { }, }, }, + "/api/template/extract": { + post: { + tags: ["template"], + summary: "ดึงค่า Content Control ทั้งหมดออกมาจากไฟล์ .docx ใช้สำหรับนักพัฒนาเท่านั้น", + description: + "อ่าน Content Control, bookmark , property ของ MS Word(docx) ออกมาแสดง" + + "ค่าของ Content Control จะเป็น Plain Text ไม่มี markup ซึ่งเหมาะกับการเอาไปใส่ในฐานข้อมูล"+ + 'สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ Public key เองไม่ใช้ของระบบของระบบ', + requestBody: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: ["file"], + properties: { + file: { + type: "string", + format: "binary", + description: "ไฟล์ .docx ที่มี Content Control", + }, + secret: { + type: "string", + description: + "ค่า secret สำหรับ validate HMAC-SHA256 กับข้อมูลใน custom property", + example: + "super_secret", + }, + public_key: { + type: "string", + description: + "Public key ในรูปแบบ HEX
openssl genpkey -algorithm ED25519 -out private.pem
openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex
openssl pkey -in private.pem -pubout -outform DER | tail -c 32 | xxd -p -c 32 > public.hex", + example: + "66b09b4a1b66271d5728c592c2409657e1c070a7bf371bf9f6ed457855270546", + }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "รายการแท็กพร้อมข้อความ", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/ExtractResponse" }, + }, + }, + }, + 400: { $ref: "#/components/responses/BadRequest" }, + 500: { $ref: "#/components/responses/ServerError" }, + }, + }, + }, + "/api/template/generate": { + post: { + tags: ["template"], + summary: "สร้างเอกสาร DOCX จากข้อมูล JSON และ template ที่กำหนด", + description: + "เทมเพลตมีแท็กแบบพิเศษ(รองรับตัวแปร ลูป เงื่อนไข), Content Control เมื่อรวมกับข้อมูล(JSON) "+ + "จะได้เอกสาร รูปแบบฟอร์มที่กรอกค่าแล้วดึงค่ากลับมาใช้งานต่อได้เช่นอัปเดตฐานข้อมูล
    "+ + "
  • JSON payload ต้องมี info.ticket_data เป็น JSON string รูปแบบถูกต้อวมีค่าครบ(tid, fid และ uid)
  • " + + "
  • ไฟล์เทมเพลต(tid)ในระบบ + ข้อมูล(payload) เพื่อสร้างเอกสาร
  • " + + "
  • ticket_data จะทำการ hash/sign แบบอายุสั้นแล้วนำไปฝังไว้ใน custom property เพื่อนำไปใช้ร่วมกับระบบ Versioning
  • "+ + "
", + security: [{ ApiKeyAuth: [] }], + requestBody: { + required: true, + content: { + "application/json": { + schema: { + type: "object", + required: ["info"], + properties: { + info: { + type: "object", + required: ["ticket_data"], + properties: { + ticket_data: { + type: "string", + description: + "JSON string ที่ต้อง parse ได้ และต้องมี tid, fid และ uid", + example: + '{"tid":"T12","fid":"DOC-2569-0001","uid":"123"}', + }, + }, + additionalProperties: true, + }, + }, + additionalProperties: true, + }, + example: { + "info": { + "ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" + }, + "dc": { + "Interpleader": true, + "docname": "", + "docabbr": "(ต. ๑๒)", + "blackno": "คดีหมายเลขดำที่", + "redno": "คดีหมายเลขแดงที่", + "court": "ศาลปกครองนครราชสีมา", + "panels": [ + { + "pname": "นายวิรุฬห์ จินตนะกุล", + "pmname": "ตุลาการศาลปกครองกลาง", + "pm": "ตุลาการเจ้าของสำนวน" + }, + { + "pname": "นายสัมฤทธิ์ อ่อนคำ", + "pmname": "ตุลาการศาลปกครองสูงสุด" + } + ], + "pnameT": "", + "pmnameT": "", + "pmT": "", + "acc_name": "" + + }, + "cc": { + "blackyear": "๙๐/๒๕๖๙", + "redyear": "", + "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", + "accuser": "นายธีรพล ร่มเริง", + "acc1": "ผู้ฟ้องคดี", + "accused": "รร", + "acc2": "ผู้ถูกฟ้องคดี", + "interpleader": "สมชาย ใจดี", + "acc3": "ผู้ร้องสอด", + "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง" + } + }, + }, + }, + }, + responses: { + 200: { + description: "ไฟล์ DOCX ที่สร้างสำเร็จ", + headers: { + "Content-Disposition": { + description: "ชื่อไฟล์สำหรับดาวน์โหลด", + schema: { type: "string" }, + }, + "Cache-Control": { + description: "ป้องกันการ cache response", + schema: { type: "string", example: "no-store, max-age=0" }, + }, + }, + content: { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + 400: { + description: + "JSON ไม่มี info.ticket_data, ticket_data ไม่ใช่ string, JSON string ไม่ถูกต้อง หรือไม่มี tid/fid/uid", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + examples: { + missingTicketData: { + value: { + error: + "template/generate json.info.ticket_data not avaliable", + }, + }, + missingIdentifiers: { + value: { + error: + "template/generate require json.info.ticket_data.(fid|tid|uid)", + }, + }, + }, + }, + }, + }, + 401: { + description: "ไม่ได้ส่ง x-api-key หรือ API key ไม่ถูกต้อง", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Unauthorized: Invalid API Key" }, + }, + }, + }, + 404: { + description: "ไม่สามารถดึงข้อมูลขึ้นมาได้", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "NotFound: Invalid template ID or incorrect storage path" }, + }, + }, + }, + 500: { + description: + "เกิดข้อผิดพลาดระหว่างอ่าน template หรือสร้างเอกสาร", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Failed to generate document payload" }, + }, + }, + }, + }, + }, + }, + "/api/template/download-docx": { + get: { + tags: ["template"], + summary: "ดาวน์โหลดเอกสาร DOCX ผ่าน Query Parameters (GET) ใช้เพื่อเป็นตัวอย่างจะถูกเอาออกในภายหลัง", + description: + "รับค่า fid, tid, uid, uname จาก Query Parameter อ่านข้อมูล JSON ประจำไฟล์ fid จาก TMPL_PATH แล้วส่งไปประมวลผลต่อที่ /api/template/generate เพื่อสร้างและคืนค่าเป็นไฟล์ DOCX stream ใช้เพื่อเป็นตัวอย่างสำหรับนักพัฒนา ต้องนำออกก่อน production", + parameters: [ + { + name: "fid", + in: "query", + required: true, + schema: { type: "string" }, + description: "ID เอกสาร (File ID)", + example: "T_no_warning", + }, + { + name: "tid", + in: "query", + required: true, + schema: { type: "string" }, + description: "ID เทมเพลต (Template ID)", + example: "T_X", + }, + { + name: "uid", + in: "query", + required: true, + schema: { type: "string" }, + description: "รหัสผู้ใช้งาน (User ID)", + example: "123", + }, + { + name: "uname", + in: "query", + required: true, + schema: { type: "string" }, + description: "ชื่อผู้ใช้งาน (User Name)", + example: "สมชาย ใจดี", + }, + ], + responses: { + 200: { + description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ", + headers: { + "Content-Disposition": { + description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)", + schema: { type: "string", example: 'attachment; filename="T_no_warning_123_xxx.docx"' }, + }, + "Cache-Control": { + description: "การควบคุม Cache (no-store)", + schema: { type: "string", example: "no-store, max-age=0" }, + }, + }, + content: { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + 400: { + description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง", + content: { + "text/plain": { + schema: { type: "string" }, + examples: { + missingParams: { value: "Missing fid,tid,uid or uname" }, + invalidData: { value: "Invalid data format" }, + }, + }, + }, + }, + 404: { + description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Data not found: /path/to/TMPL_PATH/T_no_warning.json" }, + }, + }, + }, + 502: { + description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate", + content: { + "text/plain": { + schema: { type: "string" }, + example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)", + }, + }, + }, + 500: { + description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", + content: { + "text/plain": { + schema: { type: "string" }, + example: "Internal Server Error", + }, + }, + }, + }, + }, + post: { + 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: { + required: true, + content: { + "multipart/form-data": { + schema: { + type: "object", + required: ["fid", "tid", "uid", "uname"], + properties: { + fid: { type: "string", description: "ID เอกสาร (File ID)",example:"T12_b2_IN" }, + tid: { type: "string", description: "ID เทมเพลต (Template ID)",example:"T12_X" }, + uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)", example:"john" }, + uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)", example:"John Doe"}, + }, + }, + }, + "application/x-www-form-urlencoded": { + schema: { + type: "object", + required: ["fid", "tid", "uid", "uname"], + properties: { + fid: { type: "string", description: "ID เอกสาร (File ID)" }, + tid: { type: "string", description: "ID เทมเพลต (Template ID)" }, + uid: { type: "string", description: "รหัสผู้ใช้งาน (User ID)" }, + uname: { type: "string", description: "ชื่อผู้ใช้งาน (User Name)" }, + }, + }, + }, + }, + }, + responses: { + 200: { + description: "ไฟล์ DOCX stream ที่สร้างสำเร็จ", + headers: { + "Content-Disposition": { + description: "ชื่อไฟล์สำหรับการดาวน์โหลด (attachment)", + schema: { type: "string", example: 'attachment; filename="T12_b2_IN_john_xxx.docx"' }, + }, + "Cache-Control": { + description: "การควบคุม Cache (no-store)", + schema: { type: "string", example: "no-store, max-age=0" }, + }, + }, + content: { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + 400: { + description: "พารามิเตอร์ไม่ครบถ้วน หรือรูปแบบไฟล์ JSON ไม่ถูกต้อง", + content: { + "text/plain": { + schema: { type: "string" }, + examples: { + missingParams: { value: "Missing fid,tid,uid or uname" }, + invalidData: { value: "Invalid data format" }, + }, + }, + }, + }, + 404: { + description: "ไม่พบไฟล์ข้อมูล JSON ใน TMPL_PATH", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + example: { error: "Data not found: /path/to/TMPL_PATH/T12_b2_IN.json" }, + }, + }, + }, + 502: { + description: "เกิดข้อผิดพลาดในการเรียกไปยังบริการ Docedit /api/template/generate", + content: { + "text/plain": { + schema: { type: "string" }, + example: "Error from docedit generate: Expected docx stream but received text/html (Status 500)", + }, + }, + }, + 500: { + description: "เกิดข้อผิดพลาดภายในเซิร์ฟเวอร์", + content: { + "text/plain": { + schema: { type: "string" }, + example: "Internal Server Error", + }, + }, + }, + }, + }, + }, "/api/note/external/documents": { post: { tags: ["external"], @@ -524,7 +875,6 @@ export const openApiSpec = { Error: { type: "object", properties: { - success: { type: "boolean", example: false }, error: { type: "string" }, }, }, @@ -563,14 +913,44 @@ export const openApiSpec = { ExtractResponse: { type: "object", properties: { - success: { type: "boolean", example: true }, data: { type: "object", description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)", additionalProperties: { type: "string" }, example: { - blackno: "คดีหมายเลขดำที่", - court: "ศาลปกครองสุพรรณบุรี", + "cc": { + "blackyear": "๙๐/๒๕๖๙", + "redyear": "xxx", + "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙ ", + "accuser": "นายธีรพล ร่มเริง", + "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง...", + }, + "bookmark": { + "signature": "" + }, + "properties": { + "standard": { + "creator": "mam", + "created": "2026-10-22T02:58:00.000Z", + "modified": "2026-10-22T07:08:00.000Z" + }, + "extended": { + "template": "TS_P2_IN.dotx", + "totalTime": 172, + "pages": 2, + "words": 124, + }, + "custom": { + "ticket_data": "{\"exp\":1793013726,\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}", + "HmacSha256": "1dc53fbd48410845c895ddb841a8fe4ad93da413126fae9c688202e57f2baaa9", + "Ed25519": "95ea0ca6d81df885c22a2334ed89b2fc3c75a1a309fb5843e0deb0a16c9d05a45b345fb28fd606f3b152285e70560ad43a6389534794aa0d6f06d13f1307b005" + } + }, + "verify": { + "exp": "26/10/2569 18:22:06", + "HmacSha256": true, + "Ed25519": true + } }, }, }, diff --git a/middleware.ts b/middleware.ts index a67185a..712d46e 100644 --- a/middleware.ts +++ b/middleware.ts @@ -37,6 +37,6 @@ export function middleware(req: NextRequest) { // ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ export const config = { matcher: [ - "/((?!note/login|api/note|api/template|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)", + "/((?!note/login|api/note|api/template|tmpl/|api/documents|api/swagger|tmpl|wordaddin|note/uploads|note/samples|note/swagger-ui|note/pdf\\.worker\\.min\\.mjs|_next|fonts|favicon\\.ico).+)", ], }; diff --git a/public/tmpl/T12_FD155_3s-long.json b/public/tmpl/T12_FD155_3s-long.json index 8a27173..294791e 100644 --- a/public/tmpl/T12_FD155_3s-long.json +++ b/public/tmpl/T12_FD155_3s-long.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T12_FD155_3s-long_ZZZ\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" + "ticket_data": "{\"fid\":\"T12_FD155_3s-long\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}" }, "dc": { "Interpleader": false, diff --git a/public/tmpl/T12_b2_IN.json b/public/tmpl/T12_b2_IN.json index d38a698..4bd98a5 100644 --- a/public/tmpl/T12_b2_IN.json +++ b/public/tmpl/T12_b2_IN.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" + "ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}" }, "dc": { "Interpleader": true, diff --git a/public/tmpl/T12_b2_IN.out.json b/public/tmpl/T12_b2_IN.out.json deleted file mode 100644 index e1b24b9..0000000 --- a/public/tmpl/T12_b2_IN.out.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "cc": { - "blackyear": "๙๐/๒๕๖๙", - "redyear": "", - "curdate": "วันที่ เดือน สิงหาคม พุทธศักราช ๒๕๖๙", - "accuser": "นายธีรพล ร่มเริง", - "acc1": "ผู้ฟ้องคดี", - "interpleader": "สมชาย ใจดี", - "acc3": "ผู้ร้องสอด", - "accused": "รร", - "acc2": "ผู้ถูกฟ้องคดี", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้องและเอกสารประกอบคำฟ้องแล้วเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด ผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไขความเดือดร้อนหรือเสียหายก่อนฟ้องคดี และผู้ฟ้องคดีได้ดำเนินการครบถ้วน โดยเป็นการยื่นฟ้องคดีภายในระยะเวลาการฟ้องคดีตาม พรบ. จัดตั้งศาลปกครองและวิธีพิจารณาคดีปกครองฯ คดีนี้มีค่าธรรมเนียมที่ต้องชำระ\nโดยผู้ฟ้องคดีได้ชำระค่าธรรมเนียมครบถ้วนแล้ว รวมทั้งคดีนี้ไม่เป็นการฟ้องซ้อน การดำเนินกระบวนพิจารณาซ้ำ และการฟ้องซ้ำ\n\nจึงมีคำสั่งรับคำฟ้องไว้พิจารณาและสั่งให้ส่งสำเนาคำฟ้องพร้อมทั้งสำเนาพยานหลักฐานไปยังผู้ถูกฟ้องคดีเพื่อให้ผู้ถูกฟ้องคดีทำคำให้การยื่นต่อศาลภายใน ๓๐ วัน นับแต่วันที่ได้รับคำสั่ง", - "pmname1": "ตุลาการศาลปกครองกลาง", - "pmname2": "ตุลาการศาลปกครองสูงสุด" - }, - "bookmark": { - "signature1": "", - "signature2": "" - }, - "properties": { - "standard": { - "title": " ", - "subject": "", - "creator": "oom", - "keywords": "", - "lastModifiedBy": "Sorawit Bholsithi", - "revision": "61", - "created": "2026-10-15T05:18:00.000Z", - "modified": "2026-10-21T04:59:00.000Z" - }, - "extended": { - "template": "Normal.dotm", - "totalTime": 345, - "pages": 1, - "words": 42, - "characters": 493, - "charactersWithSpaces": 534, - "lines": 4, - "paragraphs": 1, - "application": "Microsoft Office Word", - "appVersion": "16.0000", - "company": "", - "docSecurity": 0 - }, - "custom": { - "ticket_data": "{\"exp\":1792667826,\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}", - "HmacSha256": "791987c9274493db02561f4ec16b83d05c89766b54661da615e8e02db293d1d4", - "Ed25519": "7ef8b0f2a03a52c49f8c348e3c392f448b71c8462ee860342b0e06a91b555f860cad5649c648dc466982017683f3718caff139c85e17e952ee1570ecfc021e08" - } - } -} \ No newline at end of file diff --git a/public/tmpl/TF_no_warning_P2.json b/public/tmpl/TF_no_warning_P2.json index f2ef337..152736c 100755 --- a/public/tmpl/TF_no_warning_P2.json +++ b/public/tmpl/TF_no_warning_P2.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" + "ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" }, "dc": { "Interpleader": false, diff --git a/public/tmpl/TS_P2_IN.json b/public/tmpl/TS_P2_IN.json index a70c9fe..479478a 100755 --- a/public/tmpl/TS_P2_IN.json +++ b/public/tmpl/TS_P2_IN.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"TS_P2_IN_YYY\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" + "ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" }, "dc": { "Interpleader": true, diff --git a/public/tmpl/T_X.docx b/public/tmpl/T_X.docx index 3ae25d7..06182a4 100755 Binary files a/public/tmpl/T_X.docx and b/public/tmpl/T_X.docx differ diff --git a/public/tmpl/T_no_warning.json b/public/tmpl/T_no_warning.json index 59a8b65..7697123 100755 --- a/public/tmpl/T_no_warning.json +++ b/public/tmpl/T_no_warning.json @@ -1,6 +1,6 @@ { "info": { - "ticket_data": "{\"fid\":\"T3_P2_IN_XXX\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}" + "ticket_data": "{\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}" }, "dc": { "Interpleader": false, @@ -40,7 +40,7 @@ "acc2": "ผู้ถูกฟ้องคดี", "interpleader": "สมชาย ใจดี", "acc3": "ผู้ร้องสอด", - "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ", + "detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว \nเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ", "sendto" : "นายสมศักดิ์ ฟักมี", "warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี", diff --git a/public/tmpl/index.html b/public/tmpl/index.html deleted file mode 100644 index e47e08c..0000000 --- a/public/tmpl/index.html +++ /dev/null @@ -1,20 +0,0 @@ - - - - - - Document - - -

Template & Data

- -
    -
  1. T12_X - (T12_b2_IN, T12_FD155_3s-long) -
  2. -
  3. T_X - (TF_no_warning_P2, T_no_warning, TS_P2_IN.json) -
  4. -
- - \ No newline at end of file