diff --git a/README.md b/README.md index 5d09cd4..273bbf4 100644 --- a/README.md +++ b/README.md @@ -211,6 +211,12 @@ The house rule: everything ships a TUI, a CLI, an MCP and an API. | MCP | `pit mcp`: tools `search`, `moshpit_resolve`, `moshpit_network`, `listing`, `pros`, `ring`, `edit_listing`, `vote_tallies`, `vote_top`. In the official MCP Registry as `world.pit/pit` (`server.json`) | | PWA | installable from any browser (`/manifest.webmanifest`, share target into `/search`); the worker in `public/sw.js` caches the static shell and an offline page, never a search | +## Art Auctions House + +`auctions.pit.world` is the canonical Art Auctions House site. `art.auctions.pit.world` redirects there. Artists can submit a title, name, description and photo; submissions appear publicly in the gallery. Photos up to 8 MB are stored in the local `auctions.db` database, or the path in `PIT_AUCTIONS_DB`. + +The gallery and photo submissions are live; bidding and payment are not active. Those terms need to be decided before transactions are added. + ### Newsletter (one list for every Profullstack property) `GET /subscribe` shows the form; `POST /subscribe` (form: `email`, optional `site`) and `POST /api/subscribe` (JSON, CORS `*`) start a double opt-in. The @profullstack/footer subscribe form posts here with `site=`, so every signup keeps the property it came from; pit.world, `*.pit.world` and Moshpit names get pit.world copy, every other site gets Profullstack copy and a link back. An address already on the list still gets an email (its click records the new property). diff --git a/bin/install.sh b/bin/install.sh index ed0edee..4bd1df3 100755 --- a/bin/install.sh +++ b/bin/install.sh @@ -451,6 +451,81 @@ write_nginx() { fi } +# pit.world's relay config is shipped with the app because first-visit TLS and +# nested Moshpit host routing are part of the site's production path. +write_pit_proxy() { + local source_conf="$SCRIPT_DIR/../deploy/pit-proxy.nginx.conf" + local source_cert="$SCRIPT_DIR/../deploy/pit-proxy-cert" + [ -f "$source_conf" ] && [ -f "$source_cert" ] || return 0 + need_root "pit.world relay" + command -v nginx >/dev/null || apt_install nginx + + local conf link cert_script backup_dir stamp conf_changed=0 link_changed=0 conf_existed=0 conf_backed_up=0 + if [ -d /etc/nginx/sites-available ]; then + conf=/etc/nginx/sites-available/pit-proxy + link=/etc/nginx/sites-enabled/pit-proxy + else + conf=/etc/nginx/conf.d/pit-proxy.conf + link="" + fi + cert_script=/usr/local/bin/pit-proxy-cert + backup_dir=/var/backups/pit-world + stamp="$(date -u +%Y%m%dT%H%M%SZ)" + + # Accept files installed by older pit.world deploys, as well as ones carrying + # the installer marker. Refuse to overwrite unrelated server configuration. + if $SUDO test -f "$conf" && ! $SUDO grep -qF "$MARKER" "$conf"; then + $SUDO grep -qF "# .pit.world: pit.world's relay" "$conf" || die "$conf exists and is not pit.world's relay config" + fi + if $SUDO test -f "$cert_script" && ! $SUDO grep -qF "$MARKER" "$cert_script"; then + $SUDO grep -qF "# Make (or keep) the Let's Encrypt certificate for one Moshpit name on pit.world." "$cert_script" || die "$cert_script exists and is not pit.world's certificate helper" + fi + if [ -n "$link" ] && $SUDO test -e "$link" && ! $SUDO test -L "$link"; then + die "$link exists and is not a symlink; move it aside before installing the relay config" + fi + if [ -n "$link" ] && $SUDO test -L "$link" && [ "$( $SUDO readlink "$link" )" != "$conf" ]; then + die "$link points somewhere else; move it aside before installing the relay config" + fi + + $SUDO install -d -m 0700 "$backup_dir" + if $SUDO test -f "$conf"; then + conf_existed=1 + if ! $SUDO cmp -s "$source_conf" "$conf"; then + $SUDO cp -a "$conf" "$backup_dir/pit-proxy.nginx.conf.$stamp" + conf_backed_up=1 + fi + fi + if $SUDO test -f "$cert_script" && ! $SUDO cmp -s "$source_cert" "$cert_script"; then + $SUDO cp -a "$cert_script" "$backup_dir/pit-proxy-cert.$stamp" + fi + + AS="$SUDO" + CHANGED=0 + put_root_file "$conf" 0644 < "$source_conf" + conf_changed="$CHANGED" + if [ -n "$link" ] && ! $SUDO test -L "$link"; then + $SUDO ln -s "$conf" "$link" + link_changed=1 + fi + CHANGED=0 + put_root_file "$cert_script" 0755 < "$source_cert" + + if [ "$conf_changed" = 1 ] || [ "$link_changed" = 1 ]; then + if ! $SUDO nginx -t >/dev/null 2>&1; then + if [ "$conf_backed_up" = 1 ]; then + $SUDO cp -a "$backup_dir/pit-proxy.nginx.conf.$stamp" "$conf" + elif [ "$conf_existed" = 0 ]; then + $SUDO rm -f "$conf" + fi + [ "$link_changed" = 0 ] || $SUDO rm -f "$link" + $SUDO nginx -t || true + die "nginx -t failed for the pit.world relay; restored its previous config" + fi + $SUDO systemctl reload nginx + log "nginx: pit.world relay config reloaded" + fi +} + health() { local deadline=$(( $(date +%s) + HEALTH_TIMEOUT )) url="http://127.0.0.1:$PORT$HEALTH_PATH" log "health: waiting for $url (up to ${HEALTH_TIMEOUT}s)" @@ -474,6 +549,7 @@ phase_activate() { return 0 fi systemd_mode + write_pit_proxy if [ "$SYSTEMD_MODE" = user ] && [ "$(loginctl show-user "$(id -un)" -p Linger --value 2>/dev/null)" != yes ]; then loginctl enable-linger "$(id -un)" 2>/dev/null \ || warn "could not enable linger: $UNIT stops when $(id -un) logs out (run: sudo loginctl enable-linger $(id -un))" diff --git a/deploy/pit-proxy-cert b/deploy/pit-proxy-cert index f429464..87df4dd 100755 --- a/deploy/pit-proxy-cert +++ b/deploy/pit-proxy-cert @@ -1,13 +1,14 @@ #!/bin/sh +# managed by bin/install.sh # Make (or keep) the Let's Encrypt certificate for one Moshpit name on pit.world. # Installed as /usr/local/bin/pit-proxy-cert; pit.world runs it on a host's first # plain-http visit (src/proxy.ts, src/services.ts), and it is safe to run by hand: # pit-proxy-cert family.ancestry.pit.world # pit-proxy-cert bbs.family.ancestry.pit.world (same certificate) # -# One certificate per name covers all three of its hosts: +# For a nested Moshpit name, one certificate covers all three of its hosts: # .pit.world social..pit.world bbs..pit.world -# so a name costs one of Let's Encrypt's 50 certificates a week for pit.world, not three. +# A one-label pit.world app host only needs a certificate for itself. set -eu # nginx workers (www-data) read these at handshake time; /etc/letsencrypt/live is root only. @@ -21,14 +22,14 @@ install_for_nginx() { # As the certbot deploy hook: copy whatever was just renewed, under every host it covers. if [ "${1:-}" = "--renewed" ]; then for d in ${RENEWED_DOMAINS:-}; do - case "$d" in *.*.pit.world) install_for_nginx "${RENEWED_LINEAGE:-/etc/letsencrypt/live/$d}" "$d" ;; esac + case "$d" in *.pit.world) install_for_nginx "${RENEWED_LINEAGE:-/etc/letsencrypt/live/$d}" "$d" ;; esac done exit 0 fi host="${1:-}" case "$host" in - *.*.pit.world) ;; + *.pit.world) ;; *) echo "usage: pit-proxy-cert .pit.world" >&2; exit 2 ;; esac printf '%s' "$host" | grep -Eq '^[a-z0-9.-]+$' || { echo "bad host: $host" >&2; exit 2; } @@ -40,15 +41,25 @@ case "$host" in bbs.*.*.pit.world) base="${host#bbs.}" ;; esac +# A nested Moshpit name gets one certificate for its three site hosts. A one-label +# pit.world app host (such as auctions.pit.world) needs only its own certificate. # This box's root umask is 007; nginx (www-data) must read the challenge files. umask 022 mkdir -p /var/www/pit-acme/.well-known/acme-challenge chmod -R a+rX /var/www/pit-acme exec 9>/run/lock/pit-proxy-cert.lock flock 9 -certbot certonly --webroot -w /var/www/pit-acme -d "$base" -d "social.$base" -d "bbs.$base" --cert-name "$base" \ - --non-interactive --agree-tos --keep-until-expiring --expand --quiet -for h in "$base" "social.$base" "bbs.$base"; do +case "$base" in + *.*.pit.world) + certbot certonly --webroot -w /var/www/pit-acme -d "$base" -d "social.$base" -d "bbs.$base" --cert-name "$base" \ + --non-interactive --agree-tos --keep-until-expiring --expand --quiet + hosts="$base social.$base bbs.$base" ;; + *.pit.world) + certbot certonly --webroot -w /var/www/pit-acme -d "$base" --cert-name "$base" \ + --non-interactive --agree-tos --keep-until-expiring --expand --quiet + hosts="$base" ;; +esac +for h in $hosts; do install_for_nginx "/etc/letsencrypt/live/$base" "$h" done nginx -t -q && systemctl reload nginx diff --git a/deploy/pit-proxy.nginx.conf b/deploy/pit-proxy.nginx.conf index 471dac6..da6725b 100644 --- a/deploy/pit-proxy.nginx.conf +++ b/deploy/pit-proxy.nginx.conf @@ -1,9 +1,11 @@ +# managed by bin/install.sh # .pit.world: pit.world's relay of every Moshpit name to the old internet (src/proxy.ts). # Installed as /etc/nginx/sites-available/pit-proxy (symlinked into sites-enabled). # # DNS: one wildcard, *.pit.world -> dev2. TLS: a wildcard certificate covers a single -# label and these hosts have two or more (family.ancestry.pit.world), so every name has -# its own Let's Encrypt certificate, chosen here by SNI. A certificate named by a variable +# label; nested names and pit.world-owned app hosts have an exact certificate chosen by SNI. +# The relay handles one or more labels before .pit.world, including auctions.pit.world. +# A certificate named by a variable # is read by the worker (www-data) at handshake time, and /etc/letsencrypt/live is root # only, so pit-proxy-cert copies each one to /etc/nginx/pit-proxy-certs (root:www-data). /usr/local/bin/pit-proxy-cert makes one; the app asks for it on a name's first # plain-http visit. pit.world and www.pit.world have their own exact server blocks, @@ -12,7 +14,7 @@ server { listen 80; listen [::]:80; - server_name ~^[a-z0-9-]+(\.[a-z0-9-]+)+\.pit\.world$; + server_name ~^[a-z0-9-]+(\.[a-z0-9-]+)*\.pit\.world$; access_log off; error_log /var/log/nginx/pit-proxy.error.log crit; @@ -32,7 +34,7 @@ server { listen 443 ssl; listen [::]:443 ssl; http2 on; - server_name ~^[a-z0-9-]+(\.[a-z0-9-]+)+\.pit\.world$; + server_name ~^[a-z0-9-]+(\.[a-z0-9-]+)*\.pit\.world$; ssl_certificate /etc/nginx/pit-proxy-certs/$ssl_server_name.crt; ssl_certificate_key /etc/nginx/pit-proxy-certs/$ssl_server_name.key; diff --git a/src/auctions.ts b/src/auctions.ts new file mode 100644 index 0000000..a156fcd --- /dev/null +++ b/src/auctions.ts @@ -0,0 +1,178 @@ +import { Database } from 'bun:sqlite' +import { mkdirSync } from 'node:fs' +import { dirname } from 'node:path' +import { randomBytes } from 'node:crypto' +import { Hono } from 'hono' +import { RateLimiter } from './ratelimit.js' + +export const AUCTION_HOST = 'auctions.pit.world' +export const AUCTION_ALIAS = 'art.auctions.pit.world' +export const AUCTION_HOSTS = [AUCTION_HOST, AUCTION_ALIAS] as const + +export function isAuctionHost(value: string | null | undefined): boolean { + const host = String(value ?? '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '') + return (AUCTION_HOSTS as readonly string[]).includes(host) +} + +type Artwork = { + id: string + title: string + artist: string + medium: string + description: string + image_type: string + created_at: number +} + +type ArtworkImage = { image_type: string; image: Uint8Array } + +const MAX_IMAGE_BYTES = 8 * 1024 * 1024 +const IMAGE_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/avif', 'image/heic', 'image/heif']) +const esc = (value: unknown) => String(value ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!) +const clean = (value: unknown, length: number) => String(value ?? '').replace(/[\u0000-\u001f\u007f]/g, ' ').trim().slice(0, length) + +function validImage(type: string, bytes: Uint8Array): boolean { + if (type === 'image/jpeg') return bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff + if (type === 'image/png') return bytes.length >= 8 && [137, 80, 78, 71, 13, 10, 26, 10].every((b, i) => bytes[i] === b) + if (type === 'image/webp') return bytes.length >= 12 && String.fromCharCode(...bytes.slice(0, 4)) === 'RIFF' && String.fromCharCode(...bytes.slice(8, 12)) === 'WEBP' + if (type === 'image/avif') return bytes.length >= 12 && String.fromCharCode(...bytes.slice(4, 12)).startsWith('ftypavif') + if (type === 'image/heic' || type === 'image/heif') { + if (bytes.length < 12) return false + const brand = String.fromCharCode(...bytes.slice(4, 12)) + return brand.startsWith('ftypheic') || brand.startsWith('ftypheix') || brand.startsWith('ftyphevc') || brand.startsWith('ftypmif1') || brand.startsWith('ftypmsf1') + } + return false +} + +function page(title: string, body: string, description = 'A home for art, artists and the stories behind every piece.', canonicalPath = '/') { + return ` +${esc(title)} · Art Auctions +
AArt AuctionsTHE HOUSE FOR ORIGINAL WORK
${body}` +} + +function artworkCard(item: Artwork) { + return `` +} + +function home(house: AuctionHouse) { + const works = house.list() + const gallery = works.length + ? `` + : `

The walls are waiting.

Bring the first piece. Share the work you want the world to see.

Submit your artwork
` + return page('Art Auctions House', `
+

ART · PEOPLE · STORIES

Good art deserves
a good home.

A new house for original art, made by people with something to say.

Bring us a piece

Made for artists. Ready for your camera roll.

01 / 01
+ +

AN OPEN DOOR

Make room
for your work.

Send a photo and a few words about the piece. It will appear in the gallery with your name.

Add your artwork Only share work you own or have permission to display. The gallery is public.
+
`) +} + +function formPage(error = '') { + return page('Submit artwork', `
← Back to the house

ADD A PIECE TO THE WALLS

Show us
what you made.

Share a clear photo and a little about the work. Your submission will appear in the public gallery.

${error ? `` : ''}

Submissions are public as soon as they are added. Bidding and payment features are not active yet.

`, undefined, '/new') +} + +function detail(item: Artwork) { + return page(item.title, `
← Back to the gallery
${esc(item.title)} by ${esc(item.artist)}

${esc(item.medium || 'ORIGINAL WORK')}

${esc(item.title)}

by ${esc(item.artist)}

${item.description ? `

${esc(item.description).replace(/\n/g, '
')}

` : ''}Add your work

Bidding and payment features are not active yet.

`, 'A piece in the Art Auctions House gallery.', `/art/${encodeURIComponent(item.id)}`) +} + +export class AuctionHouse { + readonly db: Database + private readonly submissions = new RateLimiter(2, 1 / 15) + + constructor(path = ':memory:') { + if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true }) + this.db = new Database(path, { create: true }) + this.db.exec('PRAGMA journal_mode = WAL; PRAGMA foreign_keys = ON;') + this.db.exec(`CREATE TABLE IF NOT EXISTS auction_artworks ( + id TEXT PRIMARY KEY, + title TEXT NOT NULL, + artist TEXT NOT NULL, + medium TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + image_type TEXT NOT NULL, + image BLOB NOT NULL, + created_at INTEGER NOT NULL + ); CREATE INDEX IF NOT EXISTS auction_artworks_created ON auction_artworks(created_at DESC);`) + } + + list(limit = 48): Artwork[] { + return this.db.query('SELECT id, title, artist, medium, description, image_type, created_at FROM auction_artworks ORDER BY created_at DESC LIMIT ?').all(Math.max(1, Math.min(100, limit))) + } + + get(id: string): Artwork | null { + return this.db.query('SELECT id, title, artist, medium, description, image_type, created_at FROM auction_artworks WHERE id = ?').get(id) ?? null + } + + image(id: string): ArtworkImage | null { + return this.db.query('SELECT image_type, image FROM auction_artworks WHERE id = ?').get(id) ?? null + } + + add(input: { title: string; artist: string; medium: string; description: string; imageType: string; image: Uint8Array }, now = Date.now()): string { + const id = randomBytes(12).toString('base64url') + this.db.query('INSERT INTO auction_artworks (id, title, artist, medium, description, image_type, image, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)') + .run(id, input.title, input.artist, input.medium, input.description, input.imageType, input.image, now) + return id + } + + allowSubmission(ip: string): boolean { + return this.submissions.take(ip) + } +} + +export function createAuctionApp(house: AuctionHouse) { + const app = new Hono<{ Bindings: { ip?: string } }>() + app.use('*', async (c, next) => { + await next() + if (!c.res.headers.has('cache-control')) c.header('cache-control', 'no-store') + c.header('x-content-type-options', 'nosniff') + c.header('referrer-policy', 'no-referrer') + }) + app.get('/style.css', (c) => c.body(AUCTIONS_CSS, 200, { 'content-type': 'text/css; charset=utf-8', 'cache-control': 'public, max-age=3600' })) + app.get('/image/:id', (c) => { + const image = house.image(c.req.param('id')) + if (!image) return c.text('Not found', 404) + const bytes = image.image.slice().buffer as ArrayBuffer + return c.body(bytes, 200, { 'content-type': image.image_type, 'cache-control': 'public, max-age=86400', 'x-content-type-options': 'nosniff' }) + }) + app.get('/art/:id', (c) => { + const item = house.get(c.req.param('id')) + return item ? c.html(detail(item)) : c.html(page('Not found', '

This piece is not here.

Back to the house
'), 404) + }) + app.get('/new', (c) => c.html(formPage())) + app.post('/new', async (c) => { + const host = String(c.req.header('host') ?? new URL(c.req.url).host).trim().toLowerCase().replace(/:\d+$/, '') + const origin = c.req.header('origin') + if (origin) { + try { + if (new URL(origin).host.toLowerCase() !== host) return c.text('Forbidden', 403) + } catch { + return c.text('Forbidden', 403) + } + } + const fetchSite = c.req.header('sec-fetch-site') + if (fetchSite && fetchSite !== 'same-origin') return c.text('Forbidden', 403) + const ip = c.env?.ip ?? c.req.header('x-real-ip') ?? 'unknown' + if (!house.allowSubmission(String(ip))) return c.html(formPage('Too many submissions from this connection. Please try again later.'), 429) + const body = await c.req.parseBody().catch(() => ({}) as Record) as Record + if (clean(body.website, 160)) return c.redirect('/', 303) + const title = clean(body.title, 120) + const artist = clean(body.artist, 80) + const medium = clean(body.medium, 80) + const description = clean(body.description, 1600) + if (!title || !artist) return c.html(formPage('Add a title and artist name to continue.'), 400) + if (body.permission !== 'yes') return c.html(formPage('Please confirm you have permission to share this work.'), 400) + const image = body.image + if (!(image instanceof File) || image.size === 0) return c.html(formPage('Choose a photo of your work to continue.'), 400) + if (image.size > MAX_IMAGE_BYTES) return c.html(formPage('That photo is over 8 MB. Choose a smaller image and try again.'), 413) + const imageType = image.type.toLowerCase() + if (!IMAGE_TYPES.has(imageType)) return c.html(formPage('Choose a JPEG, PNG, WebP, AVIF or HEIC image.'), 415) + const bytes = new Uint8Array(await image.arrayBuffer()) + if (!validImage(imageType, bytes)) return c.html(formPage('That file does not look like a supported image.'), 415) + const id = house.add({ title, artist, medium, description, imageType, image: bytes }) + return c.redirect(`/art/${encodeURIComponent(id)}`, 303) + }) + app.get('/', (c) => c.html(home(house))) + app.notFound((c) => c.html(page('Not found', '

This page is not in the house.

Back to the house
'), 404)) + return app +} + +const AUCTIONS_CSS = `:root{color-scheme:light;--ink:#24251f;--muted:#74756c;--paper:#f3eee5;--white:#fffdf8;--green:#384b36;--line:#d9d3c7;--accent:#d16d48;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;font-synthesis:none;text-rendering:optimizeLegibility}*{box-sizing:border-box}html{scroll-behavior:smooth}body{margin:0;background:var(--paper);color:var(--ink)}a{color:inherit;text-decoration:none}.top{height:82px;padding:0 max(6vw,24px);display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:12px;font-family:Georgia,serif;font-size:20px;line-height:1}.brand small{display:block;margin-top:7px;font:9px/1.1 ui-sans-serif,system-ui,sans-serif;letter-spacing:.15em;color:var(--muted)}.brand-mark{display:grid;place-items:center;width:38px;height:38px;border:1px solid var(--green);border-radius:50%;color:var(--green);font:italic 24px Georgia,serif}.top nav{display:flex;align-items:center;gap:32px;font-size:13px}.top nav a:first-child{color:var(--muted)}.nav-cta,.text-link{font-weight:650}.nav-cta span,.text-link span,.button span{margin-left:8px;color:var(--accent)}main{max-width:1440px;margin:0 auto;padding:0 max(6vw,24px)}.hero{position:relative;min-height:540px;padding:76px 0 86px;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:8%;border-bottom:1px solid var(--line)}.hero-copy{position:relative;z-index:1}.eyebrow{margin:0 0 18px;color:var(--green);font-size:10px;font-weight:750;letter-spacing:.19em}.hero h1,.form-wrap h1{margin:0;font:clamp(52px,6.5vw,88px)/.96 Georgia,"Times New Roman",serif;letter-spacing:-.055em;font-weight:400}.hero h1 em,.form-wrap h1 em,.invite h2 em{color:var(--green);font-weight:400}.lede{max-width:370px;margin:24px 0 28px;color:#595b52;font:18px/1.55 Georgia,serif}.button{display:inline-flex;align-items:center;justify-content:center;min-height:48px;padding:0 22px;border:0;border-radius:2px;font:650 13px ui-sans-serif,system-ui,sans-serif;cursor:pointer}.button-dark{background:var(--green);color:#fffdf8}.button-dark span{color:#f2b393}.button-light{background:var(--paper);color:var(--ink)}.hero-note{margin:17px 0 0;color:var(--muted);font-size:11px}.hero-art{position:relative;overflow:hidden;width:min(100%,500px);height:380px;justify-self:end;background:linear-gradient(135deg,#d9c3aa 0%,#bd9a7f 47%,#75866b 47%,#354b3e 100%);box-shadow:20px 24px 0 #e5d9c8}.hero-art:before{content:"";position:absolute;inset:18px;border:1px solid #fff8e0;opacity:.7}.sun{position:absolute;top:46px;right:78px;width:132px;height:132px;border-radius:50%;background:#e6a75f}.shape{position:absolute;bottom:-50px;border-radius:50% 50% 0 0}.shape-one{left:4%;width:65%;height:63%;background:#38523f;transform:rotate(-17deg)}.shape-two{right:-12%;width:62%;height:75%;background:#ad6650;transform:rotate(15deg)}.shape-three{left:27%;bottom:-28%;width:48%;height:65%;background:#e8d6ae;transform:rotate(33deg)}.shape-four{left:52%;bottom:-22%;width:30%;height:61%;background:#c78d5f;transform:rotate(-24deg)}.art-caption{position:absolute;left:34px;bottom:28px;color:white;font:italic 18px/1.45 Georgia,serif}.art-caption b{font:10px ui-sans-serif,system-ui,sans-serif;letter-spacing:.12em;text-transform:uppercase}.hero-index{position:absolute;right:0;bottom:22px;color:var(--muted);font-size:10px;letter-spacing:.15em}.gallery-section{padding:74px 0 92px}.section-head{display:flex;align-items:end;justify-content:space-between;margin-bottom:30px}.section-head .eyebrow{margin-bottom:11px}.section-head h2,.invite h2{margin:0;font:clamp(30px,4vw,48px)/1.1 Georgia,serif;font-weight:400;letter-spacing:-.03em}.count{margin-left:7px;vertical-align:top;color:var(--muted);font:11px ui-sans-serif,system-ui,sans-serif;letter-spacing:0}.text-link{font-size:12px}.gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:30px 22px}.art-card{min-width:0}.art-image{display:block;overflow:hidden;aspect-ratio:4/4.7;background:#e4ddd1}.art-image img{width:100%;height:100%;display:block;object-fit:cover;transition:transform .35s}.art-card:hover img{transform:scale(1.025)}.art-info{padding:16px 1px}.art-info .eyebrow{font-size:9px;margin:0 0 8px}.art-info h3{margin:0;font:23px/1.2 Georgia,serif;font-weight:400}.art-info p{margin:7px 0 0;color:var(--muted);font:14px Georgia,serif}.empty{padding:72px 24px;text-align:center;background:#eae3d8}.empty-stamp{display:grid;place-items:center;width:55px;height:55px;margin:0 auto 20px;border:1px solid var(--green);border-radius:50%;color:var(--green);font-size:25px}.empty h3{margin:0;font:30px Georgia,serif;font-weight:400}.empty p{max-width:390px;margin:12px auto 22px;color:var(--muted);font:15px/1.6 Georgia,serif}.invite{margin:0 calc(-1 * max(6vw,24px));padding:76px max(6vw,24px);background:var(--green);color:#fbf8f0;display:grid;grid-template-columns:1fr 1fr;gap:8%;align-items:center}.invite .eyebrow{color:#d9bd91}.invite h2 em{color:#ddc69f}.invite-copy p{max-width:360px;margin:0 0 24px;font:18px/1.55 Georgia,serif}.invite-copy small{display:block;max-width:390px;margin-top:15px;color:#d5d7cd;font-size:11px;line-height:1.5}.form-wrap{max-width:620px;margin:0 auto;padding:64px 0 100px}.back-link{display:inline-block;margin-bottom:52px;color:var(--muted);font-size:12px}.form-wrap .eyebrow{margin-bottom:16px}.form-wrap h1{font-size:clamp(48px,8vw,70px)}.form-intro{max-width:460px;margin:20px 0 30px;color:#5b5c54;font:17px/1.6 Georgia,serif}.art-form{padding:30px;background:var(--white);border:1px solid var(--line)}.field{display:block;margin-bottom:21px}.field>span{display:block;margin-bottom:8px;font-size:12px;font-weight:700}.field b{color:var(--accent)}.field small{margin-left:7px;color:var(--muted);font-weight:400}.field input:not([type=file]),.field textarea{display:block;width:100%;min-height:48px;padding:12px 13px;border:1px solid #c9c4b9;border-radius:2px;background:#fff;color:var(--ink);font:15px ui-sans-serif,system-ui,sans-serif}.field textarea{min-height:120px;resize:vertical}.upload-field input[type=file]{display:block;width:100%;min-height:54px;padding:13px;border:1px dashed #a8aa9d;background:#f7f4ed;color:var(--ink);font-size:13px}.upload-field>small{display:block;margin:8px 0 0;color:var(--muted);font-size:11px}.permission{display:flex;gap:11px;align-items:flex-start;margin:24px 0;color:#55564f;font-size:12px;line-height:1.55}.permission input{width:18px;height:18px;margin:0;flex:0 0 auto;accent-color:var(--green)}.submit-button{width:100%}.form-foot{margin:16px 0 0;color:var(--muted);font-size:11px;line-height:1.55}.error{padding:12px 14px;background:#f7e1d8;color:#7a3025;font-size:13px}.hp{position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden}.detail-wrap{padding:48px 0 90px}.detail-wrap .back-link{margin-bottom:30px}.detail-grid{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(280px,.75fr);gap:8%;align-items:start}.detail-image{display:block;width:100%;max-height:78vh;object-fit:contain;background:#e4ddd1}.detail-copy{padding:10px 0}.detail-copy h1{margin:0;font:clamp(42px,6vw,72px)/1 Georgia,serif;font-weight:400;letter-spacing:-.04em}.detail-copy .artist{margin:13px 0 28px;color:var(--muted);font:18px Georgia,serif}.detail-description{margin:0 0 28px;font:16px/1.7 Georgia,serif}.footer-brand{font-size:17px}.footer-brand .brand-mark{width:33px;height:33px;font-size:21px}footer{display:flex;justify-content:space-between;align-items:center;padding:28px max(6vw,24px);border-top:1px solid var(--line)}footer p{max-width:240px;color:var(--muted);font:13px/1.5 Georgia,serif}input:focus-visible,textarea:focus-visible,a:focus-visible,button:focus-visible{outline:3px solid #cf7d5b;outline-offset:3px}@media(max-width:760px){.top{height:70px}.top nav{gap:16px;font-size:11px}.brand{font-size:17px}.brand small{font-size:8px}.hero{min-height:auto;padding:55px 0 65px;grid-template-columns:1fr;gap:38px}.hero h1{font-size:clamp(50px,13vw,76px)}.hero-art{width:100%;height:clamp(250px,75vw,380px);justify-self:stretch}.hero-index{bottom:18px}.gallery-section{padding:58px 0 68px}.section-head{align-items:start;gap:12px}.section-head .text-link{padding-top:20px;font-size:11px}.gallery{grid-template-columns:repeat(2,minmax(0,1fr));gap:20px 13px}.art-info h3{font-size:18px}.art-info p{font-size:12px}.invite{grid-template-columns:1fr;gap:24px;padding-top:56px;padding-bottom:58px}.form-wrap{padding:42px 0 72px}.back-link{margin-bottom:36px}.art-form{padding:20px}.detail-grid{grid-template-columns:1fr;gap:30px}.detail-image{max-height:65vh}footer{gap:18px;align-items:flex-start}footer p{max-width:150px;font-size:12px}}@media(max-width:380px){.top{padding-inline:16px}.top nav{gap:12px}.top nav a:first-child{display:none}main{padding-inline:16px}.invite{margin-inline:-16px;padding-inline:16px}.hero-art{height:250px}.art-form{padding:16px}footer{padding-inline:16px}}@media(prefers-reduced-motion:reduce){html{scroll-behavior:auto}*,*:before,*:after{transition:none!important}}` diff --git a/src/server.ts b/src/server.ts index be9bfba..8479396 100644 --- a/src/server.ts +++ b/src/server.ts @@ -68,6 +68,7 @@ import { playerCss, playerJs } from './player-bundle.js' import { accountOf, mountVoteHistory, voteHistorySection } from './vote-history-routes.js' import { fingerprintSecret, VisitorGate, type WriteAction } from './visitor.js' import { itemsOf, type VoteView } from './vote-html.js' +import { AuctionHouse, AUCTION_HOST, AUCTION_HOSTS, createAuctionApp, isAuctionHost } from './auctions.js' import { AD_SLOT, esc, featuredTarget, TOP_ADS, votePage } from './html.js' export type Deps = { @@ -113,6 +114,8 @@ export type Deps = { summaryLimits?: { anon: number; signedIn: number; anonTotal: number } /** The $1/week summary pass (src/summary-pass.ts; needs accounts); unset means off. */ summaryPasses?: SummaryPasses + /** Public art submissions for auctions.pit.world and art.auctions.pit.world. */ + auctions?: AuctionHouse /** Keys every visitor hash and search hash (src/visitor.ts); a random one per process when unset (tests). */ fingerprintSecret?: string /** Anonymous votes; defaults to a table in the listings database (or memory without one). */ @@ -160,6 +163,10 @@ export function createApp(deps: Deps) { const crawlGate = deps.crawlGate if (crawlGate) { app.use('*', async (c, next) => { + if (isAuctionHost(c.req.header('host'))) { + await next() + return + } const sold = await crawlGate.handle(c.req.raw) if (sold) return sold await next() @@ -177,6 +184,14 @@ export function createApp(deps: Deps) { if (!deps.accounts || !COOKIE_PATHS.test(c.req.path)) c.res.headers.delete('set-cookie') }) + const auctionApp = deps.auctions ? createAuctionApp(deps.auctions) : null + if (auctionApp) { + app.use('*', async (c, next) => { + if (isAuctionHost(c.req.header('host'))) return auctionApp.fetch(c.req.raw, c.env) + await next() + }) + } + const limited = (c: Context) => !limiter.take(c.env?.ip || 'unknown') // --- who is writing: browser fingerprints, not addresses (src/visitor.ts) -------- @@ -1130,6 +1145,7 @@ export function keysFromEnv(env: Record = process.en export function serve(options: { port?: number; hostname?: string } = {}) { const env = process.env const dataDir = env.PIT_DATA_DIR ?? `${env.HOME ?? '.'}/.local/share/pit-world` + const auctions = new AuctionHouse(env.PIT_AUCTIONS_DB ?? `${dataDir}/auctions.db`) const newsletter = new Newsletter(env.PIT_NEWSLETTER_DB ?? `${dataDir}/newsletter.db`, env.RESEND_API_KEY ? resendMailer(env.RESEND_API_KEY) : null) const registry = new Registry() const rootPem = readFileSync(new URL('../public/moshpit-root-ca.crt', import.meta.url), 'utf8') @@ -1195,7 +1211,7 @@ export function serve(options: { port?: number; hostname?: string } = {}) { ...(env.PIT_FFMPEG ? { ffmpeg: env.PIT_FFMPEG } : {}), }) const fpSecret = fingerprintSecret(dataDir) - const app = createApp({ keys: keysFromEnv(), summarizer, summaryPasses, corpus, speech, crawlGate: createCrawlGate(), fingerprintSecret: fpSecret, votes: new Votes(listings.db, fpSecret), voteRewards: new VoteRewards(listings.db, { usdPerPoint: Number(env.PIT_VOTE_POINT_USD) || null }), newsletter, listings, accounts, promotion, promoters, rewards, internalIps, ...(buildInvoicing ? { buildInvoicing, featuredBusinesses } : {}), ...(ringSync ? { ringSync } : {}), ...(env.PIT_ADMIN_TOKEN ? { adminToken: env.PIT_ADMIN_TOKEN } : {}) }) + const app = createApp({ keys: keysFromEnv(), summarizer, summaryPasses, corpus, speech, auctions, crawlGate: createCrawlGate(), fingerprintSecret: fpSecret, votes: new Votes(listings.db, fpSecret), voteRewards: new VoteRewards(listings.db, { usdPerPoint: Number(env.PIT_VOTE_POINT_USD) || null }), newsletter, listings, accounts, promotion, promoters, rewards, internalIps, ...(buildInvoicing ? { buildInvoicing, featuredBusinesses } : {}), ...(ringSync ? { ringSync } : {}), ...(env.PIT_ADMIN_TOKEN ? { adminToken: env.PIT_ADMIN_TOKEN } : {}) }) // Every hour: start measurement windows that are ready, mark ended ones due and write their reports and drafts. Sends nothing. const hourly = () => { try { @@ -1258,6 +1274,9 @@ export function serve(options: { port?: number; hostname?: string } = {}) { // This is a pit.world-owned exact host, not a claimed Moshpit registry name. // Provision its dedicated multi-label certificate before HSTS browsers connect. if (import.meta.main && !certs.has(DAILY_STITCHER_HOST)) certs.issue(DAILY_STITCHER_HOST) + // These are pit.world-owned hosts with explicit app routes, including the + // one-label auctions.pit.world host that does not represent a Moshpit name. + if (import.meta.main) for (const host of AUCTION_HOSTS) if (!certs.has(host)) certs.issue(host) // Certificates for every reachable name before its first visit (HSTS browsers never make the http one). if (env.PIT_CERT_SWEEP !== 'off') { const sweep = registrySweep(proxyDeps.access, proxyDeps.certs, undefined, proxyDeps.targets) @@ -1293,6 +1312,14 @@ export function serve(options: { port?: number; hostname?: string } = {}) { const forwarded = local ? request.headers.get('x-real-ip') : null const proto = (local && request.headers.get('x-forwarded-proto')) || 'http' const host = String(request.headers.get('host') ?? '').toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '') + if (isAuctionHost(host)) { + const url = new URL(request.url) + url.protocol = 'https:' + url.host = AUCTION_HOST + if (proto !== 'https' || host !== AUCTION_HOST) return Response.redirect(url, 301) + // threatcrush-disable-next-line js-ssrf-outbound-request not an outbound request: Hono's in-process dispatch of the incoming auction house request + return app.fetch(request, { ip: forwarded || peer }) + } if (host === DAILY_STITCHER_HOST) { if (proto !== 'https') { const url = new URL(request.url) diff --git a/test/auctions.test.ts b/test/auctions.test.ts new file mode 100644 index 0000000..4817a2a --- /dev/null +++ b/test/auctions.test.ts @@ -0,0 +1,74 @@ +import { describe, expect, it } from 'bun:test' +import { AuctionHouse, createAuctionApp, isAuctionHost } from '../src/auctions.js' + +const png = Uint8Array.from(Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+/l0sAAAAASUVORK5CYII=', 'base64')) + +describe('Art Auctions House', () => { + it('serves only its two exact hostnames', () => { + expect(isAuctionHost('AUCTIONS.PIT.WORLD:443')).toBe(true) + expect(isAuctionHost('art.auctions.pit.world')).toBe(true) + expect(isAuctionHost('other.auctions.pit.world')).toBe(false) + expect(isAuctionHost('auctions.pit.world.example')).toBe(false) + }) + + it('accepts a mobile multipart artwork submission and serves the saved image', async () => { + const house = new AuctionHouse() + const app = createAuctionApp(house) + const landing = await app.fetch(new Request('https://auctions.pit.world/')) + expect(landing.status).toBe(200) + expect(await landing.text()).toContain('Good art deserves') + + const form = new FormData() + form.set('title', 'Small morning') + form.set('artist', 'Ada Artist') + form.set('medium', 'Watercolor') + form.set('description', 'A quiet study.') + form.set('permission', 'yes') + form.set('image', new File([png], 'morning.png', { type: 'image/png' })) + const response = await app.fetch(new Request('https://auctions.pit.world/new', { + method: 'POST', + headers: { origin: 'https://auctions.pit.world' }, + body: form, + }), { ip: '203.0.113.8' }) + + expect(response.status).toBe(303) + const location = response.headers.get('location')! + expect(location).toMatch(/^\/art\/[A-Za-z0-9_-]+$/) + + const detail = await app.fetch(new Request(`https://auctions.pit.world${location}`)) + expect(detail.status).toBe(200) + expect(await detail.text()).toContain('Ada Artist') + const id = location.split('/').at(-1)! + const image = await app.fetch(new Request(`https://auctions.pit.world/image/${id}`)) + expect(image.status).toBe(200) + expect(image.headers.get('content-type')).toBe('image/png') + expect(new Uint8Array(await image.arrayBuffer())).toEqual(png) + house.db.close() + }) + + it('rejects submissions from another origin and images with mismatched content', async () => { + const house = new AuctionHouse() + const app = createAuctionApp(house) + const foreign = new FormData() + const csrf = await app.fetch(new Request('https://auctions.pit.world/new', { + method: 'POST', + headers: { origin: 'https://example.net' }, + body: foreign, + }), { ip: '203.0.113.9' }) + expect(csrf.status).toBe(403) + + const form = new FormData() + form.set('title', 'Not a picture') + form.set('artist', 'Ada Artist') + form.set('permission', 'yes') + form.set('image', new File(['