feat: add file browser and file download with multi-backend support (#142)
* feat: add file browser and file download with multi-backend support Adds a built-in File Browser page and a File Download system to Hermes Web UI, enabling users to browse, edit, preview, upload, and download files from the workspace directly from the web dashboard. File Browser (/hermes/files): - New view FilesView.vue plus components under components/hermes/files/ (FileTree, FileList, FileBreadcrumb, FileToolbar, FileContextMenu, FileEditor, FilePreview, FileRenameModal, FileUploadModal) - New Pinia store stores/hermes/files.ts for directory tree, selection, and editing state - New API module api/hermes/files.ts - New server routes routes/hermes/files.ts with CRUD, rename, upload, and directory listing - New service services/hermes/file-provider.ts with a pluggable provider architecture (local filesystem + multi-terminal backends) File Download: - New server route routes/hermes/download.ts and client API api/hermes/download.ts - Integration in chat messages (MessageItem.vue, MarkdownRenderer.vue) to surface downloadable file references Packaging: - package.json: add a prepare script so the package can be installed directly from a git URL with dist/ built automatically i18n: add files/download translations to en.ts and zh.ts. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> * fix: use clipboard fallback for non-secure HTTP contexts navigator.clipboard is undefined on HTTP intranet deployments (only available in secure contexts). The previous synchronous calls threw silently and the success toast still fired, making 'copy' actions appear broken. - Add packages/client/src/utils/clipboard.ts with execCommand fallback via a hidden textarea - Use the helper in FileContextMenu (copy file path), CodexLoginModal (copy user code), NousLoginModal (copy user code), ChatPanel (copy session id) - Each call now awaits the result and shows success/failure based on the actual outcome Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
import Router from '@koa/router'
|
||||
import { basename, extname } from 'path'
|
||||
import {
|
||||
createFileProvider,
|
||||
localProvider,
|
||||
isInUploadDir,
|
||||
validatePath,
|
||||
resolveHermesPath,
|
||||
} from '../../services/hermes/file-provider'
|
||||
|
||||
export const downloadRoutes = new Router()
|
||||
|
||||
// MIME type mapping for common extensions
|
||||
const MIME_MAP: Record<string, string> = {
|
||||
'.txt': 'text/plain',
|
||||
'.html': 'text/html',
|
||||
'.htm': 'text/html',
|
||||
'.css': 'text/css',
|
||||
'.js': 'application/javascript',
|
||||
'.json': 'application/json',
|
||||
'.xml': 'application/xml',
|
||||
'.csv': 'text/csv',
|
||||
'.md': 'text/markdown',
|
||||
'.pdf': 'application/pdf',
|
||||
'.zip': 'application/zip',
|
||||
'.gz': 'application/gzip',
|
||||
'.tar': 'application/x-tar',
|
||||
'.png': 'image/png',
|
||||
'.jpg': 'image/jpeg',
|
||||
'.jpeg': 'image/jpeg',
|
||||
'.gif': 'image/gif',
|
||||
'.svg': 'image/svg+xml',
|
||||
'.webp': 'image/webp',
|
||||
'.mp3': 'audio/mpeg',
|
||||
'.wav': 'audio/wav',
|
||||
'.mp4': 'video/mp4',
|
||||
'.webm': 'video/webm',
|
||||
'.doc': 'application/msword',
|
||||
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
'.xls': 'application/vnd.ms-excel',
|
||||
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
'.ppt': 'application/vnd.ms-powerpoint',
|
||||
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
||||
'.py': 'text/x-python',
|
||||
'.ts': 'text/typescript',
|
||||
'.tsx': 'text/typescript',
|
||||
'.rs': 'text/x-rust',
|
||||
'.go': 'text/x-go',
|
||||
'.java': 'text/x-java',
|
||||
'.c': 'text/x-c',
|
||||
'.cpp': 'text/x-c++',
|
||||
'.h': 'text/x-c',
|
||||
'.sh': 'text/x-shellscript',
|
||||
'.yaml': 'text/yaml',
|
||||
'.yml': 'text/yaml',
|
||||
'.toml': 'text/toml',
|
||||
'.log': 'text/plain',
|
||||
}
|
||||
|
||||
function getMimeType(fileName: string): string {
|
||||
const ext = extname(fileName).toLowerCase()
|
||||
return MIME_MAP[ext] || 'application/octet-stream'
|
||||
}
|
||||
|
||||
downloadRoutes.get('/api/hermes/download', async (ctx) => {
|
||||
const filePath = ctx.query.path as string | undefined
|
||||
const fileName = ctx.query.name as string | undefined
|
||||
|
||||
if (!filePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
// Validate the path first
|
||||
// Support both absolute and relative paths
|
||||
const validPath = filePath.startsWith('/') ? validatePath(filePath) : resolveHermesPath(filePath)
|
||||
|
||||
// Choose provider: always use local for upload directory files
|
||||
let data: Buffer
|
||||
if (isInUploadDir(validPath)) {
|
||||
data = await localProvider.readFile(validPath)
|
||||
} else {
|
||||
const provider = await createFileProvider()
|
||||
data = await provider.readFile(validPath)
|
||||
}
|
||||
|
||||
// Determine filename and MIME type
|
||||
const name = fileName || basename(validPath)
|
||||
const mime = getMimeType(name)
|
||||
|
||||
// Set response headers
|
||||
ctx.set('Content-Type', mime)
|
||||
ctx.set('Content-Disposition', `attachment; filename="${encodeURIComponent(name)}"; filename*=UTF-8''${encodeURIComponent(name)}`)
|
||||
ctx.set('Content-Length', String(data.length))
|
||||
ctx.set('Cache-Control', 'no-cache')
|
||||
ctx.body = data
|
||||
} catch (err: any) {
|
||||
const code = err.code || 'unknown'
|
||||
const statusMap: Record<string, number> = {
|
||||
missing_path: 400,
|
||||
invalid_path: 400,
|
||||
not_found: 404,
|
||||
ENOENT: 404,
|
||||
file_too_large: 413,
|
||||
unsupported_backend: 501,
|
||||
backend_error: 502,
|
||||
backend_timeout: 504,
|
||||
}
|
||||
ctx.status = statusMap[code] || 500
|
||||
ctx.body = { error: err.message, code }
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,283 @@
|
||||
import Router from '@koa/router'
|
||||
import {
|
||||
createFileProvider,
|
||||
resolveHermesPath,
|
||||
isSensitivePath,
|
||||
MAX_EDIT_SIZE,
|
||||
} from '../../services/hermes/file-provider'
|
||||
|
||||
export const fileRoutes = new Router()
|
||||
|
||||
function handleError(ctx: any, err: any) {
|
||||
const code = err.code || 'unknown'
|
||||
const statusMap: Record<string, number> = {
|
||||
missing_path: 400,
|
||||
invalid_path: 400,
|
||||
not_found: 404,
|
||||
ENOENT: 404,
|
||||
already_exists: 409,
|
||||
permission_denied: 403,
|
||||
file_too_large: 413,
|
||||
not_a_directory: 400,
|
||||
not_a_file: 400,
|
||||
unsupported_backend: 501,
|
||||
backend_error: 502,
|
||||
backend_timeout: 504,
|
||||
}
|
||||
ctx.status = statusMap[code] || 500
|
||||
ctx.body = { error: err.message, code }
|
||||
}
|
||||
|
||||
// GET /api/hermes/files/list?path=
|
||||
fileRoutes.get('/api/hermes/files/list', async (ctx) => {
|
||||
const relativePath = (ctx.query.path as string) || ''
|
||||
try {
|
||||
const absPath = resolveHermesPath(relativePath)
|
||||
const provider = await createFileProvider()
|
||||
const entries = await provider.listDir(absPath)
|
||||
entries.sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
ctx.body = { entries, path: relativePath }
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/hermes/files/stat?path=
|
||||
fileRoutes.get('/api/hermes/files/stat', async (ctx) => {
|
||||
const relativePath = ctx.query.path as string
|
||||
if (!relativePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const absPath = resolveHermesPath(relativePath)
|
||||
const provider = await createFileProvider()
|
||||
const info = await provider.stat(absPath)
|
||||
ctx.body = info
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// GET /api/hermes/files/read?path=
|
||||
fileRoutes.get('/api/hermes/files/read', async (ctx) => {
|
||||
const relativePath = ctx.query.path as string
|
||||
if (!relativePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const absPath = resolveHermesPath(relativePath)
|
||||
const provider = await createFileProvider()
|
||||
const data = await provider.readFile(absPath)
|
||||
if (data.length > MAX_EDIT_SIZE) {
|
||||
ctx.status = 413
|
||||
ctx.body = { error: 'File too large to edit', code: 'file_too_large' }
|
||||
return
|
||||
}
|
||||
ctx.body = { content: data.toString('utf-8'), path: relativePath, size: data.length }
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// PUT /api/hermes/files/write body: { path, content }
|
||||
fileRoutes.put('/api/hermes/files/write', async (ctx) => {
|
||||
const { path: relativePath, content } = ctx.request.body as { path?: string; content?: string }
|
||||
if (!relativePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
if (isSensitivePath(relativePath)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Cannot modify sensitive file', code: 'permission_denied' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const buf = Buffer.from(content || '', 'utf-8')
|
||||
if (buf.length > MAX_EDIT_SIZE) {
|
||||
ctx.status = 413
|
||||
ctx.body = { error: 'Content too large', code: 'file_too_large' }
|
||||
return
|
||||
}
|
||||
const absPath = resolveHermesPath(relativePath)
|
||||
const provider = await createFileProvider()
|
||||
await provider.writeFile(absPath, buf)
|
||||
ctx.body = { ok: true, path: relativePath }
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// DELETE /api/hermes/files/delete body: { path, recursive? }
|
||||
fileRoutes.delete('/api/hermes/files/delete', async (ctx) => {
|
||||
const { path: relativePath, recursive } = ctx.request.body as { path?: string; recursive?: boolean }
|
||||
if (!relativePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
if (isSensitivePath(relativePath)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Cannot delete sensitive file', code: 'permission_denied' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const absPath = resolveHermesPath(relativePath)
|
||||
const provider = await createFileProvider()
|
||||
if (recursive) {
|
||||
await provider.deleteDir(absPath)
|
||||
} else {
|
||||
await provider.deleteFile(absPath)
|
||||
}
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/hermes/files/rename body: { oldPath, newPath }
|
||||
fileRoutes.post('/api/hermes/files/rename', async (ctx) => {
|
||||
const { oldPath, newPath } = ctx.request.body as { oldPath?: string; newPath?: string }
|
||||
if (!oldPath || !newPath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing oldPath or newPath', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
if (isSensitivePath(oldPath)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: 'Cannot rename sensitive file', code: 'permission_denied' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const absOld = resolveHermesPath(oldPath)
|
||||
const absNew = resolveHermesPath(newPath)
|
||||
const provider = await createFileProvider()
|
||||
await provider.renameFile(absOld, absNew)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/hermes/files/mkdir body: { path }
|
||||
fileRoutes.post('/api/hermes/files/mkdir', async (ctx) => {
|
||||
const { path: relativePath } = ctx.request.body as { path?: string }
|
||||
if (!relativePath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing path parameter', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const absPath = resolveHermesPath(relativePath)
|
||||
const provider = await createFileProvider()
|
||||
await provider.mkDir(absPath)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/hermes/files/copy body: { srcPath, destPath }
|
||||
fileRoutes.post('/api/hermes/files/copy', async (ctx) => {
|
||||
const { srcPath, destPath } = ctx.request.body as { srcPath?: string; destPath?: string }
|
||||
if (!srcPath || !destPath) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing srcPath or destPath', code: 'missing_path' }
|
||||
return
|
||||
}
|
||||
try {
|
||||
const absSrc = resolveHermesPath(srcPath)
|
||||
const absDest = resolveHermesPath(destPath)
|
||||
const provider = await createFileProvider()
|
||||
await provider.copyFile(absSrc, absDest)
|
||||
ctx.body = { ok: true }
|
||||
} catch (err: any) {
|
||||
handleError(ctx, err)
|
||||
}
|
||||
})
|
||||
|
||||
// POST /api/hermes/files/upload?path= (multipart/form-data)
|
||||
fileRoutes.post('/api/hermes/files/upload', async (ctx) => {
|
||||
const targetDir = (ctx.query.path as string) || ''
|
||||
const contentType = ctx.get('content-type') || ''
|
||||
if (!contentType.startsWith('multipart/form-data')) {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Expected multipart/form-data', code: 'invalid_request' }
|
||||
return
|
||||
}
|
||||
|
||||
const boundary = '--' + contentType.split('boundary=')[1]
|
||||
if (!boundary || boundary === '--undefined') {
|
||||
ctx.status = 400
|
||||
ctx.body = { error: 'Missing boundary', code: 'invalid_request' }
|
||||
return
|
||||
}
|
||||
|
||||
const chunks: Buffer[] = []
|
||||
for await (const chunk of ctx.req) chunks.push(chunk)
|
||||
const raw = Buffer.concat(chunks)
|
||||
|
||||
const boundaryBuf = Buffer.from(boundary)
|
||||
const parts = splitMultipart(raw, boundaryBuf)
|
||||
const provider = await createFileProvider()
|
||||
const results: { name: string; path: string }[] = []
|
||||
|
||||
for (const part of parts) {
|
||||
const headerEnd = part.indexOf(Buffer.from('\r\n\r\n'))
|
||||
if (headerEnd === -1) continue
|
||||
const headerBuf = part.subarray(0, headerEnd)
|
||||
const header = headerBuf.toString('utf-8')
|
||||
const data = part.subarray(headerEnd + 4, part.length - 2)
|
||||
|
||||
let filename = ''
|
||||
const filenameStarMatch = header.match(/filename\*=UTF-8''(.+)/i)
|
||||
if (filenameStarMatch) {
|
||||
filename = decodeURIComponent(filenameStarMatch[1])
|
||||
} else {
|
||||
const filenameMatch = header.match(/filename="([^"]+)"/)
|
||||
if (!filenameMatch) continue
|
||||
filename = filenameMatch[1]
|
||||
}
|
||||
|
||||
if (data.length > MAX_EDIT_SIZE) {
|
||||
ctx.status = 413
|
||||
ctx.body = { error: `File ${filename} too large`, code: 'file_too_large' }
|
||||
return
|
||||
}
|
||||
|
||||
const filePath = targetDir ? `${targetDir}/${filename}` : filename
|
||||
if (isSensitivePath(filePath)) {
|
||||
ctx.status = 403
|
||||
ctx.body = { error: `Cannot overwrite sensitive file: ${filename}`, code: 'permission_denied' }
|
||||
return
|
||||
}
|
||||
|
||||
const absPath = resolveHermesPath(filePath)
|
||||
await provider.writeFile(absPath, data)
|
||||
results.push({ name: filename, path: filePath })
|
||||
}
|
||||
|
||||
ctx.body = { files: results }
|
||||
})
|
||||
|
||||
function splitMultipart(raw: Buffer, boundary: Buffer): Buffer[] {
|
||||
const parts: Buffer[] = []
|
||||
let start = 0
|
||||
while (true) {
|
||||
const idx = raw.indexOf(boundary, start)
|
||||
if (idx === -1) break
|
||||
if (start > 0) {
|
||||
const partStart = start + 2
|
||||
parts.push(raw.subarray(partStart, idx))
|
||||
}
|
||||
start = idx + boundary.length
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -20,6 +20,8 @@ import { codexAuthRoutes } from './hermes/codex-auth'
|
||||
import { nousAuthRoutes } from './hermes/nous-auth'
|
||||
import { gatewayRoutes } from './hermes/gateways'
|
||||
import { weixinRoutes } from './hermes/weixin'
|
||||
import { fileRoutes } from './hermes/files'
|
||||
import { downloadRoutes } from './hermes/download'
|
||||
import { proxyRoutes, proxyMiddleware } from './hermes/proxy'
|
||||
|
||||
/**
|
||||
@@ -52,6 +54,8 @@ export function registerRoutes(app: any, requireAuth: (ctx: Context, next: Next)
|
||||
app.use(nousAuthRoutes.routes())
|
||||
app.use(gatewayRoutes.routes())
|
||||
app.use(weixinRoutes.routes())
|
||||
app.use(fileRoutes.routes()) // Must be before proxy (proxy catch-all matches everything)
|
||||
app.use(downloadRoutes.routes()) // Must be before proxy
|
||||
app.use(proxyRoutes.routes())
|
||||
|
||||
// Proxy catch-all middleware (must be last)
|
||||
|
||||
Reference in New Issue
Block a user