Add Public API
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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__">
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
''
|
||||
|
||||
@@ -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.'
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user