diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fb37b3f --- /dev/null +++ b/.dockerignore @@ -0,0 +1,5 @@ +.git +.DS_Store +data +node_modules +stitch_the_meme_protocol diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..268700a --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore index acafe10..6726651 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .DS_Store stitch_the_meme_protocol/ +data/ +node_modules/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..ac086ac --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..d1ba4c7 --- /dev/null +++ b/README.md @@ -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/. + meta/YYYY/MM/DD/aa/bb/.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. diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..e567b3e --- /dev/null +++ b/compose.yaml @@ -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: diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..0c673ab --- /dev/null +++ b/package-lock.json @@ -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 + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..5721791 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/admin.html b/public/admin.html new file mode 100644 index 0000000..e7d3e82 --- /dev/null +++ b/public/admin.html @@ -0,0 +1,31 @@ + + + + + + The Meme Protocol Review + + + + + + + +
+
THE_MEME_PROTOCOL_REVIEW
+
+ + + +
+
+ +
+
+ PENDING_REVIEW + 0 ITEMS +
+
+
+ + diff --git a/public/assets/admin.js b/public/assets/admin.js new file mode 100644 index 0000000..cd64543 --- /dev/null +++ b/public/assets/admin.js @@ -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 = '
ADMIN_TOKEN_INVALID
'; + return; + } + reviewStatus.textContent = 'PENDING_REVIEW'; + reviewCount.textContent = `${payload.total} ITEMS`; + renderPending(payload.memes); +} + +function renderPending(memes) { + if (memes.length === 0) { + grid.innerHTML = '
NO_PENDING_MEMES
'; + return; + } + + grid.replaceChildren(...memes.map((meme) => { + const article = document.createElement('article'); + article.className = 'meme-card'; + article.innerHTML = ` +
+ + SCORE ${meme.moderationScore} +
+
+ Pending meme ${shortId(meme.id)} +
+
+

${escapeText(meme.moderationReason || 'Queued for review.')}

+
+
+
+ ${formatBytes(meme.byteSize)} +
+
+ + +
+
+ `; + 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; +} diff --git a/public/assets/app.js b/public/assets/app.js new file mode 100644 index 0000000..7b19fab --- /dev/null +++ b/public/assets/app.js @@ -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 = `
FEED_ERROR
`; + setLoader('FEED_ERROR', false); + } finally { + grid.ariaBusy = 'false'; + isLoading = false; + } +} + +function renderMemes(memes, options = {}) { + if (!options.append && memes.length === 0) { + grid.innerHTML = `
NO_MEMES_IN_STREAM
`; + return; + } + + const cards = memes.map((meme, index) => { + const article = document.createElement('article'); + article.className = 'meme-card'; + article.dataset.memeId = meme.id; + article.innerHTML = ` +
+
+ + ID: ${shortId(meme.id)} +
+ ${relativeAge(meme.createdAt)} +
+
+ Uploaded meme ${shortId(meme.id)} +
+
+
+ ${formatCount(meme.viewCount)} + ${formatCount(meme.downloadCount)} +
+
+ + +
+
+ `; + 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); + }); +} diff --git a/public/assets/styles.css b/public/assets/styles.css new file mode 100644 index 0000000..b0af089 --- /dev/null +++ b/public/assets/styles.css @@ -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; + } +} diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..5349ad5 --- /dev/null +++ b/public/index.html @@ -0,0 +1,78 @@ + + + + + + The Meme Protocol + + + + + + + +
+
THE_MEME_PROTOCOL
+ +
+ +
+
+ PROTOCOL_STREAM_V.1.04 + LIVE_FEED +
+ +
+ +
+ + FETCHING_NEXT_BLOCK... +
+
+ + + + + + + + + + + + + DOWNLOAD + + + diff --git a/server.js b/server.js new file mode 100644 index 0000000..ef8c849 --- /dev/null +++ b/server.js @@ -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'; +} diff --git a/src/errors.js b/src/errors.js new file mode 100644 index 0000000..e460601 --- /dev/null +++ b/src/errors.js @@ -0,0 +1,6 @@ +export class HttpError extends Error { + constructor(statusCode, message) { + super(message); + this.statusCode = statusCode; + } +} diff --git a/src/http.js b/src/http.js new file mode 100644 index 0000000..eba9769 --- /dev/null +++ b/src/http.js @@ -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); + }); +} diff --git a/src/image.js b/src/image.js new file mode 100644 index 0000000..f6d6a3a --- /dev/null +++ b/src/image.js @@ -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) + ); +} diff --git a/src/moderation.js b/src/moderation.js new file mode 100644 index 0000000..4105d3a --- /dev/null +++ b/src/moderation.js @@ -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'); +} diff --git a/src/multipart.js b/src/multipart.js new file mode 100644 index 0000000..05cade3 --- /dev/null +++ b/src/multipart.js @@ -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); +} diff --git a/src/normalize.js b/src/normalize.js new file mode 100644 index 0000000..79007b7 --- /dev/null +++ b/src/normalize.js @@ -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 + } + }; +} diff --git a/src/png.js b/src/png.js new file mode 100644 index 0000000..eadab2b --- /dev/null +++ b/src/png.js @@ -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; +} diff --git a/src/seed.js b/src/seed.js new file mode 100644 index 0000000..417ba22 --- /dev/null +++ b/src/seed.js @@ -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]; + }; +} diff --git a/src/store.js b/src/store.js new file mode 100644 index 0000000..a3e0b19 --- /dev/null +++ b/src/store.js @@ -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) + }; +} diff --git a/src/uploadLimits.js b/src/uploadLimits.js new file mode 100644 index 0000000..cf60b16 --- /dev/null +++ b/src/uploadLimits.js @@ -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: {} }; + } +} diff --git a/tests/image.test.js b/tests/image.test.js new file mode 100644 index 0000000..800798e --- /dev/null +++ b/tests/image.test.js @@ -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(''), { + 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'); +}); diff --git a/tests/store.test.js b/tests/store.test.js new file mode 100644 index 0000000..7450ad9 --- /dev/null +++ b/tests/store.test.js @@ -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); +});