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/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php new file mode 100644 index 000000000..5d53013dc --- /dev/null +++ b/app/Entities/Repos/DeletionRepo.php @@ -0,0 +1,36 @@ +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..bbe19bd86 --- /dev/null +++ b/app/Http/Controllers/Api/RecycleBinApiController.php @@ -0,0 +1,90 @@ +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); + } + } +} 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/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]); + } + } + } +} 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..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 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/routes/api.php b/routes/api.php index a87169ee5..20e167d70 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/{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 index 000000000..05c896bd9 --- /dev/null +++ b/tests/Api/RecycleBinApiTest.php @@ -0,0 +1,184 @@ +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]); + } +}