allow disproportionate memes

This commit is contained in:
2026-05-09 13:37:20 -03:00
parent e5fa16a88d
commit ff3db71084
8 changed files with 31 additions and 35 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ The server listens on `http://localhost:8080` by default.
- `OPENAI_MODERATION_MODEL`: moderation vision model, default `gpt-4o-mini` - `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` - `TRUST_PROXY`: set to `true` when running behind a trusted reverse proxy so upload limits use `X-Forwarded-For`
Uploads accept only square PNG and JPEG images. The server rejects files over 5 MB, images over `6000x6000`, and images over 20 million pixels. Accepted uploads are decoded, metadata-stripped, resized down to `1600x1600` if needed, and stored as WebP. Uploads accept PNG and JPEG images. The server rejects files over 5 MB, images over `6000x6000`, and images over 20 million pixels. Accepted uploads are decoded, metadata-stripped, resized so the longest edge is at most `1600px`, and stored as WebP.
Upload caps are 5 per hour per IP, 10 per day per IP, and 100 globally per day. AI-approved uploads publish immediately; ambiguous uploads are queued for the secret admin review page; likely illegal uploads are rejected immediately. Upload caps are 5 per hour per IP, 10 per day per IP, and 100 globally per day. 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: Files are stored under sharded date/hash paths:
-1
View File
@@ -193,7 +193,6 @@ async function validateClientFile(file) {
try { try {
const dimensions = await readImageDimensions(file); const dimensions = await readImageDimensions(file);
if (dimensions.width !== dimensions.height) return 'IMAGE MUST BE SQUARE.';
if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EXCEEDS 6000x6000.'; if (dimensions.width > 6000 || dimensions.height > 6000) return 'IMAGE EXCEEDS 6000x6000.';
} catch { } catch {
return 'IMAGE COULD NOT BE INSPECTED.'; return 'IMAGE COULD NOT BE INSPECTED.';
+4 -1
View File
@@ -296,6 +296,7 @@ a {
height: 100%; height: 100%;
display: block; display: block;
object-fit: cover; object-fit: cover;
object-position: center;
opacity: 0.9; opacity: 0.9;
transition: transform 700ms ease, opacity 200ms ease; transition: transform 700ms ease, opacity 200ms ease;
} }
@@ -632,8 +633,10 @@ dialog::backdrop {
.lightbox img { .lightbox img {
display: block; display: block;
width: 100%; width: auto;
max-width: 100%;
max-height: calc(100vh - 144px); max-height: calc(100vh - 144px);
margin: 0 auto;
object-fit: contain; object-fit: contain;
background: #000; background: #000;
} }
+1 -2
View File
@@ -163,8 +163,7 @@ const server = http.createServer(async (req, res) => {
maxBytes: UPLOAD_MAX_BYTES, maxBytes: UPLOAD_MAX_BYTES,
maxWidth: 6000, maxWidth: 6000,
maxHeight: 6000, maxHeight: 6000,
maxPixels: 20_000_000, maxPixels: 20_000_000
requireSquare: true
}); });
const normalized = await normalizeToWebp(upload.buffer); const normalized = await normalizeToWebp(upload.buffer);
const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime }); const moderation = await moderateImage({ buffer: normalized.buffer, mime: normalized.image.mime });
+9 -12
View File
@@ -19,32 +19,29 @@ export async function normalizeToWebp(buffer) {
if (!metadata?.width || !metadata?.height) { if (!metadata?.width || !metadata?.height) {
throw new HttpError(400, 'Image could not be decoded.'); throw new HttpError(400, 'Image could not be decoded.');
} }
if (metadata.width !== metadata.height) {
throw new HttpError(422, 'Image must be square.');
}
const targetSize = Math.min(metadata.width, MAX_OUTPUT_DIMENSION);
const output = await image const output = await image
.rotate() .rotate()
.resize(targetSize, targetSize, { .resize({
fit: 'cover', width: MAX_OUTPUT_DIMENSION,
height: MAX_OUTPUT_DIMENSION,
fit: 'inside',
withoutEnlargement: true withoutEnlargement: true
}) })
.webp({ .webp({
quality: WEBP_QUALITY, quality: WEBP_QUALITY,
effort: 4 effort: 4
}) })
.toBuffer(); .toBuffer({ resolveWithObject: true });
return { return {
buffer: output, buffer: output.data,
image: { image: {
format: 'webp', format: 'webp',
ext: 'webp', ext: 'webp',
mime: 'image/webp', mime: 'image/webp',
width: targetSize, width: output.info.width,
height: targetSize, height: output.info.height,
byteSize: output.length byteSize: output.data.length
} }
}; };
} }
+2 -2
View File
@@ -1,5 +1,5 @@
const SITE_NAME = 'The Meme Protocol'; const SITE_NAME = 'The Meme Protocol';
const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: square WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.'; const SITE_DESCRIPTION = 'A live, moderated meme stream for The Meme Protocol: WebP memes, MEME_CONSENSUS_SCORE ranking, and community review.';
const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree'; const REPO_URL = 'https://git.yoonect.com/Nautilus/bitsforfree';
export function publicBaseUrl(req) { export function publicBaseUrl(req) {
@@ -76,7 +76,7 @@ export function llmsTxt(baseUrl) {
return [ return [
'# The Meme Protocol', '# The Meme Protocol',
'', '',
'> A live, moderated meme gallery. Uploads are square PNG/JPEG inputs normalized to metadata-stripped WebP, scored with MEME_CONSENSUS_SCORE, and published only after AI or admin approval.', '> A live, moderated meme gallery. Uploads are PNG/JPEG inputs normalized to metadata-stripped WebP, scored with MEME_CONSENSUS_SCORE, and published only after AI or admin approval.',
'', '',
'Important URLs:', 'Important URLs:',
`- Site: ${baseUrl}/`, `- Site: ${baseUrl}/`,
+13 -14
View File
@@ -40,28 +40,27 @@ test('rejects gif uploads', () => {
); );
}); });
test('rejects non-square images when square uploads are required', () => { test('accepts non-square png uploads', () => {
const png = encodePng(32, 24, () => [0, 255, 65, 255]); const png = encodePng(32, 24, () => [0, 255, 65, 255]);
assert.throws( const image = validateImage(png, {
() => validateImage(png, { maxBytes: 5 * 1024 * 1024,
maxBytes: 5 * 1024 * 1024, maxWidth: 6000,
maxWidth: 6000, maxHeight: 6000,
maxHeight: 6000, maxPixels: 20_000_000
maxPixels: 20_000_000, });
requireSquare: true
}), assert.equal(image.width, 32);
/square/ assert.equal(image.height, 24);
);
}); });
test('normalizes png uploads to square webp', async () => { test('normalizes png uploads to webp while preserving aspect ratio', async () => {
const png = encodePng(32, 32, () => [0, 255, 65, 255]); const png = encodePng(32, 24, () => [0, 255, 65, 255]);
const normalized = await normalizeToWebp(png); const normalized = await normalizeToWebp(png);
assert.equal(normalized.image.mime, 'image/webp'); assert.equal(normalized.image.mime, 'image/webp');
assert.equal(normalized.image.ext, 'webp'); assert.equal(normalized.image.ext, 'webp');
assert.equal(normalized.image.width, 32); assert.equal(normalized.image.width, 32);
assert.equal(normalized.image.height, 32); assert.equal(normalized.image.height, 24);
assert.equal(normalized.buffer.subarray(0, 4).toString('ascii'), 'RIFF'); assert.equal(normalized.buffer.subarray(0, 4).toString('ascii'), 'RIFF');
assert.equal(normalized.buffer.subarray(8, 12).toString('ascii'), 'WEBP'); assert.equal(normalized.buffer.subarray(8, 12).toString('ascii'), 'WEBP');
}); });
+1 -2
View File
@@ -39,8 +39,7 @@ test('increments view and download counters', async () => {
maxBytes: 5 * 1024 * 1024, maxBytes: 5 * 1024 * 1024,
maxWidth: 6000, maxWidth: 6000,
maxHeight: 6000, maxHeight: 6000,
maxPixels: 20_000_000, maxPixels: 20_000_000
requireSquare: true
}); });
const meme = await store.save({ const meme = await store.save({
buffer, buffer,