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, createSnapshot, getSnapshot, touchSnapshot } = require('./db');
const { renderHtml } = require('./browserPool');
const { notifyImageGenerated, notifySnapshotCreated } = require('./discordWebhook');
const CACHE_DIR = path.join(__dirname, 'cache');
// get client ip address
function getClientIp(req) {
const forwarded = req.headers['x-forwarded-for'];
if (forwarded) {
return forwarded.split(',')[0].trim();
}
return req.ip || req.socket.remoteAddress || 'unknown';
}
// 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;
}
}
function normalizeUsername(username) {
if (!username) return undefined;
const trimmed = username.trim();
// If empty or just "@", return undefined
if (!trimmed || trimmed === '@') return undefined;
// Add @ if it doesn't start with it
return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
}
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?.trim(),
username: normalizeUsername(req.body.username),
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?.trim();
if (req.body.username !== undefined) {
data.username = normalizeUsername(req.body.username);
}
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;
// notify discord about new image
const clientIp = getClientIp(req);
notifyImageGenerated(config, clientIp).catch(err => console.error('Discord notification failed:', err));
}
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();
});
// POST /v2/quote/:id/snapshot-link - create snapshot link
router.post('/quote/:id/snapshot-link', (req, res) => {
try {
const session = getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session not found or expired' });
}
const config = buildConfigFromSession(session);
const snapshot = createSnapshot(session.id, config);
// notify discord about new snapshot
const clientIp = getClientIp(req);
notifySnapshotCreated(session.id, snapshot.token, clientIp).catch(err => console.error('Discord notification failed:', err));
const baseUrl = `${req.protocol}://${req.get('host')}`;
res.status(201).json({
token: snapshot.token,
url: `${baseUrl}/v2/snapshot/${snapshot.token}`,
sessionId: session.id,
createdAt: snapshot.createdAt,
expiresAt: snapshot.accessedAt + (48 * 60 * 60)
});
} catch (error) {
console.error('Failed to create snapshot:', error);
res.status(500).json({ error: 'Failed to create snapshot' });
}
});
// GET /v2/snapshot/:token - retrieve snapshot image
router.get('/snapshot/:token', async (req, res) => {
try {
const snapshot = getSnapshot(req.params.token);
if (!snapshot) {
return res.status(404).json({ error: 'Snapshot not found or expired' });
}
touchSnapshot(req.params.token);
const config = JSON.parse(snapshot.configJson);
let image = getCachedImage(config);
let fromCache = true;
if (!image) {
image = await generateQuoteBuffer(config);
cacheImage(config, image);
fromCache = false;
// notify discord about new image
const clientIp = getClientIp(req);
notifyImageGenerated(config, clientIp).catch(err => console.error('Discord notification failed:', err));
}
res.setHeader('Content-Type', 'image/png');
res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS');
res.setHeader('X-Snapshot-Token', snapshot.token);
res.send(image);
} catch (error) {
console.error('Failed to retrieve snapshot:', error);
res.status(500).json({ error: 'Failed to retrieve snapshot' });
}
});
module.exports = router;