430 lines
17 KiB
JavaScript
430 lines
17 KiB
JavaScript
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 `
|
|
<div class="engagement-bar" role="group">
|
|
<div class="engagement-item">
|
|
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M1.751 10c0-4.42 3.584-8 8.005-8h4.366c4.49 0 8.129 3.64 8.129 8.13 0 2.96-1.607 5.68-4.196 7.11l-8.054 4.46v-3.69h-.067c-4.49.1-8.183-3.51-8.183-8.01zm8.005-6c-3.317 0-6.005 2.69-6.005 6 0 3.37 2.77 6.08 6.138 6.01l.351-.01h1.761v2.3l5.087-2.81c1.951-1.08 3.163-3.13 3.163-5.36 0-3.39-2.744-6.13-6.129-6.13H9.756z"></path></g></svg>
|
|
<span class="engagement-count">${engagement.replies}</span>
|
|
</div>
|
|
<div class="engagement-item">
|
|
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M4.5 3.88l4.432 4.14-1.364 1.46L5.5 7.55V16c0 1.1.896 2 2 2H13v2H7.5c-2.209 0-4-1.79-4-4V7.55L1.432 9.48.068 8.02 4.5 3.88zM16.5 6H11V4h5.5c2.209 0 4 1.79 4 4v8.45l2.068-1.93 1.364 1.46-4.432 4.14-4.432-4.14 1.364-1.46 2.068 1.93V8c0-1.1-.896-2-2-2z"></path></g></svg>
|
|
<span class="engagement-count">${engagement.retweets}</span>
|
|
</div>
|
|
<div class="engagement-item">
|
|
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z"></path></g></svg>
|
|
<span class="engagement-count">${engagement.likes}</span>
|
|
</div>
|
|
<div class="engagement-item">
|
|
<svg viewBox="0 0 24 24" class="engagement-icon"><g><path d="M8.75 21V3h2v18h-2zM18 21V8.5h2V21h-2zM4 21l.004-10h2L6 21H4zm9.248 0v-7h2v7h-2z"></path></g></svg>
|
|
<span class="engagement-count">${engagement.views}</span>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async function generateQuoteBuffer(config) {
|
|
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 engagementHtml = buildEngagementHtml(config.engagement);
|
|
|
|
const verifiedBadge = config.verified ? '<svg viewBox="0 0 22 22" class="verified-badge"><g><path d="M20.396 11c-.018-.646-.215-1.275-.57-1.816-.354-.54-.852-.972-1.438-1.246.223-.607.27-1.264.14-1.897-.131-.634-.437-1.218-.882-1.687-.47-.445-1.053-.75-1.687-.882-.633-.13-1.29-.083-1.897.14-.273-.587-.704-1.086-1.245-1.44S11.647 1.62 11 1.604c-.646.017-1.273.213-1.813.568s-.969.854-1.24 1.44c-.608-.223-1.267-.272-1.902-.14-.635.13-1.22.436-1.69.882-.445.47-.749 1.055-.878 1.688-.13.633-.08 1.29.144 1.896-.587.274-1.087.705-1.443 1.245-.356.54-.555 1.17-.574 1.817.02.647.218 1.276.574 1.817.356.54.856.972 1.443 1.245-.224.606-.274 1.263-.144 1.896.13.634.433 1.218.877 1.688.47.443 1.054.747 1.687.878.633.132 1.29.084 1.897-.136.274.586.705 1.084 1.246 1.439.54.354 1.17.551 1.816.569.647-.016 1.276-.213 1.817-.567s.972-.854 1.245-1.44c.604.239 1.266.296 1.903.164.636-.132 1.22-.447 1.68-.907.46-.46.776-1.044.908-1.681s.075-1.299-.165-1.903c.586-.274 1.084-.705 1.439-1.246.354-.54.551-1.17.569-1.816zM9.662 14.85l-3.429-3.428 1.293-1.302 2.072 2.072 4.4-4.794 1.347 1.246z"></path></g></svg>' : '';
|
|
|
|
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;
|