Compare commits
18 Commits
7fa2724e66
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23defb327 | ||
|
|
333a773774 | ||
|
|
18c7304327 | ||
|
|
e935513ba1 | ||
|
|
1d51b9a341 | ||
|
|
469eea4e2f | ||
|
|
48e6c7e3c1 | ||
|
|
48fce1564a | ||
|
|
635f8b2001 | ||
|
|
2245b31af6 | ||
|
|
737d8d3ad5 | ||
|
|
78a598bf7f | ||
|
|
f876b01529 | ||
|
|
410a78ab2b | ||
|
|
1d60f55cb8 | ||
|
|
4da4ff98ba | ||
|
|
d4b1dffd74 | ||
|
|
084130feb7 |
3
.env.example
Normal file
3
.env.example
Normal 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
1
.gitignore
vendored
@@ -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
12
.idea/dataSources.xml
generated
Normal 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>
|
||||||
@@ -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
318
README.md
@@ -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
317
api.js
@@ -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×tamp=1735574400`);
|
console.log(`GET: http://localhost:${PORT}/generate?text=Hello&displayName=Test&username=@test×tamp=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
113
browserPool.js
Normal 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
347
db.js
Normal 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
161
discordWebhook.js
Normal 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
|
||||||
|
};
|
||||||
19
generate.js
19
generate.js
@@ -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
394
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
32
quote.html
32
quote.html
@@ -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
136
template.html
Normal 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
429
v2Routes.js
Normal 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;
|
||||||
Reference in New Issue
Block a user