Mounted publicly, deliberately not behind requireAdminGate. These are clicked from an inbox by someone who is not signed in, which is the whole point; the signature is what protects them. GET confirms and changes nothing, POST acts. Mail scanners and corporate link-rewriting gateways issue a GET against every URL in a message before a human sees it, so a GET that discarded a draft would fire itself on delivery — carrying a valid signature, looking entirely legitimate in the log, and nobody would know to go and recover it. That is the case the split exists for and it has its own test. Forged, replayed, upgraded and expired links are each refused with the same 403. Distinguishing them would tell somebody probing which of those they had achieved. There is no signable publish, and asking for one finds no handler. The two registry guard tests are updated rather than worked around: they assert the full set of settings and template keys, so adding either is exactly what should trip them. Backend now 367 unit and 329 integration, all passing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
118 lines
5.8 KiB
TypeScript
Executable File
118 lines
5.8 KiB
TypeScript
Executable File
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 adminUploadLinksRouter from './routes/adminUploadLinks';
|
|
import adminItemDraftsRouter from './routes/adminItemDrafts';
|
|
import intakeActionsRouter from './routes/intakeActions';
|
|
import intakeRouter from './routes/intake';
|
|
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';
|
|
import { trimTrailingSlashes } from './utils';
|
|
|
|
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'));
|
|
|
|
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);
|
|
// Public and unauthenticated by design (#222). No requireAdminGate: the token
|
|
// in the path is the whole access control, and every refusal is a 404.
|
|
app.use('/api/intake', intakeRouter);
|
|
app.use('/api/intake-actions', intakeActionsRouter);
|
|
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/upload-links', requireAdminGate, adminUploadLinksRouter);
|
|
app.use('/api/admin/item-drafts', requireAdminGate, adminItemDraftsRouter);
|
|
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;
|