diff --git a/CLAUDE.md b/CLAUDE.md index 4e15d3e..170bbc0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,7 +51,7 @@ lib/remote-file.ts ← N8 ดึงไฟล์รีโมตจาก allow lib/external-api-auth.ts ← N10 auth ของ /api/note/external/* — requireExternalApiKey เทียบ header `x-api-key` กับ env `EXTERNAL_API_KEY` ด้วย SHA-256 + crypto.timingSafeEqual (env ไม่ตั้ง/ว่าง = 401 fail closed ทุก request; ข้อความ 401 อันเดียวกันทั้งไม่ใส่/key ผิด กัน enumerate; ห้าม log ค่า key/header ดิบ) + externalApiError สร้าง Response ผิดพลาด JSON {"error": ไทย} ใช้ร่วมทุก route ของ N10 — **module นี้ห้าม import อะไรที่ลาก @/db มาด้วย** (db/index.ts throw ตอน import เมื่อไม่มี DATABASE_URL — unit test tests/lib/external-api-auth.test.ts ต้องรันได้โดยไม่แตะ DB) lib/external-api.ts ← N10 ส่วนกลางของ route — parseQueryBody (validate JSON body ของ /api/note/external/documents: รับเฉพาะ fileId/typeId/filter, ต้องมี fileId หรือ typeId อย่างน้อยหนึ่งอย่าง — ส่งทั้งคู่ได้ = AND เหลือเฉพาะเอกสารเดียวที่ตรงทั้งสองเงื่อนไข, คำขอผู้ใช้ 2026-09-25 (รอบเดียวกันเคยเป็น OR แล้วเปลี่ยนหลังทดสอบจริง), filter รับเฉพาะ type ∈ bookmarks|annotations ไม่ส่ง = ทั้งสอง + userId (string|array กรองตามเจ้าของรายการ annotations+bookmarks) + annotationTypes (ค่า enum annotation_type เฉพาะ annotations ค่าอื่น = 400 — เดิมชื่อ types เปลี่ยน 2026-09-25) — คืน discriminated mode "file" (fileId + typeId อาจมาด้วย) / "type" (typeId เดี่ยว)) + loadDocumentBundles(fileRows, parts) (bulk: inArray ทุกไฟล์ในชุด query เดียว = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ แทนยิง 3 query ต่อไฟล์พร้อมกันทั้งกลุ่ม, code review 2026-09-25 — กรอง userIds/annotationTypes ใน SQL; คืน bundle ยุบชั้น: field เอกสาร + annotations + bookmarks ระดับเดียวกัน ไม่มี object document ซ้อน; ต่อรายการมี `url` deep link F12 กลับหน้า `/annotate/[id]` — annotation `?ann=` / bookmark `?page=<หน้า 1-based>` — URL เต็ม: origin จาก request ผู้เรียก (route อ่าน x-forwarded-proto/host แล้ว fallback host ตรง ส่งเข้าทาง parameter origin) + basePath ผ่าน withBasePath, คำขอผู้ใช้ 2026-09-25) + type ExternalDocumentDTO (externalFileId เป็น null สำหรับไฟล์ url:-fallback/อัปโหลด N4 ในกลุ่ม) lib/openapi.ts ← OpenAPI 3.0 spec ของ API ทดสอบ (กลุ่ม template + N10 + docver — 2026-10-06) — แหล่งเดียวของ Swagger UI: GET /api/template inline ลง HTML ตรง ๆ ไม่มี endpoint spec แยก; URL ใน paths เป็น path เต็ม /api/… (servers เติมแค่ basePath ตาม invariant #12) — แก้ contract ของ API ต้องมาแก้ไฟล์นี้ให้ตรงด้วย -lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) +lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseVersionUploadForm (allowlist field — identity ไม่รับจาก form แล้ว แกะจาก token ผ่าน lib/docver-token.ts + fields JSON ≤1,000 keys **ความยาวค่าไม่จำกัด** (2026-10-27 เอกสารจริง 100–200 หน้า) + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม (parseDocId ถอดแล้ว 2026-10-26 — docId ไม่มาจาก path param) lib/docver-token.ts ← docver token + auth ของกลุ่ม /api/documents/* (2026-10-22 — pure module ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย เหมือน lib/document-versioning.ts; unit test tests/lib/docver-token.test.ts ต้องรันได้โดยไม่ต้องตั้ง env) lib/addin-ticket.ts ← add-in ticket auth ฝั่ง server ของทางเข้าที่สองของ POST /api/documents/token (2026-10-27, docs/PLAN-ADDIN-AUTH.md — pure เหมือน lib/docver-token.ts; unit test tests/lib/addin-ticket.test.ts): addinTicketSecret (env ADDIN_TICKET_SECRET → SECRET → "super_secret") + verifyAddinTicket (HMAC timing-safe บน ticket_data ดิบ + exp ผ่าน utils/docver-ticket.ts) + ticketToDocverClaims (uid→userid, uname→username, tid→template) + parseTicketTokenBody (allowlist ticket_data/HmacSha256 — missing → 401 / invalid → 400): issueDocverToken/verifyDocverToken (JWT HS256 มือเขียน node:crypto ล้วน — บังคับ alg=HS256 กัน alg-confusion, timingSafeEqual หลัง hash ทั้งสองฝั่ง, exp บังคับ) + requireDocverApiKey (header x-api-key เทียบ env DOCVER_API_KEY — fail closed 401 ไม่ตั้ง/ว่าง, 401 เดียวกันทั้งไม่ใส่/ผิด กัน enumerate, ห้าม log ค่า key/header ดิบ) + requireDocverBearer (Authorization: Bearer → claims {userid, username, fid, template} — secret จาก env DOCVER_TOKEN_SECRET ไม่ตั้ง = 401, ทุกกรณี 401 ข้อความเดียวกัน) + parseTokenRequestBody (allowlist userid/username/fid/template — **fid = fileId ของ e-Court (string, บังคับ 2026-10-26)** เป็น docId ของทุก operation ตรง ๆ · verify ไม่รับ token ที่ไม่มี fid) lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver//document.docx|pdf` (uuid ล้วน ไม่มี input ผู้ใช้), putVersionObjects (docx ก่อน — pdf ล้ม = ลบ docx คืนแล้วโยนต่อ), removeVersionObjects (best-effort), presignVersionUrl (TTL env DOCVER_LINK_TTL_SECONDS), insertVersionRecord (**tx เดียว**: upsert documents ON CONFLICT DO NOTHING → SELECT id FOR UPDATE → max(versionNumber)+1 → insert — จ่ายเลข version ปลอดภัยเรียกคู่ขนาน, UNIQUE(document_id, version_number) เป็น safety net) diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index 3f9769f..64de641 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -80,7 +80,7 @@ multipart/form-data: | `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` | | `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) | | `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง | -| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤100 keys, value ≤10,000) | +| `fields` | string (JSON) | optional — map `{ [sdtTag]: text }` (≤1,000 keys · **ความยาวค่าไม่จำกัด** 2026-10-27 ตามคำขอผู้ใช้ — เอกสาร 100–200 หน้า; jsonb รับได้ถึง 255MB) | | `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) | | `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) | diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index bbbb054..7d1dd63 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -124,8 +124,11 @@ export function parseFieldsJson(raw: string): } const record = parsed as Record; const keys = Object.keys(record); - if (keys.length > 100) { - return { ok: false, error: 'field "fields" มี key เกิน 100 รายการ' }; + // เหลือ guard เชิงโครงสร้างเท่านั้น — ความยาวค่าไม่จำกัดแล้ว (2026-10-27 ตามคำขอผู้ใช้: + // เอกสารจริง 100–200 หน้า มี CC ยาวหลายแสนตัวอักษร; ผู้เรียกผ่าน Bearer เท่านั้น + jsonb + // ของ Postgres รับได้ถึงระดับ 255MB จึงไม่มีคอขวดฝั่งเก็บ) + if (keys.length > 1_000) { + return { ok: false, error: 'field "fields" มี key เกิน 1,000 รายการ' }; } for (const key of keys) { if (key.length > 200) { @@ -135,9 +138,6 @@ export function parseFieldsJson(raw: string): 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) }; } diff --git a/tests/lib/document-versioning.test.ts b/tests/lib/document-versioning.test.ts index da18b93..a8d13a4 100644 --- a/tests/lib/document-versioning.test.ts +++ b/tests/lib/document-versioning.test.ts @@ -153,6 +153,25 @@ describe("parseVersionUploadForm", () => { form.append("fields", '{"a":1}'); expect(parseVersionUploadForm(form).ok).toBe(false); }); + + it("ค่าของ fields ไม่จำกัดความยาว (เอกสาร 100–200 หน้า — 2026-10-27) · key รับได้ถึง 1,000", () => { + const big = baseForm(); + big.append("fields", JSON.stringify({ detail1: "พ".repeat(1_000_000) })); + expect(parseVersionUploadForm(big).ok).toBe(true); + + const manyKeys = baseForm(); + manyKeys.append( + "fields", + JSON.stringify(Object.fromEntries(Array.from({ length: 1_000 }, (_, i) => [`tag${i}`, "x"]))), + ); + expect(parseVersionUploadForm(manyKeys).ok).toBe(true); + const over = baseForm(); + over.append( + "fields", + JSON.stringify(Object.fromEntries(Array.from({ length: 1_001 }, (_, i) => [`tag${i}`, "x"]))), + ); + expect(parseVersionUploadForm(over).ok).toBe(false); + }); }); describe("sanitizeDisplayFileName / sanitizeFilePartName", () => { diff --git a/tests/wordaddin/versioningService.test.ts b/tests/wordaddin/versioningService.test.ts index b89ae20..4de9046 100644 --- a/tests/wordaddin/versioningService.test.ts +++ b/tests/wordaddin/versioningService.test.ts @@ -162,6 +162,18 @@ describe("versioningService (real mode)", () => { vi.stubGlobal("fetch", vi.fn(async () => new Response("{}", { status: 401 }))); await expect(getVersions("d")).rejects.toMatchObject({ name: "AuthRequiredError" }); }); + + it("404 จาก history (เอกสารยังไม่เคยมี version เช่น stamp ค้างสมัย mock) = list ว่าง ไม่ใช่ error", async () => { + await signInReal(); + vi.stubGlobal( + "fetch", + vi.fn( + async (_url: string, _init?: RequestInit) => + new Response(JSON.stringify({ error: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)" }), { status: 404 }), + ), + ); + await expect(getVersions("d")).resolves.toEqual([]); + }); }); // โหมด demo ไว้ท้ายสุด — signInForDemo เปิด flag ค้างใน module จนกว่าจะ sign-in จริง diff --git a/wordaddin/services/versioningService.ts b/wordaddin/services/versioningService.ts index 36a97dc..f8fb51a 100644 --- a/wordaddin/services/versioningService.ts +++ b/wordaddin/services/versioningService.ts @@ -17,7 +17,7 @@ import * as authService from "./authService"; import { generateGuid } from "./metadataService"; -import { apiGetJson, apiSendExpectJson } from "./apiClient"; +import { ApiError, apiGetJson, apiSendExpectJson } from "./apiClient"; import { UploadVersionResult, VersionRecord } from "../models/versionRecord"; // --------------------------------------------------------------------------- @@ -81,13 +81,21 @@ export async function uploadVersion( } /** FR-6 — version history เรียงใหม่ → เก่า (คืนสำเนากัน caller mutate store ตรง ๆ) - * docId มาจาก fid ใน token — documentId พารามิเตอร์ไม่ถูกใช้กับ server ของจริง */ + * docId มาจาก fid ใน token — documentId พารามิเตอร์ไม่ถูกใช้กับ server ของจริง + * 404 = เอกสารนี้ยังไม่เคยมี version (เช่น ไฟล์ที่ stamp SavedByAddIn ไว้สมัย mock แล้วเพิ่ง + * มาใช้ backend จริง) — สำหรับ add-in ความหมายเดียวกับ list ว่าง: UI แสดง "ยังไม่มี version + * ในระบบ" และกด Save & Version สร้าง v1 ได้เลย (upload สร้างเอกสารให้อัตโนมัติ) */ export async function getVersions(documentId: string): Promise { if (!authService.isDemoMode()) { - const rows = await apiGetJson( - "/api/documents/versions", - "โหลด version history ไม่สำเร็จ", - ); + let rows: DocumentVersionDTO[]; + try { + rows = await apiGetJson("/api/documents/versions", "โหลด version history ไม่สำเร็จ"); + } catch (error) { + if (error instanceof ApiError && error.status === 404) { + return []; + } + throw error; + } // server เรียงใหม่ → เก่าอยู่แล้ว — แปลงเป็น VersionRecord ตามที่ UI ใช้ return rows.map((row) => ({ versionId: row.versionId,