eCourt-note/lib/external-api-auth.ts

44 lines
2.9 KiB
TypeScript
Raw Normal View History

external-api: N10 API ดึงข้อมูลให้ระบบภายนอกด้วย API Key + ถอด F16 export CSV / N7 bookmark JSON แทนด้วย N10 — (1) endpoint เดียว POST /api/external/documents (JSON body {fileId และ/หรือ typeId — ส่งทั้งคู่ = AND เหลือเอกสารเดียว, filter:{type bookmarks|annotations, userId string|array, annotationTypes ค่า enum}}) ให้ backend e-Court เรียก server-to-server ผ่าน header x-api-key คืน {documents:[…]} รูปเดียวเสมอ — bundle ยุบชั้น field เอกสาร + annotations (พร้อม url deep link + replies) + bookmarks ระดับเดียวกัน, url เป็น URL เต็ม origin จาก request ผู้เรียก (x-forwarded-proto/host fallback host ตรง) + basePath (เดิม 2 GET query-param รวมเป็นเส้นเดียว 2026-09-24 · OR→AND + url relative→เต็ม + filter.types→annotationTypes กันสับสนกับ filter.type 2026-09-25) (2) lib/external-api-auth.ts — requireExternalApiKey เทียบ SHA-256 + crypto.timingSafeEqual, env EXTERNAL_API_KEY ไม่ตั้ง/ว่าง = 401 fail closed, ข้อความ 401 เดียวกันกัน enumerate + externalApiError คืน JSON {"error": ไทย} ใช้ร่วมทุก route — module DB-free ให้ tests/lib/external-api-auth.test.ts (5 tests) รันได้ไม่ต้องมี DATABASE_URL (3) lib/external-api.ts parseQueryBody strict กัน typo เงียบ ๆ (field นอก 3 ตัว = 400, annotationTypes ค่านอก enum = 400 บอกค่าที่รับ) + loadDocumentBundles bulk inArray = 3 query ต่อ request ไม่ว่ากลุ่มกี่ไฟล์ กรอง userIds/annotationTypes ใน SQL เรียง F15/N5 (4) route: auth ก่อนแตะ DB + ครอบ try/catch ทุก DB call ล้ม = log ฝั่ง server + 500 JSON ตาม contract ไม่ปล่อยเป็น 500 ของ framework, read-only ไม่ publish SSE ไม่ revalidatePath ไม่มี CORS, REDACTION รวมในผลลัพธ์ (5) ถอด F16/N7 ตามคำขอผู้ใช้ 2026-09-25 (N10 ครอบคลุมข้อมูลชุดเดียวกันพร้อม auth): ลบ app/api/annotations/export-csv + app/api/bookmarks/export + utils/csv.ts + tests/utils/csv.test.ts + ปุ่มส่งออก 2 ปุ่มบนหน้าสรุป + ปุ่ม JSON ใน sidebar + รูป requirement 1.png/2.png (บรรทัดอ้างถึงใน gap-analysis.md เปลี่ยนเป็น historical note) (6) docs sync ครบ: PLAN-API.md แผนฉบับเต็ม 9 รอบ + PLAN-FEAT.md §7 N10 + PLAN-BOOKMARK-PDF.md/gap-analysis.md 5.3.14 เปลี่ยนตอบด้วย N10 + CLAUDE.md + README (env/ตัวอย่าง curl/troubleshooting) + .env.example + .claude/rules/data-layer.md — ผ่าน code review 2 รอบ 2026-09-25: แก้ 500 JSON ตอน DB ล้ม + ถอด fan-out ต่อไฟล์เป็น bulk + ถอด as cast แทน type guard (tsc + vitest 149/149) (คำขอผู้ใช้ 2026-09-25) Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-25 12:02:42 +07:00
// N10 (PLAN-API.md D1) — auth ของ /api/external/* (ผู้เรียก: backend e-Court,
// server-to-server ไม่มี browser)
// - API Key เดียวใน env EXTERNAL_API_KEY ส่งผ่าน header "x-api-key"
// - เทียบแบบ timing-safe: hash SHA-256 ทั้งสองค่าแล้ว timingSafeEqual
// (digest ยาว 32 ไบต์เท่ากันเสมอ — กัน timing + length leak)
// - env ไม่ตั้ง/ว่าง = ปฏิเสธทุก request (fail closed) — ไม่มี mode เปิดฟรี
// - ข้อความ 401 ของ "ไม่ใส่ key" กับ "key ผิด" เป็นอันเดียวกัน (กัน enumerate)
// - ห้าม log ค่า key / header ดิบ
//
// ห้าม import อะไรที่ลาก @/db มาด้วย (db/index.ts throw ตอน import เมื่อไม่มี
// DATABASE_URL) — unit test ของไฟล์นี้ต้องรันได้โดยไม่ต้องตั้งค่า DB เลย
import { createHash, timingSafeEqual } from "node:crypto";
/** Response ผิดพลาดของ API ภายนอกทุกกรณี — JSON {"error": "<ไทย>"} ตาม D2 */
export function externalApiError(message: string, status: number): Response {
return new Response(JSON.stringify({ error: message }), {
status,
headers: { "content-type": "application/json; charset=utf-8" },
});
}
/**
* คืน null เมื่อผ่าน / คืน Response 401 เมื่อไม่ผ่าน — route เรียกเป็นอย่างแรก
* ก่อนแตะ DB ทุกครั้ง (PLAN-API.md D3)
*/
export function requireExternalApiKey(req: Request): Response | null {
// เทียบกับค่า trim แล้ว — key สุ่มไม่ควรมี whitespace อยู่แล้ว และกัน
// trailing newline จาก K8s secret / docker env_file ทำ 401 ทั้งระบบตามผู้เรียก
// ที่ส่ง key ถูก (header เองก็ strip OWS รอบค่า ผู้เรียกส่ง whitespace ครอบไม่ได้)
const envKey = process.env.EXTERNAL_API_KEY?.trim();
if (envKey === undefined || envKey === "") {
return externalApiError("API ยังไม่ได้ตั้งค่าคีย์", 401);
}
const provided = req.headers.get("x-api-key");
if (provided === null || provided === "") {
return externalApiError("API key ไม่ถูกต้อง", 401);
}
const providedHash = createHash("sha256").update(provided).digest();
const envHash = createHash("sha256").update(envKey).digest();
return timingSafeEqual(providedHash, envHash)
? null
: externalApiError("API key ไม่ถูกต้อง", 401);
}