From 1d51b9a341e2847c456659db4beb90fc94092e8f Mon Sep 17 00:00:00 2001 From: ImBenji Date: Fri, 2 Jan 2026 18:09:29 +0000 Subject: [PATCH] Add snapshot management to API and enhance user-agent validation for v2 routes --- README.md | 53 +++++++++++++++++++- api.js | 30 +++++++++++- db.js | 130 ++++++++++++++++++++++++++++++++++++++++++++++++-- template.html | 2 +- v2Routes.js | 71 +++++++++++++++++++++++++-- 5 files changed, 273 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index c6b6811..b261664 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Generate Twitter-style quote images programmatically. Perfect for creating socia - Base64 image support - Docker-ready - v2 API with stateful sessions for incremental updates +- Persistent snapshot links (48-hour TTL, immutable, shareable) ## API Endpoints @@ -323,6 +324,47 @@ curl -X DELETE https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef123 Returns `204 No Content` on success. +### POST /v2/quote/:id/snapshot-link + +Create a persistent snapshot link. This captures the current state of your session and generates a shareable URL that persists for 48 hours (refreshing on each access). + +Unlike the regular `/image` endpoint, snapshots are immutable - they always show the image as it was when the snapshot was created, even if you update the session afterwards. + +**Request:** +```bash +curl -X POST https://quotes.imbenji.net/v2/quote/a1b2c3d4-e5f6-7890-abcd-ef1234567890/snapshot-link +``` + +**Response (201):** +```json +{ + "token": "xY9pQmN3kL8vFw2jRtZ7", + "url": "https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7", + "sessionId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + "createdAt": 1735600000, + "expiresAt": 1735772800 +} +``` + +### GET /v2/snapshot/:token + +Retrieve a snapshot image using its token. + +```bash +curl https://quotes.imbenji.net/v2/snapshot/xY9pQmN3kL8vFw2jRtZ7 --output snapshot.png +``` + +**Response headers:** +- `Content-Type: image/png` +- `X-Snapshot-Token: ` +- `X-Cache: HIT` or `MISS` + +**Snapshot behavior:** +- **Immutable:** Shows the session state when the snapshot was created +- **TTL:** 48 hours from last access (resets on each view) +- **Cascade delete:** Deleted automatically when parent session is deleted +- **Shareable:** Token can be shared with anyone + ### v2 Example Workflow ```javascript @@ -360,6 +402,14 @@ await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, { // 4. Generate the final image const imgRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`); const blob = await imgRes.blob(); + +// 5. (Optional) Create a shareable snapshot link +const snapshotRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/snapshot-link`, { + method: 'POST' +}); +const snapshot = await snapshotRes.json(); +console.log('Shareable URL:', snapshot.url); +// Anyone can access: https://quotes.imbenji.net/v2/snapshot/ ``` ### v2 Parameters @@ -468,8 +518,9 @@ Response: - **Max tweet width:** 450px (auto-scaled to fit) - **Cache TTL:** 24 hours from last access - **Session TTL:** 24 hours from last update (v2 API) +- **Snapshot TTL:** 48 hours from last access (v2 API) - **Cleanup interval:** Every hour -- **Database:** SQLite (for v2 sessions) +- **Database:** SQLite (for v2 sessions and snapshots) ## Limitations diff --git a/api.js b/api.js index 3611b41..718ab32 100644 --- a/api.js +++ b/api.js @@ -5,7 +5,7 @@ const path = require('path'); const crypto = require('crypto'); const { initPool, renderHtml, POOL_SIZE } = require('./browserPool'); const v2Routes = require('./v2Routes'); -const { cleanupExpiredSessions } = require('./db'); +const { cleanupExpiredSessions, cleanupExpiredSnapshots } = require('./db'); const app = express(); const PORT = 3000; @@ -23,10 +23,34 @@ app.use(express.urlencoded({ limit: '1gb', extended: true })); app.use(cors({ origin: '*', methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], - allowedHeaders: ['Content-Type', 'Authorization'], + 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 @@ -372,11 +396,13 @@ app.get('/health', (req, res) => { // 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 diff --git a/db.js b/db.js index cd1085d..4e0d00c 100644 --- a/db.js +++ b/db.js @@ -35,6 +35,21 @@ db.exec(` CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at); `); +// snapshots table +db.exec(` + CREATE TABLE IF NOT EXISTS snapshots ( + token TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + config_json TEXT NOT NULL, + created_at INTEGER NOT NULL, + accessed_at INTEGER NOT NULL, + FOREIGN KEY(session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_snapshots_accessed_at ON snapshots(accessed_at); + CREATE INDEX IF NOT EXISTS idx_snapshots_session_id ON snapshots(session_id); +`); + // migration: add verified column if it doesn't exist try { db.exec(`ALTER TABLE sessions ADD COLUMN verified INTEGER DEFAULT 0`); @@ -51,6 +66,14 @@ function nowEpoch() { return Math.floor(Date.now() / 1000); } +function generateSnapshotToken() { + const buffer = crypto.randomBytes(16); + return buffer.toString('base64') + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=/g, '~'); +} + // convert db row to api resposne format function rowToSession(row) { if (!row) return null; @@ -130,15 +153,15 @@ function updateSession(id, data) { if (data.displayName !== undefined) { updates.push('display_name = ?'); - values.push(data.displayName); + values.push(data.displayName || 'Anonymous'); } if (data.username !== undefined) { updates.push('username = ?'); - values.push(data.username); + values.push(data.username || '@anonymous'); } if (data.text !== undefined) { updates.push('text = ?'); - values.push(data.text); + values.push(data.text || 'No text provided'); } if (data.avatarUrl !== undefined) { updates.push('avatar_url = ?'); @@ -203,6 +226,98 @@ function deleteSession(id) { return result.changes > 0; } +function createSnapshot(sessionId, config, maxRetries = 3) { + const configJson = JSON.stringify(config); + const now = nowEpoch(); + + for (let attempt = 0; attempt < maxRetries; attempt++) { + const token = generateSnapshotToken(); + + try { + const stmt = db.prepare(` + INSERT INTO snapshots (token, session_id, config_json, created_at, accessed_at) + VALUES (?, ?, ?, ?, ?) + `); + + stmt.run(token, sessionId, configJson, now, now); + + return { + token: token, + sessionId: sessionId, + configJson: configJson, + createdAt: now, + accessedAt: now + }; + } catch (err) { + if (err.code === 'SQLITE_CONSTRAINT' && attempt < maxRetries - 1) { + continue; + } + throw err; + } + } + + throw new Error('Failed to generate unique snapshot token after retries'); +} + +function getSnapshot(token) { + const stmt = db.prepare('SELECT * FROM snapshots WHERE token = ?'); + const row = stmt.get(token); + + if (!row) return null; + + return { + token: row.token, + sessionId: row.session_id, + configJson: row.config_json, + createdAt: row.created_at, + accessedAt: row.accessed_at + }; +} + +function touchSnapshot(token) { + const now = nowEpoch(); + const stmt = db.prepare('UPDATE snapshots SET accessed_at = ? WHERE token = ?'); + const result = stmt.run(now, token); + return result.changes > 0; +} + +function deleteSnapshot(token) { + const stmt = db.prepare('DELETE FROM snapshots WHERE token = ?'); + const result = stmt.run(token); + return result.changes > 0; +} + +function getSnapshotsForSession(sessionId) { + const stmt = db.prepare('SELECT * FROM snapshots WHERE session_id = ? ORDER BY created_at DESC'); + const rows = stmt.all(sessionId); + + return rows.map(row => ({ + token: row.token, + sessionId: row.session_id, + configJson: row.config_json, + createdAt: row.created_at, + accessedAt: row.accessed_at + })); +} + +function cleanupExpiredSnapshots() { + const FORTY_EIGHT_HOURS = 48 * 60 * 60; + const cutoff = nowEpoch() - FORTY_EIGHT_HOURS; + + const stmt = db.prepare('DELETE FROM snapshots WHERE accessed_at < ?'); + const result = stmt.run(cutoff); + + if (result.changes > 0) { + console.log(`Cleaned up ${result.changes} expired snapshot(s)`); + } +} + +function countSnapshotsForSession(sessionId) { + const stmt = db.prepare('SELECT COUNT(*) as count FROM snapshots WHERE session_id = ?'); + const row = stmt.get(sessionId); + return row.count; +} + function cleanupExpiredSessions() { const ONE_DAY = 24 * 60 * 60; @@ -221,5 +336,12 @@ module.exports = { getSession, updateSession, deleteSession, - cleanupExpiredSessions + cleanupExpiredSessions, + createSnapshot, + getSnapshot, + touchSnapshot, + deleteSnapshot, + getSnapshotsForSession, + cleanupExpiredSnapshots, + countSnapshotsForSession }; diff --git a/template.html b/template.html index 2b2e151..1408376 100644 --- a/template.html +++ b/template.html @@ -117,7 +117,7 @@
{{text}}
{{imageHtml}} -
{{timestamp}} · via quotes.imbenji.net
+
{{timestamp}} · via tweetforge.imbenji.net
{{engagementHtml}} diff --git a/v2Routes.js b/v2Routes.js index 2809f7a..31b4ba6 100644 --- a/v2Routes.js +++ b/v2Routes.js @@ -3,7 +3,7 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const router = express.Router(); -const { createSession, getSession, updateSession, deleteSession } = require('./db'); +const { createSession, getSession, updateSession, deleteSession, createSnapshot, getSnapshot, touchSnapshot } = require('./db'); const { renderHtml } = require('./browserPool'); const CACHE_DIR = path.join(__dirname, 'cache'); @@ -207,9 +207,11 @@ async function generateQuoteBuffer(config) { // POST /v2/quote - create new session router.post('/quote', (req, res) => { try { + const username = req.body.username?.trim(); + const data = { - displayName: req.body.displayName, - username: req.body.username?.trim(), + displayName: req.body.displayName?.trim(), + username: (username && username !== "@") ? username : undefined, text: req.body.text, avatarUrl: fixDataUri(req.body.avatarUrl), imageUrl: fixDataUri(req.body.imageUrl), @@ -250,8 +252,11 @@ router.patch('/quote/:id', (req, res) => { 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.displayName !== undefined) data.displayName = req.body.displayName?.trim(); + if (req.body.username !== undefined) { + const username = req.body.username?.trim(); + data.username = (username && username !== "@") ? username : undefined; + } 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); @@ -334,4 +339,60 @@ router.delete('/quote/:id', (req, res) => { 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); + + 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; + } + + 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;