const express = require('express'); const cors = require('cors'); const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const { initPool, renderHtml, POOL_SIZE } = require('./browserPool'); const v2Routes = require('./v2Routes'); const { cleanupExpiredSessions, cleanupExpiredSnapshots } = require('./db'); const app = express(); const PORT = 3000; const CACHE_DIR = path.join(__dirname, 'cache'); // Create cache directory if it doesn't exist if (!fs.existsSync(CACHE_DIR)) { fs.mkdirSync(CACHE_DIR); } app.use(express.json({ limit: '1gb' })); app.use(express.urlencoded({ limit: '1gb', extended: true })); // enable CORS for all routes app.use(cors({ origin: '*', methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], allowedHeaders: ['Content-Type', 'Authorization', 'User-Agent'], credentials: false })); // user-agent check middleware (only for v2 routes) app.use((req, res, next) => { // only check user-agent for v2 routes if (!req.url.startsWith('/v2')) { return next(); } const userAgent = req.get('User-Agent') || ''; // allow flutter app or web browsers const isFlutterApp = userAgent.includes('QuoteGen-Flutter/1.0'); const isBrowser = userAgent.includes('Mozilla') || userAgent.includes('Chrome') || userAgent.includes('Safari') || userAgent.includes('Firefox') || userAgent.includes('Edge'); if (!isFlutterApp && !isBrowser) { return res.status(403).json({ error: 'Forbidden: Invalid user agent' }); } next(); }); // Request logging middleware app.use((req, res, next) => { // skip logging health checks if (req.url === '/health') { return next(); } const timestamp = new Date().toISOString(); console.log(`\n[${timestamp}] ${req.method} ${req.url}`); if ((req.method === 'POST' || req.method === 'PATCH') && req.body) { const logBody = { ...req.body }; // Truncate long base64 strings for readability if (logBody.avatarUrl && logBody.avatarUrl.startsWith('data:')) { logBody.avatarUrl = logBody.avatarUrl.substring(0, 50) + '... (base64 truncated)'; } if (logBody.imageUrl && logBody.imageUrl.startsWith('data:')) { logBody.imageUrl = logBody.imageUrl.substring(0, 50) + '... (base64 truncated)'; } console.log('Body:', JSON.stringify(logBody, null, 2)); } if (req.method === 'GET' && Object.keys(req.query).length > 0) { const logQuery = { ...req.query }; // Truncate long base64 strings for readability if (logQuery.avatarUrl && logQuery.avatarUrl.startsWith('data:')) { logQuery.avatarUrl = logQuery.avatarUrl.substring(0, 50) + '... (base64 truncated)'; } if (logQuery.imageUrl && logQuery.imageUrl.startsWith('data:')) { logQuery.imageUrl = logQuery.imageUrl.substring(0, 50) + '... (base64 truncated)'; } console.log('Query:', JSON.stringify(logQuery, null, 2)); } next(); }); // mount v2 api app.use('/v2', v2Routes); function normalizeConfig(config) { // Remove null, undefined, and empty string values 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 getCachePath(hash) { return path.join(CACHE_DIR, `${hash}.png`); } function getCachedImage(config) { const hash = hashConfig(config); const cachePath = getCachePath(hash); if (fs.existsSync(cachePath)) { // Update file modification time to mark as recently accessed 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 = getCachePath(hash); fs.writeFileSync(cachePath, imageBuffer); } function clearCache() { if (!fs.existsSync(CACHE_DIR)) { return; } const files = fs.readdirSync(CACHE_DIR); files.forEach(file => { const filePath = path.join(CACHE_DIR, file); fs.unlinkSync(filePath); }); console.log(`Cleared ${files.length} cached image(s)`); } function cleanupOldCache() { const ONE_DAY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const now = Date.now(); if (!fs.existsSync(CACHE_DIR)) { return; } const files = fs.readdirSync(CACHE_DIR); let deletedCount = 0; files.forEach(file => { const filePath = path.join(CACHE_DIR, file); const stats = fs.statSync(filePath); // Check if file was last modified more than 24 hours ago if (now - stats.mtime.getTime() > ONE_DAY) { fs.unlinkSync(filePath); deletedCount++; } }); if (deletedCount > 0) { console.log(`Cleaned up ${deletedCount} cached image(s)`); } } 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}`; } 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); } // GET endpoint - use query parameters app.get('/generate', async (req, res) => { try { const timestamp = req.query.timestamp ? formatTimestamp(parseInt(req.query.timestamp)) : formatTimestamp(Date.now() / 1000); const config = { displayName: req.query.displayName || "Anonymous", username: normalizeUsername(req.query.username), avatarUrl: fixDataUri(req.query.avatarUrl) || null, text: req.query.text || "No text provided", imageUrl: fixDataUri(req.query.imageUrl) || null, timestamp: timestamp, engagement: null, verified: req.query.verified === 'true' || req.query.verified === '1' }; // Check cache first let image = getCachedImage(config); let fromCache = true; if (!image) { // Generate new image image = await generateQuoteBuffer(config); cacheImage(config, image); fromCache = false; } res.setHeader('Content-Type', 'image/png'); res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS'); res.send(image); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to generate image' }); } }); // POST endpoint - use request body function detectImageType(base64String) { // Extract base64 data const base64Data = base64String.includes(',') ? base64String.split(',')[1] : base64String; const buffer = Buffer.from(base64Data, 'base64'); // Check magic numbers 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'; // default fallback } function fixDataUri(dataUri) { if (!dataUri || !dataUri.startsWith('data:')) return dataUri; // Extract the base64 part 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 '@anonymous'; const trimmed = username.trim(); // If empty or just "@", return default if (!trimmed || trimmed === '@') return '@anonymous'; // Add @ if it doesn't start with it return trimmed.startsWith('@') ? trimmed : `@${trimmed}`; } app.post('/generate', async (req, res) => { try { const timestamp = req.body.timestamp ? formatTimestamp(parseInt(req.body.timestamp)) : formatTimestamp(Date.now() / 1000); // Only include engagement if all fields are provded let engagement = null; if (req.body.engagement && req.body.engagement.likes !== undefined && req.body.engagement.retweets !== undefined && req.body.engagement.replies !== undefined && req.body.engagement.views !== undefined) { engagement = { likes: formatCount(req.body.engagement.likes), retweets: formatCount(req.body.engagement.retweets), replies: formatCount(req.body.engagement.replies), views: formatCount(req.body.engagement.views) }; } const config = { displayName: req.body.displayName || "Anonymous", username: normalizeUsername(req.body.username), avatarUrl: fixDataUri(req.body.avatarUrl) || null, text: req.body.text || "No text provided", imageUrl: fixDataUri(req.body.imageUrl) || null, timestamp: timestamp, engagement: engagement, verified: req.body.verified === true || req.body.verified === 'true' || req.body.verified === '1' }; // Check cache first let image = getCachedImage(config); let fromCache = true; if (!image) { // Generate new image image = await generateQuoteBuffer(config); cacheImage(config, image); fromCache = false; } res.setHeader('Content-Type', 'image/png'); res.setHeader('X-Cache', fromCache ? 'HIT' : 'MISS'); res.send(image); } catch (error) { console.error(error); res.status(500).json({ error: 'Failed to generate image' }); } }); // Health check endpoint app.get('/health', (req, res) => { res.status(200).json({ status: 'ok' }); }); // Clear all cache on startup clearCache(); cleanupExpiredSessions(); cleanupExpiredSnapshots(); // Run cleanup every hour setInterval(() => { cleanupOldCache(); cleanupExpiredSessions(); cleanupExpiredSnapshots(); }, 60 * 60 * 1000); // Initialize browser pool then start server initPool().then(() => { app.listen(PORT, () => { console.log(`Quote generator API running on http://localhost:${PORT}`); console.log(`Browser pool size: ${POOL_SIZE} (set BROWSER_POOL_SIZE env var to change)`); console.log(`GET: http://localhost:${PORT}/generate?text=Hello&displayName=Test&username=@test×tamp=1735574400`); console.log(`POST: http://localhost:${PORT}/generate`); console.log(`v2 API: POST/GET/PATCH/DELETE http://localhost:${PORT}/v2/quote`); console.log(`Cache cleared on startup, cleanup runs every hour`); }); }).catch(err => { console.error('Failed to initialize browser pool:', err); process.exit(1); });