3 namespace Tests\Uploads;
5 use BookStack\Entities\Repos\PageRepo;
6 use BookStack\Uploads\Image;
7 use BookStack\Uploads\ImageService;
8 use Illuminate\Support\Str;
11 class ImageTest extends TestCase
13 public function test_image_upload()
15 $page = $this->entities->page();
16 $admin = $this->users->admin();
17 $this->actingAs($admin);
19 $imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
20 $relPath = $imgDetails['path'];
22 $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image found at path: ' . public_path($relPath));
24 $this->files->deleteAtRelativePath($relPath);
26 $this->assertDatabaseHas('images', [
27 'url' => $this->baseUrl . $relPath,
29 'uploaded_to' => $page->id,
31 'created_by' => $admin->id,
32 'updated_by' => $admin->id,
33 'name' => $imgDetails['name'],
37 public function test_image_display_thumbnail_generation_does_not_increase_image_size()
39 $page = $this->entities->page();
40 $admin = $this->users->admin();
41 $this->actingAs($admin);
43 $originalFile = $this->files->testFilePath('compressed.png');
44 $originalFileSize = filesize($originalFile);
45 $imgDetails = $this->files->uploadGalleryImageToPage($this, $page, 'compressed.png');
46 $relPath = $imgDetails['path'];
48 $this->assertTrue(file_exists(public_path($relPath)), 'Uploaded image found at path: ' . public_path($relPath));
49 $displayImage = $imgDetails['response']->thumbs->display;
51 $displayImageRelPath = implode('/', array_slice(explode('/', $displayImage), 3));
52 $displayImagePath = public_path($displayImageRelPath);
53 $displayFileSize = filesize($displayImagePath);
55 $this->files->deleteAtRelativePath($relPath);
56 $this->files->deleteAtRelativePath($displayImageRelPath);
58 $this->assertEquals($originalFileSize, $displayFileSize, 'Display thumbnail generation should not increase image size');
61 public function test_image_display_thumbnail_generation_for_apng_images_uses_original_file()
63 $page = $this->entities->page();
64 $admin = $this->users->admin();
65 $this->actingAs($admin);
67 $imgDetails = $this->files->uploadGalleryImageToPage($this, $page, 'animated.png');
68 $this->files->deleteAtRelativePath($imgDetails['path']);
70 $this->assertStringContainsString('thumbs-', $imgDetails['response']->thumbs->gallery);
71 $this->assertStringNotContainsString('thumbs-', $imgDetails['response']->thumbs->display);
74 public function test_image_edit()
76 $editor = $this->users->editor();
77 $this->actingAs($editor);
79 $imgDetails = $this->files->uploadGalleryImageToPage($this, $this->entities->page());
80 $image = Image::query()->first();
82 $newName = Str::random();
83 $update = $this->put('/images/' . $image->id, ['name' => $newName]);
84 $update->assertSuccessful();
85 $update->assertSee($newName);
87 $this->files->deleteAtRelativePath($imgDetails['path']);
89 $this->assertDatabaseHas('images', [
95 public function test_image_file_update()
97 $page = $this->entities->page();
100 $imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
101 $relPath = $imgDetails['path'];
103 $newUpload = $this->files->uploadedImage('updated-image.png', 'compressed.png');
104 $this->assertFileEquals($this->files->testFilePath('test-image.png'), public_path($relPath));
106 $imageId = $imgDetails['response']->id;
107 $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
110 $this->assertFileEquals($this->files->testFilePath('compressed.png'), public_path($relPath));
112 $this->files->deleteAtRelativePath($relPath);
115 public function test_image_file_update_does_not_allow_change_in_image_extension()
117 $page = $this->entities->page();
120 $imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
121 $relPath = $imgDetails['path'];
122 $newUpload = $this->files->uploadedImage('updated-image.jpg', 'compressed.png');
124 $imageId = $imgDetails['response']->id;
125 $this->call('PUT', "/images/{$imageId}/file", [], [], ['file' => $newUpload])
127 "message" => "Image file replacements must be of the same type",
131 $this->files->deleteAtRelativePath($relPath);
134 public function test_gallery_get_list_format()
138 $imgDetails = $this->files->uploadGalleryImageToPage($this, $this->entities->page());
139 $image = Image::query()->first();
141 $pageId = $imgDetails['page']->id;
142 $firstPageRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}");
143 $firstPageRequest->assertSuccessful();
144 $this->withHtml($firstPageRequest)->assertElementExists('div');
145 $firstPageRequest->assertSuccessful()->assertSeeText($image->name);
147 $secondPageRequest = $this->get("/images/gallery?page=2&uploaded_to={$pageId}");
148 $secondPageRequest->assertSuccessful();
149 $this->withHtml($secondPageRequest)->assertElementNotExists('div');
151 $namePartial = substr($imgDetails['name'], 0, 3);
152 $searchHitRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
153 $searchHitRequest->assertSuccessful()->assertSee($imgDetails['name']);
155 $namePartial = Str::random(16);
156 $searchFailRequest = $this->get("/images/gallery?page=1&uploaded_to={$pageId}&search={$namePartial}");
157 $searchFailRequest->assertSuccessful()->assertDontSee($imgDetails['name']);
158 $searchFailRequest->assertSuccessful();
159 $this->withHtml($searchFailRequest)->assertElementNotExists('div');
162 public function test_image_gallery_lists_for_draft_page()
164 $this->actingAs($this->users->editor());
165 $draft = $this->entities->newDraftPage();
166 $this->files->uploadGalleryImageToPage($this, $draft);
167 $image = Image::query()->where('uploaded_to', '=', $draft->id)->firstOrFail();
169 $resp = $this->get("/images/gallery?page=1&uploaded_to={$draft->id}");
170 $resp->assertSee($image->getThumb(150, 150));
173 public function test_image_usage()
175 $page = $this->entities->page();
176 $editor = $this->users->editor();
177 $this->actingAs($editor);
179 $imgDetails = $this->files->uploadGalleryImageToPage($this, $page);
181 $image = Image::query()->first();
182 $page->html = '<img src="' . $image->url . '">';
185 $usage = $this->get('/images/edit/' . $image->id . '?delete=true');
186 $usage->assertSuccessful();
187 $usage->assertSeeText($page->name);
188 $usage->assertSee($page->getUrl());
190 $this->files->deleteAtRelativePath($imgDetails['path']);
193 public function test_php_files_cannot_be_uploaded()
195 $page = $this->entities->page();
196 $admin = $this->users->admin();
197 $this->actingAs($admin);
199 $fileName = 'bad.php';
200 $relPath = $this->files->expectedImagePath('gallery', $fileName);
201 $this->files->deleteAtRelativePath($relPath);
203 $file = $this->files->imageFromBase64File('bad-php.base64', $fileName);
204 $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
205 $upload->assertStatus(500);
206 $this->assertStringContainsString('The file must have a valid & supported image extension', $upload->json('message'));
208 $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded php file was uploaded but should have been stopped');
210 $this->assertDatabaseMissing('images', [
216 public function test_php_like_files_cannot_be_uploaded()
218 $page = $this->entities->page();
219 $admin = $this->users->admin();
220 $this->actingAs($admin);
222 $fileName = 'bad.phtml';
223 $relPath = $this->files->expectedImagePath('gallery', $fileName);
224 $this->files->deleteAtRelativePath($relPath);
226 $file = $this->files->imageFromBase64File('bad-phtml.base64', $fileName);
227 $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
228 $upload->assertStatus(500);
229 $this->assertStringContainsString('The file must have a valid & supported image extension', $upload->json('message'));
231 $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded php file was uploaded but should have been stopped');
234 public function test_files_with_double_extensions_will_get_sanitized()
236 $page = $this->entities->page();
237 $admin = $this->users->admin();
238 $this->actingAs($admin);
240 $fileName = 'bad.phtml.png';
241 $relPath = $this->files->expectedImagePath('gallery', $fileName);
242 $expectedRelPath = dirname($relPath) . '/bad-phtml.png';
243 $this->files->deleteAtRelativePath($expectedRelPath);
245 $file = $this->files->imageFromBase64File('bad-phtml-png.base64', $fileName);
246 $upload = $this->withHeader('Content-Type', 'image/png')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
247 $upload->assertStatus(200);
249 $lastImage = Image::query()->latest('id')->first();
251 $this->assertEquals('bad.phtml.png', $lastImage->name);
252 $this->assertEquals('bad-phtml.png', basename($lastImage->path));
253 $this->assertFileDoesNotExist(public_path($relPath), 'Uploaded image file name was not stripped of dots');
254 $this->assertFileExists(public_path($expectedRelPath));
256 $this->files->deleteAtRelativePath($lastImage->path);
259 public function test_url_entities_removed_from_filenames()
263 'bad-char-#-image.png',
264 'bad-char-?-image.png',
269 foreach ($badNames as $name) {
270 $galleryFile = $this->files->uploadedImage($name);
271 $page = $this->entities->page();
272 $badPath = $this->files->expectedImagePath('gallery', $name);
273 $this->files->deleteAtRelativePath($badPath);
275 $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
276 $upload->assertStatus(200);
278 $lastImage = Image::query()->latest('id')->first();
279 $newFileName = explode('.', basename($lastImage->path))[0];
281 $this->assertEquals($lastImage->name, $name);
282 $this->assertFalse(strpos($lastImage->path, $name), 'Path contains original image name');
283 $this->assertFalse(file_exists(public_path($badPath)), 'Uploaded image file name was not stripped of url entities');
285 $this->assertTrue(strlen($newFileName) > 0, 'File name was reduced to nothing');
287 $this->files->deleteAtRelativePath($lastImage->path);
291 public function test_secure_images_uploads_to_correct_place()
293 config()->set('filesystems.images', 'local_secure');
295 $galleryFile = $this->files->uploadedImage('my-secure-test-upload.png');
296 $page = $this->entities->page();
297 $expectedPath = storage_path('uploads/images/gallery/' . date('Y-m') . '/my-secure-test-upload.png');
299 $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
300 $upload->assertStatus(200);
302 $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath);
304 if (file_exists($expectedPath)) {
305 unlink($expectedPath);
309 public function test_secure_image_paths_traversal_causes_500()
311 config()->set('filesystems.images', 'local_secure');
314 $resp = $this->get('/uploads/images/../../logs/laravel.log');
315 $resp->assertStatus(500);
318 public function test_secure_image_paths_traversal_on_non_secure_images_causes_404()
320 config()->set('filesystems.images', 'local');
323 $resp = $this->get('/uploads/images/../../logs/laravel.log');
324 $resp->assertStatus(404);
327 public function test_secure_image_paths_dont_serve_non_images()
329 config()->set('filesystems.images', 'local_secure');
332 $testFilePath = storage_path('/uploads/images/testing.txt');
333 file_put_contents($testFilePath, 'hello from test_secure_image_paths_dont_serve_non_images');
335 $resp = $this->get('/uploads/images/testing.txt');
336 $resp->assertStatus(404);
339 public function test_secure_images_included_in_exports()
341 config()->set('filesystems.images', 'local_secure');
343 $galleryFile = $this->files->uploadedImage('my-secure-test-upload.png');
344 $page = $this->entities->page();
345 $expectedPath = storage_path('uploads/images/gallery/' . date('Y-m') . '/my-secure-test-upload.png');
347 $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
348 $imageUrl = json_decode($upload->getContent(), true)['url'];
349 $page->html .= "<img src=\"{$imageUrl}\">";
351 $upload->assertStatus(200);
353 $encodedImageContent = base64_encode(file_get_contents($expectedPath));
354 $export = $this->get($page->getUrl('/export/html'));
355 $this->assertTrue(strpos($export->getContent(), $encodedImageContent) !== false, 'Uploaded image in export content');
357 if (file_exists($expectedPath)) {
358 unlink($expectedPath);
362 public function test_system_images_remain_public_with_local_secure()
364 config()->set('filesystems.images', 'local_secure');
366 $galleryFile = $this->files->uploadedImage('my-system-test-upload.png');
367 $expectedPath = public_path('uploads/images/system/' . date('Y-m') . '/my-system-test-upload.png');
369 $upload = $this->call('POST', '/settings/customization', [], [], ['app_logo' => $galleryFile], []);
370 $upload->assertRedirect('/settings/customization');
372 $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath);
374 if (file_exists($expectedPath)) {
375 unlink($expectedPath);
379 public function test_system_images_remain_public_with_local_secure_restricted()
381 config()->set('filesystems.images', 'local_secure_restricted');
383 $galleryFile = $this->files->uploadedImage('my-system-test-restricted-upload.png');
384 $expectedPath = public_path('uploads/images/system/' . date('Y-m') . '/my-system-test-restricted-upload.png');
386 $upload = $this->call('POST', '/settings/customization', [], [], ['app_logo' => $galleryFile], []);
387 $upload->assertRedirect('/settings/customization');
389 $this->assertTrue(file_exists($expectedPath), 'Uploaded image not found at path: ' . $expectedPath);
391 if (file_exists($expectedPath)) {
392 unlink($expectedPath);
396 public function test_secure_restricted_images_inaccessible_without_relation_permission()
398 config()->set('filesystems.images', 'local_secure_restricted');
400 $galleryFile = $this->files->uploadedImage('my-secure-restricted-test-upload.png');
401 $page = $this->entities->page();
403 $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
404 $upload->assertStatus(200);
405 $expectedUrl = url('uploads/images/gallery/' . date('Y-m') . '/my-secure-restricted-test-upload.png');
406 $expectedPath = storage_path('uploads/images/gallery/' . date('Y-m') . '/my-secure-restricted-test-upload.png');
408 $this->get($expectedUrl)->assertOk();
410 $this->permissions->setEntityPermissions($page, [], []);
412 $resp = $this->get($expectedUrl);
413 $resp->assertNotFound();
415 if (file_exists($expectedPath)) {
416 unlink($expectedPath);
420 public function test_thumbnail_path_handled_by_secure_restricted_images()
422 config()->set('filesystems.images', 'local_secure_restricted');
424 $galleryFile = $this->files->uploadedImage('my-secure-restricted-thumb-test-test.png');
425 $page = $this->entities->page();
427 $upload = $this->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $galleryFile], []);
428 $upload->assertStatus(200);
429 $expectedUrl = url('uploads/images/gallery/' . date('Y-m') . '/thumbs-150-150/my-secure-restricted-thumb-test-test.png');
430 $expectedPath = storage_path('uploads/images/gallery/' . date('Y-m') . '/my-secure-restricted-thumb-test-test.png');
432 $this->get($expectedUrl)->assertOk();
434 $this->permissions->setEntityPermissions($page, [], []);
436 $resp = $this->get($expectedUrl);
437 $resp->assertNotFound();
439 if (file_exists($expectedPath)) {
440 unlink($expectedPath);
444 public function test_secure_restricted_image_access_controlled_in_exports()
446 config()->set('filesystems.images', 'local_secure_restricted');
448 $galleryFile = $this->files->uploadedImage('my-secure-restricted-export-test.png');
450 $pageA = $this->entities->page();
451 $pageB = $this->entities->page();
452 $expectedPath = storage_path('uploads/images/gallery/' . date('Y-m') . '/my-secure-restricted-export-test.png');
454 $upload = $this->asEditor()->call('POST', '/images/gallery', ['uploaded_to' => $pageA->id], [], ['file' => $galleryFile], []);
457 $imageUrl = json_decode($upload->getContent(), true)['url'];
458 $pageB->html .= "<img src=\"{$imageUrl}\">";
461 $encodedImageContent = base64_encode(file_get_contents($expectedPath));
462 $export = $this->get($pageB->getUrl('/export/html'));
463 $this->assertStringContainsString($encodedImageContent, $export->getContent());
465 $this->permissions->setEntityPermissions($pageA, [], []);
467 $export = $this->get($pageB->getUrl('/export/html'));
468 $this->assertStringNotContainsString($encodedImageContent, $export->getContent());
470 if (file_exists($expectedPath)) {
471 unlink($expectedPath);
475 public function test_image_delete()
477 $page = $this->entities->page();
479 $imageName = 'first-image.png';
480 $relPath = $this->files->expectedImagePath('gallery', $imageName);
481 $this->files->deleteAtRelativePath($relPath);
483 $this->files->uploadGalleryImage($this, $imageName, $page->id);
484 $image = Image::first();
486 $delete = $this->delete('/images/' . $image->id);
487 $delete->assertStatus(200);
489 $this->assertDatabaseMissing('images', [
490 'url' => $this->baseUrl . $relPath,
494 $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has not been deleted as expected');
497 public function test_image_delete_does_not_delete_similar_images()
499 $page = $this->entities->page();
501 $imageName = 'first-image.png';
503 $relPath = $this->files->expectedImagePath('gallery', $imageName);
504 $this->files->deleteAtRelativePath($relPath);
506 $this->files->uploadGalleryImage($this, $imageName, $page->id);
507 $this->files->uploadGalleryImage($this, $imageName, $page->id);
508 $this->files->uploadGalleryImage($this, $imageName, $page->id);
510 $image = Image::first();
511 $folder = public_path(dirname($relPath));
512 $imageCount = count(glob($folder . '/*'));
514 $delete = $this->delete('/images/' . $image->id);
515 $delete->assertStatus(200);
517 $newCount = count(glob($folder . '/*'));
518 $this->assertEquals($imageCount - 1, $newCount, 'More files than expected have been deleted');
519 $this->assertFalse(file_exists(public_path($relPath)), 'Uploaded image has not been deleted as expected');
522 public function test_image_manager_delete_button_only_shows_with_permission()
524 $page = $this->entities->page();
526 $imageName = 'first-image.png';
527 $relPath = $this->files->expectedImagePath('gallery', $imageName);
528 $this->files->deleteAtRelativePath($relPath);
529 $viewer = $this->users->viewer();
531 $this->files->uploadGalleryImage($this, $imageName, $page->id);
532 $image = Image::first();
534 $resp = $this->get("/images/edit/{$image->id}");
535 $this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]');
537 $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}");
538 $this->withHtml($resp)->assertElementNotExists('button#image-manager-delete[title="Delete"]');
540 $this->permissions->grantUserRolePermissions($viewer, ['image-delete-all']);
542 $resp = $this->actingAs($viewer)->get("/images/edit/{$image->id}");
543 $this->withHtml($resp)->assertElementExists('button#image-manager-delete[title="Delete"]');
545 $this->files->deleteAtRelativePath($relPath);
548 protected function getTestProfileImage()
550 $imageName = 'profile.png';
551 $relPath = $this->files->expectedImagePath('user', $imageName);
552 $this->files->deleteAtRelativePath($relPath);
554 return $this->files->uploadedImage($imageName);
557 public function test_user_image_upload()
559 $editor = $this->users->editor();
560 $admin = $this->users->admin();
561 $this->actingAs($admin);
563 $file = $this->getTestProfileImage();
564 $this->call('PUT', '/settings/users/' . $editor->id, [], [], ['profile_image' => $file], []);
566 $this->assertDatabaseHas('images', [
568 'uploaded_to' => $editor->id,
569 'created_by' => $admin->id,
573 public function test_user_images_deleted_on_user_deletion()
575 $editor = $this->users->editor();
576 $this->actingAs($editor);
578 $file = $this->getTestProfileImage();
579 $this->call('PUT', '/settings/users/' . $editor->id, [], [], ['profile_image' => $file], []);
581 $profileImages = Image::where('type', '=', 'user')->where('created_by', '=', $editor->id)->get();
582 $this->assertTrue($profileImages->count() === 1, 'Found profile images does not match upload count');
584 $imagePath = public_path($profileImages->first()->path);
585 $this->assertTrue(file_exists($imagePath));
587 $userDelete = $this->asAdmin()->delete("/settings/users/{$editor->id}");
588 $userDelete->assertStatus(302);
590 $this->assertDatabaseMissing('images', [
592 'created_by' => $editor->id,
594 $this->assertDatabaseMissing('images', [
596 'uploaded_to' => $editor->id,
599 $this->assertFalse(file_exists($imagePath));
602 public function test_deleted_unused_images()
604 $page = $this->entities->page();
605 $admin = $this->users->admin();
606 $this->actingAs($admin);
608 $imageName = 'unused-image.png';
609 $relPath = $this->files->expectedImagePath('gallery', $imageName);
610 $this->files->deleteAtRelativePath($relPath);
612 $upload = $this->files->uploadGalleryImage($this, $imageName, $page->id);
613 $upload->assertStatus(200);
614 $image = Image::where('type', '=', 'gallery')->first();
616 $pageRepo = app(PageRepo::class);
617 $pageRepo->update($page, [
618 'name' => $page->name,
619 'html' => $page->html . "<img src=\"{$image->url}\">",
623 // Ensure no images are reported as deletable
624 $imageService = app(ImageService::class);
625 $toDelete = $imageService->deleteUnusedImages(true, true);
626 $this->assertCount(0, $toDelete);
628 // Save a revision of our page without the image;
629 $pageRepo->update($page, [
630 'name' => $page->name,
631 'html' => '<p>Hello</p>',
635 // Ensure revision images are picked up okay
636 $imageService = app(ImageService::class);
637 $toDelete = $imageService->deleteUnusedImages(true, true);
638 $this->assertCount(0, $toDelete);
639 $toDelete = $imageService->deleteUnusedImages(false, true);
640 $this->assertCount(1, $toDelete);
642 // Check image is found when revisions are destroyed
643 $page->revisions()->delete();
644 $toDelete = $imageService->deleteUnusedImages(true, true);
645 $this->assertCount(1, $toDelete);
647 // Check the image is deleted
648 $absPath = public_path($relPath);
649 $this->assertTrue(file_exists($absPath), "Existing uploaded file at path {$absPath} exists");
650 $toDelete = $imageService->deleteUnusedImages(true, false);
651 $this->assertCount(1, $toDelete);
652 $this->assertFalse(file_exists($absPath));
654 $this->files->deleteAtRelativePath($relPath);