Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1385efec70
|
||
|
|
5f20e5d2b7 | ||
|
|
a3859f1efc
|
||
|
|
48f05bcf8a
|
||
|
|
0bf215555a
|
||
|
|
4d317ba1d3
|
||
|
|
2baa814cbd
|
||
|
|
d80d596664
|
||
|
|
36edd32fa3
|
||
|
|
a66fc3b760
|
||
|
|
101842a713
|
||
|
|
772b0cc3e8
|
||
|
|
62043b1f8f
|
||
|
|
7b5d4afd07
|
||
|
|
ff3db71084
|
||
|
|
e5fa16a88d
|
||
|
|
908084c394
|
||
|
|
290cbd5bcb
|
||
|
|
321d3ccf62
|
||
|
|
6cf711ad97
|
||
|
|
31a6b7f2c9
|
||
|
|
e4515a4699
|
||
|
|
4b15ccc09e |
@@ -1,3 +1,4 @@
|
|||||||
|
SITE_URL=https://bitsforfree.com
|
||||||
ADMIN_TOKEN=replace-with-a-long-random-secret
|
ADMIN_TOKEN=replace-with-a-long-random-secret
|
||||||
OPENAI_API_KEY=sk-your-key
|
OPENAI_API_KEY=sk-your-key
|
||||||
OPENAI_MODERATION_MODEL=gpt-4o-mini
|
OPENAI_MODERATION_MODEL=gpt-4o-mini
|
||||||
|
|||||||
@@ -2,3 +2,4 @@
|
|||||||
stitch_the_meme_protocol/
|
stitch_the_meme_protocol/
|
||||||
data/
|
data/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
.env
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -1,16 +1,18 @@
|
|||||||
FROM node:22-alpine
|
FROM node:20-bookworm-slim
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
COPY package.json package-lock.json ./
|
COPY package.json package-lock.json ./
|
||||||
RUN npm ci --omit=dev && npm cache clean --force
|
RUN npm ci --omit=dev --no-audit --no-fund && npm cache clean --force
|
||||||
|
|
||||||
COPY server.js ./server.js
|
COPY server.js ./server.js
|
||||||
|
COPY openapi.json meme-api.skill.md ./
|
||||||
COPY src ./src
|
COPY src ./src
|
||||||
COPY public ./public
|
COPY public ./public
|
||||||
|
|
||||||
RUN addgroup -S meme && adduser -S meme -G meme \
|
RUN groupadd --system --gid 10001 meme \
|
||||||
|
&& useradd --system --uid 10001 --gid meme --home-dir /app --shell /usr/sbin/nologin meme \
|
||||||
&& mkdir -p /data \
|
&& mkdir -p /data \
|
||||||
&& chown -R meme:meme /data /app
|
&& chown -R meme:meme /data /app
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Small self-hosted meme gallery matching the `stitch_the_meme_protocol` desktop mockup direction.
|
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
|
## Run locally
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -15,14 +23,16 @@ The server listens on `http://localhost:8080` by default.
|
|||||||
- `PORT`: HTTP port, default `8080`
|
- `PORT`: HTTP port, default `8080`
|
||||||
- `HOST`: bind address, default `0.0.0.0`
|
- `HOST`: bind address, default `0.0.0.0`
|
||||||
- `DATA_DIR`: disk storage root, default `./data`
|
- `DATA_DIR`: disk storage root, default `./data`
|
||||||
- `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes on first boot
|
- `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.
|
- `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs.
|
||||||
- `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review.
|
- `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review.
|
||||||
- `OPENAI_MODERATION_MODEL`: moderation vision model, default `gpt-4o-mini`
|
- `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`
|
- `TRUST_PROXY`: set to `true` when running behind a trusted reverse proxy so upload limits use `X-Forwarded-For`
|
||||||
|
|
||||||
Uploads accept only square PNG and JPEG images. The server rejects files over 5 MB, images over `6000x6000`, and images over 20 million pixels. Accepted uploads are decoded, metadata-stripped, resized down to `1600x1600` if needed, and stored as WebP.
|
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. AI-approved uploads publish immediately; ambiguous uploads are queued for the secret admin review page; likely illegal uploads are rejected immediately.
|
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:
|
Files are stored under sharded date/hash paths:
|
||||||
|
|
||||||
@@ -33,6 +43,33 @@ data/
|
|||||||
meta/YYYY/MM/DD/aa/bb/<sha256>.json
|
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
|
## Docker
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -47,3 +84,30 @@ 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.
|
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
|
||||||
|
```
|
||||||
|
|||||||
@@ -9,17 +9,21 @@ services:
|
|||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 8080
|
PORT: 8080
|
||||||
DATA_DIR: /data
|
DATA_DIR: /data
|
||||||
|
SITE_URL: ${SITE_URL:-https://bitsforfree.com}
|
||||||
SEED_DEMO_MEMES: "false"
|
SEED_DEMO_MEMES: "false"
|
||||||
TRUST_PROXY: "true"
|
TRUST_PROXY: ${TRUST_PROXY:-false}
|
||||||
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
ADMIN_TOKEN: ${ADMIN_TOKEN}
|
||||||
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
OPENAI_API_KEY: ${OPENAI_API_KEY}
|
||||||
OPENAI_MODERATION_MODEL: ${OPENAI_MODERATION_MODEL:-gpt-4o-mini}
|
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}
|
MAX_IMAGE_DIMENSION: ${MAX_IMAGE_DIMENSION:-1600}
|
||||||
WEBP_QUALITY: ${WEBP_QUALITY:-85}
|
WEBP_QUALITY: ${WEBP_QUALITY:-85}
|
||||||
volumes:
|
volumes:
|
||||||
- meme_protocol_data:/data
|
- ./data:/data
|
||||||
ports:
|
networks:
|
||||||
- "127.0.0.1:18080:8080"
|
npm_proxy:
|
||||||
|
ipv4_address: 192.168.99.21
|
||||||
|
|
||||||
volumes:
|
networks:
|
||||||
meme_protocol_data:
|
npm_proxy:
|
||||||
|
external: true
|
||||||
@@ -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.
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "meme-protocol",
|
"name": "meme-protocol",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "meme-protocol",
|
"name": "meme-protocol",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"sharp": "^0.34.5"
|
"sharp": "^0.34.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "meme-protocol",
|
"name": "meme-protocol",
|
||||||
"version": "1.0.0",
|
"version": "1.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"description": "Self-hosted meme gallery for The Meme Protocol.",
|
"description": "Self-hosted meme gallery for The Meme Protocol.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1,5 +1,11 @@
|
|||||||
const PAGE_SIZE = 12;
|
const PAGE_SIZE = 12;
|
||||||
const MAX_FILE_BYTES = 5 * 1024 * 1024;
|
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 SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
|
||||||
|
|
||||||
const grid = document.querySelector('#meme-grid');
|
const grid = document.querySelector('#meme-grid');
|
||||||
@@ -10,21 +16,40 @@ const uploadForm = document.querySelector('#upload-form');
|
|||||||
const fileInput = document.querySelector('#meme-input');
|
const fileInput = document.querySelector('#meme-input');
|
||||||
const fileLabel = document.querySelector('#file-label');
|
const fileLabel = document.querySelector('#file-label');
|
||||||
const formStatus = document.querySelector('#form-status');
|
const formStatus = document.querySelector('#form-status');
|
||||||
const submitUpload = document.querySelector('#submit-upload');
|
const addMemeButton = document.querySelector('#add-meme-button');
|
||||||
const lightbox = document.querySelector('#lightbox');
|
const lightbox = document.querySelector('#lightbox');
|
||||||
const lightboxImage = document.querySelector('#lightbox-image');
|
const lightboxImage = document.querySelector('#lightbox-image');
|
||||||
|
const lightboxCopy = document.querySelector('#lightbox-copy');
|
||||||
const lightboxDownload = document.querySelector('#lightbox-download');
|
const lightboxDownload = document.querySelector('#lightbox-download');
|
||||||
|
const lightboxShare = document.querySelector('#lightbox-share');
|
||||||
const lightboxId = document.querySelector('#lightbox-id');
|
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 scrollIndicator = document.querySelector('#scroll-indicator');
|
||||||
const statStatus = document.querySelector('#stat-status');
|
const actionStatus = document.querySelector('#action-status');
|
||||||
const statLatency = document.querySelector('#stat-latency');
|
|
||||||
const statNodes = document.querySelector('#stat-nodes');
|
|
||||||
const statMemes = document.querySelector('#stat-memes');
|
|
||||||
|
|
||||||
let currentPage = 0;
|
let currentPage = 0;
|
||||||
let totalPages = 1;
|
let totalPages = 1;
|
||||||
let isLoading = false;
|
let isLoading = false;
|
||||||
let latestValidationRun = 0;
|
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', () => {
|
document.querySelector('#open-upload').addEventListener('click', () => {
|
||||||
formStatus.textContent = '';
|
formStatus.textContent = '';
|
||||||
@@ -33,6 +58,23 @@ document.querySelector('#open-upload').addEventListener('click', () => {
|
|||||||
document.querySelector('#close-upload').addEventListener('click', closeUpload);
|
document.querySelector('#close-upload').addEventListener('click', closeUpload);
|
||||||
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
|
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
|
||||||
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
|
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 () => {
|
fileInput.addEventListener('change', async () => {
|
||||||
const validationRun = ++latestValidationRun;
|
const validationRun = ++latestValidationRun;
|
||||||
@@ -54,7 +96,7 @@ uploadForm.addEventListener('submit', async (event) => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
submitUpload.disabled = true;
|
addMemeButton.disabled = true;
|
||||||
formStatus.textContent = 'UPLOADING...';
|
formStatus.textContent = 'UPLOADING...';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -69,25 +111,39 @@ uploadForm.addEventListener('submit', async (event) => {
|
|||||||
} else {
|
} else {
|
||||||
uploadForm.reset();
|
uploadForm.reset();
|
||||||
fileLabel.textContent = 'SELECT PNG / JPEG';
|
fileLabel.textContent = 'SELECT PNG / JPEG';
|
||||||
formStatus.textContent = `${payload.message || 'QUEUED_FOR_REVIEW'} MEME_CONSENSUS_SCORE: ${payload.meme?.moderationScore ?? '--'}`;
|
formStatus.textContent = payload.message || 'Upload queued for admin review.';
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
formStatus.textContent = error.message.toUpperCase();
|
formStatus.textContent = error.message.toUpperCase();
|
||||||
} finally {
|
} finally {
|
||||||
submitUpload.disabled = false;
|
addMemeButton.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
grid.addEventListener('click', async (event) => {
|
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]');
|
const viewButton = event.target.closest('[data-view-id]');
|
||||||
if (!viewButton) return;
|
if (!viewButton) return;
|
||||||
const card = viewButton.closest('.meme-card');
|
const card = viewButton.closest('.meme-card');
|
||||||
lightboxImage.src = viewButton.dataset.viewUrl;
|
await openLightbox({
|
||||||
lightboxImage.alt = card.querySelector('img').alt;
|
id: viewButton.dataset.viewId,
|
||||||
lightboxDownload.href = viewButton.dataset.downloadUrl;
|
url: viewButton.dataset.viewUrl,
|
||||||
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
|
downloadUrl: viewButton.dataset.downloadUrl,
|
||||||
lightbox.showModal();
|
alt: card.querySelector('img').alt,
|
||||||
await recordView(viewButton.dataset.viewId);
|
viewCount: Number.parseInt(viewButton.dataset.viewCount || '0', 10),
|
||||||
|
downloadCount: Number.parseInt(viewButton.dataset.downloadCount || '0', 10)
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const observer = new IntersectionObserver((entries) => {
|
const observer = new IntersectionObserver((entries) => {
|
||||||
@@ -96,12 +152,17 @@ const observer = new IntersectionObserver((entries) => {
|
|||||||
}
|
}
|
||||||
}, { rootMargin: '420px 0px' });
|
}, { rootMargin: '420px 0px' });
|
||||||
|
|
||||||
|
const viewObserver = 'IntersectionObserver' in window
|
||||||
|
? new IntersectionObserver(handleViewIntersections, { threshold: [VIEW_THRESHOLD] })
|
||||||
|
: null;
|
||||||
|
|
||||||
connectLiveCounters();
|
connectLiveCounters();
|
||||||
updateScrollIndicator();
|
updateScrollIndicator();
|
||||||
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
|
||||||
window.addEventListener('resize', updateScrollIndicator);
|
window.addEventListener('resize', updateScrollIndicator);
|
||||||
refreshStatus();
|
refreshStatus();
|
||||||
window.setInterval(refreshStatus, 5000);
|
window.setInterval(refreshStatus, 5000);
|
||||||
|
await openSharedContentFromUrl();
|
||||||
await loadMemes(1, { reset: true });
|
await loadMemes(1, { reset: true });
|
||||||
observer.observe(feedLoader);
|
observer.observe(feedLoader);
|
||||||
|
|
||||||
@@ -113,6 +174,7 @@ async function loadNextPage() {
|
|||||||
async function loadMemes(page, options = {}) {
|
async function loadMemes(page, options = {}) {
|
||||||
if (isLoading) return;
|
if (isLoading) return;
|
||||||
isLoading = true;
|
isLoading = true;
|
||||||
|
const loaderStartedAt = performance.now();
|
||||||
setLoader('FETCHING_NEXT_BLOCK...', true);
|
setLoader('FETCHING_NEXT_BLOCK...', true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -123,6 +185,10 @@ async function loadMemes(page, options = {}) {
|
|||||||
currentPage = payload.page;
|
currentPage = payload.page;
|
||||||
totalPages = payload.totalPages;
|
totalPages = payload.totalPages;
|
||||||
renderMemes(payload.memes, { append: !options.reset });
|
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);
|
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
|
||||||
updateScrollIndicator();
|
updateScrollIndicator();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -134,8 +200,13 @@ async function loadMemes(page, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function delay(milliseconds) {
|
||||||
|
return new Promise((resolve) => window.setTimeout(resolve, milliseconds));
|
||||||
|
}
|
||||||
|
|
||||||
function renderMemes(memes, options = {}) {
|
function renderMemes(memes, options = {}) {
|
||||||
if (!options.append && memes.length === 0) {
|
if (!options.append && memes.length === 0) {
|
||||||
|
unobserveViewedImages();
|
||||||
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
|
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -153,16 +224,18 @@ function renderMemes(memes, options = {}) {
|
|||||||
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
|
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="image-frame">
|
<div class="image-frame">
|
||||||
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy">
|
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy" data-view-observe-id="${meme.id}">
|
||||||
</div>
|
</div>
|
||||||
<div class="card-actions">
|
<div class="card-actions">
|
||||||
<div class="stats">
|
<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>${formatCount(meme.viewCount)}</span>
|
<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>${formatCount(meme.downloadCount)}</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>
|
||||||
<div class="action-group">
|
<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>
|
<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 text-action-accent icon-only-action" type="button" data-view-id="${meme.id}" data-view-url="${meme.url}" data-download-url="${meme.downloadUrl}" aria-label="View full meme" title="View full meme"><span class="material-symbols-outlined" aria-hidden="true">visibility</span></button>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -173,8 +246,263 @@ function renderMemes(memes, options = {}) {
|
|||||||
if (options.append) {
|
if (options.append) {
|
||||||
grid.append(...cards);
|
grid.append(...cards);
|
||||||
} else {
|
} else {
|
||||||
|
unobserveViewedImages();
|
||||||
grid.replaceChildren(...cards);
|
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) {
|
async function validateClientFile(file) {
|
||||||
@@ -184,8 +512,8 @@ async function validateClientFile(file) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const dimensions = await readImageDimensions(file);
|
const dimensions = await readImageDimensions(file);
|
||||||
if (dimensions.width !== dimensions.height) return 'IMAGE MUST BE SQUARE.';
|
if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EDGE EXCEEDS 6000PX.';
|
||||||
if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EXCEEDS 6000x6000.';
|
if (dimensions.width * dimensions.height > MAX_IMAGE_PIXELS) return 'IMAGE EXCEEDS 20MP.';
|
||||||
} catch {
|
} catch {
|
||||||
return 'IMAGE COULD NOT BE INSPECTED.';
|
return 'IMAGE COULD NOT BE INSPECTED.';
|
||||||
}
|
}
|
||||||
@@ -215,13 +543,106 @@ function closeUpload() {
|
|||||||
uploadModal.close();
|
uploadModal.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function recordView(id) {
|
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 {
|
try {
|
||||||
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
|
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
|
||||||
const payload = await response.json();
|
const payload = await response.json();
|
||||||
if (response.ok) updateCounters(payload.meme);
|
if (!response.ok) throw new Error(payload.error || 'View update failed.');
|
||||||
|
updateCounters(payload.meme);
|
||||||
} catch {
|
} catch {
|
||||||
// Live counter updates are best-effort; the lightbox should still open.
|
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.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -235,13 +656,19 @@ function connectLiveCounters() {
|
|||||||
|
|
||||||
function updateCounters(meme) {
|
function updateCounters(meme) {
|
||||||
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
|
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') {
|
if (element.dataset.counterKind === 'view') {
|
||||||
element.lastChild.textContent = formatCount(meme.viewCount);
|
count.textContent = formatCount(meme.viewCount);
|
||||||
}
|
}
|
||||||
if (element.dataset.counterKind === 'download') {
|
if (element.dataset.counterKind === 'download') {
|
||||||
element.lastChild.textContent = formatCount(meme.downloadCount);
|
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) {
|
function setLoader(label, spinning) {
|
||||||
@@ -285,18 +712,39 @@ async function refreshStatus() {
|
|||||||
const response = await fetch('/api/status', { cache: 'no-store' });
|
const response = await fetch('/api/status', { cache: 'no-store' });
|
||||||
const status = await response.json();
|
const status = await response.json();
|
||||||
if (!response.ok) throw new Error('status failed');
|
if (!response.ok) throw new Error('status failed');
|
||||||
statStatus.textContent = status.ok ? 'ONLINE' : 'DEGRADED';
|
setStat('status', `${status.ok ? 'ONLINE' : 'DEGRADED'} ${formatUptime(status.uptimeSeconds)}`);
|
||||||
statLatency.textContent = `${Math.max(1, Math.round(performance.now() - started))}MS`;
|
setStat('latency', `${Math.max(1, Math.round(performance.now() - started))}MS`);
|
||||||
statNodes.textContent = formatCount(status.liveClients);
|
setStat('nodes', formatCount(status.liveClients));
|
||||||
statMemes.textContent = formatCount(status.memeCount);
|
setStat('memes', formatCount(status.memeCount));
|
||||||
} catch {
|
} catch {
|
||||||
statStatus.textContent = 'OFFLINE';
|
setStat('status', 'OFFLINE');
|
||||||
statLatency.textContent = '--MS';
|
setStat('latency', '--MS');
|
||||||
statNodes.textContent = '--';
|
setStat('nodes', '--');
|
||||||
statMemes.textContent = '--';
|
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() {
|
function updateScrollIndicator() {
|
||||||
const segments = [...scrollIndicator.querySelectorAll('span')];
|
const segments = [...scrollIndicator.querySelectorAll('span')];
|
||||||
const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
|
const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
|
||||||
|
|||||||
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 4.1 MiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 106 KiB |
|
After Width: | Height: | Size: 204 KiB |
@@ -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.
|
||||||
|
After Width: | Height: | Size: 226 KiB |
@@ -18,6 +18,7 @@
|
|||||||
--error: #ffb4ab;
|
--error: #ffb4ab;
|
||||||
--gutter: 16px;
|
--gutter: 16px;
|
||||||
--desktop-margin: 32px;
|
--desktop-margin: 32px;
|
||||||
|
--footer-height: 44px;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
@@ -29,6 +30,10 @@ body {
|
|||||||
min-height: 100%;
|
min-height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
scroll-padding-bottom: calc(var(--footer-height) + 24px);
|
||||||
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
background: var(--void);
|
background: var(--void);
|
||||||
@@ -86,6 +91,15 @@ a {
|
|||||||
letter-spacing: 0.2em;
|
letter-spacing: 0.2em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-link {
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-link:hover,
|
||||||
|
.brand-link:focus-visible {
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.primary-action,
|
.primary-action,
|
||||||
.secondary-action,
|
.secondary-action,
|
||||||
.icon-action {
|
.icon-action {
|
||||||
@@ -148,7 +162,7 @@ a {
|
|||||||
.shell {
|
.shell {
|
||||||
width: min(100%, 1440px);
|
width: min(100%, 1440px);
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 96px var(--desktop-margin) 48px;
|
padding: 96px var(--desktop-margin) calc(var(--footer-height) + 16px + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-line {
|
.status-line {
|
||||||
@@ -162,6 +176,33 @@ a {
|
|||||||
border-bottom: 1px solid var(--outline-variant);
|
border-bottom: 1px solid var(--outline-variant);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.protocol-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-menu a,
|
||||||
|
.protocol-menu button {
|
||||||
|
appearance: none;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
letter-spacing: inherit;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.protocol-menu a:hover,
|
||||||
|
.protocol-menu a:focus-visible,
|
||||||
|
.protocol-menu button:hover,
|
||||||
|
.protocol-menu button:focus-visible {
|
||||||
|
color: var(--primary-dim);
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
.live {
|
.live {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -296,6 +337,7 @@ a {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
display: block;
|
display: block;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
|
object-position: center;
|
||||||
opacity: 0.9;
|
opacity: 0.9;
|
||||||
transition: transform 700ms ease, opacity 200ms ease;
|
transition: transform 700ms ease, opacity 200ms ease;
|
||||||
}
|
}
|
||||||
@@ -338,12 +380,14 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.text-action {
|
.text-action {
|
||||||
|
appearance: none;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 8px 10px;
|
padding: 8px 10px;
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid #1a1a1a;
|
||||||
|
border-radius: 0;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
@@ -370,14 +414,25 @@ a {
|
|||||||
background: var(--surface-high);
|
background: var(--surface-high);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.text-action.is-copied {
|
||||||
|
border-color: var(--primary);
|
||||||
|
background: rgb(0 255 65 / 12%);
|
||||||
|
color: var(--primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-action:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
.feed-loader {
|
.feed-loader {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 16px;
|
gap: 12px;
|
||||||
min-height: 104px;
|
min-height: 64px;
|
||||||
margin-top: 24px;
|
margin-top: 16px;
|
||||||
color: var(--outline);
|
color: var(--outline);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -386,23 +441,64 @@ a {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.loader-ring {
|
.loader-ring {
|
||||||
|
display: none;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 2px solid transparent;
|
border: 2px solid transparent;
|
||||||
border-top-color: var(--primary);
|
border-top-color: var(--primary);
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
opacity: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.feed-loader.is-spinning .loader-ring {
|
.feed-loader.is-spinning .loader-ring {
|
||||||
|
display: block;
|
||||||
animation: spin 850ms linear infinite;
|
animation: spin 850ms linear infinite;
|
||||||
opacity: 1;
|
}
|
||||||
|
|
||||||
|
.repo-footer {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 20;
|
||||||
|
inset: auto 0 0;
|
||||||
|
display: flex;
|
||||||
|
min-height: calc(var(--footer-height) + env(safe-area-inset-bottom));
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 16px env(safe-area-inset-bottom);
|
||||||
|
border-top: 1px solid var(--outline-variant);
|
||||||
|
background: var(--void);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-footer a {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
|
opacity: 0.45;
|
||||||
|
text-decoration: none;
|
||||||
|
text-transform: uppercase;
|
||||||
|
transition: color 120ms ease, opacity 120ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-footer a:hover,
|
||||||
|
.repo-footer a:focus-visible {
|
||||||
|
color: var(--primary-dim);
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.repo-footer img {
|
||||||
|
width: auto;
|
||||||
|
height: 20px;
|
||||||
|
object-fit: contain;
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.side-terminal {
|
.side-terminal {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: 16px;
|
left: 16px;
|
||||||
top: 50%;
|
top: calc(64px + (100vh - 64px) / 2);
|
||||||
transform: translateY(-50%);
|
transform: translateY(-50%);
|
||||||
width: 192px;
|
width: 192px;
|
||||||
padding-left: 8px;
|
padding-left: 8px;
|
||||||
@@ -419,10 +515,31 @@ a {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.terminal-strip {
|
||||||
|
display: none;
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 10px 0;
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-strip strong {
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-strip .active {
|
||||||
|
color: var(--primary-dim);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
.scroll-indicator {
|
.scroll-indicator {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
right: 16px;
|
right: 16px;
|
||||||
top: 50%;
|
top: calc(64px + (100vh - 64px) / 2);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
@@ -467,6 +584,14 @@ dialog::backdrop {
|
|||||||
box-shadow: 0 0 18px rgb(0 255 65 / 12%);
|
box-shadow: 0 0 18px rgb(0 255 65 / 12%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-panel {
|
||||||
|
width: min(640px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-panel {
|
||||||
|
width: min(580px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header,
|
.modal-header,
|
||||||
.lightbox-toolbar {
|
.lightbox-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -478,6 +603,15 @@ dialog::backdrop {
|
|||||||
background: var(--surface);
|
background: var(--surface);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lightbox-toolbar > span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lightbox-toolbar .icon-action {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-header h2 {
|
.modal-header h2 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: Geist, Inter, system-ui, sans-serif;
|
font-family: Geist, Inter, system-ui, sans-serif;
|
||||||
@@ -520,6 +654,142 @@ dialog::backdrop {
|
|||||||
color: var(--error);
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-body {
|
||||||
|
padding: 18px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-body label,
|
||||||
|
.share-status {
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-row input {
|
||||||
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
height: 40px;
|
||||||
|
padding: 0 12px;
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
border-radius: 0;
|
||||||
|
outline: 0;
|
||||||
|
background: var(--surface-lowest);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-link-row input:focus {
|
||||||
|
border-color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy:disabled {
|
||||||
|
cursor: wait;
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-status {
|
||||||
|
min-height: 16px;
|
||||||
|
margin: 10px 0 0;
|
||||||
|
color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-downloads {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-prompt {
|
||||||
|
margin: 0 0 4px;
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-footnote {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 9px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-footnote a {
|
||||||
|
color: var(--muted);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-footnote a:hover,
|
||||||
|
.pet-footnote a:focus-visible {
|
||||||
|
color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-download {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
border: 1px solid var(--outline-variant);
|
||||||
|
background: var(--surface-lowest);
|
||||||
|
color: var(--muted);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-download:hover,
|
||||||
|
.pet-download:focus-visible {
|
||||||
|
border-color: var(--primary-dim);
|
||||||
|
background: var(--surface-high);
|
||||||
|
color: var(--primary-dim);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-download > span:first-child {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-download strong,
|
||||||
|
.pet-download small {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-download strong {
|
||||||
|
font-size: 12px;
|
||||||
|
letter-spacing: 0.1em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-download small {
|
||||||
|
color: var(--outline);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pet-download > .material-symbols-outlined {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-actions {
|
.modal-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -575,20 +845,85 @@ dialog::backdrop {
|
|||||||
box-shadow: 0 0 24px rgb(0 255 65 / 14%);
|
box-shadow: 0 0 24px rgb(0 255 65 / 14%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.lightbox[open] {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
.lightbox img {
|
.lightbox img {
|
||||||
display: block;
|
display: block;
|
||||||
width: 100%;
|
width: auto;
|
||||||
max-height: calc(100vh - 144px);
|
max-width: 100%;
|
||||||
|
max-height: calc(100vh - 176px);
|
||||||
|
margin: 0 auto;
|
||||||
object-fit: contain;
|
object-fit: contain;
|
||||||
background: #000;
|
background: #000;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lightbox-download {
|
.lightbox-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: max-content;
|
|
||||||
margin: 12px 12px 12px auto;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 6px;
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
margin: 12px 12px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lorebox {
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.lore-text {
|
||||||
|
min-height: 260px;
|
||||||
|
max-height: calc(100vh - 176px);
|
||||||
|
margin: 0;
|
||||||
|
overflow: auto;
|
||||||
|
padding: 24px;
|
||||||
|
background: #080808;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.7;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lore-actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.viewer-status {
|
||||||
|
min-height: 16px;
|
||||||
|
margin: 0 12px 8px;
|
||||||
|
color: var(--primary-dim);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-toast {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 19;
|
||||||
|
right: 16px;
|
||||||
|
bottom: calc(var(--footer-height) + 12px + env(safe-area-inset-bottom));
|
||||||
|
max-width: calc(100vw - 32px);
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--surface);
|
||||||
|
color: var(--primary-dim);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 600;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-toast:empty {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clipboard-fallback {
|
||||||
|
position: fixed;
|
||||||
|
inset: auto auto 0 -9999px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state {
|
.empty-state {
|
||||||
@@ -599,6 +934,75 @@ dialog::backdrop {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.not-found-page {
|
||||||
|
min-height: 100svh;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto 1fr;
|
||||||
|
min-height: 100svh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-content {
|
||||||
|
display: grid;
|
||||||
|
width: min(100%, 760px);
|
||||||
|
justify-items: center;
|
||||||
|
place-self: center;
|
||||||
|
padding: 24px 0 48px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-visual {
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
width: clamp(127px, 26vmin, 276px);
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-page .pulse {
|
||||||
|
background: #ff453a;
|
||||||
|
box-shadow: 0 0 8px rgb(255 69 58 / 55%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-visual::before {
|
||||||
|
position: absolute;
|
||||||
|
z-index: -1;
|
||||||
|
inset: 7% 5% 6%;
|
||||||
|
border-radius: 50%;
|
||||||
|
background:
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% 47%,
|
||||||
|
rgb(0 255 65 / 48%) 0%,
|
||||||
|
rgb(0 230 57 / 30%) 36%,
|
||||||
|
rgb(0 128 33 / 14%) 58%,
|
||||||
|
transparent 76%
|
||||||
|
),
|
||||||
|
radial-gradient(
|
||||||
|
ellipse at 50% 48%,
|
||||||
|
rgb(0 255 65 / 14%) 0%,
|
||||||
|
transparent 72%
|
||||||
|
);
|
||||||
|
content: "";
|
||||||
|
filter: blur(36px);
|
||||||
|
transform: scale(1.24);
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-visual img {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
filter: contrast(1.06) drop-shadow(0 0 16px rgb(0 255 65 / 16%));
|
||||||
|
}
|
||||||
|
|
||||||
|
.not-found-action {
|
||||||
|
display: inline-flex;
|
||||||
|
min-height: 40px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
[hidden] {
|
[hidden] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -635,6 +1039,25 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1759px) and (min-width: 901px) {
|
||||||
|
.side-terminal {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.terminal-strip {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1760px) {
|
||||||
|
.terminal-strip {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.topbar,
|
.topbar,
|
||||||
.shell {
|
.shell {
|
||||||
@@ -647,6 +1070,7 @@ dialog::backdrop {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.side-terminal,
|
.side-terminal,
|
||||||
|
.terminal-strip,
|
||||||
.scroll-indicator {
|
.scroll-indicator {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -674,6 +1098,15 @@ dialog::backdrop {
|
|||||||
padding-inline: 14px;
|
padding-inline: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.share-link-row {
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-copy {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.shell {
|
.shell {
|
||||||
padding-top: 84px;
|
padding-top: 84px;
|
||||||
}
|
}
|
||||||
@@ -693,3 +1126,9 @@ dialog::backdrop {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.not-found-page .pulse {
|
||||||
|
animation: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 43 KiB |
@@ -3,39 +3,87 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>The Meme Protocol</title>
|
<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.googleapis.com">
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
<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 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">
|
<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>
|
<script type="module" src="/assets/app.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<header class="topbar">
|
||||||
<div class="brand">THE_MEME_PROTOCOL</div>
|
<div class="brand">THE_MEME_PROTOCOL</div>
|
||||||
<button class="primary-action" id="open-upload" type="button">SUBMIT</button>
|
<button class="primary-action" id="open-upload" type="button">ADD MEME</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<main class="shell">
|
<main class="shell">
|
||||||
<div class="status-line" aria-live="polite">
|
<div class="status-line" aria-live="polite">
|
||||||
<span>PROTOCOL_STREAM_V.1.04</span>
|
<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>
|
<span class="live"><span class="pulse"></span>LIVE_FEED</span>
|
||||||
</div>
|
</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>
|
<section class="meme-grid" id="meme-grid" aria-label="Meme feed"></section>
|
||||||
|
|
||||||
<div class="feed-loader" id="feed-loader" aria-live="polite">
|
<div class="feed-loader" id="feed-loader" aria-live="polite">
|
||||||
<div class="loader-ring" aria-hidden="true"></div>
|
<div class="loader-ring" aria-hidden="true"></div>
|
||||||
<span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span>
|
<span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</main>
|
</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">
|
<aside class="side-terminal" aria-live="polite">
|
||||||
<div>STATUS: <span id="stat-status">SYNCING</span></div>
|
<div>STATUS: <span data-stat="status">SYNCING</span></div>
|
||||||
<div>LATENCY: <span id="stat-latency">--MS</span></div>
|
<div>LATENCY: <span data-stat="latency">--MS</span></div>
|
||||||
<div>NODES: <span id="stat-nodes">--</span></div>
|
<div>NODES: <span data-stat="nodes">--</span></div>
|
||||||
<div>MEMES: <span id="stat-memes">--</span></div>
|
<div>MEMES: <span data-stat="memes">--</span></div>
|
||||||
<div class="active" id="stat-pun">MEMETICALLY_ACTIVE</div>
|
<div class="active">MEMETICALLY_ACTIVE</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div class="scroll-indicator" id="scroll-indicator" aria-hidden="true">
|
<div class="scroll-indicator" id="scroll-indicator" aria-hidden="true">
|
||||||
@@ -45,7 +93,7 @@
|
|||||||
<dialog class="modal" id="upload-modal" aria-labelledby="upload-title">
|
<dialog class="modal" id="upload-modal" aria-labelledby="upload-title">
|
||||||
<form class="modal-panel" id="upload-form" method="dialog">
|
<form class="modal-panel" id="upload-form" method="dialog">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 id="upload-title">SUBMIT_MEME</h2>
|
<h2 id="upload-title">ADD_MEME</h2>
|
||||||
<button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button>
|
<button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button>
|
||||||
</div>
|
</div>
|
||||||
<label class="file-drop" for="meme-input">
|
<label class="file-drop" for="meme-input">
|
||||||
@@ -54,13 +102,15 @@
|
|||||||
</label>
|
</label>
|
||||||
<div class="upload-rules">
|
<div class="upload-rules">
|
||||||
<span>MAX_SIZE: 5MB</span>
|
<span>MAX_SIZE: 5MB</span>
|
||||||
<span>REQUIRED_RATIO: 1:1 SQUARE</span>
|
<span>RATIO: ANY</span>
|
||||||
<span>OUTPUT_FORMAT: WEBP</span>
|
<span>OUTPUT_FORMAT: WEBP</span>
|
||||||
<span>MAX_DIMENSIONS: 6000x6000</span>
|
<span>MAX_INPUT_EDGE: 6000PX</span>
|
||||||
|
<span>MAX_PIXELS: 20MP</span>
|
||||||
|
<span>OUTPUT_MAX_EDGE: 1600PX</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button class="secondary-action" id="cancel-upload" type="button">CANCEL</button>
|
<button class="secondary-action" id="cancel-upload" type="button">CANCEL</button>
|
||||||
<button class="primary-action" id="submit-upload" type="submit">UPLOAD</button>
|
<button class="primary-action" id="add-meme-button" type="submit">ADD MEME</button>
|
||||||
</div>
|
</div>
|
||||||
<p class="form-status" id="form-status" role="status"></p>
|
<p class="form-status" id="form-status" role="status"></p>
|
||||||
</form>
|
</form>
|
||||||
@@ -72,7 +122,83 @@
|
|||||||
<button class="icon-action" id="close-lightbox" type="button" aria-label="Close">X</button>
|
<button class="icon-action" id="close-lightbox" type="button" aria-label="Close">X</button>
|
||||||
</div>
|
</div>
|
||||||
<img id="lightbox-image" alt="">
|
<img id="lightbox-image" alt="">
|
||||||
<a class="primary-action lightbox-download" id="lightbox-download" href="#"><span class="material-symbols-outlined" aria-hidden="true">download</span><span>DOWNLOAD</span></a>
|
<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>
|
||||||
|
|
||||||
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -1,14 +1,19 @@
|
|||||||
import http from 'node:http';
|
import http from 'node:http';
|
||||||
import crypto from 'node:crypto';
|
import crypto from 'node:crypto';
|
||||||
|
import fs from 'node:fs/promises';
|
||||||
import { URL } from 'node:url';
|
import { URL } from 'node:url';
|
||||||
import { parseMultipartUpload } from './src/multipart.js';
|
import { parseMultipartUpload } from './src/multipart.js';
|
||||||
import { sendFile, sendJson, sendText, withSecurityHeaders } from './src/http.js';
|
import { sendFile, sendHtml, sendJson, sendText, withSecurityHeaders } from './src/http.js';
|
||||||
import { createStore } from './src/store.js';
|
import { createStore } from './src/store.js';
|
||||||
import { validateImage } from './src/image.js';
|
import { validateImage } from './src/image.js';
|
||||||
import { normalizeToWebp } from './src/normalize.js';
|
import { normalizeToWebp } from './src/normalize.js';
|
||||||
import { seedDemoMemes } from './src/seed.js';
|
import { seedDemoMemes } from './src/seed.js';
|
||||||
import { moderateImage } from './src/moderation.js';
|
import { moderateImage } from './src/moderation.js';
|
||||||
import { createUploadLimiter } from './src/uploadLimits.js';
|
import { createUploadLimiter } from './src/uploadLimits.js';
|
||||||
|
import { createIpRateLimiter } from './src/rateLimits.js';
|
||||||
|
import { feedJson, llmsTxt, manifest, noIndex, publicBaseUrl, renderIndex, renderNotFound, robotsTxt, sitemapXml } from './src/seo.js';
|
||||||
|
|
||||||
|
await loadDotEnv();
|
||||||
|
|
||||||
const PORT = Number.parseInt(process.env.PORT || '8080', 10);
|
const PORT = Number.parseInt(process.env.PORT || '8080', 10);
|
||||||
const HOST = process.env.HOST || '0.0.0.0';
|
const HOST = process.env.HOST || '0.0.0.0';
|
||||||
@@ -16,11 +21,40 @@ const DATA_DIR = process.env.DATA_DIR || './data';
|
|||||||
const PAGE_SIZE_MAX = 48;
|
const PAGE_SIZE_MAX = 48;
|
||||||
const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
|
||||||
const REQUEST_MAX_BYTES = 6 * 1024 * 1024;
|
const REQUEST_MAX_BYTES = 6 * 1024 * 1024;
|
||||||
|
const API_READ_LIMIT_PER_MINUTE = 120;
|
||||||
|
const MEDIA_READ_LIMIT_PER_MINUTE = 600;
|
||||||
|
const SSE_HEARTBEAT_MS = 25_000;
|
||||||
|
const 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 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 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 store = await createStore({ dataDir: DATA_DIR });
|
||||||
const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR });
|
const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR });
|
||||||
|
const apiReadLimiter = createIpRateLimiter({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: API_READ_LIMIT_PER_MINUTE,
|
||||||
|
message: 'API read limit exceeded. Try again shortly.'
|
||||||
|
});
|
||||||
|
const mediaReadLimiter = createIpRateLimiter({
|
||||||
|
windowMs: 60_000,
|
||||||
|
max: MEDIA_READ_LIMIT_PER_MINUTE,
|
||||||
|
message: 'Media read limit exceeded. Try again shortly.'
|
||||||
|
});
|
||||||
if (process.env.SEED_DEMO_MEMES !== 'false') {
|
if (process.env.SEED_DEMO_MEMES !== 'false') {
|
||||||
await seedDemoMemes(store);
|
await seedDemoMemes(store);
|
||||||
}
|
}
|
||||||
@@ -29,32 +63,118 @@ if (!process.env.ADMIN_TOKEN) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer(async (req, res) => {
|
const server = http.createServer(async (req, res) => {
|
||||||
withSecurityHeaders(res);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
|
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 (req.method === 'GET' && url.pathname === '/') {
|
if (isPublicRead && url.pathname === '/') {
|
||||||
return sendFile(res, './public/index.html');
|
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})$/);
|
const adminPageMatch = url.pathname.match(/^\/admin\/([A-Za-z0-9_-]{24,128})$/);
|
||||||
if (req.method === 'GET' && adminPageMatch) {
|
if (req.method === 'GET' && adminPageMatch) {
|
||||||
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
|
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
|
||||||
|
noIndex(res);
|
||||||
return sendFile(res, './public/admin.html');
|
return sendFile(res, './public/admin.html');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
|
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}`);
|
return sendFile(res, `./public${url.pathname}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/memes') {
|
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 page = positiveInt(url.searchParams.get('page'), 1);
|
||||||
const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX);
|
const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX);
|
||||||
return sendJson(res, 200, store.list({ page, pageSize }));
|
return sendJson(res, 200, {
|
||||||
|
...store.list({ page, pageSize }),
|
||||||
|
rateLimit: readQuota
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/status') {
|
if (req.method === 'GET' && url.pathname === '/api/status') {
|
||||||
|
noIndex(res);
|
||||||
|
checkReadLimit(req, res, apiReadLimiter);
|
||||||
return sendJson(res, 200, {
|
return sendJson(res, 200, {
|
||||||
ok: true,
|
ok: true,
|
||||||
memeCount: store.count('approved'),
|
memeCount: store.count('approved'),
|
||||||
@@ -64,11 +184,13 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/admin/pending') {
|
if (req.method === 'GET' && url.pathname === '/api/admin/pending') {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
||||||
return sendJson(res, 200, store.listForReview({ status: 'pending' }));
|
return sendJson(res, 200, store.listForReview({ status: 'pending' }));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/admin/approve') {
|
if (req.method === 'POST' && url.pathname === '/api/admin/approve') {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
||||||
const body = await readJsonBody(req, 64 * 1024);
|
const body = await readJsonBody(req, 64 * 1024);
|
||||||
const approved = await store.approve(safeIds(body.ids));
|
const approved = await store.approve(safeIds(body.ids));
|
||||||
@@ -76,6 +198,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/admin/delete') {
|
if (req.method === 'POST' && url.pathname === '/api/admin/delete') {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
|
||||||
const body = await readJsonBody(req, 64 * 1024);
|
const body = await readJsonBody(req, 64 * 1024);
|
||||||
const deleted = await store.delete(safeIds(body.ids));
|
const deleted = await store.delete(safeIds(body.ids));
|
||||||
@@ -83,63 +206,47 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/api/events') {
|
if (req.method === 'GET' && url.pathname === '/api/events') {
|
||||||
|
noIndex(res);
|
||||||
res.writeHead(200, {
|
res.writeHead(200, {
|
||||||
'Content-Type': 'text/event-stream; charset=utf-8',
|
'Content-Type': 'text/event-stream; charset=utf-8',
|
||||||
'Cache-Control': 'no-cache, no-transform',
|
'Cache-Control': 'no-cache, no-transform',
|
||||||
Connection: 'keep-alive'
|
Connection: 'keep-alive',
|
||||||
|
'X-Accel-Buffering': 'no'
|
||||||
});
|
});
|
||||||
|
res.write('retry: 5000\n');
|
||||||
res.write('event: ready\ndata: {}\n\n');
|
res.write('event: ready\ndata: {}\n\n');
|
||||||
events.add(res);
|
events.add(res);
|
||||||
req.on('close', () => events.delete(res));
|
const heartbeat = setInterval(() => {
|
||||||
|
res.write(': keep-alive\n\n');
|
||||||
|
}, SSE_HEARTBEAT_MS);
|
||||||
|
req.on('close', () => {
|
||||||
|
clearInterval(heartbeat);
|
||||||
|
events.delete(res);
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'POST' && url.pathname === '/api/memes') {
|
if (req.method === 'POST' && url.pathname === '/api/memes') {
|
||||||
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
|
noIndex(res);
|
||||||
if (!Number.isFinite(contentLength) || contentLength <= 0) {
|
const { meme, quota } = await addMeme(req);
|
||||||
return sendJson(res, 411, { error: 'Missing upload size.' });
|
|
||||||
}
|
|
||||||
if (contentLength > REQUEST_MAX_BYTES) {
|
|
||||||
return sendJson(res, 413, { error: 'Upload request is too large.' });
|
|
||||||
}
|
|
||||||
const quota = await uploadLimiter.checkAndConsume(clientIp(req));
|
|
||||||
|
|
||||||
const upload = await parseMultipartUpload(req, {
|
|
||||||
maxRequestBytes: REQUEST_MAX_BYTES,
|
|
||||||
maxFileBytes: UPLOAD_MAX_BYTES,
|
|
||||||
fieldName: 'meme'
|
|
||||||
});
|
|
||||||
const image = validateImage(upload.buffer, {
|
|
||||||
maxBytes: UPLOAD_MAX_BYTES,
|
|
||||||
maxWidth: 6000,
|
|
||||||
maxHeight: 6000,
|
|
||||||
maxPixels: 20_000_000,
|
|
||||||
requireSquare: true
|
|
||||||
});
|
|
||||||
const normalized = await normalizeToWebp(upload.buffer);
|
|
||||||
const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime });
|
|
||||||
if (moderation.status === 'rejected') {
|
|
||||||
return sendJson(res, 422, {
|
|
||||||
error: `Upload rejected: ${moderation.reason}`,
|
|
||||||
moderationScore: moderation.score
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const meme = await store.save({
|
|
||||||
buffer: normalized.buffer,
|
|
||||||
image: normalized.image,
|
|
||||||
originalName: upload.filename,
|
|
||||||
originalMime: image.mime,
|
|
||||||
status: moderation.status,
|
|
||||||
moderationScore: moderation.score,
|
|
||||||
moderationReason: moderation.reason
|
|
||||||
});
|
|
||||||
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
|
return sendJson(res, meme.status === 'approved' ? 201 : 202, {
|
||||||
meme,
|
meme,
|
||||||
quota,
|
quota,
|
||||||
|
moderationScore: meme.moderationScore,
|
||||||
|
moderationReason: meme.moderationReason,
|
||||||
message: meme.status === 'approved' ? 'Upload approved.' : 'Upload queued for admin review.'
|
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$/);
|
const viewMatch = url.pathname.match(/^\/api\/memes\/([a-f0-9]{64})\/view$/);
|
||||||
if (req.method === 'POST' && viewMatch) {
|
if (req.method === 'POST' && viewMatch) {
|
||||||
if (store.get(viewMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
if (store.get(viewMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
@@ -150,7 +257,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
|
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && mediaMatch) {
|
if (isPublicRead && mediaMatch) {
|
||||||
|
checkReadLimit(req, res, mediaReadLimiter);
|
||||||
const meme = store.get(mediaMatch[1]);
|
const meme = store.get(mediaMatch[1]);
|
||||||
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
res.setHeader('Content-Type', meme.mime);
|
res.setHeader('Content-Type', meme.mime);
|
||||||
@@ -162,6 +270,7 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
const adminMediaMatch = url.pathname.match(/^\/admin-media\/([A-Za-z0-9_-]{24,128})\/([a-f0-9]{64})$/);
|
const adminMediaMatch = url.pathname.match(/^\/admin-media\/([A-Za-z0-9_-]{24,128})\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && adminMediaMatch) {
|
if (req.method === 'GET' && adminMediaMatch) {
|
||||||
|
noIndex(res);
|
||||||
if (!isAdminToken(adminMediaMatch[1])) return sendText(res, 404, 'Not found');
|
if (!isAdminToken(adminMediaMatch[1])) return sendText(res, 404, 'Not found');
|
||||||
const meme = store.get(adminMediaMatch[2]);
|
const meme = store.get(adminMediaMatch[2]);
|
||||||
if (!meme) return sendText(res, 404, 'Not found');
|
if (!meme) return sendText(res, 404, 'Not found');
|
||||||
@@ -174,6 +283,8 @@ const server = http.createServer(async (req, res) => {
|
|||||||
|
|
||||||
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
|
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
|
||||||
if (req.method === 'GET' && downloadMatch) {
|
if (req.method === 'GET' && downloadMatch) {
|
||||||
|
noIndex(res);
|
||||||
|
checkReadLimit(req, res, mediaReadLimiter);
|
||||||
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
|
||||||
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
|
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
|
||||||
const meme = store.get(downloadMatch[1]);
|
const meme = store.get(downloadMatch[1]);
|
||||||
@@ -185,14 +296,27 @@ const server = http.createServer(async (req, res) => {
|
|||||||
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
|
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (req.method === 'GET' && url.pathname === '/healthz') {
|
if (isPublicRead && url.pathname === '/healthz') {
|
||||||
|
noIndex(res);
|
||||||
return sendJson(res, 200, { ok: true });
|
return sendJson(res, 200, { ok: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isBrowserPageRequest(req, url)) {
|
||||||
|
return sendNotFoundPage(res, { baseUrl, pathname: url.pathname });
|
||||||
|
}
|
||||||
return sendText(res, 404, 'Not found');
|
return sendText(res, 404, 'Not found');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const status = error.statusCode || 500;
|
const status = error.statusCode || 500;
|
||||||
const message = status === 500 ? 'Internal server error.' : error.message;
|
const message = status === 500 ? 'Internal server error.' : error.message;
|
||||||
|
if (status === 429 && error.retryAfterSeconds) {
|
||||||
|
res.setHeader('Retry-After', String(error.retryAfterSeconds));
|
||||||
|
}
|
||||||
|
if (status === 429 && error.rateLimit) {
|
||||||
|
setRateLimitHeaders(res, error.rateLimit);
|
||||||
|
}
|
||||||
|
if (error.payload && typeof error.payload === 'object') {
|
||||||
|
return sendJson(res, status, error.payload);
|
||||||
|
}
|
||||||
return sendJson(res, status, { error: message });
|
return sendJson(res, status, { error: message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -206,6 +330,75 @@ function positiveInt(value, fallback) {
|
|||||||
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
|
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) {
|
function downloadName(meme) {
|
||||||
return `meme-protocol-${meme.id.slice(0, 12)}.${meme.ext}`;
|
return `meme-protocol-${meme.id.slice(0, 12)}.${meme.ext}`;
|
||||||
}
|
}
|
||||||
@@ -221,6 +414,88 @@ function broadcastMetric(meme) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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) {
|
function isAdminRequest(req) {
|
||||||
return isAdminToken(req.headers['x-admin-token'] || '');
|
return isAdminToken(req.headers['x-admin-token'] || '');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,28 @@ const TYPES = new Map([
|
|||||||
['.html', 'text/html; charset=utf-8'],
|
['.html', 'text/html; charset=utf-8'],
|
||||||
['.css', 'text/css; charset=utf-8'],
|
['.css', 'text/css; charset=utf-8'],
|
||||||
['.js', 'text/javascript; charset=utf-8'],
|
['.js', 'text/javascript; charset=utf-8'],
|
||||||
|
['.txt', 'text/plain; charset=utf-8'],
|
||||||
['.png', 'image/png'],
|
['.png', 'image/png'],
|
||||||
['.jpg', 'image/jpeg'],
|
['.jpg', 'image/jpeg'],
|
||||||
['.jpeg', 'image/jpeg'],
|
['.jpeg', 'image/jpeg'],
|
||||||
['.webp', 'image/webp'],
|
['.webp', 'image/webp'],
|
||||||
|
['.svg', 'image/svg+xml'],
|
||||||
|
['.zip', 'application/zip'],
|
||||||
['.ico', 'image/x-icon']
|
['.ico', 'image/x-icon']
|
||||||
]);
|
]);
|
||||||
|
|
||||||
export function withSecurityHeaders(res) {
|
export function withSecurityHeaders(res, options = {}) {
|
||||||
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
|
||||||
res.setHeader('Referrer-Policy', 'same-origin');
|
res.setHeader('Referrer-Policy', 'same-origin');
|
||||||
res.setHeader('X-Content-Type-Options', 'nosniff');
|
res.setHeader('X-Content-Type-Options', 'nosniff');
|
||||||
res.setHeader('X-Frame-Options', 'DENY');
|
res.setHeader('X-Frame-Options', 'DENY');
|
||||||
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
|
||||||
|
if (options.contentSecurityPolicy === false) return;
|
||||||
|
|
||||||
|
const scriptSrc = options.scriptNonce ? `script-src 'self' 'nonce-${options.scriptNonce}'; ` : '';
|
||||||
res.setHeader(
|
res.setHeader(
|
||||||
'Content-Security-Policy',
|
'Content-Security-Policy',
|
||||||
"default-src 'self'; 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'"
|
`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'`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,16 +37,25 @@ export function sendJson(res, statusCode, payload) {
|
|||||||
'Content-Type': 'application/json; charset=utf-8',
|
'Content-Type': 'application/json; charset=utf-8',
|
||||||
'Content-Length': String(body.length)
|
'Content-Length': String(body.length)
|
||||||
});
|
});
|
||||||
res.end(body);
|
endBody(res, body);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sendText(res, statusCode, message) {
|
export function sendText(res, statusCode, message, contentType = 'text/plain; charset=utf-8') {
|
||||||
const body = Buffer.from(message);
|
const body = Buffer.from(message);
|
||||||
res.writeHead(statusCode, {
|
res.writeHead(statusCode, {
|
||||||
'Content-Type': 'text/plain; charset=utf-8',
|
'Content-Type': contentType,
|
||||||
'Content-Length': String(body.length)
|
'Content-Length': String(body.length)
|
||||||
});
|
});
|
||||||
res.end(body);
|
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 = {}) {
|
export function sendFile(res, filePath, options = {}) {
|
||||||
@@ -60,8 +75,15 @@ export function sendFile(res, filePath, options = {}) {
|
|||||||
if (!res.getHeader('Content-Length')) {
|
if (!res.getHeader('Content-Length')) {
|
||||||
res.setHeader('Content-Length', String(stats.size));
|
res.setHeader('Content-Length', String(stats.size));
|
||||||
}
|
}
|
||||||
|
if (res.req?.method === 'HEAD') {
|
||||||
|
return res.end();
|
||||||
|
}
|
||||||
fs.createReadStream(resolved)
|
fs.createReadStream(resolved)
|
||||||
.on('error', () => sendText(res, 500, 'File read failed'))
|
.on('error', () => sendText(res, 500, 'File read failed'))
|
||||||
.pipe(res);
|
.pipe(res);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function endBody(res, body) {
|
||||||
|
return res.req?.method === 'HEAD' ? res.end() : res.end(body);
|
||||||
|
}
|
||||||
|
|||||||
@@ -23,10 +23,6 @@ export function validateImage(buffer, limits) {
|
|||||||
if (image.width * image.height > limits.maxPixels) {
|
if (image.width * image.height > limits.maxPixels) {
|
||||||
throw new HttpError(413, 'Image has too many pixels.');
|
throw new HttpError(413, 'Image has too many pixels.');
|
||||||
}
|
}
|
||||||
if (limits.requireSquare && image.width !== image.height) {
|
|
||||||
throw new HttpError(422, 'Image must be square.');
|
|
||||||
}
|
|
||||||
|
|
||||||
return { ...image, byteSize: buffer.length };
|
return { ...image, byteSize: buffer.length };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
const DEFAULT_MODEL = process.env.OPENAI_MODERATION_MODEL || 'gpt-4o-mini';
|
const DEFAULT_MODEL = process.env.OPENAI_MODERATION_MODEL || 'gpt-4o-mini';
|
||||||
|
const DEFAULT_AUTO_APPROVE_MIN_SCORE = 80;
|
||||||
|
|
||||||
export async function moderateImage({ buffer, mime }) {
|
export async function moderateImage({ buffer, mime }) {
|
||||||
if (!process.env.OPENAI_API_KEY) {
|
if (!process.env.OPENAI_API_KEY) {
|
||||||
@@ -58,17 +59,22 @@ export async function moderateImage({ buffer, mime }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function moderationPrompt() {
|
function moderationPrompt() {
|
||||||
|
const autoApproveMinScore = autoApproveMinScoreValue();
|
||||||
return [
|
return [
|
||||||
'You are moderating image uploads for THE_MEME_PROTOCOL, a meme gallery.',
|
'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.',
|
'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.',
|
'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.',
|
'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.',
|
'If legality or age is ambiguous, choose pending.',
|
||||||
'Assign MEME_CONSENSUS_SCORE from 0-100: higher means it is clearly a meme/reaction/roast/remix and suitable for this site; lower means random photo, spam, ad, QR scam, screenshot dump, or unclear.',
|
'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.',
|
'Return only compact JSON with keys: decision, score, reason.',
|
||||||
'decision must be one of: approved, pending, rejected.',
|
'decision must be one of: approved, pending, rejected.',
|
||||||
'Use approved only when it appears legal and score is at least 65.',
|
`Use approved only when it appears legal and score is at least ${autoApproveMinScore}.`,
|
||||||
'Use pending for ambiguity, uncertainty, low meme relevance, or anything that needs human review.',
|
'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.'
|
'Use rejected only for likely illegal content.'
|
||||||
].join('\n');
|
].join('\n');
|
||||||
}
|
}
|
||||||
@@ -95,12 +101,19 @@ function normalizeDecision(raw) {
|
|||||||
? raw.reason.trim().slice(0, 300)
|
? raw.reason.trim().slice(0, 300)
|
||||||
: 'No moderation reason supplied.';
|
: 'No moderation reason supplied.';
|
||||||
|
|
||||||
if (decision === 'approved' && score < 65) {
|
const autoApproveMinScore = autoApproveMinScoreValue();
|
||||||
|
if (decision === 'approved' && score < autoApproveMinScore) {
|
||||||
return { status: 'pending', score, reason: `${reason} Low MEME_CONSENSUS_SCORE queued for review.` };
|
return { status: 'pending', score, reason: `${reason} Low MEME_CONSENSUS_SCORE queued for review.` };
|
||||||
}
|
}
|
||||||
return { status: decision, score, reason };
|
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) {
|
function extractOutputText(payload) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
for (const item of payload.output || []) {
|
for (const item of payload.output || []) {
|
||||||
|
|||||||
@@ -19,32 +19,29 @@ export async function normalizeToWebp(buffer) {
|
|||||||
if (!metadata?.width || !metadata?.height) {
|
if (!metadata?.width || !metadata?.height) {
|
||||||
throw new HttpError(400, 'Image could not be decoded.');
|
throw new HttpError(400, 'Image could not be decoded.');
|
||||||
}
|
}
|
||||||
if (metadata.width !== metadata.height) {
|
|
||||||
throw new HttpError(422, 'Image must be square.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const targetSize = Math.min(metadata.width, MAX_OUTPUT_DIMENSION);
|
|
||||||
const output = await image
|
const output = await image
|
||||||
.rotate()
|
.rotate()
|
||||||
.resize(targetSize, targetSize, {
|
.resize({
|
||||||
fit: 'cover',
|
width: MAX_OUTPUT_DIMENSION,
|
||||||
|
height: MAX_OUTPUT_DIMENSION,
|
||||||
|
fit: 'inside',
|
||||||
withoutEnlargement: true
|
withoutEnlargement: true
|
||||||
})
|
})
|
||||||
.webp({
|
.webp({
|
||||||
quality: WEBP_QUALITY,
|
quality: WEBP_QUALITY,
|
||||||
effort: 4
|
effort: 4
|
||||||
})
|
})
|
||||||
.toBuffer();
|
.toBuffer({ resolveWithObject: true });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
buffer: output,
|
buffer: output.data,
|
||||||
image: {
|
image: {
|
||||||
format: 'webp',
|
format: 'webp',
|
||||||
ext: 'webp',
|
ext: 'webp',
|
||||||
mime: 'image/webp',
|
mime: 'image/webp',
|
||||||
width: targetSize,
|
width: output.info.width,
|
||||||
height: targetSize,
|
height: output.info.height,
|
||||||
byteSize: output.length
|
byteSize: output.data.length
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,12 +2,20 @@ import { validateImage } from './image.js';
|
|||||||
import { normalizeToWebp } from './normalize.js';
|
import { normalizeToWebp } from './normalize.js';
|
||||||
import { encodePng } from './png.js';
|
import { encodePng } from './png.js';
|
||||||
|
|
||||||
const DEMO_COUNT = 12;
|
const DEMO_COUNT = 18;
|
||||||
|
|
||||||
export async function seedDemoMemes(store) {
|
export async function seedDemoMemes(store) {
|
||||||
if (store.count() > 0) return;
|
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) {
|
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 width = 960;
|
||||||
const height = 960;
|
const height = 960;
|
||||||
const buffer = encodePng(width, height, pixelFactory(index));
|
const buffer = encodePng(width, height, pixelFactory(index));
|
||||||
@@ -22,7 +30,7 @@ export async function seedDemoMemes(store) {
|
|||||||
await store.save({
|
await store.save({
|
||||||
buffer: normalized.buffer,
|
buffer: normalized.buffer,
|
||||||
image: normalized.image,
|
image: normalized.image,
|
||||||
originalName: `protocol-sample-${String(index + 1).padStart(2, '0')}.png`,
|
originalName,
|
||||||
originalMime: image.mime,
|
originalMime: image.mime,
|
||||||
createdAt,
|
createdAt,
|
||||||
demo: true
|
demo: true
|
||||||
|
|||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('<', '<')
|
||||||
|
.replaceAll('>', '>')
|
||||||
|
.replaceAll('"', '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlEscape(value) {
|
||||||
|
return escapeHtml(value).replaceAll("'", ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeJsonScript(value) {
|
||||||
|
return value.replaceAll('<', '\\u003c').replaceAll('>', '\\u003e').replaceAll('&', '\\u0026');
|
||||||
|
}
|
||||||
@@ -40,28 +40,27 @@ test('rejects gif uploads', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('rejects non-square images when square uploads are required', () => {
|
test('accepts non-square png uploads', () => {
|
||||||
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
|
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
|
||||||
assert.throws(
|
const image = validateImage(png, {
|
||||||
() => validateImage(png, {
|
maxBytes: 5 * 1024 * 1024,
|
||||||
maxBytes: 5 * 1024 * 1024,
|
maxWidth: 6000,
|
||||||
maxWidth: 6000,
|
maxHeight: 6000,
|
||||||
maxHeight: 6000,
|
maxPixels: 20_000_000
|
||||||
maxPixels: 20_000_000,
|
});
|
||||||
requireSquare: true
|
|
||||||
}),
|
assert.equal(image.width, 32);
|
||||||
/square/
|
assert.equal(image.height, 24);
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizes png uploads to square webp', async () => {
|
test('normalizes png uploads to webp while preserving aspect ratio', async () => {
|
||||||
const png = encodePng(32, 32, () => [0, 255, 65, 255]);
|
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
|
||||||
const normalized = await normalizeToWebp(png);
|
const normalized = await normalizeToWebp(png);
|
||||||
|
|
||||||
assert.equal(normalized.image.mime, 'image/webp');
|
assert.equal(normalized.image.mime, 'image/webp');
|
||||||
assert.equal(normalized.image.ext, 'webp');
|
assert.equal(normalized.image.ext, 'webp');
|
||||||
assert.equal(normalized.image.width, 32);
|
assert.equal(normalized.image.width, 32);
|
||||||
assert.equal(normalized.image.height, 32);
|
assert.equal(normalized.image.height, 24);
|
||||||
assert.equal(normalized.buffer.subarray(0, 4).toString('ascii'), 'RIFF');
|
assert.equal(normalized.buffer.subarray(0, 4).toString('ascii'), 'RIFF');
|
||||||
assert.equal(normalized.buffer.subarray(8, 12).toString('ascii'), 'WEBP');
|
assert.equal(normalized.buffer.subarray(8, 12).toString('ascii'), 'WEBP');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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]);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -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.'
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -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}`));
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -39,8 +39,7 @@ test('increments view and download counters', async () => {
|
|||||||
maxBytes: 5 * 1024 * 1024,
|
maxBytes: 5 * 1024 * 1024,
|
||||||
maxWidth: 6000,
|
maxWidth: 6000,
|
||||||
maxHeight: 6000,
|
maxHeight: 6000,
|
||||||
maxPixels: 20_000_000,
|
maxPixels: 20_000_000
|
||||||
requireSquare: true
|
|
||||||
});
|
});
|
||||||
const meme = await store.save({
|
const meme = await store.save({
|
||||||
buffer,
|
buffer,
|
||||||
|
|||||||