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

@ -104,6 +104,96 @@ export function parseDocverTicket(raw: string): DocverTicketParseResult {
return { ok: true, ticket: { exp, fid, tid, uid, uname } };
}
/** ticket ก่อนเติม exp ของ parseTicketDataParam — exp เติมตอน stamp เสมอ */
export type TicketDataParam = {
fid: string;
uid: string;
uname: string;
tid: string | null;
};
export type TicketDataParamResult =
| { ok: true; ticket: TicketDataParam }
| { ok: false; error: string };
/** cap ความยาวรวมของ query param ticket_data — เทียบ parseTicketTokenBody
* ของ lib/addin-ticket.ts (body ทาง ticket ของ POST /api/documents/token) */
const TICKET_DATA_PARAM_MAX = 2048;
/**
* แกะ + ตรวจ query param `ticket_data` ของ GET /api/documents/latest —
* JSON string ของ ticket ที่ยังไม่มี exp (server เติม exp ใหม่ตอน stamp เสมอ
* จึงไม่บังคับ แต่ถ้าส่งมาต้องเป็นจำนวนเต็ม)
*
* บังคับ fid/uid/uname ครบตั้งแต่รับเข้า: parseDocverTicket ด้านบนบังคับทั้งสาม
* ตอน add-in เอาไฟล์ไป redeem ที่ /api/documents/token — ปล่อย ticket ที่ขาด
* field ผ่านไปเงียบ ๆ = คืนไฟล์ที่ add-in เปิดใช้ไม่ได้ภายหลัง
*/
export function parseTicketDataParam(raw: string | null): TicketDataParamResult {
if (raw === null || raw.trim() === "") {
return { ok: false, error: "ต้องระบุ ticket_data (JSON string ของ ticket)" };
}
if (raw.length > TICKET_DATA_PARAM_MAX) {
return { ok: false, error: `ticket_data ยาวเกิน ${TICKET_DATA_PARAM_MAX} ตัวอักษร` };
}
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return { ok: false, error: "ticket_data ไม่ใช่ JSON ที่ถูกต้อง" };
}
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
return { ok: false, error: "ticket_data ต้องเป็น JSON object" };
}
const record = parsed as Record<string, unknown>;
for (const key of Object.keys(record)) {
if (!(TICKET_FIELDS as readonly string[]).includes(key)) {
return {
ok: false,
error: `ticket_data มี field ที่ไม่รู้จัก '${key}' (รับเฉพาะ ${TICKET_FIELDS.join(", ")})`,
};
}
}
// exp ไม่บังคับ (server เติมใหม่ตอน stamp) — แต่ถ้าส่งมาต้องผิดรูปไม่ได้ กันเงียบ ๆ
if (record.exp !== undefined &&
(typeof record.exp !== "number" || !Number.isSafeInteger(record.exp))) {
return { ok: false, error: "ticket_data ของ exp ต้องเป็นจำนวนเต็ม (unix วินาที)" };
}
const field = (name: "uid" | "uname" | "fid"): string | null => {
const value = record[name];
if (typeof value !== "string" || value.trim() === "") {
return null;
}
const trimmed = value.trim();
return trimmed.length <= TICKET_LIMITS[name] ? trimmed : null;
};
const uid = field("uid");
if (uid === null) return { ok: false, error: "ticket_data ต้องมี uid (string 1–100)" };
const uname = field("uname");
if (uname === null) return { ok: false, error: "ticket_data ต้องมี uname (string 1–200)" };
const fid = field("fid");
if (fid === null) return { ok: false, error: "ticket_data ต้องมี fid (string 1–200)" };
let tid: string | null = null;
if (record.tid !== undefined) {
if (typeof record.tid !== "string") {
return { ok: false, error: "ticket_data ของ tid ต้องเป็น string" };
}
const trimmed = record.tid.trim();
if (trimmed !== "") {
if (trimmed.length > TICKET_LIMITS.tid) {
return { ok: false, error: "ticket_data ของ tid ยาวเกิน 100 ตัวอักษร" };
}
tid = trimmed;
}
}
return { ok: true, ticket: { fid, uid, uname, tid } };
}
/** exp หมดอายุไหม — exp เท่ากับ now พอดีก็ถือว่าหมดแล้ว */
export function isTicketExpired(exp: number, nowSec: number = Math.floor(Date.now() / 1000)): boolean {
return exp <= nowSec;