Files
vyndr/src/routes/shareCard.js
T

224 lines
8.2 KiB
JavaScript

/**
* 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;