There was no way to tell which build an environment was running. That is not hypothetical: minutes after #232 merged, `npm run backfill:images` in QA failed with `tsx: not found` because the container was still serving the pre-merge image, and the only thing that revealed it was npm echoing the old script line. Had the change been anywhere other than a package.json script, the container would have looked healthy while running the wrong code.
The header now reads something like `a5076cc · built 29 Aug 20:36`. The commit answers "is this the code I expect"; the build time answers "did my redeploy actually rebuild", which is a different question and the one that would have caught the case above.
The commit is read out of `.git` directly rather than by shelling out, because node:20-bookworm-slim has no git binary and adding an apt layer so the image can print seven characters is a poor trade. `.git` is copied into the build stage only — verified absent from the final image — so no repository history reaches a deployed container.
Resolution is pure and separately tested across every shape that actually occurs: a detached HEAD holding the object name, which is what a checkout of a ref produces; a symbolic HEAD followed to a loose ref file; the same followed to packed-refs, which is what a fresh clone commonly has; peeled `^` tag lines ignored so an annotated tag cannot yield the wrong commit; and every failure path returning `unknown`. That last part is the one that matters most — this runs during a Docker build, and a version stamp must never be the thing that stops a deploy.
Served from a gated /api/admin/version rather than folded into /api/config. That endpoint is public, and a commit hash there would tell any storefront visitor exactly which revision of a public repository is deployed. An integration test asserts the gate and asserts the public config does not carry it, because the boundary is the whole point rather than an implementation detail.
Verified in the built image rather than argued: the stamp inside it reads a5076cc, matching `git rev-parse --short HEAD`, and a running container serves it from /api/admin/version while /api/config returns only what it did before.
Backend: 296 unit, 263 integration, tsc clean, lint unchanged at six pre-existing warnings. Frontend builds clean with its two pre-existing warnings untouched.
Closes #233
114 lines
5.4 KiB
TypeScript
Executable File
114 lines
5.4 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 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;
|