From d8b59330fb0dc02505c7a71a62063e19fe18b216 Mon Sep 17 00:00:00 2001 From: lovefyf Date: Thu, 13 Aug 2026 05:17:53 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BD=91=E5=85=B3=E4=B8=8A=E4=BC=A0?= =?UTF-8?q?=E8=BD=AC=E5=AF=B9=E8=B1=A1=E5=AD=98=E5=82=A8=EF=BC=88FS=20?= =?UTF-8?q?=E9=80=82=E9=85=8D=E5=99=A8=E3=80=818MB=20=E4=B8=8A=E9=99=90?= =?UTF-8?q?=E3=80=81=E9=98=B2=E8=B7=AF=E5=BE=84=E7=A9=BF=E8=B6=8A=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + src/app/app.ts | 3 +++ src/modules/upload/fs-store.ts | 33 ++++++++++++++++++++++++ src/modules/upload/objectstore.ts | 7 ++++++ src/modules/upload/routes.ts | 34 +++++++++++++++++++++++++ test/integration/upload.test.ts | 42 +++++++++++++++++++++++++++++++ 6 files changed, 120 insertions(+) create mode 100644 src/modules/upload/fs-store.ts create mode 100644 src/modules/upload/objectstore.ts create mode 100644 src/modules/upload/routes.ts create mode 100644 test/integration/upload.test.ts diff --git a/.gitignore b/.gitignore index 3690233..b5da528 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,4 @@ dist/ .env.* !.env.example *.log +.objectstore/ diff --git a/src/app/app.ts b/src/app/app.ts index d3adfbc..965664c 100644 --- a/src/app/app.ts +++ b/src/app/app.ts @@ -2,11 +2,14 @@ import { Hono } from 'hono'; import { healthRoute, registerHealthCheck } from './health'; import { checkNats } from '../shared/bus/nats'; import { proxyRoutes } from '../modules/proxy/routes'; +import { uploadRoutes } from '../modules/upload/routes'; export function buildApp(): Hono { registerHealthCheck('nats', checkNats); const app = new Hono(); app.route('/health', healthRoute()); // 公开:不过 requireAuth + // 顺序要紧:/api/upload 必须先于 /api 代理挂载,否则被代理的 catch-all 吞掉 + app.route('/api/upload', uploadRoutes()); app.route('/api', proxyRoutes()); return app; } diff --git a/src/modules/upload/fs-store.ts b/src/modules/upload/fs-store.ts new file mode 100644 index 0000000..09a6cb4 --- /dev/null +++ b/src/modules/upload/fs-store.ts @@ -0,0 +1,33 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { randomUUID } from 'node:crypto'; +import type { ObjectStore, StoredMeta } from './objectstore'; + +export function createFsObjectStore(dir: string): ObjectStore { + return { + async put(data, meta) { + await mkdir(dir, { recursive: true }); + const ref = randomUUID(); + const stored: StoredMeta = { + contentType: meta.contentType ?? 'application/octet-stream', + ...(meta.filename ? { filename: meta.filename } : {}), + size: data.byteLength, + }; + await writeFile(join(dir, `${ref}.bin`), data); + await writeFile(join(dir, `${ref}.json`), JSON.stringify(stored)); + return ref; + }, + async get(ref) { + if (!/^[0-9a-f-]{36}$/.test(ref)) return null; // 防路径穿越 + try { + const [data, metaRaw] = await Promise.all([ + readFile(join(dir, `${ref}.bin`)), + readFile(join(dir, `${ref}.json`), 'utf8'), + ]); + return { data: new Uint8Array(data), meta: JSON.parse(metaRaw) as StoredMeta }; + } catch { + return null; + } + }, + }; +} diff --git a/src/modules/upload/objectstore.ts b/src/modules/upload/objectstore.ts new file mode 100644 index 0000000..6c22f7f --- /dev/null +++ b/src/modules/upload/objectstore.ts @@ -0,0 +1,7 @@ +export interface StoredMeta { contentType: string; filename?: string; size: number } +export interface ObjectStore { + put(data: Uint8Array, meta: { contentType?: string; filename?: string }): Promise; + // D4:data 显式标注 Uint8Array——TS 5.9 下 Uint8Array + // 不可赋给 Hono 的 Data(要求 ArrayBuffer 支撑),c.body(found.data) 会红。 + get(ref: string): Promise<{ data: Uint8Array; meta: StoredMeta } | null>; +} diff --git a/src/modules/upload/routes.ts b/src/modules/upload/routes.ts new file mode 100644 index 0000000..82181cd --- /dev/null +++ b/src/modules/upload/routes.ts @@ -0,0 +1,34 @@ +import { Hono } from 'hono'; +import { requireAuth, requireRealmRole } from '../../shared/keycloak-auth'; +import { createFsObjectStore } from './fs-store'; + +const MAX_BYTES = 8 * 1024 * 1024; + +export function uploadRoutes(): Hono { + const r = new Hono(); + r.use('*', requireAuth(), requireRealmRole('user', 'admin', 'service')); + + r.post('/', async (c) => { + const store = createFsObjectStore(process.env.OBJECT_STORE_DIR ?? './.objectstore'); + const body = new Uint8Array(await c.req.arrayBuffer()); + if (body.byteLength > MAX_BYTES) { + return c.json({ error: 'payload_too_large', message: `上限 8MB,收到 ${body.byteLength} 字节——拆分或压缩后再传` }, 413); + } + const ref = await store.put(body, { + contentType: c.req.header('content-type') ?? 'application/octet-stream', + ...(c.req.header('x-filename') ? { filename: c.req.header('x-filename') } : {}), + }); + return c.json({ ref }, 201); + }); + + r.get('/:ref', async (c) => { + const store = createFsObjectStore(process.env.OBJECT_STORE_DIR ?? './.objectstore'); + const found = await store.get(c.req.param('ref')); + if (!found) return c.json({ error: 'not_found', message: '对象不存在——核对 ref' }, 404); + c.header('Content-Type', found.meta.contentType); + if (found.meta.filename) c.header('Content-Disposition', `attachment; filename="${encodeURIComponent(found.meta.filename)}"`); + return c.body(found.data); + }); + + return r; +} diff --git a/test/integration/upload.test.ts b/test/integration/upload.test.ts new file mode 100644 index 0000000..525731a --- /dev/null +++ b/test/integration/upload.test.ts @@ -0,0 +1,42 @@ +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { beforeAll, afterAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../../src/app/app'; +import { makeToken, setupTestJwks } from '../support/token'; + +const dir = mkdtempSync(join(tmpdir(), 'bpos-store-')); + +describe('upload', () => { + let token = ''; + beforeAll(async () => { + process.env.OBJECT_STORE_DIR = dir; + await setupTestJwks(); + token = await makeToken({ roles: ['user'], aud: 'api-gateway' }); + }); + afterAll(() => rmSync(dir, { recursive: true, force: true })); + + it('上传→取回一致;越权 401;超 8MB 413', async () => { + const app = buildApp(); + const payload = new TextEncoder().encode('原件字节'); + const up = await app.request('/api/upload', { + method: 'POST', + headers: { authorization: `Bearer ${token}`, 'content-type': 'text/plain', 'x-filename': 'a.txt' }, + body: payload, + }); + expect(up.status).toBe(201); + const { ref } = (await up.json()) as { ref: string }; + const back = await app.request(`/api/upload/${ref}`, { headers: { authorization: `Bearer ${token}` } }); + expect(back.status).toBe(200); + expect(back.headers.get('content-type')).toContain('text/plain'); + expect(new Uint8Array(await back.arrayBuffer())).toEqual(payload); + + expect((await app.request('/api/upload', { method: 'POST' })).status).toBe(401); + + const big = new Uint8Array(8 * 1024 * 1024 + 1); + const tooBig = await app.request('/api/upload', { + method: 'POST', headers: { authorization: `Bearer ${token}` }, body: big, + }); + expect(tooBig.status).toBe(413); + }); +});