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`
- `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.
Files are stored under sharded date/hash paths:
-1
View File
@@ -193,7 +193,6 @@ async function validateClientFile(file) {
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.';
+4 -1
View File
@@ -296,6 +296,7 @@ a {
height: 100%;
display: block;
object-fit: cover;
object-position: center;
opacity: 0.9;
transition: transform 700ms ease, opacity 200ms ease;
}
@@ -632,8 +633,10 @@ dialog::backdrop {
.lightbox img {
display: block;
width: 100%;
width: auto;
max-width: 100%;
max-height: calc(100vh - 144px);
margin: 0 auto;
object-fit: contain;
background: #000;
}
+1 -2
View File
@@ -163,8 +163,7 @@ const server = http.createServer(async (req, res) => {
maxBytes: UPLOAD_MAX_BYTES,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000,
requireSquare: true
maxPixels: 20_000_000
});
const normalized = await normalizeToWebp(upload.buffer);
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) {
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',
.resize({
width: MAX_OUTPUT_DIMENSION,
height: MAX_OUTPUT_DIMENSION,
fit: 'inside',
withoutEnlargement: true
})
.webp({
quality: WEBP_QUALITY,
effort: 4
})
.toBuffer();
.toBuffer({ resolveWithObject: true });
return {
buffer: output,
buffer: output.data,
image: {
format: 'webp',
ext: 'webp',
mime: 'image/webp',
width: targetSize,
height: targetSize,
byteSize: output.length
width: output.info.width,
height: output.info.height,
byteSize: output.data.length
}
};
}
+2 -2
View File
@@ -1,5 +1,5 @@
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';
export function publicBaseUrl(req) {
@@ -76,7 +76,7 @@ export function llmsTxt(baseUrl) {
return [
'# 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:',
`- 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]);
assert.throws(
() => validateImage(png, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000,
requireSquare: true
}),
/square/
);
const image = validateImage(png, {
maxBytes: 5 * 1024 * 1024,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000
});
assert.equal(image.width, 32);
assert.equal(image.height, 24);
});
test('normalizes png uploads to square webp', async () => {
const png = encodePng(32, 32, () => [0, 255, 65, 255]);
test('normalizes png uploads to webp while preserving aspect ratio', async () => {
const png = encodePng(32, 24, () => [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.image.height, 24);
assert.equal(normalized.buffer.subarray(0, 4).toString('ascii'), 'RIFF');
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,
maxWidth: 6000,
maxHeight: 6000,
maxPixels: 20_000_000,
requireSquare: true
maxPixels: 20_000_000
});
const meme = await store.save({
buffer,