fenghuo/packages/storage/src/middleware/hono.ts

585 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Hono } from 'hono';
import { handleTusRequest, cleanupExpiredUploads, getStorageInfo } from '../services/tus';
import {
getResourceByFileId,
deleteResource,
updateResource,
migrateResourcesStorageType,
} from '../database/operations';
import { StorageManager, validateStorageConfig } from '../core/adapter';
import { StorageType, type StorageConfig } from '../types';
/**
* 创建存储相关的 Hono 路由
* @param basePath 基础路径,默认为 '/api/storage'
* @returns Hono 应用实例
*/
export function createStorageRoutes(basePath: string = '/api/storage') {
const app = new Hono();
// 获取文件资源信息
app.get('/resource/:fileId', async (c) => {
const encodedFileId = c.req.param('fileId');
const fileId = decodeURIComponent(encodedFileId);
console.log('API request - Encoded fileId:', encodedFileId);
console.log('API request - Decoded fileId:', fileId);
const result = await getResourceByFileId(fileId);
return c.json(result);
});
// 删除资源
app.delete('/resource/:id', async (c) => {
const id = c.req.param('id');
const result = await deleteResource(id);
return c.json(result);
});
// 更新资源
app.patch('/resource/:id', async (c) => {
const id = c.req.param('id');
const data = await c.req.json();
const result = await updateResource(id, data);
return c.json(result);
});
// 迁移资源存储类型(批量更新数据库中的存储类型标记)
app.post('/migrate-storage', async (c) => {
try {
const { from, to } = await c.req.json();
const result = await migrateResourcesStorageType(from as StorageType, to as StorageType);
return c.json({
success: true,
message: `Migrated ${result.count} resources from ${from} to ${to}`,
count: result.count,
});
} catch (error) {
console.error('Failed to migrate storage type:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
400,
);
}
});
// 清理过期上传
app.post('/cleanup', async (c) => {
const result = await cleanupExpiredUploads();
return c.json(result);
});
// 获取存储信息
app.get('/storage/info', async (c) => {
const storageInfo = getStorageInfo();
return c.json(storageInfo);
});
// 切换存储类型(需要重启应用)
app.post('/storage/switch', async (c) => {
try {
const newConfig = (await c.req.json()) as StorageConfig;
const storageManager = StorageManager.getInstance();
await storageManager.switchStorage(newConfig);
return c.json({
success: true,
message: 'Storage configuration updated. Please restart the application for changes to take effect.',
newType: newConfig.type,
});
} catch (error) {
console.error('Failed to switch storage:', error);
return c.json(
{
success: false,
error: error instanceof Error ? error.message : 'Unknown error',
},
400,
);
}
});
// 验证存储配置
app.post('/storage/validate', async (c) => {
try {
const config = (await c.req.json()) as StorageConfig;
const errors = validateStorageConfig(config);
if (errors.length > 0) {
return c.json({ valid: false, errors }, 400);
}
return c.json({ valid: true, message: 'Storage configuration is valid' });
} catch (error) {
return c.json(
{
valid: false,
errors: [error instanceof Error ? error.message : 'Invalid JSON'],
},
400,
);
}
});
return app;
}
/**
* 创建TUS上传处理路由
* @param uploadPath 上传路径,默认为 '/upload'
* @returns Hono 应用实例
*/
export function createTusUploadRoutes(uploadPath: string = '/upload') {
const app = new Hono();
// TUS 协议处理 - 使用通用处理器
app.all('/*', async (c) => {
try {
// 创建适配的请求和响应对象
const adaptedReq = createNodeRequestAdapter(c);
const adaptedRes = createNodeResponseAdapter(c);
await handleTusRequest(adaptedReq, adaptedRes);
return adaptedRes.getResponse();
} catch (error) {
console.error('TUS request error:', error);
return c.json({ error: 'Upload request failed' }, 500);
}
});
return app;
}
// Node.js 请求适配器
function createNodeRequestAdapter(c: any) {
const honoReq = c.req;
const url = new URL(honoReq.url);
// 导入Node.js模块
const { Readable } = require('stream');
const { EventEmitter } = require('events');
// 创建一个继承自Readable的适配器类
class TusRequestAdapter extends Readable {
method: string;
url: string;
headers: Record<string, string>;
httpVersion: string;
complete: boolean;
private reader: ReadableStreamDefaultReader<Uint8Array> | null = null;
private _reading: boolean = false;
constructor() {
super();
this.method = honoReq.method;
this.url = url.pathname + url.search;
this.headers = honoReq.header() || {};
this.httpVersion = '1.1';
this.complete = false;
// 如果有请求体获取reader
if (honoReq.method !== 'GET' && honoReq.method !== 'HEAD' && honoReq.raw.body) {
this.reader = honoReq.raw.body.getReader();
}
}
_read() {
if (this._reading || !this.reader) {
return;
}
this._reading = true;
this.reader
.read()
.then(({ done, value }) => {
this._reading = false;
if (done) {
this.push(null); // 结束流
this.complete = true;
} else {
// 确保我们推送的是正确的二进制数据
const buffer = Buffer.from(value);
this.push(buffer);
}
})
.catch((error) => {
this._reading = false;
this.destroy(error);
});
}
// 模拟IncomingMessage的destroy方法
destroy(error?: Error) {
if (this.reader) {
this.reader.cancel().catch(() => {
// 忽略取消错误
});
this.reader = null;
}
super.destroy(error);
}
}
return new TusRequestAdapter();
}
// Node.js 响应适配器
function createNodeResponseAdapter(c: any) {
let statusCode = 200;
let headers: Record<string, string> = {};
let body: any = null;
const adapter = {
statusCode,
setHeader: (name: string, value: string) => {
headers[name] = value;
},
getHeader: (name: string) => {
return headers[name];
},
writeHead: (code: number, reasonOrHeaders?: any, headersObj?: any) => {
statusCode = code;
if (typeof reasonOrHeaders === 'object') {
Object.assign(headers, reasonOrHeaders);
}
if (headersObj) {
Object.assign(headers, headersObj);
}
},
write: (chunk: any) => {
if (body === null) {
body = chunk;
} else if (typeof body === 'string' && typeof chunk === 'string') {
body += chunk;
} else {
// 处理 Buffer 或其他类型
body = chunk;
}
},
end: (data?: any) => {
if (data !== undefined) {
body = data;
}
},
// 添加事件方法
on: (event: string, handler: Function) => {
// 简单的空实现
},
emit: (event: string, ...args: any[]) => {
// 简单的空实现
},
// 获取最终的 Response 对象
getResponse: () => {
if (body === null || body === undefined) {
return new Response(null, {
status: statusCode,
headers: headers,
});
}
return new Response(body, {
status: statusCode,
headers: headers,
});
},
};
return adapter;
}
/**
* 创建文件下载路由(支持所有存储类型)
* @param downloadPath 下载路径,默认为 '/download'
* @returns Hono 应用实例
*/
export function createFileDownloadRoutes(downloadPath: string = '/download') {
const app = new Hono();
// 通过文件ID下载文件
app.get('/:fileId', async (c) => {
try {
// 获取并解码fileId
const encodedFileId = c.req.param('fileId');
const fileId = decodeURIComponent(encodedFileId);
console.log('=== DOWNLOAD DEBUG START ===');
console.log('Download request - Encoded fileId:', encodedFileId);
console.log('Download request - Decoded fileId:', fileId);
const storageManager = StorageManager.getInstance();
const storageType = storageManager.getStorageType();
// 从数据库获取文件信息
const { status, resource } = await getResourceByFileId(fileId);
if (status !== 'UPLOADED' || !resource) {
console.log('Download - File not found, status:', status);
return c.json({ error: `File not found or not ready. Status: ${status}, FileId: ${fileId}` }, 404);
}
// 详细记录资源信息
console.log('Download - Full resource object:', JSON.stringify(resource, null, 2));
console.log('Download - Resource title:', resource.title);
console.log('Download - Resource type:', resource.type);
console.log('Download - Resource fileId:', resource.fileId);
// 使用resource.title作为下载文件名如果没有则使用默认名称
let downloadFileName = resource.title || 'download';
// 确保文件名有正确的扩展名
if (downloadFileName && !downloadFileName.includes('.') && resource.type) {
// 如果没有扩展名尝试从MIME类型推断
const mimeTypeToExt: Record<string, string> = {
// Microsoft Office
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': '.pptx',
'application/msword': '.doc',
'application/vnd.ms-excel': '.xls',
'application/vnd.ms-powerpoint': '.ppt',
// WPS Office
'application/wps-office.docx': '.docx',
'application/wps-office.xlsx': '.xlsx',
'application/wps-office.pptx': '.pptx',
'application/wps-office.doc': '.doc',
'application/wps-office.xls': '.xls',
'application/wps-office.ppt': '.ppt',
// 其他文档格式
'application/pdf': '.pdf',
'application/rtf': '.rtf',
'text/plain': '.txt',
'text/csv': '.csv',
'application/json': '.json',
'application/xml': '.xml',
'text/xml': '.xml',
// 图片格式
'image/jpeg': '.jpg',
'image/jpg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/bmp': '.bmp',
'image/webp': '.webp',
'image/svg+xml': '.svg',
'image/tiff': '.tiff',
// 音频格式
'audio/mpeg': '.mp3',
'audio/wav': '.wav',
'audio/ogg': '.ogg',
'audio/aac': '.aac',
'audio/flac': '.flac',
// 视频格式
'video/mp4': '.mp4',
'video/avi': '.avi',
'video/quicktime': '.mov',
'video/x-msvideo': '.avi',
'video/webm': '.webm',
// 压缩文件
'application/zip': '.zip',
'application/x-rar-compressed': '.rar',
'application/x-7z-compressed': '.7z',
'application/gzip': '.gz',
'application/x-tar': '.tar',
// 其他常见格式
'application/octet-stream': '',
};
const extension = mimeTypeToExt[resource.type];
if (extension) {
downloadFileName += extension;
console.log('Download - Added extension from MIME type:', extension);
}
}
console.log('Download - Final download filename:', downloadFileName);
if (storageType === StorageType.LOCAL) {
// 本地存储:直接读取文件
const config = storageManager.getStorageConfig();
const uploadDir = config.local?.directory || './uploads';
// fileId 是目录路径格式,直接使用
const fileDir = `${uploadDir}/${fileId}`;
try {
// 使用 Node.js fs 而不是 Bun.file
const fs = await import('fs');
const path = await import('path');
// 检查目录是否存在
if (!fs.existsSync(fileDir)) {
console.log('Download - Directory not found:', fileDir);
return c.json({ error: `File directory not found: ${fileDir}` }, 404);
}
// 读取目录内容,找到实际的文件(排除 .json 文件)
const files = fs.readdirSync(fileDir).filter((f) => !f.endsWith('.json'));
console.log('Download - Files in directory:', files);
if (files.length === 0) {
return c.json({ error: `No file found in directory: ${fileDir}` }, 404);
}
// 通常只有一个文件,取第一个
const actualFileName = files[0];
if (!actualFileName) {
return c.json({ error: 'No valid file found' }, 404);
}
const filePath = path.join(fileDir, actualFileName);
console.log('Download - Actual file in directory:', actualFileName);
console.log('Download - Full file path:', filePath);
// 获取文件统计信息
const stats = fs.statSync(filePath);
const fileSize = stats.size;
// 强制设置正确的MIME类型
let contentType = resource.type || 'application/octet-stream';
if (downloadFileName.endsWith('.docx')) {
contentType = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document';
} else if (downloadFileName.endsWith('.xlsx')) {
contentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
} else if (downloadFileName.endsWith('.pdf')) {
contentType = 'application/pdf';
}
console.log('Download - Final Content-Type:', contentType);
// 处理中文文件名 - 现在使用正确的RFC 2231格式
let contentDisposition: string;
const hasNonAscii = !/^[\x00-\x7F]*$/.test(downloadFileName);
if (hasNonAscii) {
// 包含中文字符使用RFC 2231标准
const encodedFileName = encodeURIComponent(downloadFileName);
// 同时提供fallback和UTF-8编码版本
const fallbackName = downloadFileName.replace(/[^\x00-\x7F]/g, '_');
contentDisposition = `attachment; filename="${fallbackName}"; filename*=UTF-8''${encodedFileName}`;
console.log('Download - Original filename:', downloadFileName);
console.log('Download - Encoded filename:', encodedFileName);
console.log('Download - Fallback filename:', fallbackName);
} else {
// ASCII文件名使用简单格式
contentDisposition = `attachment; filename="${downloadFileName}"`;
}
// 设置所有必要的响应头
c.header('Content-Type', contentType);
c.header('Content-Length', fileSize.toString());
c.header('Content-Disposition', contentDisposition);
// 添加额外的头部以确保浏览器正确处理
c.header('Cache-Control', 'no-cache, no-store, must-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
console.log('Download - Content-Disposition:', contentDisposition);
console.log('=== DOWNLOAD DEBUG END ===');
// 返回文件流 - 使用Hono的正确方式
const fileStream = fs.createReadStream(filePath);
// 将Node.js ReadStream转换为Web Stream
const readableStream = new ReadableStream({
start(controller) {
fileStream.on('data', (chunk) => {
controller.enqueue(chunk);
});
fileStream.on('end', () => {
controller.close();
});
fileStream.on('error', (error) => {
controller.error(error);
});
},
});
return new Response(readableStream, {
status: 200,
headers: {
'Content-Type': contentType,
'Content-Length': fileSize.toString(),
'Content-Disposition': contentDisposition,
'Cache-Control': 'no-cache, no-store, must-revalidate',
Pragma: 'no-cache',
Expires: '0',
},
});
} catch (error) {
console.error('Error reading local file:', error);
return c.json({ error: 'Failed to read file' }, 500);
}
} else if (storageType === StorageType.S3) {
// S3 存储简单重定向让S3处理文件名
const config = storageManager.getStorageConfig();
const s3Config = config.s3!;
// 构建S3 key - 使用fileId和原始文件名
const fileName = resource.title || 'file';
const fullS3Key = `${fileId}/${fileName}`;
console.log('Download - S3 Key:', fullS3Key);
// 生成 S3 URL
let s3Url: string;
if (s3Config.endpoint && s3Config.endpoint !== 'https://s3.amazonaws.com') {
// 自定义 S3 兼容服务
s3Url = `${s3Config.endpoint}/${s3Config.bucket}/${fullS3Key}`;
} else {
// AWS S3
s3Url = `https://${s3Config.bucket}.s3.${s3Config.region}.amazonaws.com/${fullS3Key}`;
}
console.log(`Redirecting to S3 URL: ${s3Url}`);
console.log('=== DOWNLOAD DEBUG END ===');
// 重定向到 S3 URL
return c.redirect(s3Url, 302);
}
return c.json({ error: 'Unsupported storage type' }, 500);
} catch (error) {
console.error('Download error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
return app;
}
/**
* 创建完整的存储应用包含API和上传功能
* @param options 配置选项
* @returns Hono 应用实例
*/
export function createStorageApp(
options: {
apiBasePath?: string;
uploadPath?: string;
downloadPath?: string;
} = {},
) {
const { apiBasePath = '/api/storage', uploadPath = '/upload', downloadPath = '/download' } = options;
const app = new Hono();
// 添加存储API路由
app.route(apiBasePath, createStorageRoutes());
// 添加TUS上传路由
app.route(uploadPath, createTusUploadRoutes());
// 添加文件下载路由
app.route(downloadPath, createFileDownloadRoutes());
return app;
}