恢复内容: - quark驱动拆解为7个子模块 (quark-api/auth/share/storage/cleanup/rename/ad-cleanup) - 工具模块: utils/crypto, utils/logger, utils/proxy-agent - 配置校验: config/startup-validator - 接线: main.ts(checkStartup), credential.service.ts(加密Cookie), admin.routes.ts(代理测试) - quark.driver.ts 从1533行巨兽瘦身到130行壳子
205 lines
8.1 KiB
TypeScript
Executable File
205 lines
8.1 KiB
TypeScript
Executable File
import express from 'express';
|
||
import path from 'path';
|
||
import cors from 'cors';
|
||
import helmet from 'helmet';
|
||
import morgan from 'morgan';
|
||
import config from './config';
|
||
import { VERSION as version } from "./version";
|
||
import { checkStartup } from './config/startup-validator';
|
||
import { getDb } from './database/database';
|
||
import { connectRedis, disconnectRedis, reconnectRedis, testRedisConnection } from './middleware/cache';
|
||
import rateLimiter from './middleware/rate-limit';
|
||
import routes from './routes';
|
||
import { pansouWebProxy } from './proxy/pansou-web';
|
||
import { checkAndRunScheduledCleanup } from './cloud/cleanup.service';
|
||
import { refreshAllStorageInfo } from './cloud/cloud.service';
|
||
|
||
const app = express();
|
||
|
||
// ============ Middleware ============
|
||
app.set('trust proxy', true);
|
||
|
||
// CORS — 生产环境必须配置真实域名(空值或占位符用 * 并打警告日志)
|
||
const corsOrigin = process.env.CORS_ORIGIN || '';
|
||
const isPlaceholder = !corsOrigin || corsOrigin === 'https://your-domain.com';
|
||
if (config.nodeEnv === 'production' && isPlaceholder) {
|
||
console.error('[FATAL] CORS_ORIGIN 未配置或使用了占位符 https://your-domain.com,生产环境必须设置真实域名。应用拒绝启动。');
|
||
process.exit(1);
|
||
}
|
||
if (config.nodeEnv === 'production' && !isPlaceholder) {
|
||
app.use(cors({ origin: corsOrigin, credentials: true }));
|
||
} else {
|
||
app.use(cors({ origin: '*', credentials: false }));
|
||
}
|
||
|
||
app.use(helmet({ contentSecurityPolicy: false }));
|
||
|
||
// morgan 日志格式:不记录 IP,避免隐私合规问题
|
||
app.use(morgan(':method :url :status :res[content-length] - :response-time ms'));
|
||
|
||
app.use(express.json({ limit: '10mb' }));
|
||
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
||
// ============ 前端静态文件 ============
|
||
const frontendDist = path.join(__dirname, 'frontend');
|
||
// Cache control: HTML no-cache, hashed assets immutable
|
||
app.use((req, res, next) => {
|
||
if (req.path.endsWith('.html')) {
|
||
res.setHeader('Cache-Control', 'no-cache, no-store, must-revalidate');
|
||
} else if (req.path.startsWith('/assets/')) {
|
||
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
|
||
}
|
||
next();
|
||
});
|
||
app.use(express.static(frontendDist));
|
||
|
||
// ============ Rate Limiting (after static files — only API routes are limited)
|
||
app.use(rateLimiter);
|
||
|
||
// ============ Routes ============
|
||
app.use('/api/uploads', express.static('/app/uploads'));
|
||
app.use('/api', routes);
|
||
|
||
// ============ Health Check(增强版:覆盖 Redis / PanSou / VideoParser 状态) ============
|
||
app.get('/health', async (_req, res) => {
|
||
const dbOk = (() => {
|
||
try { getDb(); return true; } catch { return false; }
|
||
})();
|
||
|
||
const redisStatus = await (async () => {
|
||
try {
|
||
const { getRedis } = require('./middleware/cache');
|
||
const redis = getRedis();
|
||
if (!redis) return 'disconnected';
|
||
const pong = await redis.ping().catch(() => null);
|
||
return pong === 'PONG' ? 'connected' : 'error';
|
||
// eslint-disable-next-line no-unused-vars
|
||
} catch { return 'unknown'; }
|
||
})();
|
||
|
||
const pansouStatus = await (async () => {
|
||
try {
|
||
// Native fetch available in Node 20+
|
||
const url = (config.pansouUrl || 'http://pansou:80').replace(/\/+$/, '') + '/api/search';
|
||
const controller = new AbortController();
|
||
const t = setTimeout(() => controller.abort(), 3000);
|
||
const r = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kw: 'health', page: 1 }), signal: controller.signal });
|
||
clearTimeout(t);
|
||
return r.ok ? 'ok' : 'degraded';
|
||
// eslint-disable-next-line no-unused-vars
|
||
} catch { return 'unreachable'; }
|
||
})();
|
||
|
||
const videoParserStatus = await (async () => {
|
||
try {
|
||
// Native fetch available in Node 20+
|
||
const url = (config.videoParserUrl || 'http://video-parser:3001').replace(/\/+$/, '');
|
||
const controller = new AbortController();
|
||
const t = setTimeout(() => controller.abort(), 3000);
|
||
const r = await fetch(url, { signal: controller.signal });
|
||
clearTimeout(t);
|
||
return r.ok ? 'ok' : 'degraded';
|
||
// eslint-disable-next-line no-unused-vars
|
||
} catch { return 'unreachable'; }
|
||
})();
|
||
|
||
const overall = dbOk && redisStatus === 'connected' && pansouStatus === 'ok' && videoParserStatus === 'ok'
|
||
? 'ok'
|
||
: dbOk && redisStatus !== 'unknown' && pansouStatus !== 'unreachable'
|
||
? 'degraded'
|
||
: 'unhealthy';
|
||
|
||
res.json({
|
||
version,
|
||
status: overall,
|
||
timestamp: new Date().toISOString(),
|
||
uptime: Math.floor(process.uptime()),
|
||
memory: process.memoryUsage().rss,
|
||
components: {
|
||
db: dbOk ? 'connected' : 'error',
|
||
redis: redisStatus,
|
||
pansou: pansouStatus,
|
||
videoParser: videoParserStatus,
|
||
},
|
||
});
|
||
});
|
||
|
||
// ============ PanSou Web UI Proxy ============
|
||
app.use('/pansou', pansouWebProxy);
|
||
|
||
// SPA fallback
|
||
app.use((req, res, next) => {
|
||
if (req.path.startsWith('/api/') || req.path === '/health') return next();
|
||
res.sendFile(path.join(frontendDist, 'index.html'), (err) => { if (err) next(); });
|
||
});
|
||
|
||
// Global error handler
|
||
app.use((err: any, _req: express.Request, res: express.Response, _next: express.NextFunction) => {
|
||
console.error('[Error]', err);
|
||
res.status(err.status || 500).json({
|
||
error: config.nodeEnv === 'production' ? 'Internal server error' : err.message,
|
||
code: err.status || 500,
|
||
});
|
||
});
|
||
|
||
// ============ Server Start ============
|
||
async function start(): Promise<void> {
|
||
// Startup config validation
|
||
if (!checkStartup()) {
|
||
console.error('[Server] 配置校验未通过,进程退出');
|
||
process.exit(1);
|
||
}
|
||
|
||
try {
|
||
getDb();
|
||
console.log('[DB] SQLite database initialized');
|
||
try {
|
||
const { getSystemConfig } = require('./admin/system-config.service');
|
||
const tz = getSystemConfig('timezone');
|
||
if (tz) { process.env.TZ = tz; console.log(`[Config] Timezone set to: ${tz}`); }
|
||
} catch { console.warn('[Config] Could not set timezone, using default'); }
|
||
} catch (err) {
|
||
console.error('[DB] Failed to initialize database:', err);
|
||
process.exit(1);
|
||
}
|
||
|
||
try {
|
||
const { getSystemConfig } = require('./admin/system-config.service');
|
||
const redisUrl = process.env.REDIS_URL || getSystemConfig('redis_url');
|
||
if (redisUrl) {
|
||
const ok = await reconnectRedis(redisUrl);
|
||
if (ok) console.log('[Redis] Connected to', redisUrl);
|
||
} else {
|
||
await connectRedis();
|
||
}
|
||
} catch { console.warn('[Redis] Redis not available, continuing without cache'); }
|
||
|
||
// Cleanup scheduler
|
||
const CLEANUP_INTERVAL = 10 * 60 * 1000;
|
||
setInterval(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Scheduler error:', err.message)); }, CLEANUP_INTERVAL);
|
||
setTimeout(() => { checkAndRunScheduledCleanup().catch(err => console.error('[Cleanup] Initial check error:', err.message)); }, 30000);
|
||
|
||
// Storage info refresh scheduler — every 60 minutes
|
||
const STORAGE_REFRESH_INTERVAL = 60 * 60 * 1000;
|
||
setInterval(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Refresh error:', err.message)); }, STORAGE_REFRESH_INTERVAL);
|
||
setTimeout(() => { refreshAllStorageInfo().catch(err => console.error('[Storage] Initial refresh error:', err.message)); }, 60000);
|
||
|
||
const server = app.listen(config.port, () => {
|
||
console.log(`[Server] CloudSearch Backend running on port ${config.port} (${config.nodeEnv})`);
|
||
});
|
||
|
||
const shutdown = async (signal: string) => {
|
||
console.log(`\n[Server] Received ${signal}, shutting down gracefully...`);
|
||
server.close(async () => { await disconnectRedis(); console.log('[Server] Closed'); process.exit(0); });
|
||
setTimeout(() => { console.error('[Server] Force shutdown'); process.exit(1); }, 10000);
|
||
};
|
||
|
||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||
process.on('uncaughtException', (err) => { console.error('[FATAL] Uncaught Exception:', err); setTimeout(() => process.exit(1), 1000); });
|
||
process.on('unhandledRejection', (reason) => { const msg = reason instanceof Error ? reason.message : String(reason); console.error('[FATAL] Unhandled Rejection:', msg); });
|
||
}
|
||
|
||
start().catch((err) => { console.error('[Server] Failed to start:', err); process.exit(1); });
|
||
|
||
export default app;
|