diff --git a/app/Config/filesystems.php b/app/Config/filesystems.php index ab73fec29..facf5f2df 100644 --- a/app/Config/filesystems.php +++ b/app/Config/filesystems.php @@ -11,7 +11,7 @@ return [ // Default Filesystem Disk - // Options: local, local_secure, s3 + // Options: local, local_secure, local_secure_restricted, s3 'default' => env('STORAGE_TYPE', 'local'), // Filesystem to use specifically for image uploads. diff --git a/app/Uploads/Controllers/ImageGalleryApiController.php b/app/Uploads/Controllers/ImageGalleryApiController.php index 59696dc9a..c4168a77e 100644 --- a/app/Uploads/Controllers/ImageGalleryApiController.php +++ b/app/Uploads/Controllers/ImageGalleryApiController.php @@ -3,11 +3,13 @@ namespace BookStack\Uploads\Controllers; use BookStack\Entities\Queries\PageQueries; +use BookStack\Exceptions\NotFoundException; use BookStack\Http\ApiController; use BookStack\Permissions\Permission; use BookStack\Uploads\Image; use BookStack\Uploads\ImageRepo; use BookStack\Uploads\ImageResizer; +use BookStack\Uploads\ImageService; use Illuminate\Http\Request; class ImageGalleryApiController extends ApiController @@ -20,6 +22,7 @@ class ImageGalleryApiController extends ApiController protected ImageRepo $imageRepo, protected ImageResizer $imageResizer, protected PageQueries $pageQueries, + protected ImageService $imageService, ) { } @@ -32,6 +35,9 @@ class ImageGalleryApiController extends ApiController 'image' => ['required', 'file', ...$this->getImageValidationRules()], 'name' => ['string', 'max:180'], ], + 'readDataForUrl' => [ + 'url' => ['required', 'string', 'url'], + ], 'update' => [ 'name' => ['string', 'max:180'], 'image' => ['file', ...$this->getImageValidationRules()], @@ -85,7 +91,8 @@ class ImageGalleryApiController extends ApiController * The "thumbs" response property contains links to scaled variants that BookStack may use in its UI. * The "content" response property provides HTML and Markdown content, in the format that BookStack * would typically use by default to add the image in page content, as a convenience. - * Actual image file data is not provided but can be fetched via the "url" response property. + * Actual image file data is not provided but can be fetched via the "url" response property or by + * using the "read-data" endpoint. */ public function read(string $id) { @@ -94,6 +101,37 @@ class ImageGalleryApiController extends ApiController return response()->json($this->formatForSingleResponse($image)); } + /** + * Read the image file data for a single image in the system. + * The returned response will be a stream of image data instead of a JSON response. + */ + public function readData(string $id) + { + $image = Image::query()->scopes(['visible'])->findOrFail($id); + + return $this->imageService->streamImageFromStorageResponse('gallery', $image->path); + } + + /** + * Read the image file data for a single image in the system, using the provided URL + * to identify the image instead of its ID, which is provided as a "URL" query parameter. + * The returned response will be a stream of image data instead of a JSON response. + */ + public function readDataForUrl(Request $request) + { + $data = $this->validate($request, $this->rules()['readDataForUrl']); + $basePath = url('/uploads/images/'); + $imagePath = str_replace($basePath, '', $data['url']); + + if (!$this->imageService->pathAccessible($imagePath)) { + throw (new NotFoundException(trans('errors.image_not_found'))) + ->setSubtitle(trans('errors.image_not_found_subtitle')) + ->setDetails(trans('errors.image_not_found_details')); + } + + return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath); + } + /** * Update the details of an existing image in the system. * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index ec8f8be0f..20def9de6 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -42,7 +42,9 @@ class Image extends Model implements OwnableInterface */ public function scopeVisible(Builder $query): Builder { - return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to'); + return app()->make(PermissionApplicator::class) + ->restrictPageRelationQuery($query, 'images', 'uploaded_to') + ->whereIn('type', ['gallery', 'drawio']); } /** diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 402456e97..fadafc8e5 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -264,6 +264,23 @@ class ImageService && str_starts_with($disk->mimeType($imagePath), 'image/'); } + /** + * Check if the given path exists and is accessible depending on the current settings. + */ + 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/'); + } + /** * Check that the current user has access to the relation * of the image at the given path. diff --git a/dev/api/requests/image-gallery-readDataForUrl.http b/dev/api/requests/image-gallery-readDataForUrl.http new file mode 100644 index 000000000..1892600f4 --- /dev/null +++ b/dev/api/requests/image-gallery-readDataForUrl.http @@ -0,0 +1 @@ +GET /api/image-gallery/url/data?url=https%3A%2F%2Fbookstack.example.com%2Fuploads%2Fimages%2Fgallery%2F2025-10%2Fmy-image.png diff --git a/routes/api.php b/routes/api.php index 4b661da5d..1466e638c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -64,7 +64,9 @@ Route::get('pages/{id}/export/zip', [ExportControllers\PageExportApiController:: Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); +Route::get('image-gallery/url/data', [ImageGalleryApiController::class, 'readDataForUrl']); Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']); +Route::get('image-gallery/{id}/data', [ImageGalleryApiController::class, 'readData']); Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']); Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']); diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php index 667093107..07c20c834 100644 --- a/tests/Api/ImageGalleryApiTest.php +++ b/tests/Api/ImageGalleryApiTest.php @@ -275,6 +275,69 @@ class ImageGalleryApiTest extends TestCase $resp->assertStatus(404); } + public function test_read_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_data_endpoint_permission_controlled() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + $image = Image::findOrFail($data['response']->id); + + $this->get("{$this->baseEndpoint}/{$image->id}/data")->assertOk(); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/{$image->id}/data"); + $resp->assertStatus(404); + } + + public function test_read_url_data_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + $resp->assertHeader('Content-Type', 'image/png'); + + $respData = $resp->streamedContent(); + $this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData); + } + + public function test_read_url_data_endpoint_permission_controlled_when_local_secure_restricted_storage_is_used() + { + config()->set('filesystems.images', 'local_secure_restricted'); + + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png'); + + $url = url($data['response']->path); + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(200); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url)); + $resp->assertStatus(404); + } + public function test_update_endpoint() { $this->actingAsApiAdmin();