The Meme Protocol

Small self-hosted meme gallery matching the stitch_the_meme_protocol desktop mockup direction.

Current release: 1.1.0.

The browser feed supports one-click image copy, downloads, shareable meme links, full-image viewing with live view/download counts, a fixed source footer, a two-build Homo Reseticus pet download chooser, and shareable lore at /lore.

Homepage and lore social shares use the fingerprinted, metadata-stripped 1200x630 JPEG at /assets/social-preview-v2.78d008df9860.jpg. Individual meme permalinks continue to use their own meme image.

Browser tabs, bookmarks, Apple home-screen links, and installed web apps use the supplied favicon suite under /assets/favicon/; the legacy /favicon.ico URL remains available for automatic browser requests.

Run locally

npm start

The server listens on http://localhost:8080 by default.

Configuration

  • 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, 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. When enabled, empty and legacy demo-only stores are filled to 18 samples so local pagination can be tested.
  • 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_MODERATION_MODEL: moderation vision model, default gpt-4o-mini
  • AUTO_APPROVE_MIN_SCORE: minimum MEME_CONSENSUS_SCORE for immediate AI approval, default 80
  • TRUST_PROXY: set to true when running behind a trusted reverse proxy so upload limits use X-Forwarded-For

Uploads accept PNG and JPEG images. The server rejects files over 5 MB, any image edge over 6000px, and images over 20 million pixels. Accepted uploads are decoded, metadata-stripped, resized so the longest edge is at most 1600px, and stored as WebP. Upload caps are 5 per hour per IP, 10 per day per IP, and 100 globally per day. Strong AI-approved uploads publish immediately; ambiguous or low-quality uploads are queued for the secret admin review page; likely illegal uploads are rejected immediately.

Files are stored under sharded date/hash paths:

data/
  index/memes.jsonl
  index/tags.json
  memes/YYYY/MM/DD/aa/bb/<sha256>.<ext>
  meta/YYYY/MM/DD/aa/bb/<sha256>.json

Discovery Metadata

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
  • /llms.txt
  • /site.webmanifest
  • Open Graph, Twitter card, canonical, and JSON-LD metadata on /

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 with caption and tags. pageSize is capped at 48. Add q=angry%20cat for ranked caption/topic search and tag=cats for a topic filter. Repeat tag to require all selected topics.
  • GET /api/tags: lists the available managed tag names.
  • GET /api/memes/<sha256>: returns public metadata for one approved meme.
  • GET /meme/<sha256>: opens an approved meme in the viewer and provides its shareable permalink.
  • GET /media/<sha256>: returns the normalized WebP image for one approved meme.
  • POST /api/memes: adds 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 with moderationReason; moderation rejection returns 422 with moderationReason.

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.

Captions, tags, and administration

The LLM moderation call also writes a factual searchable caption and chooses relevant tags from the admin-managed catalog. The server filters out invented tags. Uploaders cannot supply captions or tags; reads and search are public, while every metadata mutation requires the existing X-Admin-Token authentication.

Search is lexical: case/accent-insensitive words, prefixes, and small spelling corrections, ranked by relevance then newest first. All query words and all selected tags must match. It does not use embeddings or infer semantic synonyms. Limits: 200 query characters, 20 tag filters, 200 managed tags, 60 characters per tag, and 2000 caption characters.

Open /admin/<ADMIN_TOKEN> to manage pending, published, or all images; edit captions and assigned tags; and add or remove topics. Tag names are lowercase and accept letters, numbers, spaces, underscores, and hyphens. Removing a tag clears its assignments from every image.

Existing records load with empty captions/tags; no offline migration is needed. Define your topics, then use GENERATE ALL MISSING CAPTIONS to backfill all existing images. The browser processes images sequentially, saves each result immediately, and can stop after the current image. Re-running resumes missing captions. GENERATE AI FOR SELECTED can regenerate metadata for existing captions (for example after adding topics). Generation uses OPENAI_API_KEY and OPENAI_MODERATION_MODEL, incurs normal model usage, and leaves existing metadata and approval status intact on failure. Manual edits made while an AI request is running are preserved via a version check. Without an API key, uploads remain pending with empty metadata, and admins can still edit manually.

Authenticated admin endpoints (not part of the public OpenAPI):

  • GET /api/admin/memes?page=1&pageSize=12&status=all: paginated image management; status accepts pending, approved, or all. Supports q, repeated tag, and missingCaption=true.
  • PATCH /api/admin/memes/<sha256> with { "caption": "...", "tags": ["cats"] }: edit either or both metadata fields; unknown tags are rejected.
  • POST /api/admin/memes/<sha256>/generate: generate caption and tags without re-moderating; optional { "onlyMissing": true } skips already-captioned images.
  • GET /api/admin/tags, POST /api/admin/tags with { "name": "cats" }, and DELETE /api/admin/tags/<URL-encoded-name>: manage the catalog.

Admin responses use Cache-Control: no-store. Unauthenticated admin API requests return 404.

Docker

docker build -t meme-protocol .
docker run --rm -p 8080:8080 -v meme-protocol-data:/data meme-protocol

For production, copy .env.example to .env, set real secrets, then run:

docker compose up -d --build

The included compose file binds the app to 127.0.0.1:18080 on the host so a reverse proxy can publish it without exposing the Node container directly.

Production Build Note

On the production host used for this project, npm registry downloads from inside Docker timed out unless registry.npmjs.org was pinned to a known-good IPv4 address during build. The proven build command is:

sudo docker build \
  --network=host \
  --add-host registry.npmjs.org:104.16.1.34 \
  -t meme-protocol:latest .

Then start with the already-built image:

sudo docker compose up -d --no-build

If the registry IP ever stops working, resolve and test another IPv4 address for registry.npmjs.org, then replace 104.16.1.34 in the build command.

When using a host-mounted data directory, the container writes as UID/GID 10001:10001:

sudo mkdir -p ./data
sudo chown -R 10001:10001 ./data
sudo chmod -R u+rwX,g+rwX,o-rwx ./data
S
Description
Code powering the bitsforfree website
Readme
8.1 MiB
0 Stars 1 Watchers 0 Forks
Languages
JavaScript 78.3%
CSS 11.4%
HTML 9.9%
Dockerfile 0.4%