From c42956bcafc7c43275457887c119476af8f72b36 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 13 Mar 2023 13:18:33 +0000
Subject: [PATCH] Started build of content-permissions API endpoints

---
 app/Auth/Permissions/EntityPermission.php     | 17 ++--
 app/Entities/EntityProvider.php               | 38 +++-----
 app/Entities/Tools/PermissionsUpdater.php     | 74 +++++++++++++++-
 .../Api/ContentPermissionsController.php      | 87 +++++++++++++++++++
 routes/api.php                                |  4 +
 5 files changed, 181 insertions(+), 39 deletions(-)
 create mode 100644 app/Http/Controllers/Api/ContentPermissionsController.php

diff --git a/app/Auth/Permissions/EntityPermission.php b/app/Auth/Permissions/EntityPermission.php
index 32ebc440d..603cf61ad 100644
--- a/app/Auth/Permissions/EntityPermission.php
+++ b/app/Auth/Permissions/EntityPermission.php
@@ -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.
diff --git a/app/Entities/EntityProvider.php b/app/Entities/EntityProvider.php
index aaf392c7b..365daf7eb 100644
--- a/app/Entities/EntityProvider.php
+++ b/app/Entities/EntityProvider.php
@@ -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;
 
-        return $this->all()[$type];
+        if (is_null($instance)) {
+            throw new \InvalidArgumentException("Provided type \"{$type}\" is not a valid entity type");
+        }
+
+        return $instance;
     }
 
     /**
diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php
index eb4eb6b48..0d1d307af 100644
--- a/app/Entities/Tools/PermissionsUpdater.php
+++ b/app/Entities/Tools/PermissionsUpdater.php
@@ -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['override_role_permissions'])) {
+            $entity->permissions()->where('role_id', '!=', 0)->delete();
+            $rolePermissionData = $this->formatPermissionsFromApiRequestToEntityPermissions($data['override_role_permissions'] ?? [], false);
+            $entity->permissions()->createMany($rolePermissionData);
+        }
+
+        if (array_key_exists('override_fallback_permissions', $data)) {
+            $entity->permissions()->where('role_id', '=', 0)->delete();
+        }
+
+        if (isset($data['override_fallback_permissions'])) {
+            $data = $data['override_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/ContentPermissionsController.php b/app/Http/Controllers/Api/ContentPermissionsController.php
new file mode 100644
index 000000000..16fb68a0f
--- /dev/null
+++ b/app/Http/Controllers/Api/ContentPermissionsController.php
@@ -0,0 +1,87 @@
+<?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 ContentPermissionsController extends ApiController
+{
+    public function __construct(
+        protected PermissionsUpdater $permissionsUpdater,
+        protected EntityProvider $entities
+    ) {
+    }
+
+    protected $rules = [
+        'update' => [
+            'owner_id'  => ['int'],
+
+            'override_role_permissions' => ['array'],
+            'override_role_permissions.*.role_id' => ['required', 'int'],
+            'override_role_permissions.*.view' => ['required', 'boolean'],
+            'override_role_permissions.*.create' => ['required', 'boolean'],
+            'override_role_permissions.*.update' => ['required', 'boolean'],
+            'override_role_permissions.*.delete' => ['required', 'boolean'],
+
+            'override_fallback_permissions' => ['nullable'],
+            'override_fallback_permissions.view' => ['required', 'boolean'],
+            'override_fallback_permissions.create' => ['required', 'boolean'],
+            'override_fallback_permissions.update' => ['required', 'boolean'],
+            'override_fallback_permissions.delete' => ['required', '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.
+     */
+    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 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.
+     */
+    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();
+        $fallback?->makeHidden('role_id');
+
+        return [
+            'owner' => $entity->ownedBy()->first(),
+            'override_role_permissions' => $rolePermissions,
+            'override_fallback_permissions' => $fallback,
+            'inheriting' => is_null($fallback),
+        ];
+    }
+}
diff --git a/routes/api.php b/routes/api.php
index d1b64d455..1b852fed7 100644
--- a/routes/api.php
+++ b/routes/api.php
@@ -13,6 +13,7 @@ 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\PageApiController;
 use BookStack\Http\Controllers\Api\PageExportApiController;
 use BookStack\Http\Controllers\Api\RecycleBinApiController;
@@ -85,3 +86,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}', [ContentPermissionsController::class, 'read']);
+Route::put('content-permissions/{contentType}/{contentId}', [ContentPermissionsController::class, 'update']);