- 官方「设置 → 模型」页在平台环境**必然报错**(判据在**浏览器页面**的 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)。
166 lines
6.8 KiB
TypeScript
166 lines
6.8 KiB
TypeScript
/**
|
||
* 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 }
|
||
})
|
||
}
|