UNPKG

mira-app-server

Version:

Mira Server - standalone server application using mira-app-core

427 lines 19 kB
"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.FsRouter = void 0; const express_1 = require("express"); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const fast_glob_1 = __importDefault(require("fast-glob")); const archiver_1 = require("archiver"); const SyncFilter_1 = require("../sync/SyncFilter"); class FsRouter { constructor(backend) { this.backend = backend; this.router = (0, express_1.Router)(); this.setupRoutes(); } getLibraryPath(libraryId) { if (!this.backend?.libraries) return null; const lib = this.backend.libraries.getLibrary(libraryId); if (!lib?.libraryService) return null; return lib.libraryService.config.customFields?.path || lib.libraryService.config.path || null; } setupRoutes() { // 创建目录 this.router.post('/mkdir', async (req, res) => { try { const { path: parentPath, name } = req.body; if (!parentPath || !name) { res.status(400).json({ error: 'path and name are required' }); return; } const newPath = path.join(parentPath, name); await fs.promises.mkdir(newPath); res.json({ label: name, value: newPath, isLeaf: false }); } catch (error) { res.status(500).json({ error: error.message || 'Failed to create directory' }); } }); // 列出目录(仅目录,供 PathTreeSelect 使用) this.router.get('/dirs', async (req, res) => { try { const dirPath = req.query.path; if (!dirPath) { if (process.platform === 'win32') { const drives = []; for (let i = 65; i <= 90; i++) { const drive = `${String.fromCharCode(i)}:\\`; try { fs.accessSync(drive); drives.push({ label: drive, value: drive, isLeaf: false }); } catch { /* drive not available */ } } res.json(drives); return; } const rootEntries = await fs.promises.readdir('/', { withFileTypes: true }); const dirs = rootEntries .filter(e => e.isDirectory() && !e.name.startsWith('.')) .map(e => ({ label: e.name, value: `/${e.name}`, isLeaf: false })) .sort((a, b) => a.label.localeCompare(b.label)); res.json(dirs); return; } const entries = await fs.promises.readdir(dirPath, { withFileTypes: true }); const dirs = entries .filter(e => e.isDirectory() && !e.name.startsWith('.')) .map(e => ({ label: e.name, value: path.join(dirPath, e.name), isLeaf: false })) .sort((a, b) => a.label.localeCompare(b.label)); res.json(dirs); } catch (error) { res.status(500).json({ error: error.message || 'Failed to list directories' }); } }); // 列出文件和目录(带分页,供文件管理器使用) this.router.get('/list', async (req, res) => { try { const libraryId = req.query.libraryId; const relativePath = req.query.path || ''; const offset = parseInt(req.query.offset) || 0; const limit = parseInt(req.query.limit) || 50; const basePath = this.getLibraryPath(libraryId); if (!basePath) { res.status(400).json({ error: 'Invalid libraryId or library path not configured' }); return; } const targetPath = relativePath ? path.join(basePath, relativePath) : basePath; // 安全检查:防止路径穿越 const resolved = path.resolve(targetPath); if (!resolved.startsWith(path.resolve(basePath))) { res.status(403).json({ error: 'Access denied' }); return; } const stat = await fs.promises.stat(resolved); if (!stat.isDirectory()) { res.status(400).json({ error: 'Path is not a directory' }); return; } const entries = await fs.promises.readdir(resolved, { withFileTypes: true }); const items = []; for (const entry of entries) { if (entry.name.startsWith('.')) continue; const fullPath = path.join(resolved, entry.name); try { const entryStat = await fs.promises.stat(fullPath); items.push({ name: entry.name, path: relativePath ? path.join(relativePath, entry.name) : entry.name, isDir: entry.isDirectory(), size: entryStat.size, modified: entryStat.mtime.toISOString(), extension: entry.isDirectory() ? undefined : path.extname(entry.name).toLowerCase(), }); } catch { /* skip inaccessible files */ } } // 目录优先,然后按名称排序 items.sort((a, b) => { if (a.isDir !== b.isDir) return a.isDir ? -1 : 1; return a.name.localeCompare(b.name); }); const total = items.length; const paged = items.slice(offset, offset + limit); res.json({ items: paged, total, offset, limit, hasMore: offset + limit < total, }); } catch (error) { res.status(500).json({ error: error.message || 'Failed to list files' }); } }); // 移动文件/文件夹 this.router.post('/move', async (req, res) => { try { const { libraryId, source, destination } = req.body; if (!libraryId || !source || !destination) { res.status(400).json({ error: 'libraryId, source and destination are required' }); return; } const basePath = this.getLibraryPath(libraryId); if (!basePath) { res.status(400).json({ error: 'Invalid libraryId' }); return; } const sourcePath = path.resolve(path.join(basePath, source)); const destPath = path.resolve(destination); if (!sourcePath.startsWith(path.resolve(basePath))) { res.status(403).json({ error: 'Source path access denied' }); return; } if (!await fileExists(sourcePath)) { res.status(404).json({ error: 'Source not found' }); return; } await fs.promises.rename(sourcePath, path.join(destPath, path.basename(sourcePath))); res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message || 'Failed to move' }); } }); // 删除文件/文件夹 this.router.post('/remove', async (req, res) => { try { const { libraryId, paths } = req.body; if (!libraryId || !paths?.length) { res.status(400).json({ error: 'libraryId and paths are required' }); return; } const basePath = this.getLibraryPath(libraryId); if (!basePath) { res.status(400).json({ error: 'Invalid libraryId' }); return; } for (const relativePath of paths) { const fullPath = path.resolve(path.join(basePath, relativePath)); if (!fullPath.startsWith(path.resolve(basePath))) { continue; } const stat = await fs.promises.stat(fullPath); if (stat.isDirectory()) { await fs.promises.rm(fullPath, { recursive: true }); } else { await fs.promises.unlink(fullPath); } } res.json({ success: true }); } catch (error) { res.status(500).json({ error: error.message || 'Failed to delete' }); } }); // 同步:对比磁盘文件与数据库记录 this.router.post('/sync', async (req, res) => { try { const { libraryId } = req.body; if (!libraryId) { res.status(400).json({ error: 'libraryId is required' }); return; } const lib = this.backend?.libraries?.getLibrary(libraryId); const dbService = lib?.libraryService; const libraryPath = this.getLibraryPath(libraryId); if (!dbService || !libraryPath) { res.status(400).json({ error: 'Invalid libraryId or library not active' }); return; } const customFields = dbService.config.customFields; const result = await this.syncLibrary(libraryPath, dbService, customFields); res.json({ success: true, data: result }); } catch (error) { res.status(500).json({ error: error.message || 'Failed to sync' }); } }); // 批量下载:单文件直接下原文件,多文件/含目录打包成 zip this.router.post('/download', async (req, res) => { try { const { libraryId, paths } = req.body; if (!libraryId || !Array.isArray(paths) || paths.length === 0) { res.status(400).json({ error: 'libraryId and paths are required' }); return; } const basePath = this.getLibraryPath(libraryId); if (!basePath) { res.status(400).json({ error: 'Invalid libraryId' }); return; } const resolvedBase = path.resolve(basePath); // 解析为绝对路径,严格校验路径穿越 const safe = []; for (const rel of paths) { const full = path.resolve(path.join(resolvedBase, rel)); if (full.startsWith(resolvedBase) && fs.existsSync(full)) { safe.push(full); } } if (safe.length === 0) { res.status(400).json({ error: 'No valid files to download' }); return; } // 单文件:直接流式下载原文件 if (safe.length === 1) { const filePath = safe[0]; const stat = await fs.promises.stat(filePath); if (stat.isFile()) { const fileName = path.basename(filePath); res.setHeader('Content-Type', 'application/octet-stream'); res.setHeader('Content-Length', stat.size); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${encodeURIComponent(fileName)}`); fs.createReadStream(filePath).on('error', (err) => { if (!res.headersSent) res.status(500).json({ error: err.message }); }).pipe(res); return; } } // 多文件/含目录:流式打包 zip const zipName = encodeURIComponent('download.zip'); res.setHeader('Content-Type', 'application/zip'); res.setHeader('Content-Disposition', `attachment; filename*=UTF-8''${zipName}`); const archive = new archiver_1.ZipArchive({ zlib: { level: 5 } }); archive.on('error', (err) => { if (!res.headersSent) res.status(500).json({ error: err.message }); else res.end(); }); archive.on('warning', (err) => { if (err?.code !== 'ENOENT') console.warn('archiver warning:', err); }); archive.pipe(res); for (const full of safe) { const rel = path.relative(resolvedBase, full); const stat = await fs.promises.stat(full); if (stat.isDirectory()) { archive.directory(full, rel); } else { archive.file(full, { name: rel }); } } archive.finalize(); } catch (error) { if (!res.headersSent) res.status(500).json({ error: error.message || 'Failed to download' }); } }); } async scanDiskFiles(libraryPath, customFields) { // fast-glob 的 ignore 只能表达「排除」,无法表达白名单的「强制包含」。 // 因此先用默认 + 用户黑名单做粗筛(让 fast-glob 少跑文件), // 再用 createSyncFilter() 的 shouldSync 做一次精确判定,应用白名单覆盖。 const ignore = (0, SyncFilter_1.getIgnoreGlobs)(customFields); const shouldSync = (0, SyncFilter_1.createSyncFilter)(customFields); const entries = await (0, fast_glob_1.default)('**/*', { cwd: libraryPath, absolute: true, dot: false, ignore, onlyFiles: true, suppressErrors: true, }); // 过滤掉空文件,并应用白名单覆盖(shouldSync) return entries.filter((p) => { try { if (fs.statSync(p).size === 0) return false; } catch { return false; } const rel = path.relative(libraryPath, p).replace(/\\/g, '/'); return shouldSync(rel); }); } async syncLibrary(libraryPath, dbService, customFields) { const diskFiles = await this.scanDiskFiles(libraryPath, customFields); const diskSet = new Set(diskFiles); // 直接用 SQL 查询,避免 getFiles 的 processingFiles 处理 const rows = await dbService.getSql('SELECT id, path FROM files', []); const dbPathSet = new Set(rows.map(r => r.path)); let added = 0; let removed = 0; // 移除数据库中不存在于磁盘的记录 for (const row of rows) { if (!diskSet.has(row.path)) { await dbService.deleteFile(row.id); removed++; } } // 添加磁盘中存在但数据库中没有的文件 for (const filePath of diskFiles) { if (!dbPathSet.has(filePath)) { const fileData = {}; const folderId = await this.resolveFolder(filePath, libraryPath, dbService); if (folderId) fileData.folder_id = folderId; await dbService.createFileFromPath(filePath, fileData, { importType: 'link' }); added++; } } return { scanned: diskFiles.length, added, removed }; } async resolveFolder(filePath, libraryPath, dbService) { const rel = path.relative(libraryPath, path.dirname(filePath)); if (!rel) return null; const parts = rel.replace(/\\/g, '/').split('/'); let parentId = null; for (const part of parts) { if (!part) continue; let folder = await dbService.findFolderByName(part, parentId); if (!folder) { const id = await dbService.createFolder({ title: part, parent_id: parentId, color: 0, icon: '', }); folder = { id }; } parentId = folder.id; } return parentId; } getRouter() { return this.router; } } exports.FsRouter = FsRouter; async function fileExists(p) { try { await fs.promises.access(p); return true; } catch { return false; } } //# sourceMappingURL=FsRouter.js.map