feat(intake): issue and revoke named upload links (#222)

Three routes behind the admin gate: list, create, revoke. A link is named because provenance matters more than convenience — when one is shared further than intended the question is which one, and every submission will record the link it arrived through, so revoking kills that link rather than the feature.

The token is returned by exactly one response and is unrecoverable afterwards, which is why the admin screen has to present it as a one-time reveal. The listing selects its columns explicitly rather than `SELECT *`, so `token_hash` cannot reach a response the moment somebody adds a convenience — and a test asserts the listing carries neither the token nor the digest.

An absent `maxSubmissions` gets a bounded default of 25 rather than null. Absent means nobody decided; an explicit null means unlimited, which is a decision visible in the request. Reading absent as unlimited is what would quietly make every link unbounded, and the common case is the one that has to be safe.

Revoking is idempotent through COALESCE, and a test asserts the second call returns the *same* timestamp rather than merely succeeding. The useful fact is when access ended, and a button that errors on a double-click teaches people to distrust it — which is the last thing wanted on the control that contains a leak.

Mounted above the `/api/admin` catch-all, which would otherwise swallow the path, and behind requireAdminGate on the router itself per the reasoning in middleware/adminGate.ts.

Lint caught me reintroducing something this codebase had already solved: I wrote `.replace(/\/+$/, '')` to trim PUBLIC_URL, and app.ts carried a hand-written loop with a comment explaining that exact regex backtracks. Rather than duplicate the loop, trimTrailingSlashes moved to utils.ts and both callers now share it.

Backend: 272 integration (9 new), 308 unit, lint back to its 6 pre-existing warnings, build clean.

Ref #222
This commit is contained in:
2026-08-31 15:42:25 -05:00
parent 2b2cbe119e
commit 3392f6f10d
4 changed files with 247 additions and 7 deletions
+3 -7
View File
@@ -9,6 +9,7 @@ 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 adminVersionRouter from './routes/adminVersion';
import filtersRouter from './routes/filters';
import customersRouter from './routes/customers';
@@ -20,6 +21,7 @@ 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
@@ -36,13 +38,6 @@ app.use(cookieParser());
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';
@@ -79,6 +74,7 @@ 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);