Files
dsh_shenxian/src/web/routes/admin-user-ops.ts
T
admin 918f1d3a51 feat(models): 平台自建「模型设置」—— 用户自配厂家 / 条目各自开关 / 共享模型开关(档案 87)
- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 loopback 判定;官方 README 原文 Non-loopback pages get no durable settings)⇒ 该分区对全角色(含 admin)隐藏,用户自配改走平台自建页(插件 0.3.11)
- DB 迁移 V6:credential_vault.route/base_url/api/models + users.shared_model_enabled;并**重定义 getEnabledCredentialKeyRef**(互斥删除后原实现无 ORDER BY ⇒ 「任取一条」)
- 新落地层 src/web/model-landing.ts:spawn 时把「已启用条目」写进实例 .credentials.yaml 与 settings.yaml 的 llm-pi-ai.providers.<route>;字段名与官方包实测对齐(apiKeyEnv / baseURL / api,**不是** protocol);只碰自己写过的 + 一次性交接
- 接口 /api/me/keys、/api/me/keys/:id/toggle、/api/me/models/shared;前端新增「设置 → 模型设置」分区(settings.section id=model-settings / order 100 / 全角色)
- 顺手修两处:ensure-role-profile-patch.cjs 的 --force 整文件覆盖会抹掉 admin 的 disable-hmr 与 workspace-scoped-picker 两个平台块(改为 stripManagedBlock 只替换自己那段);verify-mem-model.mjs 因档案 86 重构而长期失败的陈旧断言

⚠️ 本提交同时包含**档案 86(admin 跨用户实例管理 + 两处改名)**的代码改动 —— 该部分已上线并端到端验证;其 import/register 与本次改动同处 src/web/server.ts、poc/business-plugins/lib/client.js 等文件,按**文件粒度无法拆分**,且不带它会让仓库 tsc 直接失败(缺 src/web/routes/admin-user-ops.ts)。
2026-09-14 00:00:15 +08:00

166 lines
6.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Admin 视角的「用户服务 / 工作区文件」路由(档案 86)。
*
* 背景:平台所有服务与文件 API 都是 `request.user.id` 语义(`desktop.ts` 头注释更明确写着
* "one user can never address another user's files")⇒ admin 在「设置 → 系统管理 → 服务管理」
* 里**只能管自己**。用户要求「admin 要能管所有用户的服务」。
*
* 做法:**不动既有路由的语义**(避免把越界风险塞进普通用户路径),另开一组
* `/api/admin/users/:id/...`,全部 `requireAdmin`,把 `request.user.id` 换成路径参数。
* 底层 `UserFs` / `Spawner` 本来就都接受 `userId` 首参 ⇒ 零新增能力面;
* 启动/状态复用 `dsh.ts` 导出的 `launchForUser` / `statusForUser`(一份实现,两处入口)。
*
* ⚠️ R5 权限影响评估:新增的是 **admin 对任意用户**的
* ① 浏览 / 新建 / 上传其工作区文件 —— 仍限定在该用户 ws 根内(`UserFs` 自带逃逸防护,
* 越界即 `bad_path`)
* ② 启停其 DSH 实例 —— 与用户自己点「启动 / 停止」同一条 `supervisor` 路径
* 这与 `requireAdmin` 既有职能(审批 / 禁用 / 删除用户)同级;服务器层面 admin 本就能读
* `/var/lib/dshs/users/**`。**不扩大普通用户的能力面** —— 这些前缀下没有任何 `requireAuth` 版本。
* @module dshs/web/routes/admin-user-ops
*/
import type { FastifyPluginAsync, FastifyReply } from 'fastify'
import { requireAdmin } from '../middleware/authn.js'
import { sendFsError } from './desktop.js'
import { AlreadyRunningError, CrashBreakerOpenError } from '../../supervisor/orchestrator.js'
import { dshUrl, launchForUser, sendBreakerOpen, statusForUser } from './dsh.js'
// 与 desktop.ts / dsh.ts 的同名 schema 同形(那两处未导出,这里按同一形状内联)。
const createSchema = {
body: {
type: 'object',
required: ['path', 'name', 'type'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
type: { type: 'string', enum: ['file', 'dir'] },
},
},
} as const
const uploadSchema = {
body: {
type: 'object',
required: ['path', 'name', 'data'],
additionalProperties: false,
properties: {
path: { type: 'string', maxLength: 512 },
name: { type: 'string', maxLength: 255 },
data: { type: 'string' },
},
},
} as const
const launchSchema = {
body: {
type: 'object',
required: ['folder'],
additionalProperties: false,
properties: { folder: { type: 'string', maxLength: 512 } },
},
} as const
export const adminUserOpsRoutes: FastifyPluginAsync = async (app) => {
/**
* 解析 `:id` 指向的用户;不存在则回 404 并返回 `undefined`。
* 每个路由都过这一关 —— 防 `:id` 乱填导致 `UserFs` 在错误根上操作。
*/
async function targetOr404(id: string, reply: FastifyReply) {
const user = await app.db.findUserById(id)
if (user === undefined) {
reply.code(404).send({ error: 'not_found' })
return undefined
}
return user
}
// ── 工作区文件 ──────────────────────────────────────────────────────────────
app.get('/api/admin/users/:id/fs/tree', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const { path = '' } = request.query as { path?: string }
if ((await targetOr404(id, reply)) === undefined) return
try {
return { path, entries: await app.userFs.listDir(id, path) }
} catch (err) {
return sendFsError(reply, err)
}
})
app.post(
'/api/admin/users/:id/fs/create',
{ preHandler: requireAdmin, schema: createSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { path, name, type } = request.body as { path: string; name: string; type: 'file' | 'dir' }
if ((await targetOr404(id, reply)) === undefined) return
try {
return { ok: true, name: await app.userFs.createEntry(id, path, name, type), type }
} catch (err) {
return sendFsError(reply, err)
}
},
)
app.post(
'/api/admin/users/:id/fs/upload',
{ preHandler: requireAdmin, schema: uploadSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { path, name, data } = request.body as { path: string; name: string; data: string }
if ((await targetOr404(id, reply)) === undefined) return
let buf: Buffer
try {
buf = Buffer.from(data, 'base64')
} catch {
return reply.code(400).send({ error: 'bad_data' })
}
try {
return { ok: true, name: await app.userFs.upload(id, path, name, buf) }
} catch (err) {
return sendFsError(reply, err)
}
},
)
// ── 实例启停与状态 ─────────────────────────────────────────────────────────
app.get('/api/admin/users/:id/dsh/status', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
const user = await targetOr404(id, reply)
if (user === undefined) return
return statusForUser(app, user)
})
app.post(
'/api/admin/users/:id/dsh/launch',
{ preHandler: requireAdmin, schema: launchSchema },
async (request, reply) => {
const { id } = request.params as { id: string }
const { folder } = request.body as { folder: string }
const user = await targetOr404(id, reply)
if (user === undefined) return
let instance
try {
instance = await launchForUser(app, id, folder)
} catch (err) {
if (err instanceof AlreadyRunningError) return reply.code(409).send({ error: 'already_running' })
if (err instanceof CrashBreakerOpenError) return sendBreakerOpen(reply, err)
return sendFsError(reply, err)
}
if (instance === null) return reply.code(400).send({ error: 'not_a_folder' })
return {
instance: { id: instance.id, port: instance.port, status: instance.status, launchToken: instance.launchToken },
// ⚠️ 打开的是**该用户**实例的带 token URL —— admin 用它即可直接进去看(同 `dshUrl` 语义)
url: dshUrl(app.config.baseDomain, user, instance.launchToken),
}
},
)
app.post('/api/admin/users/:id/dsh/stop', { preHandler: requireAdmin }, async (request, reply) => {
const { id } = request.params as { id: string }
if ((await targetOr404(id, reply)) === undefined) return
await app.supervisor.stop(id)
return { ok: true }
})
}