All checks were successful
Deploy eCourt Note Web to Server / Validate Database Schema (Drizzle) (push) Successful in 3s
Deploy eCourt Note Web to Server / Build Web Docker Image (push) Successful in 1m9s
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
- docId of every docver operation now comes from the Bearer token: the fid
claim (e-Court fileId) is used directly as docId; segment [docId] removed
from URLs — POST/GET /api/documents/versions and
GET /api/documents/versions/{versionId}/download-link
- fid is required and must be a string (trim 1-200): token issuance rejects
missing/blank/non-string fid with 400, verify rejects fid-less tokens with 401
- parseDocId removed from lib/document-versioning.ts (dead code, no callers)
- openapi spec: paths renamed, docId path params removed, fid required + string
- PLAN-DOCVER.md / CLAUDE.md / wordaddin versioningService comment updated
- tests: fid required/string cases added, parseDocId describe removed (256 pass)
Co-Authored-By: Claude Code <noreply@anthropic.com>
227 lines
11 KiB
TypeScript
227 lines
11 KiB
TypeScript
// PLAN-DOCVER.md §1 — validation ล้วนของกลุ่ม /api/documents (document versioning)
|
|
// pattern เดียวกับ lib/external-api.ts ของ N10: strict กัน typo เงียบ ๆ (field ที่
|
|
// ไม่รู้จัก = error), trim-ว่าง = ถือว่าไม่ส่ง, ข้อความ error เป็นไทยทุกอัน
|
|
//
|
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
|
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* เลย (pattern เดียวกับ lib/external-api-auth.ts)
|
|
import { decodeUploadFilename } from "@/utils/upload-name";
|
|
|
|
/** cap เดียวกับ N4 / api/template — route ต้องเช็คขนาดก่อน buffer เสมอ
|
|
* (route handler ไม่ได้รับเคราะห์ bodySizeLimit ของ Server Actions) */
|
|
export const DOCVER_MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
|
|
|
/** ตรง maxLength ของ commit message dialog ฝั่ง add-in (FR-13) */
|
|
export const DOCVER_MESSAGE_MAX = 2048;
|
|
|
|
export const DOCX_CONTENT_TYPE =
|
|
"application/vnd.openxmlformats-officedocument.wordprocessingml.document";
|
|
export const PDF_CONTENT_TYPE = "application/pdf";
|
|
|
|
/** รหัสเอกสาร (docId) ไม่มี validator ที่นี่แล้ว — ตอนนี้มาจาก fid ใน Bearer token
|
|
* (String(fid) — integer ที่ verify แล้ว ผ่าน lib/docver-token.ts) ไม่ใช่ path param
|
|
* ของผู้เรียก · parseDocId เดิมถอดออกพร้อมการถอด segment [docId] ออกจาก URL */
|
|
|
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
|
|
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
|
|
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
|
|
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
|
|
const FORM_FIELDS = [
|
|
"file",
|
|
"pdf",
|
|
"clientVersionId",
|
|
"fields",
|
|
"message",
|
|
"fileName",
|
|
] as const;
|
|
|
|
export type DocverError = { ok: false; error: string };
|
|
|
|
/** Response ผิดพลาดของกลุ่มนี้ — JSON {"error": "<ไทย"} รูปเดียวกับ N10
|
|
* (define ที่นี่เพื่อให้ route ทั้งสองใช้ร่วมโดยไม่ต้องพึ่ง module ของ N10) */
|
|
export function docverError(message: string, status: number): Response {
|
|
return new Response(JSON.stringify({ error: message }), {
|
|
status,
|
|
headers: { "content-type": "application/json; charset=utf-8" },
|
|
});
|
|
}
|
|
|
|
/** magic bytes ของ .docx — OOXML เป็น ZIP จึงขึ้นต้น PK\x03\x04 (mirror รูปแบบ
|
|
* matchesSignature ของ app/actions/files.ts — utils/file-kind.ts เป็นของ note files) */
|
|
export function isDocxBytes(bytes: Buffer): boolean {
|
|
return bytes.subarray(0, 4).toString("latin1") === "PK\x03\x04";
|
|
}
|
|
|
|
export function isPdfBytes(bytes: Buffer): boolean {
|
|
return bytes.subarray(0, 5).toString("latin1") === "%PDF-";
|
|
}
|
|
|
|
/** versionId จาก path param — เก็บเป็น uuid เสมอจึงบังคับรูป (normalize ลงตัวพิมพ์เล็ก) */
|
|
export function parseVersionIdParam(raw: string):
|
|
| { ok: true; versionId: string }
|
|
| DocverError {
|
|
const versionId = raw.trim().toLowerCase();
|
|
if (!UUID_RE.test(versionId)) {
|
|
return { ok: false, error: "versionId ต้องเป็น UUID" };
|
|
}
|
|
return { ok: true, versionId };
|
|
}
|
|
|
|
/** ?format= ของ download-link — ไม่ส่ง/ว่าง = docx (ค่าเริ่มต้นตาม contract) */
|
|
export function parseDownloadFormat(raw: string | null):
|
|
| { ok: true; format: "docx" | "pdf" }
|
|
| DocverError {
|
|
const format = raw?.trim() ?? "";
|
|
if (format === "") return { ok: true, format: "docx" };
|
|
if (format === "docx" || format === "pdf") return { ok: true, format };
|
|
return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" };
|
|
}
|
|
|
|
function trimOrNull(value: string): string | null {
|
|
const trimmed = value.trim();
|
|
return trimmed === "" ? null : trimmed;
|
|
}
|
|
|
|
/** อ่าน text field จาก FormData — ไม่ส่ง = null · ส่งมาเป็น File = 400 (ไม่ใช่ string) */
|
|
function formString(form: FormData, field: string):
|
|
| { ok: true; value: string | null }
|
|
| DocverError {
|
|
const entry = form.get(field);
|
|
if (entry === null) return { ok: true, value: null };
|
|
if (typeof entry !== "string") {
|
|
return { ok: false, error: `field '${field}' ต้องเป็น string` };
|
|
}
|
|
return { ok: true, value: trimOrNull(entry) };
|
|
}
|
|
|
|
/** ชื่อไฟล์แสดงผล (Content-Disposition): ตัด path ที่ฝังมา + trim + cap 200 —
|
|
* ว่าง = null (ให้ presign ใช้ชื่อ default document.docx/pdf) */
|
|
export function sanitizeDisplayFileName(raw: string | null): string | null {
|
|
if (raw === null) return null;
|
|
const base = raw.split(/[\\/]/).pop() ?? raw;
|
|
return trimOrNull(base)?.slice(0, 200) ?? null;
|
|
}
|
|
|
|
/** ชื่อไฟล์จาก File.name ของ multipart — undici ถอด Content-Disposition เป็น latin1
|
|
* ทำชื่อไทยเพี้ยน (N4) จึงต้องผ่าน decodeUploadFilename ก่อน sanitize เสมอ */
|
|
export function sanitizeFilePartName(raw: string): string | null {
|
|
return sanitizeDisplayFileName(decodeUploadFilename(raw));
|
|
}
|
|
|
|
/** fields ของ multipart (JSON string) → map ดิบ { [sdtTag]: text } —
|
|
* `{}` หรือไม่ส่ง = null · ไม่ใช่ object ล้วน/key หรือ value ผิดกติกา = 400 */
|
|
export function parseFieldsJson(raw: string):
|
|
| { ok: true; fields: Record<string, string> | null }
|
|
| DocverError {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(raw);
|
|
} catch {
|
|
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
|
}
|
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
return { ok: false, error: 'field "fields" ต้องเป็น JSON object เช่น { "tag": "ค่า" }' };
|
|
}
|
|
const record = parsed as Record<string, unknown>;
|
|
const keys = Object.keys(record);
|
|
if (keys.length > 100) {
|
|
return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' };
|
|
}
|
|
for (const key of keys) {
|
|
if (key.length > 200) {
|
|
return { ok: false, error: 'key แต่ละรายการใน "fields" ยาวเกิน 200 ตัวอักษร' };
|
|
}
|
|
const value = record[key];
|
|
if (typeof value !== "string") {
|
|
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ต้องเป็น string' };
|
|
}
|
|
if (value.length > 10_000) {
|
|
return { ok: false, error: 'ค่าของแต่ละรายการใน "fields" ยาวเกิน 10,000 ตัวอักษร' };
|
|
}
|
|
}
|
|
return { ok: true, fields: keys.length === 0 ? null : (parsed as Record<string, string>) };
|
|
}
|
|
|
|
export type ParsedVersionUploadForm = {
|
|
clientVersionId: string | null;
|
|
message: string | null;
|
|
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
|
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
|
fileName: string | null;
|
|
fields: Record<string, string> | null;
|
|
};
|
|
|
|
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
|
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
|
|
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
|
|
export function parseVersionUploadForm(form: FormData):
|
|
| { ok: true; form: ParsedVersionUploadForm }
|
|
| DocverError {
|
|
// Array.from เพราะ tsconfig ไม่ได้ตั้ง target ≥ es2015 (for...of บน iterator ต้อง
|
|
// downlevelIteration — array ธรรมดาไม่ต้อง)
|
|
for (const key of Array.from(form.keys())) {
|
|
if (!(FORM_FIELDS as readonly string[]).includes(key)) {
|
|
return {
|
|
ok: false,
|
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${FORM_FIELDS.join(", ")})`,
|
|
};
|
|
}
|
|
}
|
|
|
|
const clientVersionId = formString(form, "clientVersionId");
|
|
if (!clientVersionId.ok) return clientVersionId;
|
|
if (clientVersionId.value && clientVersionId.value.length > 100) {
|
|
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
|
|
}
|
|
|
|
const message = formString(form, "message");
|
|
if (!message.ok) return message;
|
|
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
|
|
return { ok: false, error: `message ยาวเกิน ${DOCVER_MESSAGE_MAX} ตัวอักษร` };
|
|
}
|
|
|
|
const rawFields = formString(form, "fields");
|
|
if (!rawFields.ok) return rawFields;
|
|
let fields: Record<string, string> | null = null;
|
|
if (rawFields.value !== null) {
|
|
const parsed = parseFieldsJson(rawFields.value);
|
|
if (!parsed.ok) return parsed;
|
|
fields = parsed.fields;
|
|
}
|
|
|
|
const rawFileName = formString(form, "fileName");
|
|
if (!rawFileName.ok) return rawFileName;
|
|
|
|
return {
|
|
ok: true,
|
|
form: {
|
|
clientVersionId: clientVersionId.value,
|
|
message: message.value,
|
|
fileName: sanitizeDisplayFileName(rawFileName.value),
|
|
fields,
|
|
},
|
|
};
|
|
}
|
|
|
|
/** ค่า Content-Disposition ของ object ใน MinIO — presigned GET เสิร์ฟ metadata ที่ฝัง
|
|
* ตอน putObject จึงได้ชื่อไฟล์สวยตอนดาวน์โหลด: ASCII fallback (ตัด non-ASCII/"
|
|
* "/ควบคุม — เหลือไม่มีตัวอักษรเลข = ชื่อ default) + filename*=UTF-8'' สำหรับชื่อไทย */
|
|
const DEFAULT_FILE_NAMES = { docx: "document.docx", pdf: "document.pdf" } as const;
|
|
|
|
export function contentDispositionValue(displayName: string | null, kind: "docx" | "pdf"): string {
|
|
const ext = `.${kind}`;
|
|
const ascii = (displayName ?? "")
|
|
.replace(/[^\x20-\x7E]/g, "")
|
|
.replace(/["\\]/g, "")
|
|
.replace(/\s+/g, " ")
|
|
.trim();
|
|
let asciiName: string = DEFAULT_FILE_NAMES[kind];
|
|
if (/[A-Za-z0-9]/.test(ascii)) {
|
|
asciiName = ascii.toLowerCase().endsWith(ext) ? ascii : ascii + ext;
|
|
}
|
|
const display = displayName?.trim();
|
|
if (display) {
|
|
return `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(display)}`;
|
|
}
|
|
return `attachment; filename="${asciiName}"`;
|
|
}
|