UNPKG

mira-app-server

Version:

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

453 lines 21.8 kB
"use strict"; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.MiraHttpServer = void 0; const http_1 = __importDefault(require("http")); const express_1 = __importDefault(require("express")); const fs_1 = __importDefault(require("fs")); const path_1 = __importDefault(require("path")); const axios_1 = __importDefault(require("axios")); const AuthRouter_1 = require("./routes/AuthRouter"); const UserRouter_1 = require("./routes/UserRouter"); const LibraryRoutes_1 = require("./routes/LibraryRoutes"); const AdminsRouter_1 = require("./routes/AdminsRouter"); const HttpRouter_1 = require("./routes/HttpRouter"); const PluginRoutes_1 = require("./routes/PluginRoutes"); const DatabaseRoutes_1 = require("./routes/DatabaseRoutes"); const FileRoutes_1 = require("./routes/FileRoutes"); const DeviceRoutes_1 = require("./routes/DeviceRoutes"); const TagRouter_1 = require("./routes/TagRouter"); const FolderRouter_1 = require("./routes/FolderRouter"); const FsRouter_1 = require("./routes/FsRouter"); const SettingsRouter_1 = require("./routes/SettingsRouter"); const CookieSitesRouter_1 = require("./routes/CookieSitesRouter"); const DownloadRoutes_1 = require("./routes/DownloadRoutes"); const StatisticsRouter_1 = require("./routes/StatisticsRouter"); const ThumbRouter_1 = require("./routes/ThumbRouter"); const permission_1 = require("./middleware/permission"); const LogRingBuffer_1 = require("./services/LogRingBuffer"); // 日志忽略列表:匹配这些前缀的路径完全不输出日志 const HTTP_LOG_IGNORE_PREFIXES = [ '/api/health', '/api/logs/stream', ]; // 日志数据截断:序列化后超 MAX_LOG_CHARS 字符则截断并标注原始长度 const MAX_LOG_CHARS = 500; function truncateLogData(data) { let text; try { text = typeof data === 'string' ? data : JSON.stringify(data); } catch { text = String(data); } if (text.length > MAX_LOG_CHARS) { return `${text.slice(0, MAX_LOG_CHARS)}... (${text.length} chars, truncated)`; } return data; } function createHttpLoggerMiddleware() { // 记录上一次请求的地址,用于抑制无 body 的重复请求刷屏 let lastUrl = ''; return (req, res, next) => { const startTime = Date.now(); const timestamp = new Date().toISOString(); // 记录请求信息(body 延迟到 res.finish 时读取,避免在 express.json 解析前快照到空值) const requestData = { method: req.method, url: req.originalUrl || req.url, headers: req.headers, query: req.query, params: req.params, body: undefined, ip: req.ip || req.connection.remoteAddress || 'unknown', userAgent: req.get('User-Agent') || 'unknown', timestamp }; // 命中忽略列表:直接跳过日志 const ignored = HTTP_LOG_IGNORE_PREFIXES.some(prefix => requestData.url.startsWith(prefix)); // 仅在未被忽略时拦截响应体(避免无谓的开销) const originalSend = ignored ? null : res.send; const originalJson = ignored ? null : res.json; let responseBody = null; if (!ignored) { // 重写 send 方法 res.send = function (data) { responseBody = data; return originalSend.call(this, data); }; // 重写 json 方法 res.json = function (data) { responseBody = data; return originalJson.call(this, data); }; } // 监听响应完成 res.on('finish', () => { if (ignored) return; const responseTime = Date.now() - startTime; const statusCode = res.statusCode; // 此时 express.json 已执行,req.body 是解析后的对象;保持引用同步 requestData.body = req.body; const hasBody = !!(requestData.body && typeof requestData.body === 'object' && Object.keys(requestData.body).length > 0); if (hasBody) { // 有 body:输出较完整的信息 console.log(`🔗 ${requestData.method.toUpperCase()} ${requestData.url}`); if (Object.keys(requestData.query).length > 0) { console.log(`❓ Query Parameters:`, requestData.query); } if (Object.keys(requestData.params).length > 0) { console.log(`📍 Route Parameters:`, requestData.params); } console.log(`📦 Request Body:`, truncateLogData(requestData.body)); } else if (requestData.url !== lastUrl) { // 无 body 且地址与上次不同:仅输出合并的一行(请求行 + 状态 + 耗时) console.log(`🔗 ${requestData.method.toUpperCase()} ${requestData.url} [${statusCode}] ${responseTime}ms`); } // 无 body 且地址与上次一致:不输出任何请求信息 lastUrl = requestData.url; // 有 body 的请求额外输出响应体(超长截断) if (hasBody && responseBody != null) { const truncated = truncateLogData(responseBody); if (typeof truncated === 'string') { console.log(`📤 Response: ${truncated}`); } else { console.log(`📤 Response:`, truncated); } } }); next(); }; } class MiraHttpServer { constructor(backend, dataDir = './data') { this.backend = backend; this.app = (0, express_1.default)(); this.httpServer = http_1.default.createServer(this.app); this.authRouter = new AuthRouter_1.AuthRouter(dataDir); this.userRouter = new UserRouter_1.UserRouter(this.authRouter, dataDir); this.adminsRouter = new AdminsRouter_1.AdminsRouter(this.authRouter); this.libraryRoutes = new LibraryRoutes_1.LibraryRoutes(backend); this.pluginRoutes = new PluginRoutes_1.PluginRoutes(backend); this.databaseRoutes = new DatabaseRoutes_1.DatabaseRoutes(backend); this.fileRoutes = new FileRoutes_1.FileRoutes(backend); this.deviceRoutes = new DeviceRoutes_1.DeviceRoutes(backend); this.tagRouter = new TagRouter_1.TagRouter(backend); this.folderRouter = new FolderRouter_1.FolderRouter(backend); this.fsRouter = new FsRouter_1.FsRouter(backend); this.httpRouter = new HttpRouter_1.HttpRouter(backend); this.settingsRouter = new SettingsRouter_1.SettingsRouter(backend, this.authRouter); this.cookieSitesRouter = new CookieSitesRouter_1.CookieSitesRouter(this.authRouter); this.downloadRoutes = new DownloadRoutes_1.DownloadRoutes(backend, this.authRouter); this.statisticsRouter = new StatisticsRouter_1.StatisticsRouter(backend); this.thumbRouter = new ThumbRouter_1.ThumbRouter(backend, backend.thumbnailService, backend.metadataService); this.setupMiddleware(); } async initialize() { await this.authRouter.initialize(); this.setupRoutes(); } // 开放功能 async request(options) { try { const response = await axios_1.default.request({ method: options.method, url: options.url, headers: options.headers, data: options.data }); return response.data; } catch (error) { if (axios_1.default.isAxiosError(error)) { throw new Error(`Request failed: ${error.message}`); } throw error; } } setupMiddleware() { // 添加HTTP请求日志中间件 this.app.use(createHttpLoggerMiddleware()); // CORS 中间件 this.app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, PATCH, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization'); if (req.method === 'OPTIONS') { res.sendStatus(200); } else { next(); } }); // JSON 解析中间件 - 增加文件上传限制 this.app.use(express_1.default.json({ limit: '2048mb' })); this.app.use(express_1.default.urlencoded({ extended: true })); // 静态文件中间件 // 注意:路径必须基于编译产物所在目录(__dirname),而不是 process.cwd()。 // 否则通过 npm 安装后在任意目录启动时,会找不到 public/dashboard 资源。 const publicDir = path_1.default.resolve(__dirname, '..', 'public'); this.app.use('/static', express_1.default.static(publicDir)); // Dashboard 静态托管:构建产物位于与本文件同级的 dist/dashboard 下。 // dev 模式下用 ts-node 直接跑 src/,__dirname 指向 src/,此时回退到项目根的 dist/dashboard // (该目录由 pnpm build:dashboard 生成的构建产物)。 let dashboardDir = path_1.default.resolve(__dirname, 'dashboard'); if (!fs_1.default.existsSync(dashboardDir)) { dashboardDir = path_1.default.resolve(__dirname, '..', 'dist', 'dashboard'); } this.app.use('/dashboard', express_1.default.static(dashboardDir, { // SPA 回退:未命中的静态资源回落到 index.html,保证前端路由可刷新 setHeaders: (res, filePath) => { if (path_1.default.extname(filePath) === '.html') { res.setHeader('Cache-Control', 'no-cache'); } }, })); // 前端路由(如 /dashboard/foo)刷新时回落到 dashboard 的 index.html this.app.get(/^\/dashboard(?:\/.*)?$/, (req, res, next) => { const indexFile = path_1.default.join(dashboardDir, 'index.html'); res.sendFile(indexFile, (err) => { if (err && !res.headersSent) next(); }); }); // 根路径重定向到 web 前端(dashboard 仍可通过 /dashboard 直接访问) this.app.get('/', (req, res) => res.redirect('/web/')); // Web 前端(mira-client)静态托管:构建产物位于 app-server 根目录的 web/ 下。 // 由 pnpm build:web(scripts/copy-web.mjs)生成。 // dev 模式下 __dirname 指向 src/,prod 模式指向 dist/,均通过 '..' 回到 app-server 根。 const webDir = path_1.default.resolve(__dirname, '..', 'web'); this.app.use('/web', express_1.default.static(webDir, { setHeaders: (res, filePath) => { if (path_1.default.extname(filePath) === '.html') { res.setHeader('Cache-Control', 'no-cache'); } }, })); // 前端路由(如 /web/foo)刷新时回落到 web 的 index.html this.app.get(/^\/web(?:\/.*)?$/, (req, res, next) => { const indexFile = path_1.default.join(webDir, 'index.html'); res.sendFile(indexFile, (err) => { if (err && !res.headersSent) next(); }); }); // 服务端插件的前端构建产物是公开代码资源。放在鉴权中间件之前,确保 // iframe 内的 JS/CSS/wasm 等相对资源无需重复传递 token。 this.app.get('/server-plugins/:libraryId/:pluginName/*', (req, res) => { const { libraryId, pluginName } = req.params; const library = this.backend.libraries?.getLibrary(libraryId); const pluginManager = library?.pluginManager; if (!pluginManager || !pluginManager.isPluginLoaded(pluginName)) { return res.status(404).json({ error: 'Server plugin not found' }); } const webDir = path_1.default.resolve(pluginManager.getPluginWebDir(pluginName)); const assetPath = path_1.default.resolve(webDir, req.params[0] || ''); const relativePath = path_1.default.relative(webDir, assetPath); if (relativePath.startsWith('..') || path_1.default.isAbsolute(relativePath)) { return res.status(403).json({ error: 'Access denied' }); } if (!fs_1.default.existsSync(assetPath) || !fs_1.default.statSync(assetPath).isFile()) { return res.status(404).json({ error: 'File not found' }); } res.sendFile(assetPath); }); // 统一权限中间件(CORS + body parser 之后、路由注册之前) this.app.use('/api', (0, permission_1.createHttpPermissionMiddleware)(this.authRouter.getAuthService(), this.backend.settingsManager, () => this.backend.libraries)); } setupRoutes() { this.app.use('/api', this.httpRouter.getRouter()); // 插件注册服务 this.app.use('/api/auth', this.authRouter.getRouter()); this.app.use('/api/admins', this.adminsRouter.getRouter()); // 注册符合vben标准的用户信息路由 this.app.use('/api/user', this.userRouter.getRouter()); // 注册 RESTful 路由 this.app.use('/api/libraries', this.libraryRoutes.getRouter()); this.app.use('/api/plugins', this.pluginRoutes.getRouter()); this.app.use('/api/database', this.databaseRoutes.getRouter()); this.app.use('/api/files', this.fileRoutes.getRouter()); this.app.use('/api/devices', this.deviceRoutes.getRouter()); this.app.use('/api/tags', this.tagRouter.getRouter()); this.app.use('/api/folders', this.folderRouter.getRouter()); this.app.use('/api/fs', this.fsRouter.getRouter()); this.app.use('/api/settings', this.settingsRouter.getRouter()); this.app.use('/api/cookie-sites', this.cookieSitesRouter.getRouter()); this.app.use('/api/download', this.downloadRoutes.getRouter()); this.app.use('/api/statistics', this.statisticsRouter.getRouter()); this.app.use('/api/thumb', this.thumbRouter.getRouter()); // 获取所有素材库的插件路由定义 this.app.get('/api/plugin-routes', (req, res) => { try { const allRoutes = []; const libraries = this.backend.libraries?.getLibraries() || {}; for (const [libraryId, libraryData] of Object.entries(libraries)) { if (libraryData.pluginManager) { const routes = libraryData.pluginManager.getAllPluginRoutes(); // 为每个路由添加素材库信息 const routesWithLibrary = routes.map(route => ({ ...route, libraryId, libraryName: libraryData.libraryService?.config?.name || libraryId, // 保留原始路径,同时提供带素材库ID的完整路径 originalPath: route.path, path: `/mira/library/${libraryId}${route.path}` })); allRoutes.push(...routesWithLibrary); } } res.json({ code: 0, data: allRoutes, total: allRoutes.length, timestamp: new Date().toISOString() }); } catch (error) { console.error('Error getting all plugin routes:', error); res.status(500).json({ code: 500, error: 'Failed to get plugin routes', message: error instanceof Error ? error.message : 'Unknown error', timestamp: new Date().toISOString() }); } }); // 插件路由API - 获取指定素材库的路由定义 this.app.get('/api/plugin-routes/:libraryId', (req, res) => { try { const { libraryId } = req.params; if (libraryId != null) { const obj = this.backend.libraries?.getLibrary(libraryId); if (obj == null) { return res.status(404).json({ code: 404, error: 'Library not found', message: `No library found with id: ${libraryId}`, timestamp: new Date().toISOString() }); } const routes = obj.pluginManager.getAllPluginRoutes(); res.json({ code: 0, data: routes, total: routes.length, timestamp: new Date().toISOString() }); } } catch (error) { console.error('Error getting plugin routes:', error); res.status(500).json({ code: 500, error: 'Failed to get plugin routes', message: error instanceof Error ? error.message : 'Unknown error', timestamp: new Date().toISOString() }); } }); // 健康检查端点 this.app.get('/api/health', (req, res) => { const settings = this.backend.settingsManager.getSettings(); const isDocker = fs_1.default.existsSync('/.dockerenv') || fs_1.default.existsSync('/run/.containerenv'); res.json({ code: 0, data: { status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), version: process.env.npm_package_version || '1.0.0', nodeVersion: process.version, environment: process.env.NODE_ENV || 'development', isDocker, authRequired: settings.authRequired, allowRegistration: settings.allowRegistration, } }); }); this.app.get('/health', (req, res) => { res.json({ status: 'ok', timestamp: new Date().toISOString(), uptime: process.uptime(), version: process.env.npm_package_version || '1.0.0' }); }); // 服务端日志流(SSE)—— 供本地控制台订阅后端运行日志。 // 与 /health 同级、不挂权限中间件:仅本地 127.0.0.1 控制台使用。 // 连接建立后先回放环形缓冲中「最近 100 条」历史,再实时推送新日志。 this.app.get('/api/logs/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); res.write(': connected\n\n'); // 1) 回放历史 const history = LogRingBuffer_1.logRingBuffer.recent(); res.write(`event: history\ndata: ${JSON.stringify(history)}\n\n`); // 2) 订阅实时日志 const unsubscribe = LogRingBuffer_1.logRingBuffer.subscribe(entry => { res.write(`data: ${JSON.stringify(entry)}\n\n`); }); // 周期性心跳,防止中间代理因空闲关闭连接 const heartbeat = setInterval(() => { res.write(': ping\n\n'); }, 15000); // 客户端断开时清理订阅与定时器,避免内存泄漏 req.on('close', () => { unsubscribe(); clearInterval(heartbeat); }); }); // 错误处理中间件 this.app.use((err, req, res, next) => { console.error('HTTP Server Error:', err); res.status(err.status || 500).json({ error: err.name || 'Internal Server Error', message: err.message || 'An unexpected error occurred', timestamp: new Date().toISOString(), ...(process.env.NODE_ENV === 'development' && { stack: err.stack }) }); }); } start(port = 8081) { return new Promise((resolve, reject) => { this.httpServer.listen(port, (err) => { if (err) { reject(err); } else { console.log(`🚀 HTTP Server started on port ${port}`); console.log(`📍 Health check: http://localhost:${port}/health`); console.log(`🔗 API base URL: http://localhost:${port}/api`); resolve(); } }); }); } stop() { return new Promise((resolve) => { this.httpServer.close(() => { console.log('📴 HTTP Server stopped'); resolve(); }); }); } getApp() { return this.app; } getHttpServer() { return this.httpServer; } getAuthRouter() { return this.authRouter; } } exports.MiraHttpServer = MiraHttpServer; //# sourceMappingURL=HttpServer.js.map