Limit the images that previews keep.

A preview that fails the captcha or the form token keeps no image. A session keeps one image, the directory has a size limit, and abandoned images go at the next upload.
This commit is contained in:
tim
2026-09-26 15:27:51 +02:00
parent fe30a0aaa1
commit c4fcef21aa
8 changed files with 215 additions and 42 deletions
+78
View File
@@ -2,6 +2,7 @@
namespace Xxx\Tests\Pages;
use Xxx\Tests\Support\PageTestCase;
use Xxx\Tests\Support\TestCase;
// Sending a story from publish/ as a visitor
class SubmitTest extends PageTestCase {
@@ -125,4 +126,81 @@ class SubmitTest extends PageTestCase {
$this->assertStringNotContainsString('<script>alert(1)</script>', $page['body']);
$this->assertStringContainsString('value="&quot;&gt;&lt;script&gt;alert(1)&lt;/script&gt;"', $page['body']);
}
// Preview the story with an image; returns the answer
private function previewImage(array $fields = array()): 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())));
}
public function testAPreviewKeepsTheImageForTheSession(): void {
$page = $this->previewImage();
$this->assertStringContainsString('/display?pending=', $page['body']);
$uploads = $this->uploads();
$this->assertCount(1, $uploads);
$this->assertSame(basename($uploads[0]), $this->session()['pending_upload']);
}
public function testANewPreviewReplacesTheImage(): void {
$this->previewImage();
$first = $this->uploads();
$this->postFiles('/publish', $this->story(array('action' => 'preview', 'csrf_token' => $this->session()['csrf_token'])),
array('upload' => array('b.png', TestCase::png())));
$second = $this->uploads();
$this->assertCount(1, $second);
$this->assertNotSame($first, $second);
}
public function testAFailedCaptchaKeepsNoImage(): void {
$page = $this->previewImage(array('g-recaptcha-response' => 'fail'));
$this->assertStringContainsString('Please complete the captcha.', $page['body']);
$this->assertSame(array(), $this->uploads());
$this->assertArrayNotHasKey('pending_upload', $this->session());
}
public function testAFailedTokenKeepsNoImage(): void {
$page = $this->previewImage(array('csrf_token' => 'wrong'));
$this->assertStringContainsString('Your session has expired.', $page['body']);
$this->assertSame(array(), $this->uploads());
}
public function testAFullUploadDirectoryKeepsNoImage(): void {
// tests/php/fixtures/test.env sets UPLOAD_DIR_LIMIT_MB=1
mkdir(self::$state . '/tmp/uploads', 0700);
$filler = self::$state . '/tmp/uploads/' . str_repeat('f', 32);
file_put_contents($filler, str_repeat('x', 1024 * 1024));
$page = $this->previewImage();
$this->assertStringContainsString('The site cannot keep more images just now.', $page['body']);
$this->assertSame(array($filler), $this->uploads());
}
public function testAbandonedUploadsGoAtTheNextUpload(): void {
mkdir(self::$state . '/tmp/uploads', 0700);
$old = self::$state . '/tmp/uploads/' . str_repeat('a', 32);
$recent = self::$state . '/tmp/uploads/' . str_repeat('b', 32);
file_put_contents($old, 'x');
file_put_contents($recent, 'x');
touch($old, time() - 86401);
$this->previewImage();
$this->assertFileDoesNotExist($old);
$this->assertFileExists($recent);
$this->assertCount(2, $this->uploads());
}
public function testThePreviewedImageIsSavedThenRemoved(): void {
$this->previewImage();
$this->post('/publish', $this->story(array('action' => 'submit', 'uploaded' => 'pending', 'csrf_token' => $this->session()['csrf_token'])));
$this->assertSame(TestCase::png(), $this->tables()['queue'][0]['img']);
$this->assertSame(array(), $this->uploads());
$this->assertArrayNotHasKey('pending_upload', $this->session());
}
public function testSubmittingWithAnImageKeepsNoFile(): void {
$token = $this->csrfToken($this->get('/publish')['body']);
$this->postFiles('/publish', $this->story(array('action' => 'submit', 'csrf_token' => $token, 'g-recaptcha-response' => 'pass')),
array('upload' => array('a.png', TestCase::png())));
$this->assertSame(TestCase::png(), $this->tables()['queue'][0]['img']);
$this->assertSame(array(), $this->uploads());
}
}
+23 -2
View File
@@ -161,13 +161,24 @@ abstract class PageTestCase extends \PHPUnit\Framework\TestCase {
* name => list of values, 'body' => ...). Cookies go and come back as a
* browser sends them, without regard to domain or the secure flag.
*/
protected function request(string $method, string $path, array $post = array(), array $headers = array()): array {
protected function request(string $method, string $path, array $post = array(), array $headers = array(), array $files = array()): array {
if ($this->cookies) {
$headers['Cookie'] = implode('; ', array_map(function ($name, $value) { return "$name=" . rawurlencode($value); },
array_keys($this->cookies), $this->cookies));
}
$options = array('method' => $method, 'follow_location' => 0, 'ignore_errors' => TRUE, 'timeout' => 30);
if ($method === 'POST') {
if ($method === 'POST' && $files) {
// A multipart form; $files holds field => array(file name, content)
$boundary = 'xxx-' . bin2hex(random_bytes(8));
$body = '';
foreach ($post as $name => $value)
$body .= "--$boundary\r\nContent-Disposition: form-data; name=\"$name\"\r\n\r\n$value\r\n";
foreach ($files as $name => list($filename, $content))
$body .= "--$boundary\r\nContent-Disposition: form-data; name=\"$name\"; filename=\"$filename\"\r\n"
. "Content-Type: application/octet-stream\r\n\r\n$content\r\n";
$options['content'] = $body . "--$boundary--\r\n";
$headers['Content-Type'] = "multipart/form-data; boundary=$boundary";
} else if ($method === 'POST') {
$options['content'] = http_build_query($post);
$headers['Content-Type'] = 'application/x-www-form-urlencoded';
}
@@ -207,6 +218,16 @@ abstract class PageTestCase extends \PHPUnit\Framework\TestCase {
return $this->request('POST', $path, $post, $headers);
}
// A POST with files: $files holds field => array(file name, content)
protected function postFiles(string $path, array $post, array $files): array {
return $this->request('POST', $path, $post, array(), $files);
}
// The files in the upload directory of the server
protected function uploads(): array {
return glob(self::$state . '/tmp/uploads/*') ?: array();
}
protected function header(array $response, string $name): ?string {
return $response['headers'][strtolower($name)][0] ?? NULL;
}
+54 -18
View File
@@ -88,28 +88,64 @@ class UploadTest extends TestCase {
$this->assertFalse(save_uploaded_file());
}
public function testPruneDeletesTheSavedFileAndOldUploads(): void {
$saved = $this->pending();
$old = upload_dir() . '/' . str_repeat('c', 32);
$new = upload_dir() . '/' . str_repeat('d', 32);
$other = upload_dir() . '/keep-me';
foreach (array($old, $new, $other) as $file)
file_put_contents($file, 'x');
touch($old, time() - 86401);
touch($other, time() - 86401);
// Files in the upload directory: an old upload, a new one and a file that is not an upload
private function others(): array {
$files = array('old' => upload_dir() . '/' . str_repeat('c', 32), 'new' => upload_dir() . '/' . str_repeat('d', 32),
'other' => upload_dir() . '/keep-me');
foreach ($files as $file)
file_put_contents($file, 'xx');
touch($files['old'], time() - 86401);
touch($files['other'], time() - 86401);
return $files;
}
prune_images($saved);
public function testPruneImagesDeletesTheSessionUploadAndOldUploads(): void {
$saved = $this->pending();
$files = $this->others();
prune_images();
$this->assertFileDoesNotExist($saved);
$this->assertFileDoesNotExist($old);
$this->assertFileExists($new);
$this->assertFileExists($other);
$this->assertFileDoesNotExist($files['old']);
$this->assertFileExists($files['new']);
$this->assertFileExists($files['other']);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
public function testPruneLeavesFilesOutsideTheUploadDirectory(): void {
$outside = $this->tmp . '/' . str_repeat('e', 32);
file_put_contents($outside, 'x');
prune_images($outside);
$this->assertFileExists($outside);
public function testPruneUploadsKeepsTheSessionUploadUntilItIsOld(): void {
$pending = $this->pending();
prune_uploads();
$this->assertFileExists($pending);
touch($pending, time() - 86401);
prune_uploads();
$this->assertFileDoesNotExist($pending);
}
public function testRemovePendingUploadTouchesOnlyTheSessionFile(): void {
$files = $this->others();
remove_pending_upload();
$pending = $this->pending();
remove_pending_upload();
$this->assertFileDoesNotExist($pending);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
foreach ($files as $file)
$this->assertFileExists($file);
}
public function testUploadDirSizeCountsOnlyUploads(): void {
$this->others();
$this->pending('12345');
$this->assertSame(2 + 2 + 5, upload_dir_size());
}
public function testStoreUploadPrunesAndDropsTheLastUploadFirst(): void {
$last = $this->pending();
$files = $this->others();
$file = $this->tmp . '/local';
file_put_contents($file, 'x');
// Not a real upload, so nothing is kept, but the old files still go
$this->assertFalse(store_upload($file));
$this->assertFileDoesNotExist($last);
$this->assertFileDoesNotExist($files['old']);
$this->assertFileExists($files['new']);
$this->assertArrayNotHasKey('pending_upload', $_SESSION);
}
}
+1
View File
@@ -8,4 +8,5 @@ RECAPTCHA_SITE_KEY=test-site-key
RECAPTCHA_SECRET=test-secret
GOOGLE_ANALYTICS_ID=
UPLOAD_DIR=
UPLOAD_DIR_LIMIT_MB=1
DISPLAY_ERRORS=