2026-04-23 12:09:39 +08:00
|
|
|
import Router from '@koa/router'
|
2026-05-11 20:08:13 +08:00
|
|
|
import { basename, extname, isAbsolute } from 'path'
|
2026-04-23 12:09:39 +08:00
|
|
|
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
|
2026-05-11 20:08:13 +08:00
|
|
|
const validPath = isAbsolute(filePath) ? validatePath(filePath) : resolveHermesPath(filePath)
|
2026-04-23 12:09:39 +08:00
|
|
|
|
|
|
|
|
// 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 }
|
|
|
|
|
}
|
|
|
|
|
})
|