/** * POST /api/share-card — returns a PNG (or SVG fallback) for social sharing. * * Inputs are validated against allowlists. Any caller-supplied text is * XML-escaped in the renderer. Per-IP rate limit (in-memory) prevents * abuse. Hashed inputs back a tiny disk cache in /tmp/share-cards so * repeated requests serve from disk. * * SECURITY: * - Sport / grade / type / direction / format ∈ allowlist * - Player & stat & summary length-clamped * - No HTML, no SVG injection (renderer escapes everything) * - Rate limit: 30 cards / minute / IP * - Sharp is invoked through a memory-capped sharp() chain */ const express = require('express'); const path = require('node:path'); const fs = require('node:fs/promises'); const crypto = require('node:crypto'); const renderer = require('../services/shareCards/renderer'); const router = express.Router(); const VALID_FORMATS = new Set(['twitter', 'story', 'square']); const VALID_SPORTS = new Set(['nba', 'wnba', 'mlb', 'nfl', 'nhl', 'tennis', 'mma', 'boxing', 'golf']); const VALID_GRADES = new Set([ 'A+', 'A', 'A-', 'B+', 'B', 'B-', 'C+', 'C', 'C-', 'D', 'F', ]); const VALID_DIRECTIONS = new Set(['over', 'under']); const VALID_RESULTS = new Set(['hit', 'miss', 'push', 'pending']); const MAX_PLAYER_LEN = 64; const MAX_STAT_LEN = 32; const MAX_SUMMARY_LEN = 160; const MAX_RECAP_ENTRIES = 8; const MAX_CHEATSHEET_ENTRIES = 8; const CACHE_DIR = path.join('/tmp', 'vyndr-share-cards'); fs.mkdir(CACHE_DIR, { recursive: true }).catch(() => {}); // ── tiny in-memory rate limiter (per IP, 30/min sliding window) ─────────── const RATE_WINDOW_MS = 60_000; const RATE_MAX = 30; const ipBuckets = new Map(); function checkRate(ip) { const now = Date.now(); const arr = ipBuckets.get(ip) || []; const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS); if (fresh.length >= RATE_MAX) return false; fresh.push(now); ipBuckets.set(ip, fresh); return true; } // Periodic prune so the map doesn't grow unbounded. setInterval(() => { const now = Date.now(); for (const [ip, arr] of ipBuckets.entries()) { const fresh = arr.filter((t) => now - t < RATE_WINDOW_MS); if (fresh.length === 0) ipBuckets.delete(ip); else ipBuckets.set(ip, fresh); } }, RATE_WINDOW_MS).unref?.(); // ── input shaping & validation ──────────────────────────────────────────── function pickStr(v, max) { if (typeof v !== 'string') return null; const trimmed = v.trim(); if (!trimmed) return null; return trimmed.slice(0, max); } function pickNum(v) { const n = typeof v === 'number' ? v : Number(v); return Number.isFinite(n) ? n : null; } function validateBase(body) { const errors = []; const type = pickStr(body.type, 16); if (!type || !renderer.VALID_TYPES.has(type)) errors.push('type must be one of: grade, victory, recap, cheatsheet, gotd'); const format = pickStr(body.format, 16) || 'twitter'; if (!VALID_FORMATS.has(format)) errors.push(`format must be one of: ${[...VALID_FORMATS].join(', ')}`); return { type, format, errors }; } function shapeSinglePropPayload(b) { return { player: pickStr(b.player, MAX_PLAYER_LEN), sport: (b.sport && VALID_SPORTS.has(String(b.sport).toLowerCase())) ? String(b.sport).toLowerCase() : null, stat: pickStr(b.stat, MAX_STAT_LEN), line: pickNum(b.line), direction: VALID_DIRECTIONS.has(String(b.direction || '').toLowerCase()) ? String(b.direction).toLowerCase() : 'over', grade: VALID_GRADES.has(String(b.grade || '').toUpperCase()) ? String(b.grade).toUpperCase() : null, projection: pickNum(b.projection), summary: pickStr(b.summary, MAX_SUMMARY_LEN), }; } function shapeRecapPayload(b) { const entries = Array.isArray(b.entries) ? b.entries.slice(0, MAX_RECAP_ENTRIES) : []; return { date: pickStr(b.date, 32), accuracy: pickNum(b.accuracy), entries: entries.map((e) => ({ player: pickStr(e.player, MAX_PLAYER_LEN), stat: pickStr(e.stat, MAX_STAT_LEN), direction: VALID_DIRECTIONS.has(String(e.direction || '').toLowerCase()) ? String(e.direction).toLowerCase() : 'over', line: pickNum(e.line), grade: VALID_GRADES.has(String(e.grade || '').toUpperCase()) ? String(e.grade).toUpperCase() : null, result: VALID_RESULTS.has(String(e.result || '').toLowerCase()) ? String(e.result).toLowerCase() : 'pending', })).filter((e) => e.player && e.grade), }; } function shapeCheatsheetPayload(b) { const grades = Array.isArray(b.grades) ? b.grades.slice(0, MAX_CHEATSHEET_ENTRIES) : []; return { date: pickStr(b.date, 32), gameCount: pickNum(b.gameCount), grades: grades.map((g) => ({ player: pickStr(g.player, MAX_PLAYER_LEN), stat: pickStr(g.stat, MAX_STAT_LEN), direction: VALID_DIRECTIONS.has(String(g.direction || '').toLowerCase()) ? String(g.direction).toLowerCase() : 'over', line: pickNum(g.line), grade: VALID_GRADES.has(String(g.grade || '').toUpperCase()) ? String(g.grade).toUpperCase() : null, })).filter((g) => g.player && g.grade), }; } function shapeVictoryPayload(b) { return { ...shapeSinglePropPayload(b), result_actual: pickStr(b.result_actual || b.actual || '', 64) || 'HIT', }; } function shapePayload(type, body) { switch (type) { case 'grade': return shapeSinglePropPayload(body); case 'gotd': return shapeSinglePropPayload(body); case 'victory': return shapeVictoryPayload(body); case 'recap': return shapeRecapPayload(body); case 'cheatsheet': return shapeCheatsheetPayload(body); default: return {}; } } function hashKey(type, format, payload) { const json = JSON.stringify({ type, format, payload }); return crypto.createHash('sha256').update(json).digest('hex').slice(0, 24); } // ── route ───────────────────────────────────────────────────────────────── router.post('/', async (req, res) => { const ip = (req.headers['x-forwarded-for'] || req.ip || 'unknown').toString().split(',')[0].trim(); if (!checkRate(ip)) { return res.status(429).json({ error: 'rate limit exceeded — 30 cards/min' }); } const { type, format, errors } = validateBase(req.body || {}); if (errors.length) return res.status(400).json({ error: 'invalid input', detail: errors }); const payload = shapePayload(type, req.body || {}); const key = hashKey(type, format, payload); const cachePath = path.join(CACHE_DIR, `${key}.png`); // Cache check try { const cached = await fs.readFile(cachePath); res.set('Content-Type', 'image/png'); res.set('X-Cache', 'HIT'); res.set('Cache-Control', 'public, max-age=900'); return res.send(cached); } catch { /* miss */ } let svg; try { svg = renderer.buildSvg(type, format, payload); } catch (err) { // SEC-2 (Session 7d): don't echo err.message to public callers — the // SVG renderer may surface file paths or upstream library detail. // Log to stderr for ops, return a generic 400 to the caller. console.error('[VYNDR] shareCard render failed:', err?.message); return res.status(400).json({ error: 'render failed' }); } // Optional SVG-only mode (no rasterization) if (req.query.svg === '1') { res.set('Content-Type', 'image/svg+xml'); res.set('X-Cache', 'MISS'); return res.send(svg); } let png; try { png = await renderer.rasterize(svg); } catch (err) { if (err && err.code === 'SHARP_UNAVAILABLE') { // Degrade: hand back SVG so the channel-side renderer can still embed. res.set('Content-Type', 'image/svg+xml'); res.set('X-Cache', 'MISS'); res.set('X-Degraded', 'svg-fallback'); return res.send(svg); } console.error('[VYNDR] shareCard rasterize failed:', err?.message); return res.status(500).json({ error: 'rasterize failed' }); } // Write cache (best-effort; ignore failures so the response still flies) fs.writeFile(cachePath, png).catch(() => {}); res.set('Content-Type', 'image/png'); res.set('X-Cache', 'MISS'); res.set('Cache-Control', 'public, max-age=900'); return res.send(png); }); module.exports = router;