The public way in. Photos of one item plus a free-text note, from someone with no account, landing as an `items` row at status 'pending' — already invisible to every public and storefront query since #90, so nothing is live by accident. Every refusal is a 404. Unknown, revoked and exhausted links are indistinguishable from outside, because whether a link exists is not something a stranger needs to be able to learn — the same reasoning uploads.ts applies to files. The link is resolved *before* multer runs, and that ordering is the point rather than an implementation detail. discardUnlessAccepted would delete the files afterwards, but "written then deleted" is materially worse than "never written" on an endpoint the whole internet can reach: it is disk churn an unauthenticated caller controls, and it leans on an unlink that a crash between write and delete would skip. A test asserts the volume is untouched for a bad token, so a future reordering fails loudly instead of quietly handing that control away. The link counter is incremented inside the transaction and guarded on the same conditions as the lookup, so two submissions racing for the last slot of a capped link cannot both succeed. The response carries no item id: the sender has no business knowing about the catalogue and nothing they could do with it. The AI is deliberately not called here. A slow or failing model request must not turn into a failed upload for someone who did nothing wrong, and the photos may be the only copy — the item is often no longer in the sender's hands. The row waits at state 'queued' for #223. The new limiter keys on the caller alone, since a submission carries no email. keyByCallerAndEmail's comment warns that a bare ip bucket is a shared allowance, and that trade is taken knowingly: the link is the per-caller identity and its cap is the per-caller bound, while this limiter does the different job of bounding what one address can throw at an endpoint that writes files. Twenty per fifteen minutes is deliberately looser than the password-reset allowance — somebody photographing a box of stock legitimately submits several in a row, and refusing them costs a consignment. Because the route mounts the shared uploadImages, it inherits the type allowlist, the magic-byte check and #226's EXIF stripping without asking for any of them. A test asserts the stripping specifically, since this is the route where it matters most: the photo comes from a stranger's phone rather than the shop's own camera. Backend: 284 integration (12 new), 309 unit, lint unchanged at 6 pre-existing warnings, build clean. Ref #222
114 lines
5.6 KiB
TypeScript
Executable File
114 lines
5.6 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 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/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/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;
|