feat(api): add swagger ui and docx template api
เพิ่มกลุ่ม API docx template สำหรับ wordaddin (WIP): - POST /api/template/extract ดึงค่า Content Control ทั้งหมดจาก .docx - POST /api/template/render แทนค่า JSON ลง template คืน .docx ใหม่ - GET /api/template/test ตรวจว่า service มีชีวิต logic แชร์ที่ app/api/template/lib-tmpl.ts (pizzip+xpath) ไม่แตะ DB เพิ่มหน้า Swagger UI ที่ GET /api/swagger — spec inline จาก lib/openapi.ts ครอบ API ทุกกลุ่ม (template + N10), asset self-host ที่ public/note/swagger-ui/ จาก postinstall scripts/copy-swagger-ui.mjs (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 เหมือน patch-pdf-worker, Docker build รัน script นี้ด้วยผ่าน web.Dockerfile) middleware เปิดกว้างให้ api/template, api/swagger, note/swagger-ui จาก code review: จำกัดไฟล์แนบ 100MB ทั้ง extract/render ก่อนอ่าน เข้าหน่วยความจำกัน DoS, คืน 400 แทน 500 เมื่อ JSON ผิด format, sync คำอธิบาย BadRequest ใน openapi spec ให้ตรง behavior ใหม่ Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
parent
8cc15ea75d
commit
073f72db7d
20 changed files with 1479 additions and 10 deletions
44
app/api/swagger/route.ts
Normal file
44
app/api/swagger/route.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
import { openApiSpec } from "@/lib/openapi";
|
||||
import { withBasePath } from "@/utils/base-path";
|
||||
|
||||
// GET /api/swagger — หน้า Swagger UI ของ API ทั้งหมด (กลุ่ม template + N10)
|
||||
// (asset สำหรับ intranet: postinstall scripts/copy-swagger-ui.mjs คัดลอก
|
||||
// swagger-ui-dist ไป public/note/swagger-ui/ — pattern เดียวกับ pdf.worker)
|
||||
// spec inline จาก lib/openapi.ts ตรง ๆ ไม่มี endpoint spec แยก
|
||||
export const runtime = "nodejs";
|
||||
|
||||
// กัน </script> ใน JSON ตัดสคริปต์ขาย (spec เป็นของเราเองทั้งหมด แค่กันกรณี
|
||||
// ข้อความ example มี < ในอนาคต)
|
||||
const SPEC_JSON = JSON.stringify(openApiSpec).replace(/</g, "\\u003c");
|
||||
|
||||
const HTML = `<!doctype html>
|
||||
<html lang="th">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Swagger UI — Judge's Note API</title>
|
||||
<link rel="stylesheet" href="${withBasePath("/swagger-ui/swagger-ui.css")}">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="${withBasePath("/swagger-ui/swagger-ui-bundle.js")}"></script>
|
||||
<script>
|
||||
window.onload = function () {
|
||||
window.ui = SwaggerUIBundle({
|
||||
spec: ${SPEC_JSON},
|
||||
dom_id: "#swagger-ui",
|
||||
deepLinking: true,
|
||||
persistAuthorization: true
|
||||
});
|
||||
};
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
export async function GET() {
|
||||
return new NextResponse(HTML, {
|
||||
headers: { "content-type": "text/html; charset=utf-8" },
|
||||
});
|
||||
}
|
||||
75
app/api/template/extract/route.ts
Normal file
75
app/api/template/extract/route.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import { extractContentControls } from "../lib-tmpl";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
const file = formData.get("file");
|
||||
|
||||
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(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "file" must be a .docx file.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (file.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "file" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// File → Buffer
|
||||
const buffer = Buffer.from(await file.arrayBuffer());
|
||||
|
||||
// ใช้ lib-tmpl.ts เดิม
|
||||
const data = extractContentControls(buffer);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Internal Server Error",
|
||||
},
|
||||
{ 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",
|
||||
});
|
||||
}
|
||||
171
app/api/template/render/route.ts
Normal file
171
app/api/template/render/route.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { NextResponse } from "next/server";
|
||||
import {
|
||||
templateContentControls,
|
||||
type TemplateData,
|
||||
} from "../lib-tmpl";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const DOCX_CONTENT_TYPE =
|
||||
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
||||
|
||||
// cap เดียวกับ N4 (app/actions/files.ts) — route นี้เปิดกว้างให้ภายนอกยิงได้
|
||||
// ต้องเช็คขนาดก่อนอ่านเข้าหน่วยความจำ กัน DoS (bodySizeLimit ครอบเฉพาะ Server Actions)
|
||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; // 100MB
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const formData = await request.formData();
|
||||
|
||||
const template = formData.get("template");
|
||||
const dataFile = formData.get("data");
|
||||
const dataJson = formData.get("dataJson");
|
||||
|
||||
// -----------------------------
|
||||
// ตรวจสอบ Template
|
||||
// -----------------------------
|
||||
|
||||
if (!(template instanceof File)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Missing DOCX file in field "template".',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
template.type !== DOCX_CONTENT_TYPE &&
|
||||
!template.name.toLowerCase().endsWith(".docx")
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "template" must be a .docx file.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
if (template.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "template" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// อ่าน JSON
|
||||
// -----------------------------
|
||||
|
||||
let data: TemplateData;
|
||||
|
||||
// JSON ผิด syntax เป็นความผิดของฝั่งผู้เรียก — คืน 400 ไม่ใช่ 500
|
||||
// (catch กลางท้ายฟังก์ชันสงวนไว้ให้ error ของระบบเท่านั้น)
|
||||
try {
|
||||
if (typeof dataJson === "string" && dataJson.length > 0) {
|
||||
// กรณีส่ง JSON ผ่าน form field
|
||||
data = JSON.parse(dataJson) as TemplateData;
|
||||
} else if (dataFile instanceof File) {
|
||||
if (dataFile.size > MAX_UPLOAD_BYTES) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Field "data" exceeds the 100MB size limit.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// กรณี upload data.json
|
||||
const text = await dataFile.text();
|
||||
|
||||
data = JSON.parse(text) as TemplateData;
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error:
|
||||
'Missing JSON data. Send either "data" file or "dataJson" field.',
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: `Invalid JSON data (${
|
||||
error instanceof Error ? error.message : "parse error"
|
||||
}).`,
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// JSON ต้องเป็น object
|
||||
if (
|
||||
data === null ||
|
||||
typeof data !== "object" ||
|
||||
Array.isArray(data)
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "JSON data must be an object.",
|
||||
},
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
// -----------------------------
|
||||
// Template File → Buffer
|
||||
// -----------------------------
|
||||
|
||||
const templateBuffer = Buffer.from(
|
||||
await template.arrayBuffer(),
|
||||
);
|
||||
|
||||
// -----------------------------
|
||||
// ใช้ lib-tmpl.ts เดิม
|
||||
// -----------------------------
|
||||
|
||||
const output = templateContentControls(
|
||||
templateBuffer,
|
||||
data,
|
||||
);
|
||||
|
||||
// -----------------------------
|
||||
// ส่ง 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.
57
app/api/template/test/note.md
Normal file
57
app/api/template/test/note.md
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# 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 เตรียมไว้ก่อน
|
||||
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.
10
app/api/template/test/route.ts
Normal file
10
app/api/template/test/route.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { NextResponse } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
service: "docx-template-api",
|
||||
});
|
||||
}
|
||||
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--
|
||||
Loading…
Add table
Add a link
Reference in a new issue