diff --git a/README.md b/README.md index 8a6c88c..c6b6811 100644 --- a/README.md +++ b/README.md @@ -6,12 +6,15 @@ Generate Twitter-style quote images programmatically. Perfect for creating socia ## Features -- ๐ŸŽจ Twitter-authentic styling -- ๐Ÿ–ผ๏ธ Support for images and text-only tweets -- ๐Ÿ“ธ High-resolution output (3240x3240) -- โšก Built-in caching (24-hour TTL) -- ๐Ÿ”’ Base64 image support -- ๐Ÿณ Docker-ready +- Twitter-authentic styling +- Support for images and text-only tweets +- Verified badge (blue checkmark) +- Engagement metrics (likes, retweets, replies, views) +- High-resolution output (3240x3240) +- Built-in caching (24-hour TTL) +- Base64 image support +- Docker-ready +- v2 API with stateful sessions for incremental updates ## API Endpoints @@ -51,6 +54,8 @@ curl "https://quotes.imbenji.net/generate?displayName=John%20Doe&username=@johnd | `text` | string | No | Tweet text content | | `imageUrl` | string | No | Tweet image URL, base64 data URI, or `null` | | `timestamp` | integer | No | Unix epoch timestamp in seconds | +| `verified` | boolean | No | Show verified badge (blue checkmark) next to name | +| `engagement` | object | No | Engagement metrics (likes, retweets, replies, views) | ## Response @@ -145,6 +150,251 @@ curl -G "https://quotes.imbenji.net/generate" \ --output quote.png ``` +### Verified badge + +```javascript +fetch('https://quotes.imbenji.net/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + displayName: 'Elon Musk', + username: '@elonmusk', + text: 'Just bought Twitter!', + verified: true, + timestamp: 1735574400 + }) +}); +``` + +### Engagement metrics + +```javascript +fetch('https://quotes.imbenji.net/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + displayName: 'Popular User', + username: '@viral', + text: 'This tweet went viral!', + verified: true, + engagement: { + replies: 1234, + retweets: 5678, + likes: 98765, + views: 1500000 + }, + timestamp: 1735574400 + }) +}); +``` + +**Note:** All four engagement fields (replies, retweets, likes, views) must be provided for the engagement bar to appear. Numbers are automatically formatted (e.g., 1234 โ†’ 1.2K, 1500000 โ†’ 1.5M). + +--- + +## v2 API (Stateful Sessions) + +The v2 API lets you build quote images incrementally. Instead of sending everything in one request, you create a session and update fields as needed. This is more efficient when making multiple edits since you dont need to resend large images every time. + +### How it works + +1. Create a session with `POST /v2/quote` +2. Update fields with `PATCH /v2/quote/:id` (only send whats changed) +3. Render the image with `GET /v2/quote/:id/image` + +Sessions expire after 24 hours of inactivity. + +### POST /v2/quote + +Create a new session. + +**Request:** +```bash +curl -X POST https://quotes.imbenji.net/v2/quote \ + -H "Content-Type: application/json" \ + -d '{ + "displayName": "John Doe", + "username": "@johndoe", + "text": "Hello world" + }' +``` + +**Response (201):** +```json +{ + "id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "displayName": "John Doe", + "username": "@johndoe", + "text": "Hello world", + "avatarUrl": null, + "imageUrl": null, + "timestamp": null, + "verified": false, + "engagement": null, + "createdAt": 1735600000, + "updatedAt": 1735600000 +} +``` + +### GET /v2/quote/:id + +Get current session state. + +```bash +curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +### PATCH /v2/quote/:id + +Update specific fields. Only send the fields you want to change. + +```bash +curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Content-Type: application/json" \ + -d '{ + "text": "Updated text!", + "avatarUrl": "data:image/png;base64,..." + }' +``` + +**Engagement updates:** + +```bash +# Set all engagement metrics +curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Content-Type: application/json" \ + -d '{ + "engagement": { + "replies": 100, + "retweets": 250, + "likes": 5000, + "views": 50000 + } + }' + +# Partial update (update only likes, keep other fields) +curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Content-Type: application/json" \ + -d '{ + "engagement": { + "likes": 10000 + } + }' + +# Clear engagement entirely (removes engagement bar) +curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Content-Type: application/json" \ + -d '{ + "engagement": null + }' +``` + +**Verified badge:** + +```bash +# Add verified badge +curl -X PATCH https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 \ + -H "Content-Type: application/json" \ + -d '{ + "verified": true + }' +``` + +### GET /v2/quote/:id/image + +Render the current session state as a PNG image. + +```bash +curl https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/image --output quote.png +``` + +**Response headers:** +- `Content-Type: image/png` +- `X-Session-Id: ` +- `X-Cache: HIT` or `MISS` + +### DELETE /v2/quote/:id + +Delete a session. + +```bash +curl -X DELETE https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890 +``` + +Returns `204 No Content` on success. + +### v2 Example Workflow + +```javascript +// 1. Create session with initial data +const res = await fetch('https://quotes.imbenji.net/v2/quote', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + displayName: 'Jane Doe', + username: '@janedoe', + text: 'Working on something cool' + }) +}); +const session = await res.json(); +const sessionId = session.id; + +// 2. Later, add an avatar (only sends the avatar, not everything again) +await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + avatarUrl: 'data:image/png;base64,iVBORw0KGgo...' + }) +}); + +// 3. Update the text +await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + text: 'Finished building something cool!' + }) +}); + +// 4. Generate the final image +const imgRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`); +const blob = await imgRes.blob(); +``` + +### v2 Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `displayName` | string | Display name | +| `username` | string | Twitter handle with @ | +| `avatarUrl` | string | Avatar image URL or base64 | +| `text` | string | Tweet text content | +| `imageUrl` | string | Tweet image URL or base64 | +| `timestamp` | integer | Unix epoch in seconds | +| `verified` | boolean | Show verified badge (blue checkmark) | +| `engagement` | object/null | Engagement metrics (see below) | + +**Engagement object:** +```json +{ + "engagement": { + "replies": 89, + "retweets": 567, + "likes": 1234, + "views": 50000 + } +} +``` + +**Engagement behavior:** +- All four fields must be provided for the engagement bar to appear +- Partial updates: Only update specific fields, others remain unchanged +- Set to `null` to clear all engagement and hide the engagement bar +- Numbers are auto-formatted (1234 โ†’ 1.2K, 1000000 โ†’ 1M) + +--- + ## Caching The API automatically caches generated images for 24 hours from the last request. Identical requests will be served from cache instantly. @@ -217,7 +467,9 @@ Response: - **Format:** PNG - **Max tweet width:** 450px (auto-scaled to fit) - **Cache TTL:** 24 hours from last access +- **Session TTL:** 24 hours from last update (v2 API) - **Cleanup interval:** Every hour +- **Database:** SQLite (for v2 sessions) ## Limitations @@ -232,4 +484,4 @@ For issues or questions, please contact the maintainer. --- -**Built with:** Node.js, Express, node-html-to-image, Puppeteer +**Built with:** Node.js, Express, Puppeteer, SQLite diff --git a/api.js b/api.js index a1d2549..47cfef6 100644 --- a/api.js +++ b/api.js @@ -3,6 +3,8 @@ 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 } = require('./db'); const app = express(); const PORT = 3000; @@ -26,7 +28,7 @@ app.use((req, res, next) => { const timestamp = new Date().toISOString(); console.log(`\n[${timestamp}] ${req.method} ${req.url}`); - if (req.method === 'POST' && req.body) { + if ((req.method === 'POST' || req.method === 'PATCH') && req.body) { const logBody = { ...req.body }; // Truncate long base64 strings for readability @@ -57,6 +59,9 @@ app.use((req, res, next) => { next(); }); +// mount v2 api +app.use('/v2', v2Routes); + function normalizeConfig(config) { // Remove null, undefined, and empty string values const normalized = {}; @@ -172,8 +177,34 @@ function formatTimestamp(epoch) { 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) { - // Build HTML directly with values injected const avatarHtml = config.avatarUrl ? `` : `
`; @@ -182,155 +213,21 @@ async function generateQuoteBuffer(config) { ? `
` : ''; - // Only show engagement bar if all fields provided - const engagementHtml = config.engagement ? ` -
- - - - -
- ` : ''; + const engagementHtml = buildEngagementHtml(config.engagement); - const html = ` - - - - - - -
-
-
-
${avatarHtml}
- -
-
${config.text}
- ${imageHtml} -
${config.timestamp}
- ${engagementHtml} -
-
- - -`; + const verifiedBadge = config.verified ? '' : ''; - const image = await renderHtml(html, 1000); - return image; + 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 @@ -345,7 +242,8 @@ app.get('/generate', async (req, res) => { text: req.query.text || "No text provided", imageUrl: fixDataUri(req.query.imageUrl) || null, timestamp: timestamp, - engagement: null + engagement: null, + verified: req.query.verified === 'true' || req.query.verified === '1' }; // Check cache first @@ -433,7 +331,8 @@ app.post('/generate', async (req, res) => { text: req.body.text || "No text provided", imageUrl: fixDataUri(req.body.imageUrl) || null, timestamp: timestamp, - engagement: engagement + engagement: engagement, + verified: req.body.verified === true || req.body.verified === 'true' || req.body.verified === '1' }; // Check cache first @@ -463,10 +362,12 @@ app.get('/health', (req, res) => { // Clear all cache on startup clearCache(); +cleanupExpiredSessions(); // Run cleanup every hour setInterval(() => { cleanupOldCache(); + cleanupExpiredSessions(); }, 60 * 60 * 1000); // Initialize browser pool then start server @@ -476,6 +377,7 @@ initPool().then(() => { 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 => { diff --git a/db.js b/db.js new file mode 100644 index 0000000..cd1085d --- /dev/null +++ b/db.js @@ -0,0 +1,225 @@ +const Database = require('better-sqlite3'); +const path = require('path'); +const fs = require('fs'); +const crypto = require('crypto'); + +const DATA_DIR = path.join(__dirname, 'data'); +const DB_PATH = path.join(DATA_DIR, 'sessions.db'); + +// make sure data dir exists +if (!fs.existsSync(DATA_DIR)) { + fs.mkdirSync(DATA_DIR); +} + +const db = new Database(DB_PATH); + +// setup tables +db.exec(` + CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + display_name TEXT DEFAULT 'Anonymous', + username TEXT DEFAULT '@anonymous', + text TEXT DEFAULT 'No text provided', + avatar_url TEXT, + image_url TEXT, + timestamp INTEGER, + verified INTEGER DEFAULT 0, + engagement_likes INTEGER, + engagement_retweets INTEGER, + engagement_replies INTEGER, + engagement_views INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at); +`); + +// migration: add verified column if it doesn't exist +try { + db.exec(`ALTER TABLE sessions ADD COLUMN verified INTEGER DEFAULT 0`); +} catch (err) { + // column already exists, ignore +} + + +function generateId() { + return crypto.randomUUID(); +} + +function nowEpoch() { + return Math.floor(Date.now() / 1000); +} + +// convert db row to api resposne format +function rowToSession(row) { + if (!row) return null; + + let engagement = null; + if (row.engagement_likes !== null && + row.engagement_retweets !== null && + row.engagement_replies !== null && + row.engagement_views !== null) { + engagement = { + likes: row.engagement_likes, + retweets: row.engagement_retweets, + replies: row.engagement_replies, + views: row.engagement_views + }; + } + + return { + id: row.id, + displayName: row.display_name, + username: row.username, + text: row.text, + avatarUrl: row.avatar_url, + imageUrl: row.image_url, + timestamp: row.timestamp, + verified: Boolean(row.verified), + engagement: engagement, + createdAt: row.created_at, + updatedAt: row.updated_at + }; +} + +function createSession(data = {}) { + const id = generateId(); + const now = nowEpoch(); + + const stmt = db.prepare(` + INSERT INTO sessions ( + id, display_name, username, text, avatar_url, image_url, timestamp, verified, + engagement_likes, engagement_retweets, engagement_replies, engagement_views, + created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + + stmt.run( + id, + data.displayName || 'Anonymous', + data.username || '@anonymous', + data.text || 'No text provided', + data.avatarUrl || null, + data.imageUrl || null, + data.timestamp || null, + data.verified ? 1 : 0, + data.engagement?.likes ?? null, + data.engagement?.retweets ?? null, + data.engagement?.replies ?? null, + data.engagement?.views ?? null, + now, + now + ); + + return getSession(id); +} + +function getSession(id) { + const stmt = db.prepare('SELECT * FROM sessions WHERE id = ?'); + const row = stmt.get(id); + return rowToSession(row); +} + +function updateSession(id, data) { + const session = getSession(id); + if (!session) return null; + + const updates = []; + const values = []; + + if (data.displayName !== undefined) { + updates.push('display_name = ?'); + values.push(data.displayName); + } + if (data.username !== undefined) { + updates.push('username = ?'); + values.push(data.username); + } + if (data.text !== undefined) { + updates.push('text = ?'); + values.push(data.text); + } + if (data.avatarUrl !== undefined) { + updates.push('avatar_url = ?'); + values.push(data.avatarUrl); + } + if (data.imageUrl !== undefined) { + updates.push('image_url = ?'); + values.push(data.imageUrl); + } + if (data.timestamp !== undefined) { + updates.push('timestamp = ?'); + values.push(data.timestamp); + } + + // engagement updates (atomic clear or partial updates) + if (data.engagement !== undefined) { + if (data.engagement === null) { + // explicitly clear all engagement fields + updates.push('engagement_likes = ?'); + updates.push('engagement_retweets = ?'); + updates.push('engagement_replies = ?'); + updates.push('engagement_views = ?'); + values.push(null, null, null, null); + } else { + // partial engagement field updates + if (data.engagement.likes !== undefined) { + updates.push('engagement_likes = ?'); + values.push(data.engagement.likes); + } + if (data.engagement.retweets !== undefined) { + updates.push('engagement_retweets = ?'); + values.push(data.engagement.retweets); + } + if (data.engagement.replies !== undefined) { + updates.push('engagement_replies = ?'); + values.push(data.engagement.replies); + } + if (data.engagement.views !== undefined) { + updates.push('engagement_views = ?'); + values.push(data.engagement.views); + } + } + } + + if (updates.length === 0) { + return session; + } + + updates.push('updated_at = ?'); + values.push(nowEpoch()); + values.push(id); + + const stmt = db.prepare(`UPDATE sessions SET ${updates.join(', ')} WHERE id = ?`); + stmt.run(...values); + + return getSession(id); +} + +function deleteSession(id) { + const stmt = db.prepare('DELETE FROM sessions WHERE id = ?'); + const result = stmt.run(id); + return result.changes > 0; +} + + +function cleanupExpiredSessions() { + const ONE_DAY = 24 * 60 * 60; + const cutoff = nowEpoch() - ONE_DAY; + + const stmt = db.prepare('DELETE FROM sessions WHERE updated_at < ?'); + const result = stmt.run(cutoff); + + if (result.changes > 0) { + console.log(`Cleaned up ${result.changes} expired session(s)`); + } +} + +module.exports = { + createSession, + getSession, + updateSession, + deleteSession, + cleanupExpiredSessions +}; diff --git a/package-lock.json b/package-lock.json index 564bb33..3069bcd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "better-sqlite3": "^11.0.0", "express": "^4.18.2", "puppeteer": "^23.0.0" } @@ -288,6 +289,37 @@ "node": ">=10.0.0" } }, + "node_modules/better-sqlite3": { + "version": "11.10.0", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-11.10.0.tgz", + "integrity": "sha512-EwhOpyXiOEL/lKzHz9AW1msWFNzGc/z+LzeB3/jnFJpxu+th2yqvzsSWas1v9jgs9+xiXJcD5A8CJxAG2TaghQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + } + }, + "node_modules/bindings": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz", + "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==", + "license": "MIT", + "dependencies": { + "file-uri-to-path": "1.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, "node_modules/body-parser": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", @@ -407,6 +439,12 @@ "node": ">=6" } }, + "node_modules/chownr": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", + "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==", + "license": "ISC" + }, "node_modules/chromium-bidi": { "version": "0.6.5", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz", @@ -541,6 +579,30 @@ } } }, + "node_modules/decompress-response": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", + "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", + "license": "MIT", + "dependencies": { + "mimic-response": "^3.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/deep-extend": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz", + "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==", + "license": "MIT", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/degenerator": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", @@ -574,6 +636,15 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, "node_modules/devtools-protocol": { "version": "0.0.1330662", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1330662.tgz", @@ -758,6 +829,15 @@ "bare-events": "^2.7.0" } }, + "node_modules/expand-template": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz", + "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==", + "license": "(MIT OR WTFPL)", + "engines": { + "node": ">=6" + } + }, "node_modules/express": { "version": "4.22.1", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", @@ -854,6 +934,12 @@ "pend": "~1.2.0" } }, + "node_modules/file-uri-to-path": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz", + "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==", + "license": "MIT" + }, "node_modules/finalhandler": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", @@ -905,6 +991,12 @@ "node": ">= 0.6" } }, + "node_modules/fs-constants": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz", + "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==", + "license": "MIT" + }, "node_modules/function-bind": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", @@ -989,6 +1081,12 @@ "node": ">= 14" } }, + "node_modules/github-from-package": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz", + "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==", + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1125,6 +1223,12 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ini": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", + "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", + "license": "ISC" + }, "node_modules/ip-address": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", @@ -1266,18 +1370,51 @@ "node": ">= 0.6" } }, + "node_modules/mimic-response": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", + "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "license": "MIT" }, + "node_modules/mkdirp-classic": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz", + "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", + "license": "MIT" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "license": "MIT" }, + "node_modules/napi-build-utils": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz", + "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==", + "license": "MIT" + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -1296,6 +1433,18 @@ "node": ">= 0.4.0" } }, + "node_modules/node-abi": { + "version": "3.85.0", + "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz", + "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==", + "license": "MIT", + "dependencies": { + "semver": "^7.3.5" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/object-inspect": { "version": "1.13.4", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", @@ -1418,6 +1567,60 @@ "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "license": "ISC" }, + "node_modules/prebuild-install": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz", + "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==", + "license": "MIT", + "dependencies": { + "detect-libc": "^2.0.0", + "expand-template": "^2.0.3", + "github-from-package": "0.0.0", + "minimist": "^1.2.3", + "mkdirp-classic": "^0.5.3", + "napi-build-utils": "^2.0.0", + "node-abi": "^3.3.0", + "pump": "^3.0.0", + "rc": "^1.2.7", + "simple-get": "^4.0.0", + "tar-fs": "^2.0.0", + "tunnel-agent": "^0.6.0" + }, + "bin": { + "prebuild-install": "bin.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/prebuild-install/node_modules/tar-fs": { + "version": "2.1.4", + "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz", + "integrity": "sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==", + "license": "MIT", + "dependencies": { + "chownr": "^1.1.1", + "mkdirp-classic": "^0.5.2", + "pump": "^3.0.0", + "tar-stream": "^2.1.4" + } + }, + "node_modules/prebuild-install/node_modules/tar-stream": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz", + "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==", + "license": "MIT", + "dependencies": { + "bl": "^4.0.3", + "end-of-stream": "^1.4.1", + "fs-constants": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.1.1" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -1482,7 +1685,6 @@ "deprecated": "< 24.15.0 is no longer supported", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "@puppeteer/browsers": "2.3.1", "chromium-bidi": "0.6.5", @@ -1554,6 +1756,35 @@ "node": ">= 0.8" } }, + "node_modules/rc": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", + "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==", + "license": "(BSD-2-Clause OR MIT OR Apache-2.0)", + "dependencies": { + "deep-extend": "^0.6.0", + "ini": "~1.3.0", + "minimist": "^1.2.0", + "strip-json-comments": "~2.0.1" + }, + "bin": { + "rc": "cli.js" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -1742,6 +1973,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/simple-concat": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz", + "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/simple-get": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz", + "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "decompress-response": "^6.0.0", + "once": "^1.3.1", + "simple-concat": "^1.0.0" + } + }, "node_modules/smart-buffer": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", @@ -1810,6 +2086,15 @@ "text-decoder": "^1.1.0" } }, + "node_modules/string_decoder": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz", + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, "node_modules/string-width": { "version": "4.2.3", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", @@ -1836,6 +2121,15 @@ "node": ">=8" } }, + "node_modules/strip-json-comments": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz", + "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/tar-fs": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", @@ -1891,6 +2185,18 @@ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "license": "0BSD" }, + "node_modules/tunnel-agent": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz", + "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + }, + "engines": { + "node": "*" + } + }, "node_modules/type-is": { "version": "1.6.18", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", @@ -1942,6 +2248,12 @@ "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", "license": "MIT" }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "license": "MIT" + }, "node_modules/utils-merge": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", diff --git a/package.json b/package.json index aa91849..a254e73 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "type": "commonjs", "dependencies": { "puppeteer": "^23.0.0", - "express": "^4.18.2" + "express": "^4.18.2", + "better-sqlite3": "^11.0.0" } } diff --git a/template.html b/template.html new file mode 100644 index 0000000..2b2e151 --- /dev/null +++ b/template.html @@ -0,0 +1,136 @@ + + + + + + + +
+
+
+
{{avatarHtml}}
+ +
+
{{text}}
+ {{imageHtml}} +
{{timestamp}} ยท via quotes.imbenji.net
+ {{engagementHtml}} +
+
+ + + \ No newline at end of file diff --git a/v2Routes.js b/v2Routes.js new file mode 100644 index 0000000..2809f7a --- /dev/null +++ b/v2Routes.js @@ -0,0 +1,337 @@ +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;