Files
Hermes-ui/packages/server/src/routes/hermes/files.ts
T

284 lines
8.6 KiB
TypeScript
Raw Normal View History

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
}