This commit is contained in:
2026-05-08 18:18:36 -03:00
parent c4bb073ca1
commit 5e10af882b
26 changed files with 3150 additions and 0 deletions
+5
View File
@@ -0,0 +1,5 @@
.git
.DS_Store
data
node_modules
stitch_the_meme_protocol
+5
View File
@@ -0,0 +1,5 @@
ADMIN_TOKEN=replace-with-a-long-random-secret
OPENAI_API_KEY=sk-your-key
OPENAI_MODERATION_MODEL=gpt-4o-mini
MAX_IMAGE_DIMENSION=1600
WEBP_QUALITY=85
+2
View File
@@ -1,2 +1,4 @@
.DS_Store
stitch_the_meme_protocol/
data/
node_modules/
+24
View File
@@ -0,0 +1,24 @@
FROM node:22-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY server.js ./server.js
COPY src ./src
COPY public ./public
RUN addgroup -S meme && adduser -S meme -G meme \
&& mkdir -p /data \
&& chown -R meme:meme /data /app
USER meme
EXPOSE 8080
VOLUME ["/data"]
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
CMD node -e "fetch('http://127.0.0.1:8080/healthz').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
ENV DATA_DIR=/data
CMD ["node", "server.js"]
+49
View File
@@ -0,0 +1,49 @@
# The Meme Protocol
Small self-hosted meme gallery matching the `stitch_the_meme_protocol` desktop mockup direction.
## Run locally
```sh
npm start
```
The server listens on `http://localhost:8080` by default.
## Configuration
- `PORT`: HTTP port, default `8080`
- `HOST`: bind address, default `0.0.0.0`
- `DATA_DIR`: disk storage root, default `./data`
- `SEED_DEMO_MEMES`: set to `false` to disable generated demo memes on first boot
- `ADMIN_TOKEN`: secret review URL token. If omitted, one is generated at boot and printed in server logs.
- `OPENAI_API_KEY`: enables AI upload moderation. Without it, uploads are queued for admin review.
- `OPENAI_MODERATION_MODEL`: moderation vision model, default `gpt-4o-mini`
- `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.
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.
Files are stored under sharded date/hash paths:
```text
data/
index/memes.jsonl
memes/YYYY/MM/DD/aa/bb/<sha256>.<ext>
meta/YYYY/MM/DD/aa/bb/<sha256>.json
```
## Docker
```sh
docker build -t meme-protocol .
docker run --rm -p 8080:8080 -v meme-protocol-data:/data meme-protocol
```
For production, copy `.env.example` to `.env`, set real secrets, then run:
```sh
docker compose up -d --build
```
The included compose file binds the app to `127.0.0.1:18080` on the host so a reverse proxy can publish it without exposing the Node container directly.
+25
View File
@@ -0,0 +1,25 @@
services:
meme-protocol:
build: .
image: meme-protocol:latest
container_name: meme-protocol
restart: unless-stopped
environment:
NODE_ENV: production
HOST: 0.0.0.0
PORT: 8080
DATA_DIR: /data
SEED_DEMO_MEMES: "false"
TRUST_PROXY: "true"
ADMIN_TOKEN: ${ADMIN_TOKEN}
OPENAI_API_KEY: ${OPENAI_API_KEY}
OPENAI_MODERATION_MODEL: ${OPENAI_MODERATION_MODEL:-gpt-4o-mini}
MAX_IMAGE_DIMENSION: ${MAX_IMAGE_DIMENSION:-1600}
WEBP_QUALITY: ${WEBP_QUALITY:-85}
volumes:
- meme_protocol_data:/data
ports:
- "127.0.0.1:18080:8080"
volumes:
meme_protocol_data:
+613
View File
@@ -0,0 +1,613 @@
{
"name": "meme-protocol",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meme-protocol",
"version": "1.0.0",
"dependencies": {
"sharp": "^0.34.5"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz",
"integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz",
"integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.2.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz",
"integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz",
"integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz",
"integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz",
"integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz",
"integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz",
"integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz",
"integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz",
"integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz",
"integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz",
"integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz",
"integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==",
"cpu": [
"arm"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.2.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz",
"integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz",
"integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==",
"cpu": [
"ppc64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz",
"integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.2.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz",
"integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==",
"cpu": [
"s390x"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.2.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz",
"integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz",
"integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz",
"integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.2.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz",
"integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.7.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz",
"integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz",
"integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz",
"integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.34.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz",
"integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"@img/colour": "^1.0.0",
"detect-libc": "^2.1.2",
"semver": "^7.7.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.5",
"@img/sharp-darwin-x64": "0.34.5",
"@img/sharp-libvips-darwin-arm64": "1.2.4",
"@img/sharp-libvips-darwin-x64": "1.2.4",
"@img/sharp-libvips-linux-arm": "1.2.4",
"@img/sharp-libvips-linux-arm64": "1.2.4",
"@img/sharp-libvips-linux-ppc64": "1.2.4",
"@img/sharp-libvips-linux-riscv64": "1.2.4",
"@img/sharp-libvips-linux-s390x": "1.2.4",
"@img/sharp-libvips-linux-x64": "1.2.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.2.4",
"@img/sharp-libvips-linuxmusl-x64": "1.2.4",
"@img/sharp-linux-arm": "0.34.5",
"@img/sharp-linux-arm64": "0.34.5",
"@img/sharp-linux-ppc64": "0.34.5",
"@img/sharp-linux-riscv64": "0.34.5",
"@img/sharp-linux-s390x": "0.34.5",
"@img/sharp-linux-x64": "0.34.5",
"@img/sharp-linuxmusl-arm64": "0.34.5",
"@img/sharp-linuxmusl-x64": "0.34.5",
"@img/sharp-wasm32": "0.34.5",
"@img/sharp-win32-arm64": "0.34.5",
"@img/sharp-win32-ia32": "0.34.5",
"@img/sharp-win32-x64": "0.34.5"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD",
"optional": true
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "meme-protocol",
"version": "1.0.0",
"private": true,
"description": "Self-hosted meme gallery for The Meme Protocol.",
"type": "module",
"scripts": {
"start": "node server.js",
"test": "node --test"
},
"engines": {
"node": ">=20"
},
"dependencies": {
"sharp": "^0.34.5"
}
}
+31
View File
@@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Meme Protocol Review</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/styles.css">
<script type="module" src="/assets/admin.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL_REVIEW</div>
<div class="admin-actions">
<button class="secondary-action" id="refresh-review" type="button">REFRESH</button>
<button class="primary-action" id="approve-selected" type="button">APPROVE_SELECTED</button>
<button class="secondary-action danger-action" id="delete-selected" type="button">DELETE_SELECTED</button>
</div>
</header>
<main class="shell">
<div class="status-line" aria-live="polite">
<span id="review-status">PENDING_REVIEW</span>
<span class="live"><span class="pulse"></span><span id="review-count">0 ITEMS</span></span>
</div>
<section class="meme-grid" id="review-grid" aria-label="Pending meme review"></section>
</main>
</body>
</html>
+116
View File
@@ -0,0 +1,116 @@
const token = location.pathname.split('/').pop();
history.replaceState(null, '', '/admin');
const grid = document.querySelector('#review-grid');
const reviewStatus = document.querySelector('#review-status');
const reviewCount = document.querySelector('#review-count');
const selected = new Set();
document.querySelector('#refresh-review').addEventListener('click', loadPending);
document.querySelector('#approve-selected').addEventListener('click', () => moderateSelected('approve'));
document.querySelector('#delete-selected').addEventListener('click', () => moderateSelected('delete'));
grid.addEventListener('change', (event) => {
const checkbox = event.target.closest('[data-select-id]');
if (!checkbox) return;
if (checkbox.checked) selected.add(checkbox.dataset.selectId);
else selected.delete(checkbox.dataset.selectId);
});
grid.addEventListener('click', (event) => {
const action = event.target.closest('[data-admin-action]');
if (!action) return;
moderate([action.dataset.id], action.dataset.adminAction);
});
await loadPending();
async function loadPending() {
selected.clear();
reviewStatus.textContent = 'FETCHING_PENDING_QUEUE';
const response = await fetch('/api/admin/pending', { headers: adminHeaders() });
const payload = await response.json();
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_AUTH_FAILED';
grid.innerHTML = '<div class="empty-state">ADMIN_TOKEN_INVALID</div>';
return;
}
reviewStatus.textContent = 'PENDING_REVIEW';
reviewCount.textContent = `${payload.total} ITEMS`;
renderPending(payload.memes);
}
function renderPending(memes) {
if (memes.length === 0) {
grid.innerHTML = '<div class="empty-state">NO_PENDING_MEMES</div>';
return;
}
grid.replaceChildren(...memes.map((meme) => {
const article = document.createElement('article');
article.className = 'meme-card';
article.innerHTML = `
<div class="card-head">
<label class="review-select">
<input type="checkbox" data-select-id="${meme.id}">
<span>ID: ${shortId(meme.id)}</span>
</label>
<span class="card-meta age">SCORE ${meme.moderationScore}</span>
</div>
<div class="image-frame">
<img src="/admin-media/${token}/${meme.id}" alt="Pending meme ${shortId(meme.id)}" loading="lazy">
</div>
<div class="review-body">
<p>${escapeText(meme.moderationReason || 'Queued for review.')}</p>
</div>
<div class="card-actions">
<div class="stats">
<span class="card-stat">${formatBytes(meme.byteSize)}</span>
</div>
<div class="action-group">
<button class="text-action text-action-accent icon-only-action" type="button" data-admin-action="approve" data-id="${meme.id}" aria-label="Approve meme" title="Approve meme"><span class="material-symbols-outlined" aria-hidden="true">check</span></button>
<button class="text-action icon-only-action danger-action" type="button" data-admin-action="delete" data-id="${meme.id}" aria-label="Delete meme" title="Delete meme"><span class="material-symbols-outlined" aria-hidden="true">delete</span></button>
</div>
</div>
`;
return article;
}));
}
async function moderateSelected(action) {
if (selected.size === 0) return;
await moderate([...selected], action);
}
async function moderate(ids, action) {
const endpoint = action === 'approve' ? '/api/admin/approve' : '/api/admin/delete';
const response = await fetch(endpoint, {
method: 'POST',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ ids })
});
if (!response.ok) {
reviewStatus.textContent = 'REVIEW_ACTION_FAILED';
return;
}
await loadPending();
}
function adminHeaders() {
return { 'X-Admin-Token': token };
}
function shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
}
function formatBytes(value) {
if (value >= 1024 * 1024) return `${(value / 1024 / 1024).toFixed(1)}MB`;
return `${Math.ceil(value / 1024)}KB`;
}
function escapeText(value) {
const span = document.createElement('span');
span.textContent = value;
return span.innerHTML;
}
+308
View File
@@ -0,0 +1,308 @@
const PAGE_SIZE = 12;
const MAX_FILE_BYTES = 5 * 1024 * 1024;
const SAFE_TYPES = new Set(['image/png', 'image/jpeg']);
const grid = document.querySelector('#meme-grid');
const feedLoader = document.querySelector('#feed-loader');
const feedLoaderLabel = document.querySelector('#feed-loader-label');
const uploadModal = document.querySelector('#upload-modal');
const uploadForm = document.querySelector('#upload-form');
const fileInput = document.querySelector('#meme-input');
const fileLabel = document.querySelector('#file-label');
const formStatus = document.querySelector('#form-status');
const submitUpload = document.querySelector('#submit-upload');
const lightbox = document.querySelector('#lightbox');
const lightboxImage = document.querySelector('#lightbox-image');
const lightboxDownload = document.querySelector('#lightbox-download');
const lightboxId = document.querySelector('#lightbox-id');
const scrollIndicator = document.querySelector('#scroll-indicator');
const statStatus = document.querySelector('#stat-status');
const statLatency = document.querySelector('#stat-latency');
const statNodes = document.querySelector('#stat-nodes');
const statMemes = document.querySelector('#stat-memes');
let currentPage = 0;
let totalPages = 1;
let isLoading = false;
let latestValidationRun = 0;
document.querySelector('#open-upload').addEventListener('click', () => {
formStatus.textContent = '';
uploadModal.showModal();
});
document.querySelector('#close-upload').addEventListener('click', closeUpload);
document.querySelector('#cancel-upload').addEventListener('click', closeUpload);
document.querySelector('#close-lightbox').addEventListener('click', () => lightbox.close());
fileInput.addEventListener('change', async () => {
const validationRun = ++latestValidationRun;
const file = fileInput.files?.[0];
fileLabel.textContent = file ? file.name : 'SELECT PNG / JPEG';
formStatus.textContent = file ? 'INSPECTING_IMAGE...' : '';
const validationError = await validateClientFile(file);
if (validationRun === latestValidationRun) {
formStatus.textContent = validationError || '';
}
});
uploadForm.addEventListener('submit', async (event) => {
event.preventDefault();
const file = fileInput.files?.[0];
const validationError = await validateClientFile(file);
if (validationError) {
formStatus.textContent = validationError;
return;
}
submitUpload.disabled = true;
formStatus.textContent = 'UPLOADING...';
try {
const formData = new FormData();
formData.append('meme', file);
const response = await fetch('/api/memes', { method: 'POST', body: formData });
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Upload failed.');
if (payload.meme?.status === 'approved') {
closeUpload();
await loadMemes(1, { reset: true });
} else {
uploadForm.reset();
fileLabel.textContent = 'SELECT PNG / JPEG';
formStatus.textContent = `${payload.message || 'QUEUED_FOR_REVIEW'} MEME_CONSENSUS_SCORE: ${payload.meme?.moderationScore ?? '--'}`;
}
} catch (error) {
formStatus.textContent = error.message.toUpperCase();
} finally {
submitUpload.disabled = false;
}
});
grid.addEventListener('click', async (event) => {
const viewButton = event.target.closest('[data-view-id]');
if (!viewButton) return;
const card = viewButton.closest('.meme-card');
lightboxImage.src = viewButton.dataset.viewUrl;
lightboxImage.alt = card.querySelector('img').alt;
lightboxDownload.href = viewButton.dataset.downloadUrl;
lightboxId.textContent = `ID: ${shortId(viewButton.dataset.viewId)}`;
lightbox.showModal();
await recordView(viewButton.dataset.viewId);
});
const observer = new IntersectionObserver((entries) => {
if (entries.some((entry) => entry.isIntersecting)) {
loadNextPage();
}
}, { rootMargin: '420px 0px' });
connectLiveCounters();
updateScrollIndicator();
window.addEventListener('scroll', updateScrollIndicator, { passive: true });
window.addEventListener('resize', updateScrollIndicator);
refreshStatus();
window.setInterval(refreshStatus, 5000);
await loadMemes(1, { reset: true });
observer.observe(feedLoader);
async function loadNextPage() {
if (isLoading || currentPage >= totalPages) return;
await loadMemes(currentPage + 1);
}
async function loadMemes(page, options = {}) {
if (isLoading) return;
isLoading = true;
setLoader('FETCHING_NEXT_BLOCK...', true);
try {
const response = await fetch(`/api/memes?page=${page}&pageSize=${PAGE_SIZE}`);
const payload = await response.json();
if (!response.ok) throw new Error(payload.error || 'Feed error.');
currentPage = payload.page;
totalPages = payload.totalPages;
renderMemes(payload.memes, { append: !options.reset });
setLoader(currentPage < totalPages ? 'SCROLL_FOR_NEXT_BLOCK' : 'STREAM_SYNCHRONIZED', false);
updateScrollIndicator();
} catch {
if (options.reset) grid.innerHTML = `<div class="empty-state">FEED_ERROR</div>`;
setLoader('FEED_ERROR', false);
} finally {
grid.ariaBusy = 'false';
isLoading = false;
}
}
function renderMemes(memes, options = {}) {
if (!options.append && memes.length === 0) {
grid.innerHTML = `<div class="empty-state">NO_MEMES_IN_STREAM</div>`;
return;
}
const cards = memes.map((meme, index) => {
const article = document.createElement('article');
article.className = 'meme-card';
article.dataset.memeId = meme.id;
article.innerHTML = `
<div class="card-head">
<div class="card-id">
<span class="node active node-score-${scoreBucket(meme.moderationScore)}" tabindex="0" data-tooltip="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" title="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} / 100" aria-label="MEME_CONSENSUS_SCORE: ${formatCount(meme.moderationScore)} out of 100"></span>
<span class="card-meta">ID: ${shortId(meme.id)}</span>
</div>
<span class="card-meta age">${relativeAge(meme.createdAt)}</span>
</div>
<div class="image-frame">
<img src="${meme.url}" alt="Uploaded meme ${shortId(meme.id)}" loading="lazy">
</div>
<div class="card-actions">
<div class="stats">
<span class="card-stat" data-counter-id="${meme.id}" data-counter-kind="view"><span class="material-symbols-outlined" aria-hidden="true">visibility</span>${formatCount(meme.viewCount)}</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>
</div>
<div class="action-group">
<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>
</div>
</div>
`;
if (options.append && index === 0) article.tabIndex = -1;
return article;
});
if (options.append) {
grid.append(...cards);
} else {
grid.replaceChildren(...cards);
}
}
async function validateClientFile(file) {
if (!file) return 'SELECT A FILE.';
if (!SAFE_TYPES.has(file.type)) return 'ONLY PNG AND JPEG ARE ACCEPTED.';
if (file.size > MAX_FILE_BYTES) return 'IMAGE EXCEEDS 5MB.';
try {
const dimensions = await readImageDimensions(file);
if (dimensions.width !== dimensions.height) return 'IMAGE MUST BE SQUARE.';
if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EXCEEDS 6000x6000.';
} catch {
return 'IMAGE COULD NOT BE INSPECTED.';
}
return '';
}
function readImageDimensions(file) {
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(file);
image.onload = () => {
URL.revokeObjectURL(url);
resolve({ width: image.naturalWidth, height: image.naturalHeight });
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error('invalid image'));
};
image.src = url;
});
}
function closeUpload() {
uploadForm.reset();
fileLabel.textContent = 'SELECT PNG / JPEG';
formStatus.textContent = '';
uploadModal.close();
}
async function recordView(id) {
try {
const response = await fetch(`/api/memes/${id}/view`, { method: 'POST' });
const payload = await response.json();
if (response.ok) updateCounters(payload.meme);
} catch {
// Live counter updates are best-effort; the lightbox should still open.
}
}
function connectLiveCounters() {
if (!('EventSource' in window)) return;
const source = new EventSource('/api/events');
source.addEventListener('metric', (event) => {
updateCounters(JSON.parse(event.data));
});
}
function updateCounters(meme) {
for (const element of document.querySelectorAll(`[data-counter-id="${meme.id}"]`)) {
if (element.dataset.counterKind === 'view') {
element.lastChild.textContent = formatCount(meme.viewCount);
}
if (element.dataset.counterKind === 'download') {
element.lastChild.textContent = formatCount(meme.downloadCount);
}
}
}
function setLoader(label, spinning) {
feedLoaderLabel.textContent = label;
feedLoader.classList.toggle('is-spinning', spinning);
}
function shortId(id) {
return `0x${id.slice(0, 4).toUpperCase()}...${id.slice(-4).toUpperCase()}`;
}
function relativeAge(value) {
const elapsed = Math.max(1, Date.now() - new Date(value).getTime());
const minutes = Math.floor(elapsed / 60000);
if (minutes < 60) return `${minutes}M AGO`;
const hours = Math.floor(minutes / 60);
if (hours < 24) return `${hours}H AGO`;
return `${Math.floor(hours / 24)}D AGO`;
}
function formatCount(value) {
const count = Number.isFinite(value) ? value : 0;
if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`;
return String(count);
}
function scoreBucket(value) {
const score = Number.isFinite(value) ? value : 50;
if (score >= 90) return 5;
if (score >= 75) return 4;
if (score >= 60) return 3;
if (score >= 40) return 2;
if (score >= 20) return 1;
return 0;
}
async function refreshStatus() {
const started = performance.now();
try {
const response = await fetch('/api/status', { cache: 'no-store' });
const status = await response.json();
if (!response.ok) throw new Error('status failed');
statStatus.textContent = status.ok ? 'ONLINE' : 'DEGRADED';
statLatency.textContent = `${Math.max(1, Math.round(performance.now() - started))}MS`;
statNodes.textContent = formatCount(status.liveClients);
statMemes.textContent = formatCount(status.memeCount);
} catch {
statStatus.textContent = 'OFFLINE';
statLatency.textContent = '--MS';
statNodes.textContent = '--';
statMemes.textContent = '--';
}
}
function updateScrollIndicator() {
const segments = [...scrollIndicator.querySelectorAll('span')];
const maxScroll = Math.max(1, document.documentElement.scrollHeight - window.innerHeight);
const progress = Math.min(1, Math.max(0, window.scrollY / maxScroll));
const activeIndex = Math.min(segments.length - 1, Math.round(progress * (segments.length - 1)));
segments.forEach((segment, index) => {
segment.classList.toggle('active', index === activeIndex);
});
}
+695
View File
@@ -0,0 +1,695 @@
:root {
color-scheme: dark;
--void: #050505;
--background: #131313;
--surface-lowest: #0e0e0e;
--surface-low: #1c1b1b;
--surface: #201f1f;
--surface-high: #2a2a2a;
--surface-highest: #353534;
--text: #e5e2e1;
--muted: #b9ccb2;
--outline: #84967e;
--outline-variant: #3b4b37;
--primary: #00ff41;
--primary-dim: #00e639;
--on-primary: #003907;
--secondary: #00e0ff;
--error: #ffb4ab;
--gutter: 16px;
--desktop-margin: 32px;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background: var(--void);
color: var(--text);
font-family: "JetBrains Mono", "SFMono-Regular", Consolas, monospace;
line-height: 1.5;
letter-spacing: 0;
-webkit-font-smoothing: antialiased;
}
button,
a,
input {
font: inherit;
}
button,
a {
cursor: pointer;
}
.topbar {
position: fixed;
z-index: 20;
inset: 0 0 auto;
height: 64px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 var(--desktop-margin);
background: rgb(19 19 19 / 90%);
border-bottom: 1px solid var(--outline-variant);
backdrop-filter: blur(12px);
}
.brand,
.primary-action,
.secondary-action,
.status-line,
.card-meta,
.card-stat,
.side-terminal,
.upload-rules,
.form-status,
.lightbox-toolbar {
font-size: 12px;
line-height: 1.2;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.brand {
color: var(--primary-dim);
letter-spacing: 0.2em;
}
.primary-action,
.secondary-action,
.icon-action {
border: 1px solid var(--outline-variant);
border-radius: 0;
min-height: 36px;
background: transparent;
color: var(--muted);
text-decoration: none;
}
.primary-action {
padding: 0 24px;
border-color: var(--primary);
background: var(--primary);
color: var(--on-primary);
}
.primary-action:hover,
.primary-action:focus-visible {
background: var(--text);
border-color: var(--text);
}
.secondary-action {
padding: 0 18px;
}
.secondary-action:hover,
.secondary-action:focus-visible,
.icon-action:hover,
.icon-action:focus-visible {
border-color: var(--primary-dim);
color: var(--primary-dim);
background: var(--surface-high);
}
.icon-action {
width: 36px;
height: 36px;
}
.material-symbols-outlined {
display: inline-block;
font-family: "Material Symbols Outlined";
font-size: 18px;
font-style: normal;
font-weight: normal;
line-height: 1;
letter-spacing: 0;
text-transform: none;
white-space: nowrap;
direction: ltr;
font-feature-settings: "liga";
-webkit-font-feature-settings: "liga";
font-variation-settings: "FILL" 0, "wght" 400, "GRAD" 0, "opsz" 20;
-webkit-font-smoothing: antialiased;
}
.shell {
width: min(100%, 1440px);
margin: 0 auto;
padding: 96px var(--desktop-margin) 48px;
}
.status-line {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--gutter);
padding-bottom: 8px;
color: var(--outline);
font-size: 10px;
border-bottom: 1px solid var(--outline-variant);
}
.live {
display: inline-flex;
align-items: center;
gap: 8px;
}
.pulse {
width: 6px;
height: 6px;
background: var(--primary);
animation: pulse 1.4s steps(2, end) infinite;
}
.meme-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
margin-top: 32px;
}
.meme-card {
overflow: hidden;
background: var(--surface-lowest);
border: 1px solid #1a1a1a;
box-shadow: 0 0 10px rgb(0 255 65 / 10%);
}
.card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
background: var(--surface);
border-bottom: 1px solid var(--outline-variant);
}
.card-id {
display: inline-flex;
min-width: 0;
align-items: center;
gap: 8px;
color: var(--muted);
}
.node {
position: relative;
flex: 0 0 auto;
width: 8px;
height: 8px;
background: var(--outline-variant);
}
.node.active {
background: var(--primary);
}
.node-score-0 {
opacity: 0.25;
}
.node-score-1 {
opacity: 0.4;
}
.node-score-2 {
opacity: 0.55;
}
.node-score-3 {
opacity: 0.72;
}
.node-score-4 {
opacity: 0.88;
}
.node-score-5 {
opacity: 1;
box-shadow: 0 0 8px rgb(0 255 65 / 45%);
}
.node::after {
position: absolute;
z-index: 5;
left: 50%;
bottom: calc(100% + 10px);
width: max-content;
max-width: 220px;
padding: 6px 8px;
border: 1px solid var(--outline-variant);
background: #111;
color: var(--text);
content: attr(data-tooltip);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
line-height: 1.3;
opacity: 0;
pointer-events: none;
text-transform: uppercase;
transform: translate(-50%, 4px);
transition: opacity 120ms ease, transform 120ms ease;
}
.node:hover::after,
.node:focus-visible::after {
opacity: 1;
transform: translate(-50%, 0);
}
.card-meta {
overflow: hidden;
font-size: 10px;
text-overflow: ellipsis;
white-space: nowrap;
}
.age {
flex: 0 0 auto;
color: var(--outline);
}
.image-frame {
aspect-ratio: 1 / 1;
overflow: hidden;
background: #000;
}
.image-frame img {
width: 100%;
height: 100%;
display: block;
object-fit: cover;
opacity: 0.9;
transition: transform 700ms ease, opacity 200ms ease;
}
.meme-card:hover img {
transform: scale(1.05);
opacity: 1;
}
.card-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px 12px;
}
.stats {
display: flex;
gap: 14px;
color: var(--muted);
}
.card-stat {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 10px;
letter-spacing: 0;
}
.card-stat .material-symbols-outlined {
color: var(--outline);
font-size: 16px;
}
.action-group {
display: flex;
gap: 8px;
}
.text-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 10px;
border: 1px solid #1a1a1a;
background: transparent;
color: var(--muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-decoration: none;
}
.icon-only-action {
width: 36px;
height: 36px;
padding: 0;
}
.text-action-accent {
border-color: var(--primary-dim);
color: var(--primary-dim);
}
.text-action:hover,
.text-action:focus-visible {
border-color: var(--primary-dim);
color: var(--primary-dim);
background: var(--surface-high);
}
.feed-loader {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 16px;
min-height: 104px;
margin-top: 24px;
color: var(--outline);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.loader-ring {
width: 32px;
height: 32px;
border: 2px solid transparent;
border-top-color: var(--primary);
border-radius: 9999px;
opacity: 0;
}
.feed-loader.is-spinning .loader-ring {
animation: spin 850ms linear infinite;
opacity: 1;
}
.side-terminal {
position: fixed;
left: 16px;
top: 50%;
transform: translateY(-50%);
width: 192px;
padding-left: 8px;
border-left: 1px solid var(--outline-variant);
color: var(--outline);
font-size: 10px;
opacity: 0.5;
pointer-events: none;
}
.side-terminal .active {
margin-top: 8px;
color: var(--primary-dim);
text-decoration: underline;
}
.scroll-indicator {
position: fixed;
right: 16px;
top: 50%;
display: flex;
flex-direction: column;
gap: 8px;
transform: translateY(-50%);
opacity: 0.3;
}
.scroll-indicator span {
display: block;
width: 4px;
height: 16px;
background: var(--outline-variant);
transition: height 160ms ease, background-color 160ms ease;
}
.scroll-indicator span.active {
height: 32px;
background: var(--primary);
}
dialog {
border: 0;
padding: 0;
background: transparent;
color: inherit;
}
dialog[open] {
animation: dialog-in 140ms ease-out;
}
dialog::backdrop {
background: rgb(0 0 0 / 72%);
backdrop-filter: blur(4px);
animation: backdrop-in 140ms ease-out;
}
.modal-panel {
width: min(520px, calc(100vw - 32px));
background: #111;
border: 1px solid var(--outline-variant);
box-shadow: 0 0 18px rgb(0 255 65 / 12%);
}
.modal-header,
.lightbox-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px;
border-bottom: 1px solid var(--outline-variant);
background: var(--surface);
}
.modal-header h2 {
margin: 0;
font-family: Geist, Inter, system-ui, sans-serif;
font-size: 24px;
line-height: 1.2;
letter-spacing: 0;
}
.file-drop {
display: flex;
min-height: 160px;
margin: 16px;
align-items: center;
justify-content: center;
border: 1px dashed var(--outline);
background: var(--surface-lowest);
color: var(--muted);
}
.file-drop input {
position: absolute;
width: 1px;
height: 1px;
opacity: 0;
}
.upload-rules,
.form-status {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
margin: 0 16px;
color: var(--outline);
font-size: 10px;
}
.form-status {
min-height: 18px;
margin-bottom: 16px;
color: var(--error);
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 12px;
padding: 16px;
}
.admin-actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: 8px;
}
.danger-action {
border-color: var(--error);
color: var(--error);
}
.review-select {
display: flex;
min-width: 0;
align-items: center;
gap: 8px;
color: var(--muted);
font-size: 10px;
font-weight: 600;
letter-spacing: 0.04em;
}
.review-select input {
accent-color: var(--primary);
}
.review-body {
min-height: 76px;
padding: 12px;
border-top: 1px solid var(--outline-variant);
color: var(--outline);
font-size: 11px;
line-height: 1.45;
}
.review-body p {
margin: 0;
}
.lightbox {
width: min(1120px, calc(100vw - 32px));
max-height: calc(100vh - 32px);
background: #080808;
border: 1px solid var(--outline-variant);
box-shadow: 0 0 24px rgb(0 255 65 / 14%);
}
.lightbox img {
display: block;
width: 100%;
max-height: calc(100vh - 144px);
object-fit: contain;
background: #000;
}
.lightbox-download {
display: flex;
width: max-content;
margin: 12px 12px 12px auto;
align-items: center;
gap: 6px;
}
.empty-state {
grid-column: 1 / -1;
padding: 48px 16px;
border: 1px solid var(--outline-variant);
color: var(--outline);
text-align: center;
}
[hidden] {
display: none !important;
}
@keyframes pulse {
50% {
opacity: 0.25;
}
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes dialog-in {
from {
opacity: 0;
transform: translateY(8px) scale(0.99);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
@keyframes backdrop-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (max-width: 900px) {
.topbar,
.shell {
padding-left: 16px;
padding-right: 16px;
}
.meme-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
.side-terminal,
.scroll-indicator {
display: none;
}
}
@media (max-width: 560px) {
.topbar {
height: 60px;
}
.brand {
font-size: 10px;
letter-spacing: 0.14em;
}
.admin-actions .secondary-action,
.admin-actions .primary-action {
min-height: 30px;
padding-inline: 8px;
font-size: 9px;
}
.primary-action,
.secondary-action {
padding-inline: 14px;
}
.shell {
padding-top: 84px;
}
.status-line,
.card-actions {
align-items: stretch;
flex-direction: column;
}
.meme-grid {
grid-template-columns: 1fr;
}
.action-group,
.stats {
justify-content: space-between;
}
}
+78
View File
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>The Meme Protocol</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:wght,FILL@100..700,0..1&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/assets/styles.css">
<script type="module" src="/assets/app.js"></script>
</head>
<body>
<header class="topbar">
<div class="brand">THE_MEME_PROTOCOL</div>
<button class="primary-action" id="open-upload" type="button">SUBMIT</button>
</header>
<main class="shell">
<div class="status-line" aria-live="polite">
<span>PROTOCOL_STREAM_V.1.04</span>
<span class="live"><span class="pulse"></span>LIVE_FEED</span>
</div>
<section class="meme-grid" id="meme-grid" aria-label="Meme feed"></section>
<div class="feed-loader" id="feed-loader" aria-live="polite">
<div class="loader-ring" aria-hidden="true"></div>
<span id="feed-loader-label">FETCHING_NEXT_BLOCK...</span>
</div>
</main>
<aside class="side-terminal" aria-live="polite">
<div>STATUS: <span id="stat-status">SYNCING</span></div>
<div>LATENCY: <span id="stat-latency">--MS</span></div>
<div>NODES: <span id="stat-nodes">--</span></div>
<div>MEMES: <span id="stat-memes">--</span></div>
<div class="active" id="stat-pun">MEMETICALLY_ACTIVE</div>
</aside>
<div class="scroll-indicator" id="scroll-indicator" aria-hidden="true">
<span class="active"></span><span></span><span></span><span></span>
</div>
<dialog class="modal" id="upload-modal" aria-labelledby="upload-title">
<form class="modal-panel" id="upload-form" method="dialog">
<div class="modal-header">
<h2 id="upload-title">SUBMIT_MEME</h2>
<button class="icon-action" id="close-upload" type="button" aria-label="Close">X</button>
</div>
<label class="file-drop" for="meme-input">
<span id="file-label">SELECT PNG / JPEG</span>
<input id="meme-input" name="meme" type="file" accept="image/png,image/jpeg" required>
</label>
<div class="upload-rules">
<span>MAX_SIZE: 5MB</span>
<span>REQUIRED_RATIO: 1:1 SQUARE</span>
<span>OUTPUT_FORMAT: WEBP</span>
<span>MAX_DIMENSIONS: 6000x6000</span>
</div>
<div class="modal-actions">
<button class="secondary-action" id="cancel-upload" type="button">CANCEL</button>
<button class="primary-action" id="submit-upload" type="submit">UPLOAD</button>
</div>
<p class="form-status" id="form-status" role="status"></p>
</form>
</dialog>
<dialog class="lightbox" id="lightbox" aria-label="Meme viewer">
<div class="lightbox-toolbar">
<span id="lightbox-id"></span>
<button class="icon-action" id="close-lightbox" type="button" aria-label="Close">X</button>
</div>
<img id="lightbox-image" alt="">
<a class="primary-action lightbox-download" id="lightbox-download" href="#"><span class="material-symbols-outlined" aria-hidden="true">download</span><span>DOWNLOAD</span></a>
</dialog>
</body>
</html>
+261
View File
@@ -0,0 +1,261 @@
import http from 'node:http';
import crypto from 'node:crypto';
import { URL } from 'node:url';
import { parseMultipartUpload } from './src/multipart.js';
import { sendFile, sendJson, sendText, withSecurityHeaders } from './src/http.js';
import { createStore } from './src/store.js';
import { validateImage } from './src/image.js';
import { normalizeToWebp } from './src/normalize.js';
import { seedDemoMemes } from './src/seed.js';
import { moderateImage } from './src/moderation.js';
import { createUploadLimiter } from './src/uploadLimits.js';
const PORT = Number.parseInt(process.env.PORT || '8080', 10);
const HOST = process.env.HOST || '0.0.0.0';
const DATA_DIR = process.env.DATA_DIR || './data';
const PAGE_SIZE_MAX = 48;
const UPLOAD_MAX_BYTES = 5 * 1024 * 1024;
const REQUEST_MAX_BYTES = 6 * 1024 * 1024;
const events = new Set();
const ADMIN_TOKEN = process.env.ADMIN_TOKEN || crypto.randomBytes(24).toString('hex');
const store = await createStore({ dataDir: DATA_DIR });
const uploadLimiter = await createUploadLimiter({ dataDir: DATA_DIR });
if (process.env.SEED_DEMO_MEMES !== 'false') {
await seedDemoMemes(store);
}
if (!process.env.ADMIN_TOKEN) {
console.log(`Admin review URL: /admin/${ADMIN_TOKEN}`);
}
const server = http.createServer(async (req, res) => {
withSecurityHeaders(res);
try {
const url = new URL(req.url || '/', `http://${req.headers.host || 'localhost'}`);
if (req.method === 'GET' && url.pathname === '/') {
return sendFile(res, './public/index.html');
}
const adminPageMatch = url.pathname.match(/^\/admin\/([A-Za-z0-9_-]{24,128})$/);
if (req.method === 'GET' && adminPageMatch) {
if (!isAdminToken(adminPageMatch[1])) return sendText(res, 404, 'Not found');
return sendFile(res, './public/admin.html');
}
if (req.method === 'GET' && url.pathname.startsWith('/assets/')) {
return sendFile(res, `./public${url.pathname}`);
}
if (req.method === 'GET' && url.pathname === '/api/memes') {
const page = positiveInt(url.searchParams.get('page'), 1);
const pageSize = Math.min(positiveInt(url.searchParams.get('pageSize'), 12), PAGE_SIZE_MAX);
return sendJson(res, 200, store.list({ page, pageSize }));
}
if (req.method === 'GET' && url.pathname === '/api/status') {
return sendJson(res, 200, {
ok: true,
memeCount: store.count('approved'),
liveClients: events.size,
uptimeSeconds: Math.floor(process.uptime())
});
}
if (req.method === 'GET' && url.pathname === '/api/admin/pending') {
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
return sendJson(res, 200, store.listForReview({ status: 'pending' }));
}
if (req.method === 'POST' && url.pathname === '/api/admin/approve') {
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
const body = await readJsonBody(req, 64 * 1024);
const approved = await store.approve(safeIds(body.ids));
return sendJson(res, 200, { approved });
}
if (req.method === 'POST' && url.pathname === '/api/admin/delete') {
if (!isAdminRequest(req)) return sendJson(res, 404, { error: 'Not found' });
const body = await readJsonBody(req, 64 * 1024);
const deleted = await store.delete(safeIds(body.ids));
return sendJson(res, 200, { deleted });
}
if (req.method === 'GET' && url.pathname === '/api/events') {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive'
});
res.write('event: ready\ndata: {}\n\n');
events.add(res);
req.on('close', () => events.delete(res));
return;
}
if (req.method === 'POST' && url.pathname === '/api/memes') {
const contentLength = Number.parseInt(req.headers['content-length'] || '0', 10);
if (!Number.isFinite(contentLength) || contentLength <= 0) {
return sendJson(res, 411, { error: 'Missing upload size.' });
}
if (contentLength > REQUEST_MAX_BYTES) {
return sendJson(res, 413, { error: 'Upload request is too large.' });
}
const quota = await uploadLimiter.checkAndConsume(clientIp(req));
const upload = await parseMultipartUpload(req, {
maxRequestBytes: REQUEST_MAX_BYTES,
maxFileBytes: UPLOAD_MAX_BYTES,
fieldName: 'meme'
});
const image = validateImage(upload.buffer, {
maxBytes: UPLOAD_MAX_BYTES,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000,
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, {
meme,
quota,
message: meme.status === 'approved' ? 'Upload approved.' : 'Upload queued for admin review.'
});
}
const viewMatch = url.pathname.match(/^\/api\/memes\/([a-f0-9]{64})\/view$/);
if (req.method === 'POST' && viewMatch) {
if (store.get(viewMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
const meme = await store.incrementMetric(viewMatch[1], 'viewCount');
if (!meme) return sendText(res, 404, 'Not found');
broadcastMetric(meme);
return sendJson(res, 200, { meme });
}
const mediaMatch = url.pathname.match(/^\/media\/([a-f0-9]{64})$/);
if (req.method === 'GET' && mediaMatch) {
const meme = store.get(mediaMatch[1]);
if (!meme || meme.status !== 'approved') return sendText(res, 404, 'Not found');
res.setHeader('Content-Type', meme.mime);
res.setHeader('Content-Length', String(meme.byteSize));
res.setHeader('Cache-Control', 'public, max-age=31536000, immutable');
res.setHeader('X-Content-Type-Options', 'nosniff');
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
}
const adminMediaMatch = url.pathname.match(/^\/admin-media\/([A-Za-z0-9_-]{24,128})\/([a-f0-9]{64})$/);
if (req.method === 'GET' && adminMediaMatch) {
if (!isAdminToken(adminMediaMatch[1])) return sendText(res, 404, 'Not found');
const meme = store.get(adminMediaMatch[2]);
if (!meme) return sendText(res, 404, 'Not found');
res.setHeader('Content-Type', meme.mime);
res.setHeader('Content-Length', String(meme.byteSize));
res.setHeader('Cache-Control', 'no-store');
res.setHeader('X-Content-Type-Options', 'nosniff');
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
}
const downloadMatch = url.pathname.match(/^\/download\/([a-f0-9]{64})$/);
if (req.method === 'GET' && downloadMatch) {
if (store.get(downloadMatch[1])?.status !== 'approved') return sendText(res, 404, 'Not found');
const updated = await store.incrementMetric(downloadMatch[1], 'downloadCount');
const meme = store.get(downloadMatch[1]);
if (!meme) return sendText(res, 404, 'Not found');
broadcastMetric(updated);
res.setHeader('Content-Type', meme.mime);
res.setHeader('Content-Disposition', `attachment; filename="${downloadName(meme)}"`);
res.setHeader('X-Content-Type-Options', 'nosniff');
return sendFile(res, store.absolutePath(meme.storageKey), { absolute: true });
}
if (req.method === 'GET' && url.pathname === '/healthz') {
return sendJson(res, 200, { ok: true });
}
return sendText(res, 404, 'Not found');
} catch (error) {
const status = error.statusCode || 500;
const message = status === 500 ? 'Internal server error.' : error.message;
return sendJson(res, status, { error: message });
}
});
server.listen(PORT, HOST, () => {
console.log(`The Meme Protocol listening on http://${HOST}:${PORT}`);
});
function positiveInt(value, fallback) {
const parsed = Number.parseInt(value || '', 10);
return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
}
function downloadName(meme) {
return `meme-protocol-${meme.id.slice(0, 12)}.${meme.ext}`;
}
function broadcastMetric(meme) {
const payload = JSON.stringify({
id: meme.id,
viewCount: meme.viewCount,
downloadCount: meme.downloadCount
});
for (const res of events) {
res.write(`event: metric\ndata: ${payload}\n\n`);
}
}
function isAdminRequest(req) {
return isAdminToken(req.headers['x-admin-token'] || '');
}
function isAdminToken(value) {
const received = Buffer.from(String(value));
const expected = Buffer.from(ADMIN_TOKEN);
return received.length === expected.length && crypto.timingSafeEqual(received, expected);
}
async function readJsonBody(req, maxBytes) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > maxBytes) {
const error = new Error('Request body is too large.');
error.statusCode = 413;
throw error;
}
chunks.push(chunk);
}
if (total === 0) return {};
return JSON.parse(Buffer.concat(chunks, total).toString('utf8'));
}
function safeIds(ids) {
if (!Array.isArray(ids)) return [];
return ids.filter((id) => typeof id === 'string' && /^[a-f0-9]{64}$/.test(id));
}
function clientIp(req) {
if (process.env.TRUST_PROXY === 'true') {
const forwarded = String(req.headers['x-forwarded-for'] || '').split(',')[0].trim();
if (forwarded) return forwarded;
}
return req.socket.remoteAddress || 'unknown';
}
+6
View File
@@ -0,0 +1,6 @@
export class HttpError extends Error {
constructor(statusCode, message) {
super(message);
this.statusCode = statusCode;
}
}
+67
View File
@@ -0,0 +1,67 @@
import fs from 'node:fs';
import path from 'node:path';
const PUBLIC_ROOT = path.resolve('./public');
const TYPES = new Map([
['.html', 'text/html; charset=utf-8'],
['.css', 'text/css; charset=utf-8'],
['.js', 'text/javascript; charset=utf-8'],
['.png', 'image/png'],
['.jpg', 'image/jpeg'],
['.jpeg', 'image/jpeg'],
['.webp', 'image/webp'],
['.ico', 'image/x-icon']
]);
export function withSecurityHeaders(res) {
res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
res.setHeader('Referrer-Policy', 'same-origin');
res.setHeader('X-Content-Type-Options', 'nosniff');
res.setHeader('X-Frame-Options', 'DENY');
res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');
res.setHeader(
'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'"
);
}
export function sendJson(res, statusCode, payload) {
const body = Buffer.from(JSON.stringify(payload));
res.writeHead(statusCode, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': String(body.length)
});
res.end(body);
}
export function sendText(res, statusCode, message) {
const body = Buffer.from(message);
res.writeHead(statusCode, {
'Content-Type': 'text/plain; charset=utf-8',
'Content-Length': String(body.length)
});
res.end(body);
}
export function sendFile(res, filePath, options = {}) {
const resolved = options.absolute ? path.resolve(filePath) : path.resolve(filePath);
if (!options.absolute && !resolved.startsWith(PUBLIC_ROOT + path.sep) && resolved !== path.join(PUBLIC_ROOT, 'index.html')) {
return sendText(res, 403, 'Forbidden');
}
fs.stat(resolved, (statError, stats) => {
if (statError || !stats.isFile()) {
return sendText(res, 404, 'Not found');
}
if (!res.getHeader('Content-Type')) {
res.setHeader('Content-Type', TYPES.get(path.extname(resolved).toLowerCase()) || 'application/octet-stream');
}
if (!res.getHeader('Content-Length')) {
res.setHeader('Content-Length', String(stats.size));
}
fs.createReadStream(resolved)
.on('error', () => sendText(res, 500, 'File read failed'))
.pipe(res);
});
}
+93
View File
@@ -0,0 +1,93 @@
import { HttpError } from './errors.js';
const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
export function validateImage(buffer, limits) {
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
throw new HttpError(400, 'Upload is empty.');
}
if (buffer.length > limits.maxBytes) {
throw new HttpError(413, 'Image exceeds the 5 MB limit.');
}
const image = detectImage(buffer);
if (!image) {
throw new HttpError(415, 'Only PNG and JPEG images are accepted.');
}
if (image.width < 1 || image.height < 1) {
throw new HttpError(400, 'Image dimensions are invalid.');
}
if (image.width > limits.maxWidth || image.height > limits.maxHeight) {
throw new HttpError(413, `Image dimensions must be at most ${limits.maxWidth}x${limits.maxHeight}.`);
}
if (image.width * image.height > limits.maxPixels) {
throw new HttpError(413, 'Image has too many pixels.');
}
if (limits.requireSquare && image.width !== image.height) {
throw new HttpError(422, 'Image must be square.');
}
return { ...image, byteSize: buffer.length };
}
export function detectImage(buffer) {
if (buffer.subarray(0, 8).equals(PNG_SIGNATURE) && buffer.length >= 24) {
return {
format: 'png',
ext: 'png',
mime: 'image/png',
width: buffer.readUInt32BE(16),
height: buffer.readUInt32BE(20)
};
}
if (buffer.length > 4 && buffer[0] === 0xff && buffer[1] === 0xd8) {
const jpeg = readJpegDimensions(buffer);
if (jpeg) {
return {
format: 'jpeg',
ext: 'jpg',
mime: 'image/jpeg',
width: jpeg.width,
height: jpeg.height
};
}
}
return null;
}
function readJpegDimensions(buffer) {
let offset = 2;
while (offset < buffer.length) {
while (buffer[offset] === 0xff) offset += 1;
const marker = buffer[offset];
offset += 1;
if (marker === 0xd9 || marker === 0xda) return null;
if (offset + 2 > buffer.length) return null;
const length = buffer.readUInt16BE(offset);
if (length < 2 || offset + length > buffer.length) return null;
if (isStartOfFrame(marker)) {
if (length < 7) return null;
return {
height: buffer.readUInt16BE(offset + 3),
width: buffer.readUInt16BE(offset + 5)
};
}
offset += length;
}
return null;
}
function isStartOfFrame(marker) {
return (
(marker >= 0xc0 && marker <= 0xc3) ||
(marker >= 0xc5 && marker <= 0xc7) ||
(marker >= 0xc9 && marker <= 0xcb) ||
(marker >= 0xcd && marker <= 0xcf)
);
}
+112
View File
@@ -0,0 +1,112 @@
const DEFAULT_MODEL = process.env.OPENAI_MODERATION_MODEL || 'gpt-4o-mini';
export async function moderateImage({ buffer, mime }) {
if (!process.env.OPENAI_API_KEY) {
return {
status: 'pending',
score: 50,
reason: 'AI moderation is not configured; queued for review.'
};
}
try {
const response = await fetch('https://api.openai.com/v1/responses', {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.OPENAI_API_KEY}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
model: DEFAULT_MODEL,
temperature: 0,
max_output_tokens: 220,
input: [
{
role: 'user',
content: [
{
type: 'input_text',
text: moderationPrompt()
},
{
type: 'input_image',
image_url: `data:${mime};base64,${buffer.toString('base64')}`
}
]
}
]
})
});
if (!response.ok) {
return {
status: 'pending',
score: 50,
reason: `AI moderation unavailable (${response.status}); queued for review.`
};
}
const payload = await response.json();
return normalizeDecision(parseJsonOutput(payload.output_text || extractOutputText(payload)));
} catch {
return {
status: 'pending',
score: 50,
reason: 'AI moderation failed; queued for review.'
};
}
}
function moderationPrompt() {
return [
'You are moderating image uploads for THE_MEME_PROTOCOL, a meme gallery.',
'This is a meme site. Images may be sarcastic, roasting, edgy, political, profane, absurd, or not PG-13.',
'Do not reject merely because a meme is rude, critical, weird, darkly humorous, or adult in tone.',
'Reject only if the image appears illegal or likely illegal to host or distribute, including child sexual content, sexual content involving minors, explicit non-consensual sexual content, bestiality, credible illegal activity instructions, terrorist or extremist recruitment, doxxing/private identity documents, or explicit threats that appear actionable.',
'If legality or age is ambiguous, choose pending.',
'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.',
'Return only compact JSON with keys: decision, score, reason.',
'decision must be one of: approved, pending, rejected.',
'Use approved only when it appears legal and score is at least 65.',
'Use pending for ambiguity, uncertainty, low meme relevance, or anything that needs human review.',
'Use rejected only for likely illegal content.'
].join('\n');
}
function parseJsonOutput(text) {
const trimmed = String(text || '').trim();
const match = trimmed.match(/\{[\s\S]*\}/);
if (!match) return null;
try {
return JSON.parse(match[0]);
} catch {
return null;
}
}
function normalizeDecision(raw) {
if (!raw || typeof raw !== 'object') {
return { status: 'pending', score: 50, reason: 'AI response was ambiguous; queued for review.' };
}
const decision = ['approved', 'pending', 'rejected'].includes(raw.decision) ? raw.decision : 'pending';
const score = Math.max(0, Math.min(100, Number.parseInt(raw.score, 10) || 50));
const reason = typeof raw.reason === 'string' && raw.reason.trim()
? raw.reason.trim().slice(0, 300)
: 'No moderation reason supplied.';
if (decision === 'approved' && score < 65) {
return { status: 'pending', score, reason: `${reason} Low MEME_CONSENSUS_SCORE queued for review.` };
}
return { status: decision, score, reason };
}
function extractOutputText(payload) {
const parts = [];
for (const item of payload.output || []) {
for (const content of item.content || []) {
if (content.type === 'output_text' && content.text) parts.push(content.text);
}
}
return parts.join('\n');
}
+85
View File
@@ -0,0 +1,85 @@
import { HttpError } from './errors.js';
export async function parseMultipartUpload(req, options) {
const contentType = req.headers['content-type'] || '';
const boundaryMatch = contentType.match(/multipart\/form-data;\s*boundary=(?:"([^"]+)"|([^;]+))/i);
if (!boundaryMatch) {
throw new HttpError(415, 'Expected multipart form data.');
}
const body = await readRequestBody(req, options.maxRequestBytes);
const boundary = Buffer.from(`--${boundaryMatch[1] || boundaryMatch[2]}`);
const parts = splitMultipart(body, boundary);
let upload = null;
for (const part of parts) {
const separator = part.indexOf('\r\n\r\n');
if (separator === -1) continue;
const headerText = part.subarray(0, separator).toString('latin1');
const content = trimTrailingCrlf(part.subarray(separator + 4));
const disposition = headerText.match(/^content-disposition:\s*form-data;\s*(.+)$/im);
if (!disposition) continue;
const attrs = parseDispositionAttrs(disposition[1]);
if (attrs.name !== options.fieldName || !attrs.filename) continue;
if (upload) throw new HttpError(400, 'Upload one image at a time.');
if (content.length > options.maxFileBytes) throw new HttpError(413, 'Image exceeds the 5 MB limit.');
upload = {
filename: cleanFilename(attrs.filename),
buffer: Buffer.from(content)
};
}
if (!upload) throw new HttpError(400, 'Missing image file.');
return upload;
}
async function readRequestBody(req, maxBytes) {
const chunks = [];
let total = 0;
for await (const chunk of req) {
total += chunk.length;
if (total > maxBytes) {
throw new HttpError(413, 'Upload request is too large.');
}
chunks.push(chunk);
}
return Buffer.concat(chunks, total);
}
function splitMultipart(body, boundary) {
const parts = [];
let cursor = body.indexOf(boundary);
while (cursor !== -1) {
cursor += boundary.length;
if (body[cursor] === 0x2d && body[cursor + 1] === 0x2d) break;
if (body[cursor] === 0x0d && body[cursor + 1] === 0x0a) cursor += 2;
const next = body.indexOf(boundary, cursor);
if (next === -1) break;
parts.push(body.subarray(cursor, next));
cursor = next;
}
return parts;
}
function trimTrailingCrlf(buffer) {
if (buffer.length >= 2 && buffer[buffer.length - 2] === 0x0d && buffer[buffer.length - 1] === 0x0a) {
return buffer.subarray(0, -2);
}
return buffer;
}
function parseDispositionAttrs(value) {
const attrs = {};
for (const part of value.split(';')) {
const [rawKey, ...rawValue] = part.trim().split('=');
if (!rawKey || rawValue.length === 0) continue;
attrs[rawKey.toLowerCase()] = rawValue.join('=').trim().replace(/^"|"$/g, '');
}
return attrs;
}
function cleanFilename(filename) {
const base = filename.split(/[\\/]/).pop() || 'upload';
return base.replace(/[^\w.-]+/g, '_').slice(0, 120);
}
+50
View File
@@ -0,0 +1,50 @@
import sharp from 'sharp';
import { HttpError } from './errors.js';
const MAX_OUTPUT_DIMENSION = Number.parseInt(process.env.MAX_IMAGE_DIMENSION || '1600', 10);
const WEBP_QUALITY = Number.parseInt(process.env.WEBP_QUALITY || '85', 10);
export async function normalizeToWebp(buffer) {
let image;
try {
image = sharp(buffer, {
animated: false,
limitInputPixels: 20_000_000
});
} catch {
throw new HttpError(400, 'Image could not be decoded.');
}
const metadata = await image.metadata().catch(() => null);
if (!metadata?.width || !metadata?.height) {
throw new HttpError(400, 'Image could not be decoded.');
}
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
.rotate()
.resize(targetSize, targetSize, {
fit: 'cover',
withoutEnlargement: true
})
.webp({
quality: WEBP_QUALITY,
effort: 4
})
.toBuffer();
return {
buffer: output,
image: {
format: 'webp',
ext: 'webp',
mime: 'image/webp',
width: targetSize,
height: targetSize,
byteSize: output.length
}
};
}
+57
View File
@@ -0,0 +1,57 @@
import zlib from 'node:zlib';
export function encodePng(width, height, pixelAt) {
const stride = width * 4 + 1;
const raw = Buffer.alloc(stride * height);
for (let y = 0; y < height; y += 1) {
const row = y * stride;
raw[row] = 0;
for (let x = 0; x < width; x += 1) {
const [r, g, b, a = 255] = pixelAt(x, y, width, height);
const offset = row + 1 + x * 4;
raw[offset] = r;
raw[offset + 1] = g;
raw[offset + 2] = b;
raw[offset + 3] = a;
}
}
return Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
chunk('IHDR', ihdr(width, height)),
chunk('IDAT', zlib.deflateSync(raw)),
chunk('IEND', Buffer.alloc(0))
]);
}
function ihdr(width, height) {
const buffer = Buffer.alloc(13);
buffer.writeUInt32BE(width, 0);
buffer.writeUInt32BE(height, 4);
buffer[8] = 8;
buffer[9] = 6;
buffer[10] = 0;
buffer[11] = 0;
buffer[12] = 0;
return buffer;
}
function chunk(type, data) {
const typeBuffer = Buffer.from(type, 'ascii');
const length = Buffer.alloc(4);
length.writeUInt32BE(data.length, 0);
const crc = Buffer.alloc(4);
crc.writeUInt32BE(crc32(Buffer.concat([typeBuffer, data])), 0);
return Buffer.concat([length, typeBuffer, data, crc]);
}
function crc32(buffer) {
let crc = 0xffffffff;
for (const byte of buffer) {
crc ^= byte;
for (let bit = 0; bit < 8; bit += 1) {
crc = (crc >>> 1) ^ (0xedb88320 & -(crc & 1));
}
}
return (crc ^ 0xffffffff) >>> 0;
}
+47
View File
@@ -0,0 +1,47 @@
import { validateImage } from './image.js';
import { normalizeToWebp } from './normalize.js';
import { encodePng } from './png.js';
const DEMO_COUNT = 12;
export async function seedDemoMemes(store) {
if (store.count() > 0) return;
for (let index = 0; index < DEMO_COUNT; index += 1) {
const width = 960;
const height = 960;
const buffer = encodePng(width, height, pixelFactory(index));
const image = validateImage(buffer, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
});
const normalized = await normalizeToWebp(buffer);
const createdAt = new Date(Date.now() - index * 17 * 60 * 1000).toISOString();
await store.save({
buffer: normalized.buffer,
image: normalized.image,
originalName: `protocol-sample-${String(index + 1).padStart(2, '0')}.png`,
originalMime: image.mime,
createdAt,
demo: true
});
}
}
function pixelFactory(seed) {
return (x, y, width, height) => {
const nx = x / width;
const ny = y / height;
const grid = x % (32 + seed * 3) < 1 || y % (29 + seed * 2) < 1;
const diagonal = Math.abs(((x + y + seed * 37) % 180) - 90) < 2;
const ring = Math.abs(Math.hypot(nx - 0.5, ny - 0.5) - (0.18 + (seed % 4) * 0.05)) < 0.006;
const scan = y % 7 === 0;
const pulse = (Math.sin((x * (seed + 3) + y * 2) / 42) + 1) / 2;
const green = grid || diagonal || ring ? 230 : Math.round(18 + pulse * 52);
const blue = ring || (seed % 3 === 1 && diagonal) ? 210 : Math.round(18 + pulse * 30);
const red = scan ? 22 : Math.round(4 + pulse * 14);
return [red, green, blue, 255];
};
}
+213
View File
@@ -0,0 +1,213 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
export async function createStore({ dataDir }) {
const root = path.resolve(dataDir);
const indexDir = path.join(root, 'index');
const indexFile = path.join(indexDir, 'memes.jsonl');
await fs.mkdir(indexDir, { recursive: true });
const entries = await loadIndex(indexFile, root);
const byId = new Map(entries.map((entry) => [entry.id, entry]));
let writeChain = Promise.resolve();
return {
count(status) {
return status ? entries.filter((entry) => entry.status === status).length : entries.length;
},
get(id) {
return byId.get(id) || null;
},
absolutePath(storageKey) {
return path.join(root, storageKey);
},
list({ page, pageSize }) {
const visible = entries.filter((entry) => entry.status === 'approved');
const total = visible.length;
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const safePage = Math.min(page, totalPages);
const start = (safePage - 1) * pageSize;
return {
page: safePage,
pageSize,
total,
totalPages,
memes: visible.slice(start, start + pageSize).map(toPublicMeme)
};
},
listForReview({ status = 'pending' } = {}) {
return {
total: entries.filter((entry) => entry.status === status).length,
memes: entries.filter((entry) => entry.status === status).map(toPublicMeme)
};
},
async approve(ids) {
const approved = [];
for (const id of ids) {
const record = byId.get(id);
if (!record || record.status !== 'pending') continue;
record.status = 'approved';
record.reviewedAt = new Date().toISOString();
record.moderationReason = `${record.moderationReason || 'Queued for review.'} Admin approved.`;
approved.push(toPublicMeme(record));
}
if (approved.length > 0) {
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
await writeChain;
}
return approved;
},
async delete(ids) {
const deleted = [];
for (const id of ids) {
const index = entries.findIndex((entry) => entry.id === id);
if (index === -1) continue;
const [record] = entries.splice(index, 1);
byId.delete(id);
deleted.push(id);
await fs.rm(path.join(root, record.storageKey), { force: true });
await fs.rm(path.join(root, record.metaKey), { force: true });
}
if (deleted.length > 0) {
writeChain = writeChain.then(() => persistAll(root, indexFile, entries));
await writeChain;
}
return deleted;
},
async incrementMetric(id, metric) {
const record = byId.get(id);
if (!record) return null;
if (metric !== 'viewCount' && metric !== 'downloadCount') return toPublicMeme(record);
record[metric] = Number.isFinite(record[metric]) ? record[metric] + 1 : 1;
writeChain = writeChain.then(() => persistRecord(root, indexFile, entries, record));
await writeChain;
return toPublicMeme(record);
},
async save({
buffer,
image,
originalName,
createdAt = new Date().toISOString(),
demo = false,
status = 'approved',
moderationScore = demo ? 90 : 0,
moderationReason = '',
originalMime = image.mime
}) {
const id = crypto.createHash('sha256').update(buffer).digest('hex');
const existing = byId.get(id);
if (existing) return toPublicMeme(existing);
const date = createdAt.slice(0, 10).replaceAll('-', '/');
const shard = `${id.slice(0, 2)}/${id.slice(2, 4)}`;
const storageKey = `memes/${date}/${shard}/${id}.${image.ext}`;
const metaKey = `meta/${date}/${shard}/${id}.json`;
const record = {
id,
createdAt,
originalName,
byteSize: image.byteSize,
width: image.width,
height: image.height,
mime: image.mime,
originalMime,
ext: image.ext,
status,
moderationScore,
moderationReason,
viewCount: 0,
downloadCount: 0,
storageKey,
metaKey,
demo
};
await fs.mkdir(path.dirname(path.join(root, storageKey)), { recursive: true });
await fs.mkdir(path.dirname(path.join(root, metaKey)), { recursive: true });
await fs.writeFile(path.join(root, storageKey), buffer, { flag: 'wx' }).catch((error) => {
if (error.code !== 'EEXIST') throw error;
});
await persistRecord(root, indexFile, [...entries, record], record, { writeFileFlag: 'wx' });
entries.unshift(record);
entries.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
byId.set(id, record);
return toPublicMeme(record);
}
};
}
async function loadIndex(indexFile, root) {
let content = '';
try {
content = await fs.readFile(indexFile, 'utf8');
} catch (error) {
if (error.code !== 'ENOENT') throw error;
}
const recordsById = new Map();
for (const line of content.split('\n')) {
if (!line.trim()) continue;
try {
const record = JSON.parse(line);
record.status = record.status || 'approved';
record.moderationScore = Number.isFinite(record.moderationScore) ? record.moderationScore : 80;
record.moderationReason = record.moderationReason || '';
record.originalMime = record.originalMime || record.mime;
record.viewCount = Number.isFinite(record.viewCount) ? record.viewCount : 0;
record.downloadCount = Number.isFinite(record.downloadCount) ? record.downloadCount : 0;
const mediaPath = path.join(root, record.storageKey);
const relative = path.relative(root, mediaPath);
if (relative.startsWith('..') || path.isAbsolute(relative)) continue;
await fs.access(mediaPath);
recordsById.set(record.id, record);
} catch {
// Ignore corrupt index lines; individual metadata files remain on disk for recovery.
}
}
const records = [...recordsById.values()];
records.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
return records;
}
async function persistRecord(root, indexFile, entries, record, options = {}) {
await fs.writeFile(path.join(root, record.metaKey), `${JSON.stringify(record, null, 2)}\n`, {
flag: options.writeFileFlag || 'w'
}).catch((error) => {
if (options.writeFileFlag === 'wx' && error.code === 'EEXIST') return;
throw error;
});
await persistAll(root, indexFile, entries);
}
async function persistAll(root, indexFile, entries) {
await Promise.all(entries.map((entry) => fs.writeFile(
path.join(root, entry.metaKey),
`${JSON.stringify(entry, null, 2)}\n`
).catch((error) => {
if (error.code !== 'ENOENT') throw error;
})));
await fs.writeFile(indexFile, `${entries.map((entry) => JSON.stringify(entry)).join('\n')}\n`);
}
function toPublicMeme(record) {
return {
id: record.id,
createdAt: record.createdAt,
byteSize: record.byteSize,
width: record.width,
height: record.height,
mime: record.mime,
originalMime: record.originalMime,
status: record.status,
moderationScore: record.moderationScore,
moderationReason: record.moderationReason,
viewCount: record.viewCount,
downloadCount: record.downloadCount,
originalName: record.originalName,
url: `/media/${record.id}`,
downloadUrl: `/download/${record.id}`,
demo: Boolean(record.demo)
};
}
+66
View File
@@ -0,0 +1,66 @@
import crypto from 'node:crypto';
import fs from 'node:fs/promises';
import path from 'node:path';
import { HttpError } from './errors.js';
const HOUR_LIMIT = 5;
const DAY_LIMIT = 10;
const GLOBAL_DAY_LIMIT = 100;
export async function createUploadLimiter({ dataDir }) {
const filePath = path.join(path.resolve(dataDir), 'index', 'upload-limits.json');
await fs.mkdir(path.dirname(filePath), { recursive: true });
let state = await loadState(filePath);
let writeChain = Promise.resolve();
return {
async checkAndConsume(ip) {
const now = new Date();
const day = now.toISOString().slice(0, 10);
const hour = now.toISOString().slice(0, 13);
const key = crypto.createHash('sha256').update(ip || 'unknown').digest('hex');
if (state.day !== day) state = { day, global: 0, clients: {} };
const client = state.clients[key] || { day, dayCount: 0, hour, hourCount: 0 };
if (client.day !== day) {
client.day = day;
client.dayCount = 0;
}
if (client.hour !== hour) {
client.hour = hour;
client.hourCount = 0;
}
if (state.global >= GLOBAL_DAY_LIMIT) {
throw new HttpError(429, 'Daily site upload budget reached. Try again tomorrow.');
}
if (client.hourCount >= HOUR_LIMIT) {
throw new HttpError(429, 'Upload limit reached: 5 images per hour.');
}
if (client.dayCount >= DAY_LIMIT) {
throw new HttpError(429, 'Upload limit reached: 10 images per day.');
}
state.global += 1;
client.hourCount += 1;
client.dayCount += 1;
state.clients[key] = client;
writeChain = writeChain.then(() => fs.writeFile(filePath, `${JSON.stringify(state, null, 2)}\n`));
await writeChain;
return {
remainingHour: Math.max(0, HOUR_LIMIT - client.hourCount),
remainingDay: Math.max(0, DAY_LIMIT - client.dayCount),
remainingGlobalDay: Math.max(0, GLOBAL_DAY_LIMIT - state.global)
};
}
};
}
async function loadState(filePath) {
try {
return JSON.parse(await fs.readFile(filePath, 'utf8'));
} catch {
return { day: new Date().toISOString().slice(0, 10), global: 0, clients: {} };
}
}
+67
View File
@@ -0,0 +1,67 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { detectImage, validateImage } from '../src/image.js';
import { normalizeToWebp } from '../src/normalize.js';
import { encodePng } from '../src/png.js';
test('detects generated png dimensions', () => {
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
assert.deepEqual(detectImage(png), {
format: 'png',
ext: 'png',
mime: 'image/png',
width: 32,
height: 24
});
});
test('rejects unsupported payloads', () => {
assert.throws(
() => validateImage(Buffer.from('<svg></svg>'), {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
}),
/Only PNG/
);
});
test('rejects gif uploads', () => {
const gif = Buffer.from('47494638396101000100800000000000ffffff2c00000000010001000002024401003b', 'hex');
assert.throws(
() => validateImage(gif, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
}),
/Only PNG and JPEG/
);
});
test('rejects non-square images when square uploads are required', () => {
const png = encodePng(32, 24, () => [0, 255, 65, 255]);
assert.throws(
() => validateImage(png, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000,
requireSquare: true
}),
/square/
);
});
test('normalizes png uploads to square webp', async () => {
const png = encodePng(32, 32, () => [0, 255, 65, 255]);
const normalized = await normalizeToWebp(png);
assert.equal(normalized.image.mime, 'image/webp');
assert.equal(normalized.image.ext, 'webp');
assert.equal(normalized.image.width, 32);
assert.equal(normalized.image.height, 32);
assert.equal(normalized.buffer.subarray(0, 4).toString('ascii'), 'RIFF');
assert.equal(normalized.buffer.subarray(8, 12).toString('ascii'), 'WEBP');
});
+58
View File
@@ -0,0 +1,58 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import fs from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { createStore } from '../src/store.js';
import { validateImage } from '../src/image.js';
import { encodePng } from '../src/png.js';
test('stores memes in dated hash shards and paginates', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-protocol-'));
const store = await createStore({ dataDir: dir });
const buffer = encodePng(16, 16, () => [0, 255, 65, 255]);
const image = validateImage(buffer, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
});
const meme = await store.save({
buffer,
image,
originalName: 'test.png',
createdAt: '2026-05-08T12:00:00.000Z'
});
assert.equal(store.count(), 1);
assert.equal(store.list({ page: 1, pageSize: 12 }).memes[0].id, meme.id);
assert.equal(store.list({ page: 1, pageSize: 12 }).memes[0].viewCount, 0);
assert.match(store.get(meme.id).storageKey, /^memes\/2026\/05\/08\/[a-f0-9]{2}\/[a-f0-9]{2}\//);
await fs.access(path.join(dir, store.get(meme.id).storageKey));
});
test('increments view and download counters', async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'meme-protocol-'));
const store = await createStore({ dataDir: dir });
const buffer = encodePng(16, 16, () => [0, 255, 65, 255]);
const image = validateImage(buffer, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000,
requireSquare: true
});
const meme = await store.save({
buffer,
image,
originalName: 'test.png',
createdAt: '2026-05-08T12:00:00.000Z'
});
await store.incrementMetric(meme.id, 'viewCount');
await store.incrementMetric(meme.id, 'downloadCount');
const listed = store.list({ page: 1, pageSize: 12 }).memes[0];
assert.equal(listed.viewCount, 1);
assert.equal(listed.downloadCount, 1);
});