-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathapp.js
More file actions
444 lines (397 loc) · 17 KB
/
Copy pathapp.js
File metadata and controls
444 lines (397 loc) · 17 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
/**
* Express Application Configuration
*
* This file exports the configured Express app without starting the server.
* Used by both server.js (for production) and tests (for testing).
*/
const express = require("express");
const rateLimit = require("express-rate-limit");
const path = require("path");
const favicon = require("serve-favicon");
const logger = require("morgan");
const bodyParser = require("body-parser");
const cors = require("cors");
const session = require("express-session");
const MongoStore = require("connect-mongo").default;
const backendLogger = require("./utilities/backend-logger");
const cookieParser = require("cookie-parser");
const { doubleCsrf } = require("csrf-csrf");
const fs = require("fs");
// Load environment variables from the *caller* project directory first.
// This matters when Biensperience is installed as an npm dependency or run via npx.
// Fallback to the package directory for local dev.
const cwdEnvPath = path.resolve(process.cwd(), ".env");
const localEnvPath = path.resolve(__dirname, ".env");
const envPathToUse = fs.existsSync(cwdEnvPath) ? cwdEnvPath : localEnvPath;
require("dotenv").config({ path: envPathToUse });
// Only require database connection if not in test environment
if (process.env.NODE_ENV !== 'test') {
require("./config/database");
}
/**
* Client development server port
* @type {number}
*/
const CLIENTDEVPORT = 3000;
/**
* Express application instance
* @type {express.Application}
*/
const app = express();
/**
* Configure CORS middleware
*/
app.use(
cors({
origin: process.env.CLIENT_ORIGIN || `http://localhost:${CLIENTDEVPORT}`,
credentials: true,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization', 'X-CSRF-Token'],
})
);
/**
* Cookie parser middleware (required for CSRF)
*/
app.use(cookieParser());
/**
* Session configuration
* Uses MongoDB store in production to avoid memory leaks and support scaling
* Falls back to memory store in development/test environments
*/
const isProduction = process.env.NODE_ENV === 'production';
const isRender = process.env.RENDER === 'true';
const sessionCookieDomain = process.env.COOKIE_DOMAIN || undefined;
const sessionConfig = {
secret: process.env.SESSION_SECRET || process.env.SECRET,
resave: false,
saveUninitialized: true, // Changed to true to create sessions for CSRF tokens
cookie: {
secure: isProduction || isRender, // Always secure in production/Render
httpOnly: true, // Prevents client-side JS from accessing the cookie
maxAge: 24 * 60 * 60 * 1000, // 24 hours
sameSite: (isProduction || isRender) ? 'none' : 'lax',
domain: sessionCookieDomain,
},
name: 'biensperience.sid', // Custom session cookie name
};
// Use MongoDB session store in production to prevent memory leaks
if (isProduction && process.env.DATABASE_URL) {
sessionConfig.store = MongoStore.create({
mongoUrl: process.env.DATABASE_URL,
collectionName: 'sessions',
ttl: 24 * 60 * 60, // 24 hours in seconds (matches cookie maxAge)
autoRemove: 'native', // Use MongoDB TTL index for automatic cleanup
touchAfter: 24 * 3600, // Only update session once per 24 hours unless data changes
crypto: {
secret: process.env.SESSION_SECRET || process.env.SECRET
}
});
backendLogger.info('Session store: MongoDB (production)');
} else if (isProduction) {
backendLogger.warn('Session store: MemoryStore (DATABASE_URL not set) - not recommended for production');
} else {
backendLogger.info('Session store: MemoryStore (development)');
}
app.use(session(sessionConfig));
/**
* CSRF protection configuration
* Generates and validates CSRF tokens for state-changing requests
* Note: __Host- prefix requires secure: true, so only use in production
*
* SECURITY NOTE: Using a fixed session identifier is intentional and secure.
* The Double Submit Cookie pattern works as follows:
* 1. Server generates a random token and sets it in an httpOnly cookie
* 2. Server also returns the token to the client
* 3. Client must send the token in the X-CSRF-Token header
* 4. Server validates that header token matches cookie token
*
* This prevents CSRF because:
* - Attacker can't read the httpOnly cookie to get the token
* - SameSite cookie policy blocks cross-origin cookie sending
* - Origin/Referer headers provide additional validation
*
* We use a fixed identifier because:
* - In-memory session store doesn't persist across server restarts/instances
* - The security comes from the cookie-header comparison, not session binding
* - JWT provides user authentication independently
*/
// __Host- prefix cookies MUST NOT have a Domain attribute (RFC 6265bis §4.1.3).
// Browsers silently reject __Host- cookies that include Domain, which breaks CSRF.
// Only set domain for non-__Host- cookies (development mode).
const csrfCookieOptions = {
secure: isProduction || isRender,
httpOnly: true,
sameSite: (isProduction || isRender) ? 'none' : 'lax',
path: '/',
};
if (!(isProduction || isRender) && sessionCookieDomain) {
csrfCookieOptions.domain = sessionCookieDomain;
}
const {
generateCsrfToken, // Used to create a CSRF token pair (correct name from csrf-csrf v4)
doubleCsrfProtection, // Middleware to validate CSRF tokens
} = doubleCsrf({
getSecret: () => process.env.CSRF_SECRET || process.env.SECRET,
// Fixed identifier - security comes from cookie-header matching, not session binding
getSessionIdentifier: () => 'biensperience-csrf-v1',
cookieName: (isProduction || isRender) ? '__Host-biensperience.x-csrf-token' : 'biensperience.x-csrf-token',
cookieOptions: csrfCookieOptions,
size: 64,
ignoredMethods: ['GET', 'HEAD', 'OPTIONS'],
getCsrfTokenFromRequest: (req) => req.headers['x-csrf-token'], // Get token from header (note: renamed in v4)
});
/**
* Expose CSRF token generation function for routes
*/
if (typeof generateCsrfToken === 'function') {
app.set('csrfTokenGenerator', generateCsrfToken);
backendLogger.info('CSRF token generator registered successfully');
} else {
backendLogger.error('ERROR: generateCsrfToken is not a function', { type: typeof generateCsrfToken });
}
/**
* Morgan logger middleware for development
* Disabled in test environment
*/
if (process.env.NODE_ENV !== 'test') {
app.use(logger("dev"));
}
/**
* Express JSON parsing middleware
*/
app.use(express.json());
// NOTE: Global rate limiter moved below (after auth) to allow super admin skip logic
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({ extended: true }));
// Root endpoint - API info for API clients (must be BEFORE static file serving)
app.get('/', (req, res, next) => {
// Check if request is from an API client (curl, Postman, etc.)
const userAgent = req.get('User-Agent') || '';
const acceptsJson = req.accepts('json') && !req.accepts('html');
const isApiClient = acceptsJson ||
userAgent.includes('curl') ||
userAgent.includes('Postman') ||
userAgent.includes('HTTPie') ||
userAgent.includes('Insomnia');
if (isApiClient) {
// Return API information for API clients
const clientOrigin = process.env.CLIENT_ORIGIN || `http://localhost:${CLIENTDEVPORT}`;
const apiPort = process.env.PORT || 3001;
const apiHost = req.get('host') || `localhost:${apiPort}`;
return res.json({
message: 'Biensperience API Server',
status: 'running',
version: '0.3.1',
frontend: {
url: clientOrigin,
message: `Please visit ${clientOrigin} to use the application`
},
api: {
url: `${req.protocol}://${apiHost}`,
endpoints: {
auth: '/api/auth',
users: '/api/users',
destinations: '/api/destinations',
experiences: '/api/experiences',
photos: '/api/photos',
plans: '/api/plans',
search: '/api/search',
tokens: '/api/tokens',
invites: '/api/invites',
health: '/health-check'
}
},
documentation: 'https://github.com/gokepelemo/biensperience'
});
}
// Browser request - continue to static file serving
next();
});
// Only serve static files if build directory exists (not in test environment)
const buildPath = path.join(__dirname, "build");
try {
if (process.env.NODE_ENV !== 'test') {
app.use(favicon(path.join(buildPath, "icon.svg")));
// Serve the built frontend.
// IMPORTANT: Do not cache index.html aggressively (it references hashed assets).
// Hash-busted assets under /assets/ can be cached immutably.
app.use(
express.static(buildPath, {
setHeaders: (res, filePath) => {
// Ensure the HTML shell is always revalidated.
if (filePath.endsWith(`${path.sep}index.html`)) {
res.setHeader('Cache-Control', 'no-store');
return;
}
// Vite build assets are content-hashed; they can be cached forever.
if (filePath.includes(`${path.sep}assets${path.sep}`)) {
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
}
}
})
);
}
} catch (err) {
// Build directory doesn't exist, skip static file serving
}
// API logging middleware (async, non-blocking)
const apiLogger = require('./utilities/api-logger-middleware');
app.use('/api', apiLogger);
// Trace ID middleware - attach trace ID to all API requests
const { attachTraceId, addTraceIdToResponse } = require('./utilities/trace-middleware');
app.use('/api', attachTraceId);
app.use('/api', addTraceIdToResponse);
// Passport configuration for OAuth
const { passport } = require('./config/passport');
app.use(passport.initialize());
// API token checking (populate req.user and req.isApiToken) - must be before JWT
app.use(require("./utilities/api-token-middleware"));
// JWT token checking (populate req.user) - needs to be before CSRF to check super admin status
app.use(require("./config/checkToken"));
/**
* Global API rate limiting (after auth so we can skip super admins)
* Uses config/rateLimiters apiLimiter with higher thresholds and skip for super admins
*/
if (process.env.NODE_ENV !== 'test') {
const { apiLimiter } = require('./config/rateLimiters');
app.use('/api', apiLimiter);
}
// CSRF token endpoint must be registered BEFORE the auth rate limiter.
// The CSRF token is needed by ALL users for ALL state-changing requests, so
// it should not be subject to the strict auth rate limit (15 req/15 min)
// which is designed for login/signup brute-force protection.
app.get('/api/auth/csrf-token', require('./routes/api/auth-csrf-token'));
// Register auth routes AFTER JWT/token middleware (so logout can access req.user)
// Apply strict auth rate limiter to authentication endpoints (login, password)
// to mitigate brute-force attacks. The authLimiter is configured in
// `config/rateLimiters.js` and skips successful requests where appropriate.
const { authLimiter, modificationLimiter, externalApiLimiter, staticAssetsLimiter } = require('./config/rateLimiters');
app.use('/api/auth', authLimiter, require('./routes/api/auth'));
// Session ID middleware - manage session IDs for authenticated requests (after auth)
const { attachSessionId } = require('./utilities/session-middleware');
app.use('/api', attachSessionId);
// Apply CSRF protection to state-changing API routes (after auth routes)
// Skip CSRF for safe methods and login endpoint
app.use('/api', (req, res, next) => {
// Skip CSRF entirely in test environment
if (process.env.NODE_ENV === 'test') {
backendLogger.debug('Skipping CSRF entirely in test environment');
return next();
}
// Skip CSRF for safe methods
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
// Skip CSRF for login and signup endpoints (authentication doesn't need CSRF protection)
if (req.path === '/users/login' || req.path === '/users/') {
return next();
}
// Debug logging for CSRF validation
const csrfTokenFromHeader = req.headers['x-csrf-token'];
const csrfCookieName = (isProduction || isRender) ? '__Host-biensperience.x-csrf-token' : 'biensperience.x-csrf-token';
const csrfCookieValue = req.cookies?.[csrfCookieName];
backendLogger.debug('CSRF check', {
method: req.method,
path: req.path,
sessionId: req.session?.id ? req.session.id.substring(0, 8) + '...' : 'none',
hasSession: !!req.session,
hasCsrfHeader: !!csrfTokenFromHeader,
csrfHeaderPreview: csrfTokenFromHeader ? csrfTokenFromHeader.substring(0, 16) + '...' : 'none',
hasCsrfCookie: !!csrfCookieValue,
csrfCookiePreview: csrfCookieValue ? csrfCookieValue.substring(0, 16) + '...' : 'none',
cookieMatch: !!(csrfTokenFromHeader && csrfCookieValue && csrfTokenFromHeader === csrfCookieValue),
user: req.user ? {
id: req.user._id,
isSuperAdmin: req.user.isSuperAdmin,
role: req.user.role
} : 'No user'
});
// Skip CSRF for API token authentication
if (req.isApiToken) {
backendLogger.debug('Skipping CSRF for API token authentication', { userId: req.user._id });
return next();
}
// Skip CSRF for super admins
if (req.user && (req.user.isSuperAdmin || req.user.role === 'super_admin')) {
backendLogger.debug('Skipping CSRF for super admin', { userId: req.user._id, isSuperAdmin: req.user.isSuperAdmin, role: req.user.role });
return next();
}
backendLogger.debug('Applying CSRF protection', {
sessionId: req.session?.id ? req.session.id.substring(0, 8) + '...' : 'none'
});
// Apply CSRF protection for state-changing methods
doubleCsrfProtection(req, res, (err) => {
if (err) {
backendLogger.error('CSRF validation failed', {
error: err.message,
path: req.path,
method: req.method,
sessionId: req.session?.id ? req.session.id.substring(0, 8) + '...' : 'none',
hasCsrfHeader: !!csrfTokenFromHeader,
hasCsrfCookie: !!csrfCookieValue,
cookieMatch: !!(csrfTokenFromHeader && csrfCookieValue && csrfTokenFromHeader === csrfCookieValue)
});
}
next(err);
});
});
// Protect user creation/modification endpoints with a modification limiter
// to reduce abuse and credential stuffing vectors.
app.use('/api/users', modificationLimiter, require('./routes/api/users'));
app.use("/api/destinations", require("./routes/api/destinations"));
app.use("/api/experiences", require("./routes/api/experiences"));
app.use("/api/photos", require("./routes/api/photos"));
app.use("/api/plans", require("./routes/api/plans"));
app.use("/api/documents", require("./routes/api/documents"));
app.use("/api/search", externalApiLimiter, require("./routes/api/search"));
app.use("/api/tokens", require("./routes/api/tokens"));
app.use("/api/invites", require("./routes/api/invites"));
app.use("/api/invite-tracking", require("./routes/api/invite-tracking"));
app.use("/api/activities", require("./routes/api/activities"));
app.use("/api/dashboard", require("./routes/api/dashboard"));
app.use("/api/follows", require("./routes/api/follows"));
app.use("/api/ai", require("./routes/api/ai"));
app.use("/api/ai-admin", require("./routes/api/ai-admin"));
app.use("/api/chat", require("./routes/api/chat"));
app.use("/api/bienbot", require("./routes/api/bienbot"));
// Apply stricter rate limiting to external API routes
app.use("/api/geocode", externalApiLimiter, require("./routes/api/geocode"));
app.use("/api/countries", externalApiLimiter, require("./routes/api/countries"));
app.use("/health-check", (req, res) => {
res.send("OK");
});
// Centralized API error handler: ensure API routes always return JSON
// This catches errors thrown by middleware/controllers and prevents HTML error pages
app.use('/api', (err, req, res, next) => {
backendLogger.error('Unhandled API error', { error: err && err.message, stack: err && err.stack, path: req.path });
// If headers already sent, delegate to default handler
if (res.headersSent) return next(err);
// Use standardized error response payload
const status = (err && (err.statusCode || err.status)) ? (err.statusCode || err.status) : 500;
return res.status(status).json({ success: false, error: (err && err.message) || 'Internal server error' });
});
// Catch-all route for React app (only in production)
// SECURITY: Apply rate limiting to prevent abuse of static file serving
if (process.env.NODE_ENV !== 'test') {
app.get('/*', staticAssetsLimiter, (req, res, next) => {
// Never hijack API routes.
if (req.path.startsWith('/api/')) return next();
// If the request is for a static asset (has an extension) or an /assets/* file,
// do NOT fall back to index.html.
// Returning HTML for missing JS causes the browser's strict module MIME checks
// to fail and can break the app after deploys/restarts.
const hasFileExtension = path.extname(req.path);
const isAssetPath = req.path.startsWith('/assets/');
if (hasFileExtension || isAssetPath) {
return res.status(404).end();
}
// Only serve the SPA shell for browser navigation (HTML).
const acceptsHtml = req.accepts('html');
if (!acceptsHtml) {
return res.status(404).end();
}
res.setHeader('Cache-Control', 'no-store');
res.sendFile(path.join(buildPath, 'index.html'));
});
}
module.exports = app;