284 lines
8.6 KiB
TypeScript
284 lines
8.6 KiB
TypeScript
|
|
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
|
||
|
|
}
|