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]); } }