import express, { Request, Response, NextFunction } from 'express'; import cookieParser from 'cookie-parser'; import path from 'path'; import itemsRouter from './routes/items'; import { router as cartCheckoutRouter, webhookRouter as cartCheckoutWebhookRouter } from './routes/cartCheckout'; import adminRouter from './routes/admin'; import adminCustomersRouter from './routes/adminCustomers'; import adminSettingsRouter from './routes/adminSettings'; import adminEmailTemplatesRouter from './routes/adminEmailTemplates'; import adminCategoriesRouter from './routes/adminCategories'; import adminTagsRouter from './routes/adminTags'; import adminVersionRouter from './routes/adminVersion'; import filtersRouter from './routes/filters'; import customersRouter from './routes/customers'; import publicRouter from './routes/public'; import cartRouter from './routes/cart'; import shippingAddressesRouter from './routes/shippingAddresses'; import clientErrorsRouter from './routes/clientErrors'; import { attachCustomer } from './middleware/customerAuth'; import { requireAdminGate } from './middleware/adminGate'; import { asyncRoute } from './asyncRoute'; import { uploadsRouter } from './uploads'; const app = express(); // Express advertises itself in X-Powered-By by default, which hands an // attacker the server stack for free when picking exploits to try. app.disable('x-powered-by'); app.set('trust proxy', 1); app.use('/webhooks/paypal', express.json(), cartCheckoutWebhookRouter); app.use(express.json()); app.use(cookieParser()); // Wrapped like any route: attachCustomer awaits a session lookup, and mounted // globally an unforwarded rejection here would hang every request in the app — // including the routes that wrap their own handlers correctly. app.use(asyncRoute(attachCustomer)); app.use('/uploads', uploadsRouter(process.env.UPLOADS_DIR || '/app/uploads')); // Trimmed with a loop rather than a `/+$/` regex, which backtracks. function trimTrailingSlashes(value: string): string { let trimmed = value; while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1); return trimmed; } app.get('/api/config', (_req, res) => { const clientId = process.env.PAYPAL_CLIENT_ID; const isPlaceholder = !clientId || clientId.length < 10 || clientId === 'REPLACE_WITH_PAYPAL_CLIENT_ID'; res.json({ paypalClientId: isPlaceholder ? null : clientId, demoMode: process.env.DEMO_MODE !== 'false', currency: process.env.SITE_CURRENCY || 'USD', // Where uploaded images should be fetched from (#103). Empty means the // app's own origin, which is both the default and what local development // has — there is no second hostname on a laptop. Set it to a hostname of // its own in production and user-supplied files stop sharing an origin with // the application, which is the whole unit of trust in a browser. // // Sent at runtime rather than built in, so one image serves every // environment, the same reason paypalClientId and demoMode are here. // // Trailing slash trimmed so callers can join with a stored path, which // always begins with one, without producing a double. uploadsBaseUrl: trimTrailingSlashes(process.env.UPLOADS_BASE_URL ?? '') }); }); app.use('/api/items', itemsRouter); app.use('/api/filters', filtersRouter); app.use('/api/cart', cartRouter); app.use('/api/checkout/cart', cartCheckoutRouter); // requireAdminGate is attached to each admin router rather than to a path // prefix. Attached to the router, an admin router added later at some other // path still inherits it — and since the proxy only injects the header on the // paths its regex matches, that router refuses loudly on its first request // instead of being quietly public. See middleware/adminGate.ts and #63. app.use('/api/admin/customers', requireAdminGate, adminCustomersRouter); app.use('/api/admin/settings', requireAdminGate, adminSettingsRouter); app.use('/api/admin/email-templates', requireAdminGate, adminEmailTemplatesRouter); app.use('/api/admin/categories', requireAdminGate, adminCategoriesRouter); app.use('/api/admin/tags', requireAdminGate, adminTagsRouter); app.use('/api/admin/version', requireAdminGate, adminVersionRouter); app.use('/api/admin', requireAdminGate, adminRouter); app.use('/api/customers/me/addresses', shippingAddressesRouter); app.use('/api/customers', customersRouter); app.use('/api/client-errors', clientErrorsRouter); app.use('/', publicRouter); if (process.env.NODE_ENV !== 'test') { const staticDir = path.join(__dirname, '..', 'public'); app.use(express.static(staticDir)); app.get('*', (_req, res) => { res.sendFile(path.join(staticDir, 'index.html')); }); } // Mounted last, so it sees errors from every route above. Without it, a route // that hands an error to next() falls through to Express's default handler, // and — worse — an async route that rejects never responds at all, leaving the // client hanging. A hung request is indistinguishable from an empty catalogue // in the UI, which is exactly how a schema mismatch once read as "the store // has no items". Always answer. // // The four-argument signature is what marks this as error middleware; `next` // is unused but must stay for Express to recognise it. // eslint-disable-next-line @typescript-eslint/no-unused-vars app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => { console.error(err); if (res.headersSent) return; res.status(500).json({ error: 'internal error' }); }); export default app;