feat: add GET /api/documents/latest returning latest version file with stamped ticket
All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 4s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m23s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 5s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 0s

Server-to-server endpoint for the e-Court backend (x-api-key against
DOCVER_API_KEY or EXTERNAL_API_KEY):
- type=pdf returns the latest version's PDF bytes (404 when absent)
- type=docx stamps fresh ticket custom properties (exp + HmacSha256 via
  addinTicketSecret(), Ed25519 when PRIVATE_KEY_HEX is set) into an
  in-memory copy before returning — MinIO object is never modified
- docId comes from fid inside the ticket_data query param, which must
  include fid/uid/uname so the stamped file stays redeemable by the
  add-in at /api/documents/token (buildStampedTicketData drops null
  values because parseDocverTicket rejects tid:null)
- adds parseTicketDataParam, parseLatestTypeParam, requireDocumentApiKey,
  findLatestVersionForDoc, getVersionObjectBytes + unit tests (320 pass)
- syncs PLAN-DOCVER.md, OpenAPI spec and .env.example

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
Suphonchai Phoonsawat 2026-10-31 11:09:42 +07:00
parent 5bc8d9a817
commit 0b6ee8cc84
12 changed files with 806 additions and 9 deletions

View file

@ -78,6 +78,17 @@ export function parseDownloadFormat(raw: string | null):
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
}
/** ?type= ของ GET /api/documents/latest — บังคับระบุต่างจาก format ของ
* download-link (ไม่มีค่า default): type=docx เดินไปทาง stamping ซึ่งเปลี่ยน
* ไฟล์ที่คืน จึงอย่าเดาแทนผู้เรียก */
export function parseLatestTypeParam(raw: string | null):
| { ok: true; type: "docx" | "pdf" }
| DocverError {
const type = raw?.trim() ?? "";
if (type === "docx" || type === "pdf") return { ok: true, type };
return { ok: false, error: "type รับค่า docx หรือ pdf เท่านั้น" };
}
function trimOrNull(value: string): string | null {
const trimmed = value.trim();
return trimmed === "" ? null : trimmed;

View file

@ -18,6 +18,7 @@ import {
} from "@/db/schema";
import {
contentDispositionValue,
DOCVER_MAX_UPLOAD_BYTES,
DOCX_CONTENT_TYPE,
PDF_CONTENT_TYPE,
} from "@/lib/document-versioning";
@ -177,6 +178,35 @@ export async function presignVersionUrl(
};
}
/** อ่านไบต์ของ object หนึ่งกลับมาเป็น Buffer — ใช้กับ GET /api/documents/latest
* ที่ต้องคืนไฟล์ผ่าน response เอง (ไม่ใช่ presigned URL) · อ่านอย่างเดียว ไม่เขียน
* กลับ: การ stamp ticket ของ route ทำบนสำเนอในหน่วยความจำ · กันขนาดเกิน cap ของ
* กลุ่ม (object เขียนผ่านกลุ่มนี้เองจึงไม่ควรเกิน — เกิน = ข้อมูลแปลกปลอม อ่านต่อไม่ได้)
* · ล้มทุกกรณี = throw ให้ route catch เป็น 500 */
export async function getVersionObjectBytes(
versionId: string,
format: "docx" | "pdf",
): Promise<Buffer> {
const { client, config } = getClient();
const key = versionObjectKeys(versionId)[format];
const stream = await client.getObject(config.bucket, key);
return await new Promise<Buffer>((resolve, reject) => {
const chunks: Buffer[] = [];
let total = 0;
stream.on("data", (chunk: Buffer) => {
total += chunk.length;
if (total > DOCVER_MAX_UPLOAD_BYTES) {
stream.destroy();
reject(new Error(`docver: object ${key} ใหญ่เกิน ${DOCVER_MAX_UPLOAD_BYTES} bytes`));
return;
}
chunks.push(chunk);
});
stream.on("end", () => resolve(Buffer.concat(chunks)));
stream.on("error", reject);
});
}
// ─── DB ──────────────────────────────────────────────────────────────────────
export async function findDocumentByDocId(docId: string): Promise<DocumentRow | null> {
@ -207,6 +237,21 @@ export async function findVersionForDoc(
return row?.version ?? null;
}
/** version ล่าสุดของเอกสาร (docId) — join แบบ findVersionForDoc + เรียงเลข version
* จากมากไปน้อยแล้วเอาตัวแรก · ใช้กับ GET /api/documents/latest */
export async function findLatestVersionForDoc(
docId: string,
): Promise<DocumentVersionRow | null> {
const [row] = await db
.select({ version: documentVersions })
.from(documentVersions)
.innerJoin(documents, eq(documentVersions.documentId, documents.id))
.where(eq(documents.docId, docId))
.orderBy(desc(documentVersions.versionNumber))
.limit(1);
return row?.version ?? null;
}
/**
* เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน
* "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7:

View file

@ -138,6 +138,32 @@ export function requireDocverApiKey(request: Request): Response | null {
: docverError("API key ไม่ถูกต้อง", 401);
}
/** auth ของ GET /api/documents/latest — x-api-key เทียบ env DOCVER_API_KEY
* หรือ EXTERNAL_API_KEY (ตัวใดตัวหนึ่งตรง = ผ่าน — ผู้เรียกเป็น backend e-Court
* ที่ถือ key สาย N10 หรือสาย docver ก็ได้ ต่างจาก /token ที่รับ DOCVER_API_KEY
* อย่างเดียว) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ข้อความ 401 ของ "ไม่ใส่
* key" กับ "key ผิด" เป็นอันเดียวกันเหมือน requireDocverApiKey
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
export function requireDocumentApiKey(request: Request): Response | null {
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
const envKeys = [process.env.DOCVER_API_KEY, process.env.EXTERNAL_API_KEY]
.map((value) => value?.trim())
.filter((value): value is string => value !== undefined && value !== "");
if (envKeys.length === 0) {
return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401);
}
const provided = request.headers.get("x-api-key");
if (provided === null || provided === "") {
return docverError("API key ไม่ถูกต้อง", 401);
}
const providedHash = createHash("sha256").update(provided).digest();
const matched = envKeys.some((envKey) => {
const envHash = createHash("sha256").update(envKey).digest();
return timingSafeEqual(providedHash, envHash);
});
return matched ? null : docverError("API key ไม่ถูกต้อง", 401);
}
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";

View file

@ -1376,4 +1376,60 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT
}
return templateBuffer
}
// ─── ticket stamp ของ GET /api/documents/latest (กลุ่ม docver) ────────────────
// recipe เดียวกับ applyTemplate ด้านบนแต่แยกเป็น fn เองเพราะที่มาของ ticket
// ต่างกัน: ของ route นั้นคือ query param จากผู้เรียก (ไม่ใช่ templateData.info)
// และห้ามกลืน error แบบ applyTemplate — การ stamp คือตัวประสงค์ของ endpoint
// ล้ม = throw ให้ route ตอบ 500 (คืนไฟล์ไม่มี stamp เงียบ ๆ = add-in เปิดไม่ได้)
/** เติม exp ใหม่ (now + ttlSeconds) + เรียง key ตามตัวอักษรแล้วคืน JSON string
* ที่จะ stamp — HMAC/ลายเซ็นต้องคำนวณบน string ดิบนี้เป๊ะ ๆ (ห้าม trim/normalize)
* · ตัด key ที่ค่า null/undefined ทิ้งก่อน เพราะ parser ฝั่ง redeem
* (parseDocverTicket ของ utils/docver-ticket.ts) ไม่ยอม tid:null (ต้องเป็น
* string หรือไม่มี key เลย) — ตัวรับเข้าของ route ใช้ null แทน "ไม่มี" */
export function buildStampedTicketData(
ticket: Record<string, unknown>,
ttlSeconds: number,
nowMs: number = Date.now(),
): string {
const withoutNulls = Object.fromEntries(
Object.entries(ticket).filter(([, value]) => value !== null && value !== undefined),
);
// หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms)
const expireTimestamp = Math.floor(nowMs / 1000) + ttlSeconds;
const fullObj = { ...withoutNulls, exp: expireTimestamp };
const sortedObj = sortObjectKeys(fullObj);
return JSON.stringify(sortedObj);
}
export type StampTicketOptions = {
/** secret ของ HMAC — ฝั่ง caller ของกลุ่ม docver ใช้ addinTicketSecret() เพื่อ
* ตรงกับฝั่ง verify ของ add-in / POST /api/documents/token ทาง ticket */
secret: string;
/** hex ของ private key (seed 32 หรือ 64 byte) — ไม่ตั้ง = ไม่ stamp Ed25519
* (chain ของ add-in verify แค่ HmacSha256 — Ed25519 เป็นข้อมูลเสริม) */
privateKeyHex?: string | null;
};
/** stamp custom properties `ticket_data`/`HmacSha256`(+`Ed25519`) ลงสำเนาในหน่วย
* ความจำของ .docx — setCustomDocProps คืน Buffer ใหม่ ไม่แตะไฟล์ต้นทาง
* (non-destructive) และ upsert ตามชื่อ จึงเขียนทับ stamp เดิมของ template ได้ ·
* ล้มทุกกรณี = throw */
export function stampTicketProps(
docxBuffer: Buffer,
ticketData: string,
options: StampTicketOptions,
): { buffer: Buffer; hmacSha256: string; ed25519: string | null } {
const hmacSha256 = generateHmacSha256(ticketData, options.secret);
const privateKeyHex = options.privateKeyHex?.trim() || null;
const ed25519 = privateKeyHex !== null ? signEd25519(ticketData, privateKeyHex) : null;
const buffer = setCustomDocProps(
docxBuffer,
ed25519 === null
? { ticket_data: ticketData, HmacSha256: hmacSha256 }
: { ticket_data: ticketData, HmacSha256: hmacSha256, Ed25519: ed25519 },
);
return { buffer, hmacSha256, ed25519 };
}

View file

@ -858,6 +858,100 @@ export const openApiSpec = {
},
},
},
"/api/documents/latest": {
get: {
tags: ["documents"],
summary:
"ดาวน์โหลดไฟล์ของ version ล่าสุด (docx พร้อม stamp ticket ใหม่ / pdf ตรง ๆ)",
description:
"ให้ backend e-Court ดึงไฟล์ **version ล่าสุด** ของเอกสารกลับไปเปิดให้ผู้ใช้ " +
"(server-to-server — ไม่ใช้ Bearer token)\n\n" +
"- **auth**: header `x-api-key` เทียบ env `DOCVER_API_KEY` **หรือ** " +
"`EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง (ต่างจาก /token ที่รับ DOCVER_API_KEY " +
"อย่างเดียว) · ทั้งคู่ไม่ตั้ง = 401 fail closed\n" +
"- เอกสารถูกระบุด้วย `fid` **ใน ticket_data** (ไม่ใช่ token)\n" +
"- `type=pdf` → คืนไบต์ pdf ตรง ๆ (version ล่าสุดไม่มีสำเนา PDF = 404)\n" +
"- `type=docx` → server เติม `exp` ใหม่ (now + `TICKET_SEC` default 300 วิ) + " +
"คำนวณ `HmacSha256` (secret ตาม chain `ADDIN_TICKET_SECRET` → `SECRET` → " +
"`\"super_secret\"` ให้ตรงฝั่ง verify ของ add-in) และ `Ed25519` (เฉพาะเมื่อ " +
"`PRIVATE_KEY_HEX` ตั้ง) แล้วเขียน custom properties ลง**สำเนาในหน่วยความจำ** " +
"ก่อนคืนไฟล์ — object ใน MinIO ไม่ถูกแต่ (non-destructive) และเขียนทับ stamp " +
"เดิมของ template ได้\n" +
"- **ticket_data บังคับ `fid` + `uid` + `uname`** (`tid` optional · `exp` ถ้าส่ง " +
"ต้องเป็นจำนวนเต็ม แต่จะถูกเติมใหม่เสมอ) เพราะ parser ฝั่ง add-in บังคับทั้งสาม — " +
"⚠️ `uid` ต้องเป็น*รหัส*ผู้ใช้ (ไม่ใช่ชื่อ) และต้องส่ง `uname` (ชื่อแสดงผล)\n" +
"- ticket_data หลังเติม exp ยาวเกิน 255 ตัวอักษร = 400 (ข้อจำกัด custom property " +
"ของ Word)",
security: [{ ApiKeyAuth: [] }],
parameters: [
{
name: "type",
in: "query",
required: true,
schema: { type: "string", enum: ["docx", "pdf"] },
description: "ชนิดไฟล์ที่จะดาวน์โหลด (บังคับระบุ — ไม่มีค่า default)",
},
{
name: "ticket_data",
in: "query",
required: true,
schema: { type: "string" },
description:
"JSON string ของ ticket เช่น " +
"`{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}` " +
"(ต้อง URL-encode — ใช้ URLSearchParams ของฝั่งเรียกก็ได้)",
example:
"{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}",
},
],
responses: {
200: {
description:
"ไบต์ของไฟล์ (docx = ผ่านการ stamp ticket ใหม่ · pdf = ตรงจาก version ล่าสุด)",
headers: {
"Content-Disposition": {
description: "attachment พร้อมชื่อไฟล์ของ version (ชื่อไทยผ่าน filename*)",
schema: { type: "string" },
},
"Cache-Control": {
description: "no-store — ไฟล์ผูกกับ ticket/exp ของ request นั้น",
schema: { type: "string" },
},
},
content: {
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": {
schema: { type: "string", format: "binary" },
},
"application/pdf": {
schema: { type: "string", format: "binary" },
},
},
},
400: { $ref: "#/components/responses/BadRequest" },
401: {
description:
"x-api-key ไม่ตรงทั้ง DOCVER_API_KEY และ EXTERNAL_API_KEY หรือทั้งคู่ไม่ตั้งค่า " +
"(ไม่ใส่ header กับ key ผิดได้ข้อความเดียวกัน)",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
404: {
description:
"ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version) หรือ type=pdf แต่ version " +
"ล่าสุดไม่มีสำเนา PDF",
content: {
"application/json": {
schema: { $ref: "#/components/schemas/Error" },
},
},
},
500: { $ref: "#/components/responses/ServerError" },
},
},
},
},
components: {
securitySchemes: {
@ -868,7 +962,8 @@ export const openApiSpec = {
description:
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
"request ของกลุ่มที่ key ตรง)",
"request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " +
"(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)",
},
DocverBearer: {
type: "http",