Skip to content

Commit aaa2818

Browse files
committed
Exports: Updated perm checking for images in ZIP exports
For #5885 Adds to, uses and cleans-up central permission checking in ImageService to mirror that which would be experienced by users in the UI to result in the same image access conditions. Adds testing to cover.
1 parent 8ab9252 commit aaa2818

File tree

5 files changed

+113
-26
lines changed

5 files changed

+113
-26
lines changed

app/Exports/ZipExports/ZipExportReferences.php

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use BookStack\Permissions\Permission;
1616
use BookStack\Uploads\Attachment;
1717
use BookStack\Uploads\Image;
18+
use BookStack\Uploads\ImageService;
1819

1920
class ZipExportReferences
2021
{
@@ -33,6 +34,7 @@ class ZipExportReferences
3334

3435
public function __construct(
3536
protected ZipReferenceParser $parser,
37+
protected ImageService $imageService,
3638
) {
3739
}
3840

@@ -133,17 +135,25 @@ protected function handleModelReference(Model $model, ZipExportModel $exportMode
133135
return "[[bsexport:image:{$model->id}]]";
134136
}
135137

136-
// Find and include images if in visibility
138+
// Get the page which we'll reference this image upon
137139
$page = $model->getPage();
138-
$pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
139-
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
140+
$pageExportModel = null;
141+
if ($page && isset($this->pages[$page->id])) {
142+
$pageExportModel = $this->pages[$page->id];
143+
} elseif ($exportModel instanceof ZipExportPage) {
144+
$pageExportModel = $exportModel;
145+
}
146+
147+
// Add the image to the export if it's accessible or just return the existing reference if already added
148+
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
140149
if (!isset($this->images[$model->id])) {
141150
$exportImage = ZipExportImage::fromModel($model, $files);
142151
$this->images[$model->id] = $exportImage;
143152
$pageExportModel->images[] = $exportImage;
144153
}
145154
return "[[bsexport:image:{$model->id}]]";
146155
}
156+
147157
return null;
148158
}
149159

app/Uploads/Image.php

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,14 @@
1313
use Illuminate\Database\Eloquent\Relations\HasMany;
1414

1515
/**
16-
* @property int $id
17-
* @property string $name
18-
* @property string $url
19-
* @property string $path
20-
* @property string $type
21-
* @property int $uploaded_to
22-
* @property int $created_by
23-
* @property int $updated_by
16+
* @property int $id
17+
* @property string $name
18+
* @property string $url
19+
* @property string $path
20+
* @property string $type
21+
* @property int|null $uploaded_to
22+
* @property int $created_by
23+
* @property int $updated_by
2424
*/
2525
class Image extends Model implements OwnableInterface
2626
{

app/Uploads/ImageService.php

Lines changed: 35 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ public function getImageStream(Image $image): mixed
148148
}
149149

150150
/**
151-
* Destroy an image along with its revisions, thumbnails and remaining folders.
151+
* Destroy an image along with its revisions, thumbnails, and remaining folders.
152152
*
153153
* @throws Exception
154154
*/
@@ -252,33 +252,48 @@ public function pathAccessibleInLocalSecure(string $imagePath): bool
252252
{
253253
$disk = $this->storage->getDisk('gallery');
254254

255+
return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
256+
}
257+
258+
/**
259+
* Check if the given path exists and is accessible depending on the current settings.
260+
*/
261+
public function pathAccessible(string $imagePath): bool
262+
{
255263
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
256264
return false;
257265
}
258266

259-
// Check local_secure is active
260-
return $disk->usingSecureImages()
261-
// Check the image file exists
262-
&& $disk->exists($imagePath)
263-
// Check the file is likely an image file
264-
&& str_starts_with($disk->mimeType($imagePath), 'image/');
267+
if ($this->storage->usingSecureImages() && user()->isGuest()) {
268+
return false;
269+
}
270+
271+
return $this->imageFileExists($imagePath, 'gallery');
265272
}
266273

267274
/**
268-
* Check if the given path exists and is accessible depending on the current settings.
275+
* Check if the given image should be accessible to the current user.
269276
*/
270-
public function pathAccessible(string $imagePath): bool
277+
public function imageAccessible(Image $image): bool
271278
{
272-
$disk = $this->storage->getDisk('gallery');
279+
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
280+
return false;
281+
}
273282

274-
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
283+
if ($this->storage->usingSecureImages() && user()->isGuest()) {
275284
return false;
276285
}
277286

278-
// Check local_secure is active
279-
return $disk->exists($imagePath)
280-
// Check the file is likely an image file
281-
&& str_starts_with($disk->mimeType($imagePath), 'image/');
287+
return $this->imageFileExists($image->path, $image->type);
288+
}
289+
290+
/**
291+
* Check if the given image path exists for the given image type and that it is likely an image file.
292+
*/
293+
protected function imageFileExists(string $imagePath, string $imageType): bool
294+
{
295+
$disk = $this->storage->getDisk($imageType);
296+
return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
282297
}
283298

284299
/**
@@ -307,6 +322,11 @@ protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool
307322
return false;
308323
}
309324

325+
return $this->checkUserHasAccessToRelationOfImage($image);
326+
}
327+
328+
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
329+
{
310330
$imageType = $image->type;
311331

312332
// Allow user or system (logo) images

app/Uploads/ImageStorage.php

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,15 @@ public function usingSecureRestrictedImages(): bool
3434
return config('filesystems.images') === 'local_secure_restricted';
3535
}
3636

37+
/**
38+
* Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
39+
* is currently active in the instance.
40+
*/
41+
public function usingSecureImages(): bool
42+
{
43+
return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
44+
}
45+
3746
/**
3847
* Clean up an image file name to be both URL and storage safe.
3948
*/

tests/Exports/ZipExportTest.php

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -374,6 +374,54 @@ public function test_image_links_are_handled_when_using_external_storage_url()
374374
$this->assertStringContainsString("<a href=\"{$ref}\">Original URL</a><a href=\"{$ref}\">Storage URL</a>", $pageData['html']);
375375
}
376376

377+
public function test_orphaned_images_can_be_used_on_default_local_storage()
378+
{
379+
$this->asEditor();
380+
$page = $this->entities->page();
381+
$result = $this->files->uploadGalleryImageToPage($this, $page);
382+
$displayThumb = $result['response']->thumbs->gallery ?? '';
383+
$page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
384+
$page->save();
385+
386+
$image = Image::findOrFail($result['response']->id);
387+
$image->uploaded_to = null;
388+
$image->save();
389+
390+
$zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
391+
$zipResp->assertOk();
392+
$zip = ZipTestHelper::extractFromZipResponse($zipResp);
393+
$pageData = $zip->data['page'];
394+
395+
$this->assertCount(1, $pageData['images']);
396+
$imageData = $pageData['images'][0];
397+
$this->assertEquals($image->id, $imageData['id']);
398+
399+
$this->assertEquals('<p><img src="[[bsexport:image:' . $imageData['id'] . ']]" alt="My image"></p>', $pageData['html']);
400+
}
401+
402+
public function test_orphaned_images_cannot_be_used_on_local_secure_restricted()
403+
{
404+
config()->set('filesystems.images', 'local_secure_restricted');
405+
406+
$this->asEditor();
407+
$page = $this->entities->page();
408+
$result = $this->files->uploadGalleryImageToPage($this, $page);
409+
$displayThumb = $result['response']->thumbs->gallery ?? '';
410+
$page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
411+
$page->save();
412+
413+
$image = Image::findOrFail($result['response']->id);
414+
$image->uploaded_to = null;
415+
$image->save();
416+
417+
$zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
418+
$zipResp->assertOk();
419+
$zip = ZipTestHelper::extractFromZipResponse($zipResp);
420+
$pageData = $zip->data['page'];
421+
422+
$this->assertCount(0, $pageData['images']);
423+
}
424+
377425
public function test_cross_reference_links_external_to_export_are_not_converted()
378426
{
379427
$page = $this->entities->page();

0 commit comments

Comments
 (0)