Skip to content

Commit 7881bdd

Browse files
authored
Merge pull request #5860 from BookStackApp/api_image_data_endpoint
API: Added endpoints for reading image data
2 parents 652124a + 02d024a commit 7881bdd

File tree

7 files changed

+126
-3
lines changed

7 files changed

+126
-3
lines changed

app/Config/filesystems.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
return [
1212

1313
// Default Filesystem Disk
14-
// Options: local, local_secure, s3
14+
// Options: local, local_secure, local_secure_restricted, s3
1515
'default' => env('STORAGE_TYPE', 'local'),
1616

1717
// Filesystem to use specifically for image uploads.

app/Uploads/Controllers/ImageGalleryApiController.php

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,13 @@
33
namespace BookStack\Uploads\Controllers;
44

55
use BookStack\Entities\Queries\PageQueries;
6+
use BookStack\Exceptions\NotFoundException;
67
use BookStack\Http\ApiController;
78
use BookStack\Permissions\Permission;
89
use BookStack\Uploads\Image;
910
use BookStack\Uploads\ImageRepo;
1011
use BookStack\Uploads\ImageResizer;
12+
use BookStack\Uploads\ImageService;
1113
use Illuminate\Http\Request;
1214

1315
class ImageGalleryApiController extends ApiController
@@ -20,6 +22,7 @@ public function __construct(
2022
protected ImageRepo $imageRepo,
2123
protected ImageResizer $imageResizer,
2224
protected PageQueries $pageQueries,
25+
protected ImageService $imageService,
2326
) {
2427
}
2528

@@ -32,6 +35,9 @@ protected function rules(): array
3235
'image' => ['required', 'file', ...$this->getImageValidationRules()],
3336
'name' => ['string', 'max:180'],
3437
],
38+
'readDataForUrl' => [
39+
'url' => ['required', 'string', 'url'],
40+
],
3541
'update' => [
3642
'name' => ['string', 'max:180'],
3743
'image' => ['file', ...$this->getImageValidationRules()],
@@ -85,7 +91,8 @@ public function create(Request $request)
8591
* The "thumbs" response property contains links to scaled variants that BookStack may use in its UI.
8692
* The "content" response property provides HTML and Markdown content, in the format that BookStack
8793
* would typically use by default to add the image in page content, as a convenience.
88-
* Actual image file data is not provided but can be fetched via the "url" response property.
94+
* Actual image file data is not provided but can be fetched via the "url" response property or by
95+
* using the "read-data" endpoint.
8996
*/
9097
public function read(string $id)
9198
{
@@ -94,6 +101,37 @@ public function read(string $id)
94101
return response()->json($this->formatForSingleResponse($image));
95102
}
96103

104+
/**
105+
* Read the image file data for a single image in the system.
106+
* The returned response will be a stream of image data instead of a JSON response.
107+
*/
108+
public function readData(string $id)
109+
{
110+
$image = Image::query()->scopes(['visible'])->findOrFail($id);
111+
112+
return $this->imageService->streamImageFromStorageResponse('gallery', $image->path);
113+
}
114+
115+
/**
116+
* Read the image file data for a single image in the system, using the provided URL
117+
* to identify the image instead of its ID, which is provided as a "URL" query parameter.
118+
* The returned response will be a stream of image data instead of a JSON response.
119+
*/
120+
public function readDataForUrl(Request $request)
121+
{
122+
$data = $this->validate($request, $this->rules()['readDataForUrl']);
123+
$basePath = url('/uploads/images/');
124+
$imagePath = str_replace($basePath, '', $data['url']);
125+
126+
if (!$this->imageService->pathAccessible($imagePath)) {
127+
throw (new NotFoundException(trans('errors.image_not_found')))
128+
->setSubtitle(trans('errors.image_not_found_subtitle'))
129+
->setDetails(trans('errors.image_not_found_details'));
130+
}
131+
132+
return $this->imageService->streamImageFromStorageResponse('gallery', $imagePath);
133+
}
134+
97135
/**
98136
* Update the details of an existing image in the system.
99137
* Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request if providing a

app/Uploads/Image.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,9 @@ public function jointPermissions(): HasMany
4242
*/
4343
public function scopeVisible(Builder $query): Builder
4444
{
45-
return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to');
45+
return app()->make(PermissionApplicator::class)
46+
->restrictPageRelationQuery($query, 'images', 'uploaded_to')
47+
->whereIn('type', ['gallery', 'drawio']);
4648
}
4749

4850
/**

app/Uploads/ImageService.php

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -264,6 +264,23 @@ public function pathAccessibleInLocalSecure(string $imagePath): bool
264264
&& str_starts_with($disk->mimeType($imagePath), 'image/');
265265
}
266266

267+
/**
268+
* Check if the given path exists and is accessible depending on the current settings.
269+
*/
270+
public function pathAccessible(string $imagePath): bool
271+
{
272+
$disk = $this->storage->getDisk('gallery');
273+
274+
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
275+
return false;
276+
}
277+
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/');
282+
}
283+
267284
/**
268285
* Check that the current user has access to the relation
269286
* of the image at the given path.
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
GET /api/image-gallery/url/data?url=https%3A%2F%2Fbookstack.example.com%2Fuploads%2Fimages%2Fgallery%2F2025-10%2Fmy-image.png

routes/api.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,9 @@
6464

6565
Route::get('image-gallery', [ImageGalleryApiController::class, 'list']);
6666
Route::post('image-gallery', [ImageGalleryApiController::class, 'create']);
67+
Route::get('image-gallery/url/data', [ImageGalleryApiController::class, 'readDataForUrl']);
6768
Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']);
69+
Route::get('image-gallery/{id}/data', [ImageGalleryApiController::class, 'readData']);
6870
Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']);
6971
Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']);
7072

tests/Api/ImageGalleryApiTest.php

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,69 @@ public function test_read_endpoint_does_not_show_if_no_permissions_for_related_p
275275
$resp->assertStatus(404);
276276
}
277277

278+
public function test_read_data_endpoint()
279+
{
280+
$this->actingAsApiAdmin();
281+
$imagePage = $this->entities->page();
282+
$data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
283+
$image = Image::findOrFail($data['response']->id);
284+
285+
$resp = $this->get("{$this->baseEndpoint}/{$image->id}/data");
286+
$resp->assertStatus(200);
287+
$resp->assertHeader('Content-Type', 'image/png');
288+
289+
$respData = $resp->streamedContent();
290+
$this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData);
291+
}
292+
293+
public function test_read_data_endpoint_permission_controlled()
294+
{
295+
$this->actingAsApiEditor();
296+
$imagePage = $this->entities->page();
297+
$data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
298+
$image = Image::findOrFail($data['response']->id);
299+
300+
$this->get("{$this->baseEndpoint}/{$image->id}/data")->assertOk();
301+
302+
$this->permissions->disableEntityInheritedPermissions($imagePage);
303+
304+
$resp = $this->get("{$this->baseEndpoint}/{$image->id}/data");
305+
$resp->assertStatus(404);
306+
}
307+
308+
public function test_read_url_data_endpoint()
309+
{
310+
$this->actingAsApiAdmin();
311+
$imagePage = $this->entities->page();
312+
$data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
313+
314+
$url = url($data['response']->path);
315+
$resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url));
316+
$resp->assertStatus(200);
317+
$resp->assertHeader('Content-Type', 'image/png');
318+
319+
$respData = $resp->streamedContent();
320+
$this->assertEquals(file_get_contents($this->files->testFilePath('test-image.png')), $respData);
321+
}
322+
323+
public function test_read_url_data_endpoint_permission_controlled_when_local_secure_restricted_storage_is_used()
324+
{
325+
config()->set('filesystems.images', 'local_secure_restricted');
326+
327+
$this->actingAsApiEditor();
328+
$imagePage = $this->entities->page();
329+
$data = $this->files->uploadGalleryImageToPage($this, $imagePage, 'test-image.png');
330+
331+
$url = url($data['response']->path);
332+
$resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url));
333+
$resp->assertStatus(200);
334+
335+
$this->permissions->disableEntityInheritedPermissions($imagePage);
336+
337+
$resp = $this->get("{$this->baseEndpoint}/url/data?url=" . urlencode($url));
338+
$resp->assertStatus(404);
339+
}
340+
278341
public function test_update_endpoint()
279342
{
280343
$this->actingAsApiAdmin();

0 commit comments

Comments
 (0)