feat(docver): add access token endpoint and Bearer auth on all endpoints
- 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>
This commit is contained in:
parent
280cc2da40
commit
98b370c925
13 changed files with 932 additions and 131 deletions
|
|
@ -18,3 +18,12 @@ MINIO_ACCESS_KEY=minioadmin
|
||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
# MINIO_BUCKET=docver
|
# MINIO_BUCKET=docver
|
||||||
# DOCVER_LINK_TTL_SECONDS=900
|
# DOCVER_LINK_TTL_SECONDS=900
|
||||||
|
|
||||||
|
# docver token (2026-10-22): API key ของ POST /api/documents/token (header x-api-key)
|
||||||
|
# ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ตั้งค่ายาวและสุ่ม เช่น 32+ ตัวอักษร
|
||||||
|
# (ห้าม log ค่า key ดิบ — เทียบแบบ timing-safe ทุกครั้ง)
|
||||||
|
DOCVER_API_KEY=
|
||||||
|
# secret เซ็น/ตรวจ JWT HS256 ของทุก endpoint /api/documents/* (Authorization: Bearer)
|
||||||
|
# ไม่ตั้ง/ว่าง = ออก token ไม่ได้ (500) และทุก endpoint ตอบ 401 (fail closed) — ห้าม log ค่า secret
|
||||||
|
DOCVER_TOKEN_SECRET=
|
||||||
|
# DOCVER_TOKEN_TTL_SECONDS=3600
|
||||||
|
|
|
||||||
11
CLAUDE.md
11
CLAUDE.md
|
|
@ -21,7 +21,7 @@
|
||||||
1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม)
|
1. **Non-Destructive:** ไฟล์ต้นฉบับ (PDF/รูปภาพ) ไม่ถูกแก้ไขเด็ดขาด — annotation เก็บใน DB เป็นพิกัดเท่านั้น (export PDF ของ F8/N3 สร้างไฟล์ใหม่จากสำเนาในหน่วยความจำ — อ่าน `public/` อย่างเดียว ห้ามเขียนทับ; อัปโหลด N4 เขียนไฟล์**ใหม่**ที่ `public/note/uploads/` เท่านั้น ห้ามแตะไฟล์เดิม)
|
||||||
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
|
2. **พิกัดทุกอย่างใน DB เป็น % ของขนาดหน้า** — `rects`, `paths`, `position`, `strokeWidth` (% ของ width), `yOffset` (% ของ height) — แปลง px⇄% ผ่าน `utils/coordinates.ts` เท่านั้น และก่อนบันทึก HIGHLIGHT ต้องรวบ rects ด้วย `mergeRectsByLine` เหลือ 1 rect ต่อบรรทัด
|
||||||
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
|
3. **ลำดับ layer ตาม PLAN.md §1:** canvas (`pointer-events: none` เสมอ) → highlight-layer (`none` เสมอ, `mix-blend-mode: multiply`) → textLayer → InkLayer (SVG) → comment pins — สลับ pointer-events ตาม tool ปัจจุบัน
|
||||||
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone บอก userId/userName เองใน request ไม่มี session ของแอป note) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath`
|
4. **เขียนผ่าน Server Actions เท่านั้น** (`app/actions/annotations.ts`) และ**หลังเขียน DB สำเร็จทุกครั้ง ต้อง publish event ผ่าน `lib/annotate-events.ts` + `revalidatePath` หน้า viewer และ summary ของ fileId นั้น** (helper `revalidateFilePages` — สั่ง browser ทิ้ง Router Cache ฝั่ง client, `force-dynamic` ปิดแค่ฝั่ง server ไม่พอ: ไม่งั้นกด back แล้วคลิกเข้าไฟล์ใหม่ยังได้ initial data เก่าจนต้อง hard refresh) / **อ่าน + sync ผ่าน SSE hook** (`hooks/useAnnotationRealtime.ts` เปิด `EventSource` ไป `/api/note/annotations/stream?fileId=`) — event เป็น DTO (camelCase + ISO string) จาก server ตรง ๆ — **ยกเว้น `registerExternalFile` (N8)** ที่เขียน row `files` ระหว่าง render ของหน้า `/note` และ `/note/annotate` (entry point ทั้งสองของสัญญาลิงก์ `?fileUrl=`) แล้ว `redirect` ออกทันที (หน้า `force-dynamic` + รายการไฟล์ไม่ได้ sync ผ่าน SSE — ไม่ต้อง publish event/`revalidatePath`) · เช่นกัน **กลุ่ม docver `app/api/documents/**`** (จัด version เอกสาร — `docs/PLAN-DOCVER.md`) เขียนตาราง `documents`/`document_versions` ผ่าน route handler ไม่ใช่ Server Action (ผู้เรียกเป็นระบบภายนอก stand-alone ไม่มี session ของแอป note — identity userId/userName และ templateId แกะจาก Bearer token ที่ขอจาก `POST /api/documents/token` ผ่าน `lib/docver-token.ts` ไม่รับจาก request payload อีกต่อไป) — ไม่มีหน้า note ใดอ่านตารางนั้น จึงไม่ต้อง publish event/`revalidatePath`
|
||||||
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
|
5. **Timestamp ข้าม server→client→SSE เป็น ISO string เสมอ** (DTO types) — ห้ามส่ง Date object ตรง ๆ
|
||||||
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)`
|
6. **pdfjs-dist ต้อง `dynamic import()` ภายใน useEffect เท่านั้น** — ห้าม import top-level ใน module scope ที่ server evaluate และต้องตั้ง `GlobalWorkerOptions.workerSrc = withBasePath("/pdf.worker.min.mjs")` (postinstall `scripts/patch-pdf-worker.mjs` คัดลอกไฟล์ไป `public/note/` **แล้ว patch จุดเดียว: ถอด `if (scaledDim)` รอบ `textChunk.prevTransform = getCurrentTextTransform()` ให้อัปเดตทุก glyph** — pdf.js เดิมข้าม glyph กว้าง 0 อย่างสระ/วรรณยุกต์ไทย ทำให้ charSpacing ของ glyph เหล่านั้นสะสมเป็น gap จน `addFakeSpaces` แทรก " " กลางคำ "ฉะนั้ น" — bug ของ pdf.js ที่ upstream ยังไม่แก้; การ patch ทำบนโค้ด minified ด้วย pattern จำเพาะ ถ้าอัปเกรด pdfjs-dist แล้ว pattern ไม่ตรง postinstall จะ **fail ดัง ๆ โดยตั้งใจ** กัน patch หลุดเงียบ — test กัน regression: `tests/scripts/patch-pdf-worker.test.ts` + fixture `tests/fixtures/thai_letterspacing.pdf` — Docker build ต้องใช้ script นี้ด้วย ไม่ใช่ copyFileSync เปล่า ดู `docker/web.Dockerfile`) — ห้ามใช้ `new URL(..., import.meta.url)`
|
||||||
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
|
7. **InkLayer ใช้ SVG แบบไม่มี viewBox** — render ด้วย px จริง แปลง % → px จากขนาดที่ ResizeObserver วัด (viewBox จะทำให้ความหนาเส้นบิดเบี้ยว)
|
||||||
|
|
@ -35,13 +35,13 @@
|
||||||
|
|
||||||
```
|
```
|
||||||
docker-compose.yml ← dev dependencies: `db` (judes-note-db — ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README migrate ได้เลย, POSTGRES_DB ecourt-note) + `minio` (judes-note-minio — object storage ของกลุ่ม docver; โฮสต์แมพ 19000 API/19001 console เพราะ 9000/9001 ชน container โปรเจกต์อื่นในเครื่อง dev, image จาก quay.io ทางการ; bucket `docver` ไม่ต้องสร้างเอง app สร้างตอน first use ผ่าน ensureBucket)
|
docker-compose.yml ← dev dependencies: `db` (judes-note-db — ชื่อ container/วอลุ่มเดียวกับคำสั่ง docker run เดิมใน README migrate ได้เลย, POSTGRES_DB ecourt-note) + `minio` (judes-note-minio — object storage ของกลุ่ม docver; โฮสต์แมพ 19000 API/19001 console เพราะ 9000/9001 ชน container โปรเจกต์อื่นในเครื่อง dev, image จาก quay.io ทางการ; bucket `docver` ไม่ต้องสร้างเอง app สร้างตอน first use ผ่าน ensureBucket)
|
||||||
docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 3 endpoint, ดีไซน์ตาราง, env MINIO_* ทั้งหมด
|
docs/PLAN-DOCVER.md ← แผนกลุ่ม API docver: จัด version เอกสาร .docx+.pdf ต่อ docId (ห้ามซ้ำ) เก็บ object ใน MinIO + ผู้อัปโหลดต่อ version — contract 4 endpoint (เพิ่ม POST /token ขอ access_token — Bearer บังคับทุก endpoint), ดีไซน์ตาราง, env MINIO_* + DOCVER_API_KEY/DOCVER_TOKEN_SECRET/DOCVER_TOKEN_TTL_SECONDS ทั้งหมด
|
||||||
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
scripts/patch-pdf-worker.mjs ← postinstall: คัดลอก pdf.worker.min.mjs จาก node_modules ไป public/note/ + patch fake-space ไทย (invariant #6 — เจอ pattern ≠ 1 จุดหลังอัปเกรด pdfjs-dist = exit 1 ดัง ๆ)
|
||||||
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
|
scripts/copy-swagger-ui.mjs ← postinstall ตัวที่สอง: คัดลอก swagger-ui-dist (bundle+css) ไป public/note/swagger-ui/ สำหรับหน้า Swagger UI ที่ GET /api/swagger (intranet ไม่ใช้ CDN — หาไฟล์ไม่เจอหลังอัปเกรด = exit 1 ดัง ๆ เหมือน patch-pdf-worker; Docker build รัน script นี้ด้วย ดู docker/web.Dockerfile)
|
||||||
scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน)
|
scripts/fetch-office-js.mjs ← postinstall ตัวที่สาม: ดาวน์โหลด Office.js runtime **ฉบับ CDN ล่าสุด** (appsforoffice.microsoft.com — ชุดไฟล์เฉพาะ Word: loader + host files ราย platform + legacy 15.x + o15apptofilemappingtable/microsoftajax + office_strings.js ต่อ locale en-us/th-th บังคับ) ไป public/wordaddin/office-js/ ให้ taskpane โหลด same-origin — npm `@microsoft/office-js` เลิกอัปเดตที่ 1.1.110 (~2018) ไม่มี `Document.close`/enum `CloseBehavior` ที่ runtime ใหม่+`@types/office-js` มี; ไฟล์หลักหาย/ตรวจไม่เจอ `"Document.close"`+`CloseBehavior` ใน word-win32-16.01.js หลังโหลด = exit 1 ดัง ๆ (ไฟล์ปลายทาง gitignore — ดาวน์โหลดใหม่ทุกครั้ง; ติดตั้งต้องออกเน็ตถึง CDN ของ Microsoft ส่วน runtime ตัวระบบ intranet ล้วน)
|
||||||
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
db/schema.ts ← ตาราง + JSONB types + DTO + mapper toDTO
|
||||||
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
db/index.ts ← postgres.js + drizzle (DATABASE_URL → container judes-note-db, prepare:false)
|
||||||
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ผู้เรียกบอก userId/userName เอง ไม่มี session, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
|
middleware.ts ← บังคับ login ทุกหน้า — ไม่มี cookie oacCase ที่ parse ได้ = redirect /note/login (path ที่ตัด basePath แล้ว: หน้าขึ้นต้น /note/… ส่วน API ขึ้นต้น /api/note/… — ยกเว้น note/login, api/note/*, api/template/* (กลุ่ม docx template เปิดกว้างให้ทางศาล/ผู้ทดสอบภายนอกยิงได้เลย — ไม่แตะ DB), api/documents/* (กลุ่ม docver จัด version เอกสาร — stand-alone ไม่มี session ใช้ auth ของกลุ่มเอง: ขอ access_token ผ่าน x-api-key แล้วแนบ Bearer ทุก endpoint, docs/PLAN-DOCVER.md), api/swagger/* (หน้า Swagger UI), wordaddin/* (taskpane ของ Word add-in — auth mock ของตัวเอง), note/uploads/*, note/samples, note/swagger-ui/*, note/pdf.worker.min.mjs, _next, fonts, favicon และ **root "/" เอง** (matcher ใช้ `.+` — root เป็น redirect สู่ /wordaddin ซึ่ง public ตามคำขอผู้ใช้ 2026-10-14, ลิงก์ e-Court N8 ที่ root ยังถูกคุมผ่าน /note ที่รับส่งต่อ))
|
||||||
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
lib/annotate-events.ts ← pub/sub in-process (actions publish → SSE route subscribe)
|
||||||
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
lib/server-pdf.ts ← path guard อ่าน PDF จาก public/note/ (export ใช้)
|
||||||
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
lib/session.ts ← getSessionUser — identity จาก cookie oacCase (mock login e-Court)
|
||||||
|
|
@ -50,7 +50,8 @@ 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-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=<id>` / 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/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=<id>` / 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/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): parseDocId (charset [A-Za-z0-9._:-] 1–200), parseVersionUploadForm (allowlist field + userId/userName บังคับ + fields JSON ≤100 keys + message ≤2048), isDocxBytes/isPdfBytes (magic bytes), parseDownloadFormat/parseVersionIdParam, contentDispositionValue (ASCII fallback + filename*=UTF-8'') + docverError สร้าง Response JSON {"error": ไทย} ใช้ร่วมทุก route ของกลุ่ม
|
lib/document-versioning.ts ← docver pure validation (ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test tests/lib/document-versioning.test.ts ต้องรันได้เหมือน lib/external-api-auth.ts): parseDocId (charset [A-Za-z0-9._:-] 1–200), 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 ของกลุ่ม
|
||||||
|
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): 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 เก็บเป็น claim ยังไม่ถูก operation ใดอ่าน)
|
||||||
lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/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)
|
lib/docver-store.ts ← docver server-side MinIO + DB (env MINIO_* อ่านตอนเรียกไม่ใช่ module load; client + cached ensureBucket เก็บบน globalThis แบบ db/index.ts): object key `docver/<versionId>/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)
|
||||||
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
wordaddin/ ← **client-only zone ของ Word add-in** (ห้าม import จาก lib/ หรือถูก import โดย server component — พอร์ตดิบจาก D:\word-addin-poc\addin\src, spec: docs/addin-ui-flow.md, คู่มือ dev: docs/WORDADDIN-DEV.md)
|
||||||
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth)
|
wordaddin/config.ts ← env NEXT_PUBLIC_ADDIN_API_BASE_URL ("" = ยังไม่ต่อ backend จริง) + NEXT_PUBLIC_ADDIN_MOCK_USERNAME (mock auth)
|
||||||
|
|
@ -82,7 +83,7 @@ app/api/note/annotations/export/route.ts ← export PDF ฝัง annotation
|
||||||
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
|
app/api/note/external/documents/route.ts ← N10 API ภายนอกเส้นเดียว (POST + JSON body {fileId และ/หรือ typeId, filter:{type, userId, annotationTypes}} — auth ผ่าน requireExternalApiKey ก่อนแตะ DB, ถาม fileId = ค้น files.external_id = "ecourt:<fileId>" ไม่เจอ 404 แม้ส่ง typeId มาด้วย / ถาม typeId = ทุกไฟล์ในกลุ่ม กลุ่มว่าง = 200 documents:[] / ส่งทั้งคู่ = AND กรอง externalId+typeId ใน query เดียว เหลือเอกสารเดียว ไม่ตรงคู่ = 404 แยกสาเหตุในข้อความ (2026-09-25 เดิมเคยเป็น OR แล้วเป็น 400); response รูปเดียว {documents:[bundle…]} ผ่าน loadDocumentBundles ทั้งสอง mode; ครอบ try/catch ทุก DB call — ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework; read-only ไม่ publish SSE ไม่ revalidatePath — snapshot ณ ตอนเรียก)
|
||||||
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login)
|
app/api/swagger/route.ts ← **GET /api/swagger = หน้า Swagger UI** (2026-10-06 — document ของ API ทุกกลุ่ม: spec inline จาก lib/openapi.ts + asset จาก public/note/swagger-ui/ ของ postinstall scripts/copy-swagger-ui.mjs, persistAuthorization เก็บ x-api-key ไว้ใช้ข้าม reload — เปิดกว้างไม่ต้อง login)
|
||||||
app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…)
|
app/api/template/{test,extract,render}/route.ts ← กลุ่ม API docx template ของ wordaddin (WIP — GET test ตรวจ service / POST extract ดึงค่า Content Control / POST render = template(.docx)+data(JSON ไฟล์หรือ field dataJson) คืน .docx ใหม่; logic รวมอยู่ app/api/template/lib-tmpl.ts เป็นแชร์ไลบรารี pizzip+xpath; ไฟล์ทดสอบ/โน้ตอยู่ app/api/template/test/ — ไม่แตะ DB, URL /docedit/api/template/…)
|
||||||
app/api/documents/[docId]/versions/route.ts + [versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, ไม่มี auth เปิดกว้างเหมือน api/template): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+userId/userName+fields/message/clientVersionId/templateId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath
|
app/api/documents/token/route.ts + [docId]/versions/route.ts + [versionId]/download-link/route.ts ← **กลุ่ม API docver จัด version เอกสาร** (docs/PLAN-DOCVER.md — URL /docedit/api/documents/…, auth ของกลุ่มเอง 2026-10-22: POST token ขอ access_token ด้วย header `x-api-key` + env `DOCVER_API_KEY` (lib/docver-token.ts — fail closed 401 ไม่ตั้ง/ว่าง เหมือน N10) แล้ว **endpoint ที่เหลือทุกตัวบังคับ `Authorization: Bearer`** JWT HS256 เซ็นด้วย env `DOCVER_TOKEN_SECRET` (TTL `DOCVER_TOKEN_TTL_SECONDS` default 3600) — identity userid/username + templateId แกะจาก claims ไม่รับจาก request อีกต่อไป; ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field): POST versions = multipart `file`(.docx บังคับ ≤100MB เช็ค size ก่อน buffer → 413)+`pdf`(optional ไฟล์เปล่า = ไม่มี)+fields/message/clientVersionId/fileName → สร้างเอกสารอัตโนมัติถ้า docId ใหม่, MinIO ก่อน DB (DB ล้มหลัง put = เก็บกวาด object แบบ N4) → `{hasPdf, versionId, versionNumber}` (เลขจาก server เป็นค่าจริง) / GET versions = DocumentVersionDTO[] ใหม่→เก่า (ไม่เคยมี docId = 404) / GET download-link?format=docx|pdf = presigned URL อายุสั้น (เช็ค docId+versionId คู่กันกันโยน id ข้ามเอกสาร; ขอ pdf ที่ version ไม่มี = 404) — carve-out invariant #4: ไม่ publish SSE ไม่ revalidatePath
|
||||||
app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
app/note/uploads/[name]/route.ts ← เสิร์ฟไฟล์อัปโหลดจาก public/note/uploads/ (N4 — Next production เห็นไฟล์ static เฉพาะที่มีตอน startup ไฟล์ที่เขียน runtime ต้องเสิร์ฟผ่าน route)
|
||||||
app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component)
|
app/note/page.tsx ← รายการไฟล์จัด section ตามกลุ่ม type_id + "ไม่มีกลุ่ม" ท้ายสุด (N9 — server component)
|
||||||
app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line)
|
app/note/groups/[typeId]/page.tsx ← N9 หน้าเอกสารในกลุ่ม (server component force-dynamic — files where type_id orderBy createdAt, ไม่มี = 404; ~~หมายเหตุ "แสดงเฉพาะเอกสารที่เคยเปิดในระบบนี้"~~ ถอดออกตามคำขอผู้ใช้ 2026-09-24; **redesign 2026-09-24 ตามคำขอผู้ใช้: ไม่มี navbar** — explorer 2 คอลัมน์ ตัวกรองซ้าย + list เดียวรวม bookmark+annotation ทั้งกลุ่มไม่แยกไฟล์: query ทั้งหมดฝั่ง server แล้วส่ง `GroupFileSummary[]` + `GroupListItem[]` (เรียง ไฟล์ตาม createdAt → หน้า → bookmark ก่อน annotation → createdAt) ให้ `GroupExplorer` กรองใน memory, แถวรายการ deep link `?ann=`/`?page=` เหมือนหน้าสรุป F12 + ไฟล์ที่มาต้น meta line)
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
// PLAN-DOCVER.md §1 — GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf
|
// PLAN-DOCVER.md §1 — GET /api/documents/{docId}/versions/{versionId}/download-link?format=docx|pdf
|
||||||
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
|
// คืน presigned URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS) ให้ UI เปิดในเบราว์เซอร์จริง
|
||||||
// ของผู้ใช้ — เช็คทั้ง docId และ versionId พร้อมกันกันโยน id ข้ามเอกสาร
|
// ของผู้ใช้ — เช็คทั้ง docId และ versionId พร้อมกันกันโยน id ข้ามเอกสาร
|
||||||
|
// auth: Authorization: Bearer (access_token จาก POST /api/documents/token — ทุก endpoint ของกลุ่ม)
|
||||||
import {
|
import {
|
||||||
docverError,
|
docverError,
|
||||||
parseDocId,
|
parseDocId,
|
||||||
parseDownloadFormat,
|
parseDownloadFormat,
|
||||||
parseVersionIdParam,
|
parseVersionIdParam,
|
||||||
} from "@/lib/document-versioning";
|
} from "@/lib/document-versioning";
|
||||||
|
import { requireDocverBearer } from "@/lib/docver-token";
|
||||||
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
|
import { findVersionForDoc, presignVersionUrl } from "@/lib/docver-store";
|
||||||
|
|
||||||
export const runtime = "nodejs";
|
export const runtime = "nodejs";
|
||||||
|
|
@ -16,6 +18,9 @@ export async function GET(
|
||||||
request: Request,
|
request: Request,
|
||||||
{ params }: { params: { docId: string; versionId: string } },
|
{ params }: { params: { docId: string; versionId: string } },
|
||||||
) {
|
) {
|
||||||
|
const auth = requireDocverBearer(request);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
const docId = parseDocId(params.docId);
|
const docId = parseDocId(params.docId);
|
||||||
if (!docId.ok) return docverError(docId.error, 400);
|
if (!docId.ok) return docverError(docId.error, 400);
|
||||||
const versionId = parseVersionIdParam(params.versionId);
|
const versionId = parseVersionIdParam(params.versionId);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/{docId}/versions
|
// PLAN-DOCVER.md §1 — POST อัปโหลด version ใหม่ + GET history ของ /api/documents/{docId}/versions
|
||||||
// กลุ่ม API stand-alone ไม่มี auth (เปิดกว้างเหมือน api/template — intranet) — ผู้เรียกบอก
|
// กลุ่ม API stand-alone ไม่มี session — auth ด้วย access_token ของกลุ่มนี้: ขอที่
|
||||||
// identity เองผ่าน field userId/userName เพราะไม่มี session ร่วมกับใคร
|
// POST /api/documents/token (ยืนยัน x-api-key กับ env DOCVER_API_KEY) แล้วแนบ
|
||||||
|
// Authorization: Bearer ทุก request — identity (userid/username) และ templateId
|
||||||
|
// แกะจาก claims ของ token ไม่รับจาก multipart form อีก (lib/docver-token.ts)
|
||||||
import { createHash, randomUUID } from "node:crypto";
|
import { createHash, randomUUID } from "node:crypto";
|
||||||
|
|
||||||
import { toDocumentVersionDTO } from "@/db/schema";
|
import { toDocumentVersionDTO } from "@/db/schema";
|
||||||
|
|
@ -14,6 +16,7 @@ import {
|
||||||
parseVersionUploadForm,
|
parseVersionUploadForm,
|
||||||
sanitizeFilePartName,
|
sanitizeFilePartName,
|
||||||
} from "@/lib/document-versioning";
|
} from "@/lib/document-versioning";
|
||||||
|
import { requireDocverBearer } from "@/lib/docver-token";
|
||||||
import {
|
import {
|
||||||
findDocumentByDocId,
|
findDocumentByDocId,
|
||||||
insertVersionRecord,
|
insertVersionRecord,
|
||||||
|
|
@ -27,11 +30,14 @@ export const runtime = "nodejs";
|
||||||
export const dynamic = "force-dynamic";
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
/** POST — อัปโหลด version ใหม่ (เอกสารสร้างแบบ implicit ถ้าเป็น version แรก)
|
||||||
* ลำดับ: validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
* ลำดับ: Bearer token → validate text field ก่อนแตะไฟล์ → cap ขนาดก่อน buffer → magic bytes →
|
||||||
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
* putObject MinIO → insert DB (tx) — DB ล้ม = เก็บกวาด object (pattern N4)
|
||||||
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
* response = UploadVersionResult เป๊ะ โดย versionNumber เป็นค่า authoritative
|
||||||
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
* จาก server (client เดา latest+1 แล้วแก้เองเมื่ออัปโหลดชนกัน) */
|
||||||
export async function POST(request: Request, { params }: { params: { docId: string } }) {
|
export async function POST(request: Request, { params }: { params: { docId: string } }) {
|
||||||
|
const auth = requireDocverBearer(request);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
const docId = parseDocId(params.docId);
|
const docId = parseDocId(params.docId);
|
||||||
if (!docId.ok) return docverError(docId.error, 400);
|
if (!docId.ok) return docverError(docId.error, 400);
|
||||||
|
|
||||||
|
|
@ -114,14 +120,14 @@ export async function POST(request: Request, { params }: { params: { docId: stri
|
||||||
contentHash,
|
contentHash,
|
||||||
message: parsed.form.message,
|
message: parsed.form.message,
|
||||||
fields: parsed.form.fields,
|
fields: parsed.form.fields,
|
||||||
templateId: parsed.form.templateId,
|
templateId: auth.claims.template,
|
||||||
fileName: docxDisplayName,
|
fileName: docxDisplayName,
|
||||||
docxObjectKey: keys.docx,
|
docxObjectKey: keys.docx,
|
||||||
pdfObjectKey: pdfBytes ? keys.pdf : null,
|
pdfObjectKey: pdfBytes ? keys.pdf : null,
|
||||||
docxSize: docxBytes.length,
|
docxSize: docxBytes.length,
|
||||||
pdfSize: pdfBytes?.length ?? null,
|
pdfSize: pdfBytes?.length ?? null,
|
||||||
userId: parsed.form.userId,
|
userId: auth.claims.userid,
|
||||||
userName: parsed.form.userName,
|
userName: auth.claims.username,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
|
// DB ล้มหลังเขียน object — เก็บกวาดก่อนตอบ กัน object กำพร้าไม่มี row ชี้
|
||||||
|
|
@ -142,7 +148,10 @@ export async function POST(request: Request, { params }: { params: { docId: stri
|
||||||
|
|
||||||
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
|
/** GET — history ของเอกสาร (ใหม่ → เก่า) · docId ที่ยังไม่เคยมี version = 404
|
||||||
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
|
* (สัญญาณ REST ตรง ๆ แบบ N10 — UI ฝั่ง add-in แสดง error + ปุ่มลองใหม่ ไม่พัง) */
|
||||||
export async function GET(_request: Request, { params }: { params: { docId: string } }) {
|
export async function GET(request: Request, { params }: { params: { docId: string } }) {
|
||||||
|
const auth = requireDocverBearer(request);
|
||||||
|
if (!auth.ok) return auth.response;
|
||||||
|
|
||||||
const docId = parseDocId(params.docId);
|
const docId = parseDocId(params.docId);
|
||||||
if (!docId.ok) return docverError(docId.error, 400);
|
if (!docId.ok) return docverError(docId.error, 400);
|
||||||
|
|
||||||
|
|
|
||||||
47
app/api/documents/token/route.ts
Normal file
47
app/api/documents/token/route.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
||||||
|
// PLAN-DOCVER.md §auth — POST /api/documents/token ออก access_token ของกลุ่ม docver
|
||||||
|
// ยืนยันด้วย header "x-api-key" เทียบ env DOCVER_API_KEY (fail closed เมื่อ env ไม่
|
||||||
|
// ตั้ง/ว่าง) — token เป็น JWT HS256 เซ็นด้วย env DOCVER_TOKEN_SECRET อายุตาม env
|
||||||
|
// DOCVER_TOKEN_TTL_SECONDS (default 3600) แล้วนำไปแนบ Authorization: Bearer กับ
|
||||||
|
// endpoint อื่นของกลุ่ม (lib/docver-token.ts) · segment คงที่ "token" ไม่ชน [docId]
|
||||||
|
// (Next ให้ static ชนะ) และ middleware ยกเว้น api/documents/* อยู่แล้ว
|
||||||
|
import { docverError } from "@/lib/document-versioning";
|
||||||
|
import {
|
||||||
|
issueDocverToken,
|
||||||
|
parseTokenRequestBody,
|
||||||
|
requireDocverApiKey,
|
||||||
|
} from "@/lib/docver-token";
|
||||||
|
|
||||||
|
export const runtime = "nodejs";
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
const apiKeyFail = requireDocverApiKey(request);
|
||||||
|
if (apiKeyFail !== null) return apiKeyFail;
|
||||||
|
|
||||||
|
let raw: unknown;
|
||||||
|
try {
|
||||||
|
raw = await request.json();
|
||||||
|
} catch {
|
||||||
|
return docverError("body ต้องเป็น JSON", 400);
|
||||||
|
}
|
||||||
|
const parsed = parseTokenRequestBody(raw);
|
||||||
|
if (!parsed.ok) return docverError(parsed.error, 400);
|
||||||
|
|
||||||
|
// ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — ไม่ตั้ง = 500 (ห้าม log ค่า secret)
|
||||||
|
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||||
|
if (secret === undefined || secret === "") {
|
||||||
|
return docverError("DOCVER_TOKEN_SECRET ยังไม่ถูกตั้งค่า", 500);
|
||||||
|
}
|
||||||
|
const ttlRaw = Number(process.env.DOCVER_TOKEN_TTL_SECONDS);
|
||||||
|
const ttlSeconds = Number.isFinite(ttlRaw) && ttlRaw > 0 ? Math.floor(ttlRaw) : 3600;
|
||||||
|
|
||||||
|
const accessToken = issueDocverToken(parsed.claims, secret, ttlSeconds);
|
||||||
|
return new Response(
|
||||||
|
JSON.stringify({
|
||||||
|
access_token: accessToken,
|
||||||
|
token_type: "Bearer",
|
||||||
|
expires_in: ttlSeconds,
|
||||||
|
}),
|
||||||
|
{ headers: { "content-type": "application/json; charset=utf-8" } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
# PLAN-DOCVER — Document Versioning API (stand-alone)
|
# PLAN-DOCVER — Document Versioning API (stand-alone)
|
||||||
|
|
||||||
> วันที่ตกลงแผน: 2026-10-20 · สถานะ: implement รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
|
> วันที่ตกลงแผน: 2026-10-20 · เพิ่ม auth ด้วย access_token: 2026-10-22 · สถานะ: implement
|
||||||
|
> รอบแรก (API ก่อน — ยังไม่ต่อ wordaddin)
|
||||||
|
|
||||||
## 0. บริบท/เป้าหมาย
|
## 0. บริบท/เป้าหมาย
|
||||||
|
|
||||||
|
|
@ -12,9 +13,14 @@
|
||||||
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
|
optional/best-effort ตาม contract ของ add-in — `pdf.size === 0` = ไม่มี, มี `hasPdf` บอกใน
|
||||||
response)
|
response)
|
||||||
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
|
- เก็บ `userId`, `userName`, `createdAt` ต่อ version เพื่อรู้ว่าใครอัปโหลด
|
||||||
- **endpoint รับ `userId`/`userName` ใน request** เพราะเป็นระบบ stand-alone ไม่มี session
|
- **identity (`userId`/`userName`/`templateId`) แกะจาก Bearer token** (2026-10-22 — เดิมส่ง
|
||||||
ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก — identity ส่งมาแบบ plain field)
|
plain field ใน multipart): ขอ token ที่ `POST /api/documents/token` ด้วย header
|
||||||
- **ไม่มี auth** — เปิดกว้างเหมือนกลุ่ม `api/template` (intranet) · ไม่มี CORS
|
`x-api-key` แล้วแนบ `Authorization: Bearer <token>` กับทุก request — ระบบ stand-alone
|
||||||
|
ไม่มี session ร่วมกับใคร (ผู้เรียกคือ add-in ใน Word หรือระบบภายนอก) token จึงเป็น
|
||||||
|
พาหะ identity เดียว
|
||||||
|
- **auth ทุก endpoint ของกลุ่มนี้** = Bearer JWT HS256 เซ็นด้วย `DOCVER_TOKEN_SECRET`
|
||||||
|
(ออก token ผ่าน `x-api-key` เทียบ env `DOCVER_API_KEY` — pattern `lib/external-api-auth.ts`
|
||||||
|
ของ N10) · ไม่มี CORS
|
||||||
|
|
||||||
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
|
ข้อตกลงกับผู้ใช้ (2026-10-20): ทำ API ก่อน **ไม่แต้ wordaddin รอบนี้** — contract ตรง surface
|
||||||
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
|
ที่ mock คงไว้เป๊ะ วันหน้าสลับของจริงแค่ตั้ง `NEXT_PUBLIC_ADDIN_API_BASE_URL` แล้วก้อบ body
|
||||||
|
|
@ -28,23 +34,48 @@ Contract อ้างอิง: `docs/addin-ui-flow.md` §7 + `wordaddin/service
|
||||||
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
|
## 1. Endpoint (URL เต็ม = `/docedit/api/documents/…` — prefix ใหม่ ได้รับยกเว้นจาก
|
||||||
middleware เหมือน `api/template`)
|
middleware เหมือน `api/template`)
|
||||||
|
|
||||||
|
### `POST /api/documents/token` — ขอ access_token (2026-10-22)
|
||||||
|
|
||||||
|
auth: header `x-api-key` เทียบ env `DOCVER_API_KEY` (ไม่ตั้ง/ว่าง = 401 fail closed ·
|
||||||
|
ไม่ใส่/ผิด = 401 ข้อความเดียวกันเป๊ะ กัน enumerate) — ตรวจก่อนอ่าน body เสมอ
|
||||||
|
|
||||||
|
JSON body (allowlist — key อื่น = 400 กัน typo):
|
||||||
|
|
||||||
|
| field | type | หมายเหตุ |
|
||||||
|
|---|---|---|
|
||||||
|
| `userid` | string | บังคับ — trim 1–100 (claim `userid` → column `user_id`) |
|
||||||
|
| `username` | string | บังคับ — trim 1–200 (claim `username` → column `user_name`) |
|
||||||
|
| `fid` | integer | optional — **fileId ของ e-Court** เก็บเป็น claim อย่างเดียว ยังไม่มี operation ใดอ่าน/บังคับ (ตารางของกลุ่มนี้คีย์ด้วย `docId` ไม่มี column fileId) |
|
||||||
|
| `template` | string | optional ≤100 (claim `template` → column `template_id` ตอน upload) |
|
||||||
|
|
||||||
|
- env `DOCVER_TOKEN_SECRET` ไม่ตั้ง/ว่าง = **500** "DOCVER_TOKEN_SECRET ยังไม่ได้ตั้งค่า"
|
||||||
|
(ความผิด config ฝั่ง server ไม่ใช่ความผิดของผู้เรียก — และห้าม log ค่า secret)
|
||||||
|
- response 200: `{ "access_token": string, "token_type": "Bearer", "expires_in": number }`
|
||||||
|
— JWT HS256 stateless มี `iat`/`exp` (TTL env `DOCVER_TOKEN_TTL_SECONDS` default 3600)
|
||||||
|
· verify บังคับ alg=HS256 (กัน alg-confusion) และเทียบ signature แบบ timing-safe
|
||||||
|
- **endpoint อื่นของกลุ่มนี้ทั้งหมดต้องแนบ `Authorization: Bearer <access_token>`** —
|
||||||
|
ไม่มี header / รูปผิด / signature ผิด / หมดอายุ / secret ไม่ตั้ง = 401 ข้อความไทย
|
||||||
|
เดียวกันทุกกรณี (ไม่เปิดให้ใช้ endpoint เป็น oracle รู้สาเหตุ)
|
||||||
|
|
||||||
### `POST /api/documents/{docId}/versions` — อัปโหลด version ใหม่
|
### `POST /api/documents/{docId}/versions` — อัปโหลด version ใหม่
|
||||||
|
|
||||||
|
auth: `Authorization: Bearer` (ดู /token ด้านบน) — identity/templateId ของ row ใหม่
|
||||||
|
มาจาก claims ของ token ไม่รับจาก form อีกต่อไป
|
||||||
|
|
||||||
multipart/form-data:
|
multipart/form-data:
|
||||||
|
|
||||||
| field | type | หมายเหตุ |
|
| field | type | หมายเหตุ |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
|
| `file` | File (.docx) | บังคับ — ≤ 100MB, magic bytes `PK\x03\x04` |
|
||||||
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
|
| `pdf` | File (.pdf) | optional — `size === 0` = ถือว่าไม่มี (add-in แนบ blob เปล่าได้) |
|
||||||
| `userId` | string | **บังคับ** (stand-alone) — trim 1–100 |
|
|
||||||
| `userName` | string | **บังคับ** (stand-alone) — trim 1–200 |
|
|
||||||
| `clientVersionId` | string | optional ≤100 — GUID ที่ client generate เอง |
|
| `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 }` (≤100 keys, value ≤10,000) |
|
||||||
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
|
| `message` | string | optional — commit message ≤2048 (trim ว่าง = NULL) |
|
||||||
| `templateId` | string | optional ≤100 |
|
|
||||||
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
|
| `fileName` | string | optional — ชื่อไฟล์ดั้งเดิม (ผ่าน `decodeUploadFilename` แล้ว) |
|
||||||
|
|
||||||
- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10)
|
- field ที่ไม่อยู่ในตาราง = **400 กัน typo เงียบ ๆ** (pattern `parseQueryBody` ของ N10) —
|
||||||
|
**breaking change 2026-10-22:** `userId`/`userName`/`templateId` ถอดออกจาก form แล้ว
|
||||||
|
(ส่งมา = 400) identity/template มาจาก Bearer token ของ request นั้นแทน
|
||||||
- เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
|
- เอกสาร (`documents` row) สร้าง **แบบ implicit** ตอน upload version แรก — ไม่มี endpoint
|
||||||
"สร้างเอกสาร" แยก (ตาม flow ของ add-in ที่ generate documentId เองแล้วอัปโหลดทันที)
|
"สร้างเอกสาร" แยก (ตาม flow ของ add-in ที่ generate documentId เองแล้วอัปโหลดทันที)
|
||||||
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
|
- response 200: `{ "hasPdf": boolean, "versionId": string, "versionNumber": number }`
|
||||||
|
|
@ -88,10 +119,11 @@ response: `DocumentVersionDTO[]` เรียง **ใหม่ → เก่า
|
||||||
|
|
||||||
| เหตุ | สถานะ |
|
| เหตุ | สถานะ |
|
||||||
|---|---|
|
|---|---|
|
||||||
| รูป docId/versionId/format ผิด · field ไม่รู้จัก · ไม่ใช่ multipart · magic bytes ผิด | 400 |
|
| รูป docId/versionId/format ผิด · field ไม่รู้จัก (รวม userId/userName/templateId เก่า) · ไม่ใช่ multipart · magic bytes ผิด · body ขอ /token ผิดกติกา | 400 |
|
||||||
|
| `x-api-key` ไม่ตั้ง/ไม่ใส่/ผิด (ที่ /token) · Bearer ขาด/เน่า/หมดอายุ/secret ไม่ตั้ง (ทุก endpoint อื่น) | 401 |
|
||||||
| ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
|
| ไฟล์เกิน 100MB (เช็ค `file.size` ก่อน buffer เสมอ — route ไม่มี `bodySizeLimit` คุม) | 413 |
|
||||||
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 |
|
| เอกสาร/version ไม่มี · ขอ pdf ที่ version ไม่มี | 404 |
|
||||||
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
|
| MinIO env ไม่ตั้ง/ล้ม · DB ล้ม (หลังเก็บกวาด object กำพร้า) · `DOCVER_TOKEN_SECRET` ไม่ตั้ง (ที่ /token) | 500 (log เต็มฝั่ง server + ข้อความกลาง ๆ ไทย) |
|
||||||
|
|
||||||
## 2. ตาราง (db/schema.ts)
|
## 2. ตาราง (db/schema.ts)
|
||||||
|
|
||||||
|
|
@ -156,6 +188,9 @@ read-only ของ N10 แต่ตัวนี้เขียนได้)
|
||||||
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
|
| `MINIO_ACCESS_KEY` / `MINIO_SECRET_KEY` | — บังคับ | dev compose = `minioadmin` |
|
||||||
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
|
| `MINIO_BUCKET` | `docver` | app สร้างเองตอน first use (`ensureBucket` — จับ race ข้าม process) |
|
||||||
| `DOCVER_LINK_TTL_SECONDS` | `900` | อายุ presigned URL |
|
| `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_TOKEN_SECRET` | — บังคับ | secret เซ็น/ตรวจ JWT HS256 — ไม่ตั้ง: /token = 500 · endpoint อื่น = 401 · ห้าม log ค่าดิบ |
|
||||||
|
| `DOCVER_TOKEN_TTL_SECONDS` | `3600` | อายุ access_token (วินาที) |
|
||||||
|
|
||||||
`putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
|
`putObject` แนบ `Content-Type` + `Content-Disposition` (ชื่อไฟล์ไทยผ่าน `filename*=UTF-8''`
|
||||||
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
|
best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET ได้ชื่อไฟล์สวย
|
||||||
|
|
@ -166,13 +201,16 @@ best-effort + ASCII fallback `document.docx`/`document.pdf`) — presigned GET
|
||||||
- `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO`
|
- `db/schema.ts` — ตาราง + `DocumentVersionDTO`/`toDocumentVersionDTO`
|
||||||
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
|
- `lib/document-versioning.ts` — pure validation (ห้าม import `@/db`/`minio` — unit test ได้
|
||||||
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
|
โดยไม่ต้องตั้ง env อะไรเลย ตาม pattern `lib/external-api-auth.ts`)
|
||||||
|
- `lib/docver-token.ts` — JWT HS256 issue/verify + `requireDocverApiKey`/`requireDocverBearer`
|
||||||
|
+ `parseTokenRequestBody` (pure เหมือนกัน — ไม่ import `@/db`/`minio`)
|
||||||
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
|
- `lib/docver-store.ts` — MinIO client (เก็บบน `globalThis` ใน dev แบบ `db/index.ts`) +
|
||||||
`ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers
|
`ensureBucket` + put/remove/presign + `insertVersionRecord` (tx) + query helpers
|
||||||
|
- `app/api/documents/token/route.ts` — POST ขอ access_token (x-api-key)
|
||||||
- `app/api/documents/[docId]/versions/route.ts` — POST + GET
|
- `app/api/documents/[docId]/versions/route.ts` — POST + GET
|
||||||
- `app/api/documents/[docId]/versions/[versionId]/download-link/route.ts` — GET
|
- `app/api/documents/[docId]/versions/[versionId]/download-link/route.ts` — GET
|
||||||
- `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
|
- `docker-compose.yml` — db (ชื่อ/วอลุ่มเดิมกับ `docker run` ใน README) + minio
|
||||||
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
|
(โฮสต์ 19000 API / 19001 console — ข้างใน container 9000/9001; image จาก quay.io)
|
||||||
- `tests/lib/document-versioning.test.ts` — unit test ส่วน pure
|
- `tests/lib/document-versioning.test.ts` + `tests/lib/docver-token.test.ts` — unit test ส่วน pure
|
||||||
|
|
||||||
## 8. Out of scope รอบนี้
|
## 8. Out of scope รอบนี้
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,16 +23,15 @@ 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;
|
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 เงียบ ๆ */
|
/** field ที่รับใน multipart ของ POST …/versions — อะไรนอกนี้ = 400 กัน typo เงียบ ๆ
|
||||||
|
* (userId/userName/templateId ถอดออกแล้ว — identity/template มาจาก Bearer token
|
||||||
|
* ผ่าน lib/docver-token.ts ส่ง field เก่ามา = 400 ตาม convention) */
|
||||||
const FORM_FIELDS = [
|
const FORM_FIELDS = [
|
||||||
"file",
|
"file",
|
||||||
"pdf",
|
"pdf",
|
||||||
"userId",
|
|
||||||
"userName",
|
|
||||||
"clientVersionId",
|
"clientVersionId",
|
||||||
"fields",
|
"fields",
|
||||||
"message",
|
"message",
|
||||||
"templateId",
|
|
||||||
"fileName",
|
"fileName",
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
|
|
@ -160,11 +159,8 @@ export function parseFieldsJson(raw: string):
|
||||||
}
|
}
|
||||||
|
|
||||||
export type ParsedVersionUploadForm = {
|
export type ParsedVersionUploadForm = {
|
||||||
userId: string;
|
|
||||||
userName: string;
|
|
||||||
clientVersionId: string | null;
|
clientVersionId: string | null;
|
||||||
message: string | null;
|
message: string | null;
|
||||||
templateId: string | null;
|
|
||||||
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
/** ชื่อไฟล์ดั้งเดิมจาก field "fileName" (แยกจากชื่อของ File เอง — add-in ส่งมาเอง
|
||||||
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
* เพราะ desktop Word ได้ชื่อจาก path จริง) — strip path + cap แล้ว */
|
||||||
fileName: string | null;
|
fileName: string | null;
|
||||||
|
|
@ -172,7 +168,8 @@ export type ParsedVersionUploadForm = {
|
||||||
};
|
};
|
||||||
|
|
||||||
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
/** ตรวจ text field ทั้งหมดของ POST …/versions (file/pdf เช็คที่ route เพราะต้องอ่าน
|
||||||
* size ก่อน buffer) — userId/userName บังคับเพราะระบบ stand-alone ไม่มี session */
|
* size ก่อน buffer) — identity (userId/userName) กับ templateId ไม่รับใน form
|
||||||
|
* แล้ว: route แกะจาก Bearer token ผ่าน lib/docver-token.ts ก่อนเรียกตรงนี้ */
|
||||||
export function parseVersionUploadForm(form: FormData):
|
export function parseVersionUploadForm(form: FormData):
|
||||||
| { ok: true; form: ParsedVersionUploadForm }
|
| { ok: true; form: ParsedVersionUploadForm }
|
||||||
| DocverError {
|
| DocverError {
|
||||||
|
|
@ -187,36 +184,12 @@ export function parseVersionUploadForm(form: FormData):
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = formString(form, "userId");
|
|
||||||
if (!userId.ok) return userId;
|
|
||||||
if (userId.value === null) {
|
|
||||||
return { ok: false, error: "ต้องระบุ userId (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
|
|
||||||
}
|
|
||||||
if (userId.value.length > 100) {
|
|
||||||
return { ok: false, error: "userId ยาวเกิน 100 ตัวอักษร" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const userName = formString(form, "userName");
|
|
||||||
if (!userName.ok) return userName;
|
|
||||||
if (userName.value === null) {
|
|
||||||
return { ok: false, error: "ต้องระบุ userName (ระบบ stand-alone — บอกผู้อัปโหลดเอง)" };
|
|
||||||
}
|
|
||||||
if (userName.value.length > 200) {
|
|
||||||
return { ok: false, error: "userName ยาวเกิน 200 ตัวอักษร" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const clientVersionId = formString(form, "clientVersionId");
|
const clientVersionId = formString(form, "clientVersionId");
|
||||||
if (!clientVersionId.ok) return clientVersionId;
|
if (!clientVersionId.ok) return clientVersionId;
|
||||||
if (clientVersionId.value && clientVersionId.value.length > 100) {
|
if (clientVersionId.value && clientVersionId.value.length > 100) {
|
||||||
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
|
return { ok: false, error: "clientVersionId ยาวเกิน 100 ตัวอักษร" };
|
||||||
}
|
}
|
||||||
|
|
||||||
const templateId = formString(form, "templateId");
|
|
||||||
if (!templateId.ok) return templateId;
|
|
||||||
if (templateId.value && templateId.value.length > 100) {
|
|
||||||
return { ok: false, error: "templateId ยาวเกิน 100 ตัวอักษร" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const message = formString(form, "message");
|
const message = formString(form, "message");
|
||||||
if (!message.ok) return message;
|
if (!message.ok) return message;
|
||||||
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
|
if (message.value && message.value.length > DOCVER_MESSAGE_MAX) {
|
||||||
|
|
@ -238,11 +211,8 @@ export function parseVersionUploadForm(form: FormData):
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
form: {
|
form: {
|
||||||
userId: userId.value,
|
|
||||||
userName: userName.value,
|
|
||||||
clientVersionId: clientVersionId.value,
|
clientVersionId: clientVersionId.value,
|
||||||
message: message.value,
|
message: message.value,
|
||||||
templateId: templateId.value,
|
|
||||||
fileName: sanitizeDisplayFileName(rawFileName.value),
|
fileName: sanitizeDisplayFileName(rawFileName.value),
|
||||||
fields,
|
fields,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
208
lib/docver-token.ts
Normal file
208
lib/docver-token.ts
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
// PLAN-DOCVER.md §auth — access_token ของกลุ่ม /api/documents (document versioning)
|
||||||
|
// - POST /api/documents/token ออก JWT HS256 ให้ผู้เรียกที่ยืนยัน header "x-api-key"
|
||||||
|
// กับ env DOCVER_API_KEY (fail closed เมื่อ env ไม่ตั้ง/ว่าง — pattern เดียวกับ
|
||||||
|
// lib/external-api-auth.ts ของ N10: hash SHA-256 ทั้งสองค่า + timingSafeEqual,
|
||||||
|
// ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน, ห้าม log ค่าดิบ)
|
||||||
|
// - token เซ็นด้วย env DOCVER_TOKEN_SECRET (มือเขียน HS256 ผ่าน node:crypto ล้วน —
|
||||||
|
// ไม่เพิ่ม dependency) แล้วทุก endpoint อื่นของกลุ่มบังคับ Authorization: Bearer —
|
||||||
|
// identity (userid/username) กับ templateId แกะจาก claims ไม่รับจาก payload
|
||||||
|
// - claims "fid" = fileId ของ e-Court — ตาราง docver คีย์ด้วย docId (ไม่มีคอลัมน์
|
||||||
|
// fileId ตัวเลข) จึงเก็บเป็น claim ไว้ก่อน ยังไม่มี operation ใดอ่าน/บังคับ
|
||||||
|
//
|
||||||
|
// ห้าม import อะไรที่ลาก @/db หรือ minio มาด้วย — unit test (tests/lib/) ต้องรันได้
|
||||||
|
// โดยไม่ต้องตั้ง DATABASE_URL/MINIO_* (pattern เดียวกับ lib/external-api-auth.ts)
|
||||||
|
import { createHash, createHmac, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
import { docverError, type DocverError } from "@/lib/document-versioning";
|
||||||
|
|
||||||
|
/** claims ของ access_token docver — โครงสร้างตามผู้เรียกกำหนด (e-Court) */
|
||||||
|
export type DocverTokenClaims = {
|
||||||
|
userid: string;
|
||||||
|
username: string;
|
||||||
|
fid: number | null;
|
||||||
|
template: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function base64url(input: string): string {
|
||||||
|
return Buffer.from(input, "utf8").toString("base64url");
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeJsonSegment(segment: string): Record<string, unknown> | null {
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = Buffer.from(segment, "base64url").toString("utf8");
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(text) as unknown;
|
||||||
|
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return parsed as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ออก JWT HS256 (base64url 3 segment) — payload = claims + iat/exp (วินาที) */
|
||||||
|
export function issueDocverToken(
|
||||||
|
claims: DocverTokenClaims,
|
||||||
|
secret: string,
|
||||||
|
ttlSeconds: number,
|
||||||
|
): string {
|
||||||
|
const header = base64url(JSON.stringify({ alg: "HS256", typ: "JWT" }));
|
||||||
|
const iat = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = base64url(
|
||||||
|
JSON.stringify({ ...claims, iat, exp: iat + ttlSeconds }),
|
||||||
|
);
|
||||||
|
const signature = createHmac("sha256", secret)
|
||||||
|
.update(`${header}.${payload}`)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${header}.${payload}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** claims ที่ถอดได้จาก payload ที่ตรวจ signature ผ่านแล้ว — รูปร่างผิด = invalid
|
||||||
|
* (กัน token ที่เซ็นถูกแต่ claims ไม่ครบ/ผิด type หลุดไปใช้ต่อ) */
|
||||||
|
function claimsFromPayload(payload: Record<string, unknown>): DocverTokenClaims | null {
|
||||||
|
const { userid, username, fid, template } = payload;
|
||||||
|
if (typeof userid !== "string" || userid === "") return null;
|
||||||
|
if (typeof username !== "string" || username === "") return null;
|
||||||
|
if (fid !== null && !(typeof fid === "number" && Number.isInteger(fid))) return null;
|
||||||
|
if (template !== null && typeof template !== "string") return null;
|
||||||
|
return { userid, username, fid, template };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ตรวจ token: 3 segment → alg บังคับ HS256 (กัน alg-confusion เช่น "none") →
|
||||||
|
* เทียบ signature แบบ timing-safe (hash ทั้งสองฝั่งก่อน — digest ยาวเท่ากันเสมอ) →
|
||||||
|
* exp ต้องยังไม่ผ่านมาแล้ว → claims ต้องครบรูปร่าง */
|
||||||
|
export function verifyDocverToken(
|
||||||
|
token: string,
|
||||||
|
secret: string,
|
||||||
|
): { ok: true; claims: DocverTokenClaims } | { ok: false } {
|
||||||
|
const segments = token.split(".");
|
||||||
|
if (segments.length !== 3) return { ok: false };
|
||||||
|
const [headerSegment, payloadSegment, signatureSegment] = segments;
|
||||||
|
|
||||||
|
const header = decodeJsonSegment(headerSegment);
|
||||||
|
if (header === null || header.alg !== "HS256") return { ok: false };
|
||||||
|
|
||||||
|
const expected = createHmac("sha256", secret)
|
||||||
|
.update(`${headerSegment}.${payloadSegment}`)
|
||||||
|
.digest();
|
||||||
|
// ถอด base64url กลับเป็น byte ดิบของ signature ก่อน — แล้ว hash ทั้งสองฝั่ง
|
||||||
|
// (SHA-256 = 32 byte เท่ากันเสมอ แม้ segment จะเป็นขยะ decode ได้สั้น/ว่าง —
|
||||||
|
// timingSafeEqual จึงไม่ throw เรื่องความยาวต่างกัน)
|
||||||
|
const provided = createHash("sha256")
|
||||||
|
.update(Buffer.from(signatureSegment, "base64url"))
|
||||||
|
.digest();
|
||||||
|
const expectedHash = createHash("sha256").update(expected).digest();
|
||||||
|
if (!timingSafeEqual(provided, expectedHash)) return { ok: false };
|
||||||
|
|
||||||
|
const payload = decodeJsonSegment(payloadSegment);
|
||||||
|
if (payload === null) return { ok: false };
|
||||||
|
const exp = payload.exp;
|
||||||
|
if (typeof exp !== "number" || !Number.isFinite(exp)) return { ok: false };
|
||||||
|
if (exp <= Math.floor(Date.now() / 1000)) return { ok: false };
|
||||||
|
|
||||||
|
const claims = claimsFromPayload(payload);
|
||||||
|
if (claims === null) return { ok: false };
|
||||||
|
return { ok: true, claims };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** auth ของ POST /api/documents/token — mirror requireExternalApiKey (N10) แต่
|
||||||
|
* env เป็น DOCVER_API_KEY และตอบผ่าน docverError ของกลุ่มนี้
|
||||||
|
* คืน null เมื่อผ่าน / Response 401 เมื่อไม่ผ่าน */
|
||||||
|
export function requireDocverApiKey(request: Request): Response | null {
|
||||||
|
// เทียบกับค่า trim แล้ว — กัน trailing newline จาก K8s secret / docker env_file
|
||||||
|
const envKey = process.env.DOCVER_API_KEY?.trim();
|
||||||
|
if (envKey === undefined || envKey === "") {
|
||||||
|
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 envHash = createHash("sha256").update(envKey).digest();
|
||||||
|
return timingSafeEqual(providedHash, envHash)
|
||||||
|
? null
|
||||||
|
: docverError("API key ไม่ถูกต้อง", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 401 เดียวสำหรับทุกกรณี — ไม่แยกสาเหตุ (ไม่มี header / รูปผิด / signature ผิด /
|
||||||
|
* หมดอายุ / secret ไม่ตั้ง) กันใช้ endpoint เป็น oracle สำรวจ */
|
||||||
|
const BEARER_DENIED = "โทเคนไม่ถูกต้องหรือหมดอายุ";
|
||||||
|
|
||||||
|
/** บังคับ Authorization: Bearer <token> ของกลุ่ม docver — เรียกเป็นอย่างแรกของ
|
||||||
|
* ทุก endpoint ก่อน parse path param · env DOCVER_TOKEN_SECRET ไม่ตั้ง = ไม่มี
|
||||||
|
* token ใดผ่าน (fail closed — ไม่ใช่ 500 เพราะไม่ใช่ความผิดของผู้เรียก) */
|
||||||
|
export function requireDocverBearer(request: Request):
|
||||||
|
| { ok: true; claims: DocverTokenClaims }
|
||||||
|
| { ok: false; response: Response } {
|
||||||
|
const denied = () => ({ ok: false, response: docverError(BEARER_DENIED, 401) }) as const;
|
||||||
|
const secret = process.env.DOCVER_TOKEN_SECRET?.trim();
|
||||||
|
if (secret === undefined || secret === "") return denied();
|
||||||
|
const header = request.headers.get("authorization");
|
||||||
|
if (header === null) return denied();
|
||||||
|
const match = /^Bearer\s+(\S+)$/i.exec(header);
|
||||||
|
if (match === null) return denied();
|
||||||
|
const verified = verifyDocverToken(match[1], secret);
|
||||||
|
if (!verified.ok) return denied();
|
||||||
|
return { ok: true, claims: verified.claims };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** body ของ POST /api/documents/token — strict allowlist กัน typo เงียบ ๆ ตาม
|
||||||
|
* convention ของกลุ่ม (key อื่น = 400) · trim-ว่าง = ถือว่าไม่ส่ง */
|
||||||
|
const TOKEN_BODY_FIELDS = ["userid", "username", "fid", "template"] as const;
|
||||||
|
|
||||||
|
export function parseTokenRequestBody(raw: unknown):
|
||||||
|
| { ok: true; claims: DocverTokenClaims }
|
||||||
|
| DocverError {
|
||||||
|
if (raw === null || typeof raw !== "object" || Array.isArray(raw)) {
|
||||||
|
return { ok: false, error: "body ต้องเป็น JSON object" };
|
||||||
|
}
|
||||||
|
const record = raw as Record<string, unknown>;
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (!(TOKEN_BODY_FIELDS as readonly string[]).includes(key)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `ไม่รู้จัก field '${key}' (รับเฉพาะ ${TOKEN_BODY_FIELDS.join(", ")})`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUserid = record.userid;
|
||||||
|
if (typeof rawUserid !== "string" || rawUserid.trim() === "") {
|
||||||
|
return { ok: false, error: "ต้องระบุ userid (string)" };
|
||||||
|
}
|
||||||
|
const userid = rawUserid.trim();
|
||||||
|
if (userid.length > 100) {
|
||||||
|
return { ok: false, error: "userid ยาวเกิน 100 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawUsername = record.username;
|
||||||
|
if (typeof rawUsername !== "string" || rawUsername.trim() === "") {
|
||||||
|
return { ok: false, error: "ต้องระบุ username (string)" };
|
||||||
|
}
|
||||||
|
const username = rawUsername.trim();
|
||||||
|
if (username.length > 200) {
|
||||||
|
return { ok: false, error: "username ยาวเกิน 200 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawFid = record.fid ?? null;
|
||||||
|
if (rawFid !== null && !(typeof rawFid === "number" && Number.isInteger(rawFid))) {
|
||||||
|
return { ok: false, error: "fid ต้องเป็นตัวเลขจำนวนเต็ม" };
|
||||||
|
}
|
||||||
|
|
||||||
|
const rawTemplate = record.template ?? null;
|
||||||
|
if (rawTemplate !== null && typeof rawTemplate !== "string") {
|
||||||
|
return { ok: false, error: "template ต้องเป็น string" };
|
||||||
|
}
|
||||||
|
const template = rawTemplate?.trim() || null;
|
||||||
|
if (template !== null && template.length > 100) {
|
||||||
|
return { ok: false, error: "template ยาวเกิน 100 ตัวอักษร" };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, claims: { userid, username, fid: rawFid, template } };
|
||||||
|
}
|
||||||
137
lib/openapi.ts
137
lib/openapi.ts
|
|
@ -39,8 +39,10 @@ export const openApiSpec = {
|
||||||
name: "documents",
|
name: "documents",
|
||||||
description:
|
description:
|
||||||
"จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " +
|
"จัด version เอกสาร (docver — docs/PLAN-DOCVER.md): เอกสารเดียวต่อ docId แต่ละ " +
|
||||||
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — ไม่มี auth (stand-alone): " +
|
"version เก็บ .docx + .pdf ใน MinIO พร้อมผู้อัปโหลด — auth ด้วย access_token " +
|
||||||
"ผู้เรียกบอก userId/userName เองใน multipart",
|
"ของกลุ่มนี้: ขอที่ POST /api/documents/token (ยืนยัน x-api-key กับ env " +
|
||||||
|
"DOCVER_API_KEY) แล้วแนบ Authorization: Bearer ทุก request — identity/template " +
|
||||||
|
"แกะจาก claims ของ token ไม่รับใน multipart form อีก",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Judge's Note",
|
name: "Judge's Note",
|
||||||
|
|
@ -225,6 +227,59 @@ export const openApiSpec = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
"/api/documents/token": {
|
||||||
|
post: {
|
||||||
|
tags: ["documents"],
|
||||||
|
summary: "ขอ access_token ของกลุ่ม documents (docver)",
|
||||||
|
description:
|
||||||
|
"ออก access_token ใช้กับ endpoint อื่นของกลุ่ม documents เท่านั้น — token เป็น " +
|
||||||
|
"JWT HS256 ฝัง claims {userid, username, fid, template} ตามที่ส่งมา อายุตาม env " +
|
||||||
|
"DOCVER_TOKEN_TTL_SECONDS (default 3600 วินาที) · identity (userId/userName) " +
|
||||||
|
"และ templateId ตอนอัปโหลด version แกะจาก claims ของ token นี้",
|
||||||
|
security: [{ ApiKeyAuth: [] }],
|
||||||
|
requestBody: {
|
||||||
|
required: true,
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/TokenRequest" },
|
||||||
|
examples: {
|
||||||
|
"ครบทุก claim": {
|
||||||
|
value: {
|
||||||
|
userid: "123",
|
||||||
|
username: "Sorawit Bholsithi",
|
||||||
|
fid: 3,
|
||||||
|
template: "T12",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"ขั้นต่ำ (fid/template เป็นทางเลือก)": {
|
||||||
|
value: { userid: "123", username: "Sorawit Bholsithi" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
description: "access_token พร้อมใช้ (แนบเป็น Authorization: Bearer ทุก request ถัดไป)",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/TokenResponse" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
400: { $ref: "#/components/responses/BadRequest" },
|
||||||
|
401: {
|
||||||
|
description: "ไม่ได้ส่ง header x-api-key หรือค่าไม่ถูกต้อง (env DOCVER_API_KEY)",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
500: { $ref: "#/components/responses/ServerError" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
"/api/documents/{docId}/versions": {
|
"/api/documents/{docId}/versions": {
|
||||||
post: {
|
post: {
|
||||||
tags: ["documents"],
|
tags: ["documents"],
|
||||||
|
|
@ -233,7 +288,10 @@ export const openApiSpec = {
|
||||||
"สร้างเอกสารอัตโนมัติเมื่อ docId ยังไม่เคยมี (docId ห้ามซ้ำ) แล้วเก็บไฟล์ " +
|
"สร้างเอกสารอัตโนมัติเมื่อ docId ยังไม่เคยมี (docId ห้ามซ้ำ) แล้วเก็บไฟล์ " +
|
||||||
".docx (+ .pdf ถ้าแนบมา) ลง MinIO พร้อมผู้อัปโหลด — versionNumber ที่ server จ่าย " +
|
".docx (+ .pdf ถ้าแนบมา) ลง MinIO พร้อมผู้อัปโหลด — versionNumber ที่ server จ่าย " +
|
||||||
"คือค่าจริงเสมอ (client ที่เลขไม่ตรงต้องแก้ XML part ของตัวเอง) · pdf เป็น " +
|
"คือค่าจริงเสมอ (client ที่เลขไม่ตรงต้องแก้ XML part ของตัวเอง) · pdf เป็น " +
|
||||||
"optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF (ดู hasPdf ใน response)",
|
"optional: ไม่แนบ/ไฟล์เปล่า = version นี้ไม่มีสำเนา PDF (ดู hasPdf ใน response) · " +
|
||||||
|
"ผู้อัปโหลด (userId/userName) และ templateId แกะจาก Bearer token ไม่รับใน form " +
|
||||||
|
"(ส่ง field เหล่านั้นมา = 400)",
|
||||||
|
security: [{ DocverBearer: [] }],
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: "docId",
|
name: "docId",
|
||||||
|
|
@ -249,7 +307,7 @@ export const openApiSpec = {
|
||||||
"multipart/form-data": {
|
"multipart/form-data": {
|
||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
required: ["file", "userId", "userName"],
|
required: ["file"],
|
||||||
properties: {
|
properties: {
|
||||||
file: {
|
file: {
|
||||||
type: "string",
|
type: "string",
|
||||||
|
|
@ -262,14 +320,6 @@ export const openApiSpec = {
|
||||||
description:
|
description:
|
||||||
"ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)",
|
"ไฟล์ .pdf ของ version นี้ (optional — ไม่แนบหรือไฟล์เปล่า = ไม่มี)",
|
||||||
},
|
},
|
||||||
userId: {
|
|
||||||
type: "string",
|
|
||||||
description: "รหัสผู้อัปโหลด (ระบบ stand-alone ไม่มี session)",
|
|
||||||
},
|
|
||||||
userName: {
|
|
||||||
type: "string",
|
|
||||||
description: "ชื่อผู้อัปโหลด (แสดงใน version history)",
|
|
||||||
},
|
|
||||||
clientVersionId: {
|
clientVersionId: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
|
description: "GUID ที่ client generate ก่อนเรียก (เก็บไว้สืบสวน ไม่ใช่ identity)",
|
||||||
|
|
@ -283,10 +333,6 @@ export const openApiSpec = {
|
||||||
type: "string",
|
type: "string",
|
||||||
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
|
description: "commit message ของ version (≤ 2048 ตัวอักษร)",
|
||||||
},
|
},
|
||||||
templateId: {
|
|
||||||
type: "string",
|
|
||||||
description: "รหัส template ที่ใช้สร้างเอกสาร (ถ้ามี)",
|
|
||||||
},
|
|
||||||
fileName: {
|
fileName: {
|
||||||
type: "string",
|
type: "string",
|
||||||
description:
|
description:
|
||||||
|
|
@ -307,6 +353,7 @@ export const openApiSpec = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: { $ref: "#/components/responses/BadRequest" },
|
400: { $ref: "#/components/responses/BadRequest" },
|
||||||
|
401: { $ref: "#/components/responses/DocverUnauthorized" },
|
||||||
413: {
|
413: {
|
||||||
description: "ไฟล์แนบใหญ่เกิน 100MB",
|
description: "ไฟล์แนบใหญ่เกิน 100MB",
|
||||||
content: {
|
content: {
|
||||||
|
|
@ -324,6 +371,7 @@ export const openApiSpec = {
|
||||||
description:
|
description:
|
||||||
"version ทุกตัวของ docId นี้ พร้อมผู้อัปโหลด (userId/userName), เวลา, commit " +
|
"version ทุกตัวของ docId นี้ พร้อมผู้อัปโหลด (userId/userName), เวลา, commit " +
|
||||||
"message, ค่า fields และ hasPdf — ใช้เขียน UI version history ของ add-in",
|
"message, ค่า fields และ hasPdf — ใช้เขียน UI version history ของ add-in",
|
||||||
|
security: [{ DocverBearer: [] }],
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: "docId",
|
name: "docId",
|
||||||
|
|
@ -346,6 +394,7 @@ export const openApiSpec = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: { $ref: "#/components/responses/BadRequest" },
|
400: { $ref: "#/components/responses/BadRequest" },
|
||||||
|
401: { $ref: "#/components/responses/DocverUnauthorized" },
|
||||||
404: {
|
404: {
|
||||||
description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)",
|
description: "ไม่พบเอกสารนี้ในระบบ (ยังไม่เคยอัปโหลด version)",
|
||||||
content: {
|
content: {
|
||||||
|
|
@ -366,6 +415,7 @@ export const openApiSpec = {
|
||||||
"คืน URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดใน " +
|
"คืน URL อายุสั้น (env DOCVER_LINK_TTL_SECONDS, default 900 วินาที) ให้เปิดใน " +
|
||||||
"เบราว์เซอร์/Word ของผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ " +
|
"เบราว์เซอร์/Word ของผู้ใช้ — URL อ้าง endpoint MinIO ตรง ๆ จึงต้องเป็นโฮสต์ที่ " +
|
||||||
"ผู้ใช้เข้าถึงได้ · ขอ pdf ใน version ที่ไม่มีสำเนา = 404",
|
"ผู้ใช้เข้าถึงได้ · ขอ pdf ใน version ที่ไม่มีสำเนา = 404",
|
||||||
|
security: [{ DocverBearer: [] }],
|
||||||
parameters: [
|
parameters: [
|
||||||
{
|
{
|
||||||
name: "docId",
|
name: "docId",
|
||||||
|
|
@ -399,6 +449,7 @@ export const openApiSpec = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
400: { $ref: "#/components/responses/BadRequest" },
|
400: { $ref: "#/components/responses/BadRequest" },
|
||||||
|
401: { $ref: "#/components/responses/DocverUnauthorized" },
|
||||||
404: {
|
404: {
|
||||||
description:
|
description:
|
||||||
"ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF",
|
"ไม่พบ version นี้ของเอกสาร หรือขอ format=pdf ใน version ที่ไม่มีสำเนา PDF",
|
||||||
|
|
@ -420,7 +471,17 @@ export const openApiSpec = {
|
||||||
in: "header",
|
in: "header",
|
||||||
name: "x-api-key",
|
name: "x-api-key",
|
||||||
description:
|
description:
|
||||||
"ค่าเดียวกับ env EXTERNAL_API_KEY ของ server — กดปุ่ม Authorize ด้านบนเพื่อใส่ครั้งเดียวแล้วใช้ทุก request",
|
"x-api-key ของ N10 = env EXTERNAL_API_KEY · ของการขอ token กลุ่ม documents = env " +
|
||||||
|
"DOCVER_API_KEY (คนละค่ากับของ N10 — กดปุ่ม Authorize ใส่ครั้งเดียวแล้วใช้ได้ทุก " +
|
||||||
|
"request ของกลุ่มที่ key ตรง)",
|
||||||
|
},
|
||||||
|
DocverBearer: {
|
||||||
|
type: "http",
|
||||||
|
scheme: "bearer",
|
||||||
|
bearerFormat: "JWT",
|
||||||
|
description:
|
||||||
|
"access_token จาก POST /api/documents/token — แนบเป็น Authorization: Bearer " +
|
||||||
|
"กับ endpoint อื่นของกลุ่ม documents ทุกตัว",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
responses: {
|
responses: {
|
||||||
|
|
@ -434,6 +495,15 @@ export const openApiSpec = {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
DocverUnauthorized: {
|
||||||
|
description:
|
||||||
|
"ไม่ได้แนบ Authorization: Bearer หรือ token ไม่ถูกต้อง/หมดอายุ (กลุ่ม documents — ข้อความเดียวกันทุกสาเหตุ)",
|
||||||
|
content: {
|
||||||
|
"application/json": {
|
||||||
|
schema: { $ref: "#/components/schemas/Error" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
ServerError: {
|
ServerError: {
|
||||||
description: "เกิดข้อผิดพลาดของระบบ (เช่นไฟล์ .docx ไม่ถูกต้อง / DB ล้ม)",
|
description: "เกิดข้อผิดพลาดของระบบ (เช่นไฟล์ .docx ไม่ถูกต้อง / DB ล้ม)",
|
||||||
content: {
|
content: {
|
||||||
|
|
@ -451,6 +521,39 @@ export const openApiSpec = {
|
||||||
error: { type: "string" },
|
error: { type: "string" },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
TokenRequest: {
|
||||||
|
type: "object",
|
||||||
|
description: "claims ที่จะฝังใน access_token ของกลุ่ม documents — key อื่น = 400",
|
||||||
|
required: ["userid", "username"],
|
||||||
|
properties: {
|
||||||
|
userid: { type: "string", description: "รหัสผู้ใช้ (บังคับ, ≤ 100 ตัวอักษร)" },
|
||||||
|
username: { type: "string", description: "ชื่อผู้ใช้ (บังคับ, ≤ 200 ตัวอักษร)" },
|
||||||
|
fid: {
|
||||||
|
type: "integer",
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"fileId ของ e-Court — เก็บเป็น claim ไว้ก่อน ยังไม่ถูก operation ของ docver อ่าน",
|
||||||
|
},
|
||||||
|
template: {
|
||||||
|
type: "string",
|
||||||
|
nullable: true,
|
||||||
|
description:
|
||||||
|
"รหัส template ที่ใช้สร้างเอกสาร — กลายเป็น templateId ตอนอัปโหลด version",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
TokenResponse: {
|
||||||
|
type: "object",
|
||||||
|
description: "access_token ของกลุ่ม documents (JWT HS256 อายุสั้น)",
|
||||||
|
properties: {
|
||||||
|
access_token: { type: "string", description: "JWT พร้อมใช้แนบเป็น Bearer" },
|
||||||
|
token_type: { type: "string", example: "Bearer" },
|
||||||
|
expires_in: {
|
||||||
|
type: "integer",
|
||||||
|
description: "อายุวินาที (env DOCVER_TOKEN_TTL_SECONDS, default 3600)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
ExtractResponse: {
|
ExtractResponse: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
|
|
|
||||||
194
package-lock.json
generated
194
package-lock.json
generated
|
|
@ -13,6 +13,7 @@
|
||||||
"@xmldom/xmldom": "^0.9.12",
|
"@xmldom/xmldom": "^0.9.12",
|
||||||
"drizzle-orm": "^0.45.2",
|
"drizzle-orm": "^0.45.2",
|
||||||
"lucide-react": "^1.42.0",
|
"lucide-react": "^1.42.0",
|
||||||
|
"minio": "^8.0.7",
|
||||||
"next": "^14.2.35",
|
"next": "^14.2.35",
|
||||||
"pdf-lib": "^1.17.1",
|
"pdf-lib": "^1.17.1",
|
||||||
"pdfjs-dist": "^4.10.38",
|
"pdfjs-dist": "^4.10.38",
|
||||||
|
|
@ -3902,15 +3903,13 @@
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-3.1.0.tgz",
|
||||||
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
|
"integrity": "sha512-LsS/DjHr+uDM647Gru/cA8+J3a3HfhttwCKLyuoyN7yXTFCxKBtSgMQBvrW9yNPa2/zDRUNuGPaH5QUFoa4arQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/nodable"
|
"url": "https://github.com/sponsors/nodable"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/@nodelib/fs.scandir": {
|
"node_modules/@nodelib/fs.scandir": {
|
||||||
"version": "2.1.5",
|
"version": "2.1.5",
|
||||||
|
|
@ -4670,15 +4669,13 @@
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/anynum/-/anynum-1.0.1.tgz",
|
||||||
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
"integrity": "sha512-N6//FLET/tXYNM/F6ABca1oH6fWB+KlTt909Le28WMDBk8oaT4vY17DCrwg2MvmuqUKt3Ni4N5dGJ/EoBgcO6A==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/apache-arrow": {
|
"node_modules/apache-arrow": {
|
||||||
"version": "21.2.0",
|
"version": "21.2.0",
|
||||||
|
|
@ -4771,6 +4768,12 @@
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/async": {
|
||||||
|
"version": "3.2.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz",
|
||||||
|
"integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/async-hook-jl": {
|
"node_modules/async-hook-jl": {
|
||||||
"version": "1.7.6",
|
"version": "1.7.6",
|
||||||
"resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz",
|
"resolved": "https://registry.npmjs.org/async-hook-jl/-/async-hook-jl-1.7.6.tgz",
|
||||||
|
|
@ -5015,6 +5018,15 @@
|
||||||
"readable-stream": "^3.4.0"
|
"readable-stream": "^3.4.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/block-stream2": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-suhjmLI57Ewpmq00qaygS8UgEq2ly2PCItenIyhMqVjo4t4pGzqMvfgJuX8iWTeSDdfSSqS6j38fL4ToNL7Pfg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "^3.4.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/body-parser": {
|
"node_modules/body-parser": {
|
||||||
"version": "1.20.8",
|
"version": "1.20.8",
|
||||||
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.8.tgz",
|
||||||
|
|
@ -5133,6 +5145,12 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/browser-or-node": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/browserslist": {
|
"node_modules/browserslist": {
|
||||||
"version": "4.28.9",
|
"version": "4.28.9",
|
||||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.9.tgz",
|
||||||
|
|
@ -5193,6 +5211,15 @@
|
||||||
"ieee754": "^1.1.13"
|
"ieee754": "^1.1.13"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/buffer-crc32": {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-1.0.0.tgz",
|
||||||
|
"integrity": "sha512-Db1SbgBS/fg/392AblrMJk97KggmvYhr4pB5ZIMTWtaivCPMWLkmb7m21cJvpvgK+J3nsU2CmmixNBZx4vFj/w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/buffer-equal-constant-time": {
|
"node_modules/buffer-equal-constant-time": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz",
|
||||||
|
|
@ -5845,6 +5872,15 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/decode-uri-component": {
|
||||||
|
"version": "0.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/decode-uri-component/-/decode-uri-component-0.2.2.tgz",
|
||||||
|
"integrity": "sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/decompress-response": {
|
"node_modules/decompress-response": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
|
||||||
|
|
@ -7029,6 +7065,12 @@
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||||
|
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/events": {
|
"node_modules/events": {
|
||||||
"version": "3.3.0",
|
"version": "3.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz",
|
||||||
|
|
@ -7240,7 +7282,6 @@
|
||||||
"version": "1.3.1",
|
"version": "1.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.3.1.tgz",
|
||||||
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
"integrity": "sha512-pIM/1n3ntFXKYrUZwW7QCK0gAW7XY+wzj1YMIV3tLDvPj/V+zTGJK5e3/4WJfwj0qWw2ElNXiTixda/R+3YSug==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -7248,7 +7289,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"path-expression-matcher": "^1.6.2",
|
"path-expression-matcher": "^1.6.2",
|
||||||
"xml-naming": "^0.3.0"
|
"xml-naming": "^0.3.0"
|
||||||
|
|
@ -7258,7 +7298,6 @@
|
||||||
"version": "5.11.2",
|
"version": "5.11.2",
|
||||||
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
|
"resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.11.2.tgz",
|
||||||
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
|
"integrity": "sha512-R9iDuNrQYeQut46cn2r2wHKn4HYzVDvDm5J1wW+koZewykv0yuO2HChTYeZtrULyHIDM9cj9TUXloCsueCQUog==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -7266,7 +7305,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nodable/entities": "^3.0.1",
|
"@nodable/entities": "^3.0.1",
|
||||||
"fast-xml-builder": "^1.2.0",
|
"fast-xml-builder": "^1.2.0",
|
||||||
|
|
@ -7343,6 +7381,15 @@
|
||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/filter-obj": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/finalhandler": {
|
"node_modules/finalhandler": {
|
||||||
"version": "1.3.2",
|
"version": "1.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
|
||||||
|
|
@ -8009,9 +8056,7 @@
|
||||||
"version": "2.0.4",
|
"version": "2.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
|
||||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||||
"dev": true,
|
"license": "ISC"
|
||||||
"license": "ISC",
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/ini": {
|
"node_modules/ini": {
|
||||||
"version": "1.3.8",
|
"version": "1.3.8",
|
||||||
|
|
@ -8317,15 +8362,13 @@
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/is-unsafe/-/is-unsafe-2.0.2.tgz",
|
||||||
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
"integrity": "sha512-HgbIHPBH0KHHCcjLfGsCvhtPTVxjaAZlXjwdz7/GQC40SjSe4sfQsar8J5VFo8JOSbarkpV0OLG95bbaNd9aAQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
"url": "https://github.com/sponsors/NaturalIntelligence"
|
"url": "https://github.com/sponsors/NaturalIntelligence"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/is-wsl": {
|
"node_modules/is-wsl": {
|
||||||
"version": "2.2.0",
|
"version": "2.2.0",
|
||||||
|
|
@ -8909,9 +8952,7 @@
|
||||||
"version": "4.18.1",
|
"version": "4.18.1",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz",
|
||||||
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
"integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==",
|
||||||
"dev": true,
|
"license": "MIT"
|
||||||
"license": "MIT",
|
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.includes": {
|
"node_modules/lodash.includes": {
|
||||||
"version": "4.3.0",
|
"version": "4.3.0",
|
||||||
|
|
@ -9145,9 +9186,7 @@
|
||||||
"version": "1.52.0",
|
"version": "1.52.0",
|
||||||
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
"resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
|
||||||
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
"integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
|
|
@ -9156,9 +9195,7 @@
|
||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
"integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"mime-db": "1.52.0"
|
"mime-db": "1.52.0"
|
||||||
},
|
},
|
||||||
|
|
@ -9208,6 +9245,39 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/minio": {
|
||||||
|
"version": "8.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/minio/-/minio-8.0.7.tgz",
|
||||||
|
"integrity": "sha512-E737MgufW8CeQAsTAtnEMrxZ9scMSf29kkhZoXzDTKj/Jszzo2SfeZUH9wbDQH2Rsq6TCtl/yQL0+XdVKZansQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"async": "^3.2.4",
|
||||||
|
"block-stream2": "^2.1.0",
|
||||||
|
"browser-or-node": "^2.1.1",
|
||||||
|
"buffer-crc32": "^1.0.0",
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"fast-xml-parser": "^5.3.4",
|
||||||
|
"ipaddr.js": "^2.0.1",
|
||||||
|
"lodash": "^4.17.21",
|
||||||
|
"mime-types": "^2.1.35",
|
||||||
|
"query-string": "^7.1.3",
|
||||||
|
"stream-json": "^1.8.0",
|
||||||
|
"through2": "^4.0.2",
|
||||||
|
"xml2js": "^0.5.0 || ^0.6.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^16 || ^18 || >=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/minio/node_modules/ipaddr.js": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-aq+t5NAc+cS6rZQQVWC2x98CPqGtKKTMDd4Gaodv0wShnItdKg/51djkGJ1hqH+Oy0ivDftCbSLCQob8zso01w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/minipass": {
|
"node_modules/minipass": {
|
||||||
"version": "7.1.3",
|
"version": "7.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
|
||||||
|
|
@ -10930,7 +11000,6 @@
|
||||||
"version": "1.6.2",
|
"version": "1.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.6.2.tgz",
|
||||||
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
"integrity": "sha512-enSlaiat05iasnzmgNxRj8reFdj3puY2QpNgP1aPIaVfT6nn9ICuPoFlKHk8EN22HcwewshO+mN2DGbkCEOtqQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -10938,7 +11007,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=14.0.0"
|
"node": ">=14.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -11412,6 +11480,24 @@
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/query-string": {
|
||||||
|
"version": "7.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/query-string/-/query-string-7.1.3.tgz",
|
||||||
|
"integrity": "sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"decode-uri-component": "^0.2.2",
|
||||||
|
"filter-obj": "^1.1.0",
|
||||||
|
"split-on-first": "^1.0.0",
|
||||||
|
"strict-uri-encode": "^2.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/queue-microtask": {
|
"node_modules/queue-microtask": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz",
|
||||||
|
|
@ -11528,9 +11614,7 @@
|
||||||
"version": "3.6.2",
|
"version": "3.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
|
||||||
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"inherits": "^2.0.3",
|
"inherits": "^2.0.3",
|
||||||
"string_decoder": "^1.1.1",
|
"string_decoder": "^1.1.1",
|
||||||
|
|
@ -11783,7 +11867,6 @@
|
||||||
"version": "5.2.1",
|
"version": "5.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
|
||||||
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -11798,8 +11881,7 @@
|
||||||
"url": "https://feross.org/support"
|
"url": "https://feross.org/support"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT"
|
||||||
"peer": true
|
|
||||||
},
|
},
|
||||||
"node_modules/safer-buffer": {
|
"node_modules/safer-buffer": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
|
|
@ -11812,7 +11894,6 @@
|
||||||
"version": "1.6.1",
|
"version": "1.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/sax/-/sax-1.6.1.tgz",
|
||||||
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
"integrity": "sha512-42tBVwLWnaQvW5zc4HbZrTuWccECCZfBi92FDuwtqxasH+JbPB3/FOKb1m222K42R4WxuxzzMsTswfzgtSu64Q==",
|
||||||
"dev": true,
|
|
||||||
"license": "BlueOak-1.0.0",
|
"license": "BlueOak-1.0.0",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=11.0.0"
|
"node": ">=11.0.0"
|
||||||
|
|
@ -12230,6 +12311,15 @@
|
||||||
"source-map": "^0.6.0"
|
"source-map": "^0.6.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/split-on-first": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/split-on-first/-/split-on-first-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/stack-chain": {
|
"node_modules/stack-chain": {
|
||||||
"version": "1.3.7",
|
"version": "1.3.7",
|
||||||
"resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz",
|
"resolved": "https://registry.npmjs.org/stack-chain/-/stack-chain-1.3.7.tgz",
|
||||||
|
|
@ -12263,6 +12353,21 @@
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/stream-chain": {
|
||||||
|
"version": "2.2.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-chain/-/stream-chain-2.2.5.tgz",
|
||||||
|
"integrity": "sha512-1TJmBx6aSWqZ4tx7aTpBDXK0/e2hhcNSTV8+CbFJtDjbb+I1mZ8lHit0Grw9GRT+6JbIrrDd8esncgBi8aBXGA==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/stream-json": {
|
||||||
|
"version": "1.9.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/stream-json/-/stream-json-1.9.1.tgz",
|
||||||
|
"integrity": "sha512-uWkjJ+2Nt/LO9Z/JyKZbMusL8Dkh97uUBTv3AJQ74y07lVahLY4eEFsPsE97pxYBwr8nnjMAIch5eqI0gPShyw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"stream-chain": "^2.2.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/streamroller": {
|
"node_modules/streamroller": {
|
||||||
"version": "3.1.5",
|
"version": "3.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/streamroller/-/streamroller-3.1.5.tgz",
|
||||||
|
|
@ -12325,13 +12430,20 @@
|
||||||
"node": ">=10.0.0"
|
"node": ">=10.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/strict-uri-encode": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=4"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/string_decoder": {
|
"node_modules/string_decoder": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
|
||||||
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"safe-buffer": "~5.2.0"
|
"safe-buffer": "~5.2.0"
|
||||||
}
|
}
|
||||||
|
|
@ -12392,7 +12504,6 @@
|
||||||
"version": "2.5.1",
|
"version": "2.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/strnum/-/strnum-2.5.1.tgz",
|
||||||
"integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==",
|
"integrity": "sha512-UM4nTLWzQiDBseVxUGxN0wJzIk0UU89Qi7pKXz9qISo+lhviJikTuikBsK/oFRlpvYHvSdz0TVoLyQ+sEpYwxA==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -12400,7 +12511,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"anynum": "^1.0.1"
|
"anynum": "^1.0.1"
|
||||||
}
|
}
|
||||||
|
|
@ -12742,6 +12852,15 @@
|
||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/through2": {
|
||||||
|
"version": "4.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/through2/-/through2-4.0.2.tgz",
|
||||||
|
"integrity": "sha512-iOqSav00cVxEEICeD7TjLB1sueEL+81Wpzp2bY17uZjZN0pWZPuo4suZ/61VujxmqSGFfgOcNuTZ85QJwNZQpw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"readable-stream": "3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinybench": {
|
"node_modules/tinybench": {
|
||||||
"version": "6.1.4",
|
"version": "6.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
|
||||||
|
|
@ -13123,7 +13242,6 @@
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/utils-merge": {
|
"node_modules/utils-merge": {
|
||||||
|
|
@ -13575,7 +13693,6 @@
|
||||||
"version": "0.3.0",
|
"version": "0.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml-naming/-/xml-naming-0.3.0.tgz",
|
||||||
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
"integrity": "sha512-ghig2TBE/H11aOVgmahA3MhimvkBr6JIYknH/Dhdk10nXwdbIqBJsbfMxpvFPG8bAw77gN29aQWvKpmVoPlvPQ==",
|
||||||
"dev": true,
|
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
"type": "github",
|
"type": "github",
|
||||||
|
|
@ -13583,7 +13700,6 @@
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=16.0.0"
|
"node": ">=16.0.0"
|
||||||
}
|
}
|
||||||
|
|
@ -13592,7 +13708,6 @@
|
||||||
"version": "0.5.0",
|
"version": "0.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sax": ">=0.6.0",
|
"sax": ">=0.6.0",
|
||||||
|
|
@ -13606,7 +13721,6 @@
|
||||||
"version": "11.0.1",
|
"version": "11.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
|
||||||
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=4.0"
|
"node": ">=4.0"
|
||||||
|
|
|
||||||
|
|
@ -112,11 +112,11 @@ describe("parseFieldsJson", () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("parseVersionUploadForm", () => {
|
describe("parseVersionUploadForm", () => {
|
||||||
|
// identity (userId/userName) กับ templateId ถอดออกจาก form แล้ว — route แกะจาก
|
||||||
|
// Bearer token ผ่าน lib/docver-token.ts (เคสของ token อยู่ docver-token.test.ts)
|
||||||
function baseForm(): FormData {
|
function baseForm(): FormData {
|
||||||
const form = new FormData();
|
const form = new FormData();
|
||||||
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
|
form.append("file", new File([Buffer.from("PK\x03\x04")], "draft.docx"));
|
||||||
form.append("userId", "u0001");
|
|
||||||
form.append("userName", "สมชาย ใจดี");
|
|
||||||
return form;
|
return form;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -124,17 +124,13 @@ describe("parseVersionUploadForm", () => {
|
||||||
const form = baseForm();
|
const form = baseForm();
|
||||||
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
|
form.append("clientVersionId", " 11111111-1111-4111-8111-111111111111 ");
|
||||||
form.append("message", " ");
|
form.append("message", " ");
|
||||||
form.append("templateId", "tmpl-main");
|
|
||||||
form.append("fields", '{"blackno":"1"}');
|
form.append("fields", '{"blackno":"1"}');
|
||||||
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
|
form.append("fileName", "C:\\Users\\judge\\คำสั่งเรียก.docx");
|
||||||
const result = parseVersionUploadForm(form);
|
const result = parseVersionUploadForm(form);
|
||||||
ok(result);
|
ok(result);
|
||||||
expect(result.form).toEqual({
|
expect(result.form).toEqual({
|
||||||
userId: "u0001",
|
|
||||||
userName: "สมชาย ใจดี",
|
|
||||||
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
clientVersionId: "11111111-1111-4111-8111-111111111111",
|
||||||
message: null,
|
message: null,
|
||||||
templateId: "tmpl-main",
|
|
||||||
fileName: "คำสั่งเรียก.docx",
|
fileName: "คำสั่งเรียก.docx",
|
||||||
fields: { blackno: "1" },
|
fields: { blackno: "1" },
|
||||||
});
|
});
|
||||||
|
|
@ -148,22 +144,22 @@ describe("parseVersionUploadForm", () => {
|
||||||
if (!result.ok) expect(result.error).toContain("oops");
|
if (!result.ok) expect(result.error).toContain("oops");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ตกเมื่อขาด userId หรือ userName (stand-alone บอกผู้อัปโหลดเอง)", () => {
|
it("ส่ง userId/userName/templateId ใน form = 400 ไม่รู้จัก field (identity มาจาก token)", () => {
|
||||||
const noUser = baseForm();
|
for (const legacy of ["userId", "userName", "templateId"]) {
|
||||||
noUser.delete("userId");
|
const form = baseForm();
|
||||||
expect(parseVersionUploadForm(noUser).ok).toBe(false);
|
form.append(legacy, "x");
|
||||||
const noName = baseForm();
|
const result = parseVersionUploadForm(form);
|
||||||
noName.delete("userName");
|
expect(result.ok).toBe(false);
|
||||||
expect(parseVersionUploadForm(noName).ok).toBe(false);
|
if (!result.ok) expect(result.error).toContain(legacy);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
it("ตกเมื่อ text field ถูกส่งมาเป็นไฟล์", () => {
|
||||||
const form = baseForm();
|
const form = baseForm();
|
||||||
form.delete("userId");
|
form.append("message", new File(["x"], "m.txt"));
|
||||||
form.append("userId", new File(["x"], "u.txt"));
|
|
||||||
const result = parseVersionUploadForm(form);
|
const result = parseVersionUploadForm(form);
|
||||||
expect(result.ok).toBe(false);
|
expect(result.ok).toBe(false);
|
||||||
if (!result.ok) expect(result.error).toContain("userId");
|
if (!result.ok) expect(result.error).toContain("message");
|
||||||
});
|
});
|
||||||
|
|
||||||
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
|
it(`message ยาว ${DOCVER_MESSAGE_MAX} ผ่าน แต่เกิน = ตก`, () => {
|
||||||
|
|
|
||||||
297
tests/lib/docver-token.test.ts
Normal file
297
tests/lib/docver-token.test.ts
Normal file
|
|
@ -0,0 +1,297 @@
|
||||||
|
import { createHmac } from "node:crypto";
|
||||||
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import {
|
||||||
|
issueDocverToken,
|
||||||
|
parseTokenRequestBody,
|
||||||
|
requireDocverApiKey,
|
||||||
|
requireDocverBearer,
|
||||||
|
verifyDocverToken,
|
||||||
|
type DocverTokenClaims,
|
||||||
|
} from "@/lib/docver-token";
|
||||||
|
|
||||||
|
// docver token (PLAN-DOCVER.md §auth) — validation/auth ล้วนของ /api/documents/*:
|
||||||
|
// ไฟล์นี้ตั้งใจไม่แตะ module ใดที่ลาก @/db หรือ minio มาด้วย (pattern เดียวกับ
|
||||||
|
// external-api-auth.test.ts — vitest ไม่โหลด .env จึงต้องรันได้โดยไม่ต้องตั้ง
|
||||||
|
// DATABASE_URL/MINIO_*)
|
||||||
|
|
||||||
|
const SECRET = "test-secret-docver";
|
||||||
|
const CLAIMS: DocverTokenClaims = {
|
||||||
|
userid: "123",
|
||||||
|
username: "Sorawit Bholsithi",
|
||||||
|
fid: 3,
|
||||||
|
template: "T12",
|
||||||
|
};
|
||||||
|
|
||||||
|
function ok<T>(result: { ok: boolean } & T): asserts result is { ok: true } & T {
|
||||||
|
expect(result.ok).toBe(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- 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_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_SECRET) process.env.DOCVER_TOKEN_SECRET = ORIG_SECRET;
|
||||||
|
else delete process.env.DOCVER_TOKEN_SECRET;
|
||||||
|
});
|
||||||
|
|
||||||
|
function reqWithKey(key?: string): Request {
|
||||||
|
return new Request("http://localhost/docedit/api/documents/token", {
|
||||||
|
headers: key === undefined ? {} : { "x-api-key": key },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function reqWithBearer(authorization: string | null): Request {
|
||||||
|
return new Request("http://localhost/docedit/api/documents/doc1/versions", {
|
||||||
|
headers: authorization === null ? {} : { authorization },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bodyOf(res: Response): Promise<{ error: string }> {
|
||||||
|
expect(res.headers.get("content-type")).toBe("application/json; charset=utf-8");
|
||||||
|
return (await res.json()) as { error: string };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** เซ็น payload ใด ๆ ด้วย SECRET ผ่านมือ — ใช้ประกอบเคส claims ผิดรูปที่ issueDocverToken สร้างเองไม่ได้ */
|
||||||
|
function signPayload(payload: Record<string, unknown>): string {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg: "HS256", typ: "JWT" }), "utf8").toString(
|
||||||
|
"base64url",
|
||||||
|
);
|
||||||
|
const body = Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
|
||||||
|
const signature = createHmac("sha256", SECRET)
|
||||||
|
.update(`${header}.${body}`)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${header}.${body}.${signature}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("issueDocverToken / verifyDocverToken", () => {
|
||||||
|
it("issue → verify ได้ claims ครบทุกตัว (userid/username/fid/template)", () => {
|
||||||
|
const verified = verifyDocverToken(issueDocverToken(CLAIMS, SECRET, 60), SECRET);
|
||||||
|
ok(verified);
|
||||||
|
expect(verified.claims).toEqual(CLAIMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claims ทางเลือก null ทั้งคู่ (fid/template ไม่ส่ง) ก็ roundtrip ได้", () => {
|
||||||
|
const verified = verifyDocverToken(
|
||||||
|
issueDocverToken({ userid: "u1", username: "ชื่อ", fid: null, template: null }, SECRET, 60),
|
||||||
|
SECRET,
|
||||||
|
);
|
||||||
|
ok(verified);
|
||||||
|
expect(verified.claims).toEqual({ userid: "u1", username: "ชื่อ", fid: null, template: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("แก้ payload แล้วใช้ signature เดิม (tampered) หรือ secret ผิด = ไม่ผ่าน", () => {
|
||||||
|
const token = issueDocverToken(CLAIMS, SECRET, 60);
|
||||||
|
const [header, , signature] = token.split(".");
|
||||||
|
const tamperedPayload = Buffer.from(
|
||||||
|
JSON.stringify({ ...CLAIMS, userid: "999" }),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64url");
|
||||||
|
expect(verifyDocverToken(`${header}.${tamperedPayload}.${signature}`, SECRET).ok).toBe(false);
|
||||||
|
expect(verifyDocverToken(token, "wrong-secret").ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("header alg อื่น (none / HS512) = ไม่ผ่าน แม้จะเซ็นด้วย secret ถูก (กัน alg-confusion)", () => {
|
||||||
|
const body = Buffer.from(
|
||||||
|
JSON.stringify({ ...CLAIMS, iat: 0, exp: Math.floor(Date.now() / 1000) + 60 }),
|
||||||
|
"utf8",
|
||||||
|
).toString("base64url");
|
||||||
|
for (const alg of ["none", "HS512"]) {
|
||||||
|
const header = Buffer.from(JSON.stringify({ alg, typ: "JWT" }), "utf8").toString("base64url");
|
||||||
|
const signature = createHmac("sha256", SECRET)
|
||||||
|
.update(`${header}.${body}`)
|
||||||
|
.digest("base64url");
|
||||||
|
expect(verifyDocverToken(`${header}.${body}.${signature}`, SECRET).ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("token หมดอายุ (ttl ติดลบ) หรือไม่มี exp = ไม่ผ่าน", () => {
|
||||||
|
expect(verifyDocverToken(issueDocverToken(CLAIMS, SECRET, -10), SECRET).ok).toBe(false);
|
||||||
|
const noExp = signPayload({ ...CLAIMS, iat: Math.floor(Date.now() / 1000) });
|
||||||
|
expect(verifyDocverToken(noExp, SECRET).ok).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("claims ไม่ครบรูปร่าง (แม้เซ็นถูก) = ไม่ผ่าน — กัน token เซ็นเองหลุดไปใช้", () => {
|
||||||
|
const now = Math.floor(Date.now() / 1000) + 60;
|
||||||
|
for (const bad of [
|
||||||
|
{ username: "n", fid: null, template: null, iat: 0, exp: now }, // ไม่มี userid
|
||||||
|
{ userid: "u", fid: null, template: null, iat: 0, exp: now }, // ไม่มี username
|
||||||
|
{ userid: "", username: "n", fid: null, template: null, iat: 0, exp: now }, // userid ว่าง
|
||||||
|
{ userid: "u", username: "n", fid: "3", template: null, iat: 0, exp: now }, // fid เป็น string
|
||||||
|
]) {
|
||||||
|
expect(verifyDocverToken(signPayload(bad), SECRET).ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("รูป token ผิด (ไม่ใช่ 3 segment / segment เป็น base64 ปกติไม่ใช่ base64url) = ไม่ผ่าน", () => {
|
||||||
|
expect(verifyDocverToken("not-a-jwt", SECRET).ok).toBe(false);
|
||||||
|
expect(verifyDocverToken("a.b.c.d", SECRET).ok).toBe(false);
|
||||||
|
// base64 ปกติมี + / ซึ่ง base64url decode แล้วได้ byte ผิด → header/payload พัง
|
||||||
|
expect(verifyDocverToken("???.???.???", SECRET).ok).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireDocverApiKey", () => {
|
||||||
|
it("env ไม่ตั้ง / ว่าง / ช่องว่างล้วน = 401 fail closed ทุกกรณี", async () => {
|
||||||
|
for (const value of [undefined, "", " "]) {
|
||||||
|
if (value === undefined) delete process.env.DOCVER_API_KEY;
|
||||||
|
else process.env.DOCVER_API_KEY = value;
|
||||||
|
const res = requireDocverApiKey(reqWithKey("test-key"));
|
||||||
|
expect(res?.status).toBe(401);
|
||||||
|
expect(await bodyOf(res!)).toEqual({ error: "ระบบยังไม่ได้ตั้งค่า API key" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ไม่ใส่ header กับ key ผิด ต้องได้ 401 ข้อความเดียวกันเป๊ะ (กัน enumerate)", async () => {
|
||||||
|
process.env.DOCVER_API_KEY = "test-key";
|
||||||
|
const missing = requireDocverApiKey(reqWithKey());
|
||||||
|
const wrong = requireDocverApiKey(reqWithKey("not-the-key"));
|
||||||
|
expect(missing?.status).toBe(401);
|
||||||
|
expect(wrong?.status).toBe(401);
|
||||||
|
expect(await bodyOf(missing!)).toEqual(await bodyOf(wrong!));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("key ตรง = ผ่าน (คืน null) · env มี whitespace รอบ ๆ ก็ trim ให้เทียบได้", () => {
|
||||||
|
process.env.DOCVER_API_KEY = "test-key";
|
||||||
|
expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull();
|
||||||
|
process.env.DOCVER_API_KEY = " test-key ";
|
||||||
|
expect(requireDocverApiKey(reqWithKey("test-key"))).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("requireDocverBearer", () => {
|
||||||
|
it("ทุกกรณีพ้นสภาพ (ไม่มี header / ไม่ใช่ Bearer / token เน่า / หมดอายุ / secret ไม่ตั้ง) = 401 ข้อความเดียวกัน", async () => {
|
||||||
|
const deniedBodies = new Set<string>();
|
||||||
|
const cases: { label: string; secret: string | undefined; authorization: string | null }[] = [
|
||||||
|
{ label: "ไม่มี Authorization", secret: SECRET, authorization: null },
|
||||||
|
{ label: "scheme อื่น (Basic)", secret: SECRET, authorization: "Basic dXNlcjpwYXNz" },
|
||||||
|
{ label: "Bearer ว่างเปล่า", secret: SECRET, authorization: "Bearer " },
|
||||||
|
{ label: "token เน่า", secret: SECRET, authorization: "Bearer garbage.token.here" },
|
||||||
|
{
|
||||||
|
label: "token หมดอายุ",
|
||||||
|
secret: SECRET,
|
||||||
|
authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, -10)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "เซ็นด้วย secret ผิด",
|
||||||
|
secret: SECRET,
|
||||||
|
authorization: `Bearer ${issueDocverToken(CLAIMS, "other-secret", 60)}`,
|
||||||
|
},
|
||||||
|
{ label: "secret ไม่ตั้ง (fail closed)", secret: undefined, authorization: null },
|
||||||
|
{
|
||||||
|
label: "secret ไม่ตั้งแม้จะมี token ถูก",
|
||||||
|
secret: undefined,
|
||||||
|
authorization: `Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
for (const testCase of cases) {
|
||||||
|
if (testCase.secret === undefined) delete process.env.DOCVER_TOKEN_SECRET;
|
||||||
|
else process.env.DOCVER_TOKEN_SECRET = testCase.secret;
|
||||||
|
const auth = requireDocverBearer(reqWithBearer(testCase.authorization));
|
||||||
|
expect(auth.ok, testCase.label).toBe(false);
|
||||||
|
if (!auth.ok) {
|
||||||
|
expect(auth.response.status, testCase.label).toBe(401);
|
||||||
|
deniedBodies.add(JSON.stringify(await bodyOf(auth.response)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
expect(deniedBodies.size).toBe(1); // ข้อความ 401 เดียวกันหมด — ไม่ใช้ endpoint เป็น oracle
|
||||||
|
});
|
||||||
|
|
||||||
|
it("Bearer ที่ verify ผ่าน = ได้ claims · คำว่า bearer พิมพ์เล็กก็รับ (case-insensitive)", () => {
|
||||||
|
process.env.DOCVER_TOKEN_SECRET = SECRET;
|
||||||
|
const token = issueDocverToken(CLAIMS, SECRET, 60);
|
||||||
|
const auth = requireDocverBearer(reqWithBearer(`Bearer ${token}`));
|
||||||
|
ok(auth);
|
||||||
|
expect(auth.claims).toEqual(CLAIMS);
|
||||||
|
const lower = requireDocverBearer(reqWithBearer(`bearer ${token}`));
|
||||||
|
ok(lower);
|
||||||
|
expect(lower.claims).toEqual(CLAIMS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("env มี whitespace รอบ secret ก็ trim แล้ว verify ได้", () => {
|
||||||
|
process.env.DOCVER_TOKEN_SECRET = ` ${SECRET} `;
|
||||||
|
const auth = requireDocverBearer(
|
||||||
|
reqWithBearer(`Bearer ${issueDocverToken(CLAIMS, SECRET, 60)}`),
|
||||||
|
);
|
||||||
|
ok(auth);
|
||||||
|
expect(auth.claims).toEqual(CLAIMS);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseTokenRequestBody", () => {
|
||||||
|
it("ครบทุก field = ได้ claims (trim ค่า) · fid/template ไม่ส่ง = null", () => {
|
||||||
|
const full = parseTokenRequestBody({
|
||||||
|
userid: " 123 ",
|
||||||
|
username: " Sorawit Bholsithi ",
|
||||||
|
fid: 3,
|
||||||
|
template: " T12 ",
|
||||||
|
});
|
||||||
|
ok(full);
|
||||||
|
expect(full.claims).toEqual({ userid: "123", username: "Sorawit Bholsithi", fid: 3, template: "T12" });
|
||||||
|
|
||||||
|
const minimal = parseTokenRequestBody({ userid: "123", username: "Sorawit" });
|
||||||
|
ok(minimal);
|
||||||
|
expect(minimal.claims).toEqual({ userid: "123", username: "Sorawit", fid: null, template: null });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("template ส่งมาเป็นช่องว่าง = null (ถือว่าไม่ส่ง)", () => {
|
||||||
|
const result = parseTokenRequestBody({ userid: "123", username: "n", template: " " });
|
||||||
|
ok(result);
|
||||||
|
expect(result.claims.template).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ไม่ใช่ object (array / string / null) = 400", () => {
|
||||||
|
for (const raw of [[], "text", null]) {
|
||||||
|
const result = parseTokenRequestBody(raw);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toBe("body ต้องเป็น JSON object");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("key อื่นนอก allowlist = 400 ไม่รู้จัก field (กัน typo เงียบ ๆ)", () => {
|
||||||
|
const result = parseTokenRequestBody({ userid: "1", username: "n", oops: 1 });
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) expect(result.error).toContain("oops");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ขาด userid หรือ username / ส่งมาไม่ใช่ string = 400 ข้อความเฉพาะ field", () => {
|
||||||
|
for (const raw of [
|
||||||
|
{ username: "n" },
|
||||||
|
{ userid: " ", username: "n" },
|
||||||
|
{ userid: 123, username: "n" },
|
||||||
|
{ userid: "1" },
|
||||||
|
{ userid: "1", username: "" },
|
||||||
|
]) {
|
||||||
|
const result = parseTokenRequestBody(raw);
|
||||||
|
expect(result.ok).toBe(false);
|
||||||
|
if (!result.ok) {
|
||||||
|
expect(result.error).toMatch(/ต้องระบุ (userid|username)/);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fid ไม่ใช่ integer (string / ทศนิยม) หรือ template ไม่ใช่ string = 400", () => {
|
||||||
|
for (const raw of [
|
||||||
|
{ userid: "1", username: "n", fid: "3" },
|
||||||
|
{ userid: "1", username: "n", fid: 3.5 },
|
||||||
|
{ userid: "1", username: "n", template: 12 },
|
||||||
|
]) {
|
||||||
|
expect(parseTokenRequestBody(raw).ok).toBe(false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ความยาวเกิน cap: userid > 100 / username > 200 / template > 100 = 400", () => {
|
||||||
|
expect(parseTokenRequestBody({ userid: "a".repeat(101), username: "n" }).ok).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseTokenRequestBody({ userid: "1", username: "a".repeat(201) }).ok,
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
parseTokenRequestBody({ userid: "1", username: "n", template: "a".repeat(101) }).ok,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
|
* - คงชื่อ/ลายเซ็น uploadVersion/getVersions/getDownloadLink/newClientVersionId ไว้เป๊ะ —
|
||||||
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
|
* วันปลด mock: ก้อบ body ของต้นฉบับกลับมาได้เลย
|
||||||
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
|
* - ต้นฉบับมีอีก 2 ตัวที่ไม่มี caller ใน UI จึงไม่พอร์ต: buildDownloadUrl, downloadVersion
|
||||||
|
* - **วันปลด mock ห้ามส่ง userId/userName/templateId ใน FormData** — contract ของ
|
||||||
|
* POST /api/documents/{docId}/versions ถอด 3 field นี้ออกแล้ว (ส่งมา = 400):
|
||||||
|
* server แกะ identity/template จาก Bearer token (ออกที่ POST /api/documents/token
|
||||||
|
* ด้วย x-api-key) ที่ apiClient.ts แนบให้อยู่แล้ว — templateId เด้งเป็น claim "template"
|
||||||
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
|
* - **metadata ของ version ถูก persist ลง localStorage** (record เล็ก ๆ ต่อ documentId) —
|
||||||
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
|
* ไม่งั้นปิดเอกสาร (taskpane ปิดตาม) แล้วเปิดใหม่ history ว่างเปล่า ทั้งที่ metadata
|
||||||
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
|
* การ track อยู่ในไฟล์จริง ทำให้ทดสอบวง FR-18 ปิด→เปิดใหม่ไม่ได้; blob ของไฟล์จริง
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue