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
|
ADMIN_TOKEN=replace-with-a-long-random-secret
|
||||||
OPENAI_API_KEY=sk-your-key
|
OPENAI_API_KEY=sk-your-key
|
||||||
OPENAI_MODERATION_MODEL=gpt-4o-mini
|
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
|
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||||
|
|
||||||
COPY server.js ./server.js
|
COPY server.js ./server.js
|
||||||
|
COPY openapi.json meme-api.skill.md ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ The server listens on `http://localhost:8080` by default.
|
|||||||
- `PORT`: HTTP port, default `8080`
|
- `PORT`: HTTP port, default `8080`
|
||||||
- `HOST`: bind address, default `0.0.0.0`
|
- `HOST`: bind address, default `0.0.0.0`
|
||||||
- `DATA_DIR`: disk storage root, default `./data`
|
- `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
|
- `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.
|
- `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.
|
- `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:
|
The app serves crawler and answer-engine metadata without adding visible page copy:
|
||||||
|
|
||||||
|
- `/openapi.json`
|
||||||
|
- `/meme-api.skill.md`
|
||||||
- `/robots.txt`
|
- `/robots.txt`
|
||||||
- `/sitemap.xml` with the home page and approved meme URLs
|
- `/sitemap.xml` with the home page and approved meme URLs
|
||||||
- `/feed.json`
|
- `/feed.json`
|
||||||
@@ -45,7 +47,18 @@ The app serves crawler and answer-engine metadata without adding visible page co
|
|||||||
- `/site.webmanifest`
|
- `/site.webmanifest`
|
||||||
- Open Graph, Twitter card, canonical, and JSON-LD metadata on `/`
|
- 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
|
## Docker
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ services:
|
|||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
|
SITE_URL: ${SITE_URL:-https://bitsforfree.com}
|
||||||
SEED_DEMO_MEMES: "false"
|
SEED_DEMO_MEMES: "false"
|
||||||
TRUST_PROXY: ${TRUST_PROXY:-false}
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
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">
|
<meta name="theme-color" content="#00ff41">
|
||||||
<link rel="canonical" href="__SEO_CANONICAL__">
|
<link rel="canonical" href="__SEO_CANONICAL__">
|
||||||
<link rel="manifest" href="/site.webmanifest">
|
<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:type" content="website">
|
||||||
<meta property="og:site_name" content="__SEO_TITLE__">
|
<meta property="og:site_name" content="__SEO_TITLE__">
|
||||||
<meta property="og:title" 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 { seedDemoMemes } from './src/seed.js';
|
||||||
import { moderateImage } from './src/moderation.js';
|
import { moderateImage } from './src/moderation.js';
|
||||||
import { createUploadLimiter } from './src/uploadLimits.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';
|
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 PORT = Number.parseInt(process.env.PORT || '8080', 10);
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
const DATA_DIR = process.env.DATA_DIR || './data';
|
const DATA_DIR = process.env.DATA_DIR || './data';
|
||||||
const PAGE_SIZE_MAX = 48;
|
const PAGE_SIZE_MAX = 48;
|
||||||
const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
const REQUEST_MAX_BYTES = 6 * 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 SSE_HEARTBEAT_MS = 25_000;
|
||||||
const events = new Set();
|
const events = new Set();
|
||||||
const DISCOVERY_ROUTES = new Set([
|
const DISCOVERY_ROUTES = new Set([
|
||||||
|
'/openapi.json',
|
||||||
|
'/meme-api.skill.md',
|
||||||
'/robots.txt',
|
'/robots.txt',
|
||||||
'/llms.txt',
|
'/llms.txt',
|
||||||
'/site.webmanifest',
|
'/site.webmanifest',
|
||||||
@@ -29,9 +36,21 @@ const DISCOVERY_ROUTES = new Set([
|
|||||||
]);
|
]);
|
||||||
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
|
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
|
||||||
const indexTemplate = await fs.readFile('./public/index.html', 'utf8');
|
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 store = await createStore({ dataDir: DATA_DIR });
|
||||||
const uploadLimiter = await createUploadLimiter({ 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') {
|
if (process.env.SEED_DEMO_MEMES !== 'false') {
|
||||||
await seedDemoMemes(store);
|
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') {
|
if (req.method === 'GET' && url.pathname === '/robots.txt') {
|
||||||
return sendText(res, 200, robotsTxt(baseUrl));
|
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') {
|
if (req.method === 'GET' && url.pathname === '/api/memes') {
|
||||||
noIndex(res);
|
noIndex(res);
|
||||||
|
const readQuota = checkReadLimit(req, res, apiReadLimiter);
|
||||||
const page = positiveInt(url.searchParams.get('page'), 1);
|
const page = positiveInt(url.searchParams.get('page'), 1);
|
||||||
const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX);
|
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') {
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
||||||
noIndex(res);
|
noIndex(res);
|
||||||
|
checkReadLimit(req, res, apiReadLimiter);
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
memeCount: store.count('approved'),
|
memeCount: store.count('approved'),
|
||||||
@@ -154,43 +188,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/memes') {
|
if (req.method === 'POST' && url.pathname === '/api/memes') {
|
||||||
noIndex(res);
|
noIndex(res);
|
||||||
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
|
const { meme, quota } = await submitMeme(req);
|
||||||
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
|
|
||||||
});
|
|
||||||
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
|
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
|
||||||
meme,
|
meme,
|
||||||
quota,
|
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$/);
|
const viewMatch = url.pathname.match(/^\/api\/memes\/([a-f0-9]{64})\/view$/);
|
||||||
if (req.method === 'POST' && viewMatch) {
|
if (req.method === 'POST' && viewMatch) {
|
||||||
if (store.get(viewMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
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})$/);
|
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && mediaMatch) {
|
if (req.method === 'GET' && mediaMatch) {
|
||||||
|
checkReadLimit(req, res, mediaReadLimiter);
|
||||||
const meme = store.get(mediaMatch[1]);
|
const meme = store.get(mediaMatch[1]);
|
||||||
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
res.setHeader('Content-Type', meme.mime);
|
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})$/);
|
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && downloadMatch) {
|
if (req.method === 'GET' && downloadMatch) {
|
||||||
noIndex(res);
|
noIndex(res);
|
||||||
|
checkReadLimit(req, res, mediaReadLimiter);
|
||||||
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
|
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
|
||||||
const meme = store.get(downloadMatch[1]);
|
const meme = store.get(downloadMatch[1]);
|
||||||
@@ -254,6 +263,15 @@ const server = http.createServer(async (req, res) => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.statusCode || 500;
|
const status = error.statusCode || 500;
|
||||||
const message = status === 500 ? 'Internal server error.' : error.message;
|
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 });
|
return sendJson(res, status, { error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -272,6 +290,45 @@ function isDiscoveryRoute(req, url) {
|
|||||||
return DISCOVERY_ROUTES.has(url.pathname);
|
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) {
|
function downloadName(meme) {
|
||||||
return `meme-protocol-${meme.id.slice(0, 12)}.${meme.ext}`;
|
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) {
|
function isAdminRequest(req) {
|
||||||
return isAdminToken(req.headers['x-admin-token'] || '');
|
return isAdminToken(req.headers['x-admin-token'] || '');
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -37,10 +37,10 @@ export function sendJson(res, statusCode, payload) {
|
|||||||
res.end(body);
|
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);
|
const body = Buffer.from(message);
|
||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': contentType,
|
||||||
'Content-Length': String(body.length)
|
'Content-Length': String(body.length)
|
||||||
});
|
});
|
||||||
res.end(body);
|
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:',
|
'Important URLs:',
|
||||||
`- Site: ${baseUrl}/`,
|
`- Site: ${baseUrl}/`,
|
||||||
|
`- OpenAPI: ${baseUrl}/openapi.json`,
|
||||||
|
`- API skill: ${baseUrl}/meme-api.skill.md`,
|
||||||
`- JSON feed: ${baseUrl}/feed.json`,
|
`- JSON feed: ${baseUrl}/feed.json`,
|
||||||
`- Sitemap: ${baseUrl}/sitemap.xml`,
|
`- Sitemap: ${baseUrl}/sitemap.xml`,
|
||||||
`- Source: ${REPO_URL}`,
|
`- Source: ${REPO_URL}`,
|
||||||
'',
|
'',
|
||||||
'Crawler guidance:',
|
'Crawler guidance:',
|
||||||
'- Public approved meme images are available under /media/<sha256>.',
|
'- 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.',
|
'- 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.',
|
'- 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