Add snapshot management to API and enhance user-agent validation for v2 routes

This commit is contained in:
ImBenji
2026-01-02 18:09:29 +00:00
parent 469eea4e2f
commit 1d51b9a341
5 changed files with 273 additions and 13 deletions

View File

@@ -15,6 +15,7 @@ Generate Twitter-style quote images programmatically. Perfect for creating socia
- Base64 image support - Base64 image support
- Docker-ready - Docker-ready
- v2 API with stateful sessions for incremental updates - v2 API with stateful sessions for incremental updates
- Persistent snapshot links (48-hour TTL, immutable, shareable)
## API Endpoints ## 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. 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: <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 ### v2 Example Workflow
```javascript ```javascript
@@ -360,6 +402,14 @@ await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}`, {
// 4. Generate the final image // 4. Generate the final image
const imgRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`); const imgRes = await fetch(`https://quotes.imbenji.net/v2/quote/${sessionId}/image`);
const blob = await imgRes.blob(); 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/<token>
``` ```
### v2 Parameters ### v2 Parameters
@@ -468,8 +518,9 @@ Response:
- **Max tweet width:** 450px (auto-scaled to fit) - **Max tweet width:** 450px (auto-scaled to fit)
- **Cache TTL:** 24 hours from last access - **Cache TTL:** 24 hours from last access
- **Session TTL:** 24 hours from last update (v2 API) - **Session TTL:** 24 hours from last update (v2 API)
- **Snapshot TTL:** 48 hours from last access (v2 API)
- **Cleanup interval:** Every hour - **Cleanup interval:** Every hour
- **Database:** SQLite (for v2 sessions) - **Database:** SQLite (for v2 sessions and snapshots)
## Limitations ## Limitations

30
api.js
View File

@@ -5,7 +5,7 @@ const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { initPool, renderHtml, POOL_SIZE } = require('./browserPool'); const { initPool, renderHtml, POOL_SIZE } = require('./browserPool');
const v2Routes = require('./v2Routes'); const v2Routes = require('./v2Routes');
const { cleanupExpiredSessions } = require('./db'); const { cleanupExpiredSessions, cleanupExpiredSnapshots } = require('./db');
const app = express(); const app = express();
const PORT = 3000; const PORT = 3000;
@@ -23,10 +23,34 @@ app.use(express.urlencoded({ limit: '1gb', extended: true }));
app.use(cors({ app.use(cors({
origin: '*', origin: '*',
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'], methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
allowedHeaders: ['Content-Type', 'Authorization'], allowedHeaders: ['Content-Type', 'Authorization', 'User-Agent'],
credentials: false 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 // Request logging middleware
app.use((req, res, next) => { app.use((req, res, next) => {
// skip logging health checks // skip logging health checks
@@ -372,11 +396,13 @@ app.get('/health', (req, res) => {
// Clear all cache on startup // Clear all cache on startup
clearCache(); clearCache();
cleanupExpiredSessions(); cleanupExpiredSessions();
cleanupExpiredSnapshots();
// Run cleanup every hour // Run cleanup every hour
setInterval(() => { setInterval(() => {
cleanupOldCache(); cleanupOldCache();
cleanupExpiredSessions(); cleanupExpiredSessions();
cleanupExpiredSnapshots();
}, 60 * 60 * 1000); }, 60 * 60 * 1000);
// Initialize browser pool then start server // Initialize browser pool then start server

130
db.js
View File

@@ -35,6 +35,21 @@ db.exec(`
CREATE INDEX IF NOT EXISTS idx_sessions_updated_at ON sessions(updated_at); 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 // migration: add verified column if it doesn't exist
try { try {
db.exec(`ALTER TABLE sessions ADD COLUMN verified INTEGER DEFAULT 0`); db.exec(`ALTER TABLE sessions ADD COLUMN verified INTEGER DEFAULT 0`);
@@ -51,6 +66,14 @@ function nowEpoch() {
return Math.floor(Date.now() / 1000); 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 // convert db row to api resposne format
function rowToSession(row) { function rowToSession(row) {
if (!row) return null; if (!row) return null;
@@ -130,15 +153,15 @@ function updateSession(id, data) {
if (data.displayName !== undefined) { if (data.displayName !== undefined) {
updates.push('display_name = ?'); updates.push('display_name = ?');
values.push(data.displayName); values.push(data.displayName || 'Anonymous');
} }
if (data.username !== undefined) { if (data.username !== undefined) {
updates.push('username = ?'); updates.push('username = ?');
values.push(data.username); values.push(data.username || '@anonymous');
} }
if (data.text !== undefined) { if (data.text !== undefined) {
updates.push('text = ?'); updates.push('text = ?');
values.push(data.text); values.push(data.text || 'No text provided');
} }
if (data.avatarUrl !== undefined) { if (data.avatarUrl !== undefined) {
updates.push('avatar_url = ?'); updates.push('avatar_url = ?');
@@ -203,6 +226,98 @@ function deleteSession(id) {
return result.changes > 0; 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() { function cleanupExpiredSessions() {
const ONE_DAY = 24 * 60 * 60; const ONE_DAY = 24 * 60 * 60;
@@ -221,5 +336,12 @@ module.exports = {
getSession, getSession,
updateSession, updateSession,
deleteSession, deleteSession,
cleanupExpiredSessions cleanupExpiredSessions,
createSnapshot,
getSnapshot,
touchSnapshot,
deleteSnapshot,
getSnapshotsForSession,
cleanupExpiredSnapshots,
countSnapshotsForSession
}; };

View File

@@ -117,7 +117,7 @@
</div> </div>
<div class="tweet-text">{{text}}</div> <div class="tweet-text">{{text}}</div>
{{imageHtml}} {{imageHtml}}
<div class="tweet-time">{{timestamp}} · <span class="tweet-source">via quotes.imbenji.net</span></div> <div class="tweet-time">{{timestamp}} · <span class="tweet-source">via tweetforge.imbenji.net</span></div>
{{engagementHtml}} {{engagementHtml}}
</div> </div>
</div> </div>

View File

@@ -3,7 +3,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const router = express.Router(); 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 { renderHtml } = require('./browserPool');
const CACHE_DIR = path.join(__dirname, 'cache'); const CACHE_DIR = path.join(__dirname, 'cache');
@@ -207,9 +207,11 @@ async function generateQuoteBuffer(config) {
// POST /v2/quote - create new session // POST /v2/quote - create new session
router.post('/quote', (req, res) => { router.post('/quote', (req, res) => {
try { try {
const username = req.body.username?.trim();
const data = { const data = {
displayName: req.body.displayName, displayName: req.body.displayName?.trim(),
username: req.body.username?.trim(), username: (username && username !== "@") ? username : undefined,
text: req.body.text, text: req.body.text,
avatarUrl: fixDataUri(req.body.avatarUrl), avatarUrl: fixDataUri(req.body.avatarUrl),
imageUrl: fixDataUri(req.body.imageUrl), imageUrl: fixDataUri(req.body.imageUrl),
@@ -250,8 +252,11 @@ router.patch('/quote/:id', (req, res) => {
const data = {}; const data = {};
if (req.body.displayName !== undefined) data.displayName = req.body.displayName; if (req.body.displayName !== undefined) data.displayName = req.body.displayName?.trim();
if (req.body.username !== undefined) data.username = req.body.username?.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.text !== undefined) data.text = req.body.text;
if (req.body.avatarUrl !== undefined) data.avatarUrl = fixDataUri(req.body.avatarUrl); 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.imageUrl !== undefined) data.imageUrl = fixDataUri(req.body.imageUrl);
@@ -334,4 +339,60 @@ router.delete('/quote/:id', (req, res) => {
res.status(204).send(); 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; module.exports = router;