init
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
data
|
||||||
|
node_modules
|
||||||
|
stitch_the_meme_protocol
|
||||||
@@ -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
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
.DS_Store
|
.DS_Store
|
||||||
stitch_the_meme_protocol/
|
stitch_the_meme_protocol/
|
||||||
|
data/
|
||||||
|
node_modules/
|
||||||
|
|||||||
+24
@@ -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"]
|
||||||
@@ -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.
|
||||||
@@ -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:
|
||||||
Generated
+613
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export class HttpError extends Error {
|
||||||
|
constructor(statusCode, message) {
|
||||||
|
super(message);
|
||||||
|
this.statusCode = statusCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
+67
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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: {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user