From a4f72a3d05b2ba8d7b98173b6c066b22d8a5a783 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Fri, 30 Oct 2026 18:09:44 +0700 Subject: [PATCH] feat(media): image-only per-npub Blossom quotas and upload proxy --- scripts/host/relay-health.sh | 10 ++ scripts/quota-proxy.mjs | 184 +++++++++++++++++++++++ scripts/systemd/skatespots-quota.service | 28 ++++ src/hooks/useUploadFile.ts | 45 +++++- 4 files changed, 265 insertions(+), 2 deletions(-) create mode 100644 scripts/quota-proxy.mjs create mode 100644 scripts/systemd/skatespots-quota.service diff --git a/scripts/host/relay-health.sh b/scripts/host/relay-health.sh index 812da97..d292b48 100644 --- a/scripts/host/relay-health.sh +++ b/scripts/host/relay-health.sh @@ -35,12 +35,15 @@ svc skatespots-relay.service svc skatespots-ai.service svc skatespots-push.service svc skatespots-cvm.service +svc skatespots-quota.service svc caddy.service [ "$(code -H 'Accept: application/nostr+json' https://relay.skate-spots.org/)" != "200" ] \ && ISSUES+=("relay public TLS not answering 200") [ "$(code https://push.skate-spots.org/health)" != "200" ] \ && ISSUES+=("push /health not 200") +[ "$(code http://127.0.0.1:8790/health)" != "200" ] \ + && ISSUES+=("blossom quota proxy /health not 200") disk=$(df --output=pcent / | tail -1 | tr -d ' %') [ "${disk:-0}" -ge 85 ] && ISSUES+=("disk ${disk}% used") @@ -58,6 +61,13 @@ cordn_rejects=$(docker logs --since 15m newlay-newlay-1 2>&1 | grep -c 'cordn ra cordn_announce=$(docker logs --since 20m newlay-newlay-1 2>&1 | grep -c 'cordn announce' || true) [ "${cordn_announce:-0}" -eq 0 ] && ISSUES+=("cordn coordinator has not announced in 20m") +# Blossom media budget (usage is persisted by the quota proxy). +quota_state=/home/ubuntu/skatespots-quota/usage.json +if [ -f "$quota_state" ]; then + total_bytes=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1])).get("totalBytes",0))' "$quota_state" 2>/dev/null || echo 0) + [ "${total_bytes:-0}" -ge 19327352832 ] && ISSUES+=("blossom media >18 GiB") +fi + # TLS expiry (Caddy auto-renews; this catches a broken renewal early). expiry=$(echo | openssl s_client -servername relay.skate-spots.org -connect relay.skate-spots.org:443 2>/dev/null \ | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2) diff --git a/scripts/quota-proxy.mjs b/scripts/quota-proxy.mjs new file mode 100644 index 0000000..d83b3d9 --- /dev/null +++ b/scripts/quota-proxy.mjs @@ -0,0 +1,184 @@ +#!/usr/bin/env node +/** + * SkateSpots Blossom upload quota proxy. + * + * Caddy sends ONLY `PUT /upload` here; reads and all other Blossom routes go + * straight to Newlay. The proxy verifies the signed BUD-11 (kind 24242) token, + * enforces image-only + size, per-npub daily budgets and a generous per-IP + * backstop, then forwards the accepted bytes to Newlay. + * + * Primary quota key = npub (portable, fair across devices/IPs). + * Secondary key = client IP (anti-abuse / key-rotation backstop). + * Admin pubkeys get a higher npub budget, never an unlimited bypass. + * + * This is intentionally dependency-light: it reuses the nostr-tools already + * installed by the push service, so the relay host needs no new npm install. + * + * Environment: + * PORT=8790 + * NOSTR_TOOLS_ROOT=/home/ubuntu/skatespots-push/ + * QUOTA_STATE=/home/ubuntu/skatespots-quota/usage.json + * ADMIN_PUBKEYS=20e7...,caba... + * MAX_IMAGE_BYTES=10485760 + * DAILY_BYTES=262144000 (250 MiB) + * DAILY_UPLOADS=50 + * ADMIN_MULTIPLIER=3 + * IP_HOURLY_BYTES=524288000 (500 MiB) + * IP_HOURLY_UPLOADS=100 + * TOTAL_BYTES=21474836480 (20 GiB) + */ +import http from 'node:http'; +import { createHash } from 'node:crypto'; +import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { createRequire } from 'node:module'; + +const require = createRequire(process.env.NOSTR_TOOLS_ROOT || '/home/ubuntu/skatespots-push/'); +const { verifyEvent } = require('nostr-tools'); + +const PORT = Number(process.env.PORT || 8790); +const UPSTREAM_PORT = Number(process.env.UPSTREAM_PORT || 7777); +const MAX_IMAGE_BYTES = Number(process.env.MAX_IMAGE_BYTES || 10 * 1024 * 1024); +const DAILY_BYTES = Number(process.env.DAILY_BYTES || 250 * 1024 * 1024); +const DAILY_UPLOADS = Number(process.env.DAILY_UPLOADS || 50); +const ADMIN_MULTIPLIER = Number(process.env.ADMIN_MULTIPLIER || 3); +const IP_HOURLY_BYTES = Number(process.env.IP_HOURLY_BYTES || 500 * 1024 * 1024); +const IP_HOURLY_UPLOADS = Number(process.env.IP_HOURLY_UPLOADS || 100); +const TOTAL_BYTES = Number(process.env.TOTAL_BYTES || 20 * 1024 * 1024 * 1024); +const STATE_PATH = process.env.QUOTA_STATE || '/home/ubuntu/skatespots-quota/usage.json'; +const ADMIN_PUBKEYS = new Set( + String(process.env.ADMIN_PUBKEYS || '') + .split(',').map((v) => v.trim().toLowerCase()).filter(Boolean), +); +const IMAGE_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/webp', 'image/avif', + 'image/heic', 'image/heif', 'image/gif', +]); + +mkdirSync(dirname(STATE_PATH), { recursive: true }); + +let state = loadState(); +function loadState() { + try { + return JSON.parse(readFileSync(STATE_PATH, 'utf8')); + } catch { + return { day: '', hour: '', totalBytes: 0, users: {}, ips: {} }; + } +} +function saveState() { + const tmp = `${STATE_PATH}.tmp`; + writeFileSync(tmp, JSON.stringify(state)); + renameSync(tmp, STATE_PATH); +} +function rollWindows() { + const now = new Date(); + const day = now.toISOString().slice(0, 10); + const hour = now.toISOString().slice(0, 13); + if (state.day !== day) { state.day = day; state.users = {}; } + if (state.hour !== hour) { state.hour = hour; state.ips = {}; } +} +function clientIp(req) { + const xff = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim(); + return xff || req.socket.remoteAddress || 'unknown'; +} +function json(res, status, body) { + const payload = JSON.stringify(body); + res.writeHead(status, { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(payload), + 'access-control-allow-origin': '*', + }); + res.end(payload); +} +function readBody(req, limit) { + return new Promise((resolve, reject) => { + const declared = Number(req.headers['content-length'] || 0); + if (declared > limit) { reject(Object.assign(new Error('too large'), { code: 413 })); return; } + const chunks = []; + let total = 0; + req.on('data', (chunk) => { + total += chunk.length; + if (total > limit) { + reject(Object.assign(new Error('too large'), { code: 413 })); + req.destroy(); + return; + } + chunks.push(chunk); + }); + req.on('end', () => resolve(Buffer.concat(chunks))); + req.on('error', reject); + }); +} +function decodeToken(header) { + const m = /^Nostr\s+(.+)$/i.exec(String(header || '')); + if (!m) return null; + try { + return JSON.parse(Buffer.from(m[1], 'base64').toString('utf8')); + } catch { + return null; + } +} +function tag(event, name) { + return event.tags?.find(([n]) => n === name)?.[1]; +} +function checkToken(event, body, contentType) { + if (!event || event.kind !== 24242) return 'Invalid upload authorization.'; + if (!verifyEvent(event)) return 'Invalid upload signature.'; + if (tag(event, 't') !== 'upload') return 'Invalid upload token type.'; + const exp = Number(tag(event, 'expiration') || 0); + if (!exp || exp < Math.floor(Date.now() / 1000)) return 'Upload authorization expired.'; + if (!contentType.startsWith('image/')) return 'Only images can be uploaded.'; + const x = String(tag(event, 'x') || '').toLowerCase(); + const size = Number(tag(event, 'size') || 0); + const digest = createHash('sha256').update(body).digest('hex'); + if (!x || x !== digest || size !== body.length) return 'Upload token does not match the image.'; + return null; +} +function checkQuota(pubkey, bytes, ip) { + const admin = ADMIN_PUBKEYS.has(pubkey); + const multiplier = admin ? ADMIN_MULTIPLIER : 1; + const user = state.users[pubkey] || (state.users[pubkey] = { uploads: 0, bytes: 0 }); + if (user.uploads + 1 > DAILY_UPLOADS * multiplier) return 'Daily upload count reached. Try again tomorrow.'; + if (user.bytes + bytes > DAILY_BYTES * multiplier) return 'Daily upload size reached. Try again tomorrow.'; + const ipKey = createHash('sha256').update(ip).digest('hex').slice(0, 24); + const ipRow = state.ips[ipKey] || (state.ips[ipKey] = { uploads: 0, bytes: 0 }); + if (ipRow.uploads + 1 > IP_HOURLY_UPLOADS) return 'Too many uploads from this connection. Try again later.'; + if (ipRow.bytes + bytes > IP_HOURLY_BYTES) return 'Upload burst limit reached. Try again later.'; + if (state.totalBytes + bytes > TOTAL_BYTES) return 'The relay media budget is full. Try again later.'; + user.uploads += 1; user.bytes += bytes; + ipRow.uploads += 1; ipRow.bytes += bytes; + state.totalBytes += bytes; + saveState(); + return null; +} +function forward(req, res, body) { + const headers = { ...req.headers, host: `127.0.0.1:${UPSTREAM_PORT}` }; + if (body) headers['content-length'] = String(body.length); + const upstream = http.request({ + hostname: '127.0.0.1', port: UPSTREAM_PORT, path: req.url, method: req.method, headers, + }, (up) => { + res.writeHead(up.statusCode || 502, up.headers); + up.pipe(res); + }); + upstream.on('error', () => json(res, 502, { error: 'Media relay unavailable.' })); + if (body) upstream.end(body); else req.pipe(upstream); +} +const server = http.createServer(async (req, res) => { + if (req.url === '/health') { json(res, 200, { ok: true }); return; } + if (req.method !== 'PUT' || req.url !== '/upload') { forward(req, res); return; } + + const contentType = String(req.headers['content-type'] || '').toLowerCase().split(';')[0]; + if (!IMAGE_TYPES.has(contentType)) { json(res, 415, { error: 'Only JPEG, PNG, WebP, AVIF, HEIC or GIF images can be uploaded.' }); return; } + let body; + try { body = await readBody(req, MAX_IMAGE_BYTES); } + catch (err) { json(res, err?.code === 413 ? 413 : 400, { error: err?.code === 413 ? 'Image is too large. Max 10 MB.' : 'Could not read upload.' }); return; } + const event = decodeToken(req.headers.authorization); + const problem = checkToken(event, body, contentType); + if (problem) { json(res, 401, { error: problem }); return; } + rollWindows(); + const quota = checkQuota(event.pubkey.toLowerCase(), body.length, clientIp(req)); + if (quota) { json(res, 429, { error: quota, 'retry-after': '3600' }); return; } + console.log(`[quota] accepted ${event.pubkey.slice(0, 8)}… ${body.length} bytes ${contentType}`); + forward(req, res, body); +}); +server.listen(PORT, '127.0.0.1', () => console.log(`[quota] listening on 127.0.0.1:${PORT}`)); diff --git a/scripts/systemd/skatespots-quota.service b/scripts/systemd/skatespots-quota.service new file mode 100644 index 0000000..796ae97 --- /dev/null +++ b/scripts/systemd/skatespots-quota.service @@ -0,0 +1,28 @@ +[Unit] +Description=SkateSpots Blossom upload quota proxy +After=network-online.target docker.service +Wants=network-online.target + +[Service] +Type=simple +User=ubuntu +WorkingDirectory=/home/ubuntu/skatespots-quota +ExecStart=/usr/bin/node /home/ubuntu/skatespots-quota/quota-proxy.mjs +Restart=always +RestartSec=2 +Environment=PORT=8790 +Environment=NOSTR_TOOLS_ROOT=/home/ubuntu/skatespots-push/ +Environment=QUOTA_STATE=/home/ubuntu/skatespots-quota/usage.json +Environment=ADMIN_PUBKEYS=20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21,caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b +Environment=MAX_IMAGE_BYTES=10485760 +Environment=DAILY_BYTES=262144000 +Environment=DAILY_UPLOADS=50 +Environment=ADMIN_MULTIPLIER=3 +Environment=IP_HOURLY_BYTES=524288000 +Environment=IP_HOURLY_UPLOADS=100 +Environment=TOTAL_BYTES=21474836480 +NoNewPrivileges=true +PrivateTmp=true + +[Install] +WantedBy=multi-user.target diff --git a/src/hooks/useUploadFile.ts b/src/hooks/useUploadFile.ts index c12e329..90e3e27 100644 --- a/src/hooks/useUploadFile.ts +++ b/src/hooks/useUploadFile.ts @@ -11,7 +11,41 @@ import { useCurrentUser } from "./useCurrentUser"; * mirror URL on the returned tags so photo redundancy is explicit in the event; * the read side also derives it from the sha256 (lib/imageFallback). */ -const UPLOAD_SERVERS = ['https://blossom.skate-spots.org/', 'https://blossom.primal.net/', 'https://blossom.band/']; +// Uploads go only through our own Blossom/quota proxy. The public mirrors are +// still used by the read-side image fallback for older blobs, but including them +// here would let a quota rejection silently fall through to third-party hosting. +const UPLOAD_SERVERS = ['https://blossom.skate-spots.org/']; + +const MAX_IMAGE_BYTES = 10 * 1024 * 1024; +const IMAGE_TYPES = new Set([ + 'image/jpeg', 'image/png', 'image/webp', 'image/avif', + 'image/heic', 'image/heif', 'image/gif', +]); + +/** Unwrap the quota proxy's JSON error so forms can show a human message. */ +function uploadErrorMessage(error: unknown): string { + const message = error instanceof Error ? error.message : String(error); + const match = /Blossom upload failed \(\d+\):\s*(\{.*\})/.exec(message); + if (match) { + try { + const parsed = JSON.parse(match[1]) as { error?: string }; + if (parsed.error) return parsed.error; + } catch { + // fall through to the original message + } + } + return message; +} + +function validateImage(file: File): void { + const type = (file.type || '').toLowerCase(); + if (!IMAGE_TYPES.has(type)) { + throw new Error('Only JPEG, PNG, WebP, AVIF, HEIC or GIF images can be uploaded.'); + } + if (file.size > MAX_IMAGE_BYTES) { + throw new Error('Image is too large. The maximum is 10 MB.'); + } +} function hostOf(url: string): string { try { @@ -30,12 +64,19 @@ export function useUploadFile() { throw new Error('Must be logged in to upload files'); } + validateImage(file); + const uploader = new BlossomUploader({ servers: UPLOAD_SERVERS, signer: user.signer, }); - const tags = await uploader.upload(file); + let tags; + try { + tags = await uploader.upload(file); + } catch (error) { + throw new Error(uploadErrorMessage(error)); + } // Remember the mirror copy of the same blob. Blossom is content-addressed, // so `/` is the same file.