585 lines
17 KiB
TypeScript
585 lines
17 KiB
TypeScript
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;
|
||
}
|