mira-app-server
Version:
Mira Server - standalone server application using mira-app-core
453 lines • 21.8 kB
JavaScript
"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