diff --git a/README.md b/README.md index 946f829..9511900 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/public/assets/app.js b/public/assets/app.js index 83888dc..34c4b8f 100644 --- a/public/assets/app.js +++ b/public/assets/app.js @@ -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.'; diff --git a/public/assets/styles.css b/public/assets/styles.css index 4e3885a..f879602 100644 --- a/public/assets/styles.css +++ b/public/assets/styles.css @@ -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; } diff --git a/server.js b/server.js index ca6eebf..4c5d66b 100644 --- a/server.js +++ b/server.js @@ -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 }); diff --git a/src/normalize.js b/src/normalize.js index 79007b7..a297870 100644 --- a/src/normalize.js +++ b/src/normalize.js @@ -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 } }; } diff --git a/src/seo.js b/src/seo.js index ff51d07..f9fb7aa 100644 --- a/src/seo.js +++ b/src/seo.js @@ -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}/`, diff --git a/tests/image.test.js b/tests/image.test.js index 800798e..7264cb8 100644 --- a/tests/image.test.js +++ b/tests/image.test.js @@ -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'); }); diff --git a/tests/store.test.js b/tests/store.test.js index 7450ad9..73aa41e 100644 --- a/tests/store.test.js +++ b/tests/store.test.js @@ -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,