--- /dev/null
+<?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);
+ }
+}
--- /dev/null
+<?php
+
+namespace BookStack\Http\Controllers\Api;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Deletion;
+use BookStack\Entities\Repos\DeletionRepo;
+use BookStack\Entities\Tools\TrashCan;
+
+class RecycleBinApiController extends ApiController
+{
+ public function __construct()
+ {
+ $this->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
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
*
* @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]));
*
* @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]));
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;
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']);
--- /dev/null
+<?php
+
+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
+{
+ 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_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);
+ }
+}