feat: 网关上传转对象存储(FS 适配器、8MB 上限、防路径穿越)

This commit is contained in:
lovefyf
2026-08-13 05:17:53 +08:00
parent 0159c3a7eb
commit d8b59330fb
6 changed files with 120 additions and 0 deletions
+1
View File
@@ -4,3 +4,4 @@ dist/
.env.* .env.*
!.env.example !.env.example
*.log *.log
.objectstore/
+3
View File
@@ -2,11 +2,14 @@ import { Hono } from 'hono';
import { healthRoute, registerHealthCheck } from './health'; import { healthRoute, registerHealthCheck } from './health';
import { checkNats } from '../shared/bus/nats'; import { checkNats } from '../shared/bus/nats';
import { proxyRoutes } from '../modules/proxy/routes'; import { proxyRoutes } from '../modules/proxy/routes';
import { uploadRoutes } from '../modules/upload/routes';
export function buildApp(): Hono { export function buildApp(): Hono {
registerHealthCheck('nats', checkNats); registerHealthCheck('nats', checkNats);
const app = new Hono(); const app = new Hono();
app.route('/health', healthRoute()); // 公开:不过 requireAuth app.route('/health', healthRoute()); // 公开:不过 requireAuth
// 顺序要紧:/api/upload 必须先于 /api 代理挂载,否则被代理的 catch-all 吞掉
app.route('/api/upload', uploadRoutes());
app.route('/api', proxyRoutes()); app.route('/api', proxyRoutes());
return app; return app;
} }
+33
View File
@@ -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;
}
},
};
}
+7
View File
@@ -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<string>;
// D4:data 显式标注 Uint8Array<ArrayBuffer>——TS 5.9 下 Uint8Array<ArrayBufferLike>
// 不可赋给 Hono 的 Data(要求 ArrayBuffer 支撑),c.body(found.data) 会红。
get(ref: string): Promise<{ data: Uint8Array<ArrayBuffer>; meta: StoredMeta } | null>;
}
+34
View File
@@ -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;
}
+42
View File
@@ -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);
});
});