mira-app-server
Version:
Mira Server - standalone server application using mira-app-core
999 lines • 61.1 kB
JavaScript
"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.FileRoutes = void 0;
const express_1 = require("express");
const fs = __importStar(require("fs"));
const path = __importStar(require("path"));
const crypto_1 = require("crypto");
const multer_1 = __importDefault(require("multer"));
const permission_1 = require("../middleware/permission");
class FileRoutes {
constructor(backend) {
this.backend = backend;
this.router = (0, express_1.Router)();
this.setupUpload();
this.setupRoutes();
}
setupUpload() {
// 配置multer文件上传
this.upload = (0, multer_1.default)({
storage: multer_1.default.diskStorage({
destination: (req, file, cb) => {
const tempDir = path.join(this.backend.dataPath, 'temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
cb(null, tempDir);
},
filename: (req, file, cb) => {
// 处理中文名,确保文件名为utf8编码
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
cb(null, uniqueSuffix + path.extname(originalName));
}
}),
limits: {
fileSize: 2048 * 1024 * 1024, // 2GB per file
}
});
this.coverUpload = (0, multer_1.default)({
storage: multer_1.default.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 },
});
}
setupRoutes() {
// 上传文件到资源库
this.router.post('/upload', this.upload.array('files'), async (req, res) => {
const { libraryId, sourcePath, fileId } = req.body; // sourcePath是用户的本地文件位置,用来验证是否上传成功
const clientId = req.body.clientId || null;
const isBatchImport = req.body.batchImport === 'true' || req.body.batchImport === true;
const batchId = isBatchImport ? (0, crypto_1.randomUUID)() : undefined;
const urlItems = isBatchImport && req.body.urlItems
? JSON.parse(req.body.urlItems)
: [];
const fields = req.body.fields ? JSON.parse(req.body.fields) : null;
const payload = req.body.payload ? JSON.parse(req.body.payload) : null;
const uploader = req.user?.id || null;
console.log('[Upload] req.user:', req.user, '| uploader:', uploader);
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj)
return res.status(404).send('Library not found');
// 检查是否为更新操作
const isUpdateOperation = fileId && fileId.trim();
let existingFile = null;
if (isUpdateOperation) {
try {
existingFile = await obj.libraryService.getFile(parseInt(fileId));
if (!existingFile) {
return res.status(404).send('File to update not found');
}
}
catch (error) {
return res.status(400).send('Invalid file ID for update operation');
}
}
// 解析上传的文件
const files = req.files || [];
// 如果是更新操作且没有文件,则只更新元数据
if (isUpdateOperation && (!files || !files.length)) {
try {
const { tags, folder_id } = payload?.data || {};
const updateData = {
tags: JSON.stringify(tags || []),
folder_id: folder_id || existingFile.folder_id,
imported_at: Date.now(),
};
if (uploader)
updateData.uploader = uploader;
const { success: updateSuccess, oldData } = await obj.libraryService.updateFile(parseInt(fileId), updateData);
let result = null;
if (updateSuccess) {
result = await obj.libraryService.getFile(parseInt(fileId));
}
const response = {
results: [{
success: updateSuccess,
file: null,
result,
operation: 'metadata_update'
}]
};
// 发送WebSocket事件
if (this.backend.webSocketServer && updateSuccess) {
this.backend.webSocketServer.broadcastPluginEvent('file::updated', {
message: {
type: 'file',
action: 'metadata_update',
fields,
payload
},
result,
libraryId,
fileId: parseInt(fileId)
});
this.backend.webSocketServer?.broadcastLibraryEvent(libraryId, 'file::updated', {
...result,
libraryId,
fileId: parseInt(fileId),
old_data: oldData,
});
if (clientId) {
const ws = this.backend.webSocketServer?.getWsClientById(libraryId, clientId);
ws && this.backend.webSocketServer?.sendToWebsocket(ws, {
eventName: 'file::updated',
data: { fileId: parseInt(fileId), old_data: oldData }
});
}
}
return res.send(response);
}
catch (error) {
console.error('Error updating file metadata:', error);
return res.status(500).send('Internal server error while updating file metadata.');
}
}
// 如果不是更新操作,或者是更新操作但有文件,则需要文件
if ((!files || !files.length) && !urlItems.length)
return res.status(400).send('No files or URLs uploaded.');
try {
const results = [];
let urlBatchId;
const validUrlItems = urlItems.filter((url) => typeof url === 'string' && /^https?:\/\//i.test(url));
if (validUrlItems.length) {
const userId = req.user?.id;
if (!userId || !this.backend.downloadExecutor) {
return res.status(503).send('URL import executor unavailable.');
}
urlBatchId = await this.backend.downloadExecutor.enqueueBatch(validUrlItems
.map((url) => ({
url,
libraryId,
userId,
folderId: payload?.data?.folder_id ? Number(payload.data.folder_id) : null,
clientId,
})));
}
for (const file of files) {
try {
// 生成唯一文件名并保存文件
const originalName = Buffer.from(file.originalname, 'latin1').toString('utf8');
const { tags, folder_id } = payload?.data || {};
let result;
if (isUpdateOperation) {
// 更新操作:更新文件内容和元数据
const updateData = {
name: req.body.name || originalName,
tags: JSON.stringify(tags || []),
folder_id: folder_id || existingFile.folder_id,
size: file.size,
imported_at: Date.now(),
};
if (uploader)
updateData.uploader = uploader;
// 处理物理文件替换
try {
const existingFilePath = await obj.libraryService.getItemFilePath(existingFile);
// 计算新文件的目标路径
const targetDir = await obj.libraryService.getItemPath({ ...existingFile, ...updateData });
const targetPath = path.join(targetDir, updateData.name);
// 确保目标目录存在
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
const isCrossDevice = path.parse(file.path).root.toLowerCase() !== path.parse(targetPath).root.toLowerCase();
if (isCrossDevice) {
console.info('[Upload] Cross-device update, using copy/unlink:', file.path, '->', targetPath);
fs.copyFileSync(file.path, targetPath);
fs.unlinkSync(file.path);
}
else {
if (existingFilePath && fs.existsSync(existingFilePath)) {
fs.unlinkSync(existingFilePath);
}
try {
fs.renameSync(file.path, targetPath);
}
catch (error) {
if (error?.code !== 'EXDEV')
throw error;
console.info('[Upload] Cross-device update, using copy/unlink:', file.path, '->', targetPath);
fs.copyFileSync(file.path, targetPath);
fs.unlinkSync(file.path);
}
}
if (existingFilePath && existingFilePath !== targetPath && fs.existsSync(existingFilePath)) {
fs.unlinkSync(existingFilePath);
}
// 更新数据库中的path字段
updateData.path = targetPath;
}
catch (fileError) {
console.error('File handling error during update:', fileError);
throw new Error('Failed to update file content');
}
// 更新文件记录
const { success: updateSuccess, oldData } = await obj.libraryService.updateFile(parseInt(fileId), updateData);
if (updateSuccess) {
// 获取更新后的文件信息
result = await obj.libraryService.getFile(parseInt(fileId));
}
results.push({
success: updateSuccess,
file: file.path,
result,
operation: 'update'
});
// 发送WebSocket事件
if (this.backend.webSocketServer && updateSuccess) {
this.backend.webSocketServer.broadcastPluginEvent('file::updated', {
message: {
type: 'file',
action: 'update',
fields,
payload
},
result,
libraryId,
fileId: parseInt(fileId)
});
if (clientId) {
const ws = this.backend.webSocketServer?.getWsClientById(libraryId, clientId);
ws && this.backend.webSocketServer?.sendToWebsocket(ws, {
eventName: 'file::updated',
data: { path: sourcePath, fileId: parseInt(fileId), old_data: oldData }
});
this.backend.webSocketServer?.broadcastLibraryEvent(libraryId, 'file::updated', {
...result,
libraryId,
fileId: parseInt(fileId),
old_data: oldData,
});
}
}
}
else {
// 创建操作(原有逻辑)
const fileData = {
name: req.body.name || originalName,
tags: JSON.stringify(tags || []),
folder_id: folder_id || null,
uploader,
};
result = await obj.libraryService.createFileFromPath(file.path, fileData, { importType: 'move' }); // 使用move上传完成后自动删除临时文件
const isDuplicate = result?.duplicate === true;
results.push({
success: !isDuplicate,
file: file.path,
result,
operation: isDuplicate ? 'duplicate' : 'create'
});
// 发送WebSocket事件(命中重复时不广播 file::created,因为并没有新建文件)
if (this.backend.webSocketServer && !isDuplicate) {
const eventData = { ...result, libraryId, ...(isBatchImport ? { batchImport: true, batchId } : {}) };
this.backend.webSocketServer.broadcastPluginEvent('file::created', {
message: {
type: 'file',
action: 'create',
fields, payload
}, result: eventData, libraryId
});
this.backend.webSocketServer?.broadcastLibraryEvent(libraryId, 'file::created', eventData);
}
// 命中重复也向上传方回传 file::uploaded(带 duplicate 标记),让前端能给出"已存在,已跳过"的提示
if (this.backend.webSocketServer && clientId) {
const ws = this.backend.webSocketServer?.getWsClientById(libraryId, clientId);
ws && this.backend.webSocketServer?.sendToWebsocket(ws, {
eventName: 'file::uploaded',
data: isDuplicate ? { path: sourcePath, duplicate: true } : { path: sourcePath }
});
}
}
}
catch (error) {
console.error(`Error processing file ${file.originalname}:`, error);
results.push({
success: false,
file: file.path,
error: error instanceof Error ? error.message : String(error)
});
}
}
res.send(isBatchImport ? {
batchId: batchId || urlBatchId,
urlBatchId,
total: files.length + validUrlItems.length,
results,
} : { results });
}
catch (error) {
console.error('Error uploading files:', error);
res.status(500).send('Internal server error while processing the upload.');
}
});
// 获取文件缩略图
this.router.get('/thumb/:libraryId/:id', async (req, res) => {
try {
const ret = await this.parseLibraryItem(req, res);
if (ret) {
const thumbPath = await ret.library.getItemThumbPath(ret.item, { isNetworkImage: false });
if (!fs.existsSync(thumbPath))
return res.status(404).send('Thumbnail not found');
res.setHeader('Content-Type', 'image/png');
fs.createReadStream(thumbPath).pipe(res);
}
}
catch (err) {
console.error('Error serving thumbnail:', err);
res.status(500).send('Internal server error');
}
});
// 获取容器格式插件暴露的附属文件列表,真实解压路径不对外返回。
this.router.get('/extra/:libraryId/:fileId', async (req, res) => {
try {
if (!this.canAccessExtraFileLibrary(req))
return res.status(403).json({ code: 403, message: 'Access denied' });
const ret = await this.resolveExtraFileSource(req.params.libraryId, req.params.fileId);
if (!ret)
return res.status(404).json({ code: 404, message: 'File not found' });
const files = await ret.pluginManager.getExtraFileList(ret.filePath, {
libraryId: req.params.libraryId,
fileId: req.params.fileId,
});
if (!files)
return res.status(404).json({ code: 404, message: 'File format has no extra files' });
res.json({ code: 0, data: files });
}
catch (error) {
console.error('Error listing extra files:', error);
res.status(400).json({ code: 400, message: error instanceof Error ? error.message : 'Failed to list extra files' });
}
});
// 文件名由格式插件解析;这里只流式返回插件确认过的临时文件。
this.router.get('/extra/:libraryId/:fileId/*', async (req, res) => {
try {
if (!this.canAccessExtraFileLibrary(req))
return res.status(403).send('Access denied');
const ret = await this.resolveExtraFileSource(req.params.libraryId, req.params.fileId);
if (!ret)
return res.status(404).send('File not found');
const fileName = req.params[0];
if (!fileName)
return res.status(400).send('Extra file name is required');
const extraPath = await ret.pluginManager.getExtraFile(ret.filePath, fileName, {
libraryId: req.params.libraryId,
fileId: req.params.fileId,
});
if (!extraPath || !fs.existsSync(extraPath))
return res.status(404).send('Extra file not found');
const stat = await fs.promises.stat(extraPath);
if (!stat.isFile())
return res.status(404).send('Extra file not found');
res.setHeader('Content-Type', this.getContentType(path.extname(extraPath).toLowerCase()));
res.setHeader('Content-Length', stat.size);
res.setHeader('Cache-Control', 'private, max-age=86400');
fs.createReadStream(extraPath).pipe(res);
}
catch (error) {
console.error('Error serving extra file:', error);
res.status(400).send(error instanceof Error ? error.message : 'Failed to serve extra file');
}
});
// 覆盖素材缩略图;客户端裁切组件统一导出 PNG。
this.router.post('/cover/:libraryId/:id', this.coverUpload.single('cover'), async (req, res) => {
try {
const { libraryId, id } = req.params;
const fileId = Number(id);
const cover = req.file;
if (!Number.isInteger(fileId) || fileId <= 0 || !cover) {
return res.status(400).json({ code: 400, message: 'Valid file id and cover are required' });
}
if (!cover.buffer.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) {
return res.status(400).json({ code: 400, message: 'Cover must be a PNG image' });
}
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj)
return res.status(404).json({ code: 404, message: 'Library not found' });
const item = await obj.libraryService.getFile(fileId);
if (!item)
return res.status(404).json({ code: 404, message: 'File not found' });
const thumbPath = await obj.libraryService.getItemThumbPath(item, { isNetworkImage: false });
await fs.promises.mkdir(path.dirname(thumbPath), { recursive: true });
await fs.promises.writeFile(thumbPath, cover.buffer);
await obj.libraryService.updateFile(fileId, { thumb: 1 });
const result = await obj.libraryService.getFile(fileId);
result.thumb = await obj.libraryService.getItemThumbPath(result, { isUrlFile: true });
this.backend.webSocketServer?.broadcastLibraryEvent(libraryId, 'thumbnail::generated', result);
res.json({ code: 0, message: 'Cover updated', data: result });
}
catch (error) {
console.error('Error setting file cover:', error);
res.status(500).json({ code: 500, message: 'Failed to set cover' });
}
});
// JIT HLS 播放列表:只描述时间轴,分片在播放器请求时生成
this.router.get('/preview/:libraryId/:fileId/index.m3u8', async (req, res) => {
try {
const { libraryId, fileId } = req.params;
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj)
return res.status(404).send('Library not found');
const item = await obj.libraryService.getFile(parseInt(fileId, 10));
if (!item)
return res.status(404).send('File not found');
const filePath = await obj.libraryService.getItemFilePath(item);
if (!filePath || !fs.existsSync(filePath))
return res.status(404).send('File not found');
const tokenQuery = typeof req.query.token === 'string'
? `?token=${encodeURIComponent(req.query.token)}`
: '';
const playlist = await this.backend.thumbnailService.getHlsPlaylist(filePath, path.join(this.backend.dataPath, 'temp', 'previews'), `${libraryId}:${fileId}`, tokenQuery);
res.setHeader('Content-Type', 'application/vnd.apple.mpegurl');
res.setHeader('Cache-Control', 'private, max-age=3600');
res.send(playlist);
}
catch (err) {
console.error('Error generating HLS playlist:', err);
res.status(500).send('HLS playlist generation failed');
}
});
// JIT HLS 分片:按进度条请求的序号转换,完成后从 temp 缓存流式返回
this.router.get('/preview/:libraryId/:fileId/segment/:segment.ts', async (req, res) => {
try {
const { libraryId, fileId, segment } = req.params;
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj)
return res.status(404).send('Library not found');
const item = await obj.libraryService.getFile(parseInt(fileId, 10));
if (!item)
return res.status(404).send('File not found');
const filePath = await obj.libraryService.getItemFilePath(item);
if (!filePath || !fs.existsSync(filePath))
return res.status(404).send('File not found');
const segmentPath = await this.backend.thumbnailService.getHlsSegmentPath(filePath, path.join(this.backend.dataPath, 'temp', 'previews'), `${libraryId}:${fileId}`, Number(segment));
const stat = await fs.promises.stat(segmentPath);
res.setHeader('Content-Type', 'video/mp2t');
res.setHeader('Content-Length', stat.size);
res.setHeader('Cache-Control', 'private, max-age=86400');
fs.createReadStream(segmentPath).pipe(res);
}
catch (err) {
if (err instanceof RangeError)
return res.status(400).send(err.message);
console.error('Error generating HLS segment:', err);
res.status(500).send('HLS segment generation failed');
}
});
// 按需生成浏览器可展示的大图,结果只缓存到服务端 temp 目录
this.router.get('/preview/:libraryId/:fileId', async (req, res) => {
try {
const { libraryId, fileId } = req.params;
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj)
return res.status(404).send('Library not found');
const item = await obj.libraryService.getFile(parseInt(fileId, 10));
if (!item)
return res.status(404).send('File not found');
const filePath = await obj.libraryService.getItemFilePath(item);
if (!filePath || !fs.existsSync(filePath))
return res.status(404).send('File not found');
const previewPath = await this.backend.thumbnailService.getPreviewPath(filePath, path.join(this.backend.dataPath, 'temp', 'previews'), `${libraryId}:${fileId}`);
const stat = await fs.promises.stat(previewPath);
res.setHeader('Content-Type', 'image/webp');
res.setHeader('Content-Length', stat.size);
res.setHeader('Cache-Control', 'private, max-age=86400');
fs.createReadStream(previewPath).on('error', (error) => {
console.error('Preview stream error:', error);
if (!res.headersSent)
res.status(500).send('Preview stream error');
}).pipe(res);
}
catch (err) {
console.error('Error generating preview:', err);
res.status(500).send('Preview generation failed');
}
});
// 获取文件内容 - 支持 Range 请求
this.router.get('/file/:libraryId/:id', async (req, res) => {
const ret = await this.parseLibraryItem(req, res);
if (ret) {
const filePath = await ret.library.getItemFilePath(ret.item);
if (!filePath || !fs.existsSync(filePath)) {
return res.status(404).send('File not found');
}
const fileExt = path.extname(filePath).toLowerCase();
const contentType = this.getContentType(fileExt);
// 获取文件大小
const stats = fs.statSync(filePath);
const fileSize = stats.size;
// 添加文件名到响应头
const fileName = ret.item.name || path.basename(filePath);
res.setHeader('X-File-Name', encodeURIComponent(fileName));
// 检查是否为 Range 请求
const range = req.headers.range;
if (range) {
// 处理 Range 请求
// Processing Range request
// 解析 Range 头: "bytes=start-end"
const matches = range.match(/bytes=(\d*)-(\d*)/);
if (!matches) {
return res.status(416).send('Invalid Range header format');
}
const start = matches[1] ? parseInt(matches[1], 10) : 0;
const end = matches[2] ? parseInt(matches[2], 10) : fileSize - 1;
// 验证范围
if (start >= fileSize || end >= fileSize || start > end) {
res.setHeader('Content-Range', `bytes */${fileSize}`);
return res.status(416).send('Range Not Satisfiable');
}
const chunkSize = (end - start) + 1;
// 设置 206 Partial Content 响应头
res.status(206);
res.setHeader('Content-Range', `bytes ${start}-${end}/${fileSize}`);
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Length', chunkSize);
res.setHeader('Content-Type', contentType);
res.setHeader('Cache-Control', 'public, max-age=3600');
// 创建指定范围的文件流
const stream = fs.createReadStream(filePath, { start, end });
// 处理流错误
stream.on('error', (error) => {
console.error('Stream error:', error);
if (!res.headersSent) {
res.status(500).send('Stream error');
}
});
stream.pipe(res);
// Range request served
}
else {
// 非 Range 请求,返回完整文件
res.setHeader('Accept-Ranges', 'bytes');
res.setHeader('Content-Type', contentType);
res.setHeader('Content-Length', fileSize);
res.setHeader('Cache-Control', 'public, max-age=3600');
const disposition = contentType === 'application/pdf' ? 'inline' : 'attachment';
res.setHeader('Content-Disposition', `${disposition}; filename="${encodeURIComponent(fileName)}"`);
// 创建完整文件流
const stream = fs.createReadStream(filePath);
// 处理流错误
stream.on('error', (error) => {
console.error('Stream error:', error);
if (!res.headersSent) {
res.status(500).send('Stream error');
}
});
stream.pipe(res);
// Full file served
}
}
});
// 清空回收站(需要 admin 权限)
this.router.delete('/:libraryId/trash', async (req, res) => {
try {
const user = req.user;
if (!user || (user.role !== 'admin' && user.role !== 'super')) {
return res.status(403).json({
success: false,
error: '权限不足,清空回收站需要管理员权限',
});
}
const { libraryId } = req.params;
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj) {
return res.status(404).json({
success: false,
error: 'Library not found',
libraryId
});
}
const result = await obj.libraryService.emptyTrash();
// 广播 WebSocket 事件
if (this.backend.webSocketServer) {
this.backend.webSocketServer.broadcastLibraryEvent(libraryId, 'files::trash-emptied', {
libraryId,
deletedCount: result.deletedCount
});
}
res.json({
success: true,
message: `清空回收站完成,删除 ${result.deletedCount} 个文件`,
deletedCount: result.deletedCount,
errors: result.errors.length > 0 ? result.errors : undefined
});
}
catch (error) {
console.error('Error emptying trash:', error);
res.status(500).json({
success: false,
error: 'Internal server error while emptying trash',
details: error instanceof Error ? error.message : String(error)
});
}
});
// 恢复文件(从回收站还原)
this.router.post('/recover', async (req, res) => {
try {
const { libraryId, fileId } = req.body;
if (!libraryId || !fileId) {
return res.status(400).json({
code: 400,
message: 'libraryId and fileId are required',
data: null
});
}
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj) {
return res.status(404).json({
code: 404,
message: 'Library not found',
data: null
});
}
// 获取恢复前的文件信息(用于事件携带 folder_id)
const before = await obj.libraryService.getFile(parseInt(fileId));
const success = await obj.libraryService.recoverFile(parseInt(fileId));
if (!success) {
return res.status(500).json({
code: 500,
message: 'Failed to recover file',
data: null
});
}
const result = await obj.libraryService.getFile(parseInt(fileId));
const response = {
success: true,
message: 'File recovered successfully',
recoveredFile: {
id: parseInt(fileId),
name: result?.name,
libraryId,
folderId: result?.folder_id ?? null,
recoveredAt: new Date().toISOString()
}
};
// 发送 WebSocket 事件
this.broadcastFileEvent('file::recovered', libraryId, result, parseInt(fileId), before ? { folder_id: before.folder_id, recycled: 1 } : undefined);
// 标记 recovered,便于回收站 tab 精确匹配刷新
if (this.backend.webSocketServer) {
this.backend.webSocketServer.broadcastLibraryEvent(libraryId, 'file::recovered', { recovered: 1, libraryId, fileId: parseInt(fileId) });
}
res.json(response);
}
catch (error) {
console.error('Error recovering file:', error);
res.status(500).json({
code: 500,
message: 'Internal server error while recovering file',
details: error instanceof Error ? error.message : String(error)
});
}
});
// 删除文件
this.router.delete('/:libraryId/:id', async (req, res) => {
try {
const { libraryId, id } = req.params;
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj) {
return res.status(404).json({
success: false,
error: 'Library not found',
libraryId
});
}
// 获取文件信息
const item = await obj.libraryService.getFile(parseInt(id));
if (!item) {
return res.status(404).json({
success: false,
error: 'File not found',
libraryId,
fileId: id
});
}
// 获取文件路径
const filePath = await obj.libraryService.getItemFilePath(item);
// 读取 moveToRecycleBin 参数,默认 true(移到回收站)
const moveToRecycleBin = req.query.moveToRecycleBin !== 'false';
// 删除数据库记录
const deleteSuccess = await obj.libraryService.deleteFile(parseInt(id), { moveToRecycleBin });
if (!deleteSuccess) {
return res.status(500).json({
success: false,
error: 'Failed to delete file from database',
libraryId,
fileId: id
});
}
// 仅在彻底删除时才删除物理文件和缩略图
if (!moveToRecycleBin) {
if (filePath && fs.existsSync(filePath)) {
try {
fs.unlinkSync(filePath);
console.log(`Physical file deleted: ${filePath}`);
}
catch (fileError) {
console.error(`Error deleting physical file ${filePath}:`, fileError);
}
}
try {
const thumbPath = await obj.libraryService.getItemThumbPath(item, { isNetworkImage: false });
if (thumbPath && fs.existsSync(thumbPath)) {
fs.unlinkSync(thumbPath);
console.log(`Thumbnail deleted: ${thumbPath}`);
}
}
catch (thumbError) {
console.error(`Error deleting thumbnail:`, thumbError);
}
}
const response = {
success: true,
message: 'File deleted successfully',
deletedFile: {
id: parseInt(id),
name: item.name,
libraryId: libraryId,
deletedAt: new Date().toISOString()
}
};
// 发送WebSocket事件
if (this.backend.webSocketServer) {
this.backend.webSocketServer.broadcastPluginEvent('file::deleted', {
message: {
type: 'file',
action: 'delete'
},
result: response.deletedFile,
libraryId,
fileId: parseInt(id)
});
this.backend.webSocketServer.broadcastLibraryEvent(libraryId, 'file::deleted', {
...response.deletedFile,
libraryId,
fileId: parseInt(id)
});
}
res.json(response);
}
catch (error) {
console.error('Error deleting file:', error);
res.status(500).json({
success: false,
error: 'Internal server error while deleting file',
details: error instanceof Error ? error.message : String(error)
});
}
});
// 批量获取文件 metadata(瀑布流等布局只需要宽高时使用)
this.router.post('/metadata', async (req, res) => {
try {
const { libraryId, ids, clientId } = req.body;
if (!libraryId || !Array.isArray(ids)) {
return res.status(400).json({ code: 400, message: 'libraryId and ids are required', data: null });
}
if (ids.length > 1000) {
return res.status(400).json({ code: 400, message: 'ids cannot contain more than 1000 items', data: null });
}
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj)
return res.status(404).json({ code: 404, message: 'Library not found', data: null });
const allowed = await obj.pluginManager.runHttpHooks({
libraryId,
clientId,
method: req.method,
path: '/api/files/metadata',
req,
res,
fields: this.backend.webSocketServer?.getClientFields(libraryId, clientId),
});
if (!allowed || res.headersSent)
return;
const fileIds = [...new Set(ids.map((id) => Number(id)).filter(Number.isSafeInteger))];
const files = await Promise.all(fileIds.map(id => obj.libraryService.getFile(id)));
const data = files.filter(Boolean).map(file => {
const metadata = file.metadata && typeof file.metadata === 'object' ? file.metadata : undefined;
const width = Number(metadata?.width);
const height = Number(metadata?.height);
return {
id: String(file.id),
metadata,
...(Number.isFinite(width) && width > 0 ? { width } : {}),
...(Number.isFinite(height) && height > 0 ? { height } : {}),
};
});
res.json({ code: 0, message: 'Success', data });
}
catch (error) {
console.error('Error getting file metadata:', error);
res.status(500).json({ code: 500, message: 'Internal server error while getting file metadata', data: null });
}
});
// 获取文件列表 - 支持过滤参数
this.router.post('/getFiles', async (req, res) => {
try {
const { libraryId, filters = {}, clientId } = req.body;
if (!libraryId) {
return res.status(400).json({
code: 400,
message: 'Library ID is required',
data: null
});
}
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj) {
return res.status(404).json({
code: 404,
message: 'Library not found',
data: null
});
}
const allowed = await obj.pluginManager.runHttpHooks({
libraryId,
clientId,
method: req.method,
path: '/api/files/getFiles',
req,
res,
fields: this.backend.webSocketServer?.getClientFields(libraryId, clientId),
});
if (!allowed || res.headersSent)
return;
const files = await obj.libraryService.getFiles({
filters: filters,
isUrlFile: true,
});
res.json({
code: 0,
message: 'Success',
data: files
});
}
catch (error) {
console.error('Error getting files:', error);
res.status(500).json({
code: 500,
message: 'Internal server error while getting files',
data: null
});
}
});
// 获取单个文件信息
this.router.post('/getFile', async (req, res) => {
try {
const { libraryId, fileId, clientId } = req.body;
if (!libraryId) {
return res.status(400).json({
code: 400,
message: 'Library ID is required',
data: null
});
}
if (!fileId) {
return res.status(400).json({
code: 400,
message: 'File ID is required',
data: null
});
}
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj) {
return res.status(404).json({
code: 404,
message: 'Library not found',
data: null
});
}
const allowed = await obj.pluginManager.runHttpHooks({
libraryId,
clientId,
method: req.method,
path: '/api/files/getFile',
req,
res,
fields: this.backend.webSocketServer?.getClientFields(libraryId, clientId),
});
if (!allowed || res.headersSent)
return;
const file = await obj.libraryService.getFile(parseInt(fileId));
if (!file) {
return res.status(404).json({
code: 404,
message: 'File not found',
data: null
});
}
res.json({
code: 0,
message: 'Success',
data: file
});
}
catch (error) {
console.error('Error getting file:', error);
res.status(500).json({
code: 500,
message: 'Internal server error while getting file',
data: null
});
}
});
// 获取文件可用的全部插件 iframe Viewer
this.router.post('/getPreviewViewers', async (req, res) => {
try {
const { libraryId, fileId, clientId } = req.body;
if (!libraryId || !fileId) {
return res.status(400).json({
code: 400,
message: 'libraryId and fileId are required',
data: null
});
}
const obj = this.backend.libraries.getLibrary(libraryId);
if (!obj) {
return res.status(404).json({ code: 404, message: 'Library not found', data: null });
}
const allowed = await obj.pluginManager.runHttpHooks({
libraryId,
clientId,
method: req.method,
path: '/api/files/getPreviewViewers',
req,
res,
fields: this.backend.webSocketServer?.getClientFields(libraryId, clientId),
});
if (!allowed || res.headersSent)
return;
const file = await obj.libraryService.getFile(parseInt(fileId, 10));
if (!file) {
return res.status(404).json({ code: 404, message: 'File not found', data: null });
}
const filePath = await obj.libraryService.getItemFilePath(file, { isUrlFile: false });
const requestToken = req.headers.authorization?.replace(/^Bearer\s+/i, '') ||
(typeof req.query.token === 'string' ? req.query.token : '');
const fileUrl = this.appendToken(await obj.libraryService.getItemFilePath(file, { isUrlFile: true }), requestToken);
const viewers = await obj.pluginManager.getPreviewViewers({
libraryId: String(libraryId),
fileId: String(fileId),
file: { ...file, libraryId },
filePath,
fileUrl,
getExtraFileUrl: (fileName) => this.buildExtraFileUrl(fileUrl, libraryId, fileId, fileName, requestToken),
});
res.json({
code