224 lines
8.2 KiB
JavaScript
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;
|