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\Uploads\Attachment;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageService;
class ZipExportReferences
{
@@ -33,6 +34,7 @@ class ZipExportReferences
public function __construct(
protected ZipReferenceParser $parser,
protected ImageService $imageService,
) {
}
@@ -133,10 +135,17 @@ class ZipExportReferences
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();
$pageExportModel = $this->pages[$page->id] ?? ($exportModel instanceof ZipExportPage ? $exportModel : null);
if (isset($this->images[$model->id]) || ($page && $pageExportModel && userCan(Permission::PageView, $page))) {
$pageExportModel = null;
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])) {
$exportImage = ZipExportImage::fromModel($model, $files);
$this->images[$model->id] = $exportImage;
@@ -144,6 +153,7 @@ class ZipExportReferences
}
return "[[bsexport:image:{$model->id}]]";
}
return null;
}

View File

@@ -18,7 +18,7 @@ use Illuminate\Database\Eloquent\Relations\HasMany;
* @property string $url
* @property string $path
* @property string $type
* @property int $uploaded_to
* @property int|null $uploaded_to
* @property int $created_by
* @property int $updated_by
*/

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
*/
@@ -252,16 +252,7 @@ class ImageService
{
$disk = $this->storage->getDisk('gallery');
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($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/');
return $disk->usingSecureImages() && $this->pathAccessible($imagePath);
}
/**
@@ -269,16 +260,40 @@ class ImageService
*/
public function pathAccessible(string $imagePath): bool
{
$disk = $this->storage->getDisk('gallery');
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false;
}
// Check local_secure is active
return $disk->exists($imagePath)
// Check the file is likely an image file
&& str_starts_with($disk->mimeType($imagePath), 'image/');
if ($this->storage->usingSecureImages() && user()->isGuest()) {
return false;
}
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 $this->checkUserHasAccessToRelationOfImage($image);
}
protected function checkUserHasAccessToRelationOfImage(Image $image): bool
{
$imageType = $image->type;
// Allow user or system (logo) images

View File

@@ -34,6 +34,15 @@ class ImageStorage
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.
*/

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']);
}
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()
{
$page = $this->entities->page();