Add Public API

This commit is contained in:
2026-06-26 15:23:53 -03:00
parent 36edd32fa3
commit d80d596664
12 changed files with 891 additions and 42 deletions
+1
View File
@@ -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
+1
View File
@@ -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
+15 -2
View File
@@ -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/<sha256>`: returns public metadata for one approved meme.
- `GET /media/<sha256>`: 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
+1
View File
@@ -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}
+60
View File
@@ -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.
+566
View File
@@ -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"
}
}
}
}
}
}
}
+2
View File
@@ -10,6 +10,8 @@
<meta name="theme-color" content="#00ff41">
<link rel="canonical" href="__SEO_CANONICAL__">
<link rel="manifest" href="/site.webmanifest">
<link rel="service-desc" type="application/openapi+json" href="/openapi.json">
<link rel="help" type="text/markdown" href="/meme-api.skill.md">
<meta property="og:type" content="website">
<meta property="og:site_name" content="__SEO_TITLE__">
<meta property="og:title" content="__SEO_TITLE__">
+176 -38
View File
@@ -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'] || '');
}
+2 -2
View File
@@ -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);
+44
View File
@@ -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);
}
}
+3
View File
@@ -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/<sha256>.',
'- 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.',
''
+20
View File
@@ -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.'
);
});