Files
dsh_shenxian/src/web/routes/admin-user-ops.ts
T

165 lines
6.8 KiB
TypeScript
Raw Normal View History

/**
* 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 }
})
}