165 lines
6.8 KiB
TypeScript
165 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 }
|
|||
|
|
})
|
|||
|
|
}
|