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
221 lines
7.9 KiB
TypeScript
Executable File
221 lines
7.9 KiB
TypeScript
Executable File
import { useState, useRef } from 'react';
|
|
import Card from 'antd/es/card';
|
|
import Badge from 'antd/es/badge';
|
|
import Typography from 'antd/es/typography';
|
|
import Carousel from 'antd/es/carousel';
|
|
import Button from 'antd/es/button';
|
|
import message from 'antd/es/message';
|
|
import Tag from 'antd/es/tag';
|
|
import Modal from 'antd/es/modal';
|
|
import Tooltip from 'antd/es/tooltip';
|
|
import { LeftOutlined, RightOutlined, HeartOutlined, HeartFilled } from '@ant-design/icons';
|
|
import type { CarouselRef } from 'antd/es/carousel';
|
|
import { Item } from '../api';
|
|
import MarkdownView from './MarkdownView';
|
|
import { addToCart } from '../cart/cartApi';
|
|
import { useCart } from '../cart/CartContext';
|
|
import { useCustomerAuth } from '../customer/CustomerAuthContext';
|
|
import AuthPromptModal from '../customer/AuthPromptModal';
|
|
import { useFavorites } from '../customer/FavoritesContext';
|
|
import { addFavorite, removeFavorite, setFavoriteAlerts } from '../customer/favoritesApi';
|
|
import { uploadUrl } from '../uploadUrl';
|
|
|
|
const { Text, Title } = Typography;
|
|
|
|
interface Props {
|
|
item: Item;
|
|
onChanged: () => void;
|
|
// Render exactly as the storefront does, but inert. The admin's inventory
|
|
// preview uses this: the cart and favorites providers wrap the whole app, so
|
|
// without it an admin looking at an item could add their own stock to their
|
|
// own cart — and on a one-of-a-kind catalogue that reserves the item and
|
|
// takes it off sale.
|
|
//
|
|
// Deliberately not `disabled` on the buttons. A disabled antd button renders
|
|
// in a different colour with a different cursor and no hover, and the entire
|
|
// point of the preview is to show what a customer will actually see. The
|
|
// controls keep their normal appearance and their correct state for the
|
|
// item's status; only the handlers stop.
|
|
preview?: boolean;
|
|
}
|
|
|
|
export default function ItemCard({ item, onChanged, preview = false }: Readonly<Props>) {
|
|
const carouselRef = useRef<CarouselRef>(null);
|
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
const [adding, setAdding] = useState(false);
|
|
const [favoriting, setFavoriting] = useState(false);
|
|
// Which action to run once the auth modal succeeds — the modal is shared by
|
|
// Add to Cart and the heart.
|
|
const [pendingAction, setPendingAction] = useState<'cart' | 'favorite'>('cart');
|
|
const { customer, loading: authLoading, refresh: refreshCustomer } = useCustomerAuth();
|
|
const { itemIds, refresh: refreshCart } = useCart();
|
|
const { itemIds: favoriteIds, refresh: refreshFavorites } = useFavorites();
|
|
|
|
const inMyCart = itemIds.has(item.id);
|
|
const isFavorite = favoriteIds.has(item.id);
|
|
|
|
async function doAddToCart() {
|
|
setAdding(true);
|
|
try {
|
|
await addToCart(item.id);
|
|
message.success('Added to cart');
|
|
refreshCart();
|
|
onChanged();
|
|
} catch (err) {
|
|
message.error((err as Error).message);
|
|
} finally {
|
|
setAdding(false);
|
|
}
|
|
}
|
|
|
|
function handleAddClick() {
|
|
if (preview) return;
|
|
// Until the session has resolved, `customer` is null for a signed-in
|
|
// visitor too, and prompting them to sign in again would be wrong.
|
|
if (authLoading) return;
|
|
if (!customer) {
|
|
setPendingAction('cart');
|
|
setAuthModalOpen(true);
|
|
return;
|
|
}
|
|
void doAddToCart();
|
|
}
|
|
|
|
// Asked only once a customer has actually favorited something, so the reason
|
|
// for asking is concrete rather than an abstract marketing prompt. This is a
|
|
// consent of its own — accepting it does not sign anyone up for marketing.
|
|
function offerAlerts() {
|
|
Modal.confirm({
|
|
title: 'Want to know if this sells?',
|
|
content:
|
|
'Every piece is one of a kind, so a favorite can be bought by someone else at any time. ' +
|
|
'We can email you if that happens, so you are not left waiting on something that has gone. ' +
|
|
'This is only about items you favorite — it is separate from any marketing email.',
|
|
okText: 'Yes, email me',
|
|
cancelText: 'No thanks',
|
|
onOk: async () => {
|
|
try {
|
|
await setFavoriteAlerts(true);
|
|
refreshCustomer();
|
|
message.success('We will let you know');
|
|
} catch (err) {
|
|
message.error((err as Error).message);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
async function doToggleFavorite() {
|
|
setFavoriting(true);
|
|
const wasFavorite = isFavorite;
|
|
try {
|
|
if (wasFavorite) {
|
|
await removeFavorite(item.id);
|
|
} else {
|
|
await addFavorite(item.id);
|
|
}
|
|
refreshFavorites();
|
|
} catch (err) {
|
|
message.error((err as Error).message);
|
|
return;
|
|
} finally {
|
|
setFavoriting(false);
|
|
}
|
|
|
|
if (!wasFavorite && customer && !customer.favorite_alerts) offerAlerts();
|
|
}
|
|
|
|
function handleFavoriteClick() {
|
|
if (preview) return;
|
|
if (authLoading) return;
|
|
if (!customer) {
|
|
setPendingAction('favorite');
|
|
setAuthModalOpen(true);
|
|
return;
|
|
}
|
|
void doToggleFavorite();
|
|
}
|
|
|
|
const hasMultiple = item.images.length > 1;
|
|
|
|
const cover = item.images.length ? (
|
|
<div className="carousel-wrap">
|
|
<Carousel ref={carouselRef} dots={hasMultiple}>
|
|
{item.images.map(img => (
|
|
<div key={img.id}>
|
|
<img src={uploadUrl(img.image_path)} alt={item.name} className="card-cover-img" />
|
|
</div>
|
|
))}
|
|
</Carousel>
|
|
{hasMultiple && (
|
|
<>
|
|
<Button className="carousel-arrow carousel-arrow-left" shape="circle" size="small" icon={<LeftOutlined />}
|
|
onClick={(e) => { e.stopPropagation(); carouselRef.current?.prev(); }} />
|
|
<Button className="carousel-arrow carousel-arrow-right" shape="circle" size="small" icon={<RightOutlined />}
|
|
onClick={(e) => { e.stopPropagation(); carouselRef.current?.next(); }} />
|
|
<div className="carousel-count">{item.images.length} photos</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="card-cover-placeholder" />
|
|
);
|
|
|
|
let actionButton = null;
|
|
if (item.status === 'available') {
|
|
actionButton = (
|
|
<Button block type="primary" loading={adding} onClick={handleAddClick} style={{ marginTop: 8 }}>
|
|
Add to Cart
|
|
</Button>
|
|
);
|
|
} else if (item.status === 'reserved') {
|
|
actionButton = inMyCart
|
|
? <Button block disabled style={{ marginTop: 8 }}>In Your Cart</Button>
|
|
: <Button block disabled style={{ marginTop: 8 }}>Reserved</Button>;
|
|
}
|
|
|
|
const card = (
|
|
<Card hoverable cover={cover} className="item-card">
|
|
<div className="item-card-title">
|
|
<Title level={5} style={{ marginBottom: 4 }}>{item.name}</Title>
|
|
<Tooltip title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}>
|
|
<Button
|
|
type="text"
|
|
shape="circle"
|
|
loading={favoriting || authLoading}
|
|
aria-pressed={isFavorite}
|
|
aria-label={isFavorite ? `Remove ${item.name} from favorites` : `Add ${item.name} to favorites`}
|
|
icon={isFavorite ? <HeartFilled style={{ color: '#c41d7f' }} /> : <HeartOutlined />}
|
|
onClick={handleFavoriteClick}
|
|
/>
|
|
</Tooltip>
|
|
</div>
|
|
{item.category_name && <Text type="secondary" className="item-category">{item.category_name}</Text>}
|
|
<MarkdownView content={item.description} />
|
|
{item.tags.length > 0 && (
|
|
<div className="item-tags">
|
|
{item.tags.map(tag => (
|
|
<Tag key={tag.id} color={tag.color} style={{ marginInlineEnd: 4 }}>{tag.name}</Tag>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="price">${(item.price_cents / 100).toFixed(2)}</div>
|
|
{actionButton}
|
|
<AuthPromptModal
|
|
open={authModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
onSuccess={() => {
|
|
setAuthModalOpen(false);
|
|
if (pendingAction === 'favorite') void doToggleFavorite();
|
|
else void doAddToCart();
|
|
}}
|
|
/>
|
|
</Card>
|
|
);
|
|
|
|
if (item.status === 'sold') {
|
|
return <Badge.Ribbon text="SOLD" color="black">{card}</Badge.Ribbon>;
|
|
}
|
|
return card;
|
|
}
|