From 691027a522d43c5a52085b89abe3123596bf08cc Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 27 Sep 2020 23:24:33 +0100 Subject: [PATCH 01/10] Started implementation of recycle bin functionality --- app/Actions/ViewService.php | 25 ++-- app/Auth/Permissions/PermissionService.php | 13 +- app/Entities/DeleteRecord.php | 41 ++++++ app/Entities/Entity.php | 13 +- app/Entities/Managers/TrashCan.php | 130 +++++++++++++----- app/Entities/Repos/BookRepo.php | 5 +- app/Entities/Repos/BookshelfRepo.php | 2 +- app/Entities/Repos/ChapterRepo.php | 6 +- app/Entities/Repos/PageRepo.php | 5 +- app/Entities/SearchService.php | 9 +- app/Http/Controllers/HomeController.php | 2 +- ...0_09_27_210059_add_entity_soft_deletes.php | 50 +++++++ ..._27_210528_create_delete_records_table.php | 38 +++++ 13 files changed, 266 insertions(+), 73 deletions(-) create mode 100644 app/Entities/DeleteRecord.php create mode 100644 database/migrations/2020_09_27_210059_add_entity_soft_deletes.php create mode 100644 database/migrations/2020_09_27_210528_create_delete_records_table.php diff --git a/app/Actions/ViewService.php b/app/Actions/ViewService.php index 324bfaa4e..aa75abb72 100644 --- a/app/Actions/ViewService.php +++ b/app/Actions/ViewService.php @@ -79,29 +79,26 @@ class ViewService /** * Get all recently viewed entities for the current user. - * @param int $count - * @param int $page - * @param Entity|bool $filterModel - * @return mixed */ - public function getUserRecentlyViewed($count = 10, $page = 0, $filterModel = false) + public function getUserRecentlyViewed(int $count = 10, int $page = 1) { $user = user(); if ($user === null || $user->isDefault()) { return collect(); } - $query = $this->permissionService - ->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type'); - - if ($filterModel) { - $query = $query->where('viewable_type', '=', $filterModel->getMorphClass()); + $all = collect(); + /** @var Entity $instance */ + foreach ($this->entityProvider->all() as $name => $instance) { + $items = $instance::visible()->withLastView() + ->orderBy('last_viewed_at', 'desc') + ->skip($count * ($page - 1)) + ->take($count) + ->get(); + $all = $all->concat($items); } - $query = $query->where('user_id', '=', $user->id); - $viewables = $query->with('viewable')->orderBy('updated_at', 'desc') - ->skip($count * $page)->take($count)->get()->pluck('viewable'); - return $viewables; + return $all->sortByDesc('last_viewed_at')->slice(0, $count); } /** diff --git a/app/Auth/Permissions/PermissionService.php b/app/Auth/Permissions/PermissionService.php index 97cc1ca24..2609779bf 100644 --- a/app/Auth/Permissions/PermissionService.php +++ b/app/Auth/Permissions/PermissionService.php @@ -51,11 +51,6 @@ class PermissionService /** * PermissionService constructor. - * @param JointPermission $jointPermission - * @param EntityPermission $entityPermission - * @param Role $role - * @param Connection $db - * @param EntityProvider $entityProvider */ public function __construct( JointPermission $jointPermission, @@ -176,7 +171,7 @@ class PermissionService }); // Chunk through all bookshelves - $this->entityProvider->bookshelf->newQuery()->select(['id', 'restricted', 'created_by']) + $this->entityProvider->bookshelf->newQuery()->withTrashed()->select(['id', 'restricted', 'created_by']) ->chunk(50, function ($shelves) use ($roles) { $this->buildJointPermissionsForShelves($shelves, $roles); }); @@ -188,11 +183,11 @@ class PermissionService */ protected function bookFetchQuery() { - return $this->entityProvider->book->newQuery() + return $this->entityProvider->book->withTrashed()->newQuery() ->select(['id', 'restricted', 'created_by'])->with(['chapters' => function ($query) { - $query->select(['id', 'restricted', 'created_by', 'book_id']); + $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id']); }, 'pages' => function ($query) { - $query->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']); + $query->withTrashed()->select(['id', 'restricted', 'created_by', 'book_id', 'chapter_id']); }]); } diff --git a/app/Entities/DeleteRecord.php b/app/Entities/DeleteRecord.php new file mode 100644 index 000000000..84b37f5a3 --- /dev/null +++ b/app/Entities/DeleteRecord.php @@ -0,0 +1,41 @@ +morphTo(); + } + + /** + * The the user that performed the deletion. + */ + public function deletedBy(): BelongsTo + { + return $this->belongsTo(User::class); + } + + /** + * Create a new deletion record for the provided entity. + */ + public static function createForEntity(Entity $entity): DeleteRecord + { + $record = (new self())->forceFill([ + 'deleted_by' => user()->id, + 'deletable_type' => $entity->getMorphClass(), + 'deletable_id' => $entity->id, + ]); + $record->save(); + return $record; + } + +} diff --git a/app/Entities/Entity.php b/app/Entities/Entity.php index cc7df46d4..d1a8664e4 100644 --- a/app/Entities/Entity.php +++ b/app/Entities/Entity.php @@ -12,6 +12,7 @@ use Carbon\Carbon; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\MorphMany; +use Illuminate\Database\Eloquent\SoftDeletes; /** * Class Entity @@ -36,6 +37,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; */ class Entity extends Ownable { + use SoftDeletes; /** * @var string - Name of property where the main text content is found @@ -193,13 +195,20 @@ class Entity extends Ownable /** * Get the entity jointPermissions this is connected to. - * @return MorphMany */ - public function jointPermissions() + public function jointPermissions(): MorphMany { return $this->morphMany(JointPermission::class, 'entity'); } + /** + * Get the related delete records for this entity. + */ + public function deleteRecords(): MorphMany + { + return $this->morphMany(DeleteRecord::class, 'deletable'); + } + /** * Check if this instance or class is a certain type of entity. * Examples of $type are 'page', 'book', 'chapter' diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php index 1a32294fc..9a21f5e2c 100644 --- a/app/Entities/Managers/TrashCan.php +++ b/app/Entities/Managers/TrashCan.php @@ -3,6 +3,7 @@ use BookStack\Entities\Book; use BookStack\Entities\Bookshelf; use BookStack\Entities\Chapter; +use BookStack\Entities\DeleteRecord; use BookStack\Entities\Entity; use BookStack\Entities\HasCoverImage; use BookStack\Entities\Page; @@ -11,46 +12,67 @@ use BookStack\Facades\Activity; use BookStack\Uploads\AttachmentService; use BookStack\Uploads\ImageService; use Exception; -use Illuminate\Contracts\Container\BindingResolutionException; class TrashCan { /** - * Remove a bookshelf from the system. - * @throws Exception + * Send a shelf to the recycle bin. */ - public function destroyShelf(Bookshelf $shelf) + public function softDestroyShelf(Bookshelf $shelf) { - $this->destroyCommonRelations($shelf); + DeleteRecord::createForEntity($shelf); $shelf->delete(); } /** - * Remove a book from the system. - * @throws NotifyException - * @throws BindingResolutionException + * Send a book to the recycle bin. + * @throws Exception */ - public function destroyBook(Book $book) + public function softDestroyBook(Book $book) { + DeleteRecord::createForEntity($book); + foreach ($book->pages as $page) { - $this->destroyPage($page); + $this->softDestroyPage($page, false); } foreach ($book->chapters as $chapter) { - $this->destroyChapter($chapter); + $this->softDestroyChapter($chapter, false); } - $this->destroyCommonRelations($book); $book->delete(); } /** - * Remove a page from the system. - * @throws NotifyException + * Send a chapter to the recycle bin. + * @throws Exception */ - public function destroyPage(Page $page) + public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true) { + if ($recordDelete) { + DeleteRecord::createForEntity($chapter); + } + + if (count($chapter->pages) > 0) { + foreach ($chapter->pages as $page) { + $this->softDestroyPage($page, false); + } + } + + $chapter->delete(); + } + + /** + * Send a page to the recycle bin. + * @throws Exception + */ + public function softDestroyPage(Page $page, bool $recordDelete = true) + { + if ($recordDelete) { + DeleteRecord::createForEntity($page); + } + // Check if set as custom homepage & remove setting if not used or throw error if active $customHome = setting('app-homepage', '0:'); if (intval($page->id) === intval(explode(':', $customHome)[0])) { @@ -60,6 +82,64 @@ class TrashCan setting()->remove('app-homepage'); } + $page->delete(); + } + + /** + * Remove a bookshelf from the system. + * @throws Exception + */ + public function destroyShelf(Bookshelf $shelf) + { + $this->destroyCommonRelations($shelf); + $shelf->forceDelete(); + } + + /** + * Remove a book from the system. + * Destroys any child chapters and pages. + * @throws Exception + */ + public function destroyBook(Book $book) + { + $pages = $book->pages()->withTrashed()->get(); + foreach ($pages as $page) { + $this->destroyPage($page); + } + + $chapters = $book->chapters()->withTrashed()->get(); + foreach ($chapters as $chapter) { + $this->destroyChapter($chapter); + } + + $this->destroyCommonRelations($book); + $book->forceDelete(); + } + + /** + * Remove a chapter from the system. + * Destroys all pages within. + * @throws Exception + */ + public function destroyChapter(Chapter $chapter) + { + $pages = $chapter->pages()->withTrashed()->get(); + if (count($pages)) { + foreach ($pages as $page) { + $this->destroyPage($page); + } + } + + $this->destroyCommonRelations($chapter); + $chapter->forceDelete(); + } + + /** + * Remove a page from the system. + * @throws Exception + */ + public function destroyPage(Page $page) + { $this->destroyCommonRelations($page); // Delete Attached Files @@ -68,24 +148,7 @@ class TrashCan $attachmentService->deleteFile($attachment); } - $page->delete(); - } - - /** - * Remove a chapter from the system. - * @throws Exception - */ - public function destroyChapter(Chapter $chapter) - { - if (count($chapter->pages) > 0) { - foreach ($chapter->pages as $page) { - $page->chapter_id = 0; - $page->save(); - } - } - - $this->destroyCommonRelations($chapter); - $chapter->delete(); + $page->forceDelete(); } /** @@ -100,6 +163,7 @@ class TrashCan $entity->comments()->delete(); $entity->jointPermissions()->delete(); $entity->searchTerms()->delete(); + $entity->deleteRecords()->delete(); if ($entity instanceof HasCoverImage && $entity->cover) { $imageService = app()->make(ImageService::class); diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 70db0fa65..bb1895b36 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -123,12 +123,11 @@ class BookRepo /** * Remove a book from the system. - * @throws NotifyException - * @throws BindingResolutionException + * @throws Exception */ public function destroy(Book $book) { $trashCan = new TrashCan(); - $trashCan->destroyBook($book); + $trashCan->softDestroyBook($book); } } diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index ba687c6f6..eb1536da7 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -174,6 +174,6 @@ class BookshelfRepo public function destroy(Bookshelf $shelf) { $trashCan = new TrashCan(); - $trashCan->destroyShelf($shelf); + $trashCan->softDestroyShelf($shelf); } } diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index c6f3a2d2f..c1573f5db 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -6,10 +6,7 @@ use BookStack\Entities\Managers\BookContents; use BookStack\Entities\Managers\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; -use BookStack\Exceptions\NotifyException; use Exception; -use Illuminate\Contracts\Container\BindingResolutionException; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class ChapterRepo @@ -19,7 +16,6 @@ class ChapterRepo /** * ChapterRepo constructor. - * @param $baseRepo */ public function __construct(BaseRepo $baseRepo) { @@ -77,7 +73,7 @@ class ChapterRepo public function destroy(Chapter $chapter) { $trashCan = new TrashCan(); - $trashCan->destroyChapter($chapter); + $trashCan->softDestroyChapter($chapter); } /** diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index e5f13463c..87839192b 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -12,6 +12,7 @@ use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\NotFoundException; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\PermissionsException; +use Exception; use Illuminate\Database\Eloquent\Builder; use Illuminate\Pagination\LengthAwarePaginator; use Illuminate\Support\Collection; @@ -259,12 +260,12 @@ class PageRepo /** * Destroy a page from the system. - * @throws NotifyException + * @throws Exception */ public function destroy(Page $page) { $trashCan = new TrashCan(); - $trashCan->destroyPage($page); + $trashCan->softDestroyPage($page); } /** diff --git a/app/Entities/SearchService.php b/app/Entities/SearchService.php index 11b731cd0..7da8192cc 100644 --- a/app/Entities/SearchService.php +++ b/app/Entities/SearchService.php @@ -287,9 +287,12 @@ class SearchService foreach ($this->entityProvider->all() as $entityModel) { $selectFields = ['id', 'name', $entityModel->textField]; - $entityModel->newQuery()->select($selectFields)->chunk(1000, function ($entities) { - $this->indexEntities($entities); - }); + $entityModel->newQuery() + ->withTrashed() + ->select($selectFields) + ->chunk(1000, function ($entities) { + $this->indexEntities($entities); + }); } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 60d2664d0..3d68b8bcd 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -29,7 +29,7 @@ class HomeController extends Controller $recentFactor = count($draftPages) > 0 ? 0.5 : 1; $recents = $this->isSignedIn() ? - Views::getUserRecentlyViewed(12*$recentFactor, 0) + Views::getUserRecentlyViewed(12*$recentFactor, 1) : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); $recentlyUpdatedPages = Page::visible()->where('draft', false) ->orderBy('updated_at', 'desc')->take(12)->get(); diff --git a/database/migrations/2020_09_27_210059_add_entity_soft_deletes.php b/database/migrations/2020_09_27_210059_add_entity_soft_deletes.php new file mode 100644 index 000000000..d2b63e8d0 --- /dev/null +++ b/database/migrations/2020_09_27_210059_add_entity_soft_deletes.php @@ -0,0 +1,50 @@ +softDeletes(); + }); + Schema::table('books', function(Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('chapters', function(Blueprint $table) { + $table->softDeletes(); + }); + Schema::table('pages', function(Blueprint $table) { + $table->softDeletes(); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('bookshelves', function(Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('books', function(Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('chapters', function(Blueprint $table) { + $table->dropSoftDeletes(); + }); + Schema::table('pages', function(Blueprint $table) { + $table->dropSoftDeletes(); + }); + } +} diff --git a/database/migrations/2020_09_27_210528_create_delete_records_table.php b/database/migrations/2020_09_27_210528_create_delete_records_table.php new file mode 100644 index 000000000..cdb18ced6 --- /dev/null +++ b/database/migrations/2020_09_27_210528_create_delete_records_table.php @@ -0,0 +1,38 @@ +increments('id'); + $table->integer('deleted_by'); + $table->string('deletable_type', 100); + $table->integer('deletable_id'); + $table->timestamps(); + + $table->index('deleted_by'); + $table->index('deletable_type'); + $table->index('deletable_id'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::dropIfExists('delete_records'); + } +} From 04197e393ac69934df85df76e5ba2c4361f5e1d4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 3 Oct 2020 18:44:12 +0100 Subject: [PATCH 02/10] Started work on the recycle bin interface --- .../{DeleteRecord.php => Deletion.php} | 10 +-- app/Entities/Entity.php | 4 +- app/Entities/EntityProvider.php | 1 + app/Entities/Managers/TrashCan.php | 66 ++++++++++++-- app/Http/Controllers/HomeController.php | 8 +- .../Controllers/MaintenanceController.php | 9 +- app/Http/Controllers/RecycleBinController.php | 35 ++++++++ ...0_09_27_210528_create_deletions_table.php} | 6 +- resources/lang/en/settings.php | 12 +++ resources/sass/styles.scss | 4 +- resources/views/partials/table-user.blade.php | 12 +++ resources/views/settings/audit.blade.php | 11 +-- .../views/settings/maintenance.blade.php | 18 ++++ .../views/settings/recycle-bin.blade.php | 90 +++++++++++++++++++ routes/web.php | 4 + 15 files changed, 259 insertions(+), 31 deletions(-) rename app/Entities/{DeleteRecord.php => Deletion.php} (80%) create mode 100644 app/Http/Controllers/RecycleBinController.php rename database/migrations/{2020_09_27_210528_create_delete_records_table.php => 2020_09_27_210528_create_deletions_table.php} (80%) create mode 100644 resources/views/partials/table-user.blade.php create mode 100644 resources/views/settings/recycle-bin.blade.php diff --git a/app/Entities/DeleteRecord.php b/app/Entities/Deletion.php similarity index 80% rename from app/Entities/DeleteRecord.php rename to app/Entities/Deletion.php index 84b37f5a3..576862caa 100644 --- a/app/Entities/DeleteRecord.php +++ b/app/Entities/Deletion.php @@ -5,7 +5,7 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\MorphTo; -class DeleteRecord extends Model +class Deletion extends Model { /** @@ -13,21 +13,21 @@ class DeleteRecord extends Model */ public function deletable(): MorphTo { - return $this->morphTo(); + return $this->morphTo('deletable')->withTrashed(); } /** * The the user that performed the deletion. */ - public function deletedBy(): BelongsTo + public function deleter(): BelongsTo { - return $this->belongsTo(User::class); + return $this->belongsTo(User::class, 'deleted_by'); } /** * Create a new deletion record for the provided entity. */ - public static function createForEntity(Entity $entity): DeleteRecord + public static function createForEntity(Entity $entity): Deletion { $record = (new self())->forceFill([ 'deleted_by' => user()->id, diff --git a/app/Entities/Entity.php b/app/Entities/Entity.php index d1a8664e4..14328386c 100644 --- a/app/Entities/Entity.php +++ b/app/Entities/Entity.php @@ -204,9 +204,9 @@ class Entity extends Ownable /** * Get the related delete records for this entity. */ - public function deleteRecords(): MorphMany + public function deletions(): MorphMany { - return $this->morphMany(DeleteRecord::class, 'deletable'); + return $this->morphMany(Deletion::class, 'deletable'); } /** diff --git a/app/Entities/EntityProvider.php b/app/Entities/EntityProvider.php index 6bf923b31..d28afe6f2 100644 --- a/app/Entities/EntityProvider.php +++ b/app/Entities/EntityProvider.php @@ -57,6 +57,7 @@ class EntityProvider /** * Fetch all core entity types as an associated array * with their basic names as the keys. + * @return [string => Entity] */ public function all(): array { diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php index 9a21f5e2c..280694906 100644 --- a/app/Entities/Managers/TrashCan.php +++ b/app/Entities/Managers/TrashCan.php @@ -3,8 +3,9 @@ use BookStack\Entities\Book; use BookStack\Entities\Bookshelf; use BookStack\Entities\Chapter; -use BookStack\Entities\DeleteRecord; +use BookStack\Entities\Deletion; use BookStack\Entities\Entity; +use BookStack\Entities\EntityProvider; use BookStack\Entities\HasCoverImage; use BookStack\Entities\Page; use BookStack\Exceptions\NotifyException; @@ -21,7 +22,7 @@ class TrashCan */ public function softDestroyShelf(Bookshelf $shelf) { - DeleteRecord::createForEntity($shelf); + Deletion::createForEntity($shelf); $shelf->delete(); } @@ -31,7 +32,7 @@ class TrashCan */ public function softDestroyBook(Book $book) { - DeleteRecord::createForEntity($book); + Deletion::createForEntity($book); foreach ($book->pages as $page) { $this->softDestroyPage($page, false); @@ -51,7 +52,7 @@ class TrashCan public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true) { if ($recordDelete) { - DeleteRecord::createForEntity($chapter); + Deletion::createForEntity($chapter); } if (count($chapter->pages) > 0) { @@ -70,7 +71,7 @@ class TrashCan public function softDestroyPage(Page $page, bool $recordDelete = true) { if ($recordDelete) { - DeleteRecord::createForEntity($page); + Deletion::createForEntity($page); } // Check if set as custom homepage & remove setting if not used or throw error if active @@ -151,6 +152,59 @@ class TrashCan $page->forceDelete(); } + /** + * Get the total counts of those that have been trashed + * but not yet fully deleted (In recycle bin). + */ + public function getTrashedCounts(): array + { + $provider = app(EntityProvider::class); + $counts = []; + + /** @var Entity $instance */ + foreach ($provider->all() as $key => $instance) { + $counts[$key] = $instance->newQuery()->onlyTrashed()->count(); + } + + return $counts; + } + + /** + * Destroy all items that have pending deletions. + */ + public function destroyFromAllDeletions() + { + $deletions = Deletion::all(); + foreach ($deletions as $deletion) { + // For each one we load in the relation since it may have already + // been deleted as part of another deletion in this loop. + $entity = $deletion->deletable()->first(); + if ($entity) { + $this->destroyEntity($deletion->deletable); + } + $deletion->delete(); + } + } + + /** + * Destroy the given entity. + */ + protected function destroyEntity(Entity $entity) + { + if ($entity->isA('page')) { + return $this->destroyPage($entity); + } + if ($entity->isA('chapter')) { + return $this->destroyChapter($entity); + } + if ($entity->isA('book')) { + return $this->destroyBook($entity); + } + if ($entity->isA('shelf')) { + return $this->destroyShelf($entity); + } + } + /** * Update entity relations to remove or update outstanding connections. */ @@ -163,7 +217,7 @@ class TrashCan $entity->comments()->delete(); $entity->jointPermissions()->delete(); $entity->searchTerms()->delete(); - $entity->deleteRecords()->delete(); + $entity->deletions()->delete(); if ($entity instanceof HasCoverImage && $entity->cover) { $imageService = app()->make(ImageService::class); diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index 3d68b8bcd..3b8b7c6e2 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -14,7 +14,6 @@ class HomeController extends Controller /** * Display the homepage. - * @return Response */ public function index() { @@ -22,9 +21,12 @@ class HomeController extends Controller $draftPages = []; if ($this->isSignedIn()) { - $draftPages = Page::visible()->where('draft', '=', true) + $draftPages = Page::visible() + ->where('draft', '=', true) ->where('created_by', '=', user()->id) - ->orderBy('updated_at', 'desc')->take(6)->get(); + ->orderBy('updated_at', 'desc') + ->take(6) + ->get(); } $recentFactor = count($draftPages) > 0 ? 0.5 : 1; diff --git a/app/Http/Controllers/MaintenanceController.php b/app/Http/Controllers/MaintenanceController.php index 664a896b2..0d6265f90 100644 --- a/app/Http/Controllers/MaintenanceController.php +++ b/app/Http/Controllers/MaintenanceController.php @@ -2,6 +2,7 @@ namespace BookStack\Http\Controllers; +use BookStack\Entities\Managers\TrashCan; use BookStack\Notifications\TestEmail; use BookStack\Uploads\ImageService; use Illuminate\Http\Request; @@ -19,7 +20,13 @@ class MaintenanceController extends Controller // Get application version $version = trim(file_get_contents(base_path('version'))); - return view('settings.maintenance', ['version' => $version]); + // Recycle bin details + $recycleStats = (new TrashCan())->getTrashedCounts(); + + return view('settings.maintenance', [ + 'version' => $version, + 'recycleStats' => $recycleStats, + ]); } /** diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php new file mode 100644 index 000000000..b30eddf0c --- /dev/null +++ b/app/Http/Controllers/RecycleBinController.php @@ -0,0 +1,35 @@ +checkPermission('settings-manage'); + $this->checkPermission('restrictions-manage-all'); + + $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10); + + return view('settings.recycle-bin', [ + 'deletions' => $deletions, + ]); + } + + /** + * Empty out the recycle bin. + */ + public function empty() + { + $this->checkPermission('settings-manage'); + $this->checkPermission('restrictions-manage-all'); + + (new TrashCan())->destroyFromAllDeletions(); + return redirect('/settings/recycle-bin'); + } +} diff --git a/database/migrations/2020_09_27_210528_create_delete_records_table.php b/database/migrations/2020_09_27_210528_create_deletions_table.php similarity index 80% rename from database/migrations/2020_09_27_210528_create_delete_records_table.php rename to database/migrations/2020_09_27_210528_create_deletions_table.php index cdb18ced6..c38a9357f 100644 --- a/database/migrations/2020_09_27_210528_create_delete_records_table.php +++ b/database/migrations/2020_09_27_210528_create_deletions_table.php @@ -4,7 +4,7 @@ use Illuminate\Database\Migrations\Migration; use Illuminate\Database\Schema\Blueprint; use Illuminate\Support\Facades\Schema; -class CreateDeleteRecordsTable extends Migration +class CreateDeletionsTable extends Migration { /** * Run the migrations. @@ -13,7 +13,7 @@ class CreateDeleteRecordsTable extends Migration */ public function up() { - Schema::create('delete_records', function (Blueprint $table) { + Schema::create('deletions', function (Blueprint $table) { $table->increments('id'); $table->integer('deleted_by'); $table->string('deletable_type', 100); @@ -33,6 +33,6 @@ class CreateDeleteRecordsTable extends Migration */ public function down() { - Schema::dropIfExists('delete_records'); + Schema::dropIfExists('deletions'); } } diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index e280396a2..66a1fe30c 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -80,6 +80,18 @@ return [ 'maint_send_test_email_mail_subject' => 'Test Email', 'maint_send_test_email_mail_greeting' => 'Email delivery seems to work!', 'maint_send_test_email_mail_text' => 'Congratulations! As you received this email notification, your email settings seem to be configured properly.', + 'maint_recycle_bin_desc' => 'Items deleted remain in the recycle bin until it is emptied. Open the recycle bin to restore or permanently remove items.', + 'maint_recycle_bin_open' => 'Open Recycle Bin', + + // Recycle Bin + 'recycle_bin' => 'Recycle Bin', + 'recycle_bin_desc' => 'Here you can restore items that have been deleted or choose to permanently remove them from the system. This list is unfiltered unlike similar activity lists in the system where permission filters are applied.', + 'recycle_bin_deleted_item' => 'Deleted Item', + 'recycle_bin_deleted_by' => 'Deleted By', + 'recycle_bin_deleted_at' => 'Deletion Time', + 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', + 'recycle_bin_empty' => 'Empty Recycle Bin', + 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', // Audit Log 'audit' => 'Audit Log', diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 376541b5d..78d94f977 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -290,12 +290,12 @@ $btt-size: 40px; } } -table a.audit-log-user { +table.table .table-user-item { display: grid; grid-template-columns: 42px 1fr; align-items: center; } -table a.icon-list-item { +table.table .table-entity-item { display: grid; grid-template-columns: 36px 1fr; align-items: center; diff --git a/resources/views/partials/table-user.blade.php b/resources/views/partials/table-user.blade.php new file mode 100644 index 000000000..a8f2777f0 --- /dev/null +++ b/resources/views/partials/table-user.blade.php @@ -0,0 +1,12 @@ +{{-- +$user - User mode to display, Can be null. +$user_id - Id of user to show. Must be provided. +--}} +@if($user) + +
{{ $user->name }}
+
{{ $user->name }}
+
+@else + [ID: {{ $user_id }}] {{ trans('common.deleted_user') }} +@endif \ No newline at end of file diff --git a/resources/views/settings/audit.blade.php b/resources/views/settings/audit.blade.php index 9b97f060d..47a2355d1 100644 --- a/resources/views/settings/audit.blade.php +++ b/resources/views/settings/audit.blade.php @@ -60,19 +60,12 @@ @foreach($activities as $activity) - @if($activity->user) - -
{{ $activity->user->name }}
-
{{ $activity->user->name }}
-
- @else - [ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }} - @endif + @include('partials.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id]) {{ $activity->key }} @if($activity->entity) - + @icon($activity->entity->getType())
{{ $activity->entity->name }} diff --git a/resources/views/settings/maintenance.blade.php b/resources/views/settings/maintenance.blade.php index 35686ca33..804112c91 100644 --- a/resources/views/settings/maintenance.blade.php +++ b/resources/views/settings/maintenance.blade.php @@ -50,5 +50,23 @@
+
+ @stop diff --git a/resources/views/settings/recycle-bin.blade.php b/resources/views/settings/recycle-bin.blade.php new file mode 100644 index 000000000..145eb5d3c --- /dev/null +++ b/resources/views/settings/recycle-bin.blade.php @@ -0,0 +1,90 @@ +@extends('simple-layout') + +@section('body') +
+ +
+
+ @include('settings.navbar', ['selected' => 'maintenance']) +
+
+ +
+

{{ trans('settings.recycle_bin') }}

+ +
+
+

{{ trans('settings.recycle_bin_desc') }}

+
+
+ + +
+
+ + +
+ + {!! $deletions->links() !!} + + + + + + + + @if(count($deletions) === 0) + + + + @endif + @foreach($deletions as $deletion) + + + + + + @endforeach +
{{ trans('settings.recycle_bin_deleted_item') }}{{ trans('settings.recycle_bin_deleted_by') }}{{ trans('settings.recycle_bin_deleted_at') }}
+

{{ trans('settings.recycle_bin_contents_empty') }}

+
+
+ @icon($deletion->deletable->getType()) +
+ {{ $deletion->deletable->name }} +
+
+ @if($deletion->deletable instanceof \BookStack\Entities\Book) +
+
+ @icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }} +
+
+ @endif + @if($deletion->deletable instanceof \BookStack\Entities\Book || $deletion->deletable instanceof \BookStack\Entities\Chapter) +
+
+ @icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }} +
+
+ @endif +
@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by]){{ $deletion->created_at }}
+ + {!! $deletions->links() !!} + +
+ +
+@stop diff --git a/routes/web.php b/routes/web.php index acbcb4e8f..20f6639a5 100644 --- a/routes/web.php +++ b/routes/web.php @@ -166,6 +166,10 @@ Route::group(['middleware' => 'auth'], function () { Route::delete('/maintenance/cleanup-images', 'MaintenanceController@cleanupImages'); Route::post('/maintenance/send-test-email', 'MaintenanceController@sendTestEmail'); + // Recycle Bin + Route::get('/recycle-bin', 'RecycleBinController@index'); + Route::post('/recycle-bin/empty', 'RecycleBinController@empty'); + // Audit Log Route::get('/audit', 'AuditLogController@index'); From ff7cbd14fcdad963a7f53f788dabbb43ccd73b8b Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 3 Oct 2020 18:53:09 +0100 Subject: [PATCH 03/10] Added recycle bin empty notification response with count --- app/Entities/Managers/TrashCan.php | 26 ++++++++++++++----- app/Http/Controllers/RecycleBinController.php | 4 ++- resources/lang/en/settings.php | 1 + 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php index 280694906..aedf4d7af 100644 --- a/app/Entities/Managers/TrashCan.php +++ b/app/Entities/Managers/TrashCan.php @@ -90,10 +90,11 @@ class TrashCan * Remove a bookshelf from the system. * @throws Exception */ - public function destroyShelf(Bookshelf $shelf) + public function destroyShelf(Bookshelf $shelf): int { $this->destroyCommonRelations($shelf); $shelf->forceDelete(); + return 1; } /** @@ -101,20 +102,24 @@ class TrashCan * Destroys any child chapters and pages. * @throws Exception */ - public function destroyBook(Book $book) + public function destroyBook(Book $book): int { + $count = 0; $pages = $book->pages()->withTrashed()->get(); foreach ($pages as $page) { $this->destroyPage($page); + $count++; } $chapters = $book->chapters()->withTrashed()->get(); foreach ($chapters as $chapter) { $this->destroyChapter($chapter); + $count++; } $this->destroyCommonRelations($book); $book->forceDelete(); + return $count + 1; } /** @@ -122,24 +127,27 @@ class TrashCan * Destroys all pages within. * @throws Exception */ - public function destroyChapter(Chapter $chapter) + public function destroyChapter(Chapter $chapter): int { + $count = 0; $pages = $chapter->pages()->withTrashed()->get(); if (count($pages)) { foreach ($pages as $page) { $this->destroyPage($page); + $count++; } } $this->destroyCommonRelations($chapter); $chapter->forceDelete(); + return $count + 1; } /** * Remove a page from the system. * @throws Exception */ - public function destroyPage(Page $page) + public function destroyPage(Page $page): int { $this->destroyCommonRelations($page); @@ -150,6 +158,7 @@ class TrashCan } $page->forceDelete(); + return 1; } /** @@ -172,24 +181,27 @@ class TrashCan /** * Destroy all items that have pending deletions. */ - public function destroyFromAllDeletions() + public function destroyFromAllDeletions(): int { $deletions = Deletion::all(); + $deleteCount = 0; foreach ($deletions as $deletion) { // For each one we load in the relation since it may have already // been deleted as part of another deletion in this loop. $entity = $deletion->deletable()->first(); if ($entity) { - $this->destroyEntity($deletion->deletable); + $count = $this->destroyEntity($deletion->deletable); + $deleteCount += $count; } $deletion->delete(); } + return $deleteCount; } /** * Destroy the given entity. */ - protected function destroyEntity(Entity $entity) + protected function destroyEntity(Entity $entity): int { if ($entity->isA('page')) { return $this->destroyPage($entity); diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php index b30eddf0c..3cbc99df3 100644 --- a/app/Http/Controllers/RecycleBinController.php +++ b/app/Http/Controllers/RecycleBinController.php @@ -29,7 +29,9 @@ class RecycleBinController extends Controller $this->checkPermission('settings-manage'); $this->checkPermission('restrictions-manage-all'); - (new TrashCan())->destroyFromAllDeletions(); + $deleteCount = (new TrashCan())->destroyFromAllDeletions(); + + $this->showSuccessNotification(trans('settings.recycle_bin_empty_notification', ['count' => $deleteCount])); return redirect('/settings/recycle-bin'); } } diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 66a1fe30c..6de6c2f1a 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -92,6 +92,7 @@ return [ 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', 'recycle_bin_empty' => 'Empty Recycle Bin', 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', + 'recycle_bin_empty_notification' => 'Deleted :count total items from the recycle bin.', // Audit Log 'audit' => 'Audit Log', From 9e033709a78824decbf959ea25ce8672b026da09 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 2 Nov 2020 22:47:48 +0000 Subject: [PATCH 04/10] Added per-item recycle-bin delete and restore --- app/Entities/Entity.php | 16 ++++ app/Entities/Managers/TrashCan.php | 83 ++++++++++++++++-- app/Entities/Page.php | 8 -- app/Entities/Repos/PageRepo.php | 7 +- app/Http/Controllers/PageController.php | 4 +- app/Http/Controllers/RecycleBinController.php | 87 ++++++++++++++++--- resources/lang/en/settings.php | 10 ++- resources/sass/_layout.scss | 9 +- .../partials/entity-display-item.blade.php | 7 ++ .../deletable-entity-list.blade.php | 11 +++ .../settings/recycle-bin/destroy.blade.php | 31 +++++++ .../index.blade.php} | 19 +++- .../settings/recycle-bin/restore.blade.php | 33 +++++++ routes/web.php | 4 + 14 files changed, 291 insertions(+), 38 deletions(-) create mode 100644 resources/views/partials/entity-display-item.blade.php create mode 100644 resources/views/settings/recycle-bin/deletable-entity-list.blade.php create mode 100644 resources/views/settings/recycle-bin/destroy.blade.php rename resources/views/settings/{recycle-bin.blade.php => recycle-bin/index.blade.php} (76%) create mode 100644 resources/views/settings/recycle-bin/restore.blade.php diff --git a/app/Entities/Entity.php b/app/Entities/Entity.php index 14328386c..ed3040929 100644 --- a/app/Entities/Entity.php +++ b/app/Entities/Entity.php @@ -287,6 +287,22 @@ class Entity extends Ownable return $path; } + /** + * Get the parent entity if existing. + * This is the "static" parent and does not include dynamic + * relations such as shelves to books. + */ + public function getParent(): ?Entity + { + if ($this->isA('page')) { + return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book->withTrashed()->first(); + } + if ($this->isA('chapter')) { + return $this->book->withTrashed()->first(); + } + return null; + } + /** * Rebuild the permissions for this entity. */ diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php index aedf4d7af..f99c62801 100644 --- a/app/Entities/Managers/TrashCan.php +++ b/app/Entities/Managers/TrashCan.php @@ -180,24 +180,91 @@ class TrashCan /** * Destroy all items that have pending deletions. + * @throws Exception */ public function destroyFromAllDeletions(): int { $deletions = Deletion::all(); $deleteCount = 0; foreach ($deletions as $deletion) { - // For each one we load in the relation since it may have already - // been deleted as part of another deletion in this loop. - $entity = $deletion->deletable()->first(); - if ($entity) { - $count = $this->destroyEntity($deletion->deletable); - $deleteCount += $count; - } - $deletion->delete(); + $deleteCount += $this->destroyFromDeletion($deletion); } return $deleteCount; } + /** + * Destroy an element from the given deletion model. + * @throws Exception + */ + public function destroyFromDeletion(Deletion $deletion): int + { + // We directly load the deletable element here just to ensure it still + // exists in the event it has already been destroyed during this request. + $entity = $deletion->deletable()->first(); + $count = 0; + if ($entity) { + $count = $this->destroyEntity($deletion->deletable); + } + $deletion->delete(); + return $count; + } + + /** + * Restore the content within the given deletion. + * @throws Exception + */ + public function restoreFromDeletion(Deletion $deletion): int + { + $shouldRestore = true; + $restoreCount = 0; + $parent = $deletion->deletable->getParent(); + + if ($parent && $parent->trashed()) { + $shouldRestore = false; + } + + if ($shouldRestore) { + $restoreCount = $this->restoreEntity($deletion->deletable); + } + + $deletion->delete(); + return $restoreCount; + } + + /** + * Restore an entity so it is essentially un-deleted. + * Deletions on restored child elements will be removed during this restoration. + */ + protected function restoreEntity(Entity $entity): int + { + $count = 1; + $entity->restore(); + + if ($entity->isA('chapter') || $entity->isA('book')) { + foreach ($entity->pages()->withTrashed()->withCount('deletions')->get() as $page) { + if ($page->deletions_count > 0) { + $page->deletions()->delete(); + } + + $page->restore(); + $count++; + } + } + + if ($entity->isA('book')) { + foreach ($entity->chapters()->withTrashed()->withCount('deletions')->get() as $chapter) { + if ($chapter->deletions_count === 0) { + $chapter->deletions()->delete(); + } + + $chapter->restore(); + $count++; + } + } + + return $count; + } + /** * Destroy the given entity. */ diff --git a/app/Entities/Page.php b/app/Entities/Page.php index 32ba2981d..8ad05e7aa 100644 --- a/app/Entities/Page.php +++ b/app/Entities/Page.php @@ -49,14 +49,6 @@ class Page extends BookChild return $array; } - /** - * Get the parent item - */ - public function parent(): Entity - { - return $this->chapter_id ? $this->chapter : $this->book; - } - /** * Get the chapter that this page is in, If applicable. * @return BelongsTo diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 87839192b..3b9b1f34c 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -321,7 +321,7 @@ class PageRepo */ public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page { - $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent(); + $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->getParent(); if ($parent === null) { throw new MoveOperationException('Book or chapter to move page into not found'); } @@ -440,8 +440,9 @@ class PageRepo */ protected function getNewPriority(Page $page): int { - if ($page->parent() instanceof Chapter) { - $lastPage = $page->parent()->pages('desc')->first(); + $parent = $page->getParent(); + if ($parent instanceof Chapter) { + $lastPage = $parent->pages('desc')->first(); return $lastPage ? $lastPage->priority + 1 : 0; } diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 57d70fb32..ee998996f 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -78,7 +78,7 @@ class PageController extends Controller public function editDraft(string $bookSlug, int $pageId) { $draft = $this->pageRepo->getById($pageId); - $this->checkOwnablePermission('page-create', $draft->parent()); + $this->checkOwnablePermission('page-create', $draft->getParent()); $this->setPageTitle(trans('entities.pages_edit_draft')); $draftsEnabled = $this->isSignedIn(); @@ -104,7 +104,7 @@ class PageController extends Controller 'name' => 'required|string|max:255' ]); $draftPage = $this->pageRepo->getById($pageId); - $this->checkOwnablePermission('page-create', $draftPage->parent()); + $this->checkOwnablePermission('page-create', $draftPage->getParent()); $page = $this->pageRepo->publishDraft($draftPage, $request->all()); Activity::add($page, 'page_create', $draftPage->book->id); diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php index 3cbc99df3..64459da23 100644 --- a/app/Http/Controllers/RecycleBinController.php +++ b/app/Http/Controllers/RecycleBinController.php @@ -2,36 +2,103 @@ use BookStack\Entities\Deletion; use BookStack\Entities\Managers\TrashCan; -use Illuminate\Http\Request; class RecycleBinController extends Controller { + + protected $recycleBinBaseUrl = '/settings/recycle-bin'; + + /** + * On each request to a method of this controller check permissions + * using a middleware closure. + */ + public function __construct() + { + // TODO - Check this is enforced. + $this->middleware(function ($request, $next) { + $this->checkPermission('settings-manage'); + $this->checkPermission('restrictions-manage-all'); + return $next($request); + }); + parent::__construct(); + } + + /** * Show the top-level listing for the recycle bin. */ public function index() { - $this->checkPermission('settings-manage'); - $this->checkPermission('restrictions-manage-all'); - $deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10); - return view('settings.recycle-bin', [ + return view('settings.recycle-bin.index', [ 'deletions' => $deletions, ]); } + /** + * Show the page to confirm a restore of the deletion of the given id. + */ + public function showRestore(string $id) + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + + return view('settings.recycle-bin.restore', [ + 'deletion' => $deletion, + ]); + } + + /** + * Restore the element attached to the given deletion. + * @throws \Exception + */ + public function restore(string $id) + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + $restoreCount = (new TrashCan())->restoreFromDeletion($deletion); + + $this->showSuccessNotification(trans('settings.recycle_bin_restore_notification', ['count' => $restoreCount])); + return redirect($this->recycleBinBaseUrl); + } + + /** + * Show the page to confirm a Permanent deletion of the element attached to the deletion of the given id. + */ + public function showDestroy(string $id) + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + + return view('settings.recycle-bin.destroy', [ + 'deletion' => $deletion, + ]); + } + + /** + * Permanently delete the content associated with the given deletion. + * @throws \Exception + */ + public function destroy(string $id) + { + /** @var Deletion $deletion */ + $deletion = Deletion::query()->findOrFail($id); + $deleteCount = (new TrashCan())->destroyFromDeletion($deletion); + + $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount])); + return redirect($this->recycleBinBaseUrl); + } + /** * Empty out the recycle bin. + * @throws \Exception */ public function empty() { - $this->checkPermission('settings-manage'); - $this->checkPermission('restrictions-manage-all'); - $deleteCount = (new TrashCan())->destroyFromAllDeletions(); - $this->showSuccessNotification(trans('settings.recycle_bin_empty_notification', ['count' => $deleteCount])); - return redirect('/settings/recycle-bin'); + $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount])); + return redirect($this->recycleBinBaseUrl); } } diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 6de6c2f1a..b9d91e18c 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -89,10 +89,18 @@ return [ 'recycle_bin_deleted_item' => 'Deleted Item', 'recycle_bin_deleted_by' => 'Deleted By', 'recycle_bin_deleted_at' => 'Deletion Time', + 'recycle_bin_permanently_delete' => 'Permanently Delete', + 'recycle_bin_restore' => 'Restore', 'recycle_bin_contents_empty' => 'The recycle bin is currently empty', 'recycle_bin_empty' => 'Empty Recycle Bin', 'recycle_bin_empty_confirm' => 'This will permanently destroy all items in the recycle bin including content contained within each item. Are you sure you want to empty the recycle bin?', - 'recycle_bin_empty_notification' => 'Deleted :count total items from the recycle bin.', + 'recycle_bin_destroy_confirm' => 'This action will permanently delete this item, along with any child elements listed below, from the system and you will not be able to restore this content. Are you sure you want to permanently delete this item?', + 'recycle_bin_destroy_list' => 'Items to be Destroyed', + 'recycle_bin_restore_list' => 'Items to be Restored', + 'recycle_bin_restore_confirm' => 'This action will restore the deleted item, including any child elements, to their original location. If the original location has since been deleted, and is now in the recycle bin, the parent item will also need to be restored.', + 'recycle_bin_restore_deleted_parent' => 'The parent of this item has also been deleted. These will remain deleted until that parent is also restored.', + 'recycle_bin_destroy_notification' => 'Deleted :count total items from the recycle bin.', + 'recycle_bin_restore_notification' => 'Restored :count total items from the recycle bin.', // Audit Log 'audit' => 'Audit Log', diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 519cb27ad..c4e412f0e 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -150,22 +150,25 @@ body.flexbox { .justify-flex-end { justify-content: flex-end; } +.justify-center { + justify-content: center; +} /** * Display and float utilities */ .block { - display: block; + display: block !important; position: relative; } .inline { - display: inline; + display: inline !important; } .block.inline { - display: inline-block; + display: inline-block !important; } .hidden { diff --git a/resources/views/partials/entity-display-item.blade.php b/resources/views/partials/entity-display-item.blade.php new file mode 100644 index 000000000..d6633edbe --- /dev/null +++ b/resources/views/partials/entity-display-item.blade.php @@ -0,0 +1,7 @@ +getType(); ?> +
+ @icon($type) +
+
{{ $entity->name }}
+
+
\ No newline at end of file diff --git a/resources/views/settings/recycle-bin/deletable-entity-list.blade.php b/resources/views/settings/recycle-bin/deletable-entity-list.blade.php new file mode 100644 index 000000000..07ad94f8e --- /dev/null +++ b/resources/views/settings/recycle-bin/deletable-entity-list.blade.php @@ -0,0 +1,11 @@ +@include('partials.entity-display-item', ['entity' => $entity]) +@if($entity->isA('book')) + @foreach($entity->chapters()->withTrashed()->get() as $chapter) + @include('partials.entity-display-item', ['entity' => $chapter]) + @endforeach +@endif +@if($entity->isA('book') || $entity->isA('chapter')) + @foreach($entity->pages()->withTrashed()->get() as $page) + @include('partials.entity-display-item', ['entity' => $page]) + @endforeach +@endif \ No newline at end of file diff --git a/resources/views/settings/recycle-bin/destroy.blade.php b/resources/views/settings/recycle-bin/destroy.blade.php new file mode 100644 index 000000000..2cc11dabf --- /dev/null +++ b/resources/views/settings/recycle-bin/destroy.blade.php @@ -0,0 +1,31 @@ +@extends('simple-layout') + +@section('body') +
+ +
+
+ @include('settings.navbar', ['selected' => 'maintenance']) +
+
+ +
+

{{ trans('settings.recycle_bin_permanently_delete') }}

+

{{ trans('settings.recycle_bin_destroy_confirm') }}

+
+ {!! method_field('DELETE') !!} + {!! csrf_field() !!} + {{ trans('common.cancel') }} + +
+ + @if($deletion->deletable instanceof \BookStack\Entities\Entity) +
+
{{ trans('settings.recycle_bin_destroy_list') }}
+ @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable]) + @endif + +
+ +
+@stop diff --git a/resources/views/settings/recycle-bin.blade.php b/resources/views/settings/recycle-bin/index.blade.php similarity index 76% rename from resources/views/settings/recycle-bin.blade.php rename to resources/views/settings/recycle-bin/index.blade.php index 145eb5d3c..6a61ff9fa 100644 --- a/resources/views/settings/recycle-bin.blade.php +++ b/resources/views/settings/recycle-bin/index.blade.php @@ -44,10 +44,11 @@ {{ trans('settings.recycle_bin_deleted_item') }} {{ trans('settings.recycle_bin_deleted_by') }} {{ trans('settings.recycle_bin_deleted_at') }} + @if(count($deletions) === 0) - +

{{ trans('settings.recycle_bin_contents_empty') }}

@@ -55,12 +56,15 @@ @foreach($deletions as $deletion) -
+
@icon($deletion->deletable->getType())
{{ $deletion->deletable->name }}
+ @if($deletion->deletable instanceof \BookStack\Entities\Book || $deletion->deletable instanceof \BookStack\Entities\Chapter) +
+ @endif @if($deletion->deletable instanceof \BookStack\Entities\Book)
@@ -77,7 +81,16 @@ @endif @include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by]) - {{ $deletion->created_at }} + {{ $deletion->created_at }} + + + @endforeach diff --git a/resources/views/settings/recycle-bin/restore.blade.php b/resources/views/settings/recycle-bin/restore.blade.php new file mode 100644 index 000000000..79ccf1b7d --- /dev/null +++ b/resources/views/settings/recycle-bin/restore.blade.php @@ -0,0 +1,33 @@ +@extends('simple-layout') + +@section('body') +
+ +
+
+ @include('settings.navbar', ['selected' => 'maintenance']) +
+
+ +
+

{{ trans('settings.recycle_bin_restore') }}

+

{{ trans('settings.recycle_bin_restore_confirm') }}

+
+ {!! csrf_field() !!} + {{ trans('common.cancel') }} + +
+ + @if($deletion->deletable instanceof \BookStack\Entities\Entity) +
+
{{ trans('settings.recycle_bin_restore_list') }}
+ @if($deletion->deletable->getParent() && $deletion->deletable->getParent()->trashed()) +

{{ trans('settings.recycle_bin_restore_deleted_parent') }}

+ @endif + @include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable]) + @endif + +
+ +
+@stop diff --git a/routes/web.php b/routes/web.php index 20f6639a5..b87355105 100644 --- a/routes/web.php +++ b/routes/web.php @@ -169,6 +169,10 @@ Route::group(['middleware' => 'auth'], function () { // Recycle Bin Route::get('/recycle-bin', 'RecycleBinController@index'); Route::post('/recycle-bin/empty', 'RecycleBinController@empty'); + Route::get('/recycle-bin/{id}/destroy', 'RecycleBinController@showDestroy'); + Route::delete('/recycle-bin/{id}', 'RecycleBinController@destroy'); + Route::get('/recycle-bin/{id}/restore', 'RecycleBinController@showRestore'); + Route::post('/recycle-bin/{id}/restore', 'RecycleBinController@restore'); // Audit Log Route::get('/audit', 'AuditLogController@index'); From 3e70c661a183a510d1e999e38723489650a6b96f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Mon, 2 Nov 2020 22:54:00 +0000 Subject: [PATCH 05/10] Cleaned up duplicate code in recycle-bin restore --- app/Entities/Managers/TrashCan.php | 27 +++++++++++---------------- 1 file changed, 11 insertions(+), 16 deletions(-) diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php index f99c62801..c567edaf3 100644 --- a/app/Entities/Managers/TrashCan.php +++ b/app/Entities/Managers/TrashCan.php @@ -240,26 +240,21 @@ class TrashCan $count = 1; $entity->restore(); - if ($entity->isA('chapter') || $entity->isA('book')) { - foreach ($entity->pages()->withTrashed()->withCount('deletions')->get() as $page) { - if ($page->deletions_count > 0) { - $page->deletions()->delete(); - } - - $page->restore(); - $count++; + $restoreAction = function ($entity) use (&$count) { + if ($entity->deletions_count > 0) { + $entity->deletions()->delete(); } + + $entity->restore(); + $count++; + }; + + if ($entity->isA('chapter') || $entity->isA('book')) { + $entity->pages()->withTrashed()->withCount('deletions')->get()->each($restoreAction); } if ($entity->isA('book')) { - foreach ($entity->chapters()->withTrashed()->withCount('deletions')->get() as $chapter) { - if ($chapter->deletions_count === 0) { - $chapter->deletions()->delete(); - } - - $chapter->restore(); - $count++; - } + $entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction); } return $count; From 483cb41665c9d5994b47c762670d5fd98188cf14 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 6 Nov 2020 12:54:39 +0000 Subject: [PATCH 06/10] Started testing work for recycle bin implementation --- app/Entities/Entity.php | 2 +- app/Entities/Managers/TrashCan.php | 10 +-- app/Http/Controllers/AuditLogController.php | 7 +- app/Http/Controllers/BookController.php | 3 +- app/Http/Controllers/BookshelfController.php | 2 +- app/Http/Controllers/ChapterController.php | 2 +- app/Http/Controllers/PageController.php | 3 +- app/Http/Controllers/RecycleBinController.php | 3 +- tests/AuditLogTest.php | 4 +- tests/Entity/BookShelfTest.php | 27 +++++--- tests/Entity/BookTest.php | 34 ++++++++++ tests/Entity/ChapterTest.php | 31 +++++++++ tests/Entity/EntityTest.php | 52 +------------- tests/Entity/PageTest.php | 27 ++++++++ tests/RecycleBinTest.php | 68 +++++++++++++++++++ tests/SharedTestHelpers.php | 27 ++++++-- tests/TestResponse.php | 21 +++--- tests/Uploads/AttachmentTest.php | 5 +- 18 files changed, 235 insertions(+), 93 deletions(-) create mode 100644 tests/Entity/BookTest.php create mode 100644 tests/Entity/ChapterTest.php create mode 100644 tests/Entity/PageTest.php create mode 100644 tests/RecycleBinTest.php diff --git a/app/Entities/Entity.php b/app/Entities/Entity.php index ed3040929..34cdb4b8c 100644 --- a/app/Entities/Entity.php +++ b/app/Entities/Entity.php @@ -295,7 +295,7 @@ class Entity extends Ownable public function getParent(): ?Entity { if ($this->isA('page')) { - return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book->withTrashed()->first(); + return $this->chapter_id ? $this->chapter()->withTrashed()->first() : $this->book()->withTrashed()->first(); } if ($this->isA('chapter')) { return $this->book->withTrashed()->first(); diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php index c567edaf3..686918ce2 100644 --- a/app/Entities/Managers/TrashCan.php +++ b/app/Entities/Managers/TrashCan.php @@ -90,7 +90,7 @@ class TrashCan * Remove a bookshelf from the system. * @throws Exception */ - public function destroyShelf(Bookshelf $shelf): int + protected function destroyShelf(Bookshelf $shelf): int { $this->destroyCommonRelations($shelf); $shelf->forceDelete(); @@ -102,7 +102,7 @@ class TrashCan * Destroys any child chapters and pages. * @throws Exception */ - public function destroyBook(Book $book): int + protected function destroyBook(Book $book): int { $count = 0; $pages = $book->pages()->withTrashed()->get(); @@ -127,7 +127,7 @@ class TrashCan * Destroys all pages within. * @throws Exception */ - public function destroyChapter(Chapter $chapter): int + protected function destroyChapter(Chapter $chapter): int { $count = 0; $pages = $chapter->pages()->withTrashed()->get(); @@ -147,7 +147,7 @@ class TrashCan * Remove a page from the system. * @throws Exception */ - public function destroyPage(Page $page): int + protected function destroyPage(Page $page): int { $this->destroyCommonRelations($page); @@ -182,7 +182,7 @@ class TrashCan * Destroy all items that have pending deletions. * @throws Exception */ - public function destroyFromAllDeletions(): int + public function empty(): int { $deletions = Deletion::all(); $deleteCount = 0; diff --git a/app/Http/Controllers/AuditLogController.php b/app/Http/Controllers/AuditLogController.php index a3ef01baa..fad4e8d38 100644 --- a/app/Http/Controllers/AuditLogController.php +++ b/app/Http/Controllers/AuditLogController.php @@ -23,7 +23,12 @@ class AuditLogController extends Controller ]; $query = Activity::query() - ->with(['entity', 'user']) + ->with([ + 'entity' => function ($query) { + $query->withTrashed(); + }, + 'user' + ]) ->orderBy($listDetails['sort'], $listDetails['order']); if ($listDetails['event']) { diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index 1643c62f9..25dc65194 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -181,14 +181,13 @@ class BookController extends Controller /** * Remove the specified book from the system. * @throws Throwable - * @throws NotifyException */ public function destroy(string $bookSlug) { $book = $this->bookRepo->getBySlug($bookSlug); $this->checkOwnablePermission('book-delete', $book); - Activity::addMessage('book_delete', $book->name); + Activity::add($book, 'book_delete', $book->id); $this->bookRepo->destroy($book); return redirect('/books'); diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index f2cc11c7b..efe280235 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -182,7 +182,7 @@ class BookshelfController extends Controller $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('bookshelf-delete', $shelf); - Activity::addMessage('bookshelf_delete', $shelf->name); + Activity::add($shelf, 'bookshelf_delete'); $this->bookshelfRepo->destroy($shelf); return redirect('/shelves'); diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index 135597910..5d8631154 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -128,7 +128,7 @@ class ChapterController extends Controller $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-delete', $chapter); - Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id); + Activity::add($chapter, 'chapter_delete', $chapter->book->id); $this->chapterRepo->destroy($chapter); return redirect($chapter->book->getUrl()); diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index ee998996f..6396da23e 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -308,9 +308,8 @@ class PageController extends Controller $book = $page->book; $parent = $page->chapter ?? $book; $this->pageRepo->destroy($page); - Activity::addMessage('page_delete', $page->name, $book->id); + Activity::add($page, 'page_delete', $page->book_id); - $this->showSuccessNotification(trans('entities.pages_delete_success')); return redirect($parent->getUrl()); } diff --git a/app/Http/Controllers/RecycleBinController.php b/app/Http/Controllers/RecycleBinController.php index 64459da23..459dbb39d 100644 --- a/app/Http/Controllers/RecycleBinController.php +++ b/app/Http/Controllers/RecycleBinController.php @@ -14,7 +14,6 @@ class RecycleBinController extends Controller */ public function __construct() { - // TODO - Check this is enforced. $this->middleware(function ($request, $next) { $this->checkPermission('settings-manage'); $this->checkPermission('restrictions-manage-all'); @@ -96,7 +95,7 @@ class RecycleBinController extends Controller */ public function empty() { - $deleteCount = (new TrashCan())->destroyFromAllDeletions(); + $deleteCount = (new TrashCan())->empty(); $this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount])); return redirect($this->recycleBinBaseUrl); diff --git a/tests/AuditLogTest.php b/tests/AuditLogTest.php index a2cdc33ff..94eb02599 100644 --- a/tests/AuditLogTest.php +++ b/tests/AuditLogTest.php @@ -3,6 +3,7 @@ use BookStack\Actions\Activity; use BookStack\Actions\ActivityService; use BookStack\Auth\UserRepo; +use BookStack\Entities\Managers\TrashCan; use BookStack\Entities\Page; use BookStack\Entities\Repos\PageRepo; use Carbon\Carbon; @@ -40,7 +41,7 @@ class AuditLogTest extends TestCase $resp->assertSeeText($page->name); $resp->assertSeeText('page_create'); $resp->assertSeeText($activity->created_at->toDateTimeString()); - $resp->assertElementContains('.audit-log-user', $admin->name); + $resp->assertElementContains('.table-user-item', $admin->name); } public function test_shows_name_for_deleted_items() @@ -51,6 +52,7 @@ class AuditLogTest extends TestCase app(ActivityService::class)->add($page, 'page_create', $page->book->id); app(PageRepo::class)->destroy($page); + app(TrashCan::class)->empty(); $resp = $this->get('settings/audit'); $resp->assertSeeText('Deleted Item'); diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index cb3acfb1e..c1748281e 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -222,16 +222,25 @@ class BookShelfTest extends TestCase public function test_shelf_delete() { - $shelf = Bookshelf::first(); - $resp = $this->asEditor()->get($shelf->getUrl('/delete')); - $resp->assertSeeText('Delete Bookshelf'); - $resp->assertSee("action=\"{$shelf->getUrl()}\""); + $shelf = Bookshelf::query()->whereHas('books')->first(); + $this->assertNull($shelf->deleted_at); + $bookCount = $shelf->books()->count(); - $resp = $this->delete($shelf->getUrl()); - $resp->assertRedirect('/shelves'); - $this->assertDatabaseMissing('bookshelves', ['id' => $shelf->id]); - $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]); - $this->assertSessionHas('success'); + $deleteViewReq = $this->asEditor()->get($shelf->getUrl('/delete')); + $deleteViewReq->assertSeeText('Are you sure you want to delete this bookshelf?'); + + $deleteReq = $this->delete($shelf->getUrl()); + $deleteReq->assertRedirect(url('/shelves')); + $this->assertActivityExists('bookshelf_delete', $shelf); + + $shelf->refresh(); + $this->assertNotNull($shelf->deleted_at); + + $this->assertTrue($shelf->books()->count() === $bookCount); + $this->assertTrue($shelf->deletions()->count() === 1); + + $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); + $redirectReq->assertNotificationContains('Bookshelf Successfully Deleted'); } public function test_shelf_copy_permissions() diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php new file mode 100644 index 000000000..b502bdcc5 --- /dev/null +++ b/tests/Entity/BookTest.php @@ -0,0 +1,34 @@ +whereHas('pages')->whereHas('chapters')->first(); + $this->assertNull($book->deleted_at); + $pageCount = $book->pages()->count(); + $chapterCount = $book->chapters()->count(); + + $deleteViewReq = $this->asEditor()->get($book->getUrl('/delete')); + $deleteViewReq->assertSeeText('Are you sure you want to delete this book?'); + + $deleteReq = $this->delete($book->getUrl()); + $deleteReq->assertRedirect(url('/books')); + $this->assertActivityExists('book_delete', $book); + + $book->refresh(); + $this->assertNotNull($book->deleted_at); + + $this->assertTrue($book->pages()->count() === 0); + $this->assertTrue($book->chapters()->count() === 0); + $this->assertTrue($book->pages()->withTrashed()->count() === $pageCount); + $this->assertTrue($book->chapters()->withTrashed()->count() === $chapterCount); + $this->assertTrue($book->deletions()->count() === 1); + + $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); + $redirectReq->assertNotificationContains('Book Successfully Deleted'); + } +} \ No newline at end of file diff --git a/tests/Entity/ChapterTest.php b/tests/Entity/ChapterTest.php new file mode 100644 index 000000000..d072f8d8b --- /dev/null +++ b/tests/Entity/ChapterTest.php @@ -0,0 +1,31 @@ +whereHas('pages')->first(); + $this->assertNull($chapter->deleted_at); + $pageCount = $chapter->pages()->count(); + + $deleteViewReq = $this->asEditor()->get($chapter->getUrl('/delete')); + $deleteViewReq->assertSeeText('Are you sure you want to delete this chapter?'); + + $deleteReq = $this->delete($chapter->getUrl()); + $deleteReq->assertRedirect($chapter->getParent()->getUrl()); + $this->assertActivityExists('chapter_delete', $chapter); + + $chapter->refresh(); + $this->assertNotNull($chapter->deleted_at); + + $this->assertTrue($chapter->pages()->count() === 0); + $this->assertTrue($chapter->pages()->withTrashed()->count() === $pageCount); + $this->assertTrue($chapter->deletions()->count() === 1); + + $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); + $redirectReq->assertNotificationContains('Chapter Successfully Deleted'); + } +} \ No newline at end of file diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php index de1e025ad..4aad6622f 100644 --- a/tests/Entity/EntityTest.php +++ b/tests/Entity/EntityTest.php @@ -7,7 +7,6 @@ use BookStack\Entities\Page; use BookStack\Auth\UserRepo; use BookStack\Entities\Repos\PageRepo; use Carbon\Carbon; -use Illuminate\Support\Facades\DB; use Tests\BrowserKitTest; class EntityTest extends BrowserKitTest @@ -18,27 +17,10 @@ class EntityTest extends BrowserKitTest // Test Creation $book = $this->bookCreation(); $chapter = $this->chapterCreation($book); - $page = $this->pageCreation($chapter); + $this->pageCreation($chapter); // Test Updating - $book = $this->bookUpdate($book); - - // Test Deletion - $this->bookDelete($book); - } - - public function bookDelete(Book $book) - { - $this->asAdmin() - ->visit($book->getUrl()) - // Check link works correctly - ->click('Delete') - ->seePageIs($book->getUrl() . '/delete') - // Ensure the book name is show to user - ->see($book->name) - ->press('Confirm') - ->seePageIs('/books') - ->notSeeInDatabase('books', ['id' => $book->id]); + $this->bookUpdate($book); } public function bookUpdate(Book $book) @@ -332,34 +314,4 @@ class EntityTest extends BrowserKitTest ->seePageIs($chapter->getUrl()); } - public function test_page_delete_removes_entity_from_its_activity() - { - $page = Page::query()->first(); - - $this->asEditor()->put($page->getUrl(), [ - 'name' => 'My updated page', - 'html' => '

updated content

', - ]); - $page->refresh(); - - $this->seeInDatabase('activities', [ - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), - ]); - - $resp = $this->delete($page->getUrl()); - $resp->assertResponseStatus(302); - - $this->dontSeeInDatabase('activities', [ - 'entity_id' => $page->id, - 'entity_type' => $page->getMorphClass(), - ]); - - $this->seeInDatabase('activities', [ - 'extra' => 'My updated page', - 'entity_id' => 0, - 'entity_type' => '', - ]); - } - } diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php new file mode 100644 index 000000000..742fd1151 --- /dev/null +++ b/tests/Entity/PageTest.php @@ -0,0 +1,27 @@ +first(); + $this->assertNull($page->deleted_at); + + $deleteViewReq = $this->asEditor()->get($page->getUrl('/delete')); + $deleteViewReq->assertSeeText('Are you sure you want to delete this page?'); + + $deleteReq = $this->delete($page->getUrl()); + $deleteReq->assertRedirect($page->getParent()->getUrl()); + $this->assertActivityExists('page_delete', $page); + + $page->refresh(); + $this->assertNotNull($page->deleted_at); + $this->assertTrue($page->deletions()->count() === 1); + + $redirectReq = $this->get($deleteReq->baseResponse->headers->get('location')); + $redirectReq->assertNotificationContains('Page Successfully Deleted'); + } +} \ No newline at end of file diff --git a/tests/RecycleBinTest.php b/tests/RecycleBinTest.php new file mode 100644 index 000000000..086f63679 --- /dev/null +++ b/tests/RecycleBinTest.php @@ -0,0 +1,68 @@ +first(); + $editor = $this->getEditor(); + $this->actingAs($editor)->delete($page->getUrl()); + $deletion = Deletion::query()->firstOrFail(); + + $routes = [ + 'GET:/settings/recycle-bin', + 'POST:/settings/recycle-bin/empty', + "GET:/settings/recycle-bin/{$deletion->id}/destroy", + "GET:/settings/recycle-bin/{$deletion->id}/restore", + "POST:/settings/recycle-bin/{$deletion->id}/restore", + "DELETE:/settings/recycle-bin/{$deletion->id}", + ]; + + foreach($routes as $route) { + [$method, $url] = explode(':', $route); + $resp = $this->call($method, $url); + $this->assertPermissionError($resp); + } + + $this->giveUserPermissions($editor, ['restrictions-manage-all']); + + foreach($routes as $route) { + [$method, $url] = explode(':', $route); + $resp = $this->call($method, $url); + $this->assertPermissionError($resp); + } + + $this->giveUserPermissions($editor, ['settings-manage']); + + foreach($routes as $route) { + \DB::beginTransaction(); + [$method, $url] = explode(':', $route); + $resp = $this->call($method, $url); + $this->assertNotPermissionError($resp); + \DB::rollBack(); + } + + } + + public function test_recycle_bin_view() + { + $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()); + + $viewReq = $this->asAdmin()->get('/settings/recycle-bin'); + $viewReq->assertElementContains('table.table', $page->name); + $viewReq->assertElementContains('table.table', $editor->name); + $viewReq->assertElementContains('table.table', $book->name); + $viewReq->assertElementContains('table.table', $book->pages_count . ' Pages'); + $viewReq->assertElementContains('table.table', $book->chapters_count . ' Chapters'); + } +} \ No newline at end of file diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index c7659a02d..1ba474d76 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -15,12 +15,14 @@ use BookStack\Auth\Permissions\PermissionService; use BookStack\Entities\Repos\PageRepo; use BookStack\Settings\SettingService; use BookStack\Uploads\HttpFetcher; +use Illuminate\Http\Response; use Illuminate\Support\Env; use Illuminate\Support\Facades\Log; use Mockery; use Monolog\Handler\TestHandler; use Monolog\Logger; use Throwable; +use Illuminate\Foundation\Testing\Assert as PHPUnit; trait SharedTestHelpers { @@ -270,14 +272,25 @@ trait SharedTestHelpers */ protected function assertPermissionError($response) { - if ($response instanceof BrowserKitTest) { - $response = \Illuminate\Foundation\Testing\TestResponse::fromBaseResponse($response->response); - } + PHPUnit::assertTrue($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response contains a permission error."); + } - $response->assertRedirect('/'); - $this->assertSessionHas('error'); - $error = session()->pull('error'); - $this->assertStringStartsWith('You do not have permission to access', $error); + /** + * Assert a permission error has occurred. + */ + protected function assertNotPermissionError($response) + { + PHPUnit::assertFalse($this->isPermissionError($response->baseResponse ?? $response->response), "Failed asserting the response does not contain a permission error."); + } + + /** + * Check if the given response is a permission error. + */ + private function isPermissionError($response): bool + { + return $response->status() === 302 + && $response->headers->get('Location') === url('/') + && strpos(session()->pull('error', ''), 'You do not have permission to access') === 0; } /** diff --git a/tests/TestResponse.php b/tests/TestResponse.php index a68a5783f..9c6b78782 100644 --- a/tests/TestResponse.php +++ b/tests/TestResponse.php @@ -15,9 +15,8 @@ class TestResponse extends BaseTestResponse { /** * Get the DOM Crawler for the response content. - * @return Crawler */ - protected function crawler() + protected function crawler(): Crawler { if (!is_object($this->crawlerInstance)) { $this->crawlerInstance = new Crawler($this->getContent()); @@ -27,7 +26,6 @@ class TestResponse extends BaseTestResponse { /** * Assert the response contains the specified element. - * @param string $selector * @return $this */ public function assertElementExists(string $selector) @@ -45,7 +43,6 @@ class TestResponse extends BaseTestResponse { /** * Assert the response does not contain the specified element. - * @param string $selector * @return $this */ public function assertElementNotExists(string $selector) @@ -63,8 +60,6 @@ class TestResponse extends BaseTestResponse { /** * Assert the response includes a specific element containing the given text. - * @param string $selector - * @param string $text * @return $this */ public function assertElementContains(string $selector, string $text) @@ -95,8 +90,6 @@ class TestResponse extends BaseTestResponse { /** * Assert the response does not include a specific element containing the given text. - * @param string $selector - * @param string $text * @return $this */ public function assertElementNotContains(string $selector, string $text) @@ -125,12 +118,20 @@ class TestResponse extends BaseTestResponse { return $this; } + /** + * Assert there's a notification within the view containing the given text. + * @return $this + */ + public function assertNotificationContains(string $text) + { + return $this->assertElementContains('[notification]', $text); + } + /** * Get the escaped text pattern for the constraint. - * @param string $text * @return string */ - protected function getEscapedPattern($text) + protected function getEscapedPattern(string $text) { $rawPattern = preg_quote($text, '/'); $escapedPattern = preg_quote(e($text), '/'); diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index a7efe08ab..4614c8e22 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -1,5 +1,7 @@ $fileName ]); - $this->call('DELETE', $page->getUrl()); + app(PageRepo::class)->destroy($page); + app(TrashCan::class)->empty(); $this->assertDatabaseMissing('attachments', [ 'name' => $fileName From 68b1d87ebecd4f60a2d7b4c3dff481e9f905bb9a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 7 Nov 2020 13:19:23 +0000 Subject: [PATCH 07/10] Added test coverage of recycle bin actions --- tests/RecycleBinTest.php | 93 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 91 insertions(+), 2 deletions(-) diff --git a/tests/RecycleBinTest.php b/tests/RecycleBinTest.php index 086f63679..6a10f271b 100644 --- a/tests/RecycleBinTest.php +++ b/tests/RecycleBinTest.php @@ -6,8 +6,6 @@ use BookStack\Entities\Page; class RecycleBinTest extends TestCase { - // TODO - Test activity updating on destroy - public function test_recycle_bin_routes_permissions() { $page = Page::query()->first(); @@ -65,4 +63,95 @@ class RecycleBinTest extends TestCase $viewReq->assertElementContains('table.table', $book->pages_count . ' Pages'); $viewReq->assertElementContains('table.table', $book->chapters_count . ' Chapters'); } + + public function test_recycle_bin_empty() + { + $page = Page::query()->first(); + $book = Book::query()->where('id' , '!=', $page->book_id)->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail(); + $editor = $this->getEditor(); + $this->actingAs($editor)->delete($page->getUrl()); + $this->actingAs($editor)->delete($book->getUrl()); + + $this->assertTrue(Deletion::query()->count() === 2); + $emptyReq = $this->asAdmin()->post('/settings/recycle-bin/empty'); + $emptyReq->assertRedirect('/settings/recycle-bin'); + + $this->assertTrue(Deletion::query()->count() === 0); + $this->assertDatabaseMissing('books', ['id' => $book->id]); + $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + + $itemCount = 2 + $book->pages->count() + $book->chapters->count(); + $redirectReq = $this->get('/settings/recycle-bin'); + $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin'); + } + + public function test_entity_restore() + { + $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail(); + $this->asEditor()->delete($book->getUrl()); + $deletion = Deletion::query()->firstOrFail(); + + $this->assertEquals($book->pages->count(), \DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), \DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + + $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore"); + $restoreReq->assertRedirect('/settings/recycle-bin'); + $this->assertTrue(Deletion::query()->count() === 0); + + $this->assertEquals($book->pages->count(), \DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), \DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + + $itemCount = 1 + $book->pages->count() + $book->chapters->count(); + $redirectReq = $this->get('/settings/recycle-bin'); + $redirectReq->assertNotificationContains('Restored '.$itemCount.' total items from the recycle bin'); + } + + public function test_permanent_delete() + { + $book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail(); + $this->asEditor()->delete($book->getUrl()); + $deletion = Deletion::query()->firstOrFail(); + + $deleteReq = $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}"); + $deleteReq->assertRedirect('/settings/recycle-bin'); + $this->assertTrue(Deletion::query()->count() === 0); + + $this->assertDatabaseMissing('books', ['id' => $book->id]); + $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + + $itemCount = 1 + $book->pages->count() + $book->chapters->count(); + $redirectReq = $this->get('/settings/recycle-bin'); + $redirectReq->assertNotificationContains('Deleted '.$itemCount.' total items from the recycle bin'); + } + + public function test_permanent_entity_delete_updates_existing_activity_with_entity_name() + { + $page = Page::query()->firstOrFail(); + $this->asEditor()->delete($page->getUrl()); + $deletion = $page->deletions()->firstOrFail(); + + $this->assertDatabaseHas('activities', [ + 'key' => 'page_delete', + 'entity_id' => $page->id, + 'entity_type' => $page->getMorphClass(), + ]); + + $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}"); + + $this->assertDatabaseMissing('activities', [ + 'key' => 'page_delete', + 'entity_id' => $page->id, + 'entity_type' => $page->getMorphClass(), + ]); + + $this->assertDatabaseHas('activities', [ + 'key' => 'page_delete', + 'entity_id' => 0, + 'entity_type' => '', + 'extra' => $page->name, + ]); + } } \ No newline at end of file From ec3aeb3315db201251e48b9d3713b022e7d88188 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 7 Nov 2020 13:58:23 +0000 Subject: [PATCH 08/10] Added recycle bin auto-clear lifetime functionality --- .env.example.complete | 8 +++ app/Config/app.php | 7 +++ app/Entities/Managers/TrashCan.php | 25 ++++++++ app/Entities/Repos/BookRepo.php | 1 + app/Entities/Repos/BookshelfRepo.php | 1 + app/Entities/Repos/ChapterRepo.php | 1 + app/Entities/Repos/PageRepo.php | 1 + resources/lang/en/settings.php | 2 +- .../views/settings/maintenance.blade.php | 38 ++++++------- tests/RecycleBinTest.php | 57 +++++++++++++++++-- 10 files changed, 115 insertions(+), 26 deletions(-) diff --git a/.env.example.complete b/.env.example.complete index 39e7b4360..0e62b3ea6 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -255,6 +255,14 @@ APP_VIEWS_BOOKSHELVES=grid # If set to 'false' a limit will not be enforced. REVISION_LIMIT=50 +# Recycle Bin Lifetime +# The number of days that content will remain in the recycle bin before +# being considered for auto-removal. It is not a guarantee that content will +# be removed after this time. +# Set to 0 for no recycle bin functionality. +# Set to -1 for unlimited recycle bin lifetime. +RECYCLE_BIN_LIFETIME=30 + # Allow