Files
Quote-Generator/api.js

403 lines
13 KiB
JavaScript

const express = require('express');
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { initPool, renderHtml, POOL_SIZE } = require('./browserPool');
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 }));
// Request logging middleware
app.use((req, res, next) => {
const timestamp = new Date().toISOString();
console.log(`\n[${timestamp}] ${req.method} ${req.url}`);
if (req.method === 'POST' && 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();
});
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 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}`;
}
async function generateQuoteBuffer(config) {
// Build HTML directly with values injected
const avatarHtml = config.avatarUrl
? `<img src="${config.avatarUrl}" style="width:100%;height:100%;object-fit:cover;" />`
: `<div style="width:100%;height:100%;background:rgb(51,54,57);"></div>`;
const imageHtml = config.imageUrl
? `<div class="tweet-image-container" style="margin-bottom:12px;border-radius:16px;overflow:hidden;border:1px solid rgb(47,51,54);"><img src="${config.imageUrl}" style="width:100%;display:block;" /></div>`
: '';
const html = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #000;
}
.square-container {
width: calc(100vmin - 20px);
height: calc(100vmin - 20px);
display: flex;
justify-content: center;
align-items: center;
background-color: #000;
overflow: hidden;
}
.tweet-container {
width: 450px;
background-color: #000;
padding: 12px 16px;
box-sizing: border-box;
}
.tweet-header {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-right: 12px;
background: rgb(51, 54, 57);
}
.user-info {
display: flex;
flex-direction: column;
}
.display-name {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
font-weight: 700;
color: rgb(231, 233, 234);
line-height: 20px;
}
.username {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: rgb(113, 118, 123);
line-height: 20px;
}
.tweet-text {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: rgb(231, 233, 234);
line-height: 20px;
margin-bottom: 12px;
white-space: pre-wrap;
}
.tweet-time {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: rgb(113, 118, 123);
}
</style>
</head>
<body>
<div class="square-container">
<div class="tweet-container">
<div class="tweet-header">
<div class="avatar">${avatarHtml}</div>
<div class="user-info">
<span class="display-name">${config.displayName}</span>
<span class="username">${config.username}</span>
</div>
</div>
<div class="tweet-text">${config.text}</div>
${imageHtml}
<div class="tweet-time">${config.timestamp}</div>
</div>
</div>
<script>
function fitToSquare() {
const square = document.querySelector('.square-container');
const tweet = document.querySelector('.tweet-container');
const scaleX = square.offsetWidth / tweet.offsetWidth;
const scaleY = square.offsetHeight / tweet.offsetHeight;
const scale = Math.min(scaleX, scaleY) * 0.95;
tweet.style.transform = 'scale(' + scale + ')';
}
window.onload = fitToSquare;
</script>
</body>
</html>`;
const image = await renderHtml(html, 1000);
return image;
}
// 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: req.query.username || "@anonymous",
avatarUrl: fixDataUri(req.query.avatarUrl) || null,
text: req.query.text || "No text provided",
imageUrl: fixDataUri(req.query.imageUrl) || null,
timestamp: timestamp,
viewsCount: req.query.viewsCount || "0"
};
// 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;
}
}
app.post('/generate', async (req, res) => {
try {
const timestamp = req.body.timestamp ? formatTimestamp(parseInt(req.body.timestamp)) : formatTimestamp(Date.now() / 1000);
const username = req.body.username?.trim();
const config = {
displayName: req.body.displayName || "Anonymous",
username: (username && username !== "@") ? username : "@anonymous",
avatarUrl: fixDataUri(req.body.avatarUrl) || null,
text: req.body.text || "No text provided",
imageUrl: fixDataUri(req.body.imageUrl) || null,
timestamp: timestamp,
viewsCount: req.body.viewsCount || "0"
};
// 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();
// Run cleanup every hour
setInterval(() => {
cleanupOldCache();
}, 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&timestamp=1735574400`);
console.log(`POST: http://localhost:${PORT}/generate`);
console.log(`Cache cleared on startup, cleanup runs every hour`);
});
}).catch(err => {
console.error('Failed to initialize browser pool:', err);
process.exit(1);
});