From d80d596664db0529ae6d1d1d9d02e9e2a8a5faea Mon Sep 17 00:00:00 2001 From: Beda Schmid Date: Fri, 26 Jun 2026 15:23:53 -0300 Subject: [PATCH] Add Public API --- .env.example | 1 + Dockerfile | 1 + README.md | 17 +- docker-compose.yaml | 1 + meme-api.skill.md | 60 +++++ openapi.json | 566 +++++++++++++++++++++++++++++++++++++++ public/index.html | 2 + server.js | 214 ++++++++++++--- src/http.js | 4 +- src/rateLimits.js | 44 +++ src/seo.js | 3 + tests/rateLimits.test.js | 20 ++ 12 files changed, 891 insertions(+), 42 deletions(-) create mode 100644 meme-api.skill.md create mode 100644 openapi.json create mode 100644 src/rateLimits.js create mode 100644 tests/rateLimits.test.js diff --git a/.env.example b/.env.example index 268700a..8f891d0 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,4 @@ +SITE_URL=https://bitsforfree.com ADMIN_TOKEN=replace-with-a-long-random-secret OPENAI_API_KEY=sk-your-key OPENAI_MODERATION_MODEL=gpt-4o-mini diff --git a/Dockerfile b/Dockerfile index da8d36e..23159ff 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,6 +7,7 @@ COPY package.json package-lock.json ./ RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force COPY server.js ./server.js +COPY openapi.json meme-api.skill.md ./ COPY src ./src COPY public ./public diff --git a/README.md b/README.md index 8c9eb6b..8ccc876 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ The server listens on `http://localhost:8080` by default. - `PORT`: HTTP port, default `8080` - `HOST`: bind address, default `0.0.0.0` - `DATA_DIR`: disk storage root, default `./data` -- `SITE_URL`: public canonical site URL used for SEO metadata, sitemaps, feeds, and `llms.txt` +- `SITE_URL`: public canonical site URL used for SEO metadata, sitemaps, feeds, `llms.txt`, OpenAPI servers, and API skill instructions. Production should use `https://bitsforfree.com`. - `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes on first boot - `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs. - `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review. @@ -38,6 +38,8 @@ data/ The app serves crawler and answer-engine metadata without adding visible page copy: +- `/openapi.json` +- `/meme-api.skill.md` - `/robots.txt` - `/sitemap.xml` with the home page and approved meme URLs - `/feed.json` @@ -45,7 +47,18 @@ The app serves crawler and answer-engine metadata without adding visible page co - `/site.webmanifest` - Open Graph, Twitter card, canonical, and JSON-LD metadata on `/` -Set `SITE_URL` in production so canonical URLs use the public domain instead of an internal proxy hostname. +Set `SITE_URL` in production so canonical and API discovery URLs use the public domain instead of an internal proxy hostname. The server loads `.env` at startup when present, while real environment variables still take precedence. + +## Public API + +- `GET /api/memes?page=1&pageSize=12`: lists approved memes only. `pageSize` is capped at `48`. +- `GET /api/memes/`: returns public metadata for one approved meme. +- `GET /media/`: returns the normalized WebP image for one approved meme. +- `POST /api/memes`: submits one PNG/JPEG upload as `multipart/form-data` with a file field named `meme`. + +API uploads use the exact same path as the browser form: persistent IP upload quotas, file size checks, image dimension and pixel checks, metadata-stripping WebP normalization, AI moderation, and admin-review queueing. A published upload returns `201`; a queued upload returns `202`; moderation rejection returns `422`. + +Public API reads are rate-limited per IP and return `429` with `Retry-After` when exceeded. List and metadata responses include `RateLimit-*` headers and a `rateLimit` object. ## Docker diff --git a/docker-compose.yaml b/docker-compose.yaml index 1b02fc3..b2e9bb3 100644 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -9,6 +9,7 @@ services: HOST: 0.0.0.0 PORT: 8080 DATA_DIR: /data + SITE_URL: ${SITE_URL:-https://bitsforfree.com} SEED_DEMO_MEMES: "false" TRUST_PROXY: ${TRUST_PROXY:-false} ADMIN_TOKEN: ${ADMIN_TOKEN} diff --git a/meme-api.skill.md b/meme-api.skill.md new file mode 100644 index 0000000..5df7263 --- /dev/null +++ b/meme-api.skill.md @@ -0,0 +1,60 @@ +# The Meme Protocol API Skill + +Use this skill when an agent needs to read approved memes from The Meme Protocol or submit a meme image to the public upload API. + +## Discovery + +- OpenAPI: `__API_BASE_URL__/openapi.json` +- Skill instructions: `__API_BASE_URL__/meme-api.skill.md` +- Public meme list: `GET __API_BASE_URL__/api/memes` +- Public meme metadata: `GET __API_BASE_URL__/api/memes/{id}` +- Public meme image: `GET __API_BASE_URL__/media/{id}` +- Submit meme: `POST __API_BASE_URL__/api/memes` + +## Read Memes + +Call `GET __API_BASE_URL__/api/memes?page=1&pageSize=12` to list approved memes. `page` starts at `1`; `pageSize` is capped at `48`. The response includes `page`, `pageSize`, `total`, `totalPages`, `memes`, and `rateLimit`. + +Call `GET __API_BASE_URL__/api/memes/{id}` to retrieve one approved meme by its 64-character lowercase hex SHA-256 ID. Pending, rejected, deleted, or unknown memes return `404`. + +Use each meme's `url` field to fetch the normalized WebP image. Public image URLs are under `__API_BASE_URL__/media/{id}` and may be cached aggressively by clients. + +Read endpoints are rate-limited per IP. Respect `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`, and `Retry-After` headers. Prefer small page sizes and back off on `429`. + +## Submit Memes + +Submit with `POST __API_BASE_URL__/api/memes` as `multipart/form-data`. The image file field must be named `meme`. + +Example: + +```sh +curl -F "meme=@example.png" __API_BASE_URL__/api/memes +``` + +The API intentionally uses the same upload path as the human browser form: + +- Accepts one image per request. +- Accepts PNG and JPEG only. +- Rejects empty files and unsupported media types. +- Rejects files over 5 MB. +- Rejects images with any edge over 6000 px. +- Rejects images over 20 megapixels. +- Normalizes accepted images to metadata-stripped WebP with longest edge at most 1600 px. +- Runs the same AI moderation workflow. +- Applies the same per-IP upload limits: 5 per hour, 10 per day, and 100 global uploads per day. + +Successful upload responses: + +- `201` means the meme was approved and published immediately. +- `202` means the meme was accepted and queued for admin review. + +Rejected or invalid upload responses: + +- `400` for malformed multipart requests, missing image field, multiple images, or invalid dimensions. +- `411` when upload size is missing. +- `413` when request, file, dimension, or pixel limits are exceeded. +- `415` when the image is not PNG or JPEG. +- `422` when moderation rejects the upload. The body includes `moderationScore`. +- `429` when upload quotas are exhausted. + +Do not attempt to bypass moderation, normalization, or limits. There is no privileged public upload endpoint. diff --git a/openapi.json b/openapi.json new file mode 100644 index 0000000..ba6100d --- /dev/null +++ b/openapi.json @@ -0,0 +1,566 @@ +{ + "openapi": "3.1.0", + "info": { + "title": "The Meme Protocol API", + "version": "1.0.0", + "description": "Public API for listing approved memes and submitting PNG/JPEG meme uploads. Uploads follow the same moderation, image validation, normalization, and IP quota rules as the browser form." + }, + "servers": [ + { + "url": "/" + } + ], + "tags": [ + { + "name": "memes", + "description": "Approved meme discovery and submission" + }, + { + "name": "discovery", + "description": "Machine-readable API metadata" + } + ], + "paths": { + "/api/memes": { + "get": { + "tags": [ + "memes" + ], + "summary": "List approved memes", + "description": "Returns approved public memes only. Results are paginated and read-limited per IP to reduce abuse.", + "parameters": [ + { + "name": "page", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "default": 1 + } + }, + { + "name": "pageSize", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 48, + "default": 12 + } + } + ], + "responses": { + "200": { + "description": "Approved meme page", + "headers": { + "RateLimit-Limit": { + "schema": { + "type": "integer" + } + }, + "RateLimit-Remaining": { + "schema": { + "type": "integer" + } + }, + "RateLimit-Reset": { + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemeListResponse" + } + } + } + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + }, + "post": { + "tags": [ + "memes" + ], + "summary": "Submit a meme", + "description": "Submits one PNG or JPEG image using the same path as the browser upload form. The request must be multipart/form-data with a file field named meme. The server enforces the same size, dimension, pixel, moderation, normalization, and per-IP upload limits as human submissions.", + "requestBody": { + "required": true, + "content": { + "multipart/form-data": { + "schema": { + "type": "object", + "required": [ + "meme" + ], + "properties": { + "meme": { + "type": "string", + "format": "binary", + "description": "PNG or JPEG image, maximum 5 MB, maximum edge 6000 px, maximum 20 megapixels." + } + } + }, + "encoding": { + "meme": { + "contentType": "image/png, image/jpeg" + } + } + } + } + }, + "responses": { + "201": { + "description": "Upload approved and published immediately", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemeSubmitResponse" + } + } + } + }, + "202": { + "description": "Upload accepted and queued for admin review", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MemeSubmitResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/Error" + }, + "411": { + "$ref": "#/components/responses/Error" + }, + "413": { + "$ref": "#/components/responses/Error" + }, + "415": { + "$ref": "#/components/responses/Error" + }, + "422": { + "description": "Upload rejected by moderation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ModerationRejected" + } + } + } + }, + "429": { + "$ref": "#/components/responses/Error" + } + } + } + }, + "/api/memes/{id}": { + "get": { + "tags": [ + "memes" + ], + "summary": "Get one approved meme", + "description": "Returns public metadata for an approved meme. Pending, rejected, deleted, or unknown IDs return 404.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + ], + "responses": { + "200": { + "description": "Approved meme metadata", + "headers": { + "RateLimit-Limit": { + "schema": { + "type": "integer" + } + }, + "RateLimit-Remaining": { + "schema": { + "type": "integer" + } + }, + "RateLimit-Reset": { + "schema": { + "type": "string", + "format": "date-time" + } + } + }, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "meme", + "rateLimit" + ], + "properties": { + "meme": { + "$ref": "#/components/schemas/Meme" + }, + "rateLimit": { + "$ref": "#/components/schemas/RateLimit" + } + } + } + } + } + }, + "404": { + "$ref": "#/components/responses/Error" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/media/{id}": { + "get": { + "tags": [ + "memes" + ], + "summary": "Fetch approved meme image", + "description": "Returns the normalized WebP image for an approved meme. Media reads are rate-limited per IP.", + "parameters": [ + { + "name": "id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + } + ], + "responses": { + "200": { + "description": "WebP meme image", + "content": { + "image/webp": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "404": { + "description": "Not found" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + } + } + } + }, + "/openapi.json": { + "get": { + "tags": [ + "discovery" + ], + "summary": "OpenAPI document", + "responses": { + "200": { + "description": "OpenAPI 3.1 document", + "content": { + "application/json": { + "schema": { + "type": "object" + } + } + } + } + } + } + }, + "/meme-api.skill.md": { + "get": { + "tags": [ + "discovery" + ], + "summary": "LLM API consumption instructions", + "responses": { + "200": { + "description": "Markdown skill instructions for LLM clients", + "content": { + "text/markdown": { + "schema": { + "type": "string" + } + } + } + } + } + } + } + }, + "components": { + "schemas": { + "Meme": { + "type": "object", + "required": [ + "id", + "createdAt", + "byteSize", + "width", + "height", + "mime", + "originalMime", + "status", + "moderationScore", + "moderationReason", + "viewCount", + "downloadCount", + "originalName", + "url", + "downloadUrl", + "demo" + ], + "properties": { + "id": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "byteSize": { + "type": "integer", + "minimum": 0 + }, + "width": { + "type": "integer", + "minimum": 1 + }, + "height": { + "type": "integer", + "minimum": 1 + }, + "mime": { + "type": "string", + "const": "image/webp" + }, + "originalMime": { + "type": "string", + "enum": [ + "image/png", + "image/jpeg" + ] + }, + "status": { + "type": "string", + "enum": [ + "approved", + "pending" + ] + }, + "moderationScore": { + "type": "integer", + "minimum": 0, + "maximum": 100 + }, + "moderationReason": { + "type": "string" + }, + "viewCount": { + "type": "integer", + "minimum": 0 + }, + "downloadCount": { + "type": "integer", + "minimum": 0 + }, + "originalName": { + "type": "string" + }, + "url": { + "type": "string", + "format": "uri-reference" + }, + "downloadUrl": { + "type": "string", + "format": "uri-reference" + }, + "demo": { + "type": "boolean" + } + } + }, + "MemeListResponse": { + "type": "object", + "required": [ + "page", + "pageSize", + "total", + "totalPages", + "memes", + "rateLimit" + ], + "properties": { + "page": { + "type": "integer" + }, + "pageSize": { + "type": "integer" + }, + "total": { + "type": "integer" + }, + "totalPages": { + "type": "integer" + }, + "memes": { + "type": "array", + "items": { + "$ref": "#/components/schemas/Meme" + } + }, + "rateLimit": { + "$ref": "#/components/schemas/RateLimit" + } + } + }, + "MemeSubmitResponse": { + "type": "object", + "required": [ + "meme", + "quota", + "message" + ], + "properties": { + "meme": { + "$ref": "#/components/schemas/Meme" + }, + "quota": { + "$ref": "#/components/schemas/UploadQuota" + }, + "message": { + "type": "string", + "enum": [ + "Upload approved.", + "Upload queued for admin review." + ] + } + } + }, + "UploadQuota": { + "type": "object", + "required": [ + "remainingHour", + "remainingDay", + "remainingGlobalDay" + ], + "properties": { + "remainingHour": { + "type": "integer", + "minimum": 0 + }, + "remainingDay": { + "type": "integer", + "minimum": 0 + }, + "remainingGlobalDay": { + "type": "integer", + "minimum": 0 + } + } + }, + "RateLimit": { + "type": "object", + "required": [ + "limit", + "remaining", + "resetAt" + ], + "properties": { + "limit": { + "type": "integer" + }, + "remaining": { + "type": "integer" + }, + "resetAt": { + "type": "string", + "format": "date-time" + } + } + }, + "Error": { + "type": "object", + "required": [ + "error" + ], + "properties": { + "error": { + "type": "string" + } + } + }, + "ModerationRejected": { + "type": "object", + "required": [ + "error", + "moderationScore" + ], + "properties": { + "error": { + "type": "string" + }, + "moderationScore": { + "type": "integer", + "minimum": 0, + "maximum": 100 + } + } + } + }, + "responses": { + "Error": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + }, + "RateLimited": { + "description": "Rate limit exceeded", + "headers": { + "Retry-After": { + "schema": { + "type": "integer", + "minimum": 1 + } + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Error" + } + } + } + } + } + } +} diff --git a/public/index.html b/public/index.html index 29e8163..a60b5c3 100644 --- a/public/index.html +++ b/public/index.html @@ -10,6 +10,8 @@ + + diff --git a/server.js b/server.js index f9a5163..15674b2 100644 --- a/server.js +++ b/server.js @@ -10,17 +10,24 @@ import { normalizeToWebp } from './src/normalize.js'; import { seedDemoMemes } from './src/seed.js'; import { moderateImage } from './src/moderation.js'; import { createUploadLimiter } from './src/uploadLimits.js'; +import { createIpRateLimiter } from './src/rateLimits.js'; import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, robotsTxt, sitemapXml } from './src/seo.js'; +await loadDotEnv(); + const PORT = Number.parseInt(process.env.PORT || '8080', 10); const HOST = process.env.HOST || '0.0.0.0'; const DATA_DIR = process.env.DATA_DIR || './data'; const PAGE_SIZE_MAX = 48; const UPLOAD_MAX_BYTES = 5 * 1024 * 1024; const REQUEST_MAX_BYTES = 6 * 1024 * 1024; +const API_READ_LIMIT_PER_MINUTE = 120; +const MEDIA_READ_LIMIT_PER_MINUTE = 600; const SSE_HEARTBEAT_MS = 25_000; const events = new Set(); const DISCOVERY_ROUTES = new Set([ + '/openapi.json', + '/meme-api.skill.md', '/robots.txt', '/llms.txt', '/site.webmanifest', @@ -29,9 +36,21 @@ const DISCOVERY_ROUTES = new Set([ ]); const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex'); const indexTemplate = await fs.readFile('./public/index.html', 'utf8'); +const openApiSpec = JSON.parse(await fs.readFile('./openapi.json', 'utf8')); +const memeApiSkillTemplate = await fs.readFile('./meme-api.skill.md', 'utf8'); const store = await createStore({ dataDir: DATA_DIR }); const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR }); +const apiReadLimiter = createIpRateLimiter({ + windowMs: 60_000, + max: API_READ_LIMIT_PER_MINUTE, + message: 'API read limit exceeded. Try again shortly.' +}); +const mediaReadLimiter = createIpRateLimiter({ + windowMs: 60_000, + max: MEDIA_READ_LIMIT_PER_MINUTE, + message: 'Media read limit exceeded. Try again shortly.' +}); if (process.env.SEED_DEMO_MEMES !== 'false') { await seedDemoMemes(store); } @@ -56,6 +75,16 @@ const server = http.createServer(async (req, res) => { })); } + if (req.method === 'GET' && url.pathname === '/openapi.json') { + res.setHeader('Cache-Control', 'public, max-age=300'); + return sendJson(res, 200, openApiSpecFor(baseUrl)); + } + + if (req.method === 'GET' && url.pathname === '/meme-api.skill.md') { + res.setHeader('Cache-Control', 'public, max-age=300'); + return sendText(res, 200, renderMemeApiSkill(baseUrl), 'text/markdown; charset=utf-8'); + } + if (req.method === 'GET' && url.pathname === '/robots.txt') { return sendText(res, 200, robotsTxt(baseUrl)); } @@ -94,13 +123,18 @@ const server = http.createServer(async (req, res) => { if (req.method === 'GET' && url.pathname === '/api/memes') { noIndex(res); + const readQuota = checkReadLimit(req, res, apiReadLimiter); const page = positiveInt(url.searchParams.get('page'), 1); const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX); - return sendJson(res, 200, store.list({ page, pageSize })); + return sendJson(res, 200, { + ...store.list({ page, pageSize }), + rateLimit: readQuota + }); } if (req.method === 'GET' && url.pathname === '/api/status') { noIndex(res); + checkReadLimit(req, res, apiReadLimiter); return sendJson(res, 200, { ok: true, memeCount: store.count('approved'), @@ -154,43 +188,7 @@ const server = http.createServer(async (req, res) => { if (req.method === 'POST' && url.pathname === '/api/memes') { noIndex(res); - const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10); - if (!Number.isFinite(contentLength) || contentLength <= 0) { - return sendJson(res, 411, { error: 'Missing upload size.' }); - } - if (contentLength > REQUEST_MAX_BYTES) { - return sendJson(res, 413, { error: 'Upload request is too large.' }); - } - const quota = await uploadLimiter.checkAndConsume(clientIp(req)); - - const upload = await parseMultipartUpload(req, { - maxRequestBytes: REQUEST_MAX_BYTES, - maxFileBytes: UPLOAD_MAX_BYTES, - fieldName: 'meme' - }); - const image = validateImage(upload.buffer, { - maxBytes: UPLOAD_MAX_BYTES, - maxWidth: 6000, - maxHeight: 6000, - maxPixels: 20_000_000 - }); - const normalized = await normalizeToWebp(upload.buffer); - const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime }); - if (moderation.status === 'rejected') { - return sendJson(res, 422, { - error: `Upload rejected: ${moderation.reason}`, - moderationScore: moderation.score - }); - } - const meme = await store.save({ - buffer: normalized.buffer, - image: normalized.image, - originalName: upload.filename, - originalMime: image.mime, - status: moderation.status, - moderationScore: moderation.score, - moderationReason: moderation.reason - }); + const { meme, quota } = await submitMeme(req); return sendJson(res, meme.status === 'approved' ? 201 : 202, { meme, quota, @@ -198,6 +196,15 @@ const server = http.createServer(async (req, res) => { }); } + const apiMemeMatch = url.pathname.match(/^\/api\/memes\/([a-f0-9]{64})$/); + if (req.method === 'GET' && apiMemeMatch) { + noIndex(res); + const readQuota = checkReadLimit(req, res, apiReadLimiter); + const meme = store.get(apiMemeMatch[1]); + if (!meme || meme.status !== 'approved') return sendJson(res, 404, { error: 'Not found' }); + return sendJson(res, 200, { meme: publicMeme(meme), rateLimit: readQuota }); + } + const viewMatch = url.pathname.match(/^\/api\/memes\/([a-f0-9]{64})\/view$/); if (req.method === 'POST' && viewMatch) { if (store.get(viewMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found'); @@ -209,6 +216,7 @@ const server = http.createServer(async (req, res) => { const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/); if (req.method === 'GET' && mediaMatch) { + checkReadLimit(req, res, mediaReadLimiter); const meme = store.get(mediaMatch[1]); if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found'); res.setHeader('Content-Type', meme.mime); @@ -234,6 +242,7 @@ const server = http.createServer(async (req, res) => { const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/); if (req.method === 'GET' && downloadMatch) { noIndex(res); + checkReadLimit(req, res, mediaReadLimiter); if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found'); const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount'); const meme = store.get(downloadMatch[1]); @@ -254,6 +263,15 @@ const server = http.createServer(async (req, res) => { } catch (error) { const status = error.statusCode || 500; const message = status === 500 ? 'Internal server error.' : error.message; + if (status === 429 && error.retryAfterSeconds) { + res.setHeader('Retry-After', String(error.retryAfterSeconds)); + } + if (status === 429 && error.rateLimit) { + setRateLimitHeaders(res, error.rateLimit); + } + if (error.payload && typeof error.payload === 'object') { + return sendJson(res, status, error.payload); + } return sendJson(res, status, { error: message }); } }); @@ -272,6 +290,45 @@ function isDiscoveryRoute(req, url) { return DISCOVERY_ROUTES.has(url.pathname); } +async function loadDotEnv() { + let content = ''; + try { + content = await fs.readFile('.env', 'utf8'); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + return; + } + + for (const line of content.split('\n')) { + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) continue; + const match = trimmed.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/); + if (!match || process.env[match[1]] !== undefined) continue; + process.env[match[1]] = unquoteEnvValue(match[2].trim()); + } +} + +function unquoteEnvValue(value) { + if ( + (value.startsWith('"') && value.endsWith('"')) || + (value.startsWith("'") && value.endsWith("'")) + ) { + return value.slice(1, -1); + } + return value; +} + +function openApiSpecFor(baseUrl) { + return { + ...openApiSpec, + servers: [{ url: baseUrl }] + }; +} + +function renderMemeApiSkill(baseUrl) { + return memeApiSkillTemplate.replaceAll('__API_BASE_URL__', baseUrl); +} + function downloadName(meme) { return `meme-protocol-${meme.id.slice(0, 12)}.${meme.ext}`; } @@ -287,6 +344,87 @@ function broadcastMetric(meme) { } } +async function submitMeme(req) { + const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10); + if (!Number.isFinite(contentLength) || contentLength <= 0) { + const error = new Error('Missing upload size.'); + error.statusCode = 411; + throw error; + } + if (contentLength > REQUEST_MAX_BYTES) { + const error = new Error('Upload request is too large.'); + error.statusCode = 413; + throw error; + } + const quota = await uploadLimiter.checkAndConsume(clientIp(req)); + + const upload = await parseMultipartUpload(req, { + maxRequestBytes: REQUEST_MAX_BYTES, + maxFileBytes: UPLOAD_MAX_BYTES, + fieldName: 'meme' + }); + const image = validateImage(upload.buffer, { + maxBytes: UPLOAD_MAX_BYTES, + maxWidth: 6000, + maxHeight: 6000, + maxPixels: 20_000_000 + }); + const normalized = await normalizeToWebp(upload.buffer); + const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime }); + if (moderation.status === 'rejected') { + const error = new Error(`Upload rejected: ${moderation.reason}`); + error.statusCode = 422; + error.payload = { + error: error.message, + moderationScore: moderation.score + }; + throw error; + } + const meme = await store.save({ + buffer: normalized.buffer, + image: normalized.image, + originalName: upload.filename, + originalMime: image.mime, + status: moderation.status, + moderationScore: moderation.score, + moderationReason: moderation.reason + }); + return { meme, quota }; +} + +function checkReadLimit(req, res, limiter) { + const quota = limiter.check(clientIp(req)); + setRateLimitHeaders(res, quota); + return quota; +} + +function setRateLimitHeaders(res, quota) { + res.setHeader('RateLimit-Limit', String(quota.limit)); + res.setHeader('RateLimit-Remaining', String(quota.remaining)); + res.setHeader('RateLimit-Reset', quota.resetAt); +} + +function publicMeme(record) { + return { + id: record.id, + createdAt: record.createdAt, + byteSize: record.byteSize, + width: record.width, + height: record.height, + mime: record.mime, + originalMime: record.originalMime, + status: record.status, + moderationScore: record.moderationScore, + moderationReason: record.moderationReason, + viewCount: record.viewCount, + downloadCount: record.downloadCount, + originalName: record.originalName, + url: `/media/${record.id}`, + downloadUrl: `/download/${record.id}`, + demo: Boolean(record.demo) + }; +} + function isAdminRequest(req) { return isAdminToken(req.headers['x-admin-token'] || ''); } diff --git a/src/http.js b/src/http.js index dc3cb0f..668e533 100644 --- a/src/http.js +++ b/src/http.js @@ -37,10 +37,10 @@ export function sendJson(res, statusCode, payload) { res.end(body); } -export function sendText(res, statusCode, message) { +export function sendText(res, statusCode, message, contentType = 'text/plain; charset=utf-8') { const body = Buffer.from(message); res.writeHead(statusCode, { - 'Content-Type': 'text/plain; charset=utf-8', + 'Content-Type': contentType, 'Content-Length': String(body.length) }); res.end(body); diff --git a/src/rateLimits.js b/src/rateLimits.js new file mode 100644 index 0000000..6a1a273 --- /dev/null +++ b/src/rateLimits.js @@ -0,0 +1,44 @@ +import { HttpError } from './errors.js'; + +export function createIpRateLimiter({ windowMs, max, message }) { + const clients = new Map(); + + return { + check(ip) { + const now = Date.now(); + const key = ip || 'unknown'; + const current = clients.get(key); + const bucket = current && current.resetAt > now + ? current + : { count: 0, resetAt: now + windowMs }; + + bucket.count += 1; + clients.set(key, bucket); + pruneExpired(clients, now); + + if (bucket.count > max) { + const error = new HttpError(429, message || 'Rate limit exceeded.'); + error.retryAfterSeconds = Math.max(1, Math.ceil((bucket.resetAt - now) / 1000)); + error.rateLimit = { + limit: max, + remaining: 0, + resetAt: new Date(bucket.resetAt).toISOString() + }; + throw error; + } + + return { + limit: max, + remaining: Math.max(0, max - bucket.count), + resetAt: new Date(bucket.resetAt).toISOString() + }; + } + }; +} + +function pruneExpired(clients, now) { + if (clients.size < 10_000) return; + for (const [key, bucket] of clients) { + if (bucket.resetAt <= now) clients.delete(key); + } +} diff --git a/src/seo.js b/src/seo.js index f9fb7aa..f50e065 100644 --- a/src/seo.js +++ b/src/seo.js @@ -80,12 +80,15 @@ export function llmsTxt(baseUrl) { '', 'Important URLs:', `- Site: ${baseUrl}/`, + `- OpenAPI: ${baseUrl}/openapi.json`, + `- API skill: ${baseUrl}/meme-api.skill.md`, `- JSON feed: ${baseUrl}/feed.json`, `- Sitemap: ${baseUrl}/sitemap.xml`, `- Source: ${REPO_URL}`, '', 'Crawler guidance:', '- Public approved meme images are available under /media/.', + '- Public API clients should use /openapi.json and /meme-api.skill.md before calling API routes.', '- Admin, review, upload, and API mutation routes are not public knowledge sources.', '- The site is intentionally visual and sparse; use metadata, sitemap URLs, and JSON feed for machine summaries.', '' diff --git a/tests/rateLimits.test.js b/tests/rateLimits.test.js new file mode 100644 index 0000000..a5ac20d --- /dev/null +++ b/tests/rateLimits.test.js @@ -0,0 +1,20 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { createIpRateLimiter } from '../src/rateLimits.js'; + +test('limits requests per IP within the configured window', () => { + const limiter = createIpRateLimiter({ + windowMs: 60_000, + max: 2, + message: 'Too many reads.' + }); + + assert.equal(limiter.check('203.0.113.8').remaining, 1); + assert.equal(limiter.check('203.0.113.8').remaining, 0); + assert.equal(limiter.check('203.0.113.9').remaining, 1); + + assert.throws( + () => limiter.check('203.0.113.8'), + (error) => error.statusCode === 429 && error.message === 'Too many reads.' + ); +});