From 504d36364292848b49d20a22bacd44d1bc15b3f3 Mon Sep 17 00:00:00 2001 From: Timothy Allen Date: Sat, 26 Sep 2026 10:29:39 +0200 Subject: [PATCH] Limit each image to 1 MB. UPLOAD_MAX_MB changes the limit, and UPLOAD_MAX_MB=none removes it. --- .env.example | 4 +++ get_data.lib.php | 18 +++++++++++ publish/post.lib.php | 10 +++++- tests/php/Pages/SubmitTest.php | 10 ++++-- tests/php/Unit/ValidateDataTest.php | 49 +++++++++++++++++++++++++++-- 5 files changed, 86 insertions(+), 5 deletions(-) diff --git a/.env.example b/.env.example index 216e30a..e9cf63f 100644 --- a/.env.example +++ b/.env.example @@ -25,6 +25,10 @@ GOOGLE_ANALYTICS_ID= # web root. The default is xxx-uploads in the system temporary directory. UPLOAD_DIR= +# The largest image a story can have, in megabytes (default 1). Set it +# to none for no limit. PHP's upload_max_filesize still applies. +UPLOAD_MAX_MB= + # The most that the upload directory holds, in megabytes (default 100). # Past this, previews do not keep new images. Abandoned uploads are # removed after a day. diff --git a/get_data.lib.php b/get_data.lib.php index 1e6e603..5a62561 100644 --- a/get_data.lib.php +++ b/get_data.lib.php @@ -373,6 +373,24 @@ function upload_dir() { return $dir; } +/* + * The largest image a story can have, in bytes, from UPLOAD_MAX_MB (1 MB + * by default). NULL means no limit, which UPLOAD_MAX_MB=none gives. + */ +function upload_max_bytes() { + $value = strtolower(trim((string)config('UPLOAD_MAX_MB', ''))); + if ($value === 'none') + return NULL; + if (!is_numeric($value) || (float)$value <= 0) + $value = 1; + return (int)((float)$value * 1024 * 1024); +} + +// The same in megabytes for a message, such as "1" or "2.5" +function upload_max_mb() { + return rtrim(rtrim(number_format(upload_max_bytes() / 1048576, 2, '.', ''), '0'), '.'); +} + function is_upload_name($name) { return is_string($name) && preg_match('/^[0-9a-f]{32}\z/', $name); } diff --git a/publish/post.lib.php b/publish/post.lib.php index d813a86..afc0e33 100644 --- a/publish/post.lib.php +++ b/publish/post.lib.php @@ -206,10 +206,18 @@ function validate_data() { $validate['story'] = "You've tried to remove an image without replacing it with text."; } + $upload_max = upload_max_bytes(); + $too_large = htmlspecialchars((string)($_FILES['upload']['name'] ?? '')) . + ($upload_max === NULL ? ' is larger than the server accepts.' : ' is larger than '.upload_max_mb().' MB.') . + ' Please choose a smaller image.'; // 0 == no error; 4 == no file; all others are errors. - if ($upload_error > 0 && + if ($upload_error === UPLOAD_ERR_INI_SIZE || $upload_error === UPLOAD_ERR_FORM_SIZE) { + $validate['upload'] = $too_large; + } else if ($upload_error > 0 && $upload_error !== 4) { $validate['upload'] = 'There was an error uploading the file ('.$upload_error.')'; + } else if ($upload_tmp && $upload_max !== NULL && (int)@filesize($upload_tmp) > $upload_max) { + $validate['upload'] = $too_large; } else if ($upload_tmp) { $upload = getimagesize($upload_tmp); if (!$upload) diff --git a/tests/php/Pages/SubmitTest.php b/tests/php/Pages/SubmitTest.php index 7bbf167..cf5dab4 100644 --- a/tests/php/Pages/SubmitTest.php +++ b/tests/php/Pages/SubmitTest.php @@ -112,6 +112,12 @@ class SubmitTest extends PageTestCase { $this->assertSame(array(), $this->queries('/^INSERT/')); } + public function testATooLargeImageIsRefused(): void { + $page = $this->previewImage(array(), str_pad(TestCase::png(), 1024 * 1024 + 1, "\0")); + $this->assertStringContainsString('a.png is larger than 1 MB. Please choose a smaller image.', $page['body']); + $this->assertSame(array(), $this->uploads()); + } + public function testAnArrayActionIsIgnored(): void { $page = $this->post('/publish', array('action' => array('post'))); $this->assertSame(200, $page['status']); @@ -128,10 +134,10 @@ class SubmitTest extends PageTestCase { } // Preview the story with an image; returns the answer - private function previewImage(array $fields = array()): array { + private function previewImage(array $fields = array(), ?string $image = NULL): array { $token = $this->csrfToken($this->get('/publish')['body']); return $this->postFiles('/publish', $this->story($fields + array('action' => 'preview', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')), - array('upload' => array('a.png', TestCase::png()))); + array('upload' => array('a.png', $image ?? TestCase::png()))); } public function testAPreviewKeepsTheImageForTheSession(): void { diff --git a/tests/php/Unit/ValidateDataTest.php b/tests/php/Unit/ValidateDataTest.php index 8a0eec6..248adb9 100644 --- a/tests/php/Unit/ValidateDataTest.php +++ b/tests/php/Unit/ValidateDataTest.php @@ -59,8 +59,53 @@ class ValidateDataTest extends TestCase { } public function testUploadErrorsAreReported(): void { - $_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => '', 'error' => UPLOAD_ERR_INI_SIZE); - $this->assertSame(array('upload' => 'There was an error uploading the file (1)'), $this->validate(array())); + $_FILES['upload'] = array('name' => 'a.png', 'tmp_name' => '', 'error' => UPLOAD_ERR_PARTIAL); + $this->assertSame(array('upload' => 'There was an error uploading the file (3)'), $this->validate(array())); + } + + public function testAFileThatPhpRefusedAsTooLargeIsReported(): void { + foreach (array(UPLOAD_ERR_INI_SIZE, UPLOAD_ERR_FORM_SIZE) as $error) { + $_FILES['upload'] = array('name' => 'big.png', 'tmp_name' => '', 'error' => $error); + $this->assertSame(array('upload' => 'big.png is larger than 1 MB. Please choose a smaller image.'), $this->validate(array())); + } + } + + // An image of $bytes bytes: a PNG with padding after its end + private function imageOf(int $bytes): void { + $file = $this->tmp . '/upload'; + file_put_contents($file, str_pad(self::png(), $bytes, "\0")); + $_FILES['upload'] = array('name' => 'big.png', 'tmp_name' => $file, 'error' => UPLOAD_ERR_OK); + } + + public function testImagesCanBeOneMegabyte(): void { + $this->imageOf(1024 * 1024); + $this->assertSame(array(), $this->validate(array())); + $this->imageOf(1024 * 1024 + 1); + $this->assertSame(array('upload' => '<b>big</b>.png is larger than 1 MB. Please choose a smaller image.'), $this->validate(array())); + } + + public function testTheImageLimitComesFromTheConfiguration(): void { + $this->setEnv('UPLOAD_MAX_MB', '2.5'); + $this->imageOf(2 * 1024 * 1024); + $this->assertSame(array(), $this->validate(array())); + $this->imageOf((int)(2.5 * 1024 * 1024) + 1); + $this->assertSame(array('upload' => '<b>big</b>.png is larger than 2.5 MB. Please choose a smaller image.'), $this->validate(array())); + } + + public function testNoneMeansNoImageLimit(): void { + $this->setEnv('UPLOAD_MAX_MB', 'None'); + $this->assertNull(upload_max_bytes()); + $this->imageOf(3 * 1024 * 1024); + $this->assertSame(array(), $this->validate(array())); + $_FILES['upload'] = array('name' => 'big.png', 'tmp_name' => '', 'error' => UPLOAD_ERR_INI_SIZE); + $this->assertSame(array('upload' => 'big.png is larger than the server accepts. Please choose a smaller image.'), $this->validate(array())); + } + + public function testABadImageLimitMeansTheDefault(): void { + foreach (array('', 'lots', '0', '-2') as $value) { + $this->setEnv('UPLOAD_MAX_MB', $value); + $this->assertSame(1024 * 1024, upload_max_bytes(), $value); + } } public function testNoFileIsNotAnError(): void {