Files
adopt-a-street/backend/server.js
William Valentin b5ee7571c9 fix: configure Express to trust proxy for rate limiting behind ingress
When the backend runs behind a Kubernetes ingress/reverse proxy, the
X-Forwarded-For headers cause express-rate-limit to throw errors:
ERR_ERL_UNEXPECTED_X_FORWARDED_FOR

This was causing all registration and login attempts to fail with HTTP 400.

Changes:
- Added app.set('trust proxy', 1) to trust first proxy
- Added validate: { trustProxy: false } to rate limiters to disable
  strict X-Forwarded-For validation

This allows the rate limiter to work correctly with proxy headers from
the HAProxy ingress controller while still providing rate limiting based
on client IP.

Result:
- Registration endpoint now works: POST /api/auth/register returns JWT token
- Login should work similarly
- Rate limiting still active but compatible with ingress

Tested: curl registration via ingress returns success and JWT token

🤖 Generated with AI Assistant

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
2025-12-05 21:56:43 -08:00

310 lines
9.1 KiB
JavaScript

require("dotenv").config();
const express = require("express");
const couchdbService = require("./services/couchdbService");
const cors = require("cors");
const http = require("http");
const socketio = require("socket.io");
const helmet = require("helmet");
const rateLimit = require("express-rate-limit");
const mongoSanitize = require("express-mongo-sanitize");
const xss = require("xss-clean");
const { errorHandler } = require("./middleware/errorHandler");
const socketAuth = require("./middleware/socketAuth");
const requestLogger = require("./middleware/requestLogger");
const logger = require("./utils/logger");
const { validateEnv, logEnvConfig } = require("./utils/validateEnv");
// Validate environment variables before starting server
try {
validateEnv();
logEnvConfig();
} catch (error) {
logger.error('Environment validation failed:');
logger.error(error.message);
process.exit(1);
}
const app = express();
const server = http.createServer(app);
const io = socketio(server, {
cors: {
origin: process.env.FRONTEND_URL || "http://localhost:3000",
methods: ["GET", "POST"],
credentials: true,
},
});
const port = process.env.PORT || 5000;
// Trust proxy - required when behind ingress/reverse proxy
app.set('trust proxy', 1);
// Security Headers - Helmet
app.use(helmet());
// CORS Configuration
app.use(
cors({
origin: process.env.FRONTEND_URL || "http://localhost:3000",
credentials: true,
}),
);
// Body Parser
app.use(express.json());
// Data Sanitization against NoSQL injection
app.use(mongoSanitize());
// Data Sanitization against XSS
app.use(xss());
// Request Logging
app.use(requestLogger);
// Rate Limiting for Auth Routes (5 requests per 15 minutes)
const authLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 requests per windowMs
message: {
success: false,
error: "Too many authentication attempts, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
// Trust proxy when behind ingress
validate: { trustProxy: false },
});
// General API Rate Limiting (100 requests per 15 minutes)
const apiLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 100, // 100 requests per windowMs
message: {
success: false,
error: "Too many requests, please try again later",
},
standardHeaders: true,
legacyHeaders: false,
// Trust proxy when behind ingress
validate: { trustProxy: false },
});
// Database Connection
// CouchDB (primary database)
// Skip initialization during testing
if (process.env.NODE_ENV !== 'test') {
couchdbService.initialize()
.then(() => logger.info("CouchDB initialized successfully"))
.catch((err) => {
logger.error("CouchDB initialization failed", err);
process.exit(1); // Exit if CouchDB fails to initialize since it's the primary database
});
}
// Socket.IO Authentication Middleware
io.use(socketAuth);
// Socket.IO Setup with Authentication
io.on("connection", (socket) => {
logger.info(`Socket.IO client connected`, { userId: socket.user.id });
socket.on("joinEvent", (eventId) => {
socket.join(`event_${eventId}`);
logger.debug(`User joined event`, { userId: socket.user.id, eventId });
});
socket.on("joinPost", (postId) => {
socket.join(`post_${postId}`);
logger.debug(`User joined post`, { userId: socket.user.id, postId });
});
socket.on("eventUpdate", (data) => {
io.to(`event_${data.eventId}`).emit("update", data.message);
});
socket.on("disconnect", () => {
logger.info(`Socket.IO client disconnected`, { userId: socket.user.id });
});
});
// Make io available to routes
app.set("io", io);
// Routes
const authRoutes = require("./routes/auth");
const streetRoutes = require("./routes/streets");
const taskRoutes = require("./routes/tasks");
const postRoutes = require("./routes/posts");
const commentsRoutes = require("./routes/comments");
const eventRoutes = require("./routes/events");
const rewardRoutes = require("./routes/rewards");
const reportRoutes = require("./routes/reports");
const badgesRoutes = require("./routes/badges");
const aiRoutes = require("./routes/ai");
const paymentRoutes = require("./routes/payments");
const userRoutes = require("./routes/users");
const cacheRoutes = require("./routes/cache");
const profileRoutes = require("./routes/profile");
const analyticsRoutes = require("./routes/analytics");
const leaderboardRoutes = require("./routes/leaderboard");
// Apply rate limiters
app.use("/api/auth/register", authLimiter);
app.use("/api/auth/login", authLimiter);
app.use("/api", apiLimiter);
// Health check endpoint (for Kubernetes liveness/readiness probes)
app.get("/api/health", async (req, res) => {
try {
const couchdbStatus = await couchdbService.checkConnection();
// Check Socket.IO status
const socketIOStatus = {
engine: io.engine ? "running" : "stopped",
connectedClients: io.engine ? io.engine.clientsCount : 0,
// Get number of connected sockets
sockets: io.sockets ? io.sockets.sockets.size : 0
};
const isHealthy = couchdbStatus && io.engine;
res.status(isHealthy ? 200 : 503).json({
status: isHealthy ? "healthy" : "degraded",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
services: {
couchdb: couchdbStatus ? "connected" : "disconnected",
socketIO: {
status: socketIOStatus.engine,
connectedClients: socketIOStatus.connectedClients,
activeSockets: socketIOStatus.sockets
}
},
memory: {
heapUsed: Math.round(process.memoryUsage().heapUsed / 1024 / 1024) + " MB",
heapTotal: Math.round(process.memoryUsage().heapTotal / 1024 / 1024) + " MB",
rss: Math.round(process.memoryUsage().rss / 1024 / 1024) + " MB"
}
});
} catch (error) {
res.status(503).json({
status: "unhealthy",
timestamp: new Date().toISOString(),
uptime: process.uptime(),
services: {
couchdb: "disconnected",
socketIO: "unknown"
},
error: error.message,
});
}
});
// Detailed Socket.IO health check endpoint
app.get("/api/health/socketio", (req, res) => {
try {
const socketIOInfo = {
status: io.engine ? "running" : "stopped",
connectedClients: io.engine ? io.engine.clientsCount : 0,
activeSockets: io.sockets ? io.sockets.sockets.size : 0,
rooms: [],
timestamp: new Date().toISOString()
};
// Get list of active rooms (excluding auto-generated socket ID rooms)
if (io.sockets && io.sockets.adapter && io.sockets.adapter.rooms) {
const rooms = Array.from(io.sockets.adapter.rooms.keys()).filter(room => {
// Filter out socket ID rooms (they start with socket ID pattern)
return room.startsWith('event_') || room.startsWith('post_');
});
socketIOInfo.rooms = rooms.map(room => {
const roomSize = io.sockets.adapter.rooms.get(room)?.size || 0;
return { name: room, members: roomSize };
});
}
res.status(200).json(socketIOInfo);
} catch (error) {
res.status(500).json({
status: "error",
error: error.message,
timestamp: new Date().toISOString()
});
}
});
// Routes
app.use("/api/auth", authRoutes);
app.use("/api/streets", streetRoutes);
app.use("/api/tasks", taskRoutes);
app.use("/api/posts", postRoutes);
app.use("/api/posts", commentsRoutes); // Comments are nested under posts
app.use("/api/events", eventRoutes);
app.use("/api/rewards", rewardRoutes);
app.use("/api/reports", reportRoutes);
app.use("/api/badges", badgesRoutes);
app.use("/api/ai", aiRoutes);
app.use("/api/payments", paymentRoutes);
app.use("/api/users", userRoutes);
app.use("/api/cache", cacheRoutes);
app.use("/api/profile", profileRoutes);
app.use("/api/analytics", analyticsRoutes);
app.use("/api/leaderboard", leaderboardRoutes);
app.get("/", (req, res) => {
res.send("Street Adoption App Backend");
});
// Error Handler Middleware (must be last)
app.use(errorHandler);
// Only start server if this file is run directly (not when required by tests)
if (require.main === module) {
server.listen(port, () => {
logger.info(`Server started`, { port, env: process.env.NODE_ENV || 'development' });
});
}
// Export app and server for testing
module.exports = { app, server, io };
// Graceful shutdown
process.on("SIGTERM", async () => {
logger.info("SIGTERM received, shutting down gracefully");
try {
// Close CouchDB connection
await couchdbService.shutdown();
logger.info("CouchDB connection closed");
// Close server
server.close(() => {
logger.info("Server closed");
process.exit(0);
});
} catch (error) {
logger.error("Error during shutdown", error);
process.exit(1);
}
});
process.on("SIGINT", async () => {
logger.info("SIGINT received, shutting down gracefully");
try {
// Close CouchDB connection
await couchdbService.shutdown();
logger.info("CouchDB connection closed");
// Close server
server.close(() => {
logger.info("Server closed");
process.exit(0);
});
} catch (error) {
logger.error("Error during shutdown", error);
process.exit(1);
}
});