- POST /api/documents/token issues JWT HS256 (claims userid/username/fid/template) after x-api-key check against new env DOCVER_API_KEY (fail-closed, timing-safe, uniform 401) - all docver endpoints now require Authorization: Bearer, verified with env DOCVER_TOKEN_SECRET (TTL via DOCVER_TOKEN_TTL_SECONDS, default 3600); secret unset = 401 on verify / 500 on issue - identity (userId/userName) + templateId extracted from token claims instead of request payload; removed from multipart form (legacy fields now 400) - lib/docver-token.ts: hand-rolled JWT via node:crypto only — alg pinned to HS256 (anti alg-confusion), timing-safe compare with both sides hashed, exp enforced, identical 401 for all bearer failure modes - openapi spec: /api/documents/token path + DocverBearer scheme; PLAN-DOCVER.md / CLAUDE.md / .env.example updated - tests: new tests/lib/docver-token.test.ts + adjusted parseVersionUploadForm tests (258 pass, no DB env needed) - package-lock.json: sync minio (^8.0.7) that was missing from the earlier docver commit Co-Authored-By: Claude Code <noreply@anthropic.com>
243 lines
12 KiB
TypeScript
243 lines
12 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";
|
||
|
||
/** รหัสเอกสารจากผู้เรียก — URL-path-safe ล้วน (ไม่มี / .. ช่องว่าง ไทย) กันถูก
|
||
* ฝังลง path/log แปลก ๆ และตัดขนาดให้จำกัด */
|
||
const DOC_ID_RE = /^[A-Za-z0-9._:-]{1,200}$/;
|
||
|
||
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" },
|
||
});
|
||
}
|
||
|
||
/** ตรวจ docId จาก path param — trim แล้วว่าง/เกิน 200/มีตัวอักษรนอก charset = ไม่ผ่าน
|
||
* (+ ปฏิเสธ "." / ".." เป็นพิเศษแม้อยู่ใน charset — path-traversal specials) */
|
||
export function parseDocId(raw: string):
|
||
| { ok: true; docId: string }
|
||
| DocverError {
|
||
const docId = raw.trim();
|
||
if (docId === "." || docId === ".." || !DOC_ID_RE.test(docId)) {
|
||
return {
|
||
ok: false,
|
||
error:
|
||
"รหัสเอกสาร (docId) ต้องยาว 1–200 ตัวอักษร และใช้ได้เฉพาะ A-Z a-z 0-9 . _ : -",
|
||
};
|
||
}
|
||
return { ok: true, docId };
|
||
}
|
||
|
||
/** 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}"`;
|
||
}
|