diff --git a/app/Http/Controllers/Api/ContentPermissionsController.php b/app/Http/Controllers/Api/ContentPermissionApiController.php similarity index 98% rename from app/Http/Controllers/Api/ContentPermissionsController.php rename to app/Http/Controllers/Api/ContentPermissionApiController.php index ef17af8ad..47a0d3782 100644 --- a/app/Http/Controllers/Api/ContentPermissionsController.php +++ b/app/Http/Controllers/Api/ContentPermissionApiController.php @@ -7,7 +7,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Tools\PermissionsUpdater; use Illuminate\Http\Request; -class ContentPermissionsController extends ApiController +class ContentPermissionApiController extends ApiController { public function __construct( protected PermissionsUpdater $permissionsUpdater, diff --git a/app/Http/Controllers/Api/ImageGalleryApiController.php b/app/Http/Controllers/Api/ImageGalleryApiController.php new file mode 100644 index 000000000..3dba3d464 --- /dev/null +++ b/app/Http/Controllers/Api/ImageGalleryApiController.php @@ -0,0 +1,146 @@ +<?php + +namespace BookStack\Http\Controllers\Api; + +use BookStack\Entities\Models\Page; +use BookStack\Uploads\Image; +use BookStack\Uploads\ImageRepo; +use Illuminate\Http\Request; + +class ImageGalleryApiController extends ApiController +{ + protected array $fieldsToExpose = [ + 'id', 'name', 'url', 'path', 'type', 'uploaded_to', 'created_by', 'updated_by', 'created_at', 'updated_at', + ]; + + public function __construct( + protected ImageRepo $imageRepo + ) { + } + + protected function rules(): array + { + return [ + 'create' => [ + 'type' => ['required', 'string', 'in:gallery,drawio'], + 'uploaded_to' => ['required', 'integer'], + 'image' => ['required', 'file', ...$this->getImageValidationRules()], + 'name' => ['string', 'max:180'], + ], + 'update' => [ + 'name' => ['string', 'max:180'], + ] + ]; + } + + /** + * Get a listing of images in the system. Includes gallery (page content) images and drawings. + * Requires visibility of the page they're originally uploaded to. + */ + public function list() + { + $images = Image::query()->scopes(['visible']) + ->select($this->fieldsToExpose) + ->whereIn('type', ['gallery', 'drawio']); + + return $this->apiListingResponse($images, [ + ...$this->fieldsToExpose + ]); + } + + /** + * Create a new image in the system. + * Since "image" is expected to be a file, this needs to be a 'multipart/form-data' type request. + * The provided "uploaded_to" should be an existing page ID in the system. + * If the "name" parameter is omitted, the filename of the provided image file will be used instead. + * The "type" parameter should be 'gallery' for page content images, and 'drawio' should only be used + * when the file is a PNG file with diagrams.net image data embedded within. + */ + public function create(Request $request) + { + $this->checkPermission('image-create-all'); + $data = $this->validate($request, $this->rules()['create']); + Page::visible()->findOrFail($data['uploaded_to']); + + $image = $this->imageRepo->saveNew($data['image'], $data['type'], $data['uploaded_to']); + + if (isset($data['name'])) { + $image->refresh(); + $image->update(['name' => $data['name']]); + } + + return response()->json($this->formatForSingleResponse($image)); + } + + /** + * View the details of a single image. + * 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. + */ + public function read(string $id) + { + $image = Image::query()->scopes(['visible'])->findOrFail($id); + + return response()->json($this->formatForSingleResponse($image)); + } + + /** + * Update the details of an existing image in the system. + * Only allows updating of the image name at this time. + */ + public function update(Request $request, string $id) + { + $data = $this->validate($request, $this->rules()['update']); + $image = $this->imageRepo->getById($id); + $this->checkOwnablePermission('page-view', $image->getPage()); + $this->checkOwnablePermission('image-update', $image); + + $this->imageRepo->updateImageDetails($image, $data); + + return response()->json($this->formatForSingleResponse($image)); + } + + /** + * Delete an image from the system. + * Will also delete thumbnails for the image. + * Does not check or handle image usage so this could leave pages with broken image references. + */ + public function delete(string $id) + { + $image = $this->imageRepo->getById($id); + $this->checkOwnablePermission('page-view', $image->getPage()); + $this->checkOwnablePermission('image-delete', $image); + $this->imageRepo->destroyImage($image); + + return response('', 204); + } + + /** + * Format the given image model for single-result display. + */ + protected function formatForSingleResponse(Image $image): array + { + $this->imageRepo->loadThumbs($image); + $data = $image->getAttributes(); + $data['created_by'] = $image->createdBy; + $data['updated_by'] = $image->updatedBy; + $data['content'] = []; + + $escapedUrl = htmlentities($image->url); + $escapedName = htmlentities($image->name); + if ($image->type === 'drawio') { + $data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>"; + $data['content']['markdown'] = $data['content']['html']; + } else { + $escapedDisplayThumb = htmlentities($image->thumbs['display']); + $data['content']['html'] = "<a href=\"{$escapedUrl}\" target=\"_blank\"><img src=\"{$escapedDisplayThumb}\" alt=\"{$escapedName}\"></a>"; + $mdEscapedName = str_replace(']', '', str_replace('[', '', $image->name)); + $mdEscapedThumb = str_replace(']', '', str_replace('[', '', $image->thumbs['display'])); + $data['content']['markdown'] = ""; + } + + return $data; + } +} diff --git a/app/Http/Controllers/Api/RoleApiController.php b/app/Http/Controllers/Api/RoleApiController.php index 4f78455e0..6986c73f7 100644 --- a/app/Http/Controllers/Api/RoleApiController.php +++ b/app/Http/Controllers/Api/RoleApiController.php @@ -88,10 +88,10 @@ class RoleApiController extends ApiController */ public function read(string $id) { - $user = $this->permissionsRepo->getRoleById($id); - $this->singleFormatter($user); + $role = $this->permissionsRepo->getRoleById($id); + $this->singleFormatter($role); - return response()->json($user); + return response()->json($role); } /** diff --git a/app/Http/Controllers/Images/GalleryImageController.php b/app/Http/Controllers/Images/GalleryImageController.php index 5484411d3..3f2f56265 100644 --- a/app/Http/Controllers/Images/GalleryImageController.php +++ b/app/Http/Controllers/Images/GalleryImageController.php @@ -10,14 +10,9 @@ use Illuminate\Validation\ValidationException; class GalleryImageController extends Controller { - protected $imageRepo; - - /** - * GalleryImageController constructor. - */ - public function __construct(ImageRepo $imageRepo) - { - $this->imageRepo = $imageRepo; + public function __construct( + protected ImageRepo $imageRepo + ) { } /** diff --git a/app/Uploads/Image.php b/app/Uploads/Image.php index c21a3b03f..0ab0b612a 100644 --- a/app/Uploads/Image.php +++ b/app/Uploads/Image.php @@ -3,9 +3,11 @@ namespace BookStack\Uploads; use BookStack\Auth\Permissions\JointPermission; +use BookStack\Auth\Permissions\PermissionApplicator; use BookStack\Entities\Models\Page; use BookStack\Model; use BookStack\Traits\HasCreatorAndUpdater; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; @@ -33,12 +35,21 @@ class Image extends Model ->where('joint_permissions.entity_type', '=', 'page'); } + /** + * Scope the query to just the images visible to the user based upon the + * user visibility of the uploaded_to page. + */ + public function scopeVisible(Builder $query): Builder + { + return app()->make(PermissionApplicator::class)->restrictPageRelationQuery($query, 'images', 'uploaded_to'); + } + /** * Get a thumbnail for this image. * * @throws \Exception */ - public function getThumb(int $width, int $height, bool $keepRatio = false): string + public function getThumb(?int $width, ?int $height, bool $keepRatio = false): string { return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio); } diff --git a/dev/api/requests/image-gallery-update.json b/dev/api/requests/image-gallery-update.json new file mode 100644 index 000000000..e332e3a8f --- /dev/null +++ b/dev/api/requests/image-gallery-update.json @@ -0,0 +1,3 @@ +{ + "name": "My updated image name" +} \ No newline at end of file diff --git a/dev/api/responses/image-gallery-create.json b/dev/api/responses/image-gallery-create.json new file mode 100644 index 000000000..e27824491 --- /dev/null +++ b/dev/api/responses/image-gallery-create.json @@ -0,0 +1,28 @@ +{ + "name": "cute-cat-image.png", + "path": "\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png", + "url": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png", + "type": "gallery", + "uploaded_to": 1, + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_at": "2023-03-15 08:17:37", + "created_at": "2023-03-15 08:17:37", + "id": 618, + "thumbs": { + "gallery": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/thumbs-150-150\/cute-cat-image.png", + "display": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png" + }, + "content": { + "html": "<a href=\"https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png\" target=\"_blank\"><img src=\"https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png\" alt=\"cute-cat-image.png\"><\/a>", + "markdown": "" + } +} \ No newline at end of file diff --git a/dev/api/responses/image-gallery-list.json b/dev/api/responses/image-gallery-list.json new file mode 100644 index 000000000..054d68a15 --- /dev/null +++ b/dev/api/responses/image-gallery-list.json @@ -0,0 +1,41 @@ +{ + "data": [ + { + "id": 1, + "name": "My cat scribbles", + "url": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-02\/scribbles.jpg", + "path": "\/uploads\/images\/gallery\/2023-02\/scribbles.jpg", + "type": "gallery", + "uploaded_to": 1, + "created_by": 1, + "updated_by": 1, + "created_at": "2023-02-12T16:34:57.000000Z", + "updated_at": "2023-02-12T16:34:57.000000Z" + }, + { + "id": 2, + "name": "Drawing-1.png", + "url": "https:\/\/bookstack.example.com\/uploads\/images\/drawio\/2023-02\/drawing-1.png", + "path": "\/uploads\/images\/drawio\/2023-02\/drawing-1.png", + "type": "drawio", + "uploaded_to": 2, + "created_by": 2, + "updated_by": 2, + "created_at": "2023-02-12T16:39:19.000000Z", + "updated_at": "2023-02-12T16:39:19.000000Z" + }, + { + "id": 8, + "name": "beans.jpg", + "url": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-02\/beans.jpg", + "path": "\/uploads\/images\/gallery\/2023-02\/beans.jpg", + "type": "gallery", + "uploaded_to": 6, + "created_by": 1, + "updated_by": 1, + "created_at": "2023-02-15T19:37:44.000000Z", + "updated_at": "2023-02-15T19:37:44.000000Z" + } + ], + "total": 3 +} \ No newline at end of file diff --git a/dev/api/responses/image-gallery-read.json b/dev/api/responses/image-gallery-read.json new file mode 100644 index 000000000..c6c468daa --- /dev/null +++ b/dev/api/responses/image-gallery-read.json @@ -0,0 +1,28 @@ +{ + "id": 618, + "name": "cute-cat-image.png", + "url": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png", + "created_at": "2023-03-15 08:17:37", + "updated_at": "2023-03-15 08:17:37", + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "path": "\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png", + "type": "gallery", + "uploaded_to": 1, + "thumbs": { + "gallery": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/thumbs-150-150\/cute-cat-image.png", + "display": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png" + }, + "content": { + "html": "<a href=\"https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png\" target=\"_blank\"><img src=\"https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png\" alt=\"cute-cat-image.png\"><\/a>", + "markdown": "" + } +} \ No newline at end of file diff --git a/dev/api/responses/image-gallery-update.json b/dev/api/responses/image-gallery-update.json new file mode 100644 index 000000000..6e6168a1b --- /dev/null +++ b/dev/api/responses/image-gallery-update.json @@ -0,0 +1,28 @@ +{ + "id": 618, + "name": "My updated image name", + "url": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png", + "created_at": "2023-03-15 08:17:37", + "updated_at": "2023-03-15 08:24:50", + "created_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "updated_by": { + "id": 1, + "name": "Admin", + "slug": "admin" + }, + "path": "\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png", + "type": "gallery", + "uploaded_to": 1, + "thumbs": { + "gallery": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/thumbs-150-150\/cute-cat-image.png", + "display": "https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png" + }, + "content": { + "html": "<a href=\"https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png\" target=\"_blank\"><img src=\"https:\/\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png\" alt=\"My updated image name\"><\/a>", + "markdown": "" + } +} \ No newline at end of file diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index 7358b5cd7..75b71c6be 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -14,11 +14,11 @@ HTTP POST calls upon events occurring in BookStack. </li> <li> - <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> - + <a href="https://github.com/BookStackApp/BookStack/blob/development/dev/docs/visual-theme-system.md" target="_blank" rel="noopener noreferrer">Visual Theme System</a> - Methods to override views, translations and icons within BookStack. </li> <li> - <a href="https://github.com/BookStackApp/BookStack/blob/master/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> - + <a href="https://github.com/BookStackApp/BookStack/blob/development/dev/docs/logical-theme-system.md" target="_blank" rel="noopener noreferrer">Logical Theme System</a> - Methods to extend back-end functionality within BookStack. </li> </ul> diff --git a/routes/api.php b/routes/api.php index 1b852fed7..c809cdb3a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -13,7 +13,8 @@ use BookStack\Http\Controllers\Api\BookExportApiController; use BookStack\Http\Controllers\Api\BookshelfApiController; use BookStack\Http\Controllers\Api\ChapterApiController; use BookStack\Http\Controllers\Api\ChapterExportApiController; -use BookStack\Http\Controllers\Api\ContentPermissionsController; +use BookStack\Http\Controllers\Api\ContentPermissionApiController; +use BookStack\Http\Controllers\Api\ImageGalleryApiController; use BookStack\Http\Controllers\Api\PageApiController; use BookStack\Http\Controllers\Api\PageExportApiController; use BookStack\Http\Controllers\Api\RecycleBinApiController; @@ -63,6 +64,12 @@ Route::get('pages/{id}/export/pdf', [PageExportApiController::class, 'exportPdf' Route::get('pages/{id}/export/plaintext', [PageExportApiController::class, 'exportPlainText']); Route::get('pages/{id}/export/markdown', [PageExportApiController::class, 'exportMarkdown']); +Route::get('image-gallery', [ImageGalleryApiController::class, 'list']); +Route::post('image-gallery', [ImageGalleryApiController::class, 'create']); +Route::get('image-gallery/{id}', [ImageGalleryApiController::class, 'read']); +Route::put('image-gallery/{id}', [ImageGalleryApiController::class, 'update']); +Route::delete('image-gallery/{id}', [ImageGalleryApiController::class, 'delete']); + Route::get('search', [SearchApiController::class, 'all']); Route::get('shelves', [BookshelfApiController::class, 'list']); @@ -87,5 +94,5 @@ Route::get('recycle-bin', [RecycleBinApiController::class, 'list']); Route::put('recycle-bin/{deletionId}', [RecycleBinApiController::class, 'restore']); Route::delete('recycle-bin/{deletionId}', [RecycleBinApiController::class, 'destroy']); -Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionsController::class, 'read']); -Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionsController::class, 'update']); +Route::get('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'read']); +Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']); diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php new file mode 100644 index 000000000..17c90518c --- /dev/null +++ b/tests/Api/ImageGalleryApiTest.php @@ -0,0 +1,347 @@ +<?php + +namespace Tests\Api; + +use BookStack\Entities\Models\Page; +use BookStack\Uploads\Image; +use Tests\TestCase; + +class ImageGalleryApiTest extends TestCase +{ + use TestsApi; + + protected string $baseEndpoint = '/api/image-gallery'; + + public function test_index_endpoint_returns_expected_image_and_count() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->getJson($this->baseEndpoint . '?count=1&sort=+id'); + $resp->assertJson(['data' => [ + [ + 'id' => $image->id, + 'name' => $image->name, + 'url' => $image->url, + 'path' => $image->path, + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'created_by' => $this->users->admin()->id, + 'updated_by' => $this->users->admin()->id, + ], + ]]); + + $resp->assertJson(['total' => Image::query()->count()]); + } + + public function test_index_endpoint_doesnt_show_images_for_those_uploaded_to_non_visible_pages() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $image->id); + $resp->assertJsonCount(1, 'data'); + $resp->assertJson(['total' => 1]); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $image->id); + $resp->assertJsonCount(0, 'data'); + $resp->assertJson(['total' => 0]); + } + + public function test_index_endpoint_doesnt_show_other_image_types() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $typesByCountExpectation = [ + 'cover_book' => 0, + 'drawio' => 1, + 'gallery' => 1, + 'user' => 0, + 'system' => 0, + ]; + + foreach ($typesByCountExpectation as $type => $count) { + $image->type = $type; + $image->save(); + + $resp = $this->getJson($this->baseEndpoint . '?filter[id]=' . $image->id); + $resp->assertJsonCount($count, 'data'); + $resp->assertJson(['total' => $count]); + } + } + + public function test_create_endpoint() + { + $this->actingAsApiAdmin(); + + $imagePage = $this->entities->page(); + $resp = $this->call('POST', $this->baseEndpoint, [ + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'name' => 'My awesome image!', + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + + $resp->assertStatus(200); + + $image = Image::query()->where('uploaded_to', '=', $imagePage->id)->first(); + $expectedUser = [ + 'id' => $this->users->admin()->id, + 'name' => $this->users->admin()->name, + 'slug' => $this->users->admin()->slug, + ]; + $resp->assertJson([ + 'id' => $image->id, + 'name' => 'My awesome image!', + 'url' => $image->url, + 'path' => $image->path, + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'created_by' => $expectedUser, + 'updated_by' => $expectedUser, + ]); + } + + public function test_create_endpoint_requires_image_create_permissions() + { + $user = $this->users->editor(); + $this->actingAsForApi($user); + $this->permissions->removeUserRolePermissions($user, ['image-create-all']); + + $makeRequest = function () { + return $this->call('POST', $this->baseEndpoint, []); + }; + + $resp = $makeRequest(); + $resp->assertStatus(403); + + $this->permissions->grantUserRolePermissions($user, ['image-create-all']); + + $resp = $makeRequest(); + $resp->assertStatus(422); + } + + public function test_create_fails_if_uploaded_to_not_visible_or_not_exists() + { + $this->actingAsApiEditor(); + + $makeRequest = function (int $uploadedTo) { + return $this->call('POST', $this->baseEndpoint, [ + 'type' => 'gallery', + 'uploaded_to' => $uploadedTo, + 'name' => 'My awesome image!', + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + }; + + $page = $this->entities->page(); + $this->permissions->disableEntityInheritedPermissions($page); + $resp = $makeRequest($page->id); + $resp->assertStatus(404); + + $resp = $makeRequest(Page::query()->max('id') + 55); + $resp->assertStatus(404); + } + + public function test_create_has_restricted_types() + { + $this->actingAsApiEditor(); + + $typesByStatusExpectation = [ + 'cover_book' => 422, + 'drawio' => 200, + 'gallery' => 200, + 'user' => 422, + 'system' => 422, + ]; + + $makeRequest = function (string $type) { + return $this->call('POST', $this->baseEndpoint, [ + 'type' => $type, + 'uploaded_to' => $this->entities->page()->id, + 'name' => 'My awesome image!', + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + }; + + foreach ($typesByStatusExpectation as $type => $status) { + $resp = $makeRequest($type); + $resp->assertStatus($status); + } + } + + public function test_create_will_use_file_name_if_no_name_provided_in_request() + { + $this->actingAsApiEditor(); + + $imagePage = $this->entities->page(); + $resp = $this->call('POST', $this->baseEndpoint, [ + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + ], [], [ + 'image' => $this->files->uploadedImage('my-cool-image.png'), + ]); + $resp->assertStatus(200); + + $this->assertDatabaseHas('images', [ + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'name' => 'my-cool-image.png', + ]); + } + + public function test_read_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->getJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(200); + + $expectedUser = [ + 'id' => $this->users->admin()->id, + 'name' => $this->users->admin()->name, + 'slug' => $this->users->admin()->slug, + ]; + + $displayUrl = $image->getThumb(1680, null, true); + $resp->assertJson([ + 'id' => $image->id, + 'name' => $image->name, + 'url' => $image->url, + 'path' => $image->path, + 'type' => 'gallery', + 'uploaded_to' => $imagePage->id, + 'created_by' => $expectedUser, + 'updated_by' => $expectedUser, + 'content' => [ + 'html' => "<a href=\"{$image->url}\" target=\"_blank\"><img src=\"{$displayUrl}\" alt=\"{$image->name}\"></a>", + 'markdown' => "", + ], + ]); + $this->assertStringStartsWith('http://', $resp->json('thumbs.gallery')); + $this->assertStringStartsWith('http://', $resp->json('thumbs.display')); + } + + public function test_read_endpoint_provides_different_content_for_drawings() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $image->type = 'drawio'; + $image->save(); + + $resp = $this->getJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(200); + + $drawing = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$image->url}\"></div>"; + $resp->assertJson([ + 'id' => $image->id, + 'content' => [ + 'html' => $drawing, + 'markdown' => $drawing, + ], + ]); + } + + public function test_read_endpoint_does_not_show_if_no_permissions_for_related_page() + { + $this->actingAsApiEditor(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $this->permissions->disableEntityInheritedPermissions($imagePage); + + $resp = $this->getJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(404); + } + + public function test_update_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", [ + 'name' => 'My updated image name!', + ]); + + $resp->assertStatus(200); + $resp->assertJson([ + 'id' => $image->id, + 'name' => 'My updated image name!', + ]); + $this->assertDatabaseHas('images', [ + 'id' => $image->id, + 'name' => 'My updated image name!', + ]); + } + + public function test_update_endpoint_requires_image_delete_permission() + { + $user = $this->users->editor(); + $this->actingAsForApi($user); + $imagePage = $this->entities->page(); + $this->permissions->removeUserRolePermissions($user, ['image-update-all', 'image-update-own']); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", ['name' => 'My new name']); + $resp->assertStatus(403); + $resp->assertJson($this->permissionErrorResponse()); + + $this->permissions->grantUserRolePermissions($user, ['image-update-all']); + $resp = $this->putJson($this->baseEndpoint . "/{$image->id}", ['name' => 'My new name']); + $resp->assertStatus(200); + } + + public function test_delete_endpoint() + { + $this->actingAsApiAdmin(); + $imagePage = $this->entities->page(); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + $this->assertDatabaseHas('images', ['id' => $image->id]); + + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + + $resp->assertStatus(204); + $this->assertDatabaseMissing('images', ['id' => $image->id]); + } + + public function test_delete_endpoint_requires_image_delete_permission() + { + $user = $this->users->editor(); + $this->actingAsForApi($user); + $imagePage = $this->entities->page(); + $this->permissions->removeUserRolePermissions($user, ['image-delete-all', 'image-delete-own']); + $data = $this->files->uploadGalleryImageToPage($this, $imagePage); + $image = Image::findOrFail($data['response']->id); + + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(403); + $resp->assertJson($this->permissionErrorResponse()); + + $this->permissions->grantUserRolePermissions($user, ['image-delete-all']); + $resp = $this->deleteJson($this->baseEndpoint . "/{$image->id}"); + $resp->assertStatus(204); + } +} diff --git a/tests/Api/TestsApi.php b/tests/Api/TestsApi.php index 501f28754..c566fd8de 100644 --- a/tests/Api/TestsApi.php +++ b/tests/Api/TestsApi.php @@ -2,15 +2,28 @@ namespace Tests\Api; +use BookStack\Auth\User; + trait TestsApi { - protected $apiTokenId = 'apitoken'; - protected $apiTokenSecret = 'password'; + protected string $apiTokenId = 'apitoken'; + protected string $apiTokenSecret = 'password'; + + /** + * Set the given user as the current logged-in user via the API driver. + * This does not ensure API access. The user may still lack required role permissions. + */ + protected function actingAsForApi(User $user): static + { + parent::actingAs($user, 'api'); + + return $this; + } /** * Set the API editor role as the current user via the API driver. */ - protected function actingAsApiEditor() + protected function actingAsApiEditor(): static { $this->actingAs($this->users->editor(), 'api'); @@ -20,7 +33,7 @@ trait TestsApi /** * Set the API admin role as the current user via the API driver. */ - protected function actingAsApiAdmin() + protected function actingAsApiAdmin(): static { $this->actingAs($this->users->admin(), 'api');