Compare commits

..

18 Commits

Author SHA1 Message Date
ImBenji
a23defb327 Add Discord notifications for image generation and snapshot creation 2026-01-02 20:55:12 +00:00
ImBenji
333a773774 Add Discord notifications for image generation and snapshot creation 2026-01-02 19:52:48 +00:00
ImBenji
18c7304327 Add Discord notifications for image generation and snapshot creation 2026-01-02 19:46:42 +00:00
ImBenji
e935513ba1 Refactor username handling in API and v2 routes for improved normalization 2026-01-02 18:45:27 +00:00
ImBenji
1d51b9a341 Add snapshot management to API and enhance user-agent validation for v2 routes 2026-01-02 18:09:29 +00:00
ImBenji
469eea4e2f Implement v2 API for stateful sessions and add engagement metrics support 2026-01-02 12:08:48 +00:00
ImBenji
48e6c7e3c1 Implement v2 API for stateful sessions and add engagement metrics support 2026-01-02 00:45:14 +00:00
ImBenji
48fce1564a Add engagement metrics and health check logging to API 2026-01-01 08:12:53 +00:00
ImBenji
635f8b2001 Rename variables for clarity and update browser pool management 2025-12-31 19:25:55 +00:00
ImBenji
2245b31af6 Rename variables for clarity and update browser pool management 2025-12-31 19:13:43 +00:00
ImBenji
737d8d3ad5 Rename variables for clarity and update browser pool management 2025-12-31 19:07:53 +00:00
ImBenji
78a598bf7f Rename variables for clarity and update browser pool management 2025-12-31 18:34:14 +00:00
ImBenji
f876b01529 Refactor image rendering to use a browser pool and update dependencies 2025-12-31 18:23:15 +00:00
ImBenji
410a78ab2b Add request logging middleware and improve avatar/image URL handling 2025-12-31 13:30:24 +00:00
ImBenji
1d60f55cb8 Increase JSON and URL-encoded body size limits to 1GB 2025-12-31 12:39:25 +00:00
ImBenji
4da4ff98ba Add cache clearing function and invoke on startup 2025-12-31 10:53:25 +00:00
ImBenji
d4b1dffd74 Add cache clearing function and invoke on startup 2025-12-31 10:36:21 +00:00
ImBenji
084130feb7 Hide tweet image container and update image URL to null in quote data 2025-12-31 09:01:19 +00:00
15 changed files with 2173 additions and 160 deletions

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
# Number of browser instances to keep in the pool
# More instances = more concurrent requests but more memory usage
BROWSER_POOL_SIZE=5

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
node_modules/ node_modules/
cache/ cache/
data/
*.png *.png
.env .env
.DS_Store .DS_Store

12
.idea/dataSources.xml generated Normal file
View File

@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="sessions" uuid="e68c3313-f9e3-4252-beee-e5a95c69ca2f">
<driver-ref>sqlite.xerial</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.sqlite.JDBC</jdbc-driver>
<jdbc-url>jdbc:sqlite:$PROJECT_DIR$/data/sessions.db</jdbc-url>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>

View File

@@ -6,6 +6,7 @@ RUN apt-get update && apt-get install -y \
gnupg \ gnupg \
ca-certificates \ ca-certificates \
fonts-liberation \ fonts-liberation \
fonts-noto-color-emoji \
libasound2 \ libasound2 \
libatk-bridge2.0-0 \ libatk-bridge2.0-0 \
libatk1.0-0 \ libatk1.0-0 \

318
README.md
View File

@@ -6,12 +6,16 @@ Generate Twitter-style quote images programmatically. Perfect for creating socia
## Features ## Features
- 🎨 Twitter-authentic styling - Twitter-authentic styling
- 🖼️ Support for images and text-only tweets - Support for images and text-only tweets
- 📸 High-resolution output (3240x3240) - Verified badge (blue checkmark)
- ⚡ Built-in caching (24-hour TTL) - Engagement metrics (likes, retweets, replies, views)
- 🔒 Base64 image support - High-resolution output (3240x3240)
- 🐳 Docker-ready - Built-in caching (24-hour TTL)
- Base64 image support
- Docker-ready
- v2 API with stateful sessions for incremental updates
- Persistent snapshot links (48-hour TTL, immutable, shareable)
## API Endpoints ## API Endpoints
@@ -28,7 +32,6 @@ curl -X POST https://quotes.imbenji.net/generate \
"username": "@geofftech", "username": "@geofftech",
"avatarUrl": "https://example.com/avatar.jpg", "avatarUrl": "https://example.com/avatar.jpg",
"text": "Does anyone else find it immensely satisfying when you turn a pocket inside out and get rid of the crumbs and fluff stuck in the bottom.", "text": "Does anyone else find it immensely satisfying when you turn a pocket inside out and get rid of the crumbs and fluff stuck in the bottom.",
"imageUrl": null,
"timestamp": 1499766270 "timestamp": 1499766270
}' --output quote.png }' --output quote.png
``` ```
@@ -52,6 +55,8 @@ curl "https://quotes.imbenji.net/generate?displayName=John%20Doe&username=@johnd
| `text` | string | No | Tweet text content | | `text` | string | No | Tweet text content |
| `imageUrl` | string | No | Tweet image URL, base64 data URI, or `null` | | `imageUrl` | string | No | Tweet image URL, base64 data URI, or `null` |
| `timestamp` | integer | No | Unix epoch timestamp in seconds | | `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 ## Response
@@ -146,6 +151,300 @@ curl -G "https://quotes.imbenji.net/generate" \
--output quote.png --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: <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.
### 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
// 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();
// 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
| 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 ## Caching
The API automatically caches generated images for 24 hours from the last request. Identical requests will be served from cache instantly. The API automatically caches generated images for 24 hours from the last request. Identical requests will be served from cache instantly.
@@ -218,7 +517,10 @@ Response:
- **Format:** PNG - **Format:** PNG
- **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)
- **Snapshot TTL:** 48 hours from last access (v2 API)
- **Cleanup interval:** Every hour - **Cleanup interval:** Every hour
- **Database:** SQLite (for v2 sessions and snapshots)
## Limitations ## Limitations
@@ -233,4 +535,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

317
api.js
View File

@@ -1,8 +1,12 @@
const express = require('express'); const express = require('express');
const nodeHtmlToImage = require('node-html-to-image'); const cors = require('cors');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const crypto = require('crypto'); const crypto = require('crypto');
const { initPool, renderHtml, POOL_SIZE } = require('./browserPool');
const v2Routes = require('./v2Routes');
const { cleanupExpiredSessions, cleanupExpiredSnapshots } = require('./db');
const { notifyImageGenerated } = require('./discordWebhook');
const app = express(); const app = express();
const PORT = 3000; const PORT = 3000;
@@ -13,10 +17,108 @@ if (!fs.existsSync(CACHE_DIR)) {
fs.mkdirSync(CACHE_DIR); fs.mkdirSync(CACHE_DIR);
} }
app.use(express.json()); // 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';
}
app.use(express.json({ limit: '1gb' }));
app.use(express.urlencoded({ limit: '1gb', extended: true }));
// enable CORS for all routes
app.use(cors({
origin: '*',
methods: ['GET', 'POST', 'PATCH', 'DELETE', 'OPTIONS'],
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
if (req.url === '/health') {
return next();
}
const timestamp = new Date().toISOString();
console.log(`\n[${timestamp}] ${req.method} ${req.url}`);
if ((req.method === 'POST' || req.method === 'PATCH') && req.body) {
const logBody = { ...req.body };
// Truncate long base64 strings for readability
if (logBody.avatarUrl && logBody.avatarUrl.startsWith('data:')) {
logBody.avatarUrl = logBody.avatarUrl.substring(0, 50) + '... (base64 truncated)';
}
if (logBody.imageUrl && logBody.imageUrl.startsWith('data:')) {
logBody.imageUrl = logBody.imageUrl.substring(0, 50) + '... (base64 truncated)';
}
console.log('Body:', JSON.stringify(logBody, null, 2));
}
if (req.method === 'GET' && Object.keys(req.query).length > 0) {
const logQuery = { ...req.query };
// Truncate long base64 strings for readability
if (logQuery.avatarUrl && logQuery.avatarUrl.startsWith('data:')) {
logQuery.avatarUrl = logQuery.avatarUrl.substring(0, 50) + '... (base64 truncated)';
}
if (logQuery.imageUrl && logQuery.imageUrl.startsWith('data:')) {
logQuery.imageUrl = logQuery.imageUrl.substring(0, 50) + '... (base64 truncated)';
}
console.log('Query:', JSON.stringify(logQuery, null, 2));
}
next();
});
// mount v2 api
app.use('/v2', v2Routes);
function normalizeConfig(config) {
// Remove null, undefined, and empty string values
const normalized = {};
for (const [key, value] of Object.entries(config)) {
if (value !== null && value !== undefined && value !== '') {
normalized[key] = value;
}
}
return normalized;
}
function hashConfig(config) { function hashConfig(config) {
const configString = JSON.stringify(config); const normalized = normalizeConfig(config);
const configString = JSON.stringify(normalized);
return crypto.createHash('sha256').update(configString).digest('hex'); return crypto.createHash('sha256').update(configString).digest('hex');
} }
@@ -44,6 +146,20 @@ function cacheImage(config, imageBuffer) {
fs.writeFileSync(cachePath, imageBuffer); fs.writeFileSync(cachePath, imageBuffer);
} }
function clearCache() {
if (!fs.existsSync(CACHE_DIR)) {
return;
}
const files = fs.readdirSync(CACHE_DIR);
files.forEach(file => {
const filePath = path.join(CACHE_DIR, file);
fs.unlinkSync(filePath);
});
console.log(`Cleared ${files.length} cached image(s)`);
}
function cleanupOldCache() { function cleanupOldCache() {
const ONE_DAY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds const ONE_DAY = 24 * 60 * 60 * 1000; // 24 hours in milliseconds
const now = Date.now(); const now = Date.now();
@@ -71,6 +187,23 @@ function cleanupOldCache() {
} }
} }
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) { function formatTimestamp(epoch) {
const date = new Date(epoch * 1000); const date = new Date(epoch * 1000);
@@ -87,32 +220,57 @@ function formatTimestamp(epoch) {
return `${hour12}:${minutes} ${ampm} · ${month} ${day}, ${year}`; return `${hour12}:${minutes} ${ampm} · ${month} ${day}, ${year}`;
} }
async function generateQuoteBuffer(config) { const TEMPLATE_PATH = path.join(__dirname, 'template.html');
const htmlTemplate = fs.readFileSync(path.join(__dirname, 'quote.html'), 'utf8'); const templateHtml = fs.readFileSync(TEMPLATE_PATH, 'utf8');
const configScript = ` function buildEngagementHtml(engagement) {
<script> if (!engagement) return '';
window.tweetConfig = ${JSON.stringify(config)}; return `
</script> <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>
`; `;
}
const modifiedHtml = htmlTemplate.replace('</head>', `${configScript}</head>`); 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 image = await nodeHtmlToImage({ const imageHtml = config.imageUrl
html: modifiedHtml, ? `<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>`
puppeteerArgs: { : '';
args: ['--no-sandbox'],
defaultViewport: {
width: 3240,
height: 3240
}
},
beforeScreenshot: async (page) => {
await new Promise(resolve => setTimeout(resolve, 500));
}
});
return image; 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);
} }
// GET endpoint - use query parameters // GET endpoint - use query parameters
@@ -122,12 +280,13 @@ app.get('/generate', async (req, res) => {
const config = { const config = {
displayName: req.query.displayName || "Anonymous", displayName: req.query.displayName || "Anonymous",
username: req.query.username || "@anonymous", username: normalizeUsername(req.query.username),
avatarUrl: req.query.avatarUrl || "", avatarUrl: fixDataUri(req.query.avatarUrl) || null,
text: req.query.text || "No text provided", text: req.query.text || "No text provided",
imageUrl: req.query.imageUrl || null, imageUrl: fixDataUri(req.query.imageUrl) || null,
timestamp: timestamp, timestamp: timestamp,
viewsCount: req.query.viewsCount || "0" engagement: null,
verified: req.query.verified === 'true' || req.query.verified === '1'
}; };
// Check cache first // Check cache first
@@ -139,6 +298,10 @@ app.get('/generate', async (req, res) => {
image = await generateQuoteBuffer(config); image = await generateQuoteBuffer(config);
cacheImage(config, image); cacheImage(config, image);
fromCache = false; 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('Content-Type', 'image/png');
@@ -151,18 +314,82 @@ app.get('/generate', async (req, res) => {
}); });
// POST endpoint - use request body // POST endpoint - use request body
function detectImageType(base64String) {
// Extract base64 data
const base64Data = base64String.includes(',') ? base64String.split(',')[1] : base64String;
const buffer = Buffer.from(base64Data, 'base64');
// Check magic numbers
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'; // default fallback
}
function fixDataUri(dataUri) {
if (!dataUri || !dataUri.startsWith('data:')) return dataUri;
// Extract the base64 part
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 '@anonymous';
const trimmed = username.trim();
// If empty or just "@", return default
if (!trimmed || trimmed === '@') return '@anonymous';
// Add @ if it doesn't start with it
return trimmed.startsWith('@') ? trimmed : `@${trimmed}`;
}
app.post('/generate', async (req, res) => { app.post('/generate', async (req, res) => {
try { try {
const timestamp = req.body.timestamp ? formatTimestamp(parseInt(req.body.timestamp)) : formatTimestamp(Date.now() / 1000); const timestamp = req.body.timestamp ? formatTimestamp(parseInt(req.body.timestamp)) : formatTimestamp(Date.now() / 1000);
// Only include engagement if all fields are provded
let engagement = null;
if (req.body.engagement &&
req.body.engagement.likes !== undefined &&
req.body.engagement.retweets !== undefined &&
req.body.engagement.replies !== undefined &&
req.body.engagement.views !== undefined) {
engagement = {
likes: formatCount(req.body.engagement.likes),
retweets: formatCount(req.body.engagement.retweets),
replies: formatCount(req.body.engagement.replies),
views: formatCount(req.body.engagement.views)
};
}
const config = { const config = {
displayName: req.body.displayName || "Anonymous", displayName: req.body.displayName || "Anonymous",
username: req.body.username || "@anonymous", username: normalizeUsername(req.body.username),
avatarUrl: req.body.avatarUrl || "", avatarUrl: fixDataUri(req.body.avatarUrl) || null,
text: req.body.text || "No text provided", text: req.body.text || "No text provided",
imageUrl: req.body.imageUrl || null, imageUrl: fixDataUri(req.body.imageUrl) || null,
timestamp: timestamp, timestamp: timestamp,
viewsCount: req.body.viewsCount || "0" engagement: engagement,
verified: req.body.verified === true || req.body.verified === 'true' || req.body.verified === '1'
}; };
// Check cache first // Check cache first
@@ -174,6 +401,10 @@ app.post('/generate', async (req, res) => {
image = await generateQuoteBuffer(config); image = await generateQuoteBuffer(config);
cacheImage(config, image); cacheImage(config, image);
fromCache = false; 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('Content-Type', 'image/png');
@@ -190,17 +421,29 @@ app.get('/health', (req, res) => {
res.status(200).json({ status: 'ok' }); res.status(200).json({ status: 'ok' });
}); });
// Clear all cache on startup
clearCache();
cleanupExpiredSessions();
cleanupExpiredSnapshots();
// Run cleanup every hour // Run cleanup every hour
setInterval(() => { setInterval(() => {
cleanupOldCache(); cleanupOldCache();
cleanupExpiredSessions();
cleanupExpiredSnapshots();
}, 60 * 60 * 1000); }, 60 * 60 * 1000);
// Run cleanup on startup // Initialize browser pool then start server
cleanupOldCache(); initPool().then(() => {
app.listen(PORT, () => {
app.listen(PORT, () => {
console.log(`Quote generator API running on http://localhost:${PORT}`); console.log(`Quote generator API running on http://localhost:${PORT}`);
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&timestamp=1735574400`); console.log(`GET: http://localhost:${PORT}/generate?text=Hello&displayName=Test&username=@test&timestamp=1735574400`);
console.log(`POST: http://localhost:${PORT}/generate`); console.log(`POST: http://localhost:${PORT}/generate`);
console.log(`Cache cleanup runs every hour, images older than 24h are removed`); 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 => {
console.error('Failed to initialize browser pool:', err);
process.exit(1);
}); });

113
browserPool.js Normal file
View File

@@ -0,0 +1,113 @@
const puppeteer = require('puppeteer');
const POOL_SIZE = parseInt(process.env.BROWSER_POOL_SIZE) || 5;
const VIEWPORT_WIDTH = 1500;
const VIEWPORT_HEIGHT = 1500;
let browsers = [];
let availableBrowsers = [];
let initPromise = null;
async function initPool() {
if (initPromise) return initPromise;
initPromise = (async () => {
console.log(`Initializing browser pool with ${POOL_SIZE} instances...`);
for (let i = 0; i < POOL_SIZE; i++) {
const browser = await puppeteer.launch({
headless: true,
args: ['--no-sandbox', '--disable-setuid-sandbox']
});
browsers.push(browser);
availableBrowsers.push(browser);
}
console.log(`Browser pool ready with ${POOL_SIZE} browsers`);
})();
return initPromise;
}
async function acquireBrowser() {
await initPool();
// wait for available browser
while (availableBrowsers.length === 0) {
await new Promise(resolve => setTimeout(resolve, 50));
}
return availableBrowsers.pop();
}
function releaseBrowser(browser) {
availableBrowsers.push(browser);
}
// renders html and returns screenshot as buffer
async function renderHtml(html, waitTime = 1000) {
const browser = await acquireBrowser();
let page = null;
try {
page = await browser.newPage();
await page.setViewport({
width: VIEWPORT_WIDTH,
height: VIEWPORT_HEIGHT
});
await page.setContent(html, { waitUntil: ['load', 'domcontentloaded', 'networkidle0'] });
// wait for body to be ready
await page.waitForSelector('body');
// wait for any animations or laoding
await new Promise(resolve => setTimeout(resolve, waitTime));
const screenshot = await page.screenshot({
type: 'png',
fullPage: false,
encoding: 'binary'
});
return Buffer.from(screenshot);
} finally {
if (page) await page.close();
releaseBrowser(browser);
}
}
async function shutdownPool() {
console.log('Shutting down browser pool...');
for (const browser of browsers) {
await browser.close();
}
browsers = [];
availableBrowsers = [];
initPromise = null;
console.log('Browser pool shut down');
}
// Graceful shutdown handlers
process.on('SIGINT', async () => {
await shutdownPool();
process.exit(0);
});
process.on('SIGTERM', async () => {
await shutdownPool();
process.exit(0);
});
module.exports = {
initPool,
renderHtml,
shutdownPool,
POOL_SIZE
};

347
db.js Normal file
View File

@@ -0,0 +1,347 @@
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);
`);
// 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`);
} catch (err) {
// column already exists, ignore
}
function generateId() {
return crypto.randomUUID();
}
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;
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 || 'Anonymous');
}
if (data.username !== undefined) {
updates.push('username = ?');
values.push(data.username || '@anonymous');
}
if (data.text !== undefined) {
updates.push('text = ?');
values.push(data.text || 'No text provided');
}
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 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;
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,
createSnapshot,
getSnapshot,
touchSnapshot,
deleteSnapshot,
getSnapshotsForSession,
cleanupExpiredSnapshots,
countSnapshotsForSession
};

161
discordWebhook.js Normal file
View File

@@ -0,0 +1,161 @@
const https = require("https");
const WEBHOOK_URL = "https://discord.com/api/webhooks/1456729066924277912/zf0-cSqmU18kX-S5UZJHNwsuqKtM2i7Bp8LMCyXM80n1RsDtFQCTccBIzafghf7q-U4q";
// sanitze text for discord
function sanitizeText(text) {
if (!text) return "";
// remove or replace problematic characters
return text
.replace(/[\u0000-\u001F\u007F-\u009F]/g, "") // remove control chars
.substring(0, 1800); // limit length to avoid discord limits
}
// send notfication to discord
function sendDiscordNotification(content, additionalData = {}) {
if (!WEBHOOK_URL) {
console.warn("Discord webhook URL not configured");
return Promise.resolve();
}
const payload = {
content: content,
...additionalData
};
let data;
try {
data = JSON.stringify(payload);
} catch (err) {
console.error("Failed to stringify webhook payload:", err);
return Promise.resolve();
}
const url = new URL(WEBHOOK_URL);
const options = {
hostname: url.hostname,
path: url.pathname + url.search,
method: "POST",
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(data)
}
};
return new Promise((resolve, reject) => {
const req = https.request(options, (res) => {
let responseData = "";
res.on("data", (chunk) => {
responseData += chunk;
});
res.on("end", () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
resolve(responseData);
} else {
console.error(`Discord webhook failed: ${res.statusCode} - ${responseData}`);
resolve(); // dont reject, just log the error
}
});
});
req.on("error", (error) => {
console.error("Discord webhook error:", error.message);
resolve(); // dont reject to avoid breaking the main flow
});
req.write(data);
req.end();
});
}
// notify about new image generation
function notifyImageGenerated(config, ipAddress) {
const username = sanitizeText(config.username) || "@unknown";
const displayName = sanitizeText(config.displayName) || "Unknown User";
const fields = [
{
name: "User",
value: `${displayName} (${username})`,
inline: false
}
];
if (config.text) {
const sanitized = sanitizeText(config.text);
const shortText = sanitized.length > 1000
? sanitized.substring(0, 1000) + "..."
: sanitized;
fields.push({
name: "Text",
value: shortText || "No text",
inline: false
});
}
if (ipAddress) {
fields.push({
name: "IP Address",
value: ipAddress,
inline: true
});
}
const embed = {
title: "🖼️ New Image Generated",
color: 0x5865F2, // blurple
fields: fields,
timestamp: new Date().toISOString()
};
return sendDiscordNotification("", { embeds: [embed] });
}
// notify about new snapshot creation
function notifySnapshotCreated(sessionId, token, ipAddress) {
const fields = [
{
name: "Session ID",
value: sessionId,
inline: false
},
{
name: "Token",
value: `\`${token}\``,
inline: false
},
{
name: "Link",
value: `/v2/snapshot/${token}`,
inline: false
}
];
if (ipAddress) {
fields.push({
name: "IP Address",
value: ipAddress,
inline: true
});
}
const embed = {
title: "📸 New Snapshot Created",
color: 0x57F287, // green
fields: fields,
timestamp: new Date().toISOString()
};
return sendDiscordNotification("", { embeds: [embed] });
}
module.exports = {
sendDiscordNotification,
notifyImageGenerated,
notifySnapshotCreated
};

View File

@@ -1,6 +1,6 @@
const nodeHtmlToImage = require('node-html-to-image');
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const { renderHtml, shutdownPool } = require('./browserPool');
async function generateQuote(config, outputPath = 'quote.png') { async function generateQuote(config, outputPath = 'quote.png') {
const htmlTemplate = fs.readFileSync(path.join(__dirname, 'quote.html'), 'utf8'); const htmlTemplate = fs.readFileSync(path.join(__dirname, 'quote.html'), 'utf8');
@@ -13,20 +13,8 @@ async function generateQuote(config, outputPath = 'quote.png') {
const modifiedHtml = htmlTemplate.replace('</head>', `${configScript}</head>`); const modifiedHtml = htmlTemplate.replace('</head>', `${configScript}</head>`);
await nodeHtmlToImage({ const imageBuffer = await renderHtml(modifiedHtml, 500);
output: outputPath, fs.writeFileSync(outputPath, imageBuffer);
html: modifiedHtml,
puppeteerArgs: {
args: ['--no-sandbox'],
defaultViewport: {
width: 3240,
height: 3240
}
},
beforeScreenshot: async (page) => {
await new Promise(resolve => setTimeout(resolve, 500));
}
});
console.log(`Quote generated: ${outputPath}`); console.log(`Quote generated: ${outputPath}`);
} }
@@ -57,6 +45,7 @@ const exampleTweetWithImage = {
(async () => { (async () => {
await generateQuote(exampleTweet, 'quote-no-image.png'); await generateQuote(exampleTweet, 'quote-no-image.png');
await generateQuote(exampleTweetWithImage, 'quote-with-image.png'); await generateQuote(exampleTweetWithImage, 'quote-with-image.png');
await shutdownPool();
})(); })();
module.exports = { generateQuote }; module.exports = { generateQuote };

394
package-lock.json generated
View File

@@ -9,8 +9,10 @@
"version": "1.0.0", "version": "1.0.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"better-sqlite3": "^11.0.0",
"cors": "^2.8.5",
"express": "^4.18.2", "express": "^4.18.2",
"node-html-to-image": "^5.0.0" "puppeteer": "^23.0.0"
} }
}, },
"node_modules/@babel/code-frame": { "node_modules/@babel/code-frame": {
@@ -288,6 +290,37 @@
"node": ">=10.0.0" "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": { "node_modules/body-parser": {
"version": "1.20.4", "version": "1.20.4",
"resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz",
@@ -407,6 +440,12 @@
"node": ">=6" "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": { "node_modules/chromium-bidi": {
"version": "0.6.5", "version": "0.6.5",
"resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz", "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-0.6.5.tgz",
@@ -489,6 +528,19 @@
"integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/cors": {
"version": "2.8.5",
"resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz",
"integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==",
"license": "MIT",
"dependencies": {
"object-assign": "^4",
"vary": "^1"
},
"engines": {
"node": ">= 0.10"
}
},
"node_modules/cosmiconfig": { "node_modules/cosmiconfig": {
"version": "9.0.0", "version": "9.0.0",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz", "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.0.tgz",
@@ -541,6 +593,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": { "node_modules/degenerator": {
"version": "5.0.1", "version": "5.0.1",
"resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz", "resolved": "https://registry.npmjs.org/degenerator/-/degenerator-5.0.1.tgz",
@@ -574,6 +650,15 @@
"npm": "1.2.8000 || >= 1.4.16" "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": { "node_modules/devtools-protocol": {
"version": "0.0.1330662", "version": "0.0.1330662",
"resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1330662.tgz", "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1330662.tgz",
@@ -758,6 +843,15 @@
"bare-events": "^2.7.0" "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": { "node_modules/express": {
"version": "4.22.1", "version": "4.22.1",
"resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
@@ -854,6 +948,12 @@
"pend": "~1.2.0" "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": { "node_modules/finalhandler": {
"version": "1.3.2", "version": "1.3.2",
"resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
@@ -905,6 +1005,12 @@
"node": ">= 0.6" "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": { "node_modules/function-bind": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
@@ -989,6 +1095,12 @@
"node": ">= 14" "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": { "node_modules/gopd": {
"version": "1.2.0", "version": "1.2.0",
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
@@ -1001,27 +1113,6 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/handlebars": {
"version": "4.7.8",
"resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.8.tgz",
"integrity": "sha512-vafaFqs8MZkRrSX7sFVUdo3ap/eNiLnb4IakshzvP56X5Nr1iGKAIqdX6tMlm6HcNRIkr6AxO5jFEoJzzpT8aQ==",
"license": "MIT",
"dependencies": {
"minimist": "^1.2.5",
"neo-async": "^2.6.2",
"source-map": "^0.6.1",
"wordwrap": "^1.0.0"
},
"bin": {
"handlebars": "bin/handlebars"
},
"engines": {
"node": ">=0.4.7"
},
"optionalDependencies": {
"uglify-js": "^3.1.4"
}
},
"node_modules/has-symbols": { "node_modules/has-symbols": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
@@ -1146,6 +1237,12 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC" "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": { "node_modules/ip-address": {
"version": "10.1.0", "version": "10.1.0",
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
@@ -1287,6 +1384,18 @@
"node": ">= 0.6" "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": { "node_modules/minimist": {
"version": "1.2.8", "version": "1.2.8",
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
@@ -1302,12 +1411,24 @@
"integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
"license": "MIT" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT" "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": { "node_modules/negotiator": {
"version": "0.6.3", "version": "0.6.3",
"resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
@@ -1317,12 +1438,6 @@
"node": ">= 0.6" "node": ">= 0.6"
} }
}, },
"node_modules/neo-async": {
"version": "2.6.2",
"resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz",
"integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==",
"license": "MIT"
},
"node_modules/netmask": { "node_modules/netmask": {
"version": "2.0.2", "version": "2.0.2",
"resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz", "resolved": "https://registry.npmjs.org/netmask/-/netmask-2.0.2.tgz",
@@ -1332,15 +1447,25 @@
"node": ">= 0.4.0" "node": ">= 0.4.0"
} }
}, },
"node_modules/node-html-to-image": { "node_modules/node-abi": {
"version": "5.0.0", "version": "3.85.0",
"resolved": "https://registry.npmjs.org/node-html-to-image/-/node-html-to-image-5.0.0.tgz", "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.85.0.tgz",
"integrity": "sha512-mRnggiH3PLJbBaiUMc6Assw5EKwy4uaXVIBnfd/MiqWAOJN3A6ktDwpuKhpIVaXNPeojPCD//yDGGJT+GF2CPQ==", "integrity": "sha512-zsFhmbkAzwhTft6nd3VxcG0cvJsT70rL+BIGHWVq5fi6MwGrHwzqKaxXE+Hl2GmnGItnDKPPkO5/LQqjVkIdFg==",
"license": "Apache-2.0", "license": "MIT",
"dependencies": { "dependencies": {
"handlebars": "4.7.8", "semver": "^7.3.5"
"puppeteer": "23.2.2", },
"puppeteer-cluster": "0.24.0" "engines": {
"node": ">=10"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
} }
}, },
"node_modules/object-inspect": { "node_modules/object-inspect": {
@@ -1465,6 +1590,60 @@
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC" "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": { "node_modules/progress": {
"version": "2.0.3", "version": "2.0.3",
"resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz",
@@ -1529,7 +1708,6 @@
"deprecated": "< 24.15.0 is no longer supported", "deprecated": "< 24.15.0 is no longer supported",
"hasInstallScript": true, "hasInstallScript": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"dependencies": { "dependencies": {
"@puppeteer/browsers": "2.3.1", "@puppeteer/browsers": "2.3.1",
"chromium-bidi": "0.6.5", "chromium-bidi": "0.6.5",
@@ -1545,18 +1723,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/puppeteer-cluster": {
"version": "0.24.0",
"resolved": "https://registry.npmjs.org/puppeteer-cluster/-/puppeteer-cluster-0.24.0.tgz",
"integrity": "sha512-zHPoNsrwkFLKFtgJJv2aC13EbMASQsE048uZd7CyikEXcl+sc1Nf6PMFb9kMoZI7/zMYxvuP658o2mw079ZZyQ==",
"license": "MIT",
"dependencies": {
"debug": "^4.3.4"
},
"peerDependencies": {
"puppeteer": ">=22.0.0"
}
},
"node_modules/puppeteer-core": { "node_modules/puppeteer-core": {
"version": "23.2.2", "version": "23.2.2",
"resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.2.2.tgz", "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-23.2.2.tgz",
@@ -1613,6 +1779,35 @@
"node": ">= 0.8" "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": { "node_modules/require-directory": {
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
@@ -1801,6 +1996,51 @@
"url": "https://github.com/sponsors/ljharb" "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": { "node_modules/smart-buffer": {
"version": "4.2.0", "version": "4.2.0",
"resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz",
@@ -1844,6 +2084,7 @@
"resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz",
"integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==",
"license": "BSD-3-Clause", "license": "BSD-3-Clause",
"optional": true,
"engines": { "engines": {
"node": ">=0.10.0" "node": ">=0.10.0"
} }
@@ -1868,6 +2109,15 @@
"text-decoder": "^1.1.0" "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": { "node_modules/string-width": {
"version": "4.2.3", "version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
@@ -1894,6 +2144,15 @@
"node": ">=8" "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": { "node_modules/tar-fs": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-3.1.1.tgz",
@@ -1949,6 +2208,18 @@
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD" "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": { "node_modules/type-is": {
"version": "1.6.18", "version": "1.6.18",
"resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
@@ -1968,19 +2239,6 @@
"integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==", "integrity": "sha512-SbklCd1F0EiZOyPiW192rrHZzZ5sBijB6xM+cpmrwDqObvdtunOHHIk9fCGsoK5JVIYXoyEp4iEdE3upFH3PAg==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/uglify-js": {
"version": "3.19.3",
"resolved": "https://registry.npmjs.org/uglify-js/-/uglify-js-3.19.3.tgz",
"integrity": "sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==",
"license": "BSD-2-Clause",
"optional": true,
"bin": {
"uglifyjs": "bin/uglifyjs"
},
"engines": {
"node": ">=0.8.0"
}
},
"node_modules/unbzip2-stream": { "node_modules/unbzip2-stream": {
"version": "1.4.3", "version": "1.4.3",
"resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz", "resolved": "https://registry.npmjs.org/unbzip2-stream/-/unbzip2-stream-1.4.3.tgz",
@@ -2013,6 +2271,12 @@
"integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==", "integrity": "sha512-H/A06tKD7sS1O1X2SshBVeA5FLycRpjqiBeqGKmBwBDBy28EnRjORxTNe269KSSr5un5qyWi1iL61wLxpd+ZOg==",
"license": "MIT" "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": { "node_modules/utils-merge": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
@@ -2031,12 +2295,6 @@
"node": ">= 0.8" "node": ">= 0.8"
} }
}, },
"node_modules/wordwrap": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz",
"integrity": "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==",
"license": "MIT"
},
"node_modules/wrap-ansi": { "node_modules/wrap-ansi": {
"version": "7.0.0", "version": "7.0.0",
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",

View File

@@ -13,7 +13,9 @@
"license": "ISC", "license": "ISC",
"type": "commonjs", "type": "commonjs",
"dependencies": { "dependencies": {
"node-html-to-image": "^5.0.0", "better-sqlite3": "^11.0.0",
"express": "^4.18.2" "cors": "^2.8.5",
"express": "^4.18.2",
"puppeteer": "^23.0.0"
} }
} }

View File

@@ -47,13 +47,17 @@
width: 40px; width: 40px;
height: 40px; height: 40px;
border-radius: 50%; border-radius: 50%;
background-size: cover;
background-position: center;
background-color: rgb(51, 54, 57); background-color: rgb(51, 54, 57);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.avatar img {
width: 100%;
height: 100%;
object-fit: cover;
}
.avatar-placeholder { .avatar-placeholder {
width: 100%; width: 100%;
height: 100%; height: 100%;
@@ -180,6 +184,7 @@
<div class="tweet-header"> <div class="tweet-header">
<div class="avatar-container"> <div class="avatar-container">
<div class="avatar" id="avatar"> <div class="avatar" id="avatar">
<img id="avatarImg" style="display: none;" />
<div class="avatar-placeholder" id="avatarPlaceholder" style="display: none;"> <div class="avatar-placeholder" id="avatarPlaceholder" style="display: none;">
<div class="css-175oi2r r-sdzlij r-1udh08x r-45ll9u r-u8s1d r-1v2oles r-176fswd" style="width: calc(100% - 4px); height: calc(100% - 4px);"> <div class="css-175oi2r r-sdzlij r-1udh08x r-45ll9u r-u8s1d r-1v2oles r-176fswd" style="width: calc(100% - 4px); height: calc(100% - 4px);">
<div class="css-175oi2r r-172uzmj r-1pi2tsx r-13qz1uu r-1ny4l3l"></div> <div class="css-175oi2r r-172uzmj r-1pi2tsx r-13qz1uu r-1ny4l3l"></div>
@@ -197,8 +202,8 @@
<div class="tweet-text" id="tweetText">omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much</div> <div class="tweet-text" id="tweetText">omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much</div>
<div class="tweet-image-container" id="imageContainer"> <div class="tweet-image-container" id="imageContainer" style="display: none;">
<img src="https://pbs.twimg.com/media/G9Wtk1xWcAA-WMZ?format=jpg&name=large" alt="Image" class="tweet-image" id="tweetImage"> <img src="" alt="Image" class="tweet-image" id="tweetImage">
</div> </div>
<div class="tweet-metadata"> <div class="tweet-metadata">
@@ -254,13 +259,15 @@
username: "@katiopolis", username: "@katiopolis",
avatarUrl: "https://pbs.twimg.com/profile_images/2004569144893554688/KaYjqylC_x96.jpg", avatarUrl: "https://pbs.twimg.com/profile_images/2004569144893554688/KaYjqylC_x96.jpg",
text: "omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much", text: "omg i brought these watches home because none of them worked but i thought id hang onto them and my dad fixed and polished them all for me 😭 i love him so much",
imageUrl: "https://pbs.twimg.com/media/G9Wtk1xWcAA-WMZ?format=jpg&name=large", imageUrl: null,
timestamp: "5:58 PM · Dec 29, 2025", timestamp: "5:58 PM · Dec 29, 2025",
viewsCount: "1.1M" viewsCount: "1.1M"
}; };
function loadTweetData() { function loadTweetData() {
try {
const config = window.tweetConfig; const config = window.tweetConfig;
console.log('Loading tweet data with config:', config);
document.getElementById('displayName').textContent = config.displayName; document.getElementById('displayName').textContent = config.displayName;
document.getElementById('username').textContent = config.username; document.getElementById('username').textContent = config.username;
@@ -268,14 +275,18 @@
document.getElementById('tweetTime').textContent = config.timestamp; document.getElementById('tweetTime').textContent = config.timestamp;
document.getElementById('viewsCount').textContent = config.viewsCount; document.getElementById('viewsCount').textContent = config.viewsCount;
const avatar = document.getElementById('avatar'); const avatarImg = document.getElementById('avatarImg');
const avatarPlaceholder = document.getElementById('avatarPlaceholder'); const avatarPlaceholder = document.getElementById('avatarPlaceholder');
if (config.avatarUrl) { if (config.avatarUrl) {
avatar.style.backgroundImage = `url("${config.avatarUrl}")`; console.log('Setting avatar URL, length:', config.avatarUrl.length);
avatarImg.src = config.avatarUrl;
avatarImg.style.display = 'block';
avatarPlaceholder.style.display = 'none'; avatarPlaceholder.style.display = 'none';
console.log('Avatar image set successfully');
} else { } else {
avatar.style.backgroundImage = 'none'; console.log('No avatarUrl provided, showing placeholder');
avatarImg.style.display = 'none';
avatarPlaceholder.style.display = 'flex'; avatarPlaceholder.style.display = 'flex';
} }
@@ -283,11 +294,16 @@
const tweetImage = document.getElementById('tweetImage'); const tweetImage = document.getElementById('tweetImage');
if (config.imageUrl) { if (config.imageUrl) {
console.log('Setting image URL, length:', config.imageUrl.length);
tweetImage.src = config.imageUrl; tweetImage.src = config.imageUrl;
imageContainer.style.display = 'block'; imageContainer.style.display = 'block';
} else { } else {
console.log('No imageUrl provided, hiding image container');
imageContainer.style.display = 'none'; imageContainer.style.display = 'none';
} }
} catch (error) {
console.error('Error loading tweet data:', error);
}
} }
function fitTweetToSquare() { function fitTweetToSquare() {

136
template.html Normal file
View File

@@ -0,0 +1,136 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
margin: 0;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
background-color: #000;
}
.square-container {
width: calc(100vmin - 20px);
height: calc(100vmin - 20px);
display: flex;
justify-content: center;
align-items: center;
background-color: #000;
overflow: hidden;
}
.tweet-container {
width: 450px;
background-color: #000;
padding: 12px 16px;
box-sizing: border-box;
}
.tweet-header {
display: flex;
align-items: flex-start;
margin-bottom: 12px;
}
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
overflow: hidden;
margin-right: 12px;
background: rgb(51, 54, 57);
}
.user-info {
display: flex;
flex-direction: column;
}
.display-name {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
font-weight: 700;
color: rgb(231, 233, 234);
line-height: 20px;
display: flex;
align-items: center;
gap: 4px;
}
.verified-badge {
width: 18px;
height: 18px;
fill: rgb(29, 155, 240);
}
.username {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: rgb(113, 118, 123);
line-height: 20px;
}
.tweet-text {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: rgb(231, 233, 234);
line-height: 20px;
margin-bottom: 12px;
white-space: pre-wrap;
}
.tweet-time {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 15px;
color: rgb(113, 118, 123);
}
.tweet-source {
color: rgb(29, 155, 240);
}
.engagement-bar {
display: flex;
justify-content: space-between;
padding: 12px 0;
margin-top: 12px;
border-top: 1px solid rgb(47, 51, 54);
}
.engagement-item {
display: flex;
align-items: center;
gap: 4px;
color: rgb(113, 118, 123);
}
.engagement-icon {
width: 18.75px;
height: 18.75px;
fill: rgb(113, 118, 123);
}
.engagement-count {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 13px;
color: rgb(113, 118, 123);
}
</style>
</head>
<body>
<div class="square-container">
<div class="tweet-container">
<div class="tweet-header">
<div class="avatar">{{avatarHtml}}</div>
<div class="user-info">
<span class="display-name">{{displayName}}{{verifiedBadge}}</span>
<span class="username">{{username}}</span>
</div>
</div>
<div class="tweet-text">{{text}}</div>
{{imageHtml}}
<div class="tweet-time">{{timestamp}} · <span class="tweet-source">via tweetforge.imbenji.net</span></div>
{{engagementHtml}}
</div>
</div>
<script>
function fitToSquare() {
const square = document.querySelector('.square-container');
const tweet = document.querySelector('.tweet-container');
const scaleX = square.offsetWidth / tweet.offsetWidth;
const scaleY = square.offsetHeight / tweet.offsetHeight;
const scale = Math.min(scaleX, scaleY) * 0.95;
tweet.style.transform = 'scale(' + scale + ')';
}
window.onload = fitToSquare;
</script>
</body>
</html>

429
v2Routes.js Normal file
View File

@@ -0,0 +1,429 @@
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;