Three routes in admin.ts answered a miss with a success. PUT /items/:id ran an UPDATE that matched nothing, committed happily, selected nothing back and replied 200 with an empty body — a success the admin client could do nothing with, and no record anywhere that the item was not found. mark-sold and mark-available did the same. The create route beside them has always used requireRow for exactly this, which is why this reads as an oversight rather than a decision.
A garbage id was worse in a different direction. Number('abc') is NaN, the driver sends it to Postgres as the text "NaN", Postgres raises 22P02 for an integer column, and the catch turned that into a 500 — so a caller asking for an item that cannot exist was told the server broke. Both now answer 404, because from the caller's side "/items/abc" identifies no item in exactly the way "/items/999999" does.
readId is shared rather than repeated, and rejects zero, negatives and fractions as well as text: every id in this schema is a positive serial, so anything else identifies nothing.
mark-sold now notifies favouriters only after the row is known to exist, so nobody is told about a sale that did not happen.
The issue asked for the same shape to be checked across the other admin routes. It was: unpublish already looks the item up and 404s, and the tags and categories PUT routes both do an existence check before their UPDATE, so their rows[0] is guaranteed. items.ts already guards the public read. These three were the only ones lying about a miss.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
112 lines
4.7 KiB
TypeScript
Executable File
112 lines
4.7 KiB
TypeScript
Executable File
export function toCents(price: string | number): number {
|
|
const n = typeof price === 'string' ? parseFloat(price) : price;
|
|
if (Number.isNaN(n) || n < 0) {
|
|
throw new Error('invalid price');
|
|
}
|
|
return Math.round(n * 100);
|
|
}
|
|
|
|
export function formatPrice(cents: number): string {
|
|
return `$${(cents / 100).toFixed(2)}`;
|
|
}
|
|
|
|
// RFC 5321 caps an address at 254 characters; reject anything longer up front so
|
|
// validation cost stays bounded regardless of what a client posts.
|
|
const MAX_EMAIL_LENGTH = 254;
|
|
|
|
// Both patterns are anchored single character classes with no overlapping
|
|
// alternatives, so they match in linear time. Splitting on '@' and '.' in code
|
|
// rather than in one combined pattern avoids the ambiguous (and backtracking)
|
|
// `[^\s@]+\.[^\s@]+` domain match.
|
|
const LOCAL_PART_RE = /^[^\s@]+$/;
|
|
const DOMAIN_LABEL_RE = /^[^\s@.]+$/;
|
|
|
|
export function isValidEmail(email: string): boolean {
|
|
const trimmed = email.trim();
|
|
if (trimmed.length === 0 || trimmed.length > MAX_EMAIL_LENGTH) {
|
|
return false;
|
|
}
|
|
|
|
const at = trimmed.indexOf('@');
|
|
if (at === -1 || at !== trimmed.lastIndexOf('@')) {
|
|
return false;
|
|
}
|
|
|
|
if (!LOCAL_PART_RE.test(trimmed.slice(0, at))) {
|
|
return false;
|
|
}
|
|
|
|
const labels = trimmed.slice(at + 1).split('.');
|
|
return labels.length >= 2 && labels.every((label) => DOMAIN_LABEL_RE.test(label));
|
|
}
|
|
|
|
// antd's preset Tag colours. Kept as the single source of truth for tag
|
|
// colours so the admin palette picker and the auto-assignment below can never
|
|
// drift apart — the frontend renders whatever string lands in tags.color.
|
|
export const TAG_COLORS: [string, ...string[]] = [
|
|
'magenta', 'red', 'volcano', 'orange', 'gold', 'lime',
|
|
'green', 'cyan', 'blue', 'geekblue', 'purple'
|
|
];
|
|
|
|
// Tags get a colour the moment they're created inline from the item form, with
|
|
// no prompt. Deriving it from the name (rather than picking at random or
|
|
// round-robining on insert order) means the same tag name always lands on the
|
|
// same colour, so a tag deleted and re-added doesn't silently change colour.
|
|
// The admin can still override it afterwards.
|
|
export function tagColorFor(name: string): string {
|
|
const normalized = name.trim().toLowerCase();
|
|
// djb2 — cheap, well-spread for short strings, and stable across Node
|
|
// versions. `| 0` keeps it in int32 range instead of drifting into float.
|
|
let hash = 5381;
|
|
for (let i = 0; i < normalized.length; i++) {
|
|
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
|
|
}
|
|
// The modulo keeps this in range, but an index signature cannot say so. The
|
|
// fallback is the first colour rather than a throw: a tag with an unexpected
|
|
// colour is not worth failing a request over.
|
|
// TAG_COLORS is typed as a non-empty tuple, so index 0 is known to exist —
|
|
// the annotation, rather than `as const`, because the elements must stay
|
|
// `string` for the callers that assign them. The modulo keeps the computed
|
|
// index in range; the fallback only exists because indexing cannot say so.
|
|
return TAG_COLORS[Math.abs(hash) % TAG_COLORS.length] ?? TAG_COLORS[0];
|
|
}
|
|
|
|
export const MARKETING_CONSENT_TEXT =
|
|
'I want to receive occasional emails about new one-of-a-kind items from Redefined Designs. I can unsubscribe at any time.';
|
|
|
|
/**
|
|
* Strips trailing slashes so a base URL can be joined with a stored path.
|
|
*
|
|
* A loop rather than `/\/+$/`, which backtracks: sonarjs flags that pattern as
|
|
* super-linear, and the input here is an environment variable rather than
|
|
* anything hostile, but the cheap version is no harder to read.
|
|
*
|
|
* Shared because two callers now need it — `/api/config` sends
|
|
* `uploadsBaseUrl` this way, and the upload-link routes build a submission URL
|
|
* from PUBLIC_URL. Stored paths always begin with a slash, so trimming the
|
|
* base is what stops the join producing a double.
|
|
*/
|
|
export function trimTrailingSlashes(value: string): string {
|
|
let trimmed = value;
|
|
while (trimmed.endsWith('/')) trimmed = trimmed.slice(0, -1);
|
|
return trimmed;
|
|
}
|
|
|
|
/**
|
|
* A route's `:id` as a positive integer, or null when it is not one.
|
|
*
|
|
* Guarding this is not cosmetic. `Number('abc')` is NaN, which the driver sends
|
|
* to Postgres as the text "NaN"; Postgres raises 22P02 for an integer column,
|
|
* the route's catch turns that into a 500, and a caller asking for an item that
|
|
* cannot exist is told the server broke. Returning null lets the route answer
|
|
* 404, which is what "/items/abc" actually means. See #207.
|
|
*
|
|
* Rejects 0 and negatives as well as fractions: every id in this schema is a
|
|
* positive serial, so anything else identifies nothing.
|
|
*/
|
|
export function readId(value: string | undefined): number | null {
|
|
if (value === undefined || value.trim() === '') return null;
|
|
const parsed = Number(value);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : null;
|
|
}
|