import { Hono } from 'hono'; import { streamSSE } from 'hono/streaming'; import { env } from './env.ts'; import { getRecentEvents, onEvent } from './events.ts'; import { containerName, incus } from './incus.ts'; export const admin = new Hono(); // 鉴权中间件 — token 走 query(首屏 iframe/跳转友好)或 header admin.use('*', async (c, next) => { if (!env.adminToken) return c.text('Admin UI disabled: set ADMIN_TOKEN env', 503); const token = c.req.query('token') ?? c.req.header('Admin-Token'); if (token !== env.adminToken) return c.text('Unauthorized', 401); await next(); }); // 全局用户列表 admin.get('/users', async (c) => { const r = await Bun.spawn( ['incus', 'list', '--project', env.incus.project, '--format', 'json'], { stdout: 'pipe' }, ); const raw = await new Response(r.stdout).text(); await r.exited; const instances = JSON.parse(raw || '[]') as any[]; const users = instances.map((inst) => ({ name: inst.name as string, userId: inst.name.replace(new RegExp(`^${env.incus.prefix}`), ''), state: String(inst.state?.status ?? 'UNKNOWN').toUpperCase(), ipv4: inst.state?.network?.eth0?.addresses?.find((a: any) => a.family === 'inet')?.address ?? '', memory: inst.state?.memory?.usage ?? 0, disk: inst.state?.disk?.root?.usage ?? 0, cpuSeconds: inst.state?.cpu?.usage ?? 0, lastUsedAt: inst.last_used_at as string, })); return c.json({ users }); }); // 单用户 detail admin.get('/user/:userId', async (c) => { const userId = c.req.param('userId'); const name = containerName(userId); const s = await incus.state(name); return c.json({ userId, containerName: name, state: s }); }); // 文件树(递归平铺) admin.get('/files', async (c) => { const userId = c.req.query('userId'); if (!userId) return c.json({ error: 'userId required' }, 400); const name = containerName(userId); const s = await incus.state(name); if (s === 'MISSING') return c.json({ files: [], error: 'no sandbox' }); if (s === 'STOPPED') return c.json({ files: [], note: 'sandbox stopped — start to view files' }); // 递归 + 安全限制:最多 500 个文件,排除 venv 里的包 const r = await incus.exec( name, [ 'bash', '-c', `find /workspace -type f -not -path '*/.venv/lib/*' -not -path '*/node_modules/*' -printf '%T@\\t%s\\t%p\\n' 2>/dev/null | head -500`, ], { user: 1000 }, ); const files = r.stdout .split('\n') .filter(Boolean) .map((line) => { const [ts, size, path] = line.split('\t'); return { path, size: Number(size), mtime: Math.floor(Number(ts) * 1000), }; }); return c.json({ files }); }); // 文件内容(小文件 text / 大文件截断) admin.get('/file', async (c) => { const userId = c.req.query('userId'); const path = c.req.query('path'); if (!userId || !path) return c.json({ error: 'userId + path required' }, 400); const name = containerName(userId); // 安全:限制 /workspace 下,最大 2MB if (!path.startsWith('/workspace')) return c.json({ error: 'path must be under /workspace' }, 403); try { const bytes = await incus.pull(name, path); if (bytes.byteLength > 2 * 1024 * 1024) { return c.json({ error: 'file too large (>2MB), download only' }, 413); } const ext = path.slice(path.lastIndexOf('.')).toLowerCase(); const isImage = ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'].includes(ext); if (isImage) { return new Response(bytes, { headers: { 'Content-Type': mimeFor(ext) }, }); } // 文本返回 — 让前端自己渲染 return new Response(bytes, { headers: { 'Content-Type': 'text/plain; charset=utf-8' }, }); } catch (e) { return c.json({ error: (e as Error).message }, 500); } }); // 手动 start/stop admin.post('/user/:userId/start', async (c) => { try { await incus.start(containerName(c.req.param('userId'))); return c.json({ ok: true }); } catch (e) { return c.json({ error: (e as Error).message }, 500); } }); admin.post('/user/:userId/stop', async (c) => { try { await incus.stop(containerName(c.req.param('userId'))); return c.json({ ok: true }); } catch (e) { return c.json({ error: (e as Error).message }, 500); } }); // SSE 实时事件流 admin.get('/events', (c) => { const userId = c.req.query('userId') ?? undefined; return streamSSE(c, async (stream) => { // 回放最近事件 for (const e of getRecentEvents(userId)) { await stream.writeSSE({ data: JSON.stringify(e), event: 'event' }); } let writing: Promise = Promise.resolve(); const off = onEvent((e) => { if (userId && e.userId !== userId) return; writing = writing.then(() => stream.writeSSE({ data: JSON.stringify(e), event: 'event' }).catch(() => {}), ); }); try { // keepalive while (!stream.aborted) { await stream.sleep(30_000); await stream.writeSSE({ data: String(Date.now()), event: 'ping' }).catch(() => {}); } } finally { off(); } }); }); const mimeFor = (ext: string): string => ({ '.gif': 'image/gif', '.jpeg': 'image/jpeg', '.jpg': 'image/jpeg', '.png': 'image/png', '.svg': 'image/svg+xml', '.webp': 'image/webp', })[ext] ?? 'application/octet-stream';