]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'recycle_bin_api_endpoints' into development
authorDan Brown <redacted>
Mon, 25 Apr 2022 17:32:55 +0000 (18:32 +0100)
committerDan Brown <redacted>
Mon, 25 Apr 2022 17:32:55 +0000 (18:32 +0100)
app/Entities/Models/Deletion.php
app/Entities/Repos/DeletionRepo.php [new file with mode: 0644]
app/Http/Controllers/Api/RecycleBinApiController.php [new file with mode: 0644]
app/Http/Controllers/RecycleBinController.php
app/Providers/AppServiceProvider.php
database/migrations/2022_04_25_140741_update_polymorphic_types.php [new file with mode: 0644]
dev/api/responses/recycle-bin-destroy.json [new file with mode: 0644]
dev/api/responses/recycle-bin-list.json [new file with mode: 0644]
dev/api/responses/recycle-bin-restore.json [new file with mode: 0644]
routes/api.php
tests/Api/RecycleBinApiTest.php [new file with mode: 0644]

index 181c9c5803d6441254bd89fc92e66cbddf27c4d0..101a138d1ce2185e2623fa03f664299252771f73 100644 (file)
@@ -10,10 +10,16 @@ use Illuminate\Database\Eloquent\Relations\BelongsTo;
 use Illuminate\Database\Eloquent\Relations\MorphTo;
 
 /**
+ * @property int $id
+ * @property int $deleted_by
+ * @property string $deletable_type
+ * @property int $deletable_id
  * @property Deletable $deletable
  */
 class Deletion extends Model implements Loggable
 {
+    protected $hidden = [];
+
     /**
      * Get the related deletable record.
      */
diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php
new file mode 100644 (file)
index 0000000..5d53013
--- /dev/null
@@ -0,0 +1,36 @@
+<?php
+
+namespace BookStack\Entities\Repos;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Tools\TrashCan;
+use BookStack\Facades\Activity;
+
+class DeletionRepo
+{
+    private TrashCan $trashCan;
+
+    public function __construct(TrashCan $trashCan)
+    {
+        $this->trashCan = $trashCan;
+    }
+
+    public function restore(int $id): int
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
+
+        return $this->trashCan->restoreFromDeletion($deletion);
+    }
+
+    public function destroy(int $id): int
+    {
+        /** @var Deletion $deletion */
+        $deletion = Deletion::query()->findOrFail($id);
+        Activity::add(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
+
+        return $this->trashCan->destroyFromDeletion($deletion);
+    }
+}
diff --git a/app/Http/Controllers/Api/RecycleBinApiController.php b/app/Http/Controllers/Api/RecycleBinApiController.php
new file mode 100644 (file)
index 0000000..bbe19bd
--- /dev/null
@@ -0,0 +1,90 @@
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\BookChild;
+use BookStack\Entities\Models\Chapter;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Repos\DeletionRepo;
+use Closure;
+use Illuminate\Database\Eloquent\Builder;
+
+class RecycleBinApiController extends ApiController
+{
+    public function __construct()
+    {
+        $this->middleware(function ($request, $next) {
+            $this->checkPermission('settings-manage');
+            $this->checkPermission('restrictions-manage-all');
+
+            return $next($request);
+        });
+    }
+
+    /**
+     * Get a top-level listing of the items in the recycle bin.
+     * The "deletable" property will reflect the main item deleted.
+     * For books and chapters, counts of child pages/chapters will
+     * be loaded within this "deletable" data.
+     * For chapters & pages, the parent item will be loaded within this "deletable" data.
+     * Requires permission to manage both system settings and permissions.
+     */
+    public function list()
+    {
+        return $this->apiListingResponse(Deletion::query()->with('deletable'), [
+            'id',
+            'deleted_by',
+            'created_at',
+            'updated_at',
+            'deletable_type',
+            'deletable_id',
+        ], [Closure::fromCallable([$this, 'listFormatter'])]);
+    }
+
+    /**
+     * Restore a single deletion from the recycle bin.
+     * Requires permission to manage both system settings and permissions.
+     */
+    public function restore(DeletionRepo $deletionRepo, string $deletionId)
+    {
+        $restoreCount = $deletionRepo->restore(intval($deletionId));
+
+        return response()->json(['restore_count' => $restoreCount]);
+    }
+
+    /**
+     * Remove a single deletion from the recycle bin.
+     * Use this endpoint carefully as it will entirely remove the underlying deleted items from the system.
+     * Requires permission to manage both system settings and permissions.
+     */
+    public function destroy(DeletionRepo $deletionRepo, string $deletionId)
+    {
+        $deleteCount = $deletionRepo->destroy(intval($deletionId));
+
+        return response()->json(['delete_count' => $deleteCount]);
+    }
+
+    /**
+     * Load some related details for the deletion listing.
+     */
+    protected function listFormatter(Deletion $deletion)
+    {
+        $deletable = $deletion->deletable;
+        $withTrashedQuery = fn(Builder $query) => $query->withTrashed();
+
+        if ($deletable instanceof BookChild) {
+            $parent = $deletable->getParent();
+            $parent->setAttribute('type', $parent->getType());
+            $deletable->setRelation('parent', $parent);
+        }
+
+        if ($deletable instanceof Book || $deletable instanceof Chapter) {
+            $countsToLoad = ['pages' => $withTrashedQuery];
+            if ($deletable instanceof Book) {
+                $countsToLoad['chapters'] = $withTrashedQuery;
+            }
+            $deletable->loadCount($countsToLoad);
+        }
+    }
+}
index 1cffb161cc8a3f94e68d8d6eaeb72ab2e10d7730..82e3f660bddb92475ee5f79f9cdcbd993b1550f3 100644 (file)
@@ -5,6 +5,7 @@ namespace BookStack\Http\Controllers;
 use BookStack\Actions\ActivityType;
 use BookStack\Entities\Models\Deletion;
 use BookStack\Entities\Models\Entity;
+use BookStack\Entities\Repos\DeletionRepo;
 use BookStack\Entities\Tools\TrashCan;
 
 class RecycleBinController extends Controller
@@ -73,12 +74,9 @@ class RecycleBinController extends Controller
      *
      * @throws \Exception
      */
-    public function restore(string $id)
+    public function restore(DeletionRepo $deletionRepo, string $id)
     {
-        /** @var Deletion $deletion */
-        $deletion = Deletion::query()->findOrFail($id);
-        $this->logActivity(ActivityType::RECYCLE_BIN_RESTORE, $deletion);
-        $restoreCount = (new TrashCan())->restoreFromDeletion($deletion);
+        $restoreCount = $deletionRepo->restore((int) $id);
 
         $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount]));
 
@@ -103,12 +101,9 @@ class RecycleBinController extends Controller
      *
      * @throws \Exception
      */
-    public function destroy(string $id)
+    public function destroy(DeletionRepo $deletionRepo, string $id)
     {
-        /** @var Deletion $deletion */
-        $deletion = Deletion::query()->findOrFail($id);
-        $this->logActivity(ActivityType::RECYCLE_BIN_DESTROY, $deletion);
-        $deleteCount = (new TrashCan())->destroyFromDeletion($deletion);
+        $deleteCount = $deletionRepo->destroy((int) $id);
 
         $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
 
index fc712632e9b876117f605d36d5bd0971f4290468..3c1212e3274d5b23a42da1f8d9df26a1630bcdc8 100644 (file)
@@ -51,12 +51,12 @@ class AppServiceProvider extends ServiceProvider
         // Allow longer string lengths after upgrade to utf8mb4
         Schema::defaultStringLength(191);
 
-        // Set morph-map due to namespace changes
-        Relation::morphMap([
-            'BookStack\\Bookshelf' => Bookshelf::class,
-            'BookStack\\Book'      => Book::class,
-            'BookStack\\Chapter'   => Chapter::class,
-            'BookStack\\Page'      => Page::class,
+        // Set morph-map for our relations to friendlier aliases
+        Relation::enforceMorphMap([
+            'bookshelf' => Bookshelf::class,
+            'book'      => Book::class,
+            'chapter'   => Chapter::class,
+            'page'      => Page::class,
         ]);
 
         // View Composers
diff --git a/database/migrations/2022_04_25_140741_update_polymorphic_types.php b/database/migrations/2022_04_25_140741_update_polymorphic_types.php
new file mode 100644 (file)
index 0000000..4645ab2
--- /dev/null
@@ -0,0 +1,64 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Support\Facades\DB;
+
+class UpdatePolymorphicTypes extends Migration
+{
+    /**
+     * Mapping of old polymorphic types to new simpler values.
+     */
+    protected $changeMap = [
+        'BookStack\\Bookshelf' => 'bookshelf',
+        'BookStack\\Book'      => 'book',
+        'BookStack\\Chapter'   => 'chapter',
+        'BookStack\\Page'      => 'page',
+    ];
+
+    /**
+     * Mapping of tables and columns that contain polymorphic types.
+     */
+    protected $columnsByTable = [
+        'activities' => 'entity_type',
+        'comments'   => 'entity_type',
+        'deletions'  => 'deletable_type',
+        'entity_permissions' => 'restrictable_type',
+        'favourites' => 'favouritable_type',
+        'joint_permissions' => 'entity_type',
+        'search_terms' => 'entity_type',
+        'tags' => 'entity_type',
+        'views' => 'viewable_type',
+    ];
+
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        foreach ($this->columnsByTable as $table => $column) {
+            foreach ($this->changeMap as $oldVal => $newVal) {
+                DB::table($table)
+                    ->where([$column => $oldVal])
+                    ->update([$column => $newVal]);
+            }
+        }
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        foreach ($this->columnsByTable as $table => $column) {
+            foreach ($this->changeMap as $oldVal => $newVal) {
+                DB::table($table)
+                    ->where([$column => $newVal])
+                    ->update([$column => $oldVal]);
+            }
+        }
+    }
+}
diff --git a/dev/api/responses/recycle-bin-destroy.json b/dev/api/responses/recycle-bin-destroy.json
new file mode 100644 (file)
index 0000000..21cfc40
--- /dev/null
@@ -0,0 +1,3 @@
+{
+  "delete_count": 2
+}
\ No newline at end of file
diff --git a/dev/api/responses/recycle-bin-list.json b/dev/api/responses/recycle-bin-list.json
new file mode 100644 (file)
index 0000000..8530708
--- /dev/null
@@ -0,0 +1,64 @@
+{
+  "data": [
+    {
+      "id": 18,
+      "deleted_by": 1,
+      "created_at": "2022-04-20T12:57:46.000000Z",
+      "updated_at": "2022-04-20T12:57:46.000000Z",
+      "deletable_type": "page",
+      "deletable_id": 2582,
+      "deletable": {
+        "id": 2582,
+        "book_id": 25,
+        "chapter_id": 0,
+        "name": "A Wonderful Page",
+        "slug": "a-wonderful-page",
+        "priority": 9,
+        "created_at": "2022-02-08T00:44:45.000000Z",
+        "updated_at": "2022-04-20T12:57:46.000000Z",
+        "created_by": 1,
+        "updated_by": 1,
+        "draft": false,
+        "revision_count": 1,
+        "template": false,
+        "owned_by": 1,
+        "editor": "wysiwyg",
+        "book_slug": "a-great-book",
+        "parent": {
+          "id": 25,
+          "name": "A Great Book",
+          "slug": "a-great-book",
+          "description": "",
+          "created_at": "2022-01-24T16:14:28.000000Z",
+          "updated_at": "2022-03-06T15:14:50.000000Z",
+          "created_by": 1,
+          "updated_by": 1,
+          "owned_by": 1,
+          "type": "book"
+        }
+      }
+    },
+    {
+      "id": 19,
+      "deleted_by": 1,
+      "created_at": "2022-04-25T16:07:46.000000Z",
+      "updated_at": "2022-04-25T16:07:46.000000Z",
+      "deletable_type": "book",
+      "deletable_id": 13,
+      "deletable": {
+        "id": 13,
+        "name": "A Big Book!",
+        "slug": "a-big-book",
+        "description": "This is a very large book with loads of cool stuff in it!",
+        "created_at": "2021-11-08T11:26:43.000000Z",
+        "updated_at": "2022-04-25T16:07:47.000000Z",
+        "created_by": 27,
+        "updated_by": 1,
+        "owned_by": 1,
+        "pages_count": 208,
+        "chapters_count": 50
+      }
+    }
+  ],
+  "total": 2
+}
\ No newline at end of file
diff --git a/dev/api/responses/recycle-bin-restore.json b/dev/api/responses/recycle-bin-restore.json
new file mode 100644 (file)
index 0000000..ac5c948
--- /dev/null
@@ -0,0 +1,3 @@
+{
+  "restore_count": 2
+}
\ No newline at end of file
index a87169ee51b805144f2bbfdda85dc9ba5e1b827b..20e167d70934c0d35104014986780a4c2160a510 100644 (file)
@@ -9,6 +9,7 @@ use BookStack\Http\Controllers\Api\ChapterApiController;
 use BookStack\Http\Controllers\Api\ChapterExportApiController;
 use BookStack\Http\Controllers\Api\PageApiController;
 use BookStack\Http\Controllers\Api\PageExportApiController;
+use BookStack\Http\Controllers\Api\RecycleBinApiController;
 use BookStack\Http\Controllers\Api\SearchApiController;
 use BookStack\Http\Controllers\Api\UserApiController;
 use Illuminate\Support\Facades\Route;
@@ -72,3 +73,7 @@ Route::post('users', [UserApiController::class, 'create']);
 Route::get('users/{id}', [UserApiController::class, 'read']);
 Route::put('users/{id}', [UserApiController::class, 'update']);
 Route::delete('users/{id}', [UserApiController::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']);
diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php
new file mode 100644 (file)
index 0000000..05c896b
--- /dev/null
@@ -0,0 +1,184 @@
+<?php
+
+namespace Tests\Api;
+
+use BookStack\Entities\Models\Book;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Models\Page;
+use Illuminate\Support\Collection;
+use Tests\TestCase;
+
+class RecycleBinApiTest extends TestCase
+{
+    use TestsApi;
+
+    protected string $baseEndpoint = '/api/recycle-bin';
+
+    protected array $endpointMap = [
+        ['get', '/api/recycle-bin'],
+        ['put', '/api/recycle-bin/1'],
+        ['delete', '/api/recycle-bin/1'],
+    ];
+
+    public function test_settings_manage_permission_needed_for_all_endpoints()
+    {
+        $editor = $this->getEditor();
+        $this->giveUserPermissions($editor, ['settings-manage']);
+        $this->actingAs($editor);
+
+        foreach ($this->endpointMap as [$method, $uri]) {
+            $resp = $this->json($method, $uri);
+            $resp->assertStatus(403);
+            $resp->assertJson($this->permissionErrorResponse());
+        }
+    }
+
+    public function test_restrictions_manage_all_permission_needed_for_all_endpoints()
+    {
+        $editor = $this->getEditor();
+        $this->giveUserPermissions($editor, ['restrictions-manage-all']);
+        $this->actingAs($editor);
+
+        foreach ($this->endpointMap as [$method, $uri]) {
+            $resp = $this->json($method, $uri);
+            $resp->assertStatus(403);
+            $resp->assertJson($this->permissionErrorResponse());
+        }
+    }
+
+    public function test_index_endpoint_returns_expected_page()
+    {
+        $admin = $this->getAdmin();
+
+        $page = Page::query()->first();
+        $book = Book::query()->first();
+        $this->actingAs($admin)->delete($page->getUrl());
+        $this->delete($book->getUrl());
+
+        $deletions = Deletion::query()->orderBy('id')->get();
+
+        $resp = $this->getJson($this->baseEndpoint);
+
+        $expectedData = $deletions
+            ->zip([$page, $book])
+            ->map(function (Collection $data) use ($admin) {
+                return [
+                    'id'                => $data[0]->id,
+                    'deleted_by'        => $admin->id,
+                    'created_at'        => $data[0]->created_at->toJson(),
+                    'updated_at'        => $data[0]->updated_at->toJson(),
+                    'deletable_type'    => $data[1]->getMorphClass(),
+                    'deletable_id'      => $data[1]->id,
+                    'deletable'         => [
+                        'name' => $data[1]->name,
+                    ],
+                ];
+            });
+
+        $resp->assertJson([
+            'data'  => $expectedData->values()->all(),
+            'total' => 2,
+        ]);
+    }
+
+    public function test_index_endpoint_returns_children_count()
+    {
+        $admin = $this->getAdmin();
+
+        $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first();
+        $this->actingAs($admin)->delete($book->getUrl());
+
+        $deletion = Deletion::query()->orderBy('id')->first();
+
+        $resp = $this->getJson($this->baseEndpoint);
+
+        $expectedData = [
+            [
+                'id'             => $deletion->id,
+                'deletable'      => [
+                    'pages_count'    => $book->pages_count,
+                    'chapters_count' => $book->chapters_count,
+                ],
+            ],
+        ];
+
+        $resp->assertJson([
+            'data'  => $expectedData,
+            'total' => 1,
+        ]);
+    }
+
+    public function test_index_endpoint_returns_parent()
+    {
+        $admin = $this->getAdmin();
+        $page = Page::query()->whereHas('chapter')->with('chapter')->first();
+
+        $this->actingAs($admin)->delete($page->getUrl());
+        $deletion = Deletion::query()->orderBy('id')->first();
+
+        $resp = $this->getJson($this->baseEndpoint);
+
+        $expectedData = [
+            [
+                'id'             => $deletion->id,
+                'deletable'      => [
+                    'parent' => [
+                        'id'   => $page->chapter->id,
+                        'name' => $page->chapter->name,
+                        'type' => 'chapter'
+                    ]
+                ]
+            ],
+        ];
+
+        $resp->assertJson([
+            'data'  => $expectedData,
+            'total' => 1,
+        ]);
+    }
+
+    public function test_restore_endpoint()
+    {
+        $page = Page::query()->first();
+        $this->asAdmin()->delete($page->getUrl());
+        $page->refresh();
+
+        $deletion = Deletion::query()->orderBy('id')->first();
+
+        $this->assertDatabaseHas('pages', [
+            'id'            => $page->id,
+            'deleted_at'    => $page->deleted_at,
+        ]);
+
+        $resp = $this->putJson($this->baseEndpoint . '/' . $deletion->id);
+        $resp->assertJson([
+            'restore_count' => 1
+        ]);
+
+        $this->assertDatabaseHas('pages', [
+            'id'            => $page->id,
+            'deleted_at'    => null,
+        ]);
+    }
+
+    public function test_destroy_endpoint()
+    {
+        $page = Page::query()->first();
+        $this->asAdmin()->delete($page->getUrl());
+        $page->refresh();
+
+        $deletion = Deletion::query()->orderBy('id')->first();
+
+        $this->assertDatabaseHas('pages', [
+            'id'            => $page->id,
+            'deleted_at'    => $page->deleted_at,
+        ]);
+
+        $resp = $this->deleteJson($this->baseEndpoint . '/' . $deletion->id);
+        $resp->assertJson([
+            'delete_count' => 1
+        ]);
+
+        $this->assertDatabaseMissing('pages', ['id' => $page->id]);
+    }
+}