212 lines
6.8 KiB
TypeScript
212 lines
6.8 KiB
TypeScript
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
|
|
// 生成用户范围 token(供 LobeChat 内嵌 iframe 用,只能访问自己的沙箱)
|
|
export const scopedToken = async (userId: string): Promise<string> => {
|
|
const key = await crypto.subtle.importKey(
|
|
'raw',
|
|
new TextEncoder().encode(env.orchSecret),
|
|
{ hash: 'SHA-256', name: 'HMAC' },
|
|
false,
|
|
['sign'],
|
|
);
|
|
const sig = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(`view:${userId}`));
|
|
return Buffer.from(sig).toString('base64url').slice(0, 32);
|
|
};
|
|
|
|
const tokenForUser = (token: string, userId: string) =>
|
|
scopedToken(userId).then((t) => t === token);
|
|
|
|
admin.use('*', async (c, next) => {
|
|
const token = c.req.query('token') ?? c.req.header('Admin-Token');
|
|
if (!token) return c.text('Unauthorized', 401);
|
|
|
|
// 1. admin token (全局)
|
|
if (env.adminToken && token === env.adminToken) {
|
|
await next();
|
|
return;
|
|
}
|
|
|
|
// 2. scoped token 针对单用户路由,验证 userId 匹配
|
|
const userId =
|
|
c.req.param('userId') ??
|
|
c.req.query('userId') ??
|
|
(c.req.path.match(/\/user\/([^\/]+)/)?.[1]);
|
|
if (userId && (await tokenForUser(token, userId))) {
|
|
c.set('scopedUserId', userId);
|
|
await next();
|
|
return;
|
|
}
|
|
return c.text('Unauthorized', 401);
|
|
});
|
|
|
|
// 允许 ai.milejoy.com / lobehub.kang-kang.com iframe 引用
|
|
admin.use('*', async (c, next) => {
|
|
await next();
|
|
c.header(
|
|
'Content-Security-Policy',
|
|
"frame-ancestors 'self' https://ai.milejoy.com https://lobehub.kang-kang.com",
|
|
);
|
|
c.header('X-Frame-Options', '');
|
|
});
|
|
|
|
// 全局用户列表
|
|
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 });
|
|
});
|
|
|
|
// 给一个 userId 生成 scoped token(admin-token only;LobeChat 服务器端也要实现同样算法)
|
|
admin.get('/token/:userId', async (c) => {
|
|
const userId = c.req.param('userId');
|
|
const token = await scopedToken(userId);
|
|
return c.json({ userId, token, viewUrl: `/admin/user/${userId}?token=${token}` });
|
|
});
|
|
|
|
// 单用户 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<unknown> = 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';
|