The uploads directory is the only place in this application where content someone else authored is served over HTTP. #95 stopped a dangerous file being stored; this stops a stored file doing damage if one ever gets there anyway — through a gap, a path added later, a restore, or a file written before that validation existed. Two halves, complementary rather than alternative. The app's own origin now serves uploads defensively. An allowlist of the three extensions the upload path can produce, so a `.html` or a `.svg` on disk is simply not a file this application hands out — 404, the same answer as a file that is not there, so the response cannot be used to learn which paths exist. An allowlist rather than a denylist because a denylist has to anticipate every type a browser might execute, which is a moving target across browsers and years, while this only has to know three. The content type is stated explicitly from that same list rather than sniffed or guessed from a name someone else chose, paired with `nosniff`. `default-src 'none'; sandbox` gives a directly-navigated file no capabilities at all, which is the only way one of these can do harm — an `<img>` embed does not execute script. Writes get 405 rather than falling through to a 404 that suggests the path is wrong. The other half is the separate origin, which is the real fix, because the origin is the whole unit of trust in a browser. That needs a hostname and a certificate, which live outside this repository, so what is here is the switch: `UPLOADS_BASE_URL`, sent to the frontend at runtime through `/api/config` and joined onto stored paths by `uploadUrl`. Empty means the app's own origin, which is the default and what local development has, so nothing changes until it is pointed somewhere. Stored paths stay site-relative. A stored value outlives any hostname baked into it, and rewriting them would be a migration to undo the day the hostname changes. Runtime rather than built in, so one image serves every environment — the same reason `paypalClientId` and `demoMode` are already there. `UPLOADS_BASE_URL` has a line in `docker-compose.prod.yml` while still empty, deliberately: a Portainer stack variable with no line there is substituted into the file and never reaches the container, which is exactly how `UPLOADS_DIR` went missing on 2026-08-23. Unset warns at boot, in the same shape as the admin gate — a working configuration with one defence switched off is worth saying out loud. Set without a scheme is refused outright, because a bare hostname joins onto a stored path as if it were relative and breaks every image on the site rather than failing visibly. The compose guard now resolves `${VAR:-default}` to its default, which is what the container actually receives when the stack variable behind it is unset. A bare `${VAR}` is still left opaque, so a required variable referenced that way goes on counting as present — that check is about the line existing, not about the stack being filled in. Closes #103
202 lines
6.6 KiB
TypeScript
Executable File
202 lines
6.6 KiB
TypeScript
Executable File
import type { ItemFilters } from './filters';
|
|
import { filtersToSearchParams } from './filters';
|
|
import { setUploadsBase } from './uploadUrl';
|
|
|
|
export interface ItemTag {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
}
|
|
|
|
export interface Item {
|
|
id: number;
|
|
name: string;
|
|
description: string | null;
|
|
price_cents: number;
|
|
images: { id: number; image_path: string; sort_order: number }[];
|
|
status: 'pending' | 'available' | 'reserved' | 'sold';
|
|
category_id: number | null;
|
|
category_name: string | null;
|
|
tags: ItemTag[];
|
|
}
|
|
|
|
export interface Category {
|
|
id: number;
|
|
name: string;
|
|
parent_id: number | null;
|
|
sort_order: number;
|
|
item_count: number;
|
|
}
|
|
|
|
export interface Tag {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
item_count: number;
|
|
}
|
|
|
|
export interface FilterOptions {
|
|
categories: Category[];
|
|
tags: Tag[];
|
|
priceRange: { min_cents: number; max_cents: number };
|
|
}
|
|
|
|
export interface SiteConfig {
|
|
paypalClientId: string | null;
|
|
demoMode: boolean;
|
|
currency: string;
|
|
/** Origin for uploaded images. Empty means the app's own — see uploadUrl. */
|
|
uploadsBaseUrl: string;
|
|
}
|
|
|
|
export async function fetchConfig(): Promise<SiteConfig> {
|
|
const res = await fetch('/api/config');
|
|
const config = (await res.json()) as SiteConfig;
|
|
// Applied here rather than by each caller, so no caller can fetch the config
|
|
// and forget to — the uploads origin is a property of the deployment, not of
|
|
// whichever screen happened to ask for it.
|
|
setUploadsBase(config.uploadsBaseUrl);
|
|
return config;
|
|
}
|
|
|
|
export async function fetchItems(filters?: ItemFilters): Promise<Item[]> {
|
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
|
const res = await fetch(query ? `/api/items?${query}` : '/api/items');
|
|
// An error response still parses as JSON — as `{ error: ... }`, not an array.
|
|
// Returning that unchecked would set it as the item list and crash the grid
|
|
// on `.map`, so a failure has to surface as a rejection the caller can show.
|
|
if (!res.ok) throw new Error('failed to load items');
|
|
return res.json();
|
|
}
|
|
|
|
export async function fetchFilterOptions(): Promise<FilterOptions> {
|
|
const res = await fetch('/api/filters');
|
|
if (!res.ok) throw new Error('failed to load filters');
|
|
return res.json();
|
|
}
|
|
|
|
// Every admin call goes through this. Without the res.ok check a 4xx/5xx still
|
|
// resolves — the caller then reports success for a write that never happened,
|
|
// which is worse than failing outright because nothing prompts the user to look
|
|
// for the missing row.
|
|
async function expectOk(res: Response, action: string): Promise<Response> {
|
|
if (res.ok) return res;
|
|
const detail = await res.json().catch(() => null);
|
|
throw new Error(detail?.error ? `${action}: ${detail.error}` : action);
|
|
}
|
|
|
|
export async function fetchAdminItems(filters?: ItemFilters): Promise<Item[]> {
|
|
const query = filters ? filtersToSearchParams(filters).toString() : '';
|
|
const res = await expectOk(
|
|
await fetch(query ? `/api/admin/items?${query}` : '/api/admin/items'),
|
|
'failed to load items'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
export async function saveItem(id: number | null, formData: FormData): Promise<Item> {
|
|
const url = id ? `/api/admin/items/${id}` : '/api/admin/items';
|
|
const res = await expectOk(
|
|
await fetch(url, { method: id ? 'PUT' : 'POST', body: formData }),
|
|
'failed to save item'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
export async function deleteItem(id: number): Promise<void> {
|
|
await expectOk(await fetch(`/api/admin/items/${id}`, { method: 'DELETE' }), 'failed to delete item');
|
|
}
|
|
|
|
export async function deleteItemImage(itemId: number, imageId: number): Promise<void> {
|
|
await expectOk(
|
|
await fetch(`/api/admin/items/${itemId}/images/${imageId}`, { method: 'DELETE' }),
|
|
'failed to remove image'
|
|
);
|
|
}
|
|
|
|
export async function markSold(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/mark-sold`, { method: 'POST' }),
|
|
'failed to mark sold'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// Publishing a pending item is mark-available: it is the same transition and
|
|
// the same UPDATE, so the admin UI simply labels the button "Publish" when the
|
|
// item is pending rather than calling a second endpoint that does the same
|
|
// thing.
|
|
export async function markAvailable(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/mark-available`, { method: 'POST' }),
|
|
'failed to mark available'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// Not symmetrical with the above: the server refuses to unpublish a reserved or
|
|
// sold item and says which, so the message it returns is worth surfacing rather
|
|
// than replacing with a generic one.
|
|
export async function unpublishItem(id: number): Promise<Item> {
|
|
const res = await expectOk(
|
|
await fetch(`/api/admin/items/${id}/unpublish`, { method: 'POST' }),
|
|
'failed to unpublish'
|
|
);
|
|
return res.json();
|
|
}
|
|
|
|
// The admin endpoints return a JSON error body on 4xx; surfacing its message
|
|
// lets the UI say "that name is already used here" instead of a generic
|
|
// failure.
|
|
async function sendJson<T>(url: string, method: string, body?: unknown): Promise<T> {
|
|
const res = await fetch(url, {
|
|
method,
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: body === undefined ? undefined : JSON.stringify(body)
|
|
});
|
|
if (!res.ok) {
|
|
const detail = await res.json().catch(() => ({ error: 'request failed' }));
|
|
throw new Error(detail.error || 'request failed');
|
|
}
|
|
return res.status === 204 ? (undefined as T) : res.json();
|
|
}
|
|
|
|
export async function fetchAdminCategories(): Promise<Category[]> {
|
|
const res = await fetch('/api/admin/categories');
|
|
return res.json();
|
|
}
|
|
|
|
export function createCategory(name: string, parentId: number | null): Promise<Category> {
|
|
return sendJson('/api/admin/categories', 'POST', { name, parent_id: parentId });
|
|
}
|
|
|
|
export function updateCategory(
|
|
id: number,
|
|
changes: { name?: string; parent_id?: number | null }
|
|
): Promise<Category> {
|
|
return sendJson(`/api/admin/categories/${id}`, 'PUT', changes);
|
|
}
|
|
|
|
export function deleteCategory(
|
|
id: number
|
|
): Promise<{ deleted_categories: number; uncategorized_items: number }> {
|
|
return sendJson(`/api/admin/categories/${id}`, 'DELETE');
|
|
}
|
|
|
|
export async function fetchAdminTags(): Promise<Tag[]> {
|
|
const res = await fetch('/api/admin/tags');
|
|
return res.json();
|
|
}
|
|
|
|
export function createTag(name: string): Promise<Tag> {
|
|
return sendJson('/api/admin/tags', 'POST', { name });
|
|
}
|
|
|
|
export function updateTag(id: number, changes: { name?: string; color?: string }): Promise<Tag> {
|
|
return sendJson(`/api/admin/tags/${id}`, 'PUT', changes);
|
|
}
|
|
|
|
export function deleteTag(id: number): Promise<void> {
|
|
return sendJson(`/api/admin/tags/${id}`, 'DELETE');
|
|
}
|