Compare commits

..
25 Commits
Author SHA1 Message Date
smileBeda 1385efec70 Update SocMed Image 2026-09-01 20:12:59 -03:00
smileBeda 5f20e5d2b7 perf: optimize branded 404 artwork 2026-08-31 12:07:38 -03:00
smileBeda a3859f1efc feat: add branded 404 page 2026-08-30 13:49:35 -03:00
smileBeda 48f05bcf8a feat: release meme protocol v1.1.0
Add one-click meme copying, complete viewer actions, fixed-footer pagination, protocol lore, and the Homo Reseticus download chooser.

Install dedicated social and favicon assets, expand local demo data, and synchronize the changelog, documentation, package, and OpenAPI release metadata.
2026-08-14 18:54:37 -03:00
smileBeda 0bf215555a feat: add shareable meme permalinks 2026-07-24 15:37:22 -03:00
smileBeda 4d317ba1d3 Increase meme funniness requirements 2026-07-04 17:40:39 -03:00
smileBeda 2baa814cbd Tighten meme moderation quality gate 2026-07-03 12:45:59 -03:00
smileBeda d80d596664 Add Public API 2026-06-26 15:23:53 -03:00
smileBeda 36edd32fa3 Remove IP Forwarding debug code 2026-05-09 15:33:31 -03:00
smileBeda a66fc3b760 Add custon debug var 2026-05-09 14:15:59 -03:00
smileBeda 101842a713 Add debug var 2026-05-09 14:00:46 -03:00
smileBeda 772b0cc3e8 Debug Proxy header 2026-05-09 13:56:25 -03:00
smileBeda 62043b1f8f Heartbeat and nicer status line 2026-05-09 13:47:37 -03:00
smileBeda 7b5d4afd07 cleanup 2026-05-09 13:41:07 -03:00
smileBeda ff3db71084 allow disproportionate memes 2026-05-09 13:37:20 -03:00
smileBeda e5fa16a88d Count views on stream 2026-05-09 13:31:25 -03:00
smileBeda 908084c394 XML Style 2026-05-09 13:16:52 -03:00
smileBeda 290cbd5bcb Add SEO and GEO 2026-05-09 12:52:43 -03:00
smileBeda 321d3ccf62 Responsiveness adjustements, source link 2026-05-09 11:45:13 -03:00
smileBeda 6cf711ad97 update readme 2026-05-09 11:20:33 -03:00
smileBeda 31a6b7f2c9 update dockerfile 2026-05-08 19:18:32 -03:00
smileBeda e4515a4699 update docker compose 2026-05-08 18:38:16 -03:00
smileBeda 4b15ccc09e update docker compose 2026-05-08 21:32:52 +00:00
smileBeda 5e10af882b init 2026-05-08 18:18:36 -03:00
smileBeda c4bb073ca1 .gitignore 2026-05-08 16:57:19 -03:00
52 changed files with 5998 additions and 26 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
.DS_Store
data
node_modules
stitch_the_meme_protocol
+6
View File
@@ -0,0 +1,6 @@
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
MAX_IMAGE_DIMENSION=1600
WEBP_QUALITY=85
+5
View File
@@ -0,0 +1,5 @@
.DS_Store
stitch_the_meme_protocol/
data/
node_modules/
.env
+31
View File
@@ -0,0 +1,31 @@
# Changelog
All notable changes to The Meme Protocol are documented here. Versions follow Semantic Versioning.
## [Unreleased]
### Fixed
- Added `HEAD` support for public pages, discovery documents, assets, meme media, and health checks so social crawlers can validate card resources without receiving false `404` responses.
- Replaced the 1.6 MB social-preview PNG with a fingerprinted, metadata-stripped 226 KB JPEG for faster and more reliable link-card fetching across social platforms.
## [1.1.0] - 2026-08-14
### Added
- One-click image copying from feed cards and the full-image viewer.
- Copy, download, share, view-count, and download-count controls in the full-image viewer.
- `DOWNLOAD PET` and `READ LORE` protocol menu actions.
- Shareable `/lore` viewer backed by a downloadable plain-text lore file.
- Site-styled pet download chooser offering the original and V2 Homo Reseticus ZIP packages, with a Petdex availability link.
- Eighteen generated demo memes, including automatic top-up of older demo-only stores, so local scrolling exercises pagination.
- A 300ms minimum spinner display for subsequent pages, preventing imperceptible flashes on fast local connections.
- Dedicated `1200x630` social preview metadata for homepage and lore shares, replacing the source repository logo.
- A complete, cache-versioned favicon suite for browser tabs, bookmarks, Apple Touch icons, and installed web apps.
### Changed
- Fixed the source footer to the viewport bottom and reserved enough scroll space to keep the final images and feed loader unobscured.
- Expanded meme IDs in the full-image viewer while retaining compact IDs on feed cards.
- Renamed the user-facing `SUBMIT` / `SUBMIT_MEME` language to `ADD MEME` / `ADD_MEME`.
- Updated package and OpenAPI versions from `1.0.0` to `1.1.0` for this backwards-compatible feature release.
+26
View File
@@ -0,0 +1,26 @@
FROM node:20-bookworm-slim
ENV NODE_ENV=production
WORKDIR /app
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
RUN groupadd --system --gid 10001 meme \
&& useradd --system --uid 10001 --gid meme --home-dir /app --shell /usr/sbin/nologin meme \
&& mkdir -p /data \
&& chown -R meme:meme /data /app
USER meme
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENV DATA_DIR=/data
CMD ["node", "server.js"]
-24
View File
@@ -1,24 +0,0 @@
This is free and unencumbered software released into the public domain.
Anyone is free to copy, modify, publish, use, compile, sell, or
distribute this software, either in source code form or as a compiled
binary, for any purpose, commercial or non-commercial, and by any
means.
In jurisdictions that recognize copyright laws, the author or authors
of this software dedicate any and all copyright interest in the
software to the public domain. We make this dedication for the benefit
of the public at large and to the detriment of our heirs and
successors. We intend this dedication to be an overt act of
relinquishment in perpetuity of all present and future rights to this
software under copyright law.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR
OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
OTHER DEALINGS IN THE SOFTWARE.
For more information, please refer to <https://unlicense.org/>
+112 -2
View File
@@ -1,3 +1,113 @@
# bitasforfree # The Meme Protocol
Code powering the bitsforfree website 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
```sh
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:
```text
data/
index/memes.jsonl
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 only. `pageSize` is capped at `48`.
- `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.
## Docker
```sh
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:
```sh
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:
```sh
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:
```sh
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`:
```sh
sudo mkdir -p ./data
sudo chown -R 10001:10001 ./data
sudo chmod -R u+rwX,g+rwX,o-rwx ./data
```
+29
View File
@@ -0,0 +1,29 @@
services:
meme-protocol:
build: .
image: meme-protocol:latest
container_name: meme-protocol
restart: unless-stopped
environment:
NODE_ENV: production
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}
OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_MODERATION_MODEL: ${OPENAI_MODERATION_MODEL:-gpt-4o-mini}
AUTO_APPROVE_MIN_SCORE: ${AUTO_APPROVE_MIN_SCORE:-80}
MAX_IMAGE_DIMENSION: ${MAX_IMAGE_DIMENSION:-1600}
WEBP_QUALITY: ${WEBP_QUALITY:-85}
volumes:
- ./data:/data
networks:
npm_proxy:
ipv4_address: 192.168.99.21
networks:
npm_proxy:
external: true
+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 add a meme image through 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}`
- Add 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`.
## Add Memes
Add a meme 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. The body includes `moderationReason`.
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` and `moderationReason`.
- `429` when upload quotas are exhausted.
Do not attempt to bypass moderation, normalization, or limits. There is no privileged public upload endpoint.
+580
View File
@@ -0,0 +1,580 @@
{
"openapi": "3.1.0",
"info": {
"title": "The Meme Protocol API",
"version": "1.1.0",
"description": "Public API for listing approved memes and adding 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 addition"
},
{
"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": "Add a meme",
"description": "Adds 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 browser additions.",
"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/MemeAddResponse"
}
}
}
},
"202": {
"description": "Upload accepted and queued for admin review",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/MemeAddResponse"
}
}
}
},
"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"
}
}
},
"MemeAddResponse": {
"type": "object",
"required": [
"meme",
"quota",
"moderationScore",
"moderationReason",
"message"
],
"properties": {
"meme": {
"$ref": "#/components/schemas/Meme"
},
"quota": {
"$ref": "#/components/schemas/UploadQuota"
},
"moderationScore": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"moderationReason": {
"type": "string"
},
"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",
"moderationReason"
],
"properties": {
"error": {
"type": "string"
},
"moderationScore": {
"type": "integer",
"minimum": 0,
"maximum": 100
},
"moderationReason": {
"type": "string"
}
}
}
},
"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"
}
}
}
}
}
}
}
+613
View File
@@ -0,0 +1,613 @@
{
"name": "meme-protocol",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meme-protocol",
"version": "1.1.0",
"dependencies": {
"sharp": "^0.34.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "meme-protocol",
"version": "1.1.0",
"private": true,
"description": "Self-hosted meme gallery for The Meme Protocol.",
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"sharp": "^0.34.5"
}
}
+82
View File
@@ -0,0 +1,82 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__SEO_TITLE__</title>
<meta name="description" content="__SEO_DESCRIPTION__">
<meta name="robots" content="noindex,nofollow,noarchive">
<meta name="application-name" content="The Meme Protocol">
<meta name="theme-color" content="#00ff41">
<link rel="manifest" href="/site.webmanifest?v=20260814">
<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="The Meme Protocol">
<meta property="og:title" content="__SEO_TITLE__">
<meta property="og:description" content="__SEO_DESCRIPTION__">
<meta property="og:url" content="__SEO_URL__">
<meta property="og:image" content="__SEO_IMAGE__">
<meta property="og:image:type" content="image/webp">
<meta property="og:image:width" content="828">
<meta property="og:image:height" content="813">
<meta property="og:image:alt" content="__SEO_IMAGE_ALT__">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="__SEO_TITLE__">
<meta name="twitter:description" content="__SEO_DESCRIPTION__">
<meta name="twitter:image" content="__SEO_IMAGE__">
<meta name="twitter:image:alt" content="__SEO_IMAGE_ALT__">
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg?v=20260814">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/favicon/favicon-96x96.png?v=20260814">
<link rel="shortcut icon" href="/favicon.ico?v=20260814">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png?v=20260814">
<meta name="apple-mobile-web-app-title" content="Meme Protocol">
<link
rel="preload"
as="image"
type="image/webp"
href="/assets/homo-reseticus-404-552.f829379a.webp"
imagesrcset="/assets/homo-reseticus-404-276.0fde8f3e.webp 276w, /assets/homo-reseticus-404-552.f829379a.webp 552w, /assets/homo-reseticus-404-828.68be2119.webp 828w"
imagesizes="(max-width: 488px) 127px, (max-width: 1062px) 26vmin, 276px"
fetchpriority="high"
>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body class="not-found-page">
<header class="topbar">
<a class="brand brand-link" href="/" aria-label="The Meme Protocol home">THE_MEME_PROTOCOL</a>
<a class="primary-action not-found-action" href="/">RETURN_TO_LIVE_FEED</a>
</header>
<main class="shell not-found-shell">
<div class="status-line">
<nav class="protocol-menu" aria-label="Protocol menu">
<a href="/?open=pet">DOWNLOAD PET</a>
<a href="/lore">READ LORE</a>
</nav>
<span class="live"><span class="pulse" aria-hidden="true"></span>ERROR_404 // NODE_UNREACHABLE</span>
</div>
<section class="not-found-content" aria-label="Page not found">
<div class="not-found-visual">
<img
src="/assets/homo-reseticus-404-552.f829379a.webp"
srcset="/assets/homo-reseticus-404-276.0fde8f3e.webp 276w, /assets/homo-reseticus-404-552.f829379a.webp 552w, /assets/homo-reseticus-404-828.68be2119.webp 828w"
sizes="(max-width: 488px) 127px, (max-width: 1062px) 26vmin, 276px"
width="552"
height="542"
fetchpriority="high"
alt="Homo Reseticus surrounded by an unreachable node pattern"
>
</div>
</section>
</main>
<footer class="repo-footer">
<a href="https://git.yoonect.com/Nautilus/bitsforfree" rel="noopener noreferrer" target="_blank">
<img src="/assets/yoonect-logo.png" alt="" aria-hidden="true">
<span>SOURCE</span>
</a>
</footer>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Meme Protocol Review</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/styles.css">
<script type="module" src="/assets/admin.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL_REVIEW</div>
<div class="admin-actions">
<button class="secondary-action" id="refresh-review" type="button">REFRESH</button>
<button class="primary-action" id="approve-selected" type="button">APPROVE_SELECTED</button>
<button class="secondary-action danger-action" id="delete-selected" type="button">DELETE_SELECTED</button>
</div>
</header>
<main class="shell">
<div class="status-line" aria-live="polite">
<span id="review-status">PENDING_REVIEW</span>
<span class="live"><span class="pulse"></span><span id="review-count">0 ITEMS</span></span>
</div>
<section class="meme-grid" id="review-grid" aria-label="Pending meme review"></section>
</main>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
const token = location.pathname.split('/').pop();
history.replaceState(null, '', '/admin');
const grid = document.querySelector('#review-grid');
const reviewStatus = document.querySelector('#review-status');
const reviewCount = document.querySelector('#review-count');
const selected = new Set();
document.querySelector('#refresh-review').addEventListener('click', loadPending);
document.querySelector('#approve-selected').addEventListener('click', () => moderateSelected('approve'));
document.querySelector('#delete-selected').addEventListener('click', () => moderateSelected('delete'));
grid.addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-select-id]');
if (!checkbox) return;
if (checkbox.checked) selected.add(checkbox.dataset.selectId);
else selected.delete(checkbox.dataset.selectId);
});
grid.addEventListener('click', (event) => {
const action = event.target.closest('[data-admin-action]');
if (!action) return;
moderate([action.dataset.id], action.dataset.adminAction);
});
await loadPending();
async function loadPending() {
selected.clear();
reviewStatus.textContent = 'FETCHING_PENDING_QUEUE';
const response = await fetch('/api/admin/pending', { headers: adminHeaders() });
const payload = await response.json();
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_AUTH_FAILED';
grid.innerHTML = '<div class="empty-state">ADMIN_TOKEN_INVALID</div>';
return;
}
reviewStatus.textContent = 'PENDING_REVIEW';
reviewCount.textContent = `${payload.total} ITEMS`;
renderPending(payload.memes);
}
function renderPending(memes) {
if (memes.length === 0) {
grid.innerHTML = '<div class="empty-state">NO_PENDING_MEMES</div>';
return;
}
grid.replaceChildren(...memes.map((meme) => {
const article = document.createElement('article');
article.className = 'meme-card';
article.innerHTML = `
<div class="card-head">
<label class="review-select">
<input type="checkbox" data-select-id="${meme.id}">
<span>ID: ${shortId(meme.id)}</span>
</label>
<span class="card-meta age">SCORE ${meme.moderationScore}</span>
</div>
<div class="image-frame">
<img src="/admin-media/${token}/${meme.id}" alt="Pending meme ${shortId(meme.id)}" loading="lazy">
</div>
<div class="review-body">
<p>${escapeText(meme.moderationReason || 'Queued for review.')}</p>
</div>
<div class="card-actions">
<div class="stats">
<span class="card-stat">${formatBytes(meme.byteSize)}</span>
</div>
<div class="action-group">
<button class="text-action text-action-accent icon-only-action" type="button" data-admin-action="approve" data-id="${meme.id}" aria-label="Approve meme" title="Approve meme"><span class="material-symbols-outlined" aria-hidden="true">check</span></button>
<button class="text-action icon-only-action danger-action" type="button" data-admin-action="delete" data-id="${meme.id}" aria-label="Delete meme" title="Delete meme"><span class="material-symbols-outlined" aria-hidden="true">delete</span></button>
</div>
</div>
`;
return article;
}));
}
async function moderateSelected(action) {
if (selected.size === 0) return;
await moderate([...selected], action);
}
async function moderate(ids, action) {
const endpoint = action === 'approve' ? '/api/admin/approve' : '/api/admin/delete';
const response = await fetch(endpoint, {
method: 'POST',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_ACTION_FAILED';
return;
}
await loadPending();
}
function adminHeaders() {
return { 'X-Admin-Token': token };
}
function shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
}
function formatBytes(value) {
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)}MB`;
return `${Math.ceil(value / 1024)}KB`;
}
function escapeText(value) {
const span = document.createElement('span');
span.textContent = value;
return span.innerHTML;
}
+756
View File
@@ -0,0 +1,756 @@
const PAGE_SIZE = 12;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const MAX_IMAGE_PIXELS = 20_000_000;
const VIEW_DWELL_MS = 1000;
const VIEW_THRESHOLD = 0.9;
const VIEW_DEDUPE_MS = 24 * 60 * 60 * 1000;
const VIEW_DEDUPE_PREFIX = 'meme-viewed:';
const NEXT_PAGE_LOADER_MIN_MS = 300;
const SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
const grid = document.querySelector('#meme-grid');
const feedLoader = document.querySelector('#feed-loader');
const feedLoaderLabel = document.querySelector('#feed-loader-label');
const uploadModal = document.querySelector('#upload-modal');
const uploadForm = document.querySelector('#upload-form');
const fileInput = document.querySelector('#meme-input');
const fileLabel = document.querySelector('#file-label');
const formStatus = document.querySelector('#form-status');
const addMemeButton = document.querySelector('#add-meme-button');
const lightbox = document.querySelector('#lightbox');
const lightboxImage = document.querySelector('#lightbox-image');
const lightboxCopy = document.querySelector('#lightbox-copy');
const lightboxDownload = document.querySelector('#lightbox-download');
const lightboxShare = document.querySelector('#lightbox-share');
const lightboxId = document.querySelector('#lightbox-id');
const lightboxViewCount = document.querySelector('#lightbox-view-count');
const lightboxDownloadCount = document.querySelector('#lightbox-download-count');
const petModal = document.querySelector('#pet-modal');
const loreModal = document.querySelector('#lore-modal');
const loreText = document.querySelector('#lore-text');
const loreCopy = document.querySelector('#lore-copy');
const loreShare = document.querySelector('#lore-share');
const shareModal = document.querySelector('#share-modal');
const shareTitle = document.querySelector('#share-title');
const shareLinkLabel = document.querySelector('#share-link-label');
const shareLink = document.querySelector('#share-link');
const shareStatus = document.querySelector('#share-status');
const copyShareLinkButton = document.querySelector('#copy-share-link');
const scrollIndicator = document.querySelector('#scroll-indicator');
const actionStatus = document.querySelector('#action-status');
let currentPage = 0;
let totalPages = 1;
let isLoading = false;
let latestValidationRun = 0;
let activeShareTrigger = null;
let shareStatusTimer = null;
let actionStatusTimer = null;
let loreContent = '';
const viewedThisSession = new Set();
const pendingViewTimers = new Map();
const copiedFeedbackTimers = new WeakMap();
document.querySelector('#open-upload').addEventListener('click', () => {
formStatus.textContent = '';
uploadModal.showModal();
});
document.querySelector('#close-upload').addEventListener('click', closeUpload);
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
document.querySelector('#close-share').addEventListener('click', () => shareModal.close());
document.querySelector('#open-pet').addEventListener('click', () => petModal.showModal());
document.querySelector('#close-pet').addEventListener('click', () => petModal.close());
document.querySelector('#open-lore').addEventListener('click', openLoreDialog);
document.querySelector('#close-lore').addEventListener('click', () => loreModal.close());
copyShareLinkButton.addEventListener('click', copyActiveShareLink);
lightboxShare.addEventListener('click', async () => {
await openMemeShareDialog(lightboxShare.dataset.shareId, lightboxShare);
});
lightboxCopy.addEventListener('click', () => copyImage(lightboxCopy.dataset.imageUrl, lightboxCopy));
loreCopy.addEventListener('click', copyLore);
loreShare.addEventListener('click', () => openShareDialog({
path: '/lore',
title: 'SHARE_LORE',
label: 'LORE_LINK',
triggerButton: loreShare
}));
fileInput.addEventListener('change', async () => {
const validationRun = ++latestValidationRun;
const file = fileInput.files?.[0];
fileLabel.textContent = file ? file.name : 'SELECT PNG / JPEG';
formStatus.textContent = file ? 'INSPECTING_IMAGE...' : '';
const validationError = await validateClientFile(file);
if (validationRun === latestValidationRun) {
formStatus.textContent = validationError || '';
}
});
uploadForm.addEventListener('submit', async (event) => {
event.preventDefault();
const file = fileInput.files?.[0];
const validationError = await validateClientFile(file);
if (validationError) {
formStatus.textContent = validationError;
return;
}
addMemeButton.disabled = true;
formStatus.textContent = 'UPLOADING...';
try {
const formData = new FormData();
formData.append('meme', file);
const response = await fetch('/api/memes', { method: 'POST', body: formData });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Upload failed.');
if (payload.meme?.status === 'approved') {
closeUpload();
await loadMemes(1, { reset: true });
} else {
uploadForm.reset();
fileLabel.textContent = 'SELECT PNG / JPEG';
formStatus.textContent = payload.message || 'Upload queued for admin review.';
}
} catch (error) {
formStatus.textContent = error.message.toUpperCase();
} finally {
addMemeButton.disabled = false;
}
});
grid.addEventListener('click', async (event) => {
const copyButton = event.target.closest('[data-copy-image-url]');
if (copyButton) {
await copyImage(copyButton.dataset.copyImageUrl, copyButton);
return;
}
const shareButton = event.target.closest('[data-share-id]');
if (shareButton) {
await openMemeShareDialog(shareButton.dataset.shareId, shareButton);
return;
}
const viewButton = event.target.closest('[data-view-id]');
if (!viewButton) return;
const card = viewButton.closest('.meme-card');
await openLightbox({
id: viewButton.dataset.viewId,
url: viewButton.dataset.viewUrl,
downloadUrl: viewButton.dataset.downloadUrl,
alt: card.querySelector('img').alt,
viewCount: Number.parseInt(viewButton.dataset.viewCount || '0', 10),
downloadCount: Number.parseInt(viewButton.dataset.downloadCount || '0', 10)
});
});
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadNextPage();
}
}, { rootMargin: '420px 0px' });
const viewObserver = 'IntersectionObserver' in window
? new IntersectionObserver(handleViewIntersections, { threshold: [VIEW_THRESHOLD] })
: null;
connectLiveCounters();
updateScrollIndicator();
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
window.addEventListener('resize', updateScrollIndicator);
refreshStatus();
window.setInterval(refreshStatus, 5000);
await openSharedContentFromUrl();
await loadMemes(1, { reset: true });
observer.observe(feedLoader);
async function loadNextPage() {
if (isLoading || currentPage >= totalPages) return;
await loadMemes(currentPage + 1);
}
async function loadMemes(page, options = {}) {
if (isLoading) return;
isLoading = true;
const loaderStartedAt = performance.now();
setLoader('FETCHING_NEXT_BLOCK...', true);
try {
const response = await fetch(`/api/memes?page=${page}&pageSize=${PAGE_SIZE}`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Feed error.');
currentPage = payload.page;
totalPages = payload.totalPages;
renderMemes(payload.memes, { append: !options.reset });
if (page > 1) {
const remainingLoaderTime = NEXT_PAGE_LOADER_MIN_MS - (performance.now() - loaderStartedAt);
if (remainingLoaderTime > 0) await delay(remainingLoaderTime);
}
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
updateScrollIndicator();
} catch {
if (options.reset) grid.innerHTML = `<div class="empty-state">FEED_ERROR</div>`;
setLoader('FEED_ERROR', false);
} finally {
grid.ariaBusy = 'false';
isLoading = false;
}
}
function delay(milliseconds) {
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
}
function renderMemes(memes, options = {}) {
if (!options.append && memes.length === 0) {
unobserveViewedImages();
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
return;
}
const cards = memes.map((meme, index) => {
const article = document.createElement('article');
article.className = 'meme-card';
article.dataset.memeId = meme.id;
article.innerHTML = `
<div class="card-head">
<div class="card-id">
<span class="node active node-score-${scoreBucket(meme.moderationScore)}" tabindex="0" data-tooltip="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" title="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" aria-label="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} out of 100"></span>
<span class="card-meta">ID: ${shortId(meme.id)}</span>
</div>
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
</div>
<div class="image-frame">
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy" data-view-observe-id="${meme.id}">
</div>
<div class="card-actions">
<div class="stats">
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>${formatCount(meme.viewCount)}</span></span>
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="download"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>${formatCount(meme.downloadCount)}</span></span>
</div>
<div class="action-group">
<button class="text-action icon-only-action" type="button" data-copy-image-url="${meme.url}" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<a class="text-action icon-only-action" href="${meme.downloadUrl}" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
<button class="text-action icon-only-action" type="button" data-share-id="${meme.id}" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
<button class="text-action icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" data-view-count="${meme.viewCount}" data-download-count="${meme.downloadCount}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
</div>
</div>
`;
if (options.append && index === 0) article.tabIndex = -1;
return article;
});
if (options.append) {
grid.append(...cards);
} else {
unobserveViewedImages();
grid.replaceChildren(...cards);
}
observeViewedImages(cards);
}
async function openSharedContentFromUrl() {
const requestedDialog = new URLSearchParams(window.location.search).get('open');
if (window.location.pathname === '/' && requestedDialog === 'pet') {
petModal.showModal();
window.history.replaceState({}, '', '/');
return;
}
if (window.location.pathname === '/lore' || window.location.pathname === '/lore/') {
await openLoreDialog();
return;
}
const match = window.location.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
if (!match) return;
try {
const response = await fetch(`/api/memes/${match[1]}`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Meme not found.');
await openLightbox({
...payload.meme,
alt: `Shared meme ${shortId(payload.meme.id)}`
});
} catch {
window.location.replace('/');
}
}
async function openLightbox({ id, url, downloadUrl, alt, viewCount = 0, downloadCount = 0 }) {
lightboxImage.src = url;
lightboxImage.alt = alt;
lightboxCopy.dataset.imageUrl = url;
lightboxDownload.href = downloadUrl;
lightboxShare.dataset.shareId = id;
lightboxViewCount.dataset.counterId = id;
lightboxViewCount.dataset.counterKind = 'view';
lightboxViewCount.querySelector('[data-count]').textContent = formatCount(viewCount);
lightboxDownloadCount.dataset.counterId = id;
lightboxDownloadCount.dataset.counterKind = 'download';
lightboxDownloadCount.querySelector('[data-count]').textContent = formatCount(downloadCount);
lightboxId.textContent = `ID: 0x${id.toUpperCase()}`;
setActionStatus('', lightboxCopy);
if (!lightbox.open) lightbox.showModal();
await recordViewOnce(id);
}
async function openMemeShareDialog(id, triggerButton = null) {
if (!isMemeId(id)) return;
return openShareDialog({
path: `/meme/${id}`,
title: 'SHARE_MEME',
label: 'MEME_LINK',
triggerButton
});
}
async function openShareDialog({ path, title, label, triggerButton = null }) {
activeShareTrigger = triggerButton;
shareTitle.textContent = title;
shareLinkLabel.textContent = label;
shareLink.value = new URL(path, window.location.origin).href;
window.clearTimeout(shareStatusTimer);
shareStatusTimer = null;
shareStatus.textContent = '';
resetCopiedFeedback(copyShareLinkButton);
if (!shareModal.open) shareModal.showModal();
}
async function copyActiveShareLink() {
if (!shareLink.value) return;
copyShareLinkButton.disabled = true;
try {
await copyText(shareLink.value);
shareStatus.textContent = 'LINK_COPIED_TO_CLIPBOARD';
showCopiedFeedback(copyShareLinkButton, 'Share link copied');
if (activeShareTrigger) showCopiedFeedback(activeShareTrigger, 'Share link copied');
window.clearTimeout(shareStatusTimer);
shareStatusTimer = window.setTimeout(() => {
shareStatus.textContent = '';
shareStatusTimer = null;
}, 2600);
} catch {
shareLink.focus();
shareLink.select();
shareStatus.textContent = 'COPY_UNAVAILABLE — SELECT_AND_COPY_THE_LINK';
} finally {
copyShareLinkButton.disabled = false;
}
}
async function openLoreDialog() {
setActionStatus('', loreCopy);
if (!loreModal.open) loreModal.showModal();
if (loreContent) return;
try {
const response = await fetch('/assets/meme-protocol-lore.txt');
if (!response.ok) throw new Error('Lore unavailable.');
loreContent = await response.text();
loreText.textContent = loreContent;
} catch {
loreText.textContent = 'LORE_TRANSMISSION_UNAVAILABLE';
setActionStatus('LORE_TRANSMISSION_UNAVAILABLE', loreCopy);
}
}
async function copyLore() {
if (!loreContent) await openLoreDialog();
if (!loreContent) return;
loreCopy.disabled = true;
try {
await copyText(loreContent);
showCopiedFeedback(loreCopy, 'Lore copied');
setActionStatus('LORE_COPIED_TO_CLIPBOARD', loreCopy);
} catch {
setActionStatus('LORE_COPY_UNAVAILABLE — USE_DOWNLOAD', loreCopy);
} finally {
loreCopy.disabled = false;
}
}
async function copyImage(url, button) {
if (!url || !button) return;
button.disabled = true;
setActionStatus('', button);
try {
if (!navigator.clipboard?.write || typeof ClipboardItem === 'undefined') {
throw new Error('Image clipboard unavailable.');
}
const png = fetch(url)
.then((response) => {
if (!response.ok) throw new Error('Image fetch failed.');
return response.blob();
})
.then(convertImageBlobToPng);
await navigator.clipboard.write([new ClipboardItem({ 'image/png': png })]);
showCopiedFeedback(button, 'Meme image copied');
setActionStatus('MEME_IMAGE_COPIED_TO_CLIPBOARD', button);
} catch {
setActionStatus('IMAGE_COPY_UNAVAILABLE — USE_DOWNLOAD', button);
} finally {
button.disabled = false;
}
}
async function convertImageBlobToPng(blob) {
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) throw new Error('Canvas unavailable.');
let image;
let cleanup = () => {};
if ('createImageBitmap' in window) {
image = await createImageBitmap(blob);
cleanup = () => image.close();
} else {
const objectUrl = URL.createObjectURL(blob);
image = await loadImage(objectUrl);
cleanup = () => URL.revokeObjectURL(objectUrl);
}
try {
canvas.width = image.width;
canvas.height = image.height;
context.drawImage(image, 0, 0);
return await new Promise((resolve, reject) => {
canvas.toBlob((png) => {
if (png) resolve(png);
else reject(new Error('PNG conversion failed.'));
}, 'image/png');
});
} finally {
cleanup();
}
}
function loadImage(url) {
return new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Image decode failed.'));
image.src = url;
});
}
function showCopiedFeedback(button, copiedLabel = 'Copied') {
const icon = button.querySelector('.material-symbols-outlined');
const label = button.querySelector('[data-copy-label]');
if (!icon) return;
button.dataset.defaultIcon ||= icon.textContent.trim();
if (label) button.dataset.defaultLabel ||= label.textContent.trim();
button.dataset.defaultAriaLabel ||= button.getAttribute('aria-label') || '';
button.dataset.defaultTitle ||= button.getAttribute('title') || '';
icon.textContent = 'check';
if (label) label.textContent = 'COPIED';
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', copiedLabel);
if (button.dataset.defaultTitle) button.setAttribute('title', copiedLabel);
button.classList.add('is-copied');
window.clearTimeout(copiedFeedbackTimers.get(button));
copiedFeedbackTimers.set(button, window.setTimeout(() => {
resetCopiedFeedback(button);
}, 2600));
}
function resetCopiedFeedback(button) {
const icon = button.querySelector('.material-symbols-outlined');
const label = button.querySelector('[data-copy-label]');
window.clearTimeout(copiedFeedbackTimers.get(button));
copiedFeedbackTimers.delete(button);
if (icon && button.dataset.defaultIcon) icon.textContent = button.dataset.defaultIcon;
if (label && button.dataset.defaultLabel) label.textContent = button.dataset.defaultLabel;
if (button.dataset.defaultAriaLabel) button.setAttribute('aria-label', button.dataset.defaultAriaLabel);
if (button.dataset.defaultTitle) button.setAttribute('title', button.dataset.defaultTitle);
button.classList.remove('is-copied');
}
async function copyText(value) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(value);
return;
}
const fallback = document.createElement('textarea');
fallback.value = value;
fallback.setAttribute('readonly', '');
fallback.className = 'clipboard-fallback';
document.body.append(fallback);
fallback.select();
const copied = document.execCommand('copy');
fallback.remove();
if (!copied) throw new Error('Clipboard unavailable.');
}
function setActionStatus(message, triggerButton = null) {
window.clearTimeout(actionStatusTimer);
for (const status of document.querySelectorAll('[data-action-status], #action-status')) {
status.textContent = '';
}
const target = triggerButton?.closest('dialog')?.querySelector('[data-action-status]') || actionStatus;
target.textContent = message;
if (!message) return;
actionStatusTimer = window.setTimeout(() => {
target.textContent = '';
actionStatusTimer = null;
}, 3200);
}
async function validateClientFile(file) {
if (!file) return 'SELECT A FILE.';
if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.';
if (file.size > MAX_FILE_BYTES) return 'IMAGE EXCEEDS 5MB.';
try {
const dimensions = await readImageDimensions(file);
if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EDGE EXCEEDS 6000PX.';
if (dimensions.width * dimensions.height > MAX_IMAGE_PIXELS) return 'IMAGE EXCEEDS 20MP.';
} catch {
return 'IMAGE COULD NOT BE INSPECTED.';
}
return '';
}
function readImageDimensions(file) {
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(file);
image.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: image.naturalWidth, height: image.naturalHeight });
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('invalid image'));
};
image.src = url;
});
}
function closeUpload() {
uploadForm.reset();
fileLabel.textContent = 'SELECT PNG / JPEG';
formStatus.textContent = '';
uploadModal.close();
}
function handleViewIntersections(entries) {
for (const entry of entries) {
const id = entry.target.dataset.viewObserveId;
if (!id || wasRecentlyViewed(id)) {
stopViewTimer(id);
if (wasRecentlyViewed(id)) viewObserver.unobserve(entry.target);
continue;
}
if (entry.isIntersecting && entry.intersectionRatio >= VIEW_THRESHOLD) {
startViewTimer(id, entry.target);
} else {
stopViewTimer(id);
}
}
}
function observeViewedImages(cards) {
if (!viewObserver) return;
for (const card of cards) {
const image = card.querySelector('[data-view-observe-id]');
if (image && !wasRecentlyViewed(image.dataset.viewObserveId)) viewObserver.observe(image);
}
}
function unobserveViewedImages() {
for (const timer of pendingViewTimers.values()) window.clearTimeout(timer);
pendingViewTimers.clear();
if (!viewObserver) return;
for (const image of grid.querySelectorAll('[data-view-observe-id]')) {
viewObserver.unobserve(image);
}
}
function startViewTimer(id, target) {
if (pendingViewTimers.has(id)) return;
const timer = window.setTimeout(async () => {
pendingViewTimers.delete(id);
if (wasRecentlyViewed(id)) {
viewObserver.unobserve(target);
return;
}
await recordViewOnce(id);
viewObserver.unobserve(target);
}, VIEW_DWELL_MS);
pendingViewTimers.set(id, timer);
}
function stopViewTimer(id) {
const timer = pendingViewTimers.get(id);
if (!timer) return;
window.clearTimeout(timer);
pendingViewTimers.delete(id);
}
async function recordViewOnce(id) {
if (!isMemeId(id)) return;
if (wasRecentlyViewed(id)) return;
rememberViewed(id);
try {
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'View update failed.');
updateCounters(payload.meme);
} catch {
forgetViewed(id);
// Live counter updates are best-effort; the stream and lightbox should still work.
}
}
function isMemeId(id) {
return typeof id === 'string' && /^[a-f0-9]{64}$/.test(id);
}
function wasRecentlyViewed(id) {
if (viewedThisSession.has(id)) return true;
try {
const viewedAt = Number.parseInt(localStorage.getItem(`${VIEW_DEDUPE_PREFIX}${id}`) || '0', 10);
if (Number.isFinite(viewedAt) && Date.now() - viewedAt < VIEW_DEDUPE_MS) return true;
} catch {
// Storage can be unavailable in hardened browser modes; session memory still dedupes.
}
return false;
}
function rememberViewed(id) {
viewedThisSession.add(id);
try {
localStorage.setItem(`${VIEW_DEDUPE_PREFIX}${id}`, String(Date.now()));
} catch {
// localStorage is optional; no upload or view flow depends on it.
}
}
function forgetViewed(id) {
viewedThisSession.delete(id);
try {
localStorage.removeItem(`${VIEW_DEDUPE_PREFIX}${id}`);
} catch {
// Optional storage cleanup.
}
}
function connectLiveCounters() {
if (!('EventSource' in window)) return;
const source = new EventSource('/api/events');
source.addEventListener('metric', (event) => {
updateCounters(JSON.parse(event.data));
});
}
function updateCounters(meme) {
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
const count = element.querySelector('[data-count]');
if (!count) continue;
if (element.dataset.counterKind === 'view') {
count.textContent = formatCount(meme.viewCount);
}
if (element.dataset.counterKind === 'download') {
count.textContent = formatCount(meme.downloadCount);
}
}
for (const button of document.querySelectorAll(`[data-view-id="${meme.id}"]`)) {
button.dataset.viewCount = String(meme.viewCount);
button.dataset.downloadCount = String(meme.downloadCount);
}
}
function setLoader(label, spinning) {
feedLoaderLabel.textContent = label;
feedLoader.classList.toggle('is-spinning', spinning);
}
function shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
}
function relativeAge(value) {
const elapsed = Math.max(1, Date.now() - new Date(value).getTime());
const minutes = Math.floor(elapsed / 60000);
if (minutes < 60) return `${minutes}M AGO`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}H AGO`;
return `${Math.floor(hours / 24)}D AGO`;
}
function formatCount(value) {
const count = Number.isFinite(value) ? value : 0;
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
function scoreBucket(value) {
const score = Number.isFinite(value) ? value : 50;
if (score >= 90) return 5;
if (score >= 75) return 4;
if (score >= 60) return 3;
if (score >= 40) return 2;
if (score >= 20) return 1;
return 0;
}
async function refreshStatus() {
const started = performance.now();
try {
const response = await fetch('/api/status', { cache: 'no-store' });
const status = await response.json();
if (!response.ok) throw new Error('status failed');
setStat('status', `${status.ok ? 'ONLINE' : 'DEGRADED'} ${formatUptime(status.uptimeSeconds)}`);
setStat('latency', `${Math.max(1, Math.round(performance.now() - started))}MS`);
setStat('nodes', formatCount(status.liveClients));
setStat('memes', formatCount(status.memeCount));
} catch {
setStat('status', 'OFFLINE');
setStat('latency', '--MS');
setStat('nodes', '--');
setStat('memes', '--');
}
}
function setStat(name, value) {
for (const element of document.querySelectorAll(`[data-stat="${name}"]`)) {
element.textContent = value;
}
}
function formatUptime(value) {
const seconds = Math.max(0, Number.isFinite(value) ? value : 0);
const minutes = Math.floor(seconds / 60);
if (minutes < 1) return `UP ${seconds}S`;
if (minutes < 60) return `UP ${minutes}M`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
if (hours < 24) return `UP ${hours}H${remainingMinutes > 0 ? ` ${remainingMinutes}M` : ''}`;
const days = Math.floor(hours / 24);
const remainingHours = hours % 24;
return `UP ${days}D${remainingHours > 0 ? ` ${remainingHours}H` : ''}`;
}
function updateScrollIndicator() {
const segments = [...scrollIndicator.querySelectorAll('span')];
const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
const progress = Math.min(1, Math.max(0, window.scrollY / maxScroll));
const activeIndex = Math.min(segments.length - 1, Math.round(progress * (segments.length - 1)));
segments.forEach((segment, index) => {
segment.classList.toggle('active', index === activeIndex);
});
}
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 4.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

+9
View File
@@ -0,0 +1,9 @@
THE MEME PROTOCOL // LORE PLACEHOLDER
The stream remembers what the timeline forgets.
Every meme enters as a signal, sheds its metadata, and waits for consensus.
The worthy survive as immutable blocks of shared context. The rest dissolve
back into the noise.
This is placeholder lore. A future transmission will replace it.
Binary file not shown.

After

Width:  |  Height:  |  Size: 226 KiB

File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

+204
View File
@@ -0,0 +1,204 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>__SEO_TITLE__</title>
<meta name="description" content="__SEO_DESCRIPTION__">
<meta name="robots" content="index,follow,max-image-preview:large">
<meta name="application-name" content="__SEO_TITLE__">
<meta name="theme-color" content="#00ff41">
<link rel="canonical" href="__SEO_CANONICAL__">
<link rel="manifest" href="/site.webmanifest?v=20260814">
<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__">
<meta property="og:description" content="__SEO_DESCRIPTION__">
<meta property="og:url" content="__SEO_CANONICAL__">
<meta property="og:image" content="__SEO_IMAGE__">
<meta property="og:image:type" content="__SEO_IMAGE_TYPE__">
<meta property="og:image:width" content="__SEO_IMAGE_WIDTH__">
<meta property="og:image:height" content="__SEO_IMAGE_HEIGHT__">
<meta property="og:image:alt" content="__SEO_IMAGE_ALT__">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:title" content="__SEO_TITLE__">
<meta name="twitter:description" content="__SEO_DESCRIPTION__">
<meta name="twitter:image" content="__SEO_IMAGE__">
<meta name="twitter:image:alt" content="__SEO_IMAGE_ALT__">
<link rel="icon" type="image/svg+xml" href="/assets/favicon/favicon.svg?v=20260814">
<link rel="icon" type="image/png" sizes="96x96" href="/assets/favicon/favicon-96x96.png?v=20260814">
<link rel="shortcut icon" href="/favicon.ico?v=20260814">
<link rel="apple-touch-icon" sizes="180x180" href="/assets/favicon/apple-touch-icon.png?v=20260814">
<meta name="apple-mobile-web-app-title" content="Meme Protocol">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/styles.css">
<script type="application/ld+json" nonce="__CSP_NONCE__">__SEO_JSON_LD__</script>
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL</div>
<button class="primary-action" id="open-upload" type="button">ADD MEME</button>
</header>
<main class="shell">
<div class="status-line" aria-live="polite">
<nav class="protocol-menu" aria-label="Protocol menu">
<button id="open-pet" type="button">DOWNLOAD PET</button>
<button id="open-lore" type="button">READ LORE</button>
</nav>
<span class="live"><span class="pulse"></span>LIVE_FEED</span>
</div>
<aside class="terminal-strip" aria-live="polite">
<span>STATUS: <strong data-stat="status">SYNCING</strong></span>
<span>LATENCY: <strong data-stat="latency">--MS</strong></span>
<span>NODES: <strong data-stat="nodes">--</strong></span>
<span>MEMES: <strong data-stat="memes">--</strong></span>
<span class="active">MEMETICALLY_ACTIVE</span>
</aside>
<section class="meme-grid" id="meme-grid" aria-label="Meme feed"></section>
<div class="feed-loader" id="feed-loader" aria-live="polite">
<div class="loader-ring" aria-hidden="true"></div>
<span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span>
</div>
</main>
<footer class="repo-footer">
<a href="https://git.yoonect.com/Nautilus/bitsforfree" rel="noopener noreferrer" target="_blank">
<img src="/assets/yoonect-logo.png" alt="" aria-hidden="true">
<span>SOURCE</span>
</a>
</footer>
<aside class="side-terminal" aria-live="polite">
<div>STATUS: <span data-stat="status">SYNCING</span></div>
<div>LATENCY: <span data-stat="latency">--MS</span></div>
<div>NODES: <span data-stat="nodes">--</span></div>
<div>MEMES: <span data-stat="memes">--</span></div>
<div class="active">MEMETICALLY_ACTIVE</div>
</aside>
<div class="scroll-indicator" id="scroll-indicator" aria-hidden="true">
<span class="active"></span><span></span><span></span><span></span>
</div>
<dialog class="modal" id="upload-modal" aria-labelledby="upload-title">
<form class="modal-panel" id="upload-form" method="dialog">
<div class="modal-header">
<h2 id="upload-title">ADD_MEME</h2>
<button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button>
</div>
<label class="file-drop" for="meme-input">
<span id="file-label">SELECT PNG / JPEG</span>
<input id="meme-input" name="meme" type="file" accept="image/png,image/jpeg" required>
</label>
<div class="upload-rules">
<span>MAX_SIZE: 5MB</span>
<span>RATIO: ANY</span>
<span>OUTPUT_FORMAT: WEBP</span>
<span>MAX_INPUT_EDGE: 6000PX</span>
<span>MAX_PIXELS: 20MP</span>
<span>OUTPUT_MAX_EDGE: 1600PX</span>
</div>
<div class="modal-actions">
<button class="secondary-action" id="cancel-upload" type="button">CANCEL</button>
<button class="primary-action" id="add-meme-button" type="submit">ADD MEME</button>
</div>
<p class="form-status" id="form-status" role="status"></p>
</form>
</dialog>
<dialog class="lightbox" id="lightbox" aria-label="Meme viewer">
<div class="lightbox-toolbar">
<span id="lightbox-id"></span>
<button class="icon-action" id="close-lightbox" type="button" aria-label="Close">X</button>
</div>
<img id="lightbox-image" alt="">
<div class="lightbox-actions">
<div class="stats">
<span class="card-stat" id="lightbox-view-count"><span class="material-symbols-outlined" aria-hidden="true">visibility</span><span data-count>0</span></span>
<span class="card-stat" id="lightbox-download-count"><span class="material-symbols-outlined" aria-hidden="true">download</span><span data-count>0</span></span>
</div>
<div class="action-group">
<button class="text-action icon-only-action" id="lightbox-copy" type="button" aria-label="Copy meme image" title="Copy meme image"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<a class="text-action icon-only-action" id="lightbox-download" href="#" aria-label="Download meme" title="Download meme"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
<button class="text-action icon-only-action" id="lightbox-share" type="button" aria-label="Share meme" title="Share meme"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
</div>
</div>
<p class="viewer-status" data-action-status role="status" aria-live="polite"></p>
</dialog>
<dialog class="modal" id="pet-modal" aria-labelledby="pet-title">
<div class="modal-panel pet-panel">
<div class="modal-header">
<h2 id="pet-title">DOWNLOAD_PET</h2>
<button class="icon-action" id="close-pet" type="button" aria-label="Close">X</button>
</div>
<div class="pet-downloads">
<p class="pet-prompt">SELECT_PET_BUILD</p>
<a class="pet-download" href="/assets/downloads/homo-reseticus.zip" download="homo-reseticus.zip">
<span>
<strong>HOMO_RESETICUS</strong>
<small>ORIGINAL // ZIP // 805 KB</small>
</span>
<span class="material-symbols-outlined" aria-hidden="true">download</span>
</a>
<a class="pet-download" href="/assets/downloads/homo-reseticus-v2.zip" download="homo-reseticus-v2.zip">
<span>
<strong>HOMO_RESETICUS_V2</strong>
<small>VERSION_2 // ZIP // 731 KB</small>
</span>
<span class="material-symbols-outlined" aria-hidden="true">download</span>
</a>
<p class="pet-footnote">This pet can also be downloaded from <a href="https://petdex.dev/u/homo-reseticus" rel="noopener noreferrer" target="_blank">petdex.dev/u/homo-reseticus</a>.</p>
</div>
</div>
</dialog>
<dialog class="lightbox lorebox" id="lore-modal" aria-labelledby="lore-title">
<div class="lightbox-toolbar">
<span id="lore-title">MEME_PROTOCOL_LORE.TXT</span>
<button class="icon-action" id="close-lore" type="button" aria-label="Close">X</button>
</div>
<pre class="lore-text" id="lore-text">FETCHING_LORE...</pre>
<div class="lightbox-actions lore-actions">
<div class="action-group">
<button class="text-action icon-only-action" id="lore-copy" type="button" aria-label="Copy lore" title="Copy lore"><span class="material-symbols-outlined" aria-hidden="true">content_copy</span></button>
<a class="text-action icon-only-action" href="/assets/meme-protocol-lore.txt" download="meme-protocol-lore.txt" aria-label="Download lore" title="Download lore"><span class="material-symbols-outlined" aria-hidden="true">download</span></a>
<button class="text-action icon-only-action" id="lore-share" type="button" aria-label="Share lore" title="Share lore"><span class="material-symbols-outlined" aria-hidden="true">share</span></button>
</div>
</div>
<p class="viewer-status" data-action-status role="status" aria-live="polite"></p>
</dialog>
<dialog class="modal" id="share-modal" aria-labelledby="share-title">
<div class="modal-panel share-panel">
<div class="modal-header">
<h2 id="share-title">SHARE_MEME</h2>
<button class="icon-action" id="close-share" type="button" aria-label="Close">X</button>
</div>
<div class="share-body">
<label for="share-link" id="share-link-label">MEME_LINK</label>
<div class="share-link-row">
<input id="share-link" type="text" readonly spellcheck="false" aria-describedby="share-status">
<button class="primary-action share-copy" id="copy-share-link" type="button">
<span class="material-symbols-outlined" data-copy-icon aria-hidden="true">content_copy</span>
<span data-copy-label>COPY</span>
</button>
</div>
<p class="share-status" id="share-status" role="status" aria-live="polite"></p>
</div>
</div>
</dialog>
<p class="action-toast" id="action-status" role="status" aria-live="polite"></p>
</body>
</html>
+536
View File
@@ -0,0 +1,536 @@
import http from 'node:http';
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import { URL } from 'node:url';
import { parseMultipartUpload } from './src/multipart.js';
import { sendFile, sendHtml, sendJson, sendText, withSecurityHeaders } from './src/http.js';
import { createStore } from './src/store.js';
import { validateImage } from './src/image.js';
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, renderNotFound, 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 IMMUTABLE_CACHE_CONTROL = 'public, max-age=31536000, immutable';
const REVALIDATING_CACHE_CONTROL = 'public, max-age=300, must-revalidate';
const FINGERPRINTED_ASSET = /\.[a-f0-9]{8,}\.[a-z0-9]+$/i;
const events = new Set();
const DISCOVERY_ROUTES = new Set([
'/openapi.json',
'/meme-api.skill.md',
'/robots.txt',
'/llms.txt',
'/site.webmanifest',
'/feed.json',
'/sitemap.xml'
]);
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
const indexTemplate = await fs.readFile('./public/index.html', 'utf8');
const notFoundTemplate = await fs.readFile('./public/404.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);
}
if (!process.env.ADMIN_TOKEN) {
console.log(`Admin review URL: /admin/${ADMIN_TOKEN}`);
}
const server = http.createServer(async (req, res) => {
try {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
withSecurityHeaders(res, { contentSecurityPolicy: !isDiscoveryRoute(req, url) });
const baseUrl = publicBaseUrl(req);
const isPublicRead = isReadRequest(req);
if (isPublicRead && url.pathname === '/') {
const nonce = crypto.randomBytes(16).toString('base64');
withSecurityHeaders(res, { scriptNonce: nonce });
return sendHtml(res, 200, renderIndex({
template: indexTemplate,
baseUrl,
nonce,
approvedCount: store.count('approved')
}));
}
if (isPublicRead && (url.pathname === '/lore' || url.pathname === '/lore/')) {
const nonce = crypto.randomBytes(16).toString('base64');
withSecurityHeaders(res, { scriptNonce: nonce });
return sendHtml(res, 200, renderIndex({
template: indexTemplate,
baseUrl,
nonce,
approvedCount: store.count('approved'),
lore: true
}));
}
const memePageMatch = url.pathname.match(/^\/meme\/([a-f0-9]{64})\/?$/);
if (isPublicRead && memePageMatch) {
const meme = store.get(memePageMatch[1]);
if (!meme || meme.status !== 'approved') {
return sendNotFoundPage(res, { baseUrl, pathname: url.pathname });
}
const nonce = crypto.randomBytes(16).toString('base64');
withSecurityHeaders(res, { scriptNonce: nonce });
return sendHtml(res, 200, renderIndex({
template: indexTemplate,
baseUrl,
nonce,
approvedCount: store.count('approved'),
meme: publicMeme(meme)
}));
}
if (isPublicRead && url.pathname === '/openapi.json') {
res.setHeader('Cache-Control', 'public, max-age=300');
return sendJson(res, 200, openApiSpecFor(baseUrl));
}
if (isPublicRead && 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 (isPublicRead && url.pathname === '/robots.txt') {
return sendText(res, 200, robotsTxt(baseUrl));
}
if (isPublicRead && url.pathname === '/llms.txt') {
return sendText(res, 200, llmsTxt(baseUrl));
}
if (isPublicRead && url.pathname === '/site.webmanifest') {
return sendJson(res, 200, manifest(baseUrl));
}
if (isPublicRead && url.pathname === '/favicon.ico') {
return sendFile(res, './public/assets/favicon/favicon.ico');
}
if (isPublicRead && url.pathname === '/feed.json') {
return sendJson(res, 200, feedJson(baseUrl, store.listForReview({ status: 'approved' }).memes));
}
if (isPublicRead && url.pathname === '/sitemap.xml') {
const body = sitemapXml(baseUrl, store.listForReview({ status: 'approved' }).memes);
res.writeHead(200, {
'Content-Type': 'application/xml; charset=utf-8',
'Content-Length': String(Buffer.byteLength(body))
});
return res.end(body);
}
const adminPageMatch = url.pathname.match(/^\/admin\/([A-Za-z0-9_-]{24,128})$/);
if (req.method === 'GET' && adminPageMatch) {
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
noIndex(res);
return sendFile(res, './public/admin.html');
}
if (isPublicRead && url.pathname.startsWith('/assets/')) {
const isVersioned = url.searchParams.has('v') || FINGERPRINTED_ASSET.test(url.pathname);
res.setHeader('Cache-Control', isVersioned ? IMMUTABLE_CACHE_CONTROL : REVALIDATING_CACHE_CONTROL);
return sendFile(res, `./public${url.pathname}`);
}
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 }),
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'),
liveClients: events.size,
uptimeSeconds: Math.floor(process.uptime())
});
}
if (req.method === 'GET' && url.pathname === '/api/admin/pending') {
noIndex(res);
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
return sendJson(res, 200, store.listForReview({ status: 'pending' }));
}
if (req.method === 'POST' && url.pathname === '/api/admin/approve') {
noIndex(res);
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
const body = await readJsonBody(req, 64 * 1024);
const approved = await store.approve(safeIds(body.ids));
return sendJson(res, 200, { approved });
}
if (req.method === 'POST' && url.pathname === '/api/admin/delete') {
noIndex(res);
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
const body = await readJsonBody(req, 64 * 1024);
const deleted = await store.delete(safeIds(body.ids));
return sendJson(res, 200, { deleted });
}
if (req.method === 'GET' && url.pathname === '/api/events') {
noIndex(res);
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no'
});
res.write('retry: 5000\n');
res.write('event: ready\ndata: {}\n\n');
events.add(res);
const heartbeat = setInterval(() => {
res.write(': keep-alive\n\n');
}, SSE_HEARTBEAT_MS);
req.on('close', () => {
clearInterval(heartbeat);
events.delete(res);
});
return;
}
if (req.method === 'POST' && url.pathname === '/api/memes') {
noIndex(res);
const { meme, quota } = await addMeme(req);
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
meme,
quota,
moderationScore: meme.moderationScore,
moderationReason: meme.moderationReason,
message: meme.status === 'approved' ? 'Upload approved.' : 'Upload queued for admin review.'
});
}
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');
const meme = await store.incrementMetric(viewMatch[1], 'viewCount');
if (!meme) return sendText(res, 404, 'Not found');
broadcastMetric(meme);
return sendJson(res, 200, { meme });
}
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
if (isPublicRead && 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);
res.setHeader('Content-Length', String(meme.byteSize));
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('X-Content-Type-Options', 'nosniff');
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
}
const adminMediaMatch = url.pathname.match(/^\/admin-media\/([A-Za-z0-9_-]{24,128})\/([a-f0-9]{64})$/);
if (req.method === 'GET' && adminMediaMatch) {
noIndex(res);
if (!isAdminToken(adminMediaMatch[1])) return sendText(res, 404, 'Not found');
const meme = store.get(adminMediaMatch[2]);
if (!meme) return sendText(res, 404, 'Not found');
res.setHeader('Content-Type', meme.mime);
res.setHeader('Content-Length', String(meme.byteSize));
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
}
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]);
if (!meme) return sendText(res, 404, 'Not found');
broadcastMetric(updated);
res.setHeader('Content-Type', meme.mime);
res.setHeader('Content-Disposition', `attachment; filename="${downloadName(meme)}"`);
res.setHeader('X-Content-Type-Options', 'nosniff');
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
}
if (isPublicRead && url.pathname === '/healthz') {
noIndex(res);
return sendJson(res, 200, { ok: true });
}
if (isBrowserPageRequest(req, url)) {
return sendNotFoundPage(res, { baseUrl, pathname: url.pathname });
}
return sendText(res, 404, 'Not found');
} 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 });
}
});
server.listen(PORT, HOST, () => {
console.log(`The Meme Protocol listening on http://${HOST}:${PORT}`);
});
function positiveInt(value, fallback) {
const parsed = Number.parseInt(value || '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function isDiscoveryRoute(req, url) {
if (!isReadRequest(req)) return false;
return DISCOVERY_ROUTES.has(url.pathname);
}
function isBrowserPageRequest(req, url) {
if (!isReadRequest(req)) return false;
return ![
'/api',
'/assets',
'/media',
'/download',
'/admin',
'/admin-media'
].some((prefix) => url.pathname === prefix || url.pathname.startsWith(`${prefix}/`));
}
function isReadRequest(req) {
return req.method === 'GET' || req.method === 'HEAD';
}
function sendNotFoundPage(res, { baseUrl, pathname }) {
noIndex(res);
return sendHtml(res, 404, renderNotFound({
template: notFoundTemplate,
baseUrl,
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}`;
}
function broadcastMetric(meme) {
const payload = JSON.stringify({
id: meme.id,
viewCount: meme.viewCount,
downloadCount: meme.downloadCount
});
for (const res of events) {
res.write(`event: metric\ndata: ${payload}\n\n`);
}
}
async function addMeme(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,
moderationReason: moderation.reason
};
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'] || '');
}
function isAdminToken(value) {
const received = Buffer.from(String(value));
const expected = Buffer.from(ADMIN_TOKEN);
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
async function readJsonBody(req, maxBytes) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > maxBytes) {
const error = new Error('Request body is too large.');
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
if (total === 0) return {};
return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
}
function safeIds(ids) {
if (!Array.isArray(ids)) return [];
return ids.filter((id) => typeof id === 'string' && /^[a-f0-9]{64}$/.test(id));
}
function clientIp(req) {
if (process.env.TRUST_PROXY === 'true') {
const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
if (forwarded) return forwarded;
}
return req.socket.remoteAddress || 'unknown';
}
+6
View File
@@ -0,0 +1,6 @@
export class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
+89
View File
@@ -0,0 +1,89 @@
import fs from 'node:fs';
import path from 'node:path';
const PUBLIC_ROOT = path.resolve('./public');
const TYPES = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.txt', 'text/plain; charset=utf-8'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.svg', 'image/svg+xml'],
['.zip', 'application/zip'],
['.ico', 'image/x-icon']
]);
export function withSecurityHeaders(res, options = {}) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
if (options.contentSecurityPolicy === false) return;
const scriptSrc = options.scriptNonce ? `script-src 'self' 'nonce-${options.scriptNonce}'; ` : '';
res.setHeader(
'Content-Security-Policy',
`default-src 'self'; ${scriptSrc}style-src 'self' https://fonts.googleapis.com; font-src https://fonts.gstatic.com; img-src 'self' blob:; connect-src 'self'; object-src 'none'; base-uri 'none'; frame-ancestors 'none'; form-action 'self'`
);
}
export function sendJson(res, statusCode, payload) {
const body = Buffer.from(JSON.stringify(payload));
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': String(body.length)
});
endBody(res, body);
}
export function sendText(res, statusCode, message, contentType = 'text/plain; charset=utf-8') {
const body = Buffer.from(message);
res.writeHead(statusCode, {
'Content-Type': contentType,
'Content-Length': String(body.length)
});
endBody(res, body);
}
export function sendHtml(res, statusCode, html) {
const body = Buffer.from(html);
res.writeHead(statusCode, {
'Content-Type': 'text/html; charset=utf-8',
'Content-Length': String(body.length)
});
endBody(res, body);
}
export function sendFile(res, filePath, options = {}) {
const resolved = options.absolute ? path.resolve(filePath) : path.resolve(filePath);
if (!options.absolute && !resolved.startsWith(PUBLIC_ROOT + path.sep) && resolved !== path.join(PUBLIC_ROOT, 'index.html')) {
return sendText(res, 403, 'Forbidden');
}
fs.stat(resolved, (statError, stats) => {
if (statError || !stats.isFile()) {
return sendText(res, 404, 'Not found');
}
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', TYPES.get(path.extname(resolved).toLowerCase()) || 'application/octet-stream');
}
if (!res.getHeader('Content-Length')) {
res.setHeader('Content-Length', String(stats.size));
}
if (res.req?.method === 'HEAD') {
return res.end();
}
fs.createReadStream(resolved)
.on('error', () => sendText(res, 500, 'File read failed'))
.pipe(res);
});
}
function endBody(res, body) {
return res.req?.method === 'HEAD' ? res.end() : res.end(body);
}
+89
View File
@@ -0,0 +1,89 @@
import { HttpError } from './errors.js';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
export function validateImage(buffer, limits) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw new HttpError(400, 'Upload is empty.');
}
if (buffer.length > limits.maxBytes) {
throw new HttpError(413, 'Image exceeds the 5 MB limit.');
}
const image = detectImage(buffer);
if (!image) {
throw new HttpError(415, 'Only PNG and JPEG images are accepted.');
}
if (image.width < 1 || image.height < 1) {
throw new HttpError(400, 'Image dimensions are invalid.');
}
if (image.width > limits.maxWidth || image.height > limits.maxHeight) {
throw new HttpError(413, `Image dimensions must be at most ${limits.maxWidth}x${limits.maxHeight}.`);
}
if (image.width * image.height > limits.maxPixels) {
throw new HttpError(413, 'Image has too many pixels.');
}
return { ...image, byteSize: buffer.length };
}
export function detectImage(buffer) {
if (buffer.subarray(0, 8).equals(PNG_SIGNATURE) && buffer.length >= 24) {
return {
format: 'png',
ext: 'png',
mime: 'image/png',
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20)
};
}
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
const jpeg = readJpegDimensions(buffer);
if (jpeg) {
return {
format: 'jpeg',
ext: 'jpg',
mime: 'image/jpeg',
width: jpeg.width,
height: jpeg.height
};
}
}
return null;
}
function readJpegDimensions(buffer) {
let offset = 2;
while (offset < buffer.length) {
while (buffer[offset] === 0xff) offset += 1;
const marker = buffer[offset];
offset += 1;
if (marker === 0xd9 || marker === 0xda) return null;
if (offset + 2 > buffer.length) return null;
const length = buffer.readUInt16BE(offset);
if (length < 2 || offset + length > buffer.length) return null;
if (isStartOfFrame(marker)) {
if (length < 7) return null;
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5)
};
}
offset += length;
}
return null;
}
function isStartOfFrame(marker) {
return (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
);
}
+125
View File
@@ -0,0 +1,125 @@
const DEFAULT_MODEL = process.env.OPENAI_MODERATION_MODEL || 'gpt-4o-mini';
const DEFAULT_AUTO_APPROVE_MIN_SCORE = 80;
export async function moderateImage({ buffer, mime }) {
if (!process.env.OPENAI_API_KEY) {
return {
status: 'pending',
score: 50,
reason: 'AI moderation is not configured; queued for review.'
};
}
try {
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: DEFAULT_MODEL,
temperature: 0,
max_output_tokens: 220,
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: moderationPrompt()
},
{
type: 'input_image',
image_url: `data:${mime};base64,${buffer.toString('base64')}`
}
]
}
]
})
});
if (!response.ok) {
return {
status: 'pending',
score: 50,
reason: `AI moderation unavailable (${response.status}); queued for review.`
};
}
const payload = await response.json();
return normalizeDecision(parseJsonOutput(payload.output_text || extractOutputText(payload)));
} catch {
return {
status: 'pending',
score: 50,
reason: 'AI moderation failed; queued for review.'
};
}
}
function moderationPrompt() {
const autoApproveMinScore = autoApproveMinScoreValue();
return [
'You are moderating image uploads for THE_MEME_PROTOCOL, a meme gallery.',
'This is a meme site. Images may be sarcastic, roasting, edgy, political, profane, absurd, or not PG-13.',
'Do not reject merely because a meme is rude, critical, weird, darkly humorous, or adult in tone.',
'Reject only if the image appears illegal or likely illegal to host or distribute, including child sexual content, sexual content involving minors, explicit non-consensual sexual content, bestiality, credible illegal activity instructions, terrorist or extremist recruitment, doxxing/private identity documents, or explicit threats that appear actionable.',
'If legality or age is ambiguous, choose pending.',
'Quality matters. Do not auto-approve low-quality, generic, or AI-slop memes; send them to pending for admin review.',
'Low-quality memes usually have generic stock meme templates with large caption text, captions that could fit dozens of unrelated images, jokes that rely almost entirely on text, images that add little or nothing, literal descriptions instead of punchlines, excessive text that obscures the image, no clear comedic payoff, or a humorless generated feel.',
'High-quality memes usually have strong visual composition, a joke contained in or strengthened by the image, visual storytelling, an unexpected punchline, original artwork or a meaningful remix, and a caption/image pairing where the joke would lose meaning if the image changed.',
'Before deciding, ask: is there an actual punchline; could the exact same caption work on 50 unrelated images; does the image meaningfully contribute; is the text overwhelming the artwork; does it feel like a specific joke someone wanted to make; would users share it because it is clever rather than merely because it exists?',
'Assign MEME_CONSENSUS_SCORE from 0-100: higher means it is legal, clearly a meme/reaction/roast/remix, visually meaningful, has a real punchline, and is suitable for this site; lower means random photo, spam, ad, QR scam, screenshot dump, unclear, generic template caption, text-only joke, weak payoff, or AI-slop.',
'Return only compact JSON with keys: decision, score, reason.',
'decision must be one of: approved, pending, rejected.',
`Use approved only when it appears legal and score is at least ${autoApproveMinScore}.`,
'Use pending for ambiguity, uncertainty, low meme relevance, weak quality, generic/slop memes, or anything that needs human review.',
'Use rejected only for likely illegal content.'
].join('\n');
}
function parseJsonOutput(text) {
const trimmed = String(text || '').trim();
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]);
} catch {
return null;
}
}
function normalizeDecision(raw) {
if (!raw || typeof raw !== 'object') {
return { status: 'pending', score: 50, reason: 'AI response was ambiguous; queued for review.' };
}
const decision = ['approved', 'pending', 'rejected'].includes(raw.decision) ? raw.decision : 'pending';
const score = Math.max(0, Math.min(100, Number.parseInt(raw.score, 10) || 50));
const reason = typeof raw.reason === 'string' && raw.reason.trim()
? raw.reason.trim().slice(0, 300)
: 'No moderation reason supplied.';
const autoApproveMinScore = autoApproveMinScoreValue();
if (decision === 'approved' && score < autoApproveMinScore) {
return { status: 'pending', score, reason: `${reason} Low MEME_CONSENSUS_SCORE queued for review.` };
}
return { status: decision, score, reason };
}
function autoApproveMinScoreValue() {
const value = Number.parseInt(process.env.AUTO_APPROVE_MIN_SCORE || '', 10);
if (!Number.isFinite(value)) return DEFAULT_AUTO_APPROVE_MIN_SCORE;
return Math.max(0, Math.min(100, value));
}
function extractOutputText(payload) {
const parts = [];
for (const item of payload.output || []) {
for (const content of item.content || []) {
if (content.type === 'output_text' && content.text) parts.push(content.text);
}
}
return parts.join('\n');
}
+85
View File
@@ -0,0 +1,85 @@
import { HttpError } from './errors.js';
export async function parseMultipartUpload(req, options) {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/multipart\/form-data;\s*boundary=(?:"([^"]+)"|([^;]+))/i);
if (!boundaryMatch) {
throw new HttpError(415, 'Expected multipart form data.');
}
const body = await readRequestBody(req, options.maxRequestBytes);
const boundary = Buffer.from(`--${boundaryMatch[1] || boundaryMatch[2]}`);
const parts = splitMultipart(body, boundary);
let upload = null;
for (const part of parts) {
const separator = part.indexOf('\r\n\r\n');
if (separator === -1) continue;
const headerText = part.subarray(0, separator).toString('latin1');
const content = trimTrailingCrlf(part.subarray(separator + 4));
const disposition = headerText.match(/^content-disposition:\s*form-data;\s*(.+)$/im);
if (!disposition) continue;
const attrs = parseDispositionAttrs(disposition[1]);
if (attrs.name !== options.fieldName || !attrs.filename) continue;
if (upload) throw new HttpError(400, 'Upload one image at a time.');
if (content.length > options.maxFileBytes) throw new HttpError(413, 'Image exceeds the 5 MB limit.');
upload = {
filename: cleanFilename(attrs.filename),
buffer: Buffer.from(content)
};
}
if (!upload) throw new HttpError(400, 'Missing image file.');
return upload;
}
async function readRequestBody(req, maxBytes) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > maxBytes) {
throw new HttpError(413, 'Upload request is too large.');
}
chunks.push(chunk);
}
return Buffer.concat(chunks, total);
}
function splitMultipart(body, boundary) {
const parts = [];
let cursor = body.indexOf(boundary);
while (cursor !== -1) {
cursor += boundary.length;
if (body[cursor] === 0x2d && body[cursor + 1] === 0x2d) break;
if (body[cursor] === 0x0d && body[cursor + 1] === 0x0a) cursor += 2;
const next = body.indexOf(boundary, cursor);
if (next === -1) break;
parts.push(body.subarray(cursor, next));
cursor = next;
}
return parts;
}
function trimTrailingCrlf(buffer) {
if (buffer.length >= 2 && buffer[buffer.length - 2] === 0x0d && buffer[buffer.length - 1] === 0x0a) {
return buffer.subarray(0, -2);
}
return buffer;
}
function parseDispositionAttrs(value) {
const attrs = {};
for (const part of value.split(';')) {
const [rawKey, ...rawValue] = part.trim().split('=');
if (!rawKey || rawValue.length === 0) continue;
attrs[rawKey.toLowerCase()] = rawValue.join('=').trim().replace(/^"|"$/g, '');
}
return attrs;
}
function cleanFilename(filename) {
const base = filename.split(/[\\/]/).pop() || 'upload';
return base.replace(/[^\w.-]+/g, '_').slice(0, 120);
}
+47
View File
@@ -0,0 +1,47 @@
import sharp from 'sharp';
import { HttpError } from './errors.js';
const MAX_OUTPUT_DIMENSION = Number.parseInt(process.env.MAX_IMAGE_DIMENSION || '1600', 10);
const WEBP_QUALITY = Number.parseInt(process.env.WEBP_QUALITY || '85', 10);
export async function normalizeToWebp(buffer) {
let image;
try {
image = sharp(buffer, {
animated: false,
limitInputPixels: 20_000_000
});
} catch {
throw new HttpError(400, 'Image could not be decoded.');
}
const metadata = await image.metadata().catch(() => null);
if (!metadata?.width || !metadata?.height) {
throw new HttpError(400, 'Image could not be decoded.');
}
const output = await image
.rotate()
.resize({
width: MAX_OUTPUT_DIMENSION,
height: MAX_OUTPUT_DIMENSION,
fit: 'inside',
withoutEnlargement: true
})
.webp({
quality: WEBP_QUALITY,
effort: 4
})
.toBuffer({ resolveWithObject: true });
return {
buffer: output.data,
image: {
format: 'webp',
ext: 'webp',
mime: 'image/webp',
width: output.info.width,
height: output.info.height,
byteSize: output.data.length
}
};
}
+57
View File
@@ -0,0 +1,57 @@
import zlib from 'node:zlib';
export function encodePng(width, height, pixelAt) {
const stride = width * 4 + 1;
const raw = Buffer.alloc(stride * height);
for (let y = 0; y < height; y += 1) {
const row = y * stride;
raw[row] = 0;
for (let x = 0; x < width; x += 1) {
const [r, g, b, a = 255] = pixelAt(x, y, width, height);
const offset = row + 1 + x * 4;
raw[offset] = r;
raw[offset + 1] = g;
raw[offset + 2] = b;
raw[offset + 3] = a;
}
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr(width, height)),
chunk('IDAT', zlib.deflateSync(raw)),
chunk('IEND', Buffer.alloc(0))
]);
}
function ihdr(width, height) {
const buffer = Buffer.alloc(13);
buffer.writeUInt32BE(width, 0);
buffer.writeUInt32BE(height, 4);
buffer[8] = 8;
buffer[9] = 6;
buffer[10] = 0;
buffer[11] = 0;
buffer[12] = 0;
return buffer;
}
function chunk(type, data) {
const typeBuffer = Buffer.from(type, 'ascii');
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
return Buffer.concat([length, typeBuffer, data, crc]);
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
}
return (crc ^ 0xffffffff) >>> 0;
}
+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);
}
}
+55
View File
@@ -0,0 +1,55 @@
import { validateImage } from './image.js';
import { normalizeToWebp } from './normalize.js';
import { encodePng } from './png.js';
const DEMO_COUNT = 18;
export async function seedDemoMemes(store) {
const total = store.count();
const existing = store.list({ page: 1, pageSize: Math.max(DEMO_COUNT, total) }).memes;
const isDemoOnlyStore = existing.length === total && existing.every((meme) => meme.demo);
if (total > 0 && !isDemoOnlyStore) return;
const existingNames = new Set(existing.map((meme) => meme.originalName));
for (let index = 0; index < DEMO_COUNT; index += 1) {
const originalName = `protocol-sample-${String(index + 1).padStart(2, '0')}.png`;
if (existingNames.has(originalName)) continue;
const width = 960;
const height = 960;
const buffer = encodePng(width, height, pixelFactory(index));
const image = validateImage(buffer, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
});
const normalized = await normalizeToWebp(buffer);
const createdAt = new Date(Date.now() - index * 17 * 60 * 1000).toISOString();
await store.save({
buffer: normalized.buffer,
image: normalized.image,
originalName,
originalMime: image.mime,
createdAt,
demo: true
});
}
}
function pixelFactory(seed) {
return (x, y, width, height) => {
const nx = x / width;
const ny = y / height;
const grid = x % (32 + seed * 3) < 1 || y % (29 + seed * 2) < 1;
const diagonal = Math.abs(((x + y + seed * 37) % 180) - 90) < 2;
const ring = Math.abs(Math.hypot(nx - 0.5, ny - 0.5) - (0.18 + (seed % 4) * 0.05)) < 0.006;
const scan = y % 7 === 0;
const pulse = (Math.sin((x * (seed + 3) + y * 2) / 42) + 1) / 2;
const green = grid || diagonal || ring ? 230 : Math.round(18 + pulse * 52);
const blue = ring || (seed % 3 === 1 && diagonal) ? 210 : Math.round(18 + pulse * 30);
const red = scan ? 22 : Math.round(4 + pulse * 14);
return [red, green, blue, 255];
};
}
+263
View File
@@ -0,0 +1,263 @@
const SITE_NAME = 'The Meme Protocol';
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
const SOCIAL_IMAGE_PATH = '/assets/social-preview-v2.78d008df9860.jpg';
const NOT_FOUND_IMAGE_PATH = '/assets/homo-reseticus-404-828.68be2119.webp';
const FAVICON_PATH = '/assets/favicon';
const FAVICON_VERSION = '20260814';
export function publicBaseUrl(req) {
if (process.env.SITE_URL) return cleanBase(process.env.SITE_URL);
const host = process.env.TRUST_PROXY === 'true'
? req.headers['x-forwarded-host'] || req.headers.host
: req.headers.host;
const proto = process.env.TRUST_PROXY === 'true'
? req.headers['x-forwarded-proto'] || 'https'
: 'http';
return cleanBase(`${proto}://${host || 'localhost:8080'}`);
}
export function renderIndex({ template, baseUrl, nonce, approvedCount, meme = null, lore = false }) {
const home = `${baseUrl}/`;
const canonical = meme ? `${baseUrl}/meme/${meme.id}` : lore ? `${baseUrl}/lore` : home;
const image = meme ? `${baseUrl}/media/${meme.id}` : `${baseUrl}${SOCIAL_IMAGE_PATH}`;
const imageType = meme ? meme.mime : 'image/jpeg';
const imageWidth = meme ? meme.width : 1200;
const imageHeight = meme ? meme.height : 630;
const imageAlt = meme ? `Meme ${shortId(meme.id)}` : `${SITE_NAME} — memes bring us together`;
const title = meme ? `Meme ${shortId(meme.id)} | ${SITE_NAME}` : lore ? `Lore | ${SITE_NAME}` : SITE_NAME;
const description = meme
? `View and share meme ${shortId(meme.id)} on ${SITE_NAME}.`
: lore
? `Read, copy, download, and share the lore of ${SITE_NAME}.`
: SITE_DESCRIPTION;
const pageMetadata = meme
? {
'@type': 'ImageObject',
'@id': `${canonical}#meme`,
name: `Meme ${shortId(meme.id)}`,
contentUrl: image,
url: canonical,
uploadDate: meme.createdAt,
width: meme.width,
height: meme.height,
isPartOf: { '@id': `${home}#website` }
}
: lore
? {
'@type': 'CreativeWork',
'@id': `${canonical}#lore`,
name: `${SITE_NAME} Lore`,
url: canonical,
description,
encoding: {
'@type': 'MediaObject',
contentUrl: `${baseUrl}/assets/meme-protocol-lore.txt`,
encodingFormat: 'text/plain'
},
isPartOf: { '@id': `${home}#website` }
}
: {
'@type': 'CollectionPage',
'@id': `${canonical}#collection`,
name: SITE_NAME,
url: canonical,
description: SITE_DESCRIPTION,
isPartOf: { '@id': `${home}#website` },
about: ['memes', 'internet culture', 'image gallery', 'community moderation'],
numberOfItems: approvedCount
};
const jsonLd = {
'@context': 'https://schema.org',
'@graph': [
{
'@type': 'WebSite',
'@id': `${home}#website`,
name: SITE_NAME,
url: home,
description: SITE_DESCRIPTION,
inLanguage: 'en'
},
pageMetadata,
{
'@type': 'SoftwareApplication',
name: SITE_NAME,
applicationCategory: 'MultimediaApplication',
operatingSystem: 'Web',
url: home,
codeRepository: REPO_URL,
offers: { '@type': 'Offer', price: '0', priceCurrency: 'USD' }
}
]
};
return template
.replaceAll('__SEO_TITLE__', escapeHtml(title))
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(description))
.replaceAll('__SEO_CANONICAL__', canonical)
.replaceAll('__SEO_IMAGE__', image)
.replaceAll('__SEO_IMAGE_TYPE__', imageType)
.replaceAll('__SEO_IMAGE_WIDTH__', String(imageWidth))
.replaceAll('__SEO_IMAGE_HEIGHT__', String(imageHeight))
.replaceAll('__SEO_IMAGE_ALT__', escapeHtml(imageAlt))
.replaceAll('__SEO_JSON_LD__', escapeJsonScript(JSON.stringify(jsonLd)))
.replaceAll('__CSP_NONCE__', nonce);
}
export function renderNotFound({ template, baseUrl, pathname }) {
const title = `404 | ${SITE_NAME}`;
const description = 'The requested Meme Protocol node could not be found.';
const pageUrl = new URL(pathname, `${baseUrl}/`).href;
const image = `${baseUrl}${NOT_FOUND_IMAGE_PATH}`;
const imageAlt = 'Homo Reseticus surrounded by an unreachable node pattern';
return template
.replaceAll('__SEO_TITLE__', escapeHtml(title))
.replaceAll('__SEO_DESCRIPTION__', escapeHtml(description))
.replaceAll('__SEO_URL__', escapeHtml(pageUrl))
.replaceAll('__SEO_IMAGE__', escapeHtml(image))
.replaceAll('__SEO_IMAGE_ALT__', escapeHtml(imageAlt));
}
export function robotsTxt(baseUrl) {
return [
'User-agent: *',
'Allow: /',
'Disallow: /admin/',
'Disallow: /admin-media/',
'Disallow: /api/',
'Disallow: /download/',
'',
`Sitemap: ${baseUrl}/sitemap.xml`,
''
].join('\n');
}
export function llmsTxt(baseUrl) {
return [
'# The Meme Protocol',
'',
'> A live, moderated meme gallery. Uploads are PNG/JPEG inputs normalized to metadata-stripped WebP, scored with MEME_CONSENSUS_SCORE, and published only after AI or admin approval.',
'',
'Important URLs:',
`- Site: ${baseUrl}/`,
`- OpenAPI: ${baseUrl}/openapi.json`,
`- API skill: ${baseUrl}/meme-api.skill.md`,
`- JSON feed: ${baseUrl}/feed.json`,
`- Sitemap: ${baseUrl}/sitemap.xml`,
`- Lore: ${baseUrl}/lore`,
`- 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.',
''
].join('\n');
}
export function manifest(baseUrl) {
return {
name: SITE_NAME,
short_name: 'Meme Protocol',
description: SITE_DESCRIPTION,
start_url: '/',
scope: '/',
display: 'standalone',
background_color: '#050505',
theme_color: '#00ff41',
icons: [
{
src: `${baseUrl}${FAVICON_PATH}/web-app-manifest-192x192.png?v=${FAVICON_VERSION}`,
sizes: '192x192',
type: 'image/png',
purpose: 'maskable'
},
{
src: `${baseUrl}${FAVICON_PATH}/web-app-manifest-512x512.png?v=${FAVICON_VERSION}`,
sizes: '512x512',
type: 'image/png',
purpose: 'maskable'
}
]
};
}
export function feedJson(baseUrl, memes) {
return {
version: 'https://jsonfeed.org/version/1.1',
title: SITE_NAME,
home_page_url: `${baseUrl}/`,
feed_url: `${baseUrl}/feed.json`,
description: SITE_DESCRIPTION,
items: memes.slice(0, 50).map((meme) => ({
id: meme.id,
url: `${baseUrl}/meme/${meme.id}`,
image: `${baseUrl}/media/${meme.id}`,
title: `Meme ${shortId(meme.id)}`,
content_text: `Approved meme with MEME_CONSENSUS_SCORE ${meme.moderationScore}/100.`,
date_published: meme.createdAt
}))
};
}
export function sitemapXml(baseUrl, memes) {
const latest = memes[0]?.createdAt || new Date().toISOString();
const urls = [
[
' <url>',
` <loc>${xmlEscape(`${baseUrl}/`)}</loc>`,
` <lastmod>${xmlEscape(latest)}</lastmod>`,
' </url>'
].join('\n'),
[
' <url>',
` <loc>${xmlEscape(`${baseUrl}/lore`)}</loc>`,
` <lastmod>${xmlEscape(latest)}</lastmod>`,
' </url>'
].join('\n'),
...memes.slice(0, 1000).map((meme) => [
' <url>',
` <loc>${xmlEscape(`${baseUrl}/meme/${meme.id}`)}</loc>`,
` <lastmod>${xmlEscape(meme.createdAt)}</lastmod>`,
' </url>'
].join('\n'))
].join('\n');
return [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
urls,
'</urlset>',
''
].join('\n');
}
export function noIndex(res) {
res.setHeader('X-Robots-Tag', 'noindex, nofollow, noarchive');
}
function cleanBase(value) {
return String(value).replace(/\/+$/, '');
}
function shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
}
function xmlEscape(value) {
return escapeHtml(value).replaceAll("'", '&apos;');
}
function escapeJsonScript(value) {
return value.replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026');
}
+213
View File
@@ -0,0 +1,213 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
export async function createStore({ dataDir }) {
const root = path.resolve(dataDir);
const indexDir = path.join(root, 'index');
const indexFile = path.join(indexDir, 'memes.jsonl');
await fs.mkdir(indexDir, { recursive: true });
const entries = await loadIndex(indexFile, root);
const byId = new Map(entries.map((entry) => [entry.id, entry]));
let writeChain = Promise.resolve();
return {
count(status) {
return status ? entries.filter((entry) => entry.status === status).length : entries.length;
},
get(id) {
return byId.get(id) || null;
},
absolutePath(storageKey) {
return path.join(root, storageKey);
},
list({ page, pageSize }) {
const visible = entries.filter((entry) => entry.status === 'approved');
const total = visible.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return {
page: safePage,
pageSize,
total,
totalPages,
memes: visible.slice(start, start + pageSize).map(toPublicMeme)
};
},
listForReview({ status = 'pending' } = {}) {
return {
total: entries.filter((entry) => entry.status === status).length,
memes: entries.filter((entry) => entry.status === status).map(toPublicMeme)
};
},
async approve(ids) {
const approved = [];
for (const id of ids) {
const record = byId.get(id);
if (!record || record.status !== 'pending') continue;
record.status = 'approved';
record.reviewedAt = new Date().toISOString();
record.moderationReason = `${record.moderationReason || 'Queued for review.'} Admin approved.`;
approved.push(toPublicMeme(record));
}
if (approved.length > 0) {
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
await writeChain;
}
return approved;
},
async delete(ids) {
const deleted = [];
for (const id of ids) {
const index = entries.findIndex((entry) => entry.id === id);
if (index === -1) continue;
const [record] = entries.splice(index, 1);
byId.delete(id);
deleted.push(id);
await fs.rm(path.join(root, record.storageKey), { force: true });
await fs.rm(path.join(root, record.metaKey), { force: true });
}
if (deleted.length > 0) {
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
await writeChain;
}
return deleted;
},
async incrementMetric(id, metric) {
const record = byId.get(id);
if (!record) return null;
if (metric !== 'viewCount' && metric !== 'downloadCount') return toPublicMeme(record);
record[metric] = Number.isFinite(record[metric]) ? record[metric] + 1 : 1;
writeChain = writeChain.then(() => persistRecord(root, indexFile, entries, record));
await writeChain;
return toPublicMeme(record);
},
async save({
buffer,
image,
originalName,
createdAt = new Date().toISOString(),
demo = false,
status = 'approved',
moderationScore = demo ? 90 : 0,
moderationReason = '',
originalMime = image.mime
}) {
const id = crypto.createHash('sha256').update(buffer).digest('hex');
const existing = byId.get(id);
if (existing) return toPublicMeme(existing);
const date = createdAt.slice(0, 10).replaceAll('-', '/');
const shard = `${id.slice(0, 2)}/${id.slice(2, 4)}`;
const storageKey = `memes/${date}/${shard}/${id}.${image.ext}`;
const metaKey = `meta/${date}/${shard}/${id}.json`;
const record = {
id,
createdAt,
originalName,
byteSize: image.byteSize,
width: image.width,
height: image.height,
mime: image.mime,
originalMime,
ext: image.ext,
status,
moderationScore,
moderationReason,
viewCount: 0,
downloadCount: 0,
storageKey,
metaKey,
demo
};
await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true });
await fs.mkdir(path.dirname(path.join(root, metaKey)), { recursive: true });
await fs.writeFile(path.join(root, storageKey), buffer, { flag: 'wx' }).catch((error) => {
if (error.code !== 'EEXIST') throw error;
});
await persistRecord(root, indexFile, [...entries, record], record, { writeFileFlag: 'wx' });
entries.unshift(record);
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
byId.set(id, record);
return toPublicMeme(record);
}
};
}
async function loadIndex(indexFile, root) {
let content = '';
try {
content = await fs.readFile(indexFile, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const recordsById = new Map();
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const record = JSON.parse(line);
record.status = record.status || 'approved';
record.moderationScore = Number.isFinite(record.moderationScore) ? record.moderationScore : 80;
record.moderationReason = record.moderationReason || '';
record.originalMime = record.originalMime || record.mime;
record.viewCount = Number.isFinite(record.viewCount) ? record.viewCount : 0;
record.downloadCount = Number.isFinite(record.downloadCount) ? record.downloadCount : 0;
const mediaPath = path.join(root, record.storageKey);
const relative = path.relative(root, mediaPath);
if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
await fs.access(mediaPath);
recordsById.set(record.id, record);
} catch {
// Ignore corrupt index lines; individual metadata files remain on disk for recovery.
}
}
const records = [...recordsById.values()];
records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return records;
}
async function persistRecord(root, indexFile, entries, record, options = {}) {
await fs.writeFile(path.join(root, record.metaKey), `${JSON.stringify(record, null, 2)}\n`, {
flag: options.writeFileFlag || 'w'
}).catch((error) => {
if (options.writeFileFlag === 'wx' && error.code === 'EEXIST') return;
throw error;
});
await persistAll(root, indexFile, entries);
}
async function persistAll(root, indexFile, entries) {
await Promise.all(entries.map((entry) => fs.writeFile(
path.join(root, entry.metaKey),
`${JSON.stringify(entry, null, 2)}\n`
).catch((error) => {
if (error.code !== 'ENOENT') throw error;
})));
await fs.writeFile(indexFile, `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`);
}
function toPublicMeme(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)
};
}
+66
View File
@@ -0,0 +1,66 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { HttpError } from './errors.js';
const HOUR_LIMIT = 5;
const DAY_LIMIT = 10;
const GLOBAL_DAY_LIMIT = 100;
export async function createUploadLimiter({ dataDir }) {
const filePath = path.join(path.resolve(dataDir), 'index', 'upload-limits.json');
await fs.mkdir(path.dirname(filePath), { recursive: true });
let state = await loadState(filePath);
let writeChain = Promise.resolve();
return {
async checkAndConsume(ip) {
const now = new Date();
const day = now.toISOString().slice(0, 10);
const hour = now.toISOString().slice(0, 13);
const key = crypto.createHash('sha256').update(ip || 'unknown').digest('hex');
if (state.day !== day) state = { day, global: 0, clients: {} };
const client = state.clients[key] || { day, dayCount: 0, hour, hourCount: 0 };
if (client.day !== day) {
client.day = day;
client.dayCount = 0;
}
if (client.hour !== hour) {
client.hour = hour;
client.hourCount = 0;
}
if (state.global >= GLOBAL_DAY_LIMIT) {
throw new HttpError(429, 'Daily site upload budget reached. Try again tomorrow.');
}
if (client.hourCount >= HOUR_LIMIT) {
throw new HttpError(429, 'Upload limit reached: 5 images per hour.');
}
if (client.dayCount >= DAY_LIMIT) {
throw new HttpError(429, 'Upload limit reached: 10 images per day.');
}
state.global += 1;
client.hourCount += 1;
client.dayCount += 1;
state.clients[key] = client;
writeChain = writeChain.then(() => fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`));
await writeChain;
return {
remainingHour: Math.max(0, HOUR_LIMIT - client.hourCount),
remainingDay: Math.max(0, DAY_LIMIT - client.dayCount),
remainingGlobalDay: Math.max(0, GLOBAL_DAY_LIMIT - state.global)
};
}
};
}
async function loadState(filePath) {
try {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch {
return { day: new Date().toISOString().slice(0, 10), global: 0, clients: {} };
}
}
+66
View File
@@ -0,0 +1,66 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectImage, validateImage } from '../src/image.js';
import { normalizeToWebp } from '../src/normalize.js';
import { encodePng } from '../src/png.js';
test('detects generated png dimensions', () => {
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
assert.deepEqual(detectImage(png), {
format: 'png',
ext: 'png',
mime: 'image/png',
width: 32,
height: 24
});
});
test('rejects unsupported payloads', () => {
assert.throws(
() => validateImage(Buffer.from('<svg></svg>'), {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
}),
/Only PNG/
);
});
test('rejects gif uploads', () => {
const gif = Buffer.from('47494638396101000100800000000000ffffff2c00000000010001000002024401003b', 'hex');
assert.throws(
() => validateImage(gif, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
}),
/Only PNG and JPEG/
);
});
test('accepts non-square png uploads', () => {
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
const image = validateImage(png, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
});
assert.equal(image.width, 32);
assert.equal(image.height, 24);
});
test('normalizes png uploads to webp while preserving aspect ratio', async () => {
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
const normalized = await normalizeToWebp(png);
assert.equal(normalized.image.mime, 'image/webp');
assert.equal(normalized.image.ext, 'webp');
assert.equal(normalized.image.width, 32);
assert.equal(normalized.image.height, 24);
assert.equal(normalized.buffer.subarray(0, 4).toString('ascii'), 'RIFF');
assert.equal(normalized.buffer.subarray(8, 12).toString('ascii'), 'WEBP');
});
+76
View File
@@ -0,0 +1,76 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { moderateImage } from '../src/moderation.js';
test('queues uploads when AI moderation is not configured', async () => {
const originalApiKey = process.env.OPENAI_API_KEY;
delete process.env.OPENAI_API_KEY;
try {
const moderation = await moderateImage({
buffer: Buffer.from('image'),
mime: 'image/webp'
});
assert.equal(moderation.status, 'pending');
assert.equal(moderation.score, 50);
assert.match(moderation.reason, /not configured/);
} finally {
if (originalApiKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = originalApiKey;
}
}
});
test('queues model approvals below the auto-publish quality threshold', async () => {
const originalApiKey = process.env.OPENAI_API_KEY;
const originalThreshold = process.env.AUTO_APPROVE_MIN_SCORE;
const originalFetch = globalThis.fetch;
process.env.OPENAI_API_KEY = 'test-key';
process.env.AUTO_APPROVE_MIN_SCORE = '90';
globalThis.fetch = async (_url, options) => {
const body = JSON.parse(options.body);
const prompt = body.input[0].content.find((item) => item.type === 'input_text').text;
assert.match(prompt, /could the exact same caption work on 50 unrelated images/);
assert.match(prompt, /Use approved only when it appears legal and score is at least 90/);
return {
ok: true,
json: async () => ({
output_text: JSON.stringify({
decision: 'approved',
score: 89,
reason: 'Specific joke, but not strong enough.'
})
})
};
};
try {
const moderation = await moderateImage({
buffer: Buffer.from('image'),
mime: 'image/webp'
});
assert.equal(moderation.status, 'pending');
assert.equal(moderation.score, 89);
assert.match(moderation.reason, /Low MEME_CONSENSUS_SCORE queued for review/);
} finally {
globalThis.fetch = originalFetch;
if (originalApiKey === undefined) {
delete process.env.OPENAI_API_KEY;
} else {
process.env.OPENAI_API_KEY = originalApiKey;
}
if (originalThreshold === undefined) {
delete process.env.AUTO_APPROVE_MIN_SCORE;
} else {
process.env.AUTO_APPROVE_MIN_SCORE = originalThreshold;
}
}
});
+73
View File
@@ -0,0 +1,73 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import sharp from 'sharp';
import { renderNotFound } from '../src/seo.js';
const template = fs.readFileSync(new URL('../public/404.html', import.meta.url), 'utf8');
const page = renderNotFound({
template,
baseUrl: 'https://memes.example',
pathname: '/missing-node'
});
const styles = fs.readFileSync(new URL('../public/assets/styles.css', import.meta.url), 'utf8');
const artworkPaths = [
new URL('../public/assets/homo-reseticus-404-276.0fde8f3e.webp', import.meta.url),
new URL('../public/assets/homo-reseticus-404-552.f829379a.webp', import.meta.url),
new URL('../public/assets/homo-reseticus-404-828.68be2119.webp', import.meta.url)
];
test('404 page is noindexed and links visitors back to the live feed', () => {
assert.match(page, /<title>404 \| The Meme Protocol<\/title>/);
assert.match(page, /<meta name="robots" content="noindex,nofollow,noarchive">/);
assert.match(page, /class="primary-action not-found-action" href="\/"/);
assert.doesNotMatch(page, /PAGE_NOT_FOUND/);
assert.doesNotMatch(page, /THE_REQUESTED_NODE_DOES_NOT_EXIST/);
});
test('404 page carries adjusted site discovery and social metadata', () => {
assert.match(page, /<meta name="application-name" content="The Meme Protocol">/);
assert.match(page, /<link rel="manifest" href="\/site\.webmanifest\?v=20260814">/);
assert.match(page, /<link rel="service-desc"[^>]+href="\/openapi\.json">/);
assert.match(page, /<link rel="help"[^>]+href="\/meme-api\.skill\.md">/);
assert.match(page, /<meta property="og:url" content="https:\/\/memes\.example\/missing-node">/);
assert.match(page, /<meta property="og:image" content="https:\/\/memes\.example\/assets\/homo-reseticus-404-828\.68be2119\.webp">/);
assert.match(page, /<meta property="og:image:type" content="image\/webp">/);
assert.match(page, /<meta name="twitter:card" content="summary_large_image">/);
assert.doesNotMatch(page, /<link rel="canonical"/);
});
test('404 page keeps the homepage protocol menu and replaces its live status', () => {
assert.match(page, /class="status-line"/);
assert.match(page, />DOWNLOAD PET<\/a>/);
assert.match(page, />READ LORE<\/a>/);
assert.match(page, /ERROR_404 \/\/ NODE_UNREACHABLE/);
});
test('404 page uses responsive artwork with a diffused green backdrop', () => {
assert.match(page, /src="\/assets\/homo-reseticus-404-552\.f829379a\.webp"/);
assert.match(page, /srcset="[^"]+276\.0fde8f3e\.webp 276w,[^"]+552\.f829379a\.webp 552w,[^"]+828\.68be2119\.webp 828w"/);
assert.match(page, /fetchpriority="high"/);
assert.match(page, /alt="Homo Reseticus surrounded by an unreachable node pattern"/);
assert.match(styles, /\.not-found-visual::before/);
assert.match(styles, /rgb\(0 255 65 \/ 48%\)/);
assert.match(styles, /filter: blur\(36px\)/);
assert.match(styles, /transform: scale\(1\.24\)/);
assert.match(styles, /width: clamp\(127px, 26vmin, 276px\)/);
assert.match(styles, /\.not-found-page \.pulse/);
});
test('404 artwork variants retain lossless WebP transparency', async () => {
const expectedSizes = [[276, 271], [552, 542], [828, 813]];
for (const [index, artworkPath] of artworkPaths.entries()) {
const artwork = fs.readFileSync(artworkPath);
const metadata = await sharp(artwork).metadata();
assert.equal(artwork.subarray(0, 4).toString('ascii'), 'RIFF');
assert.equal(artwork.subarray(8, 12).toString('ascii'), 'WEBP');
assert.equal(artwork.subarray(12, 16).toString('ascii'), 'VP8L');
assert.equal(metadata.format, 'webp');
assert.equal(metadata.hasAlpha, true);
assert.deepEqual([metadata.width, metadata.height], expectedSizes[index]);
}
});
+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.'
);
});
+113
View File
@@ -0,0 +1,113 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs';
import { fileURLToPath } from 'node:url';
import sharp from 'sharp';
import { feedJson, manifest, renderIndex, sitemapXml } from '../src/seo.js';
const socialPreviewPath = fileURLToPath(new URL('../public/assets/social-preview-v2.78d008df9860.jpg', import.meta.url));
const id = 'a'.repeat(64);
const meme = {
id,
createdAt: '2026-07-24T12:00:00.000Z',
width: 1200,
height: 900,
mime: 'image/webp',
moderationScore: 92
};
const template = [
'<title>__SEO_TITLE__</title>',
'<meta name="description" content="__SEO_DESCRIPTION__">',
'<link rel="canonical" href="__SEO_CANONICAL__">',
'<meta property="og:image" content="__SEO_IMAGE__">',
'<meta property="og:image:type" content="__SEO_IMAGE_TYPE__">',
'<meta name="twitter:image" content="__SEO_IMAGE__">',
'<script nonce="__CSP_NONCE__">__SEO_JSON_LD__</script>'
].join('');
test('renders a meme permalink with meme-specific share metadata', () => {
const html = renderIndex({
template,
baseUrl: 'https://memes.example',
nonce: 'test-nonce',
approvedCount: 1,
meme
});
assert.match(html, /<title>Meme 0xAAAA\.\.\.AAAA \| The Meme Protocol<\/title>/);
assert.match(html, new RegExp(`canonical" href="https://memes\\.example/meme/${id}"`));
assert.match(html, new RegExp(`og:image" content="https://memes\\.example/media/${id}"`));
assert.match(html, /"@type":"ImageObject"/);
assert.match(html, /nonce="test-nonce"/);
});
test('renders the homepage with the dedicated social preview image', () => {
const html = renderIndex({
template,
baseUrl: 'https://memes.example',
nonce: 'home-nonce',
approvedCount: 12
});
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/);
assert.match(html, /og:image:type" content="image\/jpeg"/);
assert.match(html, /twitter:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/);
assert.doesNotMatch(html, /yoonect-logo\.png/);
});
test('keeps the social preview small and broadly compatible', async () => {
const metadata = await sharp(socialPreviewPath).metadata();
const stats = fs.statSync(socialPreviewPath);
assert.equal(metadata.format, 'jpeg');
assert.deepEqual([metadata.width, metadata.height], [1200, 630]);
assert.equal(metadata.hasAlpha, false);
assert.equal(metadata.exif, undefined);
assert.equal(metadata.xmp, undefined);
assert.ok(stats.size < 300_000, `expected social preview under 300 KB, got ${stats.size}`);
});
test('uses the supplied favicon suite in the page and web app manifest', () => {
const index = fs.readFileSync(new URL('../public/index.html', import.meta.url), 'utf8');
const appManifest = manifest('https://memes.example');
assert.match(index, /\/assets\/favicon\/favicon\.svg\?v=20260814/);
assert.match(index, /\/assets\/favicon\/favicon-96x96\.png\?v=20260814/);
assert.match(index, /\/favicon\.ico\?v=20260814/);
assert.match(index, /\/assets\/favicon\/apple-touch-icon\.png\?v=20260814/);
assert.doesNotMatch(index, /rel="icon"[^>]+yoonect-logo\.png/);
assert.deepEqual(appManifest.icons.map(({ sizes, purpose }) => ({ sizes, purpose })), [
{ sizes: '192x192', purpose: 'maskable' },
{ sizes: '512x512', purpose: 'maskable' }
]);
assert.match(appManifest.icons[0].src, /web-app-manifest-192x192\.png\?v=20260814$/);
assert.match(appManifest.icons[1].src, /web-app-manifest-512x512\.png\?v=20260814$/);
});
test('renders a shareable lore page with text-file metadata', () => {
const html = renderIndex({
template,
baseUrl: 'https://memes.example',
nonce: 'lore-nonce',
approvedCount: 1,
lore: true
});
assert.match(html, /<title>Lore \| The Meme Protocol<\/title>/);
assert.match(html, /canonical" href="https:\/\/memes\.example\/lore"/);
assert.match(html, /og:image" content="https:\/\/memes\.example\/assets\/social-preview-v2\.78d008df9860\.jpg"/);
assert.match(html, /"@type":"CreativeWork"/);
assert.match(html, /https:\/\/memes\.example\/assets\/meme-protocol-lore\.txt/);
});
test('discovery feeds point readers to viewer permalinks', () => {
const feed = feedJson('https://memes.example', [meme]);
const sitemap = sitemapXml('https://memes.example', [meme]);
assert.match(sitemap, /https:\/\/memes\.example\/lore/);
assert.equal(feed.items[0].url, `https://memes.example/meme/${id}`);
assert.equal(feed.items[0].image, `https://memes.example/media/${id}`);
assert.match(sitemap, new RegExp(`https://memes\\.example/meme/${id}`));
});
+99
View File
@@ -0,0 +1,99 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { spawn } from 'node:child_process';
import { once } from 'node:events';
import fs from 'node:fs/promises';
import net from 'node:net';
import os from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const projectRoot = fileURLToPath(new URL('../', import.meta.url));
const socialPreviewPath = '/assets/social-preview-v2.78d008df9860.jpg';
test('serves crawler-safe HEAD responses for the homepage and social preview', { timeout: 15_000 }, async (t) => {
let port;
try {
port = await availablePort();
} catch (error) {
if (error.code === 'EPERM') {
t.skip('loopback listeners are disabled in this sandbox');
return;
}
throw error;
}
const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-protocol-head-'));
const child = spawn(process.execPath, ['server.js'], {
cwd: projectRoot,
env: {
...process.env,
ADMIN_TOKEN: 'test-admin-token-000000000000',
DATA_DIR: dataDir,
HOST: '127.0.0.1',
PORT: String(port),
SEED_DEMO_MEMES: 'false',
SITE_URL: `http://127.0.0.1:${port}`
},
stdio: ['ignore', 'pipe', 'pipe']
});
t.after(async () => {
if (child.exitCode === null && child.signalCode === null) {
child.kill('SIGTERM');
await once(child, 'exit');
}
await fs.rm(dataDir, { recursive: true, force: true });
});
await waitForStart(child);
const headers = { 'user-agent': 'Twitterbot/1.0' };
const page = await fetch(`http://127.0.0.1:${port}/`, { method: 'HEAD', headers });
assert.equal(page.status, 200);
assert.match(page.headers.get('content-type') || '', /^text\/html/);
assert.ok(Number(page.headers.get('content-length')) > 0);
assert.equal((await page.arrayBuffer()).byteLength, 0);
const image = await fetch(`http://127.0.0.1:${port}${socialPreviewPath}`, { method: 'HEAD', headers });
assert.equal(image.status, 200);
assert.equal(image.headers.get('content-type'), 'image/jpeg');
assert.match(image.headers.get('cache-control') || '', /immutable/);
assert.ok(Number(image.headers.get('content-length')) < 300_000);
assert.equal((await image.arrayBuffer()).byteLength, 0);
});
async function availablePort() {
const server = net.createServer();
await new Promise((resolve, reject) => {
server.once('error', reject);
server.listen(0, '127.0.0.1', resolve);
});
const address = server.address();
const port = typeof address === 'object' && address ? address.port : 0;
await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
return port;
}
async function waitForStart(child) {
await new Promise((resolve, reject) => {
let output = '';
const timeout = setTimeout(() => finish(new Error(`server did not start:\n${output}`)), 10_000);
const onData = (chunk) => {
output += chunk.toString();
if (output.includes('The Meme Protocol listening')) finish();
};
const onExit = (code, signal) => finish(new Error(`server exited before startup (${code ?? signal}):\n${output}`));
const finish = (error) => {
clearTimeout(timeout);
child.stdout.off('data', onData);
child.stderr.off('data', onData);
child.off('exit', onExit);
error ? reject(error) : resolve();
};
child.stdout.on('data', onData);
child.stderr.on('data', onData);
child.once('exit', onExit);
});
}
+57
View File
@@ -0,0 +1,57 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createStore } from '../src/store.js';
import { validateImage } from '../src/image.js';
import { encodePng } from '../src/png.js';
test('stores memes in dated hash shards and paginates', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-protocol-'));
const store = await createStore({ dataDir: dir });
const buffer = encodePng(16, 16, () => [0, 255, 65, 255]);
const image = validateImage(buffer, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
});
const meme = await store.save({
buffer,
image,
originalName: 'test.png',
createdAt: '2026-05-08T12:00:00.000Z'
});
assert.equal(store.count(), 1);
assert.equal(store.list({ page: 1, pageSize: 12 }).memes[0].id, meme.id);
assert.equal(store.list({ page: 1, pageSize: 12 }).memes[0].viewCount, 0);
assert.match(store.get(meme.id).storageKey, /^memes\/2026\/05\/08\/[a-f0-9]{2}\/[a-f0-9]{2}\//);
await fs.access(path.join(dir, store.get(meme.id).storageKey));
});
test('increments view and download counters', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-protocol-'));
const store = await createStore({ dataDir: dir });
const buffer = encodePng(16, 16, () => [0, 255, 65, 255]);
const image = validateImage(buffer, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
});
const meme = await store.save({
buffer,
image,
originalName: 'test.png',
createdAt: '2026-05-08T12:00:00.000Z'
});
await store.incrementMetric(meme.id, 'viewCount');
await store.incrementMetric(meme.id, 'downloadCount');
const listed = store.list({ page: 1, pageSize: 12 }).memes[0];
assert.equal(listed.viewCount, 1);
assert.equal(listed.downloadCount, 1);
});