165 lines
5.4 KiB
TypeScript
165 lines
5.4 KiB
TypeScript
import express from 'express';
|
|
import cors from 'cors';
|
|
import helmet from 'helmet';
|
|
import compression from 'compression';
|
|
import rateLimit from 'express-rate-limit';
|
|
import path from 'path';
|
|
import { config } from './config/config';
|
|
import { errorHandler } from './middleware/errorHandler';
|
|
import { requestLogger } from './middleware/requestLogger';
|
|
|
|
// Route imports
|
|
import recipeRoutes from './routes/recipes';
|
|
import ingredientRoutes from './routes/ingredients';
|
|
import imageRoutes from './routes/images';
|
|
import healthRoutes from './routes/health';
|
|
|
|
const app = express();
|
|
|
|
// Security middleware
|
|
app.use(helmet({
|
|
crossOriginResourcePolicy: { policy: "cross-origin" },
|
|
}));
|
|
app.use(compression());
|
|
|
|
// Rate limiting
|
|
const limiter = rateLimit({
|
|
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
max: 100, // limit each IP to 100 requests per windowMs
|
|
message: 'Too many requests from this IP, please try again later.',
|
|
});
|
|
app.use(limiter);
|
|
|
|
// CORS Hardening
|
|
// Supports comma separated origins. Wildcard '*' only allowed if ALLOW_INSECURE_CORS=1 and not in production.
|
|
const insecureOverride = process.env.ALLOW_INSECURE_CORS === '1';
|
|
const isProd = process.env.NODE_ENV === 'production';
|
|
|
|
let allowedOrigins: string[] = [];
|
|
if (config.cors.origin.includes(',')) {
|
|
allowedOrigins = config.cors.origin.split(',').map(o => o.trim()).filter(Boolean);
|
|
} else if (config.cors.origin === '*' && (!isProd || insecureOverride)) {
|
|
allowedOrigins = ['*'];
|
|
} else {
|
|
allowedOrigins = [config.cors.origin];
|
|
}
|
|
|
|
// De-dupe & normalize trailing slashes
|
|
allowedOrigins = Array.from(new Set(allowedOrigins.map(o => o.replace(/\/$/, ''))));
|
|
|
|
// Auto-add common localhost dev origins if not prod and not wildcard
|
|
if (!isProd && !allowedOrigins.includes('*')) {
|
|
['http://localhost:5173','http://localhost:3000','http://esprimo:3000','http://esprimo:5173'].forEach(def => {
|
|
if (!allowedOrigins.includes(def)) allowedOrigins.push(def);
|
|
});
|
|
}
|
|
|
|
// If in production and wildcard attempted without override, remove it
|
|
if (isProd && allowedOrigins.includes('*') && !insecureOverride) {
|
|
console.warn('[CORS] Wildcard removed in production. Set CORS_ORIGIN explicitly or ALLOW_INSECURE_CORS=1 (NOT RECOMMENDED).');
|
|
allowedOrigins = allowedOrigins.filter(o => o !== '*');
|
|
}
|
|
|
|
app.use(cors({
|
|
origin: (origin, callback) => {
|
|
if (!origin) return callback(null, true); // Non-browser / same-origin
|
|
if (allowedOrigins.includes('*') || allowedOrigins.includes(origin.replace(/\/$/, ''))) {
|
|
return callback(null, true);
|
|
}
|
|
console.warn(`[CORS] Blocked origin: ${origin}`);
|
|
return callback(new Error('CORS not allowed for this origin'));
|
|
},
|
|
credentials: true,
|
|
}));
|
|
|
|
// Additional CORS headers for all requests
|
|
app.use((req, res, next) => {
|
|
const origin = req.headers.origin;
|
|
|
|
const normalized = origin?.replace(/\/$/, '');
|
|
if (allowedOrigins.includes('*')) {
|
|
res.header('Access-Control-Allow-Origin', origin || '*');
|
|
} else if (normalized && allowedOrigins.includes(normalized)) {
|
|
res.header('Access-Control-Allow-Origin', normalized);
|
|
}
|
|
res.header('Access-Control-Allow-Credentials', 'true');
|
|
res.header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, Authorization');
|
|
|
|
if (req.method === 'OPTIONS') {
|
|
return res.sendStatus(200);
|
|
}
|
|
next();
|
|
});
|
|
|
|
// Body parsing middleware
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
|
|
// Request logging
|
|
app.use(requestLogger);
|
|
|
|
// Static file serving for uploads
|
|
app.use('/uploads', express.static(path.join(process.cwd(), 'uploads')));
|
|
|
|
// API routes
|
|
app.use('/api/health', healthRoutes);
|
|
app.use('/api/recipes', recipeRoutes);
|
|
app.use('/api/ingredients', ingredientRoutes);
|
|
app.use('/api/images', imageRoutes);
|
|
|
|
// Direct image serving (for convenience)
|
|
app.get('/serve/*', (req, res, next) => {
|
|
const imagePath = (req.params as any)[0]; // Get everything after /serve/
|
|
// Remove leading 'uploads/' if present to avoid duplication
|
|
const cleanPath = imagePath.replace(/^uploads\//, '');
|
|
const fullPath = path.join(process.cwd(), '../../uploads', cleanPath);
|
|
|
|
console.log(`Direct serve request: ${req.originalUrl} -> ${fullPath}`);
|
|
|
|
// Check if file exists
|
|
const fs = require('fs');
|
|
if (!fs.existsSync(fullPath)) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
message: 'Image not found',
|
|
requestedPath: req.originalUrl,
|
|
resolvedPath: fullPath
|
|
});
|
|
}
|
|
|
|
// Set headers for images
|
|
const requestOrigin = req.headers.origin as string | undefined;
|
|
const chosenOrigin = allowedOrigins.includes('*')
|
|
? (requestOrigin || '*')
|
|
: (requestOrigin && allowedOrigins.includes(requestOrigin) ? requestOrigin : allowedOrigins[0] || 'http://localhost:3000');
|
|
res.set({
|
|
'Access-Control-Allow-Origin': chosenOrigin,
|
|
'Access-Control-Allow-Credentials': 'true',
|
|
'Cache-Control': 'public, max-age=31536000',
|
|
});
|
|
|
|
res.sendFile(path.resolve(fullPath));
|
|
});
|
|
|
|
// 404 handler
|
|
app.use('*', (req, res) => {
|
|
res.status(404).json({
|
|
success: false,
|
|
message: `Route ${req.originalUrl} not found`,
|
|
});
|
|
});
|
|
|
|
// Global error handler
|
|
app.use(errorHandler);
|
|
|
|
// Start server
|
|
const PORT = config.port;
|
|
|
|
app.listen(PORT, () => {
|
|
console.log(`🚀 Server running on port ${PORT}`);
|
|
console.log(`📱 Health check: http://localhost:${PORT}/api/health`);
|
|
console.log(`🎯 API Documentation: http://localhost:${PORT}/api`);
|
|
});
|
|
|
|
export default app; |