1
0
mirror of https://github.com/BookStackApp/BookStack.git synced 2025-11-23 17:22:23 +03:00

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.
This commit is contained in:
Dan Brown
2025-11-18 14:19:46 +00:00
parent 8ab9252f9b
commit aaa28186bc
5 changed files with 115 additions and 28 deletions

View File

@@ -15,6 +15,7 @@ use BookStack\Exports\ZipExports\Models\ZipExportPage;
use BookStack\Permissions\Permission; use BookStack\Permissions\Permission;
use BookStack\Uploads\Attachment; use BookStack\Uploads\Attachment;
use BookStack\Uploads\Image; use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
class ZipExportReferences class ZipExportReferences
{ {
@@ -33,6 +34,7 @@ class ZipExportReferences
public function __construct( public function __construct(
protected ZipReferenceParser $parser, protected ZipReferenceParser $parser,
protected ImageService $imageService,
) { ) {
} }
@@ -133,10 +135,17 @@ class ZipExportReferences
return "[[bsexport:image:{$model->id}]]"; return "[[bsexport:image:{$model->id}]]";
} }
// Find and include images if in visibility // Get the page which we'll reference this image upon
$page = $model->getPage(); $page = $model->getPage();
$pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null); $pageExportModel = null;
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) { if ($page && isset($this->pages[$page->id])) {
$pageExportModel = $this->pages[$page->id];
} elseif ($exportModel instanceof ZipExportPage) {
$pageExportModel = $exportModel;
}
// Add the image to the export if it's accessible or just return the existing reference if already added
if (isset($this->images[$model->id]) || ($pageExportModel && $this->imageService->imageAccessible($model))) {
if (!isset($this->images[$model->id])) { if (!isset($this->images[$model->id])) {
$exportImage = ZipExportImage::fromModel($model, $files); $exportImage = ZipExportImage::fromModel($model, $files);
$this->images[$model->id] = $exportImage; $this->images[$model->id] = $exportImage;
@@ -144,6 +153,7 @@ class ZipExportReferences
} }
return "[[bsexport:image:{$model->id}]]"; return "[[bsexport:image:{$model->id}]]";
} }
return null; return null;
} }

View File

@@ -13,14 +13,14 @@ use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\HasMany;
/** /**
* @property int $id * @property int $id
* @property string $name * @property string $name
* @property string $url * @property string $url
* @property string $path * @property string $path
* @property string $type * @property string $type
* @property int $uploaded_to * @property int|null $uploaded_to
* @property int $created_by * @property int $created_by
* @property int $updated_by * @property int $updated_by
*/ */
class Image extends Model implements OwnableInterface class Image extends Model implements OwnableInterface
{ {

View File

@@ -148,7 +148,7 @@ class ImageService
} }
/** /**
* Destroy an image along with its revisions, thumbnails and remaining folders. * Destroy an image along with its revisions, thumbnails, and remaining folders.
* *
* @throws Exception * @throws Exception
*/ */
@@ -252,16 +252,7 @@ class ImageService
{ {
$disk = $this->storage->getDisk('gallery'); $disk = $this->storage->getDisk('gallery');
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) { return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
return false;
}
// Check local_secure is active
return $disk->usingSecureImages()
// Check the image file exists
&& $disk->exists($imagePath)
// Check the file is likely an image file
&& str_starts_with($disk->mimeType($imagePath), 'image/');
} }
/** /**
@@ -269,16 +260,40 @@ class ImageService
*/ */
public function pathAccessible(string $imagePath): bool public function pathAccessible(string $imagePath): bool
{ {
$disk = $this->storage->getDisk('gallery');
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) { if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false; return false;
} }
// Check local_secure is active if ($this->storage->usingSecureImages() && user()->isGuest()) {
return $disk->exists($imagePath) return false;
// Check the file is likely an image file }
&& str_starts_with($disk->mimeType($imagePath), 'image/');
return $this->imageFileExists($imagePath, 'gallery');
}
/**
* Check if the given image should be accessible to the current user.
*/
public function imageAccessible(Image $image): bool
{
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImage($image)) {
return false;
}
if ($this->storage->usingSecureImages() && user()->isGuest()) {
return false;
}
return $this->imageFileExists($image->path, $image->type);
}
/**
* Check if the given image path exists for the given image type and that it is likely an image file.
*/
protected function imageFileExists(string $imagePath, string $imageType): bool
{
$disk = $this->storage->getDisk($imageType);
return $disk->exists($imagePath) && str_starts_with($disk->mimeType($imagePath), 'image/');
} }
/** /**
@@ -307,6 +322,11 @@ class ImageService
return false; return false;
} }
return $this->checkUserHasAccessToRelationOfImage($image);
}
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
{
$imageType = $image->type; $imageType = $image->type;
// Allow user or system (logo) images // Allow user or system (logo) images

View File

@@ -34,6 +34,15 @@ class ImageStorage
return config('filesystems.images') === 'local_secure_restricted'; return config('filesystems.images') === 'local_secure_restricted';
} }
/**
* Check if "local secure" (Fetched behind auth, either with or without permissions enforced)
* is currently active in the instance.
*/
public function usingSecureImages(): bool
{
return config('filesystems.images') === 'local_secure' || $this->usingSecureRestrictedImages();
}
/** /**
* Clean up an image file name to be both URL and storage safe. * Clean up an image file name to be both URL and storage safe.
*/ */

View File

@@ -374,6 +374,54 @@ class ZipExportTest extends TestCase
$this->assertStringContainsString("<a href=\"{$ref}\">Original URL</a><a href=\"{$ref}\">Storage URL</a>", $pageData['html']); $this->assertStringContainsString("<a href=\"{$ref}\">Original URL</a><a href=\"{$ref}\">Storage URL</a>", $pageData['html']);
} }
public function test_orphaned_images_can_be_used_on_default_local_storage()
{
$this->asEditor();
$page = $this->entities->page();
$result = $this->files->uploadGalleryImageToPage($this, $page);
$displayThumb = $result['response']->thumbs->gallery ?? '';
$page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
$page->save();
$image = Image::findOrFail($result['response']->id);
$image->uploaded_to = null;
$image->save();
$zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
$zipResp->assertOk();
$zip = ZipTestHelper::extractFromZipResponse($zipResp);
$pageData = $zip->data['page'];
$this->assertCount(1, $pageData['images']);
$imageData = $pageData['images'][0];
$this->assertEquals($image->id, $imageData['id']);
$this->assertEquals('<p><img src="[[bsexport:image:' . $imageData['id'] . ']]" alt="My image"></p>', $pageData['html']);
}
public function test_orphaned_images_cannot_be_used_on_local_secure_restricted()
{
config()->set('filesystems.images', 'local_secure_restricted');
$this->asEditor();
$page = $this->entities->page();
$result = $this->files->uploadGalleryImageToPage($this, $page);
$displayThumb = $result['response']->thumbs->gallery ?? '';
$page->html = '<p><img src="' . $displayThumb . '" alt="My image"></p>';
$page->save();
$image = Image::findOrFail($result['response']->id);
$image->uploaded_to = null;
$image->save();
$zipResp = $this->asEditor()->get($page->getUrl("/export/zip"));
$zipResp->assertOk();
$zip = ZipTestHelper::extractFromZipResponse($zipResp);
$pageData = $zip->data['page'];
$this->assertCount(0, $pageData['images']);
}
public function test_cross_reference_links_external_to_export_are_not_converted() public function test_cross_reference_links_external_to_export_are_not_converted()
{ {
$page = $this->entities->page(); $page = $this->entities->page();