From 55e52e45fb11f5b733eacb010bd23aa9716466fa Mon Sep 17 00:00:00 2001 From: julesdevops Date: Wed, 6 Apr 2022 22:57:18 +0200 Subject: [PATCH 1/5] Start recycle bin API endpoints: list, restore, delete --- app/Entities/Repos/DeletionRepo.php | 34 +++++ .../Api/RecycleBinApiController.php | 45 ++++++ app/Http/Controllers/RecycleBinController.php | 15 +- routes/api.php | 5 + tests/Api/RecycleBinApiTest.php | 136 ++++++++++++++++++ 5 files changed, 225 insertions(+), 10 deletions(-) create mode 100644 app/Entities/Repos/DeletionRepo.php create mode 100644 app/Http/Controllers/Api/RecycleBinApiController.php create mode 100644 tests/Api/RecycleBinApiTest.php diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php new file mode 100644 index 000000000..8fad4e6b0 --- /dev/null +++ b/app/Entities/Repos/DeletionRepo.php @@ -0,0 +1,34 @@ +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 index 000000000..162b27adb --- /dev/null +++ b/app/Http/Controllers/Api/RecycleBinApiController.php @@ -0,0 +1,45 @@ +middleware(function ($request, $next) { + $this->checkPermission('settings-manage'); + $this->checkPermission('restrictions-manage-all'); + + return $next($request); + }); + } + + public function list() + { + return $this->apiListingResponse(Deletion::query(), [ + 'id', + 'deleted_by', + 'created_at', + 'updated_at', + 'deletable_type', + 'deletable_id' + ]); + } + + public function restore(DeletionRepo $deletionRepo, string $id) + { + $restoreCount = $deletionRepo->restore((int) $id); + return response()->json(['restore_count' => $restoreCount]); + } + + public function destroy(DeletionRepo $deletionRepo, string $id) + { + $deleteCount = $deletionRepo->destroy((int) $id); + return response()->json(['delete_count' => $deleteCount]); + } +} \ No newline at end of file diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php index 1cffb161c..82e3f660b 100644 --- a/app/Http/Controllers/RecycleBinController.php +++ b/app/Http/Controllers/RecycleBinController.php @@ -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])); diff --git a/routes/api.php b/routes/api.php index a87169ee5..465f2392c 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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/{id}', [RecycleBinApiController::class, 'restore']); +Route::delete('recycle_bin/{id}', [RecycleBinApiController::class, 'destroy']); diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php new file mode 100644 index 000000000..9371e06e8 --- /dev/null +++ b/tests/Api/RecycleBinApiTest.php @@ -0,0 +1,136 @@ +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_neeed_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() + { + $this->actingAsAuthorizedUser(); + + $page = Page::query()->first(); + $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); + $editor = $this->getEditor(); + $this->actingAs($editor)->delete($page->getUrl()); + $this->actingAs($editor)->delete($book->getUrl()); + + $deletions = Deletion::query()->orderBy('id')->get(); + + $resp = $this->getJson($this->baseEndpoint); + + $expectedData = $deletions + ->zip([$page, $book]) + ->map(function (Collection $data) use ($editor) { + return [ + 'id' => $data[0]->id, + 'deleted_by' => $editor->getKey(), + 'created_at' => $data[0]->created_at->toJson(), + 'updated_at' => $data[0]->updated_at->toJson(), + 'deletable_type' => $data[1]->getMorphClass(), + 'deletable_id' => $data[1]->getKey() + ]; + }); + + $resp->assertJson([ + 'data' => $expectedData->values()->all(), + 'total' => 2 + ]); + } + + public function test_restore_endpoint() + { + $this->actingAsAuthorizedUser(); + + $page = Page::query()->first(); + $editor = $this->getEditor(); + $this->actingAs($editor)->delete($page->getUrl()); + $page->refresh(); + + $deletion = Deletion::query()->orderBy('id')->first(); + + $this->assertDatabaseHas('pages', [ + 'id' => $page->getKey(), + 'deleted_at' => $page->deleted_at + ]); + + $this->putJson($this->baseEndpoint . '/' . $deletion->getKey()); + + $this->assertDatabaseHas('pages', [ + 'id' => $page->getKey(), + 'deleted_at' => null + ]); + } + + public function test_destroy_endpoint() + { + $this->actingAsAuthorizedUser(); + + $page = Page::query()->first(); + $editor = $this->getEditor(); + $this->actingAs($editor)->delete($page->getUrl()); + $page->refresh(); + + $deletion = Deletion::query()->orderBy('id')->first(); + + $this->assertDatabaseHas('pages', [ + 'id' => $page->getKey(), + 'deleted_at' => $page->deleted_at + ]); + + $this->deleteJson($this->baseEndpoint . '/' . $deletion->getKey()); + $this->assertDatabaseMissing('pages', ['id' => $page->getKey()]); + } + + private function actingAsAuthorizedUser() + { + $editor = $this->getEditor(); + $this->giveUserPermissions($editor, ['restrictions-manage-all']); + $this->giveUserPermissions($editor, ['settings-manage']); + $this->actingAs($editor); + } +} From f14e6e8f2dbfac04829c1819398038ec99166d8f Mon Sep 17 00:00:00 2001 From: julesdevops Date: Wed, 20 Apr 2022 22:58:16 +0200 Subject: [PATCH 2/5] Complete list endpoint and add some tests --- app/Entities/Repos/DeletionRepo.php | 2 + .../Api/RecycleBinApiController.php | 46 ++++++++-- tests/Api/RecycleBinApiTest.php | 84 +++++++++++++++++-- 3 files changed, 116 insertions(+), 16 deletions(-) diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php index 8fad4e6b0..5d53013dc 100644 --- a/app/Entities/Repos/DeletionRepo.php +++ b/app/Entities/Repos/DeletionRepo.php @@ -21,6 +21,7 @@ class DeletionRepo /** @var Deletion $deletion */ $deletion = Deletion::query()->findOrFail($id); Activity::add(ActivityType::RECYCLE_BIN_RESTORE, $deletion); + return $this->trashCan->restoreFromDeletion($deletion); } @@ -29,6 +30,7 @@ class DeletionRepo /** @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 index 162b27adb..f60115c16 100644 --- a/app/Http/Controllers/Api/RecycleBinApiController.php +++ b/app/Http/Controllers/Api/RecycleBinApiController.php @@ -2,10 +2,11 @@ namespace BookStack\Http\Controllers\Api; -use BookStack\Actions\ActivityType; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Repos\DeletionRepo; -use BookStack\Entities\Tools\TrashCan; +use Closure; class RecycleBinApiController extends ApiController { @@ -22,24 +23,55 @@ class RecycleBinApiController extends ApiController public function list() { return $this->apiListingResponse(Deletion::query(), [ - 'id', - 'deleted_by', + 'id', + 'deleted_by', 'created_at', 'updated_at', 'deletable_type', - 'deletable_id' - ]); + 'deletable_id', + ], [Closure::fromCallable([$this, 'listFormatter'])]); } public function restore(DeletionRepo $deletionRepo, string $id) { $restoreCount = $deletionRepo->restore((int) $id); + return response()->json(['restore_count' => $restoreCount]); } public function destroy(DeletionRepo $deletionRepo, string $id) { $deleteCount = $deletionRepo->destroy((int) $id); + return response()->json(['delete_count' => $deleteCount]); } -} \ No newline at end of file + + protected function listFormatter(Deletion $deletion) + { + $deletable = $deletion->deletable; + $isBook = $deletable instanceof Book; + $parent = null; + $children = null; + + if ($isBook) { + $chapterCount = $deletable->chapters()->withTrashed()->count(); + $children['Bookstack\Chapter'] = $chapterCount; + } + + if ($isBook || $deletion->deletable instanceof Chapter) { + $pageCount = $deletable->pages()->withTrashed()->count(); + $children['Bookstack\Page'] = $pageCount; + } + + $parentEntity = $deletable->getParent(); + $parent = []; + + if ($parentEntity) { + $parent['type'] = $parentEntity->getMorphClass(); + $parent['id'] = $parentEntity->getKey(); + } + + $deletion->setAttribute('parent', $parent); + $deletion->setAttribute('children', $children); + } +} diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index 9371e06e8..286227896 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -3,12 +3,9 @@ namespace Tests\Api; use BookStack\Entities\Models\Book; -use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Page; -use Carbon\Carbon; use Illuminate\Support\Collection; -use Illuminate\Support\Facades\DB; use Tests\TestCase; class RecycleBinApiTest extends TestCase @@ -52,7 +49,7 @@ class RecycleBinApiTest extends TestCase public function test_index_endpoint_returns_expected_page() { $this->actingAsAuthorizedUser(); - + $page = Page::query()->first(); $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); $editor = $this->getEditor(); @@ -72,13 +69,82 @@ class RecycleBinApiTest extends TestCase 'created_at' => $data[0]->created_at->toJson(), 'updated_at' => $data[0]->updated_at->toJson(), 'deletable_type' => $data[1]->getMorphClass(), - 'deletable_id' => $data[1]->getKey() + 'deletable_id' => $data[1]->getKey(), ]; }); $resp->assertJson([ 'data' => $expectedData->values()->all(), - 'total' => 2 + 'total' => 2, + ]); + } + + public function test_index_endpoint_returns_children() + { + $this->actingAsAuthorizedUser(); + + $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); + $editor = $this->getEditor(); + $this->actingAs($editor)->delete($book->getUrl()); + + $deletion = Deletion::query()->orderBy('id')->first(); + + $resp = $this->getJson($this->baseEndpoint); + + $expectedData = [ + [ + 'id' => $deletion->getKey(), + 'deleted_by' => $editor->getKey(), + 'created_at' => $deletion->created_at->toJson(), + 'updated_at' => $deletion->updated_at->toJson(), + 'deletable_type' => $book->getMorphClass(), + 'deletable_id' => $book->getKey(), + 'children' => [ + 'Bookstack\Page' => $book->pages_count, + 'Bookstack\Chapter' => $book->chapters_count, + ], + 'parent' => null, + ] + ]; + + $resp->assertJson([ + 'data' => $expectedData, + 'total' => 1, + ]); + } + + public function test_index_endpoint_returns_parent() + { + $this->actingAsAuthorizedUser(); + + $page = Page::query()->whereHas('chapter')->with('chapter')->first(); + + $editor = $this->getEditor(); + $this->actingAs($editor)->delete($page->getUrl()); + + $deletion = Deletion::query()->orderBy('id')->first(); + + $resp = $this->getJson($this->baseEndpoint); + + $expectedData = [ + [ + 'id' => $deletion->getKey(), + 'deleted_by' => $editor->getKey(), + 'created_at' => $deletion->created_at->toJson(), + 'updated_at' => $deletion->updated_at->toJson(), + 'deletable_type' => $page->getMorphClass(), + 'deletable_id' => $page->getKey(), + 'parent' => [ + 'type' => 'BookStack\Chapter', + 'id' => $page->chapter->getKey() + ], + 'children' => null, + ] + ]; + + $resp->assertJson([ + 'data' => $expectedData, + 'total' => 1 ]); } @@ -95,14 +161,14 @@ class RecycleBinApiTest extends TestCase $this->assertDatabaseHas('pages', [ 'id' => $page->getKey(), - 'deleted_at' => $page->deleted_at + 'deleted_at' => $page->deleted_at, ]); $this->putJson($this->baseEndpoint . '/' . $deletion->getKey()); $this->assertDatabaseHas('pages', [ 'id' => $page->getKey(), - 'deleted_at' => null + 'deleted_at' => null, ]); } @@ -119,7 +185,7 @@ class RecycleBinApiTest extends TestCase $this->assertDatabaseHas('pages', [ 'id' => $page->getKey(), - 'deleted_at' => $page->deleted_at + 'deleted_at' => $page->deleted_at, ]); $this->deleteJson($this->baseEndpoint . '/' . $deletion->getKey()); From 14bccae6bd93164c6fc647747654c0fccd8dffe7 Mon Sep 17 00:00:00 2001 From: julesdevops Date: Sun, 24 Apr 2022 10:16:45 +0200 Subject: [PATCH 3/5] do some cleanup and add doc --- .../Api/RecycleBinApiController.php | 38 +++++++++++++----- dev/api/responses/recycle_bin-destroy.json | 3 ++ dev/api/responses/recycle_bin-list.json | 34 ++++++++++++++++ dev/api/responses/recycle_bin-restore.json | 3 ++ tests/Api/RecycleBinApiTest.php | 40 +++++++++---------- 5 files changed, 88 insertions(+), 30 deletions(-) create mode 100644 dev/api/responses/recycle_bin-destroy.json create mode 100644 dev/api/responses/recycle_bin-list.json create mode 100644 dev/api/responses/recycle_bin-restore.json diff --git a/app/Http/Controllers/Api/RecycleBinApiController.php b/app/Http/Controllers/Api/RecycleBinApiController.php index f60115c16..a2176a2c8 100644 --- a/app/Http/Controllers/Api/RecycleBinApiController.php +++ b/app/Http/Controllers/Api/RecycleBinApiController.php @@ -2,14 +2,16 @@ namespace BookStack\Http\Controllers\Api; -use BookStack\Entities\Models\Book; -use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Repos\DeletionRepo; use Closure; class RecycleBinApiController extends ApiController { + protected $fieldsToExpose = [ + 'id', 'deleted_by', 'created_at', 'updated_at', 'deletable_type', 'deletable_id', + ]; + public function __construct() { $this->middleware(function ($request, $next) { @@ -20,9 +22,13 @@ class RecycleBinApiController extends ApiController }); } + /** + * Get a top-level listing of the items in the recycle bin. + * Requires the permission to manage settings and restrictions. + */ public function list() { - return $this->apiListingResponse(Deletion::query(), [ + return $this->apiListingResponse(Deletion::query()->with('deletable'), [ 'id', 'deleted_by', 'created_at', @@ -32,6 +38,10 @@ class RecycleBinApiController extends ApiController ], [Closure::fromCallable([$this, 'listFormatter'])]); } + /** + * Restore a single deletion from the recycle bin. + * You must provide the deletion id, not the id of the corresponding deleted item. + */ public function restore(DeletionRepo $deletionRepo, string $id) { $restoreCount = $deletionRepo->restore((int) $id); @@ -39,6 +49,11 @@ class RecycleBinApiController extends ApiController 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. + * You must provide the deletion id, not the id of the corresponding deleted item. + */ public function destroy(DeletionRepo $deletionRepo, string $id) { $deleteCount = $deletionRepo->destroy((int) $id); @@ -48,23 +63,26 @@ class RecycleBinApiController extends ApiController protected function listFormatter(Deletion $deletion) { + $deletion->makeVisible($this->fieldsToExpose); + $deletion->makeHidden('deletable'); + $deletable = $deletion->deletable; - $isBook = $deletable instanceof Book; + $isBook = $deletion->deletable_type === "BookStack\Book"; $parent = null; $children = null; if ($isBook) { - $chapterCount = $deletable->chapters()->withTrashed()->count(); - $children['Bookstack\Chapter'] = $chapterCount; + $chapterCount = $deletable->chapters()->withTrashed()->count(); + $children['BookStack\Chapter'] = $chapterCount; } - if ($isBook || $deletion->deletable instanceof Chapter) { - $pageCount = $deletable->pages()->withTrashed()->count(); - $children['Bookstack\Page'] = $pageCount; + if ($isBook || $deletion->deletable_type === "BookStack\Chapter") { + $pageCount = $deletable->pages()->withTrashed()->count(); + $children['BookStack\Page'] = $pageCount; } $parentEntity = $deletable->getParent(); - $parent = []; + $parent = null; if ($parentEntity) { $parent['type'] = $parentEntity->getMorphClass(); diff --git a/dev/api/responses/recycle_bin-destroy.json b/dev/api/responses/recycle_bin-destroy.json new file mode 100644 index 000000000..21cfc401b --- /dev/null +++ b/dev/api/responses/recycle_bin-destroy.json @@ -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 index 000000000..025cc9825 --- /dev/null +++ b/dev/api/responses/recycle_bin-list.json @@ -0,0 +1,34 @@ +{ + "data": [ + { + "id": 25, + "deleted_by": 1, + "created_at": "2022-04-24T07:59:34.000000Z", + "updated_at": "2022-04-24T07:59:34.000000Z", + "deletable_type": "BookStack\\Book", + "deletable_id": 4, + "parent": { + "type": "BookStack\\Book", + "id": 25 + }, + "children": { + "BookStack\\Chapter": 0, + "BookStack\\Page": 1 + } + }, + { + "id": 26, + "deleted_by": 1, + "created_at": "2022-04-24T07:59:35.000000Z", + "updated_at": "2022-04-24T07:59:35.000000Z", + "deletable_type": "BookStack\\Book", + "deletable_id": 3, + "parent": [], + "children": { + "BookStack\\Chapter": 1, + "BookStack\\Page": 1 + } + } + ], + "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 index 000000000..ac5c94808 --- /dev/null +++ b/dev/api/responses/recycle_bin-restore.json @@ -0,0 +1,3 @@ +{ + "restore_count": 2 +} \ No newline at end of file diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index 286227896..4849080b9 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -33,12 +33,12 @@ class RecycleBinApiTest extends TestCase } } - public function test_restrictions_manage_all_permission_neeed_for_all_endpoints() + 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); @@ -74,7 +74,7 @@ class RecycleBinApiTest extends TestCase }); $resp->assertJson([ - 'data' => $expectedData->values()->all(), + 'data' => $expectedData->values()->all(), 'total' => 2, ]); } @@ -82,7 +82,7 @@ class RecycleBinApiTest extends TestCase public function test_index_endpoint_returns_children() { $this->actingAsAuthorizedUser(); - + $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); $editor = $this->getEditor(); $this->actingAs($editor)->delete($book->getUrl()); @@ -100,15 +100,15 @@ class RecycleBinApiTest extends TestCase 'deletable_type' => $book->getMorphClass(), 'deletable_id' => $book->getKey(), 'children' => [ - 'Bookstack\Page' => $book->pages_count, - 'Bookstack\Chapter' => $book->chapters_count, + 'BookStack\Page' => $book->pages_count, + 'BookStack\Chapter' => $book->chapters_count, ], 'parent' => null, - ] + ], ]; $resp->assertJson([ - 'data' => $expectedData, + 'data' => $expectedData, 'total' => 1, ]); } @@ -136,22 +136,22 @@ class RecycleBinApiTest extends TestCase 'deletable_id' => $page->getKey(), 'parent' => [ 'type' => 'BookStack\Chapter', - 'id' => $page->chapter->getKey() + 'id' => $page->chapter->getKey(), ], 'children' => null, - ] + ], ]; $resp->assertJson([ - 'data' => $expectedData, - 'total' => 1 + 'data' => $expectedData, + 'total' => 1, ]); } public function test_restore_endpoint() { $this->actingAsAuthorizedUser(); - + $page = Page::query()->first(); $editor = $this->getEditor(); $this->actingAs($editor)->delete($page->getUrl()); @@ -160,22 +160,22 @@ class RecycleBinApiTest extends TestCase $deletion = Deletion::query()->orderBy('id')->first(); $this->assertDatabaseHas('pages', [ - 'id' => $page->getKey(), - 'deleted_at' => $page->deleted_at, + 'id' => $page->getKey(), + 'deleted_at' => $page->deleted_at, ]); $this->putJson($this->baseEndpoint . '/' . $deletion->getKey()); $this->assertDatabaseHas('pages', [ - 'id' => $page->getKey(), - 'deleted_at' => null, + 'id' => $page->getKey(), + 'deleted_at' => null, ]); } public function test_destroy_endpoint() { $this->actingAsAuthorizedUser(); - + $page = Page::query()->first(); $editor = $this->getEditor(); $this->actingAs($editor)->delete($page->getUrl()); @@ -184,8 +184,8 @@ class RecycleBinApiTest extends TestCase $deletion = Deletion::query()->orderBy('id')->first(); $this->assertDatabaseHas('pages', [ - 'id' => $page->getKey(), - 'deleted_at' => $page->deleted_at, + 'id' => $page->getKey(), + 'deleted_at' => $page->deleted_at, ]); $this->deleteJson($this->baseEndpoint . '/' . $deletion->getKey()); From ff8dadefee9e400bb46a54b301a8b701c4a0b61b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Apr 2022 17:54:59 +0100 Subject: [PATCH 4/5] Reviewed recycle bin API PR and made changes Made the following changes, many of these are just to align with existing conventions. - Updated urls to be hypenated, instead of underscored, to match other system endpoints. - Updated URL parameter to be `deletionId` instead of `id`, and removed the ID-based comment on controller methods, so the required ID model is clear from the URL alone, since its not clear from the URL endpoint alone like existing endpoints. This follows the pattern used in the "web" routes. - Added extra detail on some controller method comments, and copied permission comment to each method. - Removed existing field visibility mechanisms to use simpler model-based visibility since we didn't need anything too special here (After some of my other changes). - Allowed the "deletable" model to be shown in response to provide a little more detail on the main deleted item. - Updated parent/child-count loading to be on the "deletable" model instead of additional properties which results in simpler controller logic and enforces the idea these are relations on the deletable, not the deletion itself. It also removes additional exposure of model namespacing. - Updated (int) casts to intval, just since that's our most common conversion method in the codebase. - Testing: Removed `actingAsAuthorizedUser` and used the admin user instead to prevent extra auth steps on each test. - Testing: Cut logic/data-checks from tests if already covered by other tests. - Testing: Added simple assertions for delete/restore response data. - Examples: Updated list example to reflect changes. Review of PR #3377 To be followed up with changes to polymorphic relations to hide namespacing. --- app/Entities/Models/Deletion.php | 6 + .../Api/RecycleBinApiController.php | 63 +++++----- ...-destroy.json => recycle-bin-destroy.json} | 0 dev/api/responses/recycle-bin-list.json | 64 +++++++++++ ...-restore.json => recycle-bin-restore.json} | 0 dev/api/responses/recycle_bin-list.json | 34 ------ routes/api.php | 6 +- tests/Api/RecycleBinApiTest.php | 108 ++++++++---------- 8 files changed, 147 insertions(+), 134 deletions(-) rename dev/api/responses/{recycle_bin-destroy.json => recycle-bin-destroy.json} (100%) create mode 100644 dev/api/responses/recycle-bin-list.json rename dev/api/responses/{recycle_bin-restore.json => recycle-bin-restore.json} (100%) delete mode 100644 dev/api/responses/recycle_bin-list.json diff --git a/app/Entities/Models/Deletion.php b/app/Entities/Models/Deletion.php index 181c9c580..101a138d1 100644 --- a/app/Entities/Models/Deletion.php +++ b/app/Entities/Models/Deletion.php @@ -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/Http/Controllers/Api/RecycleBinApiController.php b/app/Http/Controllers/Api/RecycleBinApiController.php index a2176a2c8..bbe19bd86 100644 --- a/app/Http/Controllers/Api/RecycleBinApiController.php +++ b/app/Http/Controllers/Api/RecycleBinApiController.php @@ -2,16 +2,16 @@ 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 { - protected $fieldsToExpose = [ - 'id', 'deleted_by', 'created_at', 'updated_at', 'deletable_type', 'deletable_id', - ]; - public function __construct() { $this->middleware(function ($request, $next) { @@ -24,7 +24,11 @@ class RecycleBinApiController extends ApiController /** * Get a top-level listing of the items in the recycle bin. - * Requires the permission to manage settings and restrictions. + * 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() { @@ -40,11 +44,11 @@ class RecycleBinApiController extends ApiController /** * Restore a single deletion from the recycle bin. - * You must provide the deletion id, not the id of the corresponding deleted item. + * Requires permission to manage both system settings and permissions. */ - public function restore(DeletionRepo $deletionRepo, string $id) + public function restore(DeletionRepo $deletionRepo, string $deletionId) { - $restoreCount = $deletionRepo->restore((int) $id); + $restoreCount = $deletionRepo->restore(intval($deletionId)); return response()->json(['restore_count' => $restoreCount]); } @@ -52,44 +56,35 @@ class RecycleBinApiController extends ApiController /** * Remove a single deletion from the recycle bin. * Use this endpoint carefully as it will entirely remove the underlying deleted items from the system. - * You must provide the deletion id, not the id of the corresponding deleted item. + * Requires permission to manage both system settings and permissions. */ - public function destroy(DeletionRepo $deletionRepo, string $id) + public function destroy(DeletionRepo $deletionRepo, string $deletionId) { - $deleteCount = $deletionRepo->destroy((int) $id); + $deleteCount = $deletionRepo->destroy(intval($deletionId)); return response()->json(['delete_count' => $deleteCount]); } + /** + * Load some related details for the deletion listing. + */ protected function listFormatter(Deletion $deletion) { - $deletion->makeVisible($this->fieldsToExpose); - $deletion->makeHidden('deletable'); - $deletable = $deletion->deletable; - $isBook = $deletion->deletable_type === "BookStack\Book"; - $parent = null; - $children = null; + $withTrashedQuery = fn(Builder $query) => $query->withTrashed(); - if ($isBook) { - $chapterCount = $deletable->chapters()->withTrashed()->count(); - $children['BookStack\Chapter'] = $chapterCount; + if ($deletable instanceof BookChild) { + $parent = $deletable->getParent(); + $parent->setAttribute('type', $parent->getType()); + $deletable->setRelation('parent', $parent); } - if ($isBook || $deletion->deletable_type === "BookStack\Chapter") { - $pageCount = $deletable->pages()->withTrashed()->count(); - $children['BookStack\Page'] = $pageCount; + if ($deletable instanceof Book || $deletable instanceof Chapter) { + $countsToLoad = ['pages' => $withTrashedQuery]; + if ($deletable instanceof Book) { + $countsToLoad['chapters'] = $withTrashedQuery; + } + $deletable->loadCount($countsToLoad); } - - $parentEntity = $deletable->getParent(); - $parent = null; - - if ($parentEntity) { - $parent['type'] = $parentEntity->getMorphClass(); - $parent['id'] = $parentEntity->getKey(); - } - - $deletion->setAttribute('parent', $parent); - $deletion->setAttribute('children', $children); } } diff --git a/dev/api/responses/recycle_bin-destroy.json b/dev/api/responses/recycle-bin-destroy.json similarity index 100% rename from dev/api/responses/recycle_bin-destroy.json rename to dev/api/responses/recycle-bin-destroy.json diff --git a/dev/api/responses/recycle-bin-list.json b/dev/api/responses/recycle-bin-list.json new file mode 100644 index 000000000..853070839 --- /dev/null +++ b/dev/api/responses/recycle-bin-list.json @@ -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 similarity index 100% rename from dev/api/responses/recycle_bin-restore.json rename to dev/api/responses/recycle-bin-restore.json diff --git a/dev/api/responses/recycle_bin-list.json b/dev/api/responses/recycle_bin-list.json deleted file mode 100644 index 025cc9825..000000000 --- a/dev/api/responses/recycle_bin-list.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "data": [ - { - "id": 25, - "deleted_by": 1, - "created_at": "2022-04-24T07:59:34.000000Z", - "updated_at": "2022-04-24T07:59:34.000000Z", - "deletable_type": "BookStack\\Book", - "deletable_id": 4, - "parent": { - "type": "BookStack\\Book", - "id": 25 - }, - "children": { - "BookStack\\Chapter": 0, - "BookStack\\Page": 1 - } - }, - { - "id": 26, - "deleted_by": 1, - "created_at": "2022-04-24T07:59:35.000000Z", - "updated_at": "2022-04-24T07:59:35.000000Z", - "deletable_type": "BookStack\\Book", - "deletable_id": 3, - "parent": [], - "children": { - "BookStack\\Chapter": 1, - "BookStack\\Page": 1 - } - } - ], - "total": 2 -} \ No newline at end of file diff --git a/routes/api.php b/routes/api.php index 465f2392c..20e167d70 100644 --- a/routes/api.php +++ b/routes/api.php @@ -74,6 +74,6 @@ 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/{id}', [RecycleBinApiController::class, 'restore']); -Route::delete('recycle_bin/{id}', [RecycleBinApiController::class, 'destroy']); +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 index 4849080b9..05c896bd9 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -12,12 +12,12 @@ class RecycleBinApiTest extends TestCase { use TestsApi; - protected string $baseEndpoint = '/api/recycle_bin'; + protected string $baseEndpoint = '/api/recycle-bin'; protected array $endpointMap = [ - ['get', '/api/recycle_bin'], - ['put', '/api/recycle_bin/1'], - ['delete', '/api/recycle_bin/1'], + ['get', '/api/recycle-bin'], + ['put', '/api/recycle-bin/1'], + ['delete', '/api/recycle-bin/1'], ]; public function test_settings_manage_permission_needed_for_all_endpoints() @@ -48,13 +48,12 @@ class RecycleBinApiTest extends TestCase public function test_index_endpoint_returns_expected_page() { - $this->actingAsAuthorizedUser(); + $admin = $this->getAdmin(); $page = Page::query()->first(); - $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); - $editor = $this->getEditor(); - $this->actingAs($editor)->delete($page->getUrl()); - $this->actingAs($editor)->delete($book->getUrl()); + $book = Book::query()->first(); + $this->actingAs($admin)->delete($page->getUrl()); + $this->delete($book->getUrl()); $deletions = Deletion::query()->orderBy('id')->get(); @@ -62,14 +61,17 @@ class RecycleBinApiTest extends TestCase $expectedData = $deletions ->zip([$page, $book]) - ->map(function (Collection $data) use ($editor) { + ->map(function (Collection $data) use ($admin) { return [ 'id' => $data[0]->id, - 'deleted_by' => $editor->getKey(), + '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]->getKey(), + 'deletable_id' => $data[1]->id, + 'deletable' => [ + 'name' => $data[1]->name, + ], ]; }); @@ -79,13 +81,12 @@ class RecycleBinApiTest extends TestCase ]); } - public function test_index_endpoint_returns_children() + public function test_index_endpoint_returns_children_count() { - $this->actingAsAuthorizedUser(); + $admin = $this->getAdmin(); $book = Book::query()->whereHas('pages')->whereHas('chapters')->withCount(['pages', 'chapters'])->first(); - $editor = $this->getEditor(); - $this->actingAs($editor)->delete($book->getUrl()); + $this->actingAs($admin)->delete($book->getUrl()); $deletion = Deletion::query()->orderBy('id')->first(); @@ -93,17 +94,11 @@ class RecycleBinApiTest extends TestCase $expectedData = [ [ - 'id' => $deletion->getKey(), - 'deleted_by' => $editor->getKey(), - 'created_at' => $deletion->created_at->toJson(), - 'updated_at' => $deletion->updated_at->toJson(), - 'deletable_type' => $book->getMorphClass(), - 'deletable_id' => $book->getKey(), - 'children' => [ - 'BookStack\Page' => $book->pages_count, - 'BookStack\Chapter' => $book->chapters_count, + 'id' => $deletion->id, + 'deletable' => [ + 'pages_count' => $book->pages_count, + 'chapters_count' => $book->chapters_count, ], - 'parent' => null, ], ]; @@ -115,30 +110,24 @@ class RecycleBinApiTest extends TestCase public function test_index_endpoint_returns_parent() { - $this->actingAsAuthorizedUser(); - + $admin = $this->getAdmin(); $page = Page::query()->whereHas('chapter')->with('chapter')->first(); - $editor = $this->getEditor(); - $this->actingAs($editor)->delete($page->getUrl()); - + $this->actingAs($admin)->delete($page->getUrl()); $deletion = Deletion::query()->orderBy('id')->first(); $resp = $this->getJson($this->baseEndpoint); $expectedData = [ [ - 'id' => $deletion->getKey(), - 'deleted_by' => $editor->getKey(), - 'created_at' => $deletion->created_at->toJson(), - 'updated_at' => $deletion->updated_at->toJson(), - 'deletable_type' => $page->getMorphClass(), - 'deletable_id' => $page->getKey(), - 'parent' => [ - 'type' => 'BookStack\Chapter', - 'id' => $page->chapter->getKey(), - ], - 'children' => null, + 'id' => $deletion->id, + 'deletable' => [ + 'parent' => [ + 'id' => $page->chapter->id, + 'name' => $page->chapter->name, + 'type' => 'chapter' + ] + ] ], ]; @@ -150,53 +139,46 @@ class RecycleBinApiTest extends TestCase public function test_restore_endpoint() { - $this->actingAsAuthorizedUser(); - $page = Page::query()->first(); - $editor = $this->getEditor(); - $this->actingAs($editor)->delete($page->getUrl()); + $this->asAdmin()->delete($page->getUrl()); $page->refresh(); $deletion = Deletion::query()->orderBy('id')->first(); $this->assertDatabaseHas('pages', [ - 'id' => $page->getKey(), + 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); - $this->putJson($this->baseEndpoint . '/' . $deletion->getKey()); + $resp = $this->putJson($this->baseEndpoint . '/' . $deletion->id); + $resp->assertJson([ + 'restore_count' => 1 + ]); $this->assertDatabaseHas('pages', [ - 'id' => $page->getKey(), + 'id' => $page->id, 'deleted_at' => null, ]); } public function test_destroy_endpoint() { - $this->actingAsAuthorizedUser(); - $page = Page::query()->first(); - $editor = $this->getEditor(); - $this->actingAs($editor)->delete($page->getUrl()); + $this->asAdmin()->delete($page->getUrl()); $page->refresh(); $deletion = Deletion::query()->orderBy('id')->first(); $this->assertDatabaseHas('pages', [ - 'id' => $page->getKey(), + 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); - $this->deleteJson($this->baseEndpoint . '/' . $deletion->getKey()); - $this->assertDatabaseMissing('pages', ['id' => $page->getKey()]); - } + $resp = $this->deleteJson($this->baseEndpoint . '/' . $deletion->id); + $resp->assertJson([ + 'delete_count' => 1 + ]); - private function actingAsAuthorizedUser() - { - $editor = $this->getEditor(); - $this->giveUserPermissions($editor, ['restrictions-manage-all']); - $this->giveUserPermissions($editor, ['settings-manage']); - $this->actingAs($editor); + $this->assertDatabaseMissing('pages', ['id' => $page->id]); } } From 0930e8519c508d46a848db93c76f258da5ee84a9 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 25 Apr 2022 18:31:37 +0100 Subject: [PATCH 5/5] Updated polymorphic database relation types to simpler version - Means we can use these simpler types in API response, As desired in #3377. Closes #3395 --- app/Providers/AppServiceProvider.php | 12 ++-- ..._04_25_140741_update_polymorphic_types.php | 64 +++++++++++++++++++ 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 database/migrations/2022_04_25_140741_update_polymorphic_types.php diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index fc712632e..3c1212e32 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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 index 000000000..4645ab2db --- /dev/null +++ b/database/migrations/2022_04_25_140741_update_polymorphic_types.php @@ -0,0 +1,64 @@ + '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]); + } + } + } +}