Compare commits
No commits in common. "dev" and "v1.0.33" have entirely different histories.
29 changed files with 1285 additions and 927 deletions
|
|
@ -28,13 +28,13 @@ export async function POST(request: Request) {
|
||||||
if (!parsed.ok) return docverError(parsed.error, 400);
|
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||||
|
|
||||||
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
// const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||||
// const secret =
|
const secret =
|
||||||
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
"487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||||
|
|
||||||
if (secret === undefined || secret === "") {
|
// if (secret === undefined || secret === "") {
|
||||||
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
// return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||||
}
|
// }
|
||||||
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
||||||
const ttlSeconds =
|
const ttlSeconds =
|
||||||
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
|
Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
// ตัวอย่างรองรับค่าจาก 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<NextResponse<unknown>>
|
|
||||||
{
|
|
||||||
// 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)
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
|
import { extractContentControls, extractBookmark, templateContentControls, setCustomDocProps, getDocProps,templateDynamicContents } from "@/lib/lib-tmpl";
|
||||||
import { verifyEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
|
import { verifyDataEd25519, verifyHmacSha256 } from "@/lib/lib-utils";
|
||||||
// export const runtime = "nodejs";
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const DOCX_CONTENT_TYPE =
|
const DOCX_CONTENT_TYPE =
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
|
@ -14,14 +15,27 @@ export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
const file = formData.get("file");
|
const file = formData.get("file");
|
||||||
const TEST_SECRET = String(formData.get("secret") ?? "");
|
const TEST_SECRET = String(formData.get("secret"))??"";
|
||||||
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key") ?? "");
|
const TEST_PUBLIC_KEY_HEX = String(formData.get("public_key"))??"";
|
||||||
|
|
||||||
if (!(file instanceof File) ||
|
|
||||||
(file.type !== DOCX_CONTENT_TYPE && !file.name.toLowerCase().endsWith(".docx") )
|
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")
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error: 'Field "file" must be a .docx file.',
|
error: 'Field "file" must be a .docx file.',
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
|
|
@ -31,6 +45,7 @@ export async function POST(request: Request) {
|
||||||
if (file.size > MAX_UPLOAD_BYTES) {
|
if (file.size > MAX_UPLOAD_BYTES) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error: 'Field "file" exceeds the 100MB size limit.',
|
error: 'Field "file" exceeds the 100MB size limit.',
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
|
|
@ -47,30 +62,35 @@ export async function POST(request: Request) {
|
||||||
const bookmark = extractBookmark(buffer)
|
const bookmark = extractBookmark(buffer)
|
||||||
const properties = getDocProps(buffer)
|
const properties = getDocProps(buffer)
|
||||||
let verify: Record<string, unknown> ={}
|
let verify: Record<string, unknown> ={}
|
||||||
const ticket_data = properties?.custom?.ticket_data
|
const ticket_data = String(properties?.custom?.ticket_data)
|
||||||
const HmacSha256 = String(properties?.custom?.HmacSha256)
|
const HmacSha256 = String(properties?.custom?.HmacSha256)
|
||||||
const Ed25519 = String(properties?.custom?.Ed25519)
|
const Ed25519 = String(properties?.custom?.Ed25519)
|
||||||
|
|
||||||
if(ticket_data && typeof ticket_data === 'string'){
|
if(ticket_data){
|
||||||
const ticketDataObj= JSON.parse(ticket_data)
|
const ticketDataObj= JSON.parse(ticket_data)
|
||||||
const unix_timestamp = Number(ticketDataObj.exp)*1000
|
const unix_timestamp = Number(ticketDataObj.exp)*1000
|
||||||
if(unix_timestamp){
|
if(unix_timestamp){
|
||||||
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" })
|
verify['exp'] = new Date(unix_timestamp).toLocaleString("th-TH", { timeZone: "Asia/Bangkok" })
|
||||||
}
|
}
|
||||||
if(TEST_SECRET && HmacSha256){
|
if(TEST_SECRET && HmacSha256){
|
||||||
verify['HmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256)
|
verify['verifyHmacSha256'] = verifyHmacSha256(ticket_data,TEST_SECRET,HmacSha256)
|
||||||
}
|
}
|
||||||
if(TEST_PUBLIC_KEY_HEX&& Ed25519){
|
if(TEST_PUBLIC_KEY_HEX&& Ed25519){
|
||||||
verify["Ed25519"]=verifyEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX)
|
verify["Ed25519"]=verifyDataEd25519(ticket_data,Ed25519,TEST_PUBLIC_KEY_HEX)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
let returnData : Record<string, unknown> = {
|
||||||
|
success: true,
|
||||||
|
cc,bookmark,properties,verify
|
||||||
|
}
|
||||||
|
|
||||||
let returnData : Record<string, unknown> = {cc,bookmark,properties,verify}
|
|
||||||
return NextResponse.json(returnData);
|
return NextResponse.json(returnData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error:
|
error:
|
||||||
error instanceof Error
|
error instanceof Error
|
||||||
? error.message
|
? error.message
|
||||||
|
|
|
||||||
|
|
@ -1,101 +0,0 @@
|
||||||
// สร้าง 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 { applyTemplate } from '../render/route'
|
|
||||||
import { DocumentTemplate, templateContentControls, setCustomDocProps, templateDynamicContents } 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 || ''
|
|
||||||
|
|
||||||
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);
|
|
||||||
|
|
||||||
// สร้าง 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 }
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
518
app/api/template/lib-tmpl.ts
Normal file
518
app/api/template/lib-tmpl.ts
Normal file
|
|
@ -0,0 +1,518 @@
|
||||||
|
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<string, unknown>;
|
||||||
|
|
||||||
|
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",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -1,49 +1,73 @@
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { DocumentTemplate, templateContentControls, setCustomDocProps, templateDynamicContents } from "@/lib/lib-tmpl";
|
import { DocumentTemplate, templateContentControls, setCustomDocProps, getDocProps, templateDynamicContents } from "@/lib/lib-tmpl";
|
||||||
import { generateHmacSha256, signEd25519, sortObjectKeys } from '@/lib/lib-utils';
|
import { generateHmacSha256, verifyHmacSha256, signDataEd25519, verifyDataEd25519, sortObjectKeys } from '@/lib/lib-utils';
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
||||||
const DOCX_CONTENT_TYPE =
|
const DOCX_CONTENT_TYPE =
|
||||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||||
|
|
||||||
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||||
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||||
const TICKET_SEC = Number(process.env.TICKET_SEC||'300')
|
|
||||||
|
|
||||||
export async function POST(request: Request) {
|
export async function POST(request: Request) {
|
||||||
try {
|
try {
|
||||||
const formData = await request.formData();
|
const formData = await request.formData();
|
||||||
|
|
||||||
const template = formData.get("template");
|
const template = formData.get("template");
|
||||||
const dataFile = formData.get("data");
|
const dataFile = formData.get("data");
|
||||||
const dataJson = formData.get("dataJson");
|
const dataJson = formData.get("dataJson");
|
||||||
const TEST_SECRET = String(formData.get("secret")||"")
|
const TEST_SECRET = (typeof formData.get("secret") === "string") ?
|
||||||
const TEST_PRIVATE_KEY_HEX = String(formData.get("private_key")||"")
|
String(formData.get("secret")) : "super_secret";
|
||||||
|
const TEST_PRIVATE_KEY_HEX = (typeof formData.get("private_key") === "string") ?
|
||||||
|
String(formData.get("private_key")) : "8f2077b57b39399b6a7828f35da03a63ade8e3a36a6bf437b091a8bdb3d18ee1";
|
||||||
|
|
||||||
|
console.log(`secret:${TEST_SECRET}`)
|
||||||
|
console.log(`private:${TEST_PRIVATE_KEY_HEX}`)
|
||||||
|
// -----------------------------
|
||||||
// ตรวจสอบ Template
|
// ตรวจสอบ Template
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
if (!(template instanceof File)) {
|
if (!(template instanceof File)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error: 'Missing DOCX file in field "template".',
|
error: 'Missing DOCX file in field "template".',
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (template.type !== DOCX_CONTENT_TYPE &&
|
if (
|
||||||
!template.name.toLowerCase().endsWith(".docx") &&
|
template.type !== DOCX_CONTENT_TYPE &&
|
||||||
template.size > MAX_UPLOAD_BYTES
|
!template.name.toLowerCase().endsWith(".docx")
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
error: 'Field "template" must be a .docx file and not exceeds the 100MB size limit.',
|
success: false,
|
||||||
|
error: 'Field "template" must be a .docx file.',
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (template.size > MAX_UPLOAD_BYTES) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error: 'Field "template" exceeds the 100MB size limit.',
|
||||||
|
},
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
// อ่าน JSON
|
// อ่าน JSON
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
let templateData: DocumentTemplate;
|
let templateData: DocumentTemplate;
|
||||||
|
|
||||||
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
|
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
|
||||||
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
|
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
|
||||||
try {
|
try {
|
||||||
|
|
@ -54,11 +78,13 @@ export async function POST(request: Request) {
|
||||||
if (dataFile.size > MAX_UPLOAD_BYTES) {
|
if (dataFile.size > MAX_UPLOAD_BYTES) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error: 'Field "data" exceeds the 100MB size limit.',
|
error: 'Field "data" exceeds the 100MB size limit.',
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// กรณี upload data.json
|
// กรณี upload data.json
|
||||||
const text = await dataFile.text();
|
const text = await dataFile.text();
|
||||||
|
|
||||||
|
|
@ -66,6 +92,7 @@ export async function POST(request: Request) {
|
||||||
} else {
|
} else {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error:
|
error:
|
||||||
'Missing JSON data. Send either "data" file or "dataJson" field.',
|
'Missing JSON data. Send either "data" file or "dataJson" field.',
|
||||||
},
|
},
|
||||||
|
|
@ -75,12 +102,14 @@ export async function POST(request: Request) {
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error"
|
error: `Invalid JSON data (${error instanceof Error ? error.message : "parse error"
|
||||||
}).`,
|
}).`,
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON ต้องเป็น object
|
// JSON ต้องเป็น object
|
||||||
if (
|
if (
|
||||||
templateData === null ||
|
templateData === null ||
|
||||||
|
|
@ -89,6 +118,7 @@ export async function POST(request: Request) {
|
||||||
) {
|
) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
|
success: false,
|
||||||
error: "JSON data must be an object.",
|
error: "JSON data must be an object.",
|
||||||
},
|
},
|
||||||
{ status: 400 },
|
{ status: 400 },
|
||||||
|
|
@ -102,64 +132,34 @@ export async function POST(request: Request) {
|
||||||
let templateBuffer: Buffer = Buffer.from(
|
let templateBuffer: Buffer = Buffer.from(
|
||||||
await template.arrayBuffer(),
|
await template.arrayBuffer(),
|
||||||
);
|
);
|
||||||
const output = await applyTemplate(templateBuffer,templateData,TEST_SECRET,TEST_PRIVATE_KEY_HEX);
|
|
||||||
|
|
||||||
// -----------------------------
|
|
||||||
// ส่ง 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),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
|
||||||
|
|
||||||
return NextResponse.json(
|
|
||||||
{
|
|
||||||
|
|
||||||
error:
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Internal Server Error",
|
|
||||||
},
|
|
||||||
{ status: 500 },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentTemplate,
|
|
||||||
secret:string,private_key:string):Promise<Buffer<ArrayBufferLike>>
|
|
||||||
{
|
|
||||||
//Calculate hash
|
//Calculate hash
|
||||||
const originalTicket_data = templateData?.info?.ticket_data //expect valid json string
|
const originalTicket_data = templateData?.info?.ticket_data //expect valid json string
|
||||||
// console.log("templateData",templateData?.info?.ticket_data)
|
// console.log("templateData",templateData?.info?.ticket_data)
|
||||||
if (originalTicket_data && typeof originalTicket_data === 'string') {
|
if (originalTicket_data && typeof originalTicket_data === 'string') {
|
||||||
try {
|
try {
|
||||||
|
|
||||||
|
const ttlSeconds: number = 300
|
||||||
const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data
|
const originalTicketObj = JSON.parse(originalTicket_data)//sort and add expire to ticket data
|
||||||
if (originalTicketObj && typeof originalTicketObj === 'object') {
|
if (originalTicketObj && typeof originalTicketObj === 'object') {
|
||||||
console.log("Found valid ticket data: hash it")
|
console.log("Found valid ticket data: hash it")
|
||||||
// คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ)
|
// คำนวณค่า expire (ปัจจุบัน + จำนวนวินาทีที่ต้องการให้หมดอายุ)
|
||||||
// หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms)
|
// หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms)
|
||||||
const expireTimestamp = Math.floor(Date.now() / 1000) + TICKET_SEC;
|
const expireTimestamp = Math.floor(Date.now() / 1000) + ttlSeconds;
|
||||||
const fullObj = { ...originalTicketObj, exp: expireTimestamp };
|
const fullObj = { ...originalTicketObj, exp: expireTimestamp };
|
||||||
const sortedObj = sortObjectKeys(fullObj);
|
const sortedObj = sortObjectKeys(fullObj);
|
||||||
|
|
||||||
// แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash
|
// แปลงกลับเป็น JSON string เพื่อนำไปใช้ส่งและทำ Hash
|
||||||
const ticket_data = JSON.stringify(sortedObj);
|
const ticket_data = JSON.stringify(sortedObj);
|
||||||
// console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data)
|
// console.log("check secret:",TEST_SECRET,TEST_PRIVATE_KEY_HEX,ticket_data)
|
||||||
const HmacSha256 = generateHmacSha256(ticket_data, secret)
|
const HmacSha256 = generateHmacSha256(ticket_data, TEST_SECRET)
|
||||||
|
|
||||||
const Ed25519 = signEd25519(ticket_data, private_key)
|
const Ed25519 = signDataEd25519(ticket_data, TEST_PRIVATE_KEY_HEX)
|
||||||
templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 })
|
templateBuffer = setCustomDocProps(templateBuffer, { ticket_data, HmacSha256, Ed25519 })
|
||||||
}
|
}
|
||||||
} catch (error) { //just skip
|
} catch (error) {
|
||||||
console.error("Set custom property fail",error);
|
console.error("Set custom property fail",error);
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -173,6 +173,36 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT
|
||||||
console.log("Apply Content Control")
|
console.log("Apply Content Control")
|
||||||
templateBuffer = templateContentControls(templateBuffer, templateData.cc);
|
templateBuffer = templateContentControls(templateBuffer, templateData.cc);
|
||||||
}
|
}
|
||||||
return templateBuffer
|
|
||||||
|
|
||||||
|
const output = templateBuffer;
|
||||||
|
|
||||||
|
// -----------------------------
|
||||||
|
// ส่ง DOCX กลับ
|
||||||
|
// -----------------------------
|
||||||
|
|
||||||
|
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),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error);
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Internal Server Error",
|
||||||
|
},
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
29
app/api/template/test/T2.json
Normal file
29
app/api/template/test/T2.json
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
{
|
||||||
|
"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": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่าผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี"
|
||||||
|
}
|
||||||
BIN
app/api/template/test/T3.docx
Normal file
BIN
app/api/template/test/T3.docx
Normal file
Binary file not shown.
BIN
app/api/template/test/Test.docx
Normal file
BIN
app/api/template/test/Test.docx
Normal file
Binary file not shown.
298
app/api/template/test/make-protected.mjs
Normal file
298
app/api/template/test/make-protected.mjs
Normal file
|
|
@ -0,0 +1,298 @@
|
||||||
|
// ทดลอง: เปิด 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("<?xml")
|
||||||
|
? text
|
||||||
|
: '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>\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(
|
||||||
|
`<?xml version="1.0" encoding="UTF-8" standalone="yes"?>` +
|
||||||
|
`<Properties xmlns="${CUSTOM_NS}" xmlns:vt="${VT_NS}"/>`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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(/<w:documentProtection[^>]*\/>/)?.[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(/<w:sdtPr>[\s\S]*?<\/w:sdtPr>/g) ?? [];
|
||||||
|
const plain = sdtPrs.filter((p) => /<w:text\b/.test(p)).length;
|
||||||
|
const rich = sdtPrs.length - plain;
|
||||||
|
const tagged = sdtPrs.filter((p) => /<w:tag w:val="/.test(p)).length;
|
||||||
|
const permStarts = (docXml.match(/<w:permStart /g) ?? []).length;
|
||||||
|
const permEnds = (docXml.match(/<w:permEnd /g) ?? []).length;
|
||||||
|
const missingLock = sdtPrs.filter(
|
||||||
|
(p) => /<w:tag w:val="/.test(p) && !/<w:lock /.test(p),
|
||||||
|
).length;
|
||||||
|
|
||||||
|
console.log("-- verify (อ่านไฟล์กลับหลังเขียน) --");
|
||||||
|
console.log(`documentProtection : ${prot ?? "(ไม่พบ!)"} ${enforced ? "✓" : "✗ ไม่ถูกเปิดใช้"}`);
|
||||||
|
console.log(`docProps/custom.xml: ${customXml ? "มี ✓" : "ไม่พบ ✗"}`);
|
||||||
|
console.log(
|
||||||
|
"custom properties :",
|
||||||
|
Object.entries(propsBack)
|
||||||
|
.map(([k, v]) => `${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);
|
||||||
127
app/api/template/test/note.md
Normal file
127
app/api/template/test/note.md
Normal file
|
|
@ -0,0 +1,127 @@
|
||||||
|
# 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` → `<w:documentProtection w:edit="readOnly" w:enforcement="1"/>`
|
||||||
|
- T3.docx มี element นี้อยู่เดิมแต่เป็น `w:enforcement="0"` (ตั้งค่าไว้ใน Word แต่ไม่ได้กดบังคับใช้) — สคริปต์แค่พลิกเป็น 1 ไม่ต้องแทรก element ใหม่
|
||||||
|
- **บทเรียนจากทดสอบใน Word จริง (2026-10-07): `readOnly` + enforcement ล็อคทั้งเอกสารรวมถึง Content Control ด้วย** — CC ไม่ได้ถูกยกเว้นให้แก้อัตโนมัติ ต้องคู่กับข้อถัดไป
|
||||||
|
- **กลไกที่ถูกต้อง: ครอบ `w:sdt` ทุกตัวที่มี `w:tag` ด้วย `<w:permStart w:id="N" w:edGrp="everyone"/>` … `<w:permEnd w:id="N"/>`** — คือกลไก "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` เพิ่ม `<Override PartName="/docProps/custom.xml" ContentType="application/vnd.openxmlformats-officedocument.custom-properties+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 คง `<?xml ...?>` declaration ไว้ในผลลัพธ์อยู่แล้ว — ห้าม prepend เพิ่มเอง จะได้ declaration ซ้อนเป็น XML invalid (Word/node อ่านไม่ได้)
|
||||||
|
|
||||||
|
## extract บอก alignment รายช่อง — key `<tag>@align` (2026-10-07) — **ถอดออกแล้วตามคำขอ (ยังไม่เอา)**
|
||||||
|
|
||||||
|
- เคย implement ใน `lib-tmpl.ts` + spec `lib/openapi.ts` แล้ว**ถอดกลับสถานะเดิม** — extract ปัจจุบันคืนแค่ `{ tag: text }` เหมือนเดิม
|
||||||
|
- แนวทางที่พิสูจน์แล้วว่าใช้ได้ (เผื่อกลับมาทำภายหลัง): คืน key `<tag>@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:jc w:val="thaiDistribute"/>` ใน `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)
|
||||||
BIN
app/api/template/test/output-T3.docx
Normal file
BIN
app/api/template/test/output-T3.docx
Normal file
Binary file not shown.
BIN
app/api/template/test/output.docx
Normal file
BIN
app/api/template/test/output.docx
Normal file
Binary file not shown.
43
app/api/template/test/test.http
Normal file
43
app/api/template/test/test.http
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
@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--
|
||||||
|
|
@ -1,100 +0,0 @@
|
||||||
// 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<HTMLInputElement>)=>{
|
|
||||||
// console.log(e.target)
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div style={{ padding: '2rem' }}>
|
|
||||||
<div className="p-8 max-w-md mx-auto space-y-8 bg-gray-50 min-h-screen">
|
|
||||||
<h1 className="text-xl font-bold text-center">ตัวอย่างการใช้ สร้างเอกสารจาก Template</h1>
|
|
||||||
<div>
|
|
||||||
ตัวอย่าง Frontend ส่งข้อมูลผ่าน <form>(POST) หรือ <a>(GET)
|
|
||||||
เพื่อรองรับ Vanilla JavaScript และ Classic Framework ใช้การเปลี่ยนหน้าเพื่อดาว์นโหลดไฟล์
|
|
||||||
ต้องทำให้ ASP.NET มีฟังก์ชั่นเหมือน /docedit/api/template/download-docx
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
|
||||||
<form action="../api/template/download-docx" method="POST">
|
|
||||||
<h2 className="font-semibold text-blue-600 mb-2">Form POST</h2>
|
|
||||||
<DependentDropdown data={templateData} name1='tid' name2='fid'
|
|
||||||
label1="Template(tid):" label2="ข้อมูล(fid):" onSelectChange={handleTmplSelect} />
|
|
||||||
<div className="mb-2"></div>
|
|
||||||
<div>
|
|
||||||
uid:<input type="text" name="uid" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="john"/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
uname:<input type="text" name="uname" className="p-2 mb-2 border border-gray-300 rounded-md" defaultValue="John Doe"/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
style={{
|
|
||||||
padding: '10px 20px',
|
|
||||||
backgroundColor: '#0070f3',
|
|
||||||
color: '#fff',
|
|
||||||
border: 'none',
|
|
||||||
borderRadius: '5px',
|
|
||||||
cursor: 'pointer',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
ดาวน์โหลดเอกสาร MS Word
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
|
||||||
<h2 className="font-semibold text-blue-600 mb-2">A Link Get</h2>
|
|
||||||
<a href="../api/template/download-docx?tid=T_X&fid=T_no_warning&uid=john&uname=John%20Doe">T_no_warning_john_xxx.docx</a><br/>
|
|
||||||
<a href="../api/template/download-docx?tid=T_X&fid=TF_no_warning_P2&uid=john&uname=John%20Doe">TF_no_warning_P2_john_xxx.docx</a><br/>
|
|
||||||
<a href="../api/template/download-docx?tid=T_X&fid=TS_P2_IN&uid=john&uname=John%20Doe">TS_P2_IN_john_xxx.docx</a><br/>
|
|
||||||
|
|
||||||
<a href="../api/template/download-docx?tid=T12_X&fid=T12_b2_IN&uid=john&uname=John%20Doe">T12_b2_IN_john_xxx.docx</a><br/>
|
|
||||||
<a href="../api/template/download-docx?tid=T12_X&fid=T12_FD155_3s-long&uid=john&uname=John%20Doe">T12_FD155_3s-long_john_xxx.docx</a><br/>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
|
||||||
<h2 className="font-semibold text-blue-600 mb-2">Template & Data</h2>
|
|
||||||
Template: <a href="../tmpl/T_X.docx">T_X.docx</a>,
|
|
||||||
<a href="../tmpl/T12_X.docx">T12_X.docx</a><br/>
|
|
||||||
Data: <a href="../tmpl/TF_no_warning_P2.json">TF_no_warning_P2.json</a>,
|
|
||||||
<a href="../tmpl/T_no_warning.json">T_no_warning.json</a>,
|
|
||||||
<a href="../tmpl/TS_P2_IN.json">TS_P2_IN.json</a><br/>
|
|
||||||
<a href="../tmpl/T12_b2_IN.json">T12_b2_IN.json</a>,
|
|
||||||
<a href="../tmpl/T12_FD155_3s-long.json">T12_FD155_3s-long.json</a>
|
|
||||||
</div>
|
|
||||||
<div className="p-4 border rounded-xl bg-white shadow-sm">
|
|
||||||
<h2 className="font-semibold text-blue-600 mb-2">Flow</h2>
|
|
||||||
<div className="text-xl font-bold text-center">
|
|
||||||
หน้านี้(เลือกไฟล์)<br/>
|
|
||||||
↓ <br/>
|
|
||||||
download-docx(ตรวจสิทธิ์,ส่งเทมเพลต ID,ส่งข้อมูล)<br/>
|
|
||||||
↓ <br/>
|
|
||||||
generate(เปิดเทมเพลต,สร้าง docx)<br/>
|
|
||||||
↓ <br/>
|
|
||||||
ดาว์นโหลด docx เปิด MS Word อัตโนมัติ<br/>
|
|
||||||
↓ <br/>
|
|
||||||
Add-ins ตรวจสอบไฟล์, ผู้ใช้แก้ไข docx<br/>
|
|
||||||
↓ <br/>
|
|
||||||
Versioning(คุมเวอร์ชั่น)<br/>
|
|
||||||
↓ <br/>
|
|
||||||
ecourt(อัปเดตฐานข้อมูลและไฟล์)<br/>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
'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<string>('');
|
|
||||||
const [subSelection, setSubSelection] = useState<string>('');
|
|
||||||
|
|
||||||
const handleMainChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
const value = e.target.value;
|
|
||||||
setMainSelection(value);
|
|
||||||
setSubSelection('');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubChange = (e: ChangeEvent<HTMLSelectElement>) => {
|
|
||||||
setSubSelection(e.target.value);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (onSelectChange) {
|
|
||||||
onSelectChange(mainSelection, subSelection);
|
|
||||||
}
|
|
||||||
}, [mainSelection, subSelection, onSelectChange]);
|
|
||||||
|
|
||||||
const subOptions = mainSelection ? data[mainSelection] : [];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 w-full">
|
|
||||||
{/* Dropdown ที่ 1 */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-700">{label1}</label>
|
|
||||||
<select
|
|
||||||
name={name1} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
|
||||||
value={mainSelection}
|
|
||||||
onChange={handleMainChange}
|
|
||||||
required
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-md bg-white text-black text-sm"
|
|
||||||
>
|
|
||||||
<option value="">{placeholder1}</option>
|
|
||||||
{Object.keys(data).map((key) => (
|
|
||||||
<option key={key} value={key}>{key}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dropdown ที่ 2 */}
|
|
||||||
<div className="flex flex-col gap-1.5">
|
|
||||||
<label className="text-sm font-medium text-gray-700">{label2}</label>
|
|
||||||
<select
|
|
||||||
name={name2} // ใส่ name ตรงนี้เพื่อให้ Server Action ดึงค่าได้
|
|
||||||
value={subSelection}
|
|
||||||
onChange={handleSubChange}
|
|
||||||
disabled={!mainSelection}
|
|
||||||
required
|
|
||||||
className="w-full p-2 border border-gray-300 rounded-md bg-white disabled:bg-gray-100 text-black text-sm"
|
|
||||||
>
|
|
||||||
<option value="">
|
|
||||||
{mainSelection ? '-- กรุณาเลือก --' : placeholder2}
|
|
||||||
</option>
|
|
||||||
{subOptions.map((item) => (
|
|
||||||
<option key={item} value={item}>{item}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -37,6 +37,8 @@ ENV DOCVER_LINK_TTL_SECONDS=900
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
|
# ─── Stage 3: runtime ตัวเบา (แค่ standalone + public/) ───
|
||||||
FROM node:24-alpine AS runner
|
FROM node:24-alpine AS runner
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
|
||||||
|
|
@ -121,12 +121,12 @@ export function verifyDocverToken(
|
||||||
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
||||||
export function requireDocverApiKey(request: Request): Response | null {
|
export function requireDocverApiKey(request: Request): Response | null {
|
||||||
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
||||||
const envKey = process.env.DOCVER_API_KEY?.trim();
|
//const envKey = process.env.DOCVER_API_KEY?.trim();
|
||||||
// const envKey =
|
const envKey =
|
||||||
// "2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
|
"2d9b76d49a4f5311f6a0dacf759ecec53f34b6d102f24c696890a4052c99cf54";
|
||||||
if (envKey === undefined || envKey === "") {
|
// if (envKey === undefined || envKey === "") {
|
||||||
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
// return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
|
||||||
}
|
// }
|
||||||
const provided = request.headers.get("x-api-key");
|
const provided = request.headers.get("x-api-key");
|
||||||
if (provided === null || provided === "") {
|
if (provided === null || provided === "") {
|
||||||
return docverError("API key ไม่ถูกต้อง", 401);
|
return docverError("API key ไม่ถูกต้อง", 401);
|
||||||
|
|
@ -150,10 +150,10 @@ export function requireDocverBearer(
|
||||||
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
|
): { ok: true; claims: DocverTokenClaims } | { ok: false; response: Response } {
|
||||||
const denied = () =>
|
const denied = () =>
|
||||||
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
||||||
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
//const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||||
// const secret =
|
const secret =
|
||||||
// "487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
"487023dd1c1c4d6a07475e96b1cc300fec1e68ac9c2dd7d5ac9b1bf75da1053e";
|
||||||
if (secret === undefined || secret === "") return denied();
|
//if (secret === undefined || secret === "") return denied();
|
||||||
const header = request.headers.get("authorization");
|
const header = request.headers.get("authorization");
|
||||||
if (header === null) return denied();
|
if (header === null) return denied();
|
||||||
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ console.log('Is Signature Valid?:', isValid); // true
|
||||||
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
* @param privateKeyHex คีย์ลับในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||||
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
|
* @returns ลายเซ็น (Signature) ในรูปแบบ Hex String (ความยาว 128 ตัวอักษร)
|
||||||
*/
|
*/
|
||||||
export function signEd25519(message: string | Buffer, privateKeyHex: string): string {
|
export function signDataEd25519(message: string | Buffer, privateKeyHex: string): string {
|
||||||
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
|
const rawKeyBuffer = Buffer.from(privateKeyHex, 'hex');
|
||||||
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
|
// ตัดเอาเฉพาะ 32 bytes (Seed) กรณีที่ Hex ยาว 64 bytes (ที่ต่อกับ Public Key มา)
|
||||||
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
|
const seed = rawKeyBuffer.length === 64 ? rawKeyBuffer.subarray(0, 32) : rawKeyBuffer;
|
||||||
|
|
@ -76,7 +76,7 @@ export function signEd25519(message: string | Buffer, privateKeyHex: string): st
|
||||||
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
* @param publicKeyHex คีย์สาธารณะในรูปแบบ Hex String (ความยาว 64 ตัวอักษร)
|
||||||
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
|
* @returns ผลลัพธ์ความถูกต้อง (true = ผ่าน, false = ไม่ผ่าน)
|
||||||
*/
|
*/
|
||||||
export function verifyEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
|
export function verifyDataEd25519(message: string | Buffer, signatureHex: string, publicKeyHex: string): boolean {
|
||||||
try {
|
try {
|
||||||
const rawPublicKey = Buffer.from(publicKeyHex, 'hex');
|
const rawPublicKey = Buffer.from(publicKeyHex, 'hex');
|
||||||
|
|
||||||
|
|
|
||||||
504
lib/openapi.ts
504
lib/openapi.ts
|
|
@ -32,12 +32,13 @@ export const openApiSpec = {
|
||||||
{
|
{
|
||||||
name: "template",
|
name: "template",
|
||||||
description:
|
description:
|
||||||
"Next Generation of Template : เป็นเทมเพลต MS Word(docx) รองรับรูปแบบข้อมูลที่ซับซ้อนได้ ",
|
"Template เอกสาร DOCX ด้วย Content Control — ส่ง template (.docx) + data (JSON) " +
|
||||||
|
"แล้วได้เอกสารที่กรอกค่าแล้วกลับมา",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "documents",
|
name: "documents",
|
||||||
description:
|
description:
|
||||||
"จัด version เอกสาร (docver): เอกสารเดียวต่อ docId แต่ละ " +
|
"จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " +
|
||||||
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " +
|
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " +
|
||||||
"ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " +
|
"ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " +
|
||||||
"DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " +
|
"DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " +
|
||||||
|
|
@ -74,15 +75,67 @@ 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 <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>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": {
|
"/api/template/render": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["template"],
|
tags: ["template"],
|
||||||
summary: "สร้างเอกสารจาก template (.docx) + data (JSON) ใช้สำหรับนักพัฒนาเท่านั้น",
|
summary: "สร้างเอกสารจาก template (.docx) + data (JSON)",
|
||||||
description:
|
description:
|
||||||
"แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " +
|
"แทนค่า JSON ลงใน Content Control ของ template แล้วคืนไฟล์ .docx ใหม่ " +
|
||||||
'(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' +
|
'(filename ตอน download = generated-document.docx) — ส่ง data ได้ 2 ทาง: ' +
|
||||||
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)<br/>'+
|
'field "data" เป็นไฟล์ JSON หรือ field "dataJson" เป็น string ตรง ๆ (เลือกอย่างใดอย่างหนึ่ง)',
|
||||||
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ private key เองไม่ใช้ของระบบของระบบ</i>',
|
|
||||||
requestBody: {
|
requestBody: {
|
||||||
required: true,
|
required: true,
|
||||||
content: {
|
content: {
|
||||||
|
|
@ -141,410 +194,6 @@ 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 ซึ่งเหมาะกับการเอาไปใส่ในฐานข้อมูล"+
|
|
||||||
'<i>สำหรับนักพัฒนา Template เท่านั้น ไม่ต้อง ใช้ API Key ต้องตั้งค่า secret และ Public key เองไม่ใช้ของระบบของระบบ</i>',
|
|
||||||
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 <br/> openssl genpkey -algorithm ED25519 -out private.pem <br/> openssl pkey -in private.pem -outform DER | tail -c 32 | xxd -p -c 32 > private.hex <br/>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) "+
|
|
||||||
"จะได้เอกสาร รูปแบบฟอร์มที่กรอกค่าแล้วดึงค่ากลับมาใช้งานต่อได้เช่นอัปเดตฐานข้อมูล<br><ul>"+
|
|
||||||
"<li>JSON payload ต้องมี info.ticket_data เป็น JSON string รูปแบบถูกต้อวมีค่าครบ(tid, fid และ uid)</li>" +
|
|
||||||
"<li>ไฟล์เทมเพลต(tid)ในระบบ + ข้อมูล(payload) เพื่อสร้างเอกสาร </li>" +
|
|
||||||
"<li>ticket_data จะทำการ hash/sign แบบอายุสั้นแล้วนำไปฝังไว้ใน custom property เพื่อนำไปใช้ร่วมกับระบบ Versioning</li>"+
|
|
||||||
"</ul>",
|
|
||||||
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": {
|
"/api/note/external/documents": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["external"],
|
tags: ["external"],
|
||||||
|
|
@ -875,6 +524,7 @@ export const openApiSpec = {
|
||||||
Error: {
|
Error: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
success: { type: "boolean", example: false },
|
||||||
error: { type: "string" },
|
error: { type: "string" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -913,44 +563,14 @@ export const openApiSpec = {
|
||||||
ExtractResponse: {
|
ExtractResponse: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
success: { type: "boolean", example: true },
|
||||||
data: {
|
data: {
|
||||||
type: "object",
|
type: "object",
|
||||||
description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)",
|
description: "ชื่อแท็ก Content Control → ข้อความในนั้น (หลายบรรทัดคั่นด้วย \\n)",
|
||||||
additionalProperties: { type: "string" },
|
additionalProperties: { type: "string" },
|
||||||
example: {
|
example: {
|
||||||
"cc": {
|
blackno: "คดีหมายเลขดำที่",
|
||||||
"blackyear": "๙๐/๒๕๖๙",
|
court: "ศาลปกครองสุพรรณบุรี",
|
||||||
"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
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,6 @@ export function middleware(req: NextRequest) {
|
||||||
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
|
// ยังถูกคุมอยู่ดีเพราะ root page ส่งต่อให้ /note ซึ่ง middleware จับตามปกติ
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: [
|
matcher: [
|
||||||
"/((?!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).+)",
|
"/((?!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).+)",
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"ticket_data": "{\"fid\":\"T12_FD155_3s-long\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
"ticket_data": "{\"fid\":\"T12_FD155_3s-long_ZZZ\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
|
||||||
},
|
},
|
||||||
"dc": {
|
"dc": {
|
||||||
"Interpleader": false,
|
"Interpleader": false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"ticket_data": "{\"fid\":\"T12_b2_IN\",\"tid\":\"T12\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
"ticket_data": "{\"fid\":\"T12_b2_IN_XXX\",\"tid\":\"T12\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
|
||||||
},
|
},
|
||||||
"dc": {
|
"dc": {
|
||||||
"Interpleader": true,
|
"Interpleader": true,
|
||||||
|
|
|
||||||
51
public/tmpl/T12_b2_IN.out.json
Normal file
51
public/tmpl/T12_b2_IN.out.json
Normal file
|
|
@ -0,0 +1,51 @@
|
||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
"ticket_data": "{\"fid\":\"TF_no_warning_P2\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
|
||||||
},
|
},
|
||||||
"dc": {
|
"dc": {
|
||||||
"Interpleader": false,
|
"Interpleader": false,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"ticket_data": "{\"fid\":\"TS_P2_IN\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
"ticket_data": "{\"fid\":\"TS_P2_IN_YYY\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
|
||||||
},
|
},
|
||||||
"dc": {
|
"dc": {
|
||||||
"Interpleader": true,
|
"Interpleader": true,
|
||||||
|
|
|
||||||
Binary file not shown.
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"info": {
|
"info": {
|
||||||
"ticket_data": "{\"fid\":\"T_no_warning\",\"tid\":\"T_X\",\"uid\":\"john\",\"uname\":\"John Doe\"}"
|
"ticket_data": "{\"fid\":\"T3_P2_IN_XXX\",\"tid\":\"T_X\",\"uid\":\"oom\",\"uname\":\"สรวิชญ์\"}"
|
||||||
},
|
},
|
||||||
"dc": {
|
"dc": {
|
||||||
"Interpleader": false,
|
"Interpleader": false,
|
||||||
|
|
@ -40,7 +40,7 @@
|
||||||
"acc2": "ผู้ถูกฟ้องคดี",
|
"acc2": "ผู้ถูกฟ้องคดี",
|
||||||
"interpleader": "สมชาย ใจดี",
|
"interpleader": "สมชาย ใจดี",
|
||||||
"acc3": "ผู้ร้องสอด",
|
"acc3": "ผู้ร้องสอด",
|
||||||
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว \nเห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
|
"detail1": "ตุลาการเจ้าของสำนวนได้พิจารณาคำฟ้อง และเอกสารประกอบคำฟ้องแล้ว เห็นว่าคดีนี้เป็นคดีพิพาทซึ่งไม่อยู่ในอำนาจศาลปกครองสูงสุด\n\nผู้ฟ้องเป็นผู้มีสิทธิฟ้องคดี คดีนี้มีกฎหมายกำหนดให้ต้องดำเนินการแก้ไข ความเดือดร้อนหรือเสียหายก่อนฟ้องคดี ",
|
||||||
"sendto" : "นายสมศักดิ์ ฟักมี",
|
"sendto" : "นายสมศักดิ์ ฟักมี",
|
||||||
|
|
||||||
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
|
"warning": "หากผู้ถูกฟ้องคดีไม่ปฏิบัติตามคำสั่งศาลภายในระยะเวลาที่กำหนด ศาลจะถือว่า ผู้ถูกฟ้องคดียอมรับข้อเท็จจริงตามข้อหาของผู้ฟ้องคดี",
|
||||||
|
|
|
||||||
20
public/tmpl/index.html
Normal file
20
public/tmpl/index.html
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Document</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Template & Data</h1>
|
||||||
|
|
||||||
|
<ol>
|
||||||
|
<li><a href="T12_X.docx">T12_X</a>
|
||||||
|
(<a href="T12_b2_IN.json">T12_b2_IN</a>, <a href="T12_FD155_3s-long.json">T12_FD155_3s-long</a>)
|
||||||
|
</li>
|
||||||
|
<li><a href="T_X.docx">T_X</a>
|
||||||
|
(<a href="TF_no_warning_P2.json">TF_no_warning_P2</a>, <a href="T_no_warning">T_no_warning</a>, <a href="T_no_warning">TS_P2_IN.json</a>)
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue