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.
*/
--- /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\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);
+ }
+ }
+}
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]));
// 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
--- /dev/null
+<?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]);
+ }
+ }
+ }
+}
--- /dev/null
+{
+ "delete_count": 2
+}
\ No newline at end of file
--- /dev/null
+{
+ "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
--- /dev/null
+{
+ "restore_count": 2
+}
\ No newline at end of file
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/{deletionId}', [RecycleBinApiController::class, 'restore']);
+Route::delete('recycle-bin/{deletionId}', [RecycleBinApiController::class, 'destroy']);
--- /dev/null
+<?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]);
+ }
+}