147 lines
4.1 KiB
JavaScript

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
const compression = require('compression');
const path = require('path');
const cron = require('node-cron');
require('dotenv').config();
const logger = require('./utils/logger');
const db = require('./models/db');
const apiRoutes = require('./routes/api');
const app = express();
const PORT = process.env.PORT || 3003;
// Trust proxy for Docker/reverse proxy environments
app.set('trust proxy', ['127.0.0.1', '::1', '172.16.0.0/12', '192.168.0.0/16', '10.0.0.0/8']);
// Compression middleware
app.use(compression());
// Security middleware - disable upgrade-insecure-requests and HSTS for HTTP-only access
app.use(helmet({
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
styleSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://fonts.googleapis.com"],
scriptSrc: ["'self'", "'unsafe-inline'", "https://unpkg.com", "https://cdn.jsdelivr.net"],
imgSrc: ["'self'", "data:", "https:", "blob:"],
connectSrc: ["'self'", "https://unpkg.com", "https://tile.openstreetmap.org", "https://*.tile.openstreetmap.org", "https://router.project-osrm.org"],
fontSrc: ["'self'", "https://fonts.gstatic.com", "https://unpkg.com"],
upgradeInsecureRequests: null, // Disable for HTTP-only access
},
},
hsts: false, // Disable HSTS for HTTP-only access (will be enabled by Cloudflare tunnel)
crossOriginOpenerPolicy: false, // Disable for HTTP access
originAgentCluster: false, // Disable for consistent behavior across HTTP/Tailscale access
}));
// Middleware
app.use(cors());
app.use(express.json());
app.use(express.urlencoded({ extended: true }));
// Request logging
app.use((req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
logger.info(`${req.method} ${req.path}`, {
status: res.statusCode,
duration: `${duration}ms`,
ip: req.ip
});
});
next();
});
// Static files
app.use(express.static(path.join(__dirname, 'public'), {
maxAge: process.env.NODE_ENV === 'production' ? '1d' : 0,
etag: true
}));
// Health check endpoint
app.get('/api/health', async (req, res) => {
try {
await db.query('SELECT 1');
res.json({
status: 'healthy',
timestamp: new Date().toISOString(),
uptime: process.uptime()
});
} catch (error) {
logger.error('Health check failed', { error: error.message });
res.status(503).json({
status: 'unhealthy',
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// API routes
app.use('/api', apiRoutes);
// Serve main page
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Resource detail page
app.get('/resource/:id', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
// Weekly scraper cron job (runs every Sunday at 2 AM)
if (process.env.ENABLE_CRON === 'true') {
cron.schedule('0 2 * * 0', async () => {
logger.info('Starting weekly data scrape');
try {
const { runAllScrapers } = require('./scrapers/run-all');
await runAllScrapers();
logger.info('Weekly data scrape completed');
} catch (error) {
logger.error('Weekly data scrape failed', { error: error.message });
}
});
logger.info('Weekly cron job scheduled for Sundays at 2 AM');
}
// Error handling middleware
app.use((err, req, res, next) => {
logger.error('Application error', {
error: err.message,
stack: err.stack,
path: req.path
});
res.status(err.status || 500).json({
error: 'Something went wrong',
message: process.env.NODE_ENV === 'development' ? err.message : 'Internal server error'
});
});
// 404 handler
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
// Graceful shutdown
process.on('SIGTERM', () => {
logger.info('SIGTERM received, shutting down');
server.close(() => {
db.end();
process.exit(0);
});
});
const server = app.listen(PORT, () => {
logger.info('Server started', {
port: PORT,
environment: process.env.NODE_ENV
});
});
module.exports = app;