From 2fbf5527c70d5d3eadb2767ca5301ad05f7f28c8 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Sun, 10 Sep 2023 15:18:31 +0100
Subject: [PATCH] Simplified and aligned handling of mixed entity endpoints

Fixes #4444
---
 .../Controllers/FavouriteController.php       | 53 +++++--------------
 app/Activity/Controllers/WatchController.php  | 43 ++-------------
 .../Tools/MixedEntityRequestHelper.php        | 39 ++++++++++++++
 .../views/entities/favourite-action.blade.php |  2 +-
 .../views/entities/watch-action.blade.php     |  2 +-
 .../views/entities/watch-controls.blade.php   |  2 +-
 tests/Activity/WatchTest.php                  |  6 +--
 tests/FavouriteTest.php                       |  8 +--
 8 files changed, 67 insertions(+), 88 deletions(-)
 create mode 100644 app/Entities/Tools/MixedEntityRequestHelper.php

diff --git a/app/Activity/Controllers/FavouriteController.php b/app/Activity/Controllers/FavouriteController.php
index 1b88ffd64..d2534ddfe 100644
--- a/app/Activity/Controllers/FavouriteController.php
+++ b/app/Activity/Controllers/FavouriteController.php
@@ -6,11 +6,17 @@ use BookStack\Activity\Models\Favouritable;
 use BookStack\App\Model;
 use BookStack\Entities\Models\Entity;
 use BookStack\Entities\Queries\TopFavourites;
+use BookStack\Entities\Tools\MixedEntityRequestHelper;
 use BookStack\Http\Controller;
 use Illuminate\Http\Request;
 
 class FavouriteController extends Controller
 {
+    public function __construct(
+        protected MixedEntityRequestHelper $entityHelper,
+    ) {
+    }
+
     /**
      * Show a listing of all favourite items for the current user.
      */
@@ -36,13 +42,14 @@ class FavouriteController extends Controller
      */
     public function add(Request $request)
     {
-        $favouritable = $this->getValidatedModelFromRequest($request);
-        $favouritable->favourites()->firstOrCreate([
+        $modelInfo = $this->validate($request, $this->entityHelper->validationRules());
+        $entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
+        $entity->favourites()->firstOrCreate([
             'user_id' => user()->id,
         ]);
 
         $this->showSuccessNotification(trans('activities.favourite_add_notification', [
-            'name' => $favouritable->name,
+            'name' => $entity->name,
         ]));
 
         return redirect()->back();
@@ -53,48 +60,16 @@ class FavouriteController extends Controller
      */
     public function remove(Request $request)
     {
-        $favouritable = $this->getValidatedModelFromRequest($request);
-        $favouritable->favourites()->where([
+        $modelInfo = $this->validate($request, $this->entityHelper->validationRules());
+        $entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
+        $entity->favourites()->where([
             'user_id' => user()->id,
         ])->delete();
 
         $this->showSuccessNotification(trans('activities.favourite_remove_notification', [
-            'name' => $favouritable->name,
+            'name' => $entity->name,
         ]));
 
         return redirect()->back();
     }
-
-    /**
-     * @throws \Illuminate\Validation\ValidationException
-     * @throws \Exception
-     */
-    protected function getValidatedModelFromRequest(Request $request): Entity
-    {
-        $modelInfo = $this->validate($request, [
-            'type' => ['required', 'string'],
-            'id'   => ['required', 'integer'],
-        ]);
-
-        if (!class_exists($modelInfo['type'])) {
-            throw new \Exception('Model not found');
-        }
-
-        /** @var Model $model */
-        $model = new $modelInfo['type']();
-        if (!$model instanceof Favouritable) {
-            throw new \Exception('Model not favouritable');
-        }
-
-        $modelInstance = $model->newQuery()
-            ->where('id', '=', $modelInfo['id'])
-            ->first(['id', 'name', 'owned_by']);
-
-        $inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
-        if (is_null($modelInstance) || $inaccessibleEntity) {
-            throw new \Exception('Model instance not found');
-        }
-
-        return $modelInstance;
-    }
 }
diff --git a/app/Activity/Controllers/WatchController.php b/app/Activity/Controllers/WatchController.php
index 3d7e18116..d63918fb3 100644
--- a/app/Activity/Controllers/WatchController.php
+++ b/app/Activity/Controllers/WatchController.php
@@ -3,25 +3,23 @@
 namespace BookStack\Activity\Controllers;
 
 use BookStack\Activity\Tools\UserEntityWatchOptions;
-use BookStack\App\Model;
-use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Tools\MixedEntityRequestHelper;
 use BookStack\Http\Controller;
-use Exception;
 use Illuminate\Http\Request;
-use Illuminate\Validation\ValidationException;
 
 class WatchController extends Controller
 {
-    public function update(Request $request)
+    public function update(Request $request, MixedEntityRequestHelper $entityHelper)
     {
         $this->checkPermission('receive-notifications');
         $this->preventGuestAccess();
 
         $requestData = $this->validate($request, [
             'level' => ['required', 'string'],
+            ...$entityHelper->validationRules()
         ]);
 
-        $watchable = $this->getValidatedModelFromRequest($request);
+        $watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
         $watchOptions = new UserEntityWatchOptions(user(), $watchable);
         $watchOptions->updateLevelByName($requestData['level']);
 
@@ -29,37 +27,4 @@ class WatchController extends Controller
 
         return redirect()->back();
     }
-
-    /**
-     * @throws ValidationException
-     * @throws Exception
-     */
-    protected function getValidatedModelFromRequest(Request $request): Entity
-    {
-        $modelInfo = $this->validate($request, [
-            'type' => ['required', 'string'],
-            'id'   => ['required', 'integer'],
-        ]);
-
-        if (!class_exists($modelInfo['type'])) {
-            throw new Exception('Model not found');
-        }
-
-        /** @var Model $model */
-        $model = new $modelInfo['type']();
-        if (!$model instanceof Entity) {
-            throw new Exception('Model not an entity');
-        }
-
-        $modelInstance = $model->newQuery()
-            ->where('id', '=', $modelInfo['id'])
-            ->first(['id', 'name', 'owned_by']);
-
-        $inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
-        if (is_null($modelInstance) || $inaccessibleEntity) {
-            throw new Exception('Model instance not found');
-        }
-
-        return $modelInstance;
-    }
 }
diff --git a/app/Entities/Tools/MixedEntityRequestHelper.php b/app/Entities/Tools/MixedEntityRequestHelper.php
new file mode 100644
index 000000000..8319f6aa0
--- /dev/null
+++ b/app/Entities/Tools/MixedEntityRequestHelper.php
@@ -0,0 +1,39 @@
+<?php
+
+namespace BookStack\Entities\Tools;
+
+use BookStack\Entities\EntityProvider;
+use BookStack\Entities\Models\Entity;
+
+class MixedEntityRequestHelper
+{
+    public function __construct(
+        protected EntityProvider $entities,
+    ) {
+    }
+
+    /**
+     * Query out an entity, visible to the current user, for the given
+     * entity request details (this provided in a request validated by
+     * this classes' validationRules method).
+     * @param array{type: string, id: string} $requestData
+     */
+    public function getVisibleEntityFromRequestData(array $requestData): Entity
+    {
+        $entityType = $this->entities->get($requestData['type']);
+
+        return $entityType->newQuery()->scopes(['visible'])->findOrFail($requestData['id']);
+    }
+
+    /**
+     * Get the validation rules for an abstract entity request.
+     * @return array{type: string[], id: string[]}
+     */
+    public function validationRules(): array
+    {
+        return [
+                'type' => ['required', 'string'],
+                'id'   => ['required', 'integer'],
+        ];
+    }
+}
diff --git a/resources/views/entities/favourite-action.blade.php b/resources/views/entities/favourite-action.blade.php
index 35189044b..e596fbdce 100644
--- a/resources/views/entities/favourite-action.blade.php
+++ b/resources/views/entities/favourite-action.blade.php
@@ -3,7 +3,7 @@
 @endphp
 <form action="{{ url('/favourites/' . ($isFavourite ? 'remove' : 'add')) }}" method="POST">
     {{ csrf_field() }}
-    <input type="hidden" name="type" value="{{ get_class($entity) }}">
+    <input type="hidden" name="type" value="{{ $entity->getMorphClass() }}">
     <input type="hidden" name="id" value="{{ $entity->id }}">
     <button type="submit" data-shortcut="favourite" class="icon-list-item text-link">
         <span>@icon($isFavourite ? 'star' : 'star-outline')</span>
diff --git a/resources/views/entities/watch-action.blade.php b/resources/views/entities/watch-action.blade.php
index 34e287804..c87bc2b2b 100644
--- a/resources/views/entities/watch-action.blade.php
+++ b/resources/views/entities/watch-action.blade.php
@@ -1,7 +1,7 @@
 <form action="{{ url('/watching/update') }}" method="POST">
     {{ csrf_field() }}
     {{ method_field('PUT') }}
-    <input type="hidden" name="type" value="{{ get_class($entity) }}">
+    <input type="hidden" name="type" value="{{ $entity->getMorphClass() }}">
     <input type="hidden" name="id" value="{{ $entity->id }}">
     <button type="submit"
             name="level"
diff --git a/resources/views/entities/watch-controls.blade.php b/resources/views/entities/watch-controls.blade.php
index d24e12018..9389a6c5a 100644
--- a/resources/views/entities/watch-controls.blade.php
+++ b/resources/views/entities/watch-controls.blade.php
@@ -7,7 +7,7 @@
     <form action="{{ url('/watching/update') }}" method="POST">
         {{ method_field('PUT') }}
         {{ csrf_field() }}
-        <input type="hidden" name="type" value="{{ get_class($entity) }}">
+        <input type="hidden" name="type" value="{{ $entity->getMorphClass() }}">
         <input type="hidden" name="id" value="{{ $entity->id }}">
 
         <ul refs="dropdown@menu" class="dropdown-menu xl-limited anchor-left pb-none">
diff --git a/tests/Activity/WatchTest.php b/tests/Activity/WatchTest.php
index fa50d8c79..5db0067cc 100644
--- a/tests/Activity/WatchTest.php
+++ b/tests/Activity/WatchTest.php
@@ -66,7 +66,7 @@ class WatchTest extends TestCase
 
         $this->actingAs($editor)->get($book->getUrl());
         $resp = $this->put('/watching/update', [
-            'type' => get_class($book),
+            'type' => $book->getMorphClass(),
             'id' => $book->id,
             'level' => 'comments'
         ]);
@@ -81,7 +81,7 @@ class WatchTest extends TestCase
         ]);
 
         $resp = $this->put('/watching/update', [
-            'type' => get_class($book),
+            'type' => $book->getMorphClass(),
             'id' => $book->id,
             'level' => 'default'
         ]);
@@ -101,7 +101,7 @@ class WatchTest extends TestCase
         $book = $this->entities->book();
 
         $resp = $this->put('/watching/update', [
-            'type' => get_class($book),
+            'type' => $book->getMorphClass(),
             'id' => $book->id,
             'level' => 'comments'
         ]);
diff --git a/tests/FavouriteTest.php b/tests/FavouriteTest.php
index 0e30cbd58..48048e284 100644
--- a/tests/FavouriteTest.php
+++ b/tests/FavouriteTest.php
@@ -14,10 +14,10 @@ class FavouriteTest extends TestCase
 
         $resp = $this->actingAs($editor)->get($page->getUrl());
         $this->withHtml($resp)->assertElementContains('button', 'Favourite');
-        $this->withHtml($resp)->assertElementExists('form[method="POST"][action$="/favourites/add"]');
+        $this->withHtml($resp)->assertElementExists('form[method="POST"][action$="/favourites/add"] input[name="type"][value="page"]');
 
         $resp = $this->post('/favourites/add', [
-            'type' => get_class($page),
+            'type' => $page->getMorphClass(),
             'id'   => $page->id,
         ]);
         $resp->assertRedirect($page->getUrl());
@@ -45,7 +45,7 @@ class FavouriteTest extends TestCase
         $this->withHtml($resp)->assertElementExists('form[method="POST"][action$="/favourites/remove"]');
 
         $resp = $this->post('/favourites/remove', [
-            'type' => get_class($page),
+            'type' => $page->getMorphClass(),
             'id'   => $page->id,
         ]);
         $resp->assertRedirect($page->getUrl());
@@ -67,7 +67,7 @@ class FavouriteTest extends TestCase
 
         $this->actingAs($user)->get($book->getUrl());
         $resp = $this->post('/favourites/add', [
-            'type' => get_class($book),
+            'type' => $book->getMorphClass(),
             'id'   => $book->id,
         ]);
         $resp->assertRedirect($book->getUrl());