|
@@ -0,0 +1,248 @@
|
|
|
|
|
+// 后台文件管理(管理员):只能访问登记的"位置"——会话工作区、资料库(可读写),本地技能目录、Python 运行环境(只读)。
|
|
|
|
|
+// 服务数据目录(数据库、版本库、MCP 数据)、服务账号 home(CLI 凭证)与全局禁止项一律不可见。
|
|
|
|
|
+// 路径以"位置 + 相对路径"表示;解析符号链接后必须仍在该位置之内
|
|
|
|
|
+import fs from 'node:fs'
|
|
|
|
|
+import path from 'node:path'
|
|
|
|
|
+import { desc, 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 { isGloballyDenied } 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[] = [{ label: '全部会话工作区', rootId: 'workspaces', path: '', icon: 'folder-library' }]
|
|
|
|
|
+ const recent = this.db.select({ id: sessions.id, title: sessions.title, dir: sessions.workspaceDir }).from(sessions).orderBy(desc(sessions.updatedAt)).limit(8).all()
|
|
|
|
|
+ for (const r of recent) {
|
|
|
|
|
+ if (!isWithin(r.dir, s.paths.workspaceRoot)) continue
|
|
|
|
|
+ out.push({ label: r.title || '新会话', rootId: 'workspaces', path: path.relative(s.paths.workspaceRoot, r.dir), icon: 'comment-discussion' })
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const k of this.knowledge()) if (k.enabled) out.push({ label: k.name, rootId: `kb:${k.id}`, path: '', icon: 'library' })
|
|
|
|
|
+ out.push({ label: '本地技能目录', rootId: 'skills', path: '', icon: 'symbol-misc' })
|
|
|
|
|
+ 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)
|
|
|
|
|
+ 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)
|
|
|
|
|
+ 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) || 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: 'skills', name: '本地技能目录', kind: 'readonly', path: s.paths.skillsDir, writable: false, description: '随部署同步,在线修改会被覆盖,因此只读' })
|
|
|
|
|
+ roots.push({ id: 'runtime', name: 'Python 运行环境', kind: 'readonly', path: s.paths.pythonRuntime, writable: false, description: '随部署安装,只读' })
|
|
|
|
|
+ return roots
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** 服务数据目录、服务账号 home 与全局禁止项不可见 */
|
|
|
|
|
+ private hidden(abs: string): boolean {
|
|
|
|
|
+ const real = realpathLoose(abs)
|
|
|
|
|
+ const s = this.settings()
|
|
|
|
|
+ if (isWithin(real, realpathLoose(path.join(config.dataDir, 'data'))) || isWithin(real, realpathLoose(path.join(config.dataDir, 'home')))) return true
|
|
|
|
|
+ return isGloballyDenied(real, s)
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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(() => {})
|
|
|
|
|
+ }
|
|
|
|
|
+}
|