Merge branch 'forgejo-dev' into working
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 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
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 1m12s
Deploy eCourt Note Web to Server / Deploy eCourt Note Web to Server (push) Successful in 4s
Deploy eCourt Note Web to Server / Notify Deployment Status (push) Successful in 1s
Resolve CLAUDE.md: คำอธิบาย lib/document-versioning.ts เอาของ forgejo-dev (field `fields` ถอดออก 2026-10-30) ส่วน lib/docver-token.ts เอาของ working (purpose login / verifyDocverLoginToken — โค้ดฝั่ง forgejo-dev มีครบแล้ว แค่คำอธิบายล้าหลัง) Verify: npx tsc --noEmit ผ่าน · vitest 23 files / 338 tests ผ่าน Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
commit
a288e5643c
31 changed files with 1445 additions and 278 deletions
120
app/api/documents/latest/route.ts
Normal file
120
app/api/documents/latest/route.ts
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
// PLAN-DOCVER.md §1 — GET /api/documents/latest?type=docx|pdf&ticket_data=<json>
|
||||
// คืนไฟล์ของ version ล่าสุดให้ backend e-Court (server-to-server):
|
||||
// - type=pdf → ไบต์ pdf ตรง ๆ (version ล่าสุดไม่มี pdf = 404)
|
||||
// - type=docx → stamp ticket ใหม่ (exp + HmacSha256 + Ed25519 เมื่อมี private key)
|
||||
// ลงสำเนาในหน่วยความจำก่อนคืน — ไม่เขียนกลับ MinIO (non-destructive)
|
||||
// - auth: header x-api-key เทียบ env DOCVER_API_KEY หรือ EXTERNAL_API_KEY
|
||||
// (ต่างจาก /token ที่รับ DOCVER_API_KEY อย่างเดียว) · docId = fid ใน ticket_data
|
||||
// - secret ของ HMAC = addinTicketSecret() ให้ตรงฝั่ง verify ของ add-in /
|
||||
// POST /api/documents/token ทาง ticket · TTL = env TICKET_SEC (default 300)
|
||||
// carve-out invariant #4: route handler ของกลุ่ม docver — ไม่ publish SSE
|
||||
// ไม่ revalidatePath (ไม่มีหน้า note ใดอ่านตารางของกลุ่มนี้)
|
||||
import { addinTicketSecret } from "@/lib/addin-ticket";
|
||||
import {
|
||||
contentDispositionValue,
|
||||
docverError,
|
||||
DOCX_CONTENT_TYPE,
|
||||
isDocxBytes,
|
||||
isPdfBytes,
|
||||
parseLatestTypeParam,
|
||||
PDF_CONTENT_TYPE,
|
||||
} from "@/lib/document-versioning";
|
||||
import { requireDocumentApiKey } from "@/lib/docver-token";
|
||||
import {
|
||||
findLatestVersionForDoc,
|
||||
getVersionObjectBytes,
|
||||
} from "@/lib/docver-store";
|
||||
import { buildStampedTicketData, stampTicketProps } from "@/lib/lib-tmpl";
|
||||
import { parseTicketDataParam } from "@/utils/docver-ticket";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const GENERIC_500 = "เกิดข้อผิดพลาดของระบบ โปรดลองใหม่อีกครั้ง";
|
||||
|
||||
export async function GET(request: Request) {
|
||||
const auth = requireDocumentApiKey(request);
|
||||
if (auth !== null) return auth;
|
||||
|
||||
let searchParams: URLSearchParams;
|
||||
try {
|
||||
searchParams = new URL(request.url).searchParams;
|
||||
} catch {
|
||||
return docverError("URL ของ request ไม่ถูกต้อง", 400);
|
||||
}
|
||||
|
||||
const type = parseLatestTypeParam(searchParams.get("type"));
|
||||
if (!type.ok) return docverError(type.error, 400);
|
||||
|
||||
const ticket = parseTicketDataParam(searchParams.get("ticket_data"));
|
||||
if (!ticket.ok) return docverError(ticket.error, 400);
|
||||
|
||||
// เติม exp + เรียง key + เช็คความยาวก่อนแตะ MinIO — Word ตัดทอนค่า custom
|
||||
// property ที่ 255 ตัวอักษร คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ ปฏิเสธไปเลยดีกว่า
|
||||
let ticketData: string | null = null;
|
||||
if (type.type === "docx") {
|
||||
const ttl = Number(process.env.TICKET_SEC);
|
||||
const ttlSeconds = Number.isFinite(ttl) && ttl > 0 ? ttl : 300;
|
||||
ticketData = buildStampedTicketData(ticket.ticket, ttlSeconds);
|
||||
if (ticketData.length > 255) {
|
||||
return docverError(
|
||||
"ticket_data ยาวเกิน 255 ตัวอักษร (ข้อจำกัดของ Word custom property)",
|
||||
400,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let row;
|
||||
try {
|
||||
row = await findLatestVersionForDoc(ticket.ticket.fid);
|
||||
} catch (err) {
|
||||
console.error("docver: ค้น version ล่าสุดล้ม", err);
|
||||
return docverError(GENERIC_500, 500);
|
||||
}
|
||||
if (!row) {
|
||||
return docverError("ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)", 404);
|
||||
}
|
||||
if (type.type === "pdf" && row.pdfObjectKey === null) {
|
||||
return docverError("version นี้ไม่มีสำเนา PDF", 404);
|
||||
}
|
||||
|
||||
let bytes: Buffer;
|
||||
try {
|
||||
bytes = await getVersionObjectBytes(row.id, type.type);
|
||||
} catch (err) {
|
||||
console.error("docver: ดึง object จาก MinIO ล้ม", err);
|
||||
return docverError(GENERIC_500, 500);
|
||||
}
|
||||
// object เขียนผ่านกลุ่มนี้เองตอน upload (ผ่าน magic bytes แล้ว) — ไม่ตรงตอนนี้
|
||||
// = ความผิดปกติของพื้นที่เก็บ ไม่ใช่ความผิดของผู้เรียก จึงเป็น 500
|
||||
if (type.type === "docx" ? !isDocxBytes(bytes) : !isPdfBytes(bytes)) {
|
||||
console.error(`docver: object ของ version ${row.id} magic bytes ไม่ตรงชนิดไฟล์`);
|
||||
return docverError(GENERIC_500, 500);
|
||||
}
|
||||
|
||||
if (type.type === "docx") {
|
||||
// ticketData ถูกตั้งค่าแล้วเสมอเมื่อ type=docx (ด้านบน) — ห้ามกลืน error:
|
||||
// ไฟล์ไม่มี stamp = add-in เปิดไม่ได้ ต้องตอบ 500 ไม่ใช่คืนไฟล์เสียเงียบ ๆ
|
||||
try {
|
||||
bytes = stampTicketProps(bytes, ticketData!, {
|
||||
secret: addinTicketSecret(),
|
||||
privateKeyHex: process.env.PRIVATE_KEY_HEX?.trim() || null,
|
||||
}).buffer;
|
||||
} catch (err) {
|
||||
console.error("docver: stamp ticket ล้ม", err);
|
||||
return docverError(GENERIC_500, 500);
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(
|
||||
// Uint8Array ใหม่ (ก็อปปี้) ตามที่ BodyInit รับ — pattern เดียวกับ route pdf/export
|
||||
new Uint8Array(bytes),
|
||||
{
|
||||
headers: {
|
||||
"content-type": type.type === "docx" ? DOCX_CONTENT_TYPE : PDF_CONTENT_TYPE,
|
||||
"content-disposition": contentDispositionValue(row.fileName, type.type),
|
||||
"cache-control": "no-store, max-age=0",
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
|
|
@ -6,7 +6,8 @@
|
|||
// ไม่รับจาก URL/form อีก (lib/docver-token.ts)
|
||||
import { createHash, randomUUID } from "node:crypto";
|
||||
|
||||
import { toDocumentVersionDTO } from "@/db/schema";
|
||||
import { toDocumentVersionDTO, type DocumentVersionFields } from "@/db/schema";
|
||||
import { addinTicketSecret } from "@/lib/addin-ticket";
|
||||
import {
|
||||
docverError,
|
||||
DOCVER_MAX_UPLOAD_BYTES,
|
||||
|
|
@ -16,7 +17,9 @@ import {
|
|||
parseVersionUploadForm,
|
||||
sanitizeFilePartName,
|
||||
} from "@/lib/document-versioning";
|
||||
import { extractContentControls } from "@/lib/lib-tmpl";
|
||||
import { requireDocverBearer } from "@/lib/docver-token";
|
||||
import { extractDocumentSnapshot } from "@/lib/lib-tmpl";
|
||||
import {
|
||||
findDocumentByDocId,
|
||||
insertVersionRecord,
|
||||
|
|
@ -31,6 +34,7 @@ export const dynamic = "force-dynamic";
|
|||
|
||||
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
||||
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
||||
* แกะ fields จากไฟล์ (lib-tmpl เหมือน /api/template/extract) →
|
||||
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
||||
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
||||
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
||||
|
|
@ -92,6 +96,21 @@ export async function POST(request: Request) {
|
|||
return docverError("เนื้อไฟล์ .pdf ไม่ถูกต้อง", 400);
|
||||
}
|
||||
|
||||
// fields snapshot แกะจากไฟล์เองฝั่ง server — **เก็บทั้งก้อนเหมือน return ของ
|
||||
// /api/template/extract** ({cc, bookmark, properties, verify} ผ่าน extractDocumentSnapshot
|
||||
// ของ lib/lib-tmpl.ts ตัวเดียวกับ route นั้น — 2026-10-30 ถอด form field "fields"
|
||||
// ที่ add-in อ่านผ่าน Office.js ออกจาก contract): มองเห็น CC ทุกชนิดเสมอไม่ขึ้นกับ
|
||||
// เวอร์ชัน WordApi ของ host · verify.HmacSha256 ตรวจด้วย secret ของ add-in ticket
|
||||
// ฝั่ง server (env chain เดียวกับ lib/addin-ticket.ts) · ZIP ไม่มี word/document.xml,
|
||||
// parse ไม่ได้ หรือ ticket_data ไม่ใช่ JSON = 400
|
||||
let fields: DocumentVersionFields;
|
||||
try {
|
||||
fields = extractDocumentSnapshot(docxBytes, { secret: addinTicketSecret() });
|
||||
} catch (err) {
|
||||
console.error("docver: แกะข้อมูลจากไฟล์ไม่สำเร็จ", err);
|
||||
return docverError("เนื้อไฟล์ .docx ไม่ถูกต้อง (แกะข้อมูลไม่สำเร็จ)", 400);
|
||||
}
|
||||
|
||||
const contentHash = createHash("sha256").update(docxBytes).digest("hex");
|
||||
const versionId = randomUUID();
|
||||
// ชื่อแสดงผลตอนดาวน์โหลด: field "fileName" ของผู้เรียก (add-in ส่งมาเอง) →
|
||||
|
|
@ -119,7 +138,7 @@ export async function POST(request: Request) {
|
|||
clientVersionId: parsed.form.clientVersionId,
|
||||
contentHash,
|
||||
message: parsed.form.message,
|
||||
fields: parsed.form.fields,
|
||||
fields,
|
||||
templateId: auth.claims.template,
|
||||
fileName: docxDisplayName,
|
||||
docxObjectKey: keys.docx,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue