mirror of
				https://github.com/BookStackApp/BookStack.git
				synced 2025-11-04 13:31:45 +03:00 
			
		
		
		
	API: Added endpoints for reading image data
This commit is contained in:
		@@ -11,7 +11,7 @@
 | 
				
			|||||||
return [
 | 
					return [
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Default Filesystem Disk
 | 
					    // Default Filesystem Disk
 | 
				
			||||||
    // Options: local, local_secure, s3
 | 
					    // Options: local, local_secure, local_secure_restricted, s3
 | 
				
			||||||
    'default' => env('STORAGE_TYPE', 'local'),
 | 
					    'default' => env('STORAGE_TYPE', 'local'),
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Filesystem to use specifically for image uploads.
 | 
					    // Filesystem to use specifically for image uploads.
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -3,11 +3,13 @@
 | 
				
			|||||||
namespace BookStack\Uploads\Controllers;
 | 
					namespace BookStack\Uploads\Controllers;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
use BookStack\Entities\Queries\PageQueries;
 | 
					use BookStack\Entities\Queries\PageQueries;
 | 
				
			||||||
 | 
					use BookStack\Exceptions\NotFoundException;
 | 
				
			||||||
use BookStack\Http\ApiController;
 | 
					use BookStack\Http\ApiController;
 | 
				
			||||||
use BookStack\Permissions\Permission;
 | 
					use BookStack\Permissions\Permission;
 | 
				
			||||||
use BookStack\Uploads\Image;
 | 
					use BookStack\Uploads\Image;
 | 
				
			||||||
use BookStack\Uploads\ImageRepo;
 | 
					use BookStack\Uploads\ImageRepo;
 | 
				
			||||||
use BookStack\Uploads\ImageResizer;
 | 
					use BookStack\Uploads\ImageResizer;
 | 
				
			||||||
 | 
					use BookStack\Uploads\ImageService;
 | 
				
			||||||
use Illuminate\Http\Request;
 | 
					use Illuminate\Http\Request;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
class ImageGalleryApiController extends ApiController
 | 
					class ImageGalleryApiController extends ApiController
 | 
				
			||||||
@@ -20,6 +22,7 @@ class ImageGalleryApiController extends ApiController
 | 
				
			|||||||
        protected ImageRepo $imageRepo,
 | 
					        protected ImageRepo $imageRepo,
 | 
				
			||||||
        protected ImageResizer $imageResizer,
 | 
					        protected ImageResizer $imageResizer,
 | 
				
			||||||
        protected PageQueries $pageQueries,
 | 
					        protected PageQueries $pageQueries,
 | 
				
			||||||
 | 
					        protected ImageService $imageService,
 | 
				
			||||||
    ) {
 | 
					    ) {
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -32,6 +35,9 @@ class ImageGalleryApiController extends ApiController
 | 
				
			|||||||
                'image' => ['required', 'file', ...$this->getImageValidationRules()],
 | 
					                'image' => ['required', 'file', ...$this->getImageValidationRules()],
 | 
				
			||||||
                'name'  => ['string', 'max:180'],
 | 
					                'name'  => ['string', 'max:180'],
 | 
				
			||||||
            ],
 | 
					            ],
 | 
				
			||||||
 | 
					            'readDataForUrl' => [
 | 
				
			||||||
 | 
					                'url' => ['required', 'string', 'url'],
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
            'update' => [
 | 
					            'update' => [
 | 
				
			||||||
                'name'  => ['string', 'max:180'],
 | 
					                'name'  => ['string', 'max:180'],
 | 
				
			||||||
                'image' => ['file', ...$this->getImageValidationRules()],
 | 
					                '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 "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
 | 
					     * 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.
 | 
					     * 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)
 | 
					    public function read(string $id)
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
@@ -94,6 +101,37 @@ class ImageGalleryApiController extends ApiController
 | 
				
			|||||||
        return response()->json($this->formatForSingleResponse($image));
 | 
					        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.
 | 
					     * 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
 | 
					     * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -42,7 +42,9 @@ class Image extends Model implements OwnableInterface
 | 
				
			|||||||
     */
 | 
					     */
 | 
				
			||||||
    public function scopeVisible(Builder $query): Builder
 | 
					    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']);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    /**
 | 
					    /**
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -264,6 +264,23 @@ class ImageService
 | 
				
			|||||||
            && str_starts_with($disk->mimeType($imagePath), 'image/');
 | 
					            && 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
 | 
					     * Check that the current user has access to the relation
 | 
				
			||||||
     * of the image at the given path.
 | 
					     * of the image at the given path.
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										1
									
								
								dev/api/requests/image-gallery-readDataForUrl.http
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								dev/api/requests/image-gallery-readDataForUrl.http
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
				
			|||||||
 | 
					GET /api/image-gallery/url/data?url=https%3A%2F%2Fbookstack.example.com%2Fuploads%2Fimages%2Fgallery%2F2025-10%2Fmy-image.png
 | 
				
			||||||
@@ -64,7 +64,9 @@ Route::get('pages/{id}/export/zip', [ExportControllers\PageExportApiController::
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
Route::get('image-gallery', [ImageGalleryApiController::class, 'list']);
 | 
					Route::get('image-gallery', [ImageGalleryApiController::class, 'list']);
 | 
				
			||||||
Route::post('image-gallery', [ImageGalleryApiController::class, 'create']);
 | 
					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}', [ImageGalleryApiController::class, 'read']);
 | 
				
			||||||
 | 
					Route::get('image-gallery/{id}/data', [ImageGalleryApiController::class, 'readData']);
 | 
				
			||||||
Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']);
 | 
					Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']);
 | 
				
			||||||
Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']);
 | 
					Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -275,6 +275,69 @@ class ImageGalleryApiTest extends TestCase
 | 
				
			|||||||
        $resp->assertStatus(404);
 | 
					        $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()
 | 
					    public function test_update_endpoint()
 | 
				
			||||||
    {
 | 
					    {
 | 
				
			||||||
        $this->actingAsApiAdmin();
 | 
					        $this->actingAsApiAdmin();
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user