Add snapshot management to API and enhance user-agent validation for v2 routes
This commit is contained in:
53
README.md
53
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: <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/<token>
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
30
api.js
30
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
|
||||
|
||||
130
db.js
130
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
|
||||
};
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
</div>
|
||||
<div class="tweet-text">{{text}}</div>
|
||||
{{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}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
71
v2Routes.js
71
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;
|
||||
|
||||
Reference in New Issue
Block a user