Files
CloudSearch/source_clean/src/main.ts
admin 9a4751692f v0.3.2: fix 401 on save-records fetch + fix 429 rate limiter behind proxy
- AdminDashboard: M() now sends admin_token from localStorage with fetch
- rate-limit: keyGenerator uses req.ip instead of req.socket.remoteAddress
  (Express trust proxy reads X-Forwarded-For for real client IP)
- main.ts: moved global rateLimiter after express.static so static files
  (JS/CSS/admin page/favicon) are never rate-limited
2026-05-17 04:20:30 +08:00

198 lines
7.9 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 { 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> {
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;