#!/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 STATUS_PATH = process.env.STATUS_PATH || '/home/ubuntu/skatespots-quota/status.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); } function opsAuth(req) { const event = decodeToken(req.headers.authorization); if (!event || event.kind !== 27235 || !verifyEvent(event)) return false; if (!ADMIN_PUBKEYS.has(String(event.pubkey).toLowerCase())) return false; if (tag(event, 'method') !== 'GET') return false; if (tag(event, 'u') !== 'https://relay.skate-spots.org/ops') return false; const exp = Number(tag(event, 'expiration') || 0); if (exp && exp < Math.floor(Date.now() / 1000)) return false; return true; } const server = http.createServer(async (req, res) => { if (req.url === '/health') { json(res, 200, { ok: true }); return; } if (req.url === '/ops') { if (req.method === 'OPTIONS') { res.writeHead(204, { 'access-control-allow-origin': '*', 'access-control-allow-methods': 'GET, OPTIONS', 'access-control-allow-headers': 'Authorization, Content-Type', }); res.end(); return; } if (req.method !== 'GET' || !opsAuth(req)) { json(res, 401, { error: 'Admin authorization required.' }); return; } try { const raw = readFileSync(STATUS_PATH, 'utf8'); res.writeHead(200, { 'content-type': 'application/json', 'cache-control': 'no-store', 'access-control-allow-origin': '*', }); res.end(raw); } catch { json(res, 503, { error: 'Status snapshot unavailable.' }); } 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}`));