]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'v23.02-branch' into development
authorDan Brown <redacted>
Sat, 25 Mar 2023 12:33:59 +0000 (12:33 +0000)
committerDan Brown <redacted>
Sat, 25 Mar 2023 12:33:59 +0000 (12:33 +0000)
21 files changed:
app/Auth/Permissions/EntityPermission.php
app/Entities/EntityProvider.php
app/Entities/Tools/PermissionsUpdater.php
app/Http/Controllers/Api/ContentPermissionApiController.php [new file with mode: 0644]
app/Http/Controllers/Api/ImageGalleryApiController.php [new file with mode: 0644]
app/Http/Controllers/Api/RoleApiController.php
app/Http/Controllers/Images/GalleryImageController.php
app/Uploads/Image.php
dev/api/requests/content-permissions-update.json [new file with mode: 0644]
dev/api/requests/image-gallery-update.json [new file with mode: 0644]
dev/api/responses/content-permissions-read.json [new file with mode: 0644]
dev/api/responses/content-permissions-update.json [new file with mode: 0644]
dev/api/responses/image-gallery-create.json [new file with mode: 0644]
dev/api/responses/image-gallery-list.json [new file with mode: 0644]
dev/api/responses/image-gallery-read.json [new file with mode: 0644]
dev/api/responses/image-gallery-update.json [new file with mode: 0644]
resources/views/api-docs/parts/getting-started.blade.php
routes/api.php
tests/Api/ContentPermissionsApiTest.php [new file with mode: 0644]
tests/Api/ImageGalleryApiTest.php [new file with mode: 0644]
tests/Api/TestsApi.php

index 32ebc440d1dccc0b9274fd935531498dd23cb857..603cf61ad64ca1e6252f44b22d6981de3310a1aa 100644 (file)
@@ -5,7 +5,6 @@ namespace BookStack\Auth\Permissions;
 use BookStack\Auth\Role;
 use BookStack\Model;
 use Illuminate\Database\Eloquent\Relations\BelongsTo;
-use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 /**
  * @property int $id
@@ -23,14 +22,14 @@ class EntityPermission extends Model
 
     protected $fillable = ['role_id', 'view', 'create', 'update', 'delete'];
     public $timestamps = false;
-
-    /**
-     * Get this restriction's attached entity.
-     */
-    public function restrictable(): MorphTo
-    {
-        return $this->morphTo('restrictable');
-    }
+    protected $hidden = ['entity_id', 'entity_type', 'id'];
+    protected $casts = [
+        'view' => 'boolean',
+        'create' => 'boolean',
+        'read' => 'boolean',
+        'update' => 'boolean',
+        'delete' => 'boolean',
+    ];
 
     /**
      * Get the role assigned to this entity permission.
index aaf392c7b2782b7f54199d4d7398ce8a3059c7d8..365daf7ebe66c9ec70e42159c0e3b8600e3e69b1 100644 (file)
@@ -18,30 +18,11 @@ use BookStack\Entities\Models\PageRevision;
  */
 class EntityProvider
 {
-    /**
-     * @var Bookshelf
-     */
-    public $bookshelf;
-
-    /**
-     * @var Book
-     */
-    public $book;
-
-    /**
-     * @var Chapter
-     */
-    public $chapter;
-
-    /**
-     * @var Page
-     */
-    public $page;
-
-    /**
-     * @var PageRevision
-     */
-    public $pageRevision;
+    public Bookshelf $bookshelf;
+    public Book $book;
+    public Chapter $chapter;
+    public Page $page;
+    public PageRevision $pageRevision;
 
     public function __construct()
     {
@@ -69,13 +50,18 @@ class EntityProvider
     }
 
     /**
-     * Get an entity instance by it's basic name.
+     * Get an entity instance by its basic name.
      */
     public function get(string $type): Entity
     {
         $type = strtolower($type);
+        $instance = $this->all()[$type] ?? null;
+
+        if (is_null($instance)) {
+            throw new \InvalidArgumentException("Provided type \"{$type}\" is not a valid entity type");
+        }
 
-        return $this->all()[$type];
+        return $instance;
     }
 
     /**
index eb4eb6b48581ae037fd95f911b46beea836bee83..36ed7ccde85dd986175bcffa7c8a7fec363e6156 100644 (file)
@@ -4,20 +4,20 @@ namespace BookStack\Entities\Tools;
 
 use BookStack\Actions\ActivityType;
 use BookStack\Auth\Permissions\EntityPermission;
+use BookStack\Auth\Role;
 use BookStack\Auth\User;
 use BookStack\Entities\Models\Book;
 use BookStack\Entities\Models\Bookshelf;
 use BookStack\Entities\Models\Entity;
 use BookStack\Facades\Activity;
 use Illuminate\Http\Request;
-use Illuminate\Support\Collection;
 
 class PermissionsUpdater
 {
     /**
      * Update an entities permissions from a permission form submit request.
      */
-    public function updateFromPermissionsForm(Entity $entity, Request $request)
+    public function updateFromPermissionsForm(Entity $entity, Request $request): void
     {
         $permissions = $request->get('permissions', null);
         $ownerId = $request->get('owned_by', null);
@@ -39,12 +39,44 @@ class PermissionsUpdater
         Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
     }
 
+    /**
+     * Update permissions from API request data.
+     */
+    public function updateFromApiRequestData(Entity $entity, array $data): void
+    {
+        if (isset($data['role_permissions'])) {
+            $entity->permissions()->where('role_id', '!=', 0)->delete();
+            $rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['role_permissions'] ?? [], false);
+            $entity->permissions()->createMany($rolePermissionData);
+        }
+
+        if (array_key_exists('fallback_permissions', $data)) {
+            $entity->permissions()->where('role_id', '=', 0)->delete();
+        }
+
+        if (isset($data['fallback_permissions']['inheriting']) && $data['fallback_permissions']['inheriting'] !== true) {
+            $data = $data['fallback_permissions'];
+            $data['role_id'] = 0;
+            $rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions([$data], true);
+            $entity->permissions()->createMany($rolePermissionData);
+        }
+
+        if (isset($data['owner_id'])) {
+            $this->updateOwnerFromId($entity, intval($data['owner_id']));
+        }
+
+        $entity->save();
+        $entity->rebuildPermissions();
+
+        Activity::add(ActivityType::PERMISSIONS_UPDATE, $entity);
+    }
+
     /**
      * Update the owner of the given entity.
      * Checks the user exists in the system first.
      * Does not save the model, just updates it.
      */
-    protected function updateOwnerFromId(Entity $entity, int $newOwnerId)
+    protected function updateOwnerFromId(Entity $entity, int $newOwnerId): void
     {
         $newOwner = User::query()->find($newOwnerId);
         if (!is_null($newOwner)) {
@@ -67,7 +99,41 @@ class PermissionsUpdater
             $formatted[] = $entityPermissionData;
         }
 
-        return $formatted;
+        return $this->filterEntityPermissionDataUponRole($formatted, true);
+    }
+
+    protected function formatPermissionsFromApiRequestToEntityPermissions(array $permissions, bool $allowFallback): array
+    {
+        $formatted = [];
+
+        foreach ($permissions as $requestPermissionData) {
+            $entityPermissionData = ['role_id' => $requestPermissionData['role_id']];
+            foreach (EntityPermission::PERMISSIONS as $permission) {
+                $entityPermissionData[$permission] = boolval($requestPermissionData[$permission] ?? false);
+            }
+            $formatted[] = $entityPermissionData;
+        }
+
+        return $this->filterEntityPermissionDataUponRole($formatted, $allowFallback);
+    }
+
+    protected function filterEntityPermissionDataUponRole(array $entityPermissionData, bool $allowFallback): array
+    {
+        $roleIds = [];
+        foreach ($entityPermissionData as $permissionEntry) {
+            $roleIds[] = intval($permissionEntry['role_id']);
+        }
+
+        $actualRoleIds = array_unique(array_values(array_filter($roleIds)));
+        $rolesById = Role::query()->whereIn('id', $actualRoleIds)->get('id')->keyBy('id');
+
+        return array_values(array_filter($entityPermissionData, function ($data) use ($rolesById, $allowFallback) {
+            if (intval($data['role_id']) === 0) {
+                return $allowFallback;
+            }
+
+            return $rolesById->has($data['role_id']);
+        }));
     }
 
     /**
diff --git a/app/Http/Controllers/Api/ContentPermissionApiController.php b/app/Http/Controllers/Api/ContentPermissionApiController.php
new file mode 100644 (file)
index 0000000..47a0d37
--- /dev/null
@@ -0,0 +1,100 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\PermissionsUpdater;
+use Illuminate\Http\Request;
+
+class ContentPermissionApiController extends ApiController
+{
+    public function __construct(
+        protected PermissionsUpdater $permissionsUpdater,
+        protected EntityProvider $entities
+    ) {
+    }
+
+    protected $rules = [
+        'update' => [
+            'owner_id'  => ['int'],
+
+            'role_permissions' => ['array'],
+            'role_permissions.*.role_id' => ['required', 'int', 'exists:roles,id'],
+            'role_permissions.*.view' => ['required', 'boolean'],
+            'role_permissions.*.create' => ['required', 'boolean'],
+            'role_permissions.*.update' => ['required', 'boolean'],
+            'role_permissions.*.delete' => ['required', 'boolean'],
+
+            'fallback_permissions' => ['nullable'],
+            'fallback_permissions.inheriting' => ['required_with:fallback_permissions', 'boolean'],
+            'fallback_permissions.view' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
+            'fallback_permissions.create' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
+            'fallback_permissions.update' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
+            'fallback_permissions.delete' => ['required_if:fallback_permissions.inheriting,false', 'boolean'],
+        ]
+    ];
+
+    /**
+     * Read the configured content-level permissions for the item of the given type and ID.
+     * 'contentType' should be one of: page, book, chapter, bookshelf.
+     * 'contentId' should be the relevant ID of that item type you'd like to handle permissions for.
+     * The permissions shown are those that override the default for just the specified item, they do not show the
+     * full evaluated permission for a role, nor do they reflect permissions inherited from other items in the hierarchy.
+     * Fallback permission values may be `null` when inheriting is active.
+     */
+    public function read(string $contentType, string $contentId)
+    {
+        $entity = $this->entities->get($contentType)
+            ->newQuery()->scopes(['visible'])->findOrFail($contentId);
+
+        $this->checkOwnablePermission('restrictions-manage', $entity);
+
+        return response()->json($this->formattedPermissionDataForEntity($entity));
+    }
+
+    /**
+     * Update the configured content-level permission overrides for the item of the given type and ID.
+     * 'contentType' should be one of: page, book, chapter, bookshelf.
+     * 'contentId' should be the relevant ID of that item type you'd like to handle permissions for.
+     * Providing an empty `role_permissions` array will remove any existing configured role permissions,
+     * so you may want to fetch existing permissions beforehand if just adding/removing a single item.
+     * You should completely omit the `owner_id`, `role_permissions` and/or the `fallback_permissions` properties
+     * from your request data if you don't wish to update details within those categories.
+     */
+    public function update(Request $request, string $contentType, string $contentId)
+    {
+        $entity = $this->entities->get($contentType)
+            ->newQuery()->scopes(['visible'])->findOrFail($contentId);
+
+        $this->checkOwnablePermission('restrictions-manage', $entity);
+
+        $data = $this->validate($request, $this->rules()['update']);
+        $this->permissionsUpdater->updateFromApiRequestData($entity, $data);
+
+        return response()->json($this->formattedPermissionDataForEntity($entity));
+    }
+
+    protected function formattedPermissionDataForEntity(Entity $entity): array
+    {
+        $rolePermissions = $entity->permissions()
+            ->where('role_id', '!=', 0)
+            ->with(['role:id,display_name'])
+            ->get();
+
+        $fallback = $entity->permissions()->where('role_id', '=', 0)->first();
+        $fallbackData = [
+            'inheriting' => is_null($fallback),
+            'view' => $fallback->view ?? null,
+            'create' => $fallback->create ?? null,
+            'update' => $fallback->update ?? null,
+            'delete' => $fallback->delete ?? null,
+        ];
+
+        return [
+            'owner' => $entity->ownedBy()->first(),
+            'role_permissions' => $rolePermissions,
+            'fallback_permissions' => $fallbackData,
+        ];
+    }
+}
diff --git a/app/Http/Controllers/Api/ImageGalleryApiController.php b/app/Http/Controllers/Api/ImageGalleryApiController.php
new file mode 100644 (file)
index 0000000..3dba3d4
--- /dev/null
@@ -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'] = "![{$mdEscapedName}]({$mdEscapedThumb})";
+        }
+
+        return $data;
+    }
+}
index 4f78455e0cae1de72a6567c18df11a0a21b467d2..6986c73f78d9befe3465b0fed3ee1b8b3a98b894 100644 (file)
@@ -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);
     }
 
     /**
index 5484411d36c4208da2055e37d9e5335689c8270a..3f2f5626583dc328b72b084517bb4b8bfcb24b93 100644 (file)
@@ -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
+    ) {
     }
 
     /**
index c21a3b03fe5a27aee99faca7417b0f4a661b8b0c..0ab0b612a7c10a00b6f074d71e9e4e28f49ee2a9 100644 (file)
@@ -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/content-permissions-update.json b/dev/api/requests/content-permissions-update.json
new file mode 100644 (file)
index 0000000..124bb8b
--- /dev/null
@@ -0,0 +1,26 @@
+{
+  "owner_id": 1,
+  "role_permissions": [
+    {
+      "role_id": 2,
+      "view": true,
+      "create": true,
+      "update": true,
+      "delete": false
+    },
+    {
+      "role_id": 3,
+      "view": false,
+      "create": false,
+      "update": false,
+      "delete": false
+    }
+  ],
+  "fallback_permissions": {
+    "inheriting": false,
+    "view": true,
+    "create": true,
+    "update": false,
+    "delete": false
+  }
+}
\ No newline at end of file
diff --git a/dev/api/requests/image-gallery-update.json b/dev/api/requests/image-gallery-update.json
new file mode 100644 (file)
index 0000000..e332e3a
--- /dev/null
@@ -0,0 +1,3 @@
+{
+  "name": "My updated image name"
+}
\ No newline at end of file
diff --git a/dev/api/responses/content-permissions-read.json b/dev/api/responses/content-permissions-read.json
new file mode 100644 (file)
index 0000000..591fc5c
--- /dev/null
@@ -0,0 +1,38 @@
+{
+  "owner": {
+    "id": 1,
+    "name": "Admin",
+    "slug": "admin"
+  },
+  "role_permissions": [
+    {
+      "role_id": 2,
+      "view": true,
+      "create": false,
+      "update": true,
+      "delete": false,
+      "role": {
+        "id": 2,
+        "display_name": "Editor"
+      }
+    },
+    {
+      "role_id": 10,
+      "view": true,
+      "create": true,
+      "update": false,
+      "delete": false,
+      "role": {
+        "id": 10,
+        "display_name": "Wizards of the west"
+      }
+    }
+  ],
+  "fallback_permissions": {
+    "inheriting": false,
+    "view": true,
+    "create": false,
+    "update": false,
+    "delete": false
+  }
+}
\ No newline at end of file
diff --git a/dev/api/responses/content-permissions-update.json b/dev/api/responses/content-permissions-update.json
new file mode 100644 (file)
index 0000000..67fa40b
--- /dev/null
@@ -0,0 +1,38 @@
+{
+  "owner": {
+    "id": 1,
+    "name": "Admin",
+    "slug": "admin"
+  },
+  "role_permissions": [
+    {
+      "role_id": 2,
+      "view": true,
+      "create": true,
+      "update": true,
+      "delete": false,
+      "role": {
+        "id": 2,
+        "display_name": "Editor"
+      }
+    },
+    {
+      "role_id": 3,
+      "view": false,
+      "create": false,
+      "update": false,
+      "delete": false,
+      "role": {
+        "id": 3,
+        "display_name": "Viewer"
+      }
+    }
+  ],
+  "fallback_permissions": {
+    "inheriting": false,
+    "view": true,
+    "create": true,
+    "update": false,
+    "delete": false
+  }
+}
\ 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 (file)
index 0000000..e278244
--- /dev/null
@@ -0,0 +1,28 @@
+{
+  "name": "cute-cat-image.png",
+  "path": "\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png",
+  "url": "https:\/\/p.rizon.top:443\/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:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/thumbs-150-150\/cute-cat-image.png",
+    "display": "https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png"
+  },
+  "content": {
+    "html": "<a href=\"https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png\" target=\"_blank\"><img src=\"https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png\" alt=\"cute-cat-image.png\"><\/a>",
+    "markdown": "![cute-cat-image.png](https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png)"
+  }
+}
\ 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 (file)
index 0000000..054d68a
--- /dev/null
@@ -0,0 +1,41 @@
+{
+  "data": [
+    {
+      "id": 1,
+      "name": "My cat scribbles",
+      "url": "https:\/\/p.rizon.top:443\/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:\/\/p.rizon.top:443\/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:\/\/p.rizon.top:443\/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 (file)
index 0000000..c6c468d
--- /dev/null
@@ -0,0 +1,28 @@
+{
+  "id": 618,
+  "name": "cute-cat-image.png",
+  "url": "https:\/\/p.rizon.top:443\/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:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/thumbs-150-150\/cute-cat-image.png",
+    "display": "https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png"
+  },
+  "content": {
+    "html": "<a href=\"https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png\" target=\"_blank\"><img src=\"https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png\" alt=\"cute-cat-image.png\"><\/a>",
+    "markdown": "![cute-cat-image.png](https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png)"
+  }
+}
\ 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 (file)
index 0000000..6e6168a
--- /dev/null
@@ -0,0 +1,28 @@
+{
+  "id": 618,
+  "name": "My updated image name",
+  "url": "https:\/\/p.rizon.top:443\/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:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/thumbs-150-150\/cute-cat-image.png",
+    "display": "https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png"
+  },
+  "content": {
+    "html": "<a href=\"https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/cute-cat-image.png\" target=\"_blank\"><img src=\"https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png\" alt=\"My updated image name\"><\/a>",
+    "markdown": "![My updated image name](https:\/\/p.rizon.top:443\/https\/bookstack.example.com\/uploads\/images\/gallery\/2023-03\/scaled-1680-\/cute-cat-image.png)"
+  }
+}
\ No newline at end of file
index 7358b5cd765e4335ead15d92937d5ac3a35a0c64..75b71c6beb7d355e15ccf32440945fac07a4acbb 100644 (file)
         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>
index d1b64d455270e66b2c077ffe9b270b2021f1838a..c809cdb3af11f7cd5e1aee157d21e60d323c260c 100644 (file)
@@ -13,6 +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\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;
@@ -62,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']);
@@ -85,3 +93,6 @@ Route::delete('roles/{id}', [RoleApiController::class, 'delete']);
 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}', [ContentPermissionApiController::class, 'read']);
+Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionApiController::class, 'update']);
diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php
new file mode 100644 (file)
index 0000000..50b82e5
--- /dev/null
@@ -0,0 +1,262 @@
+<?php
+
+namespace Tests\Api;
+
+use Tests\TestCase;
+
+class ContentPermissionsApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected string $baseEndpoint = '/api/content-permissions';
+
+    public function test_user_roles_manage_permission_needed_for_all_endpoints()
+    {
+        $page = $this->entities->page();
+        $endpointMap = [
+            ['get', "/api/content-permissions/page/{$page->id}"],
+            ['put', "/api/content-permissions/page/{$page->id}"],
+        ];
+        $editor = $this->users->editor();
+
+        $this->actingAs($editor, 'api');
+        foreach ($endpointMap as [$method, $uri]) {
+            $resp = $this->json($method, $uri);
+            $resp->assertStatus(403);
+            $resp->assertJson($this->permissionErrorResponse());
+        }
+
+        $this->permissions->grantUserRolePermissions($editor, ['restrictions-manage-all']);
+
+        foreach ($endpointMap as [$method, $uri]) {
+            $resp = $this->json($method, $uri);
+            $this->assertNotEquals(403, $resp->getStatusCode());
+        }
+    }
+
+    public function test_read_endpoint_shows_expected_detail()
+    {
+        $page = $this->entities->page();
+        $owner = $this->users->newUser();
+        $role = $this->users->createRole();
+        $this->permissions->addEntityPermission($page, ['view', 'delete'], $role);
+        $this->permissions->changeEntityOwner($page, $owner);
+        $this->permissions->setFallbackPermissions($page, ['update', 'create']);
+
+        $this->actingAsApiAdmin();
+        $resp = $this->getJson($this->baseEndpoint . "/page/{$page->id}");
+
+        $resp->assertOk();
+        $resp->assertExactJson([
+            'owner' => [
+                'id' => $owner->id, 'name' => $owner->name, 'slug' => $owner->slug,
+            ],
+            'role_permissions' => [
+                [
+                    'role_id' => $role->id,
+                    'view' => true,
+                    'create' => false,
+                    'update' => false,
+                    'delete' => true,
+                    'role' => [
+                        'id' => $role->id,
+                        'display_name' => $role->display_name,
+                    ]
+                ]
+            ],
+            'fallback_permissions' => [
+                'inheriting' => false,
+                'view' => false,
+                'create' => true,
+                'update' => true,
+                'delete' => false,
+            ],
+        ]);
+    }
+
+    public function test_read_endpoint_shows_expected_detail_when_items_are_empty()
+    {
+        $page = $this->entities->page();
+        $page->permissions()->delete();
+        $page->owned_by = null;
+        $page->save();
+
+        $this->actingAsApiAdmin();
+        $resp = $this->getJson($this->baseEndpoint . "/page/{$page->id}");
+
+        $resp->assertOk();
+        $resp->assertExactJson([
+            'owner' => null,
+            'role_permissions' => [],
+            'fallback_permissions' => [
+                'inheriting' => true,
+                'view' => null,
+                'create' => null,
+                'update' => null,
+                'delete' => null,
+            ],
+        ]);
+    }
+
+    public function test_update_endpoint_can_change_owner()
+    {
+        $page = $this->entities->page();
+        $newOwner = $this->users->newUser();
+
+        $this->actingAsApiAdmin();
+        $resp = $this->putJson($this->baseEndpoint . "/page/{$page->id}", [
+            'owner_id' => $newOwner->id,
+        ]);
+
+        $resp->assertOk();
+        $resp->assertExactJson([
+            'owner' => ['id' => $newOwner->id, 'name' => $newOwner->name, 'slug' => $newOwner->slug],
+            'role_permissions' => [],
+            'fallback_permissions' => [
+                'inheriting' => true,
+                'view' => null,
+                'create' => null,
+                'update' => null,
+                'delete' => null,
+            ],
+        ]);
+    }
+
+    public function test_update_can_set_role_permissions()
+    {
+        $page = $this->entities->page();
+        $page->owned_by = null;
+        $page->save();
+        $newRoleA = $this->users->createRole();
+        $newRoleB = $this->users->createRole();
+
+        $this->actingAsApiAdmin();
+        $resp = $this->putJson($this->baseEndpoint . "/page/{$page->id}", [
+            'role_permissions' => [
+                ['role_id' => $newRoleA->id, 'view' => true, 'create' => false, 'update' => false, 'delete' => false],
+                ['role_id' => $newRoleB->id, 'view' => true, 'create' => false, 'update' => true, 'delete' => true],
+            ],
+        ]);
+
+        $resp->assertOk();
+        $resp->assertExactJson([
+            'owner' => null,
+            'role_permissions' => [
+                [
+                    'role_id' => $newRoleA->id,
+                    'view' => true,
+                    'create' => false,
+                    'update' => false,
+                    'delete' => false,
+                    'role' => [
+                        'id' => $newRoleA->id,
+                        'display_name' => $newRoleA->display_name,
+                    ]
+                ],
+                [
+                    'role_id' => $newRoleB->id,
+                    'view' => true,
+                    'create' => false,
+                    'update' => true,
+                    'delete' => true,
+                    'role' => [
+                        'id' => $newRoleB->id,
+                        'display_name' => $newRoleB->display_name,
+                    ]
+                ]
+            ],
+            'fallback_permissions' => [
+                'inheriting' => true,
+                'view' => null,
+                'create' => null,
+                'update' => null,
+                'delete' => null,
+            ],
+        ]);
+    }
+
+    public function test_update_can_set_fallback_permissions()
+    {
+        $page = $this->entities->page();
+        $page->owned_by = null;
+        $page->save();
+
+        $this->actingAsApiAdmin();
+        $resp = $this->putJson($this->baseEndpoint . "/page/{$page->id}", [
+            'fallback_permissions' => [
+                'inheriting' => false,
+                'view' => true,
+                'create' => true,
+                'update' => true,
+                'delete' => false,
+            ],
+        ]);
+
+        $resp->assertOk();
+        $resp->assertExactJson([
+            'owner' => null,
+            'role_permissions' => [],
+            'fallback_permissions' => [
+                'inheriting' => false,
+                'view' => true,
+                'create' => true,
+                'update' => true,
+                'delete' => false,
+            ],
+        ]);
+    }
+
+    public function test_update_can_clear_roles_permissions()
+    {
+        $page = $this->entities->page();
+        $this->permissions->addEntityPermission($page, ['view'], $this->users->createRole());
+        $page->owned_by = null;
+        $page->save();
+
+        $this->actingAsApiAdmin();
+        $resp = $this->putJson($this->baseEndpoint . "/page/{$page->id}", [
+            'role_permissions' => [],
+        ]);
+
+        $resp->assertOk();
+        $resp->assertExactJson([
+            'owner' => null,
+            'role_permissions' => [],
+            'fallback_permissions' => [
+                'inheriting' => true,
+                'view' => null,
+                'create' => null,
+                'update' => null,
+                'delete' => null,
+            ],
+        ]);
+    }
+
+    public function test_update_can_clear_fallback_permissions()
+    {
+        $page = $this->entities->page();
+        $this->permissions->setFallbackPermissions($page, ['view', 'update']);
+        $page->owned_by = null;
+        $page->save();
+
+        $this->actingAsApiAdmin();
+        $resp = $this->putJson($this->baseEndpoint . "/page/{$page->id}", [
+            'fallback_permissions' => [
+                'inheriting' => true,
+            ],
+        ]);
+
+        $resp->assertOk();
+        $resp->assertExactJson([
+            'owner' => null,
+            'role_permissions' => [],
+            'fallback_permissions' => [
+                'inheriting' => true,
+                'view' => null,
+                'create' => null,
+                'update' => null,
+                'delete' => null,
+            ],
+        ]);
+    }
+}
diff --git a/tests/Api/ImageGalleryApiTest.php b/tests/Api/ImageGalleryApiTest.php
new file mode 100644 (file)
index 0000000..17c9051
--- /dev/null
@@ -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' => "![{$image->name}]({$displayUrl})",
+            ],
+        ]);
+        $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);
+    }
+}
index 501f2875458616da28bf33906ae49ac267c0465d..c566fd8de337fc2401ad796f5eeab483a5ca5e7c 100644 (file)
@@ -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');