const express = require('express'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const router = express.Router(); const { createSession, getSession, updateSession, deleteSession } = require('./db'); const { renderHtml } = require('./browserPool'); const CACHE_DIR = path.join(__dirname, 'cache'); // caching functions function normalizeConfig(config) { const normalized = {}; for (const [key, value] of Object.entries(config)) { if (value !== null && value !== undefined && value !== '') { normalized[key] = value; } } return normalized; } function hashConfig(config) { const normalized = normalizeConfig(config); const configString = JSON.stringify(normalized); return crypto.createHash('sha256').update(configString).digest('hex'); } function getCachedImage(config) { const hash = hashConfig(config); const cachePath = path.join(CACHE_DIR, `${hash}.png`); if (fs.existsSync(cachePath)) { const now = new Date(); fs.utimesSync(cachePath, now, now); return fs.readFileSync(cachePath); } return null; } function cacheImage(config, imageBuffer) { const hash = hashConfig(config); const cachePath = path.join(CACHE_DIR, `${hash}.png`); fs.writeFileSync(cachePath, imageBuffer); } function deleteCachedImage(config) { const hash = hashConfig(config); const cachePath = path.join(CACHE_DIR, `${hash}.png`); if (fs.existsSync(cachePath)) { fs.unlinkSync(cachePath); } } // build config from session for cache operations function buildConfigFromSession(session) { const timestamp = session.timestamp ? formatTimestamp(session.timestamp) : formatTimestamp(Math.floor(Date.now() / 1000)); let engagement = null; if (session.engagement) { engagement = { likes: formatCount(session.engagement.likes), retweets: formatCount(session.engagement.retweets), replies: formatCount(session.engagement.replies), views: formatCount(session.engagement.views) }; } return { displayName: session.displayName, username: session.username, avatarUrl: session.avatarUrl, text: session.text, imageUrl: session.imageUrl, timestamp: timestamp, engagement: engagement, verified: session.verified }; } // helper functions (copied from api.js) function formatCount(num) { if (num === null || num === undefined) return '0'; num = parseInt(num); if (num >= 1000000000) { return (num / 1000000000).toFixed(1).replace(/\.0$/, '') + 'B'; } if (num >= 1000000) { return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'; } if (num >= 1000) { return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'K'; } return num.toString(); } function formatTimestamp(epoch) { const date = new Date(epoch * 1000); const hours = date.getHours(); const minutes = date.getMinutes().toString().padStart(2, '0'); const ampm = hours >= 12 ? 'PM' : 'AM'; const hour12 = hours % 12 || 12; const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']; const month = months[date.getMonth()]; const day = date.getDate(); const year = date.getFullYear(); return `${hour12}:${minutes} ${ampm} ยท ${month} ${day}, ${year}`; } function detectImageType(base64String) { const base64Data = base64String.includes(',') ? base64String.split(',')[1] : base64String; const buffer = Buffer.from(base64Data, 'base64'); if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) { return 'image/jpeg'; } else if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) { return 'image/png'; } else if (buffer[0] === 0x47 && buffer[1] === 0x49 && buffer[2] === 0x46) { return 'image/gif'; } else if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46) { return 'image/webp'; } return 'image/png'; } function fixDataUri(dataUri) { if (!dataUri || !dataUri.startsWith('data:')) return dataUri; const parts = dataUri.split(','); if (parts.length !== 2) return dataUri; const base64Data = parts[1]; try { const correctType = detectImageType(base64Data); return `data:${correctType};base64,${base64Data}`; } catch (error) { console.error('Invalid base64 data:', error.message); return null; } } const TEMPLATE_PATH = path.join(__dirname, 'template.html'); const templateHtml = fs.readFileSync(TEMPLATE_PATH, 'utf8'); function buildEngagementHtml(engagement) { if (!engagement) return ''; return `
`; } async function generateQuoteBuffer(config) { const avatarHtml = config.avatarUrl ? `` : `
`; const imageHtml = config.imageUrl ? `
` : ''; const engagementHtml = buildEngagementHtml(config.engagement); const verifiedBadge = config.verified ? '' : ''; const html = templateHtml .replace('{{avatarHtml}}', avatarHtml) .replace('{{displayName}}', config.displayName) .replace('{{verifiedBadge}}', verifiedBadge) .replace('{{username}}', config.username) .replace('{{text}}', config.text) .replace('{{imageHtml}}', imageHtml) .replace('{{timestamp}}', config.timestamp) .replace('{{engagementHtml}}', engagementHtml); return await renderHtml(html, 1000); } // POST /v2/quote - create new session router.post('/quote', (req, res) => { try { const data = { displayName: req.body.displayName, username: req.body.username?.trim(), text: req.body.text, avatarUrl: fixDataUri(req.body.avatarUrl), imageUrl: fixDataUri(req.body.imageUrl), timestamp: req.body.timestamp, engagement: req.body.engagement, verified: req.body.verified }; const session = createSession(data); res.status(201).json(session); } catch (error) { console.error('Failed to create session:', error); res.status(500).json({ error: 'Failed to create session' }); } }); // GET /v2/quote/:id - get session state router.get('/quote/:id', (req, res) => { const session = getSession(req.params.id); if (!session) { return res.status(404).json({ error: 'Session not found or expired' }); } res.json(session); }); // PATCH /v2/quote/:id - update session router.patch('/quote/:id', (req, res) => { try { // get current session to invalidate its cached image const currentSession = getSession(req.params.id); if (!currentSession) { return res.status(404).json({ error: 'Session not found or expired' }); } // clear cached image for old state const oldConfig = buildConfigFromSession(currentSession); deleteCachedImage(oldConfig); const data = {}; if (req.body.displayName !== undefined) data.displayName = req.body.displayName; if (req.body.username !== undefined) data.username = req.body.username?.trim(); if (req.body.text !== undefined) data.text = req.body.text; if (req.body.avatarUrl !== undefined) data.avatarUrl = fixDataUri(req.body.avatarUrl); if (req.body.imageUrl !== undefined) data.imageUrl = fixDataUri(req.body.imageUrl); if (req.body.timestamp !== undefined) data.timestamp = req.body.timestamp; if (req.body.engagement !== undefined) data.engagement = req.body.engagement; if (req.body.verified !== undefined) data.verified = req.body.verified; const session = updateSession(req.params.id, data); if (!session) { return res.status(404).json({ error: 'Session not found or expired' }); } res.json(session); } catch (error) { console.error('Failed to update session:', error); res.status(500).json({ error: 'Failed to update session' }); } }); // GET /v2/quote/:id/image - render the image router.get('/quote/:id/image', async (req, res) => { try { const session = getSession(req.params.id); if (!session) { return res.status(404).json({ error: 'Session not found or expired' }); } const timestamp = session.timestamp ? formatTimestamp(session.timestamp) : formatTimestamp(Math.floor(Date.now() / 1000)); let engagement = null; if (session.engagement) { engagement = { likes: formatCount(session.engagement.likes), retweets: formatCount(session.engagement.retweets), replies: formatCount(session.engagement.replies), views: formatCount(session.engagement.views) }; } const config = { displayName: session.displayName, username: session.username, avatarUrl: session.avatarUrl, text: session.text, imageUrl: session.imageUrl, timestamp: timestamp, engagement: engagement, verified: session.verified }; // check cache first let image = getCachedImage(config); let fromCache = true; if (!image) { image = await generateQuoteBuffer(config); cacheImage(config, image); fromCache = false; } res.setHeader('Content-Type', 'image/png'); res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS'); res.setHeader('X-Session-Id', session.id); res.send(image); } catch (error) { console.error('Failed to generate image:', error); res.status(500).json({ error: 'Failed to generate image' }); } }); // DELETE /v2/quote/:id - delete session router.delete('/quote/:id', (req, res) => { const deleted = deleteSession(req.params.id); if (!deleted) { return res.status(404).json({ error: 'Session not found or expired' }); } res.status(204).send(); }); module.exports = router;