mira-app-server
Version:
Mira Server - standalone server application using mira-app-core
427 lines • 19 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.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