| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267 |
- // 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、资料库(可读写),整个服务目录(只读浏览)。
- // 登录凭证与密钥一律不可见;数据库文件(含 MCP 服务密钥等)可见但不能读取或下载。
- // 路径以"位置 + 相对路径"表示;解析符号链接后必须仍在该位置之内
- import fs from 'node:fs'
- import path from 'node:path'
- import { eq } from 'drizzle-orm'
- import { zipSync } from 'fflate'
- import type { AppSettings, FsEntryDto, FsRootDto, FsShortcutDto, KnowledgeRootDto } from '@agent-server/shared'
- import { config } from '../config'
- import type { DB } from '../db'
- import { sessions } from '../db/schema'
- import { AppError, badRequest, conflict, notFound } from '../lib/errors'
- import { isWithin, realpathLoose } from '../lib/paths'
- import { isCredentialPath } from './access-policy'
- import type { FileService } from './files'
- const MAX_EDIT_BYTES = 2 * 1048576
- const MAX_ZIP_BYTES = 200 * 1048576
- const MAX_ZIP_FILES = 5000
- const MAX_LIST = 5000
- interface Root { id: string; name: string; kind: FsRootDto['kind']; path: string; writable: boolean; description: string }
- export class FileManagerService {
- constructor(
- private db: DB,
- private settings: () => AppSettings,
- private knowledge: () => KnowledgeRootDto[],
- private files: FileService,
- private audit: (summary: string, detail?: unknown) => void,
- ) {}
- roots(): FsRootDto[] {
- return this.rootList().map(r => ({ id: r.id, name: r.name, kind: r.kind, writable: r.writable, description: r.description, exists: fs.existsSync(r.path) }))
- }
- /** 常用目录:各资料库、服务目录中的常用子目录(会话工作区在"位置"里,不重复列出) */
- shortcuts(): FsShortcutDto[] {
- const s = this.settings()
- const out: FsShortcutDto[] = []
- for (const k of this.knowledge()) if (k.enabled) out.push({ label: k.name, rootId: `kb:${k.id}`, path: '', icon: 'library' })
- const server = realpathLoose(config.dataDir)
- const dirs: [string, string, string][] = [
- [s.paths.skillsDir, '本地技能目录', 'symbol-misc'],
- [path.join(config.dataDir, 'managed-skills'), '已安装技能', 'extensions'],
- [s.paths.pythonRuntime, 'Python 运行环境', 'symbol-namespace'],
- [path.join(config.dataDir, 'data'), '服务数据', 'database'],
- [path.join(config.dataDir, 'logs'), '日志', 'output'],
- ]
- for (const [dir, label, icon] of dirs) {
- const real = realpathLoose(dir)
- if (isWithin(real, server)) out.push({ label, rootId: 'server', path: path.relative(server, real).split(path.sep).join('/'), icon })
- else if (dir === s.paths.skillsDir) out.push({ label, rootId: 'skills', path: '', icon })
- else if (dir === s.paths.pythonRuntime) out.push({ label, rootId: 'runtime', path: '', icon })
- }
- return out
- }
- list(rootId: string, rel: string): { entries: FsEntryDto[]; truncated: boolean; sessionTitle: string | null } {
- const { root, abs } = this.resolve(rootId, rel)
- const titles = root.kind === 'workspaces' ? new Map(this.db.select({ id: sessions.id, title: sessions.title }).from(sessions).all().map(s => [s.id, s.title || '新会话'])) : new Map<string, string>()
- const atWorkspaceRoot = root.kind === 'workspaces' && abs === realpathLoose(root.path)
- if (!fs.statSync(abs).isDirectory()) throw badRequest('不是目录')
- const entries: FsEntryDto[] = []
- const names = fs.readdirSync(abs)
- for (const name of names.slice(0, MAX_LIST)) {
- const p = path.join(abs, name)
- if (this.hidden(p)) continue
- let st: fs.Stats
- try {
- st = fs.lstatSync(p)
- } catch {
- continue
- }
- let type: FsEntryDto['type'] = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : st.isSymbolicLink() ? 'link' : 'other'
- // 指向位置之内的符号链接按目标显示,指向外部的只显示为链接
- if (type === 'link') {
- const target = realpathLoose(p)
- if (isWithin(target, realpathLoose(root.path)) && fs.existsSync(target)) type = fs.statSync(target).isDirectory() ? 'dir' : 'file'
- }
- entries.push({ name, type, size: st.isFile() ? st.size : null, mtime: st.mtime.toISOString(), ...(atWorkspaceRoot && titles.has(name) ? { label: titles.get(name) } : {}) })
- }
- entries.sort((a, b) => Number(b.type === 'dir') - Number(a.type === 'dir') || a.name.localeCompare(b.name))
- const sessionId = root.kind === 'workspaces' ? path.relative(realpathLoose(root.path), abs).split(path.sep)[0] : ''
- return { entries, truncated: names.length > MAX_LIST, sessionTitle: sessionId ? titles.get(sessionId) ?? null : null }
- }
- /** 读取文本(编辑用):不超过 2 MB,且必须是 UTF-8 文本 */
- readText(rootId: string, rel: string): { content: string; mtime: string } {
- const { abs } = this.resolve(rootId, rel)
- this.assertReadable(abs)
- const st = this.fileStat(abs)
- if (st.size > MAX_EDIT_BYTES) throw badRequest(`文件超过 ${MAX_EDIT_BYTES / 1048576} MB,不能在线编辑,请下载`)
- const buf = fs.readFileSync(abs)
- if (buf.subarray(0, 8000).includes(0)) throw badRequest('这是二进制文件,不能在线编辑')
- return { content: buf.toString('utf8'), mtime: st.mtime.toISOString() }
- }
- /** 下载或预览 */
- openFile(rootId: string, rel: string): { abs: string; name: string; size: number } {
- const { abs } = this.resolve(rootId, rel)
- this.assertReadable(abs)
- const st = this.fileStat(abs)
- return { abs, name: path.basename(abs), size: st.size }
- }
- /** 保存文本:baseMtime 与磁盘不一致时拒绝(文件在打开后被别人修改了) */
- writeText(rootId: string, rel: string, content: string, baseMtime: string | null): { mtime: string } {
- const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true })
- if (Buffer.byteLength(content) > MAX_EDIT_BYTES) throw badRequest(`内容超过 ${MAX_EDIT_BYTES / 1048576} MB`)
- if (fs.existsSync(abs)) {
- const st = this.fileStat(abs)
- if (baseMtime && st.mtime.toISOString() !== baseMtime) throw conflict('file_changed', '文件在打开后被修改过,请重新加载后再保存')
- } else if (baseMtime) {
- throw conflict('file_changed', '文件已被删除或移动')
- }
- fs.mkdirSync(path.dirname(abs), { recursive: true })
- fs.writeFileSync(abs, content)
- this.audit(`文件管理:保存 ${root.name}/${rel}`)
- this.afterChange(root, abs)
- return { mtime: fs.statSync(abs).mtime.toISOString() }
- }
- mkdir(rootId: string, rel: string) {
- const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true })
- if (fs.existsSync(abs)) throw badRequest('已存在同名文件或目录')
- fs.mkdirSync(abs, { recursive: true })
- this.audit(`文件管理:新建目录 ${root.name}/${rel}`)
- }
- upload(rootId: string, relDir: string, name: string, data: Buffer): FsEntryDto {
- const safe = path.basename(name).replace(/[\x00-\x1f/\\]/g, '_').trim()
- if (!safe || safe === '.' || safe === '..') throw badRequest('文件名不正确')
- const rel = relDir ? `${relDir.replace(/\/+$/, '')}/${safe}` : safe
- const { root, abs } = this.resolve(rootId, rel, { write: true, allowMissing: true })
- fs.mkdirSync(path.dirname(abs), { recursive: true })
- fs.writeFileSync(abs, data)
- this.audit(`文件管理:上传 ${root.name}/${rel}(${(data.length / 1024).toFixed(1)} KB)`)
- this.afterChange(root, abs)
- const st = fs.statSync(abs)
- return { name: safe, type: 'file', size: st.size, mtime: st.mtime.toISOString() }
- }
- /** 重命名或移动(同一位置内) */
- move(rootId: string, from: string, to: string) {
- const src = this.resolve(rootId, from, { write: true })
- const dst = this.resolve(rootId, to, { write: true, allowMissing: true })
- if (src.abs === realpathLoose(src.root.path)) throw badRequest('不能移动位置的根目录')
- if (fs.existsSync(dst.abs)) throw badRequest('目标已存在')
- if (isWithin(dst.abs, src.abs)) throw badRequest('不能移动到自身之内')
- this.protectSessionDir(src.root, src.abs)
- fs.mkdirSync(path.dirname(dst.abs), { recursive: true })
- fs.renameSync(src.abs, dst.abs)
- this.audit(`文件管理:移动 ${src.root.name}/${from} → ${to}`)
- this.afterChange(src.root, src.abs)
- this.afterChange(dst.root, dst.abs)
- }
- delete(rootId: string, rel: string) {
- const { root, abs } = this.resolve(rootId, rel, { write: true })
- if (abs === realpathLoose(root.path)) throw badRequest('不能删除位置的根目录')
- this.protectSessionDir(root, abs)
- const isDir = fs.lstatSync(abs).isDirectory()
- fs.rmSync(abs, { recursive: true, force: true })
- this.audit(`文件管理:删除${isDir ? '目录' : '文件'} ${root.name}/${rel}`)
- this.afterChange(root, abs)
- }
- /** 目录打包下载(跳过禁止的路径与符号链接) */
- zipDir(rootId: string, rel: string): { name: string; data: Buffer } {
- const { root, abs } = this.resolve(rootId, rel)
- if (!fs.statSync(abs).isDirectory()) throw badRequest('不是目录')
- const entries: Record<string, Uint8Array> = {}
- let total = 0
- let count = 0
- const walk = (dir: string) => {
- for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
- const p = path.join(dir, e.name)
- if (this.hidden(p) || this.sealed(p) || e.isSymbolicLink()) continue
- if (e.isDirectory()) walk(p)
- else if (e.isFile()) {
- const size = fs.statSync(p).size
- total += size
- if (++count > MAX_ZIP_FILES || total > MAX_ZIP_BYTES) throw badRequest(`目录太大(超过 ${MAX_ZIP_FILES} 个文件或 ${MAX_ZIP_BYTES / 1048576} MB),请分别下载`)
- entries[path.relative(abs, p).split(path.sep).join('/')] = new Uint8Array(fs.readFileSync(p))
- }
- }
- }
- walk(abs)
- const base = abs === realpathLoose(root.path) ? root.name : path.basename(abs)
- return { name: `${base}.zip`, data: Buffer.from(zipSync(entries, { level: 6 })) }
- }
- // ---- 内部 ----
- private rootList(): Root[] {
- const s = this.settings()
- const roots: Root[] = [
- { id: 'workspaces', name: '会话工作区', kind: 'workspaces', path: s.paths.workspaceRoot, writable: true, description: '每个会话一个目录:uploads 上传的文件、outputs Agent 产出' },
- ]
- for (const k of this.knowledge()) {
- roots.push({ id: `kb:${k.id}`, name: `资料库:${k.name}`, kind: 'knowledge', path: k.path, writable: true, description: `${k.description}${k.access === 'read' ? '(Agent 只读)' : '(Agent 可读写)'}` })
- }
- roots.push({ id: 'server', name: '服务目录', kind: 'readonly', path: config.dataDir, writable: false, description: '整个服务目录(程序、运行环境、技能、数据、日志),只读浏览;要修改文件请到会话工作区或资料库' })
- // 开发环境中技能目录与运行环境不在服务目录之下,单独列出
- const server = realpathLoose(config.dataDir)
- if (!isWithin(realpathLoose(s.paths.skillsDir), server)) roots.push({ id: 'skills', name: '本地技能目录', kind: 'readonly', path: s.paths.skillsDir, writable: false, description: '随部署同步,在线修改会被覆盖,因此只读' })
- if (!isWithin(realpathLoose(s.paths.pythonRuntime), server)) roots.push({ id: 'runtime', name: 'Python 运行环境', kind: 'readonly', path: s.paths.pythonRuntime, writable: false, description: '随部署安装,只读' })
- return roots
- }
- /** 登录凭证与密钥不可见 */
- private hidden(abs: string): boolean {
- return isCredentialPath(abs)
- }
- /** 数据库及其备份:保存着 MCP 服务密钥等只写不读的配置,可以看到但不能读取或下载 */
- private sealed(abs: string): boolean {
- return isWithin(abs, realpathLoose(path.join(config.dataDir, 'data'))) && /^agent\.db(-wal|-shm|-journal|\.bak.*)?$/.test(path.basename(abs))
- }
- private assertReadable(abs: string) {
- if (this.sealed(abs)) throw new AppError(403, 'denied', '数据库文件含有密钥,不提供读取或下载')
- }
- private resolve(rootId: string, rel: string, opts: { write?: boolean; allowMissing?: boolean } = {}): { root: Root; abs: string } {
- const root = this.rootList().find(r => r.id === rootId)
- if (!root) throw notFound('位置')
- if (opts.write && !root.writable) throw new AppError(403, 'readonly_root', `${root.name}是只读的`)
- const rootReal = realpathLoose(root.path)
- if (!fs.existsSync(rootReal)) throw notFound(`${root.name}(${root.path})`)
- const clean = (rel ?? '').replace(/^\/+|\/+$/g, '')
- // eslint-disable-next-line no-control-regex
- if (/[\x00-\x1f\\]/.test(clean) || clean.split('/').some(p => p === '..' || p === '.')) throw badRequest('路径不正确')
- const joined = path.join(rootReal, ...clean.split('/').filter(Boolean))
- // 解析符号链接后仍须在位置之内(新建时检查父目录)
- const exists = fs.existsSync(joined)
- if (!exists && !opts.allowMissing) throw notFound(clean || root.name)
- const real = exists ? realpathLoose(joined) : path.join(realpathLoose(path.dirname(joined)), path.basename(joined))
- if (!isWithin(real, rootReal)) throw new AppError(403, 'outside_root', '路径指向位置之外')
- if (this.hidden(real)) throw new AppError(403, 'denied', '这个路径不允许访问')
- return { root, abs: real }
- }
- private fileStat(abs: string): fs.Stats {
- const st = fs.statSync(abs)
- if (!st.isFile()) throw badRequest('不是文件')
- return st
- }
- /** 会话目录本身由会话管理,不能在这里删除或移动(删除会话请在会话页面) */
- private protectSessionDir(root: Root, abs: string) {
- if (root.kind === 'workspaces' && path.dirname(abs) === realpathLoose(root.path)) throw badRequest('会话目录不能在这里删除或移动,请在会话页面删除会话')
- }
- /** 会话工作区中的改动:重新登记文件,删除已不存在的记录 */
- private afterChange(root: Root, abs: string) {
- if (root.kind !== 'workspaces') return
- const rel = path.relative(realpathLoose(root.path), abs)
- const sessionId = rel.split(path.sep)[0]
- if (!sessionId) return
- const row = this.db.select().from(sessions).where(eq(sessions.id, sessionId)).get()
- if (!row) return
- this.files.prune(row.id, row.workspaceDir)
- void this.files.scan(row.id, row.workspaceDir).catch(() => {})
- }
- }
|