diff --git a/.env.example b/.env.example index 0aa4f54..473e4d9 100644 --- a/.env.example +++ b/.env.example @@ -38,6 +38,8 @@ MINIO_SECRET_KEY=minioadmin # docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key) # ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร # (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง) +# GET /api/documents/latest (2026-10-31) รับ DOCVER_API_KEY นี้ หรือ EXTERNAL_API_KEY +# (ด้านบน) ตัวใดตัวหนึ่งตรง = ผ่าน — ทั้งคู่ไม่ตั้ง = 401 fail closed เช่นกัน DOCVER_API_KEY= # secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer) # ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret diff --git a/app/api/documents/latest/route.ts b/app/api/documents/latest/route.ts new file mode 100644 index 0000000..4e7cad8 --- /dev/null +++ b/app/api/documents/latest/route.ts @@ -0,0 +1,120 @@ +// PLAN-DOCVER.md §1 — GET /api/documents/latest?type=docx|pdf&ticket_data= +// คืนไฟล์ของ 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", + }, + }, + ); +} diff --git a/docs/PLAN-DOCVER.md b/docs/PLAN-DOCVER.md index e3abd79..166d583 100644 --- a/docs/PLAN-DOCVER.md +++ b/docs/PLAN-DOCVER.md @@ -146,15 +146,53 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า - URL อ้าง `MINIO_ENDPOINT:MINIO_PORT` โดยตรง — **โฮสต์นี้ต้องเข้าถึงได้จากเบราว์เซอร์ ของผู้ใช้** ไม่ใช่แค่จาก Next server (หมายเหตุนี้อยู่ใน `.env.example`/README ด้วย) +### `GET /api/documents/latest?type=docx|pdf&ticket_data=` — ไฟล์ version ล่าสุด (2026-10-31) + +ให้ backend e-Court ดึงไฟล์ **version ล่าสุด** กลับไปเปิดให้ผู้ใช้ (server-to-server — +ไม่ใช่ Bearer: auth ด้วย `x-api-key` อย่างเดียว) · `docId` = ฟิลด์ `fid` **ใน ticket_data** +ไม่ใช่ claim จาก token + +- auth: header `x-api-key` เทียบ env **`DOCVER_API_KEY` หรือ `EXTERNAL_API_KEY` ตัวใด + ตัวหนึ่ง** (ต่างจาก /token ที่รับ `DOCVER_API_KEY` อย่างเดียว — ผู้เรียกถือ key สาย N10 + หรือสาย docver ก็ได้) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ไม่ใส่/ผิด = 401 ข้อความ + เดียวกันเป๊ะ (timing-safe เหมือน `requireDocverApiKey`) +- query (บังคับทั้งคู่): + - `type` = `docx` | `pdf` — **ไม่มี default** (ต่างจาก `format` ของ download-link) + - `ticket_data` = JSON string ของ ticket — **บังคับ `fid` + `uid` + `uname`** (`tid` + optional, `exp` ถ้าส่งต้องเป็นจำนวนเต็มแต่จะถูกเติมใหม่เสมอ) เพราะ + `parseDocverTicket` (`utils/docver-ticket.ts`) บังคับทั้งสามตอน add-in เอาไฟล์ไป + redeem ที่ /token ทาง ticket — validate ตั้งแต่รับเข้า กันคืนไฟล์ที่ add-in เปิด + ไม่ได้เงียบ ๆ · ⚠️ **หมายเหตุถึงฝั่ง e-Court:** `uid` ต้องเป็น*รหัส*ผู้ใช้ (ไม่ใช่ + ชื่อ) และต้องส่ง `uname` (ชื่อแสดงผล) เช่น `{"fid":"DOC-2569-0001","uid":"123", + "uname":"สมชาย ใจดี"}` +- **type=pdf** → คืนไบต์ pdf ของ version ล่าสุดตรง ๆ (`Content-Type: application/pdf`) · + version ล่าสุดไม่มี pdf = 404 (ข้อความเดียวกับ download-link) +- **type=docx** → ดึงไบต์ docx จาก MinIO มา **stamp ticket ใหม่บนสำเนาในหน่วยความจำ** + ก่อนคืน (`buildStampedTicketData` + `stampTicketProps` ของ `lib/lib-tmpl.ts` — recipe + เดียวกับ `applyTemplate` ของกลุ่ม template: เติม `exp = now + TICKET_SEC` → เรียง key + → `HmacSha256` ด้วย secret จาก `addinTicketSecret()` (chain `ADDIN_TICKET_SECRET` → + `SECRET` → `"super_secret"` — ตรงฝั่ง verify ของ add-in//token ทาง ticket เป๊ะ) → + `Ed25519` เฉพาะเมื่อ `PRIVATE_KEY_HEX` ตั้ง (ไม่ตั้ง = ไม่ใส่ prop — chain ของ add-in + verify แค่ HMAC) → `setCustomDocProps` upsert เขียนทับ stamp เดิมได้) — + **ไม่เขียนกลับ MinIO ตาม invariant #1** (non-destructive) · stamp ล้ม = 500 + (ห้ามกลืนเหมือน `applyTemplate` — ไฟล์ไม่มี stamp = add-in เปิดไม่ได้) +- ticket_data หลังเติม exp ยาวเกิน **255 ตัวอักษร = 400** (Word ตัดทอนค่า custom + property ที่ 255 — คืนไฟล์ที่โดนตัด = HMAC เสียเงียบ ๆ) +- response 200: ไบต์ไฟล์ + `Content-Type` ตามชนิด + `Content-Disposition` จาก + `fileName` ของ version (`contentDispositionValue`) + `Cache-Control: no-store` + · เช็ค magic bytes ของไบต์ที่อ่านได้ ไม่ตรง = 500 (ความผิดปกติของพื้นที่เก็บ + ไม่ใช่ความผิดของผู้เรียก) + ### ตาราง error | เหตุ | สถานะ | |---|---| | รูป versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId/fields เก่า) · ไม่ใช่ multipart · magic bytes ผิด · แกะข้อมูลจากไฟล์ไม่ได้ · body ขอ /token ผิดกติกา (รวมขาด fid) | 400 | -| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 | +| `type` ไม่ส่ง/ค่าอื่น · `ticket_data` ไม่ส่ง/ไม่ใช่ JSON object/field นอก allowlist/ขาด fid·uid·uname/เกิน cap/ยาว >2048 · ticket_data หลังเติม exp ยาว >255 (ที่ /latest เฉพาะ type=docx) — ทั้งหมดของ GET /latest | 400 | +| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) · `x-api-key` ไม่ตั้ง (ทั้งคู่)/ไม่ใส่/ไม่ตรงทั้ง `DOCVER_API_KEY`·`EXTERNAL_API_KEY` (ที่ /latest) | 401 | | ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 | -| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 | -| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) | +| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี (รวม type=pdf ที่ /latest แต่ version ล่าสุดไม่มี pdf) | 404 | +| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) · stamp ticket ล้ม/magic bytes ของ object ไม่ตรง (ที่ /latest) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) | ## 2. ตาราง (db/schema.ts) @@ -221,9 +259,12 @@ read-only ของ N10 แต่ตัวนี้เขียนได้) | `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` | | `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) | | `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL | -| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ | +| `DOCVER_API_KEY` | — บังคับ | API key ของ `POST /api/documents/token` (header `x-api-key`) — ไม่ตั้ง/ว่าง = 401 fail closed · เทียบ timing-safe · ห้าม log ค่าดิบ · GET /latest ใช้ตัวนี้**หรือ** `EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง | | `DOCVER_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ | | `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) | +| `TICKET_SEC` | `300` | TTL (วินาที) ของ `exp` ที่ stamp ตอน GET /latest type=docx — env เดียวกับกลุ่ม template (`app/api/template/*`) | +| `PRIVATE_KEY_HEX` | — optional | private key Ed25519 (hex seed) — ตั้งแล้ว GET /latest type=docx จะ stamp prop `Ed25519` เพิ่ม · ไม่ตั้ง = ไม่ใส่ (chain ของ add-in verify แค่ `HmacSha256`) · env เดียวกับกลุ่ม template | +| `ADDIN_TICKET_SECRET` | chain → `SECRET` → `"super_secret"` | secret ของ HMAC ที่ stamp ที่ /latest — ใช้ `addinTicketSecret()` ให้ตรงฝั่ง verify ของ /token ทาง ticket (ดู `docs/PLAN-ADDIN-AUTH.md`) | `putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''` best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย @@ -235,15 +276,23 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET - `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้ โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`) - `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer` - + `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`) + + `parseTokenRequestBody` + `requireDocumentApiKey` (auth ของ GET /latest — สอง env) + (pure เหมือนกัน — ไม่ import `@/db`/`minio`) - `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) + - `ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers + `ensureBucket` + put/remove/presign + `getVersionObjectBytes` (อ่านไบต์ของ /latest) + + `insertVersionRecord` (tx) + query helpers (`findLatestVersionForDoc` ของ /latest) - `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key) - `app/api/documents/versions/route.ts` — POST + GET (docId = fid จาก token) - `app/api/documents/versions/[versionId]/download-link/route.ts` — GET +- `app/api/documents/latest/route.ts` — GET ไฟล์ version ล่าสุด (2026-10-31 — docx + stamp ticket บนสำเนาในหน่วยความจำผ่าน `buildStampedTicketData`/`stampTicketProps` + ของ `lib/lib-tmpl.ts` + `parseTicketDataParam` ของ `utils/docver-ticket.ts` + + `parseLatestTypeParam` ของ `lib/document-versioning.ts`) - `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio (โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io) - `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure + (+ `parseTicketDataParam` ใน `tests/utils/docver-ticket.test.ts` + stamp helpers ใน + `tests/lib/lib-tmpl.test.ts` ของ /latest) ## 8. Out of scope รอบนี้ diff --git a/lib/document-versioning.ts b/lib/document-versioning.ts index 69ee6fd..9cbc18e 100644 --- a/lib/document-versioning.ts +++ b/lib/document-versioning.ts @@ -78,6 +78,17 @@ export function parseDownloadFormat(raw: string | null): return { ok: false, error: "format รับค่า docx หรือ pdf เท่านั้น" }; } +/** ?type= ของ GET /api/documents/latest — บังคับระบุต่างจาก format ของ + * download-link (ไม่มีค่า default): type=docx เดินไปทาง stamping ซึ่งเปลี่ยน + * ไฟล์ที่คืน จึงอย่าเดาแทนผู้เรียก */ +export function parseLatestTypeParam(raw: string | null): + | { ok: true; type: "docx" | "pdf" } + | DocverError { + const type = raw?.trim() ?? ""; + if (type === "docx" || type === "pdf") return { ok: true, type }; + return { ok: false, error: "type รับค่า docx หรือ pdf เท่านั้น" }; +} + function trimOrNull(value: string): string | null { const trimmed = value.trim(); return trimmed === "" ? null : trimmed; diff --git a/lib/docver-store.ts b/lib/docver-store.ts index 4a98f3a..11083cd 100644 --- a/lib/docver-store.ts +++ b/lib/docver-store.ts @@ -18,6 +18,7 @@ import { } from "@/db/schema"; import { contentDispositionValue, + DOCVER_MAX_UPLOAD_BYTES, DOCX_CONTENT_TYPE, PDF_CONTENT_TYPE, } from "@/lib/document-versioning"; @@ -177,6 +178,35 @@ export async function presignVersionUrl( }; } +/** อ่านไบต์ของ object หนึ่งกลับมาเป็น Buffer — ใช้กับ GET /api/documents/latest + * ที่ต้องคืนไฟล์ผ่าน response เอง (ไม่ใช่ presigned URL) · อ่านอย่างเดียว ไม่เขียน + * กลับ: การ stamp ticket ของ route ทำบนสำเนอในหน่วยความจำ · กันขนาดเกิน cap ของ + * กลุ่ม (object เขียนผ่านกลุ่มนี้เองจึงไม่ควรเกิน — เกิน = ข้อมูลแปลกปลอม อ่านต่อไม่ได้) + * · ล้มทุกกรณี = throw ให้ route catch เป็น 500 */ +export async function getVersionObjectBytes( + versionId: string, + format: "docx" | "pdf", +): Promise { + const { client, config } = getClient(); + const key = versionObjectKeys(versionId)[format]; + const stream = await client.getObject(config.bucket, key); + return await new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let total = 0; + stream.on("data", (chunk: Buffer) => { + total += chunk.length; + if (total > DOCVER_MAX_UPLOAD_BYTES) { + stream.destroy(); + reject(new Error(`docver: object ${key} ใหญ่เกิน ${DOCVER_MAX_UPLOAD_BYTES} bytes`)); + return; + } + chunks.push(chunk); + }); + stream.on("end", () => resolve(Buffer.concat(chunks))); + stream.on("error", reject); + }); +} + // ─── DB ────────────────────────────────────────────────────────────────────── export async function findDocumentByDocId(docId: string): Promise { @@ -207,6 +237,21 @@ export async function findVersionForDoc( return row?.version ?? null; } +/** version ล่าสุดของเอกสาร (docId) — join แบบ findVersionForDoc + เรียงเลข version + * จากมากไปน้อยแล้วเอาตัวแรก · ใช้กับ GET /api/documents/latest */ +export async function findLatestVersionForDoc( + docId: string, +): Promise { + const [row] = await db + .select({ version: documentVersions }) + .from(documentVersions) + .innerJoin(documents, eq(documentVersions.documentId, documents.id)) + .where(eq(documents.docId, docId)) + .orderBy(desc(documentVersions.versionNumber)) + .limit(1); + return row?.version ?? null; +} + /** * เอกสาร (docId) นี้เคยมี version ที่สร้างโดย userId นี้หรือยัง — ใช้เป็นหลักฐาน * "เคยทำงานกับเอกสารนี้" ตอนยอมรับ ticket ที่หมดอายุ (requirement ขั้น 7: diff --git a/lib/docver-token.ts b/lib/docver-token.ts index 92bfc41..7d80335 100644 --- a/lib/docver-token.ts +++ b/lib/docver-token.ts @@ -138,6 +138,32 @@ export function requireDocverApiKey(request: Request): Response | null { : docverError("API key ไม่ถูกต้อง", 401); } +/** auth ของ GET /api/documents/latest — x-api-key เทียบ env DOCVER_API_KEY + * หรือ EXTERNAL_API_KEY (ตัวใดตัวหนึ่งตรง = ผ่าน — ผู้เรียกเป็น backend e-Court + * ที่ถือ key สาย N10 หรือสาย docver ก็ได้ ต่างจาก /token ที่รับ DOCVER_API_KEY + * อย่างเดียว) · ทั้งคู่ไม่ตั้ง/ว่าง = 401 fail closed · ข้อความ 401 ของ "ไม่ใส่ + * key" กับ "key ผิด" เป็นอันเดียวกันเหมือน requireDocverApiKey + * คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */ +export function requireDocumentApiKey(request: Request): Response | null { + // เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file + const envKeys = [process.env.DOCVER_API_KEY, process.env.EXTERNAL_API_KEY] + .map((value) => value?.trim()) + .filter((value): value is string => value !== undefined && value !== ""); + if (envKeys.length === 0) { + return docverError("ระบบยังไม่ได้ตั้งค่า API key", 401); + } + const provided = request.headers.get("x-api-key"); + if (provided === null || provided === "") { + return docverError("API key ไม่ถูกต้อง", 401); + } + const providedHash = createHash("sha256").update(provided).digest(); + const matched = envKeys.some((envKey) => { + const envHash = createHash("sha256").update(envKey).digest(); + return timingSafeEqual(providedHash, envHash); + }); + return matched ? null : docverError("API key ไม่ถูกต้อง", 401); +} + /** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด / * หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */ const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ"; diff --git a/lib/lib-tmpl.ts b/lib/lib-tmpl.ts index 631ff46..3ebe83b 100755 --- a/lib/lib-tmpl.ts +++ b/lib/lib-tmpl.ts @@ -1376,4 +1376,60 @@ export async function applyTemplate(templateBuffer:Buffer,templateData:DocumentT } return templateBuffer +} + +// ─── ticket stamp ของ GET /api/documents/latest (กลุ่ม docver) ──────────────── +// recipe เดียวกับ applyTemplate ด้านบนแต่แยกเป็น fn เองเพราะที่มาของ ticket +// ต่างกัน: ของ route นั้นคือ query param จากผู้เรียก (ไม่ใช่ templateData.info) +// และห้ามกลืน error แบบ applyTemplate — การ stamp คือตัวประสงค์ของ endpoint +// ล้ม = throw ให้ route ตอบ 500 (คืนไฟล์ไม่มี stamp เงียบ ๆ = add-in เปิดไม่ได้) + +/** เติม exp ใหม่ (now + ttlSeconds) + เรียง key ตามตัวอักษรแล้วคืน JSON string + * ที่จะ stamp — HMAC/ลายเซ็นต้องคำนวณบน string ดิบนี้เป๊ะ ๆ (ห้าม trim/normalize) + * · ตัด key ที่ค่า null/undefined ทิ้งก่อน เพราะ parser ฝั่ง redeem + * (parseDocverTicket ของ utils/docver-ticket.ts) ไม่ยอม tid:null (ต้องเป็น + * string หรือไม่มี key เลย) — ตัวรับเข้าของ route ใช้ null แทน "ไม่มี" */ +export function buildStampedTicketData( + ticket: Record, + ttlSeconds: number, + nowMs: number = Date.now(), +): string { + const withoutNulls = Object.fromEntries( + Object.entries(ticket).filter(([, value]) => value !== null && value !== undefined), + ); + // หาร 1000 เพราะต้องการหน่วยวินาที (sec) ไม่ใช่มิลลิวินาที (ms) + const expireTimestamp = Math.floor(nowMs / 1000) + ttlSeconds; + const fullObj = { ...withoutNulls, exp: expireTimestamp }; + const sortedObj = sortObjectKeys(fullObj); + return JSON.stringify(sortedObj); +} + +export type StampTicketOptions = { + /** secret ของ HMAC — ฝั่ง caller ของกลุ่ม docver ใช้ addinTicketSecret() เพื่อ + * ตรงกับฝั่ง verify ของ add-in / POST /api/documents/token ทาง ticket */ + secret: string; + /** hex ของ private key (seed 32 หรือ 64 byte) — ไม่ตั้ง = ไม่ stamp Ed25519 + * (chain ของ add-in verify แค่ HmacSha256 — Ed25519 เป็นข้อมูลเสริม) */ + privateKeyHex?: string | null; +}; + +/** stamp custom properties `ticket_data`/`HmacSha256`(+`Ed25519`) ลงสำเนาในหน่วย + * ความจำของ .docx — setCustomDocProps คืน Buffer ใหม่ ไม่แตะไฟล์ต้นทาง + * (non-destructive) และ upsert ตามชื่อ จึงเขียนทับ stamp เดิมของ template ได้ · + * ล้มทุกกรณี = throw */ +export function stampTicketProps( + docxBuffer: Buffer, + ticketData: string, + options: StampTicketOptions, +): { buffer: Buffer; hmacSha256: string; ed25519: string | null } { + const hmacSha256 = generateHmacSha256(ticketData, options.secret); + const privateKeyHex = options.privateKeyHex?.trim() || null; + const ed25519 = privateKeyHex !== null ? signEd25519(ticketData, privateKeyHex) : null; + const buffer = setCustomDocProps( + docxBuffer, + ed25519 === null + ? { ticket_data: ticketData, HmacSha256: hmacSha256 } + : { ticket_data: ticketData, HmacSha256: hmacSha256, Ed25519: ed25519 }, + ); + return { buffer, hmacSha256, ed25519 }; } \ No newline at end of file diff --git a/lib/openapi.ts b/lib/openapi.ts index 43eff5d..bac961a 100644 --- a/lib/openapi.ts +++ b/lib/openapi.ts @@ -858,6 +858,100 @@ export const openApiSpec = { }, }, }, + "/api/documents/latest": { + get: { + tags: ["documents"], + summary: + "ดาวน์โหลดไฟล์ของ version ล่าสุด (docx พร้อม stamp ticket ใหม่ / pdf ตรง ๆ)", + description: + "ให้ backend e-Court ดึงไฟล์ **version ล่าสุด** ของเอกสารกลับไปเปิดให้ผู้ใช้ " + + "(server-to-server — ไม่ใช้ Bearer token)\n\n" + + "- **auth**: header `x-api-key` เทียบ env `DOCVER_API_KEY` **หรือ** " + + "`EXTERNAL_API_KEY` ตัวใดตัวหนึ่ง (ต่างจาก /token ที่รับ DOCVER_API_KEY " + + "อย่างเดียว) · ทั้งคู่ไม่ตั้ง = 401 fail closed\n" + + "- เอกสารถูกระบุด้วย `fid` **ใน ticket_data** (ไม่ใช่ token)\n" + + "- `type=pdf` → คืนไบต์ pdf ตรง ๆ (version ล่าสุดไม่มีสำเนา PDF = 404)\n" + + "- `type=docx` → server เติม `exp` ใหม่ (now + `TICKET_SEC` default 300 วิ) + " + + "คำนวณ `HmacSha256` (secret ตาม chain `ADDIN_TICKET_SECRET` → `SECRET` → " + + "`\"super_secret\"` ให้ตรงฝั่ง verify ของ add-in) และ `Ed25519` (เฉพาะเมื่อ " + + "`PRIVATE_KEY_HEX` ตั้ง) แล้วเขียน custom properties ลง**สำเนาในหน่วยความจำ** " + + "ก่อนคืนไฟล์ — object ใน MinIO ไม่ถูกแต่ (non-destructive) และเขียนทับ stamp " + + "เดิมของ template ได้\n" + + "- **ticket_data บังคับ `fid` + `uid` + `uname`** (`tid` optional · `exp` ถ้าส่ง " + + "ต้องเป็นจำนวนเต็ม แต่จะถูกเติมใหม่เสมอ) เพราะ parser ฝั่ง add-in บังคับทั้งสาม — " + + "⚠️ `uid` ต้องเป็น*รหัส*ผู้ใช้ (ไม่ใช่ชื่อ) และต้องส่ง `uname` (ชื่อแสดงผล)\n" + + "- ticket_data หลังเติม exp ยาวเกิน 255 ตัวอักษร = 400 (ข้อจำกัด custom property " + + "ของ Word)", + security: [{ ApiKeyAuth: [] }], + parameters: [ + { + name: "type", + in: "query", + required: true, + schema: { type: "string", enum: ["docx", "pdf"] }, + description: "ชนิดไฟล์ที่จะดาวน์โหลด (บังคับระบุ — ไม่มีค่า default)", + }, + { + name: "ticket_data", + in: "query", + required: true, + schema: { type: "string" }, + description: + "JSON string ของ ticket เช่น " + + "`{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}` " + + "(ต้อง URL-encode — ใช้ URLSearchParams ของฝั่งเรียกก็ได้)", + example: + "{\"fid\":\"DOC-2569-0001\",\"uid\":\"123\",\"uname\":\"สมชาย ใจดี\"}", + }, + ], + responses: { + 200: { + description: + "ไบต์ของไฟล์ (docx = ผ่านการ stamp ticket ใหม่ · pdf = ตรงจาก version ล่าสุด)", + headers: { + "Content-Disposition": { + description: "attachment พร้อมชื่อไฟล์ของ version (ชื่อไทยผ่าน filename*)", + schema: { type: "string" }, + }, + "Cache-Control": { + description: "no-store — ไฟล์ผูกกับ ticket/exp ของ request นั้น", + schema: { type: "string" }, + }, + }, + content: { + "application/vnd.openxmlformats-officedocument.wordprocessingml.document": { + schema: { type: "string", format: "binary" }, + }, + "application/pdf": { + schema: { type: "string", format: "binary" }, + }, + }, + }, + 400: { $ref: "#/components/responses/BadRequest" }, + 401: { + description: + "x-api-key ไม่ตรงทั้ง DOCVER_API_KEY และ EXTERNAL_API_KEY หรือทั้งคู่ไม่ตั้งค่า " + + "(ไม่ใส่ header กับ key ผิดได้ข้อความเดียวกัน)", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 404: { + description: + "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version) หรือ type=pdf แต่ version " + + "ล่าสุดไม่มีสำเนา PDF", + content: { + "application/json": { + schema: { $ref: "#/components/schemas/Error" }, + }, + }, + }, + 500: { $ref: "#/components/responses/ServerError" }, + }, + }, + }, }, components: { securitySchemes: { @@ -868,7 +962,8 @@ export const openApiSpec = { description: "x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " + "DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " + - "request ของกลุ่มที่ key ตรง)", + "request ของกลุ่มที่ key ตรง) · GET /api/documents/latest พิเศษ: รับได้ทั้งสองค่า " + + "(DOCVER_API_KEY **หรือ** EXTERNAL_API_KEY ตัวใดตัวหนึ่งตรง = ผ่าน)", }, DocverBearer: { type: "http", diff --git a/tests/lib/docver-token.test.ts b/tests/lib/docver-token.test.ts index 6b2a5ab..02bc6e7 100644 --- a/tests/lib/docver-token.test.ts +++ b/tests/lib/docver-token.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { issueDocverToken, parseTokenRequestBody, + requireDocumentApiKey, requireDocverApiKey, requireDocverBearer, verifyDocverToken, @@ -30,12 +31,16 @@ function ok(result: { ok: boolean } & T): asserts result is { ok: true } & T // --- env save/restore — require* อ่าน env ตอนเรียก ไม่ใช่ตอน module load const HAD_API_KEY = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_API_KEY"); const ORIG_API_KEY = process.env.DOCVER_API_KEY; +const HAD_EXTERNAL_KEY = Object.prototype.hasOwnProperty.call(process.env, "EXTERNAL_API_KEY"); +const ORIG_EXTERNAL_KEY = process.env.EXTERNAL_API_KEY; const HAD_SECRET = Object.prototype.hasOwnProperty.call(process.env, "DOCVER_TOKEN_SECRET"); const ORIG_SECRET = process.env.DOCVER_TOKEN_SECRET; afterEach(() => { if (HAD_API_KEY) process.env.DOCVER_API_KEY = ORIG_API_KEY; else delete process.env.DOCVER_API_KEY; + if (HAD_EXTERNAL_KEY) process.env.EXTERNAL_API_KEY = ORIG_EXTERNAL_KEY; + else delete process.env.EXTERNAL_API_KEY; if (HAD_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET; else delete process.env.DOCVER_TOKEN_SECRET; }); @@ -175,6 +180,59 @@ describe("requireDocverApiKey", () => { }); }); +// requireDocumentApiKey — auth ของ GET /api/documents/latest: x-api-key เทียบ +// DOCVER_API_KEY หรือ EXTERNAL_API_KEY ตัวใดตัวหนึ่ง (ต่างจาก /token) +describe("requireDocumentApiKey", () => { + function setKeys(docver: string | undefined, external: string | undefined): void { + if (docver === undefined) delete process.env.DOCVER_API_KEY; + else process.env.DOCVER_API_KEY = docver; + if (external === undefined) delete process.env.EXTERNAL_API_KEY; + else process.env.EXTERNAL_API_KEY = external; + } + + it("ทั้งคู่ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => { + for (const [docver, external] of [ + [undefined, undefined], + ["", ""], + [" ", " "], + ] as const) { + setKeys(docver, external); + const res = requireDocumentApiKey(reqWithKey("test-key")); + expect(res?.status, `${docver}/${external}`).toBe(401); + expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" }); + } + }); + + it("ตั้งอันเดียวตัวไหนก็ผ่านเมื่อ key ตรงตัวนั้น · ตั้งทั้งคู่ตรงอันที่สองก็ผ่าน", () => { + setKeys("docver-key", undefined); + expect(requireDocumentApiKey(reqWithKey("docver-key"))).toBeNull(); + expect(requireDocumentApiKey(reqWithKey("external-key"))?.status).toBe(401); + + setKeys(undefined, "external-key"); + expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull(); + expect(requireDocumentApiKey(reqWithKey("docver-key"))?.status).toBe(401); + + setKeys("docver-key", "external-key"); + expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull(); + }); + + it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => { + setKeys("docver-key", "external-key"); + const missing = requireDocumentApiKey(reqWithKey()); + const wrong = requireDocumentApiKey(reqWithKey("not-any-key")); + expect(missing?.status).toBe(401); + expect(wrong?.status).toBe(401); + expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!)); + }); + + it("env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้ทั้งสองตัว", () => { + setKeys(" docver-key ", undefined); + expect(requireDocumentApiKey(reqWithKey("docver-key"))).toBeNull(); + setKeys(undefined, " external-key "); + expect(requireDocumentApiKey(reqWithKey("external-key"))).toBeNull(); + }); +}); + describe("requireDocverBearer", () => { it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => { const deniedBodies = new Set(); diff --git a/tests/lib/lib-tmpl.test.ts b/tests/lib/lib-tmpl.test.ts index fd585d8..a3baf51 100644 --- a/tests/lib/lib-tmpl.test.ts +++ b/tests/lib/lib-tmpl.test.ts @@ -2,11 +2,19 @@ // (2026-10-30): คุมว่าก้อน {cc, bookmark, properties, verify} แกะได้ครบและ verify ทำงาน // ตาม secret ที่ผู้เรียกส่ง · ไฟล์นี้ตั้งใจไม่แตะ module ที่ลาก @/db หรือ minio มาด้วย // (pattern document-versioning.test.ts — รันได้ไม่ต้องตั้ง DATABASE_URL/MINIO_*) -import { createHmac } from "node:crypto"; +import { createHmac, generateKeyPairSync } from "node:crypto"; import { describe, expect, it } from "vitest"; import PizzaZip from "pizzip"; -import { extractDocumentSnapshot } from "@/lib/lib-tmpl"; +import { verifyAddinTicketHmac } from "@/lib/addin-ticket"; +import { verifyEd25519 } from "@/lib/lib-utils"; +import { + buildStampedTicketData, + extractDocumentSnapshot, + getCustomDocProps, + stampTicketProps, +} from "@/lib/lib-tmpl"; +import { parseDocverTicket } from "@/utils/docver-ticket"; const WORD_NS = "http://schemas.openxmlformats.org/wordprocessingml/2006/main"; const CUSTOM_NS = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; @@ -61,6 +69,31 @@ function stampedDocx(): Buffer { }); } +// setCustomDocProps (ทาง stampTicketProps) บังคับให้ zip มี _rels/.rels และ +// [Content_Types].xml ก่อน (ensureCustomRelationship/ensureCustomContentType) +const RELS_XML = + `\n` + + `` + + `` + + ``; + +const CONTENT_TYPES_XML = + `\n` + + `` + + `` + + `` + + ``; + +/** docx จิ๋วที่ stamp ได้ — ครบส่วนที่ setCustomDocProps บังคับ */ +function stampableDocx(extra: Record = {}): Buffer { + return buildDocx({ + "word/document.xml": documentXml(), + "_rels/.rels": RELS_XML, + "[Content_Types].xml": CONTENT_TYPES_XML, + ...extra, + }); +} + describe("extractDocumentSnapshot", () => { it("แกะครบทั้งก้อน {cc, bookmark, properties, verify} เหมือน /api/template/extract", () => { const snapshot = extractDocumentSnapshot(stampedDocx(), { secret: "super_secret" }); @@ -99,3 +132,133 @@ describe("extractDocumentSnapshot", () => { expect(() => extractDocumentSnapshot(Buffer.from("PK\x03\x04-not-a-zip"))).toThrow(); }); }); + +// buildStampedTicketData/stampTicketProps — ฝั่ง stamp ของ GET /api/documents/latest +// (recipe เดียวกับ applyTemplate แต่ ticket มาจาก query param และห้ามกลืน error) +describe("buildStampedTicketData", () => { + it("เติม exp = floor(now/1000)+ttl เรียง key ตามตัวอักษร — nowMs กำหนดได้ให้ deterministic", () => { + const nowMs = 1_793_000_000_000; + const result = buildStampedTicketData({ fid: "F1", uid: "u1", uname: "น" }, 300, nowMs); + expect(result).toBe( + JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "น" }), + ); + // ลำดับ key ขาเข้าไม่มีผล — เรียงเป็น exp,fid,tid,uid,uname เสมอ (tid มีก็เข้าที่) + const shuffled = buildStampedTicketData( + { uname: "น", uid: "u1", tid: "T12", fid: "F1" }, + 300, + nowMs, + ); + expect(Object.keys(JSON.parse(shuffled))).toEqual(["exp", "fid", "tid", "uid", "uname"]); + }); + + it("ผลลัพธ์ redeem ได้จริง — parseDocverTicket (ฝั่ง add-in) แกะผ่าน", () => { + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + expect(parseDocverTicket(ticketData).ok).toBe(true); + }); + + it("ค่า null/undefined ถูกตัดออก — parseTicketDataParam ใช้ null แทน tid ที่ไม่มี แต่ parseDocverTicket ไม่ยอม tid:null", () => { + const result = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ", tid: null }, + 300, + 1_793_000_000_000, + ); + expect(result).toBe( + JSON.stringify({ exp: 1_793_000_300, fid: "F1", uid: "u1", uname: "ทดสอบ" }), + ); + expect(parseDocverTicket(result).ok).toBe(true); + }); +}); + +describe("stampTicketProps", () => { + const SECRET = "super_secret"; + + /** seed 32 byte (hex) + public key 32 byte (hex) จาก node:crypto — เข้ารูปที่ + * signEd25519/verifyEd25519 ของ lib-utils รับ */ + function ed25519KeyPair(): { seedHex: string; publicHex: string } { + const { privateKey, publicKey } = generateKeyPairSync("ed25519"); + const privDer = privateKey.export({ type: "pkcs8", format: "der" }) as Buffer; + const pubDer = publicKey.export({ type: "spki", format: "der" }) as Buffer; + return { + seedHex: privDer.subarray(privDer.length - 32).toString("hex"), + publicHex: pubDer.subarray(pubDer.length - 32).toString("hex"), + }; + } + + it("stamp ticket_data+HmacSha256 ลงไฟล์เปล่า · ไม่ให้ private key = ไม่มี Ed25519", () => { + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + const bare = stampableDocx(); + const result = stampTicketProps(bare, ticketData, { secret: SECRET }); + + expect(result.ed25519).toBeNull(); + expect(result.hmacSha256).toBe( + createHmac("sha256", SECRET).update(ticketData, "utf8").digest("hex"), + ); + const props = getCustomDocProps(result.buffer); + expect(props.ticket_data).toBe(ticketData); + expect(props.HmacSha256).toBe(result.hmacSha256); + expect(props.Ed25519).toBeUndefined(); + // non-destructive — buffer ต้นฉบับไม่ถูกแตะ (route ห้ามเขียนกลับ MinIO) + expect(bare.equals(stampableDocx())).toBe(true); + }); + + it("ให้ private key = มี Ed25519 และ verify ผ่านด้วย public key คู่กัน", () => { + const { seedHex, publicHex } = ed25519KeyPair(); + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + const result = stampTicketProps(stampableDocx(), ticketData, { + secret: SECRET, + privateKeyHex: seedHex, + }); + expect(result.ed25519).not.toBeNull(); + expect(verifyEd25519(ticketData, result.ed25519!, publicHex)).toBe(true); + expect(getCustomDocProps(result.buffer).Ed25519).toBe(result.ed25519); + }); + + it("chain จบถึง end: verifyAddinTicketHmac (ทางเข้า /api/documents/token) ผ่าน", () => { + const ticketData = buildStampedTicketData( + { fid: "F1", uid: "u1", uname: "ทดสอบ" }, + 300, + Date.now(), + ); + const result = stampTicketProps(stampableDocx(), ticketData, { + secret: SECRET, + }); + const verified = verifyAddinTicketHmac(ticketData, result.hmacSha256, SECRET); + expect(verified.ok).toBe(true); + // secret ผิด = ไม่ผ่าน (HMAC ผูกกับ secret ของ add-in จริง) + expect(verifyAddinTicketHmac(ticketData, result.hmacSha256, "wrong").ok).toBe(false); + }); + + it("stamp ทับไฟล์ที่มี ticket เดิม = ค่าถูกแทนทั้งก้อน (exp เก่าหาย)", () => { + const fresh = buildStampedTicketData( + { fid: "F2", uid: "u2", uname: "ใหม่" }, + 300, + Date.now(), + ); + const result = stampTicketProps( + stampableDocx({ "docProps/custom.xml": customPropsXml(TICKET_DATA, TICKET_HMAC) }), + fresh, + { secret: SECRET }, + ); + const props = getCustomDocProps(result.buffer); + expect(props.ticket_data).toBe(fresh); + expect(String(props.ticket_data)).not.toContain("2000000000"); // exp เก่าของ TICKET_DATA + }); + + it("ไฟล์เสีย = throw (route ตอบ 500 — ห้ามกลืนแบบ applyTemplate)", () => { + expect(() => + stampTicketProps(Buffer.from("not-a-zip"), "{}", { secret: SECRET }), + ).toThrow(); + }); +}); diff --git a/tests/utils/docver-ticket.test.ts b/tests/utils/docver-ticket.test.ts index bdde305..14011e8 100644 --- a/tests/utils/docver-ticket.test.ts +++ b/tests/utils/docver-ticket.test.ts @@ -4,6 +4,7 @@ import { isTicketExpired, normalizeHexDigest, parseDocverTicket, + parseTicketDataParam, timingSafeEqualHex, } from "@/utils/docver-ticket"; @@ -116,3 +117,84 @@ describe("timingSafeEqualHex", () => { expect(timingSafeEqualHex(a, "ab")).toBe(false); }); }); + +// parseTicketDataParam — query param `ticket_data` ของ GET /api/documents/latest: +// ticket ก่อนเติม exp (server เติมตอน stamp) แต่บังคับ fid/uid/uname เพราะ +// parseDocverTicket บังคับทั้งสามตอน add-in redeem — ขาด = ไฟล์ที่คืนไปเปิดไม่ได้ +describe("parseTicketDataParam", () => { + it("fid+uid+uname น้อยสุด = ผ่าน (trim ค่า) · +tid ก็ได้ · ไม่มี exp ก็ผ่าน", () => { + const minimal = parseTicketDataParam( + JSON.stringify({ fid: " F1 ", uid: " u1 ", uname: " ทดสอบ " }), + ); + ok(minimal); + expect(minimal.ticket).toEqual({ fid: "F1", uid: "u1", uname: "ทดสอบ", tid: null }); + + const withTid = parseTicketDataParam( + JSON.stringify({ fid: "F1", tid: "T12", uid: "u1", uname: "ทดสอบ" }), + ); + ok(withTid); + expect(withTid.ticket.tid).toBe("T12"); + }); + + it("tid ส่งค่าว่าง = null · exp ถ้าส่งมาเป็นจำนวนเต็ม = รับ (เติมทับตอน stamp)", () => { + const blankTid = parseTicketDataParam( + JSON.stringify({ fid: "F1", tid: " ", uid: "u1", uname: "n" }), + ); + ok(blankTid); + expect(blankTid.ticket.tid).toBeNull(); + + const withExp = parseTicketDataParam( + JSON.stringify({ exp: 1_793_067_433, fid: "F1", uid: "u1", uname: "n" }), + ); + ok(withExp); + }); + + it("ไม่ส่ง / ว่าง / ยาวเกิน 2048 = ไม่ผ่าน", () => { + expect(parseTicketDataParam(null).ok).toBe(false); + expect(parseTicketDataParam(" ").ok).toBe(false); + expect(parseTicketDataParam("x".repeat(2049)).ok).toBe(false); + }); + + it("JSON ไม่ผ่าน / ไม่ใช่ object (array/string/null/ตัวเลข) = ไม่ผ่าน", () => { + for (const raw of ["not json", "[1,2]", '"text"', "null", "42"]) { + expect(parseTicketDataParam(raw).ok, raw).toBe(false); + } + }); + + it("field อื่นนอก allowlist = ไม่ผ่าน (error ระบุชื่อ field)", () => { + const result = parseTicketDataParam( + JSON.stringify({ fid: "f", uid: "u", uname: "n", oops: 1 }), + ); + expect(result.ok).toBe(false); + if (!result.ok) expect(result.error).toContain("oops"); + }); + + it("exp ส่งมาผิดชนิด (string/ทศนิยม) = ไม่ผ่าน แม้จะไม่ได้ใช้ค่าเดิม", () => { + for (const exp of ["1793067433", 1.5]) { + const result = parseTicketDataParam( + JSON.stringify({ exp, fid: "f", uid: "u", uname: "n" }), + ); + expect(result.ok, String(exp)).toBe(false); + } + }); + + it("ขาด/ผิดชนิด fid·uid·uname รายตัว = ไม่ผ่าน — บังคับครบเหมือน parseDocverTicket", () => { + for (const ticket of [ + { uid: "u", uname: "n" }, // ไม่มี fid + { fid: "f", uname: "n" }, // ไม่มี uid + { fid: "f", uid: "u" }, // ไม่มี uname + { fid: "", uid: "u", uname: "n" }, // fid ว่าง + { fid: 3, uid: "u", uname: "n" }, // fid เป็น number + { fid: "f", uid: "u", uname: "n", tid: 12 }, // tid ไม่ใช่ string + ]) { + expect(parseTicketDataParam(JSON.stringify(ticket)).ok, JSON.stringify(ticket)).toBe(false); + } + }); + + it("ความยาวเกิน cap: uid > 100 / uname > 200 / fid > 200 / tid > 100 = ไม่ผ่าน", () => { + expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u".repeat(101), uname: "n" })).ok).toBe(false); + expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u", uname: "n".repeat(201) })).ok).toBe(false); + expect(parseTicketDataParam(JSON.stringify({ fid: "f".repeat(201), uid: "u", uname: "n" })).ok).toBe(false); + expect(parseTicketDataParam(JSON.stringify({ fid: "f", uid: "u", uname: "n", tid: "t".repeat(101) })).ok).toBe(false); + }); +}); diff --git a/utils/docver-ticket.ts b/utils/docver-ticket.ts index 14130c6..26b3719 100644 --- a/utils/docver-ticket.ts +++ b/utils/docver-ticket.ts @@ -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; + 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;