Files
Rezepte/backend/src/app.ts

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;