Files
ln-bi/src/server/auth/preview/routes.ts
T

69 lines
4.0 KiB
TypeScript

import { Hono, type Context, type Next } from 'hono';
import { bodyLimit } from 'hono/body-limit';
import type { AuthUser, PermissionLevel } from '../types.js';
import { previewService, PreviewError, type createPreviewService } from './service.js';
type Service = ReturnType<typeof createPreviewService>;
function failure(c: Context, error: unknown) {
if (error instanceof PreviewError) return c.json({ error: error.message, code: error.code }, error.status);
console.error('permission-preview unavailable');
return c.json({ error: '权限预览暂不可用,请稍后重试', code: 'PREVIEW_UNAVAILABLE' }, 503);
}
export function createPreviewRouter(service: Service = previewService) {
const router = new Hono<{ Variables: { user: AuthUser } }>();
router.use('*', bodyLimit({ maxSize: 2048 }));
router.use('*', async (c, next) => {
c.header('Cache-Control', 'no-store');
if (!c.get('user')) return c.json({ error: '请先登录' }, 401);
try { await next(); } catch (e) { return failure(c, e); }
});
router.get('/context', async c => c.json(await service.context(c.get('user'))));
router.get('/users', async c => {
const q = (c.req.query('q') || '').trim(), level = c.req.query('level') || undefined;
const page = Number(c.req.query('page') || '1');
if (q.length > 80 || !Number.isSafeInteger(page) || page < 1 || page > 10000 || (level && !['full', 'department', 'personal'].includes(level)))
return c.json({ error: '搜索条件无效' }, 400);
return c.json(await service.search(c.get('user'), q, level as PermissionLevel | undefined, page));
});
router.post('/start', async c => {
const body = await c.req.json().catch(() => null);
if (!body || typeof body.userId !== 'string' || !/^[\w-]{1,64}$/.test(body.userId) || Object.keys(body).some(key => key !== 'userId'))
return c.json({ error: '请选择有效用户' }, 400);
return c.json(await service.start(c.get('user'), body.userId));
});
router.get('/current', async c => {
const result = await service.current(c.get('user'), c.req.header('X-BI-Preview') || '');
return c.json({ user: result.user, expiresAt: result.expiresAt });
});
router.post('/stop', async c => {
await service.stop(c.get('user'), c.req.header('X-BI-Preview') || '');
return c.json({ ok: true });
});
router.onError((error, c) => failure(c, error));
return router;
}
// These POSTs do not change business data. AI previews suppress gap feedback but retain attributed question audit records.
const READ_POSTS = new Set(['/api/vehicles/analysis', '/api/vehicles/analysis/details', '/api/asset-assistant/ask', '/api/asset-assistant/export']);
export function createPreviewMiddleware(service: Service = previewService) {
return async (c: Context, next: Next) => {
const token = c.req.header('X-BI-Preview');
if (!token) return next();
const actor = c.get('user') as AuthUser | undefined;
if (!actor) return c.json({ error: '请先登录' }, 401);
c.header('Cache-Control', 'no-store');
try {
const session = await service.current(actor, token);
const readOnly = (!['/api/vehicles/refresh', '/api/vehicles/debug'].includes(c.req.path) && ['GET', 'HEAD'].includes(c.req.method)) || (c.req.method === 'POST' && READ_POSTS.has(c.req.path))
|| (c.req.method === 'DELETE' && /^\/api\/asset-assistant\/session\/[^/]+$/.test(c.req.path));
await service.audit({ event: readOnly ? 'read' : 'blocked-write', actorId: actor.userId, targetId: session.user.userId,
sessionId: session.sessionId, method: c.req.method, path: c.req.path, permissionLevel: session.user.permissionLevel });
if (!readOnly) return c.json({ error: '权限预览为只读模式,请退出预览后操作', code: 'PREVIEW_READ_ONLY' }, 403);
c.set('actor', actor);
c.set('user', { ...session.user, preview: { actorId: actor.userId, sessionId: session.sessionId } } satisfies AuthUser);
} catch (error) { return failure(c, error); }
// Do not catch downstream failures as authentication errors.
return next();
};
}