Merge pull request #2283 from BookStackApp/recycle_bin

Recycle Bin Implementation
This commit is contained in:
Dan Brown 2020-11-07 15:10:17 +00:00 committed by GitHub
commit 4824ef2760
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
48 changed files with 1226 additions and 185 deletions

View File

@ -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 <script> tags in page content
# Note, if set to 'true' the page editor may still escape scripts.
ALLOW_CONTENT_SCRIPTS=false

View File

@ -3,6 +3,7 @@
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Entity;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
@ -104,7 +105,9 @@ class ActivityService
$activity = $this->permissionService
->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
->with(['entity', 'user.avatar'])
->with(['entity' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
->take($count)
->get();

View File

@ -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);
}
/**

View File

@ -48,11 +48,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,
@ -173,7 +168,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);
});
@ -185,11 +180,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']);
}]);
}

View File

@ -31,6 +31,13 @@ return [
// If set to false then a limit will not be enforced.
'revision_limit' => env('REVISION_LIMIT', 50),
// 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' => env('RECYCLE_BIN_LIFETIME', 30),
// Allow <script> tags to entered within page content.
// <script> tags are escaped by default.
// Even when overridden the WYSIWYG editor may still escape script content.

41
app/Entities/Deletion.php Normal file
View File

@ -0,0 +1,41 @@
<?php namespace BookStack\Entities;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphTo;
class Deletion extends Model
{
/**
* Get the related deletable record.
*/
public function deletable(): MorphTo
{
return $this->morphTo('deletable')->withTrashed();
}
/**
* The the user that performed the deletion.
*/
public function deleter(): BelongsTo
{
return $this->belongsTo(User::class, 'deleted_by');
}
/**
* Create a new deletion record for the provided entity.
*/
public static function createForEntity(Entity $entity): Deletion
{
$record = (new self())->forceFill([
'deleted_by' => user()->id,
'deletable_type' => $entity->getMorphClass(),
'deletable_id' => $entity->id,
]);
$record->save();
return $record;
}
}

View File

@ -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 deletions(): MorphMany
{
return $this->morphMany(Deletion::class, 'deletable');
}
/**
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'
@ -278,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.
*/

View File

@ -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
{

View File

@ -3,7 +3,9 @@
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Deletion;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\HasCoverImage;
use BookStack\Entities\Page;
use BookStack\Exceptions\NotifyException;
@ -11,46 +13,68 @@ use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Support\Carbon;
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);
Deletion::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)
{
Deletion::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) {
Deletion::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) {
Deletion::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 +84,72 @@ class TrashCan
setting()->remove('app-homepage');
}
$page->delete();
}
/**
* Remove a bookshelf from the system.
* @throws Exception
*/
protected function destroyShelf(Bookshelf $shelf): int
{
$this->destroyCommonRelations($shelf);
$shelf->forceDelete();
return 1;
}
/**
* Remove a book from the system.
* Destroys any child chapters and pages.
* @throws Exception
*/
protected 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;
}
/**
* Remove a chapter from the system.
* Destroys all pages within.
* @throws Exception
*/
protected 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
*/
protected function destroyPage(Page $page): int
{
$this->destroyCommonRelations($page);
// Delete Attached Files
@ -68,24 +158,150 @@ class TrashCan
$attachmentService->deleteFile($attachment);
}
$page->delete();
$page->forceDelete();
return 1;
}
/**
* Remove a chapter from the system.
* @throws Exception
* Get the total counts of those that have been trashed
* but not yet fully deleted (In recycle bin).
*/
public function destroyChapter(Chapter $chapter)
public function getTrashedCounts(): array
{
if (count($chapter->pages) > 0) {
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
}
$provider = app(EntityProvider::class);
$counts = [];
/** @var Entity $instance */
foreach ($provider->all() as $key => $instance) {
$counts[$key] = $instance->newQuery()->onlyTrashed()->count();
}
$this->destroyCommonRelations($chapter);
$chapter->delete();
return $counts;
}
/**
* Destroy all items that have pending deletions.
* @throws Exception
*/
public function empty(): int
{
$deletions = Deletion::all();
$deleteCount = 0;
foreach ($deletions as $deletion) {
$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;
}
/**
* Automatically clear old content from the recycle bin
* depending on the configured lifetime.
* Returns the total number of deleted elements.
* @throws Exception
*/
public function autoClearOld(): int
{
$lifetime = intval(config('app.recycle_bin_lifetime'));
if ($lifetime < 0) {
return 0;
}
$clearBeforeDate = Carbon::now()->addSeconds(10)->subDays($lifetime);
$deleteCount = 0;
$deletionsToRemove = Deletion::query()->where('created_at', '<', $clearBeforeDate)->get();
foreach ($deletionsToRemove as $deletion) {
$deleteCount += $this->destroyFromDeletion($deletion);
}
return $deleteCount;
}
/**
* 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();
$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')) {
$entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
}
return $count;
}
/**
* Destroy the given entity.
*/
protected function destroyEntity(Entity $entity): int
{
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);
}
}
/**
@ -100,6 +316,7 @@ class TrashCan
$entity->comments()->delete();
$entity->jointPermissions()->delete();
$entity->searchTerms()->delete();
$entity->deletions()->delete();
if ($entity instanceof HasCoverImage && $entity->cover) {
$imageService = app()->make(ImageService::class);

View File

@ -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

View File

@ -123,12 +123,12 @@ 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);
$trashCan->autoClearOld();
}
}

View File

@ -174,6 +174,7 @@ class BookshelfRepo
public function destroy(Bookshelf $shelf)
{
$trashCan = new TrashCan();
$trashCan->destroyShelf($shelf);
$trashCan->softDestroyShelf($shelf);
$trashCan->autoClearOld();
}
}

View File

@ -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,8 @@ class ChapterRepo
public function destroy(Chapter $chapter)
{
$trashCan = new TrashCan();
$trashCan->destroyChapter($chapter);
$trashCan->softDestroyChapter($chapter);
$trashCan->autoClearOld();
}
/**

View File

@ -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,13 @@ 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);
$trashCan->autoClearOld();
}
/**
@ -320,7 +322,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');
}
@ -439,8 +441,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;
}

View File

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

View File

@ -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']) {

View File

@ -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');

View File

@ -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');

View File

@ -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());

View File

@ -14,7 +14,6 @@ class HomeController extends Controller
/**
* Display the homepage.
* @return Response
*/
public function index()
{
@ -22,14 +21,17 @@ 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;
$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();

View File

@ -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,
]);
}
/**

View File

@ -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);
@ -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());
}

View File

@ -0,0 +1,103 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\Deletion;
use BookStack\Entities\Managers\TrashCan;
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()
{
$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()
{
$deletions = Deletion::query()->with(['deletable', 'deleter'])->paginate(10);
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()
{
$deleteCount = (new TrashCan())->empty();
$this->showSuccessNotification(trans('settings.recycle_bin_destroy_notification', ['count' => $deleteCount]));
return redirect($this->recycleBinBaseUrl);
}
}

View File

@ -0,0 +1,50 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddEntitySoftDeletes extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('bookshelves', function(Blueprint $table) {
$table->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();
});
}
}

View File

@ -0,0 +1,38 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class CreateDeletionsTable extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::create('deletions', function (Blueprint $table) {
$table->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('deletions');
}
}

View File

@ -80,6 +80,27 @@ 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' => 'Deleted shelves, books, chapters & pages are sent to the recycle bin so they can be restored or permanently deleted. Older items in the recycle bin may be automatically removed after a while depending on system configuration.',
'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_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_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',

View File

@ -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 {

View File

@ -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;

View File

@ -16,10 +16,14 @@
{{ $activity->getText() }}
@if($activity->entity)
@if($activity->entity && is_null($activity->entity->deleted_at))
<a href="{{ $activity->entity->getUrl() }}">{{ $activity->entity->name }}</a>
@endif
@if($activity->entity && !is_null($activity->entity->deleted_at))
"{{ $activity->entity->name }}"
@endif
@if($activity->extra) "{{ $activity->extra }}" @endif
<br>

View File

@ -0,0 +1,7 @@
<?php $type = $entity->getType(); ?>
<div class="{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}} {{$classes ?? ''}} entity-list-item no-hover">
<span role="presentation" class="icon text-{{$type}} {{$type === 'page' && $entity->draft ? 'draft' : ''}}">@icon($type)</span>
<div class="content">
<div class="entity-list-item-name break-text">{{ $entity->name }}</div>
</div>
</div>

View File

@ -0,0 +1,12 @@
{{--
$user - User mode to display, Can be null.
$user_id - Id of user to show. Must be provided.
--}}
@if($user)
<a href="{{ $user->getEditUrl() }}" class="table-user-item">
<div><img class="avatar block" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></div>
<div>{{ $user->name }}</div>
</a>
@else
[ID: {{ $user_id }}] {{ trans('common.deleted_user') }}
@endif

View File

@ -60,19 +60,12 @@
@foreach($activities as $activity)
<tr>
<td>
@if($activity->user)
<a href="{{ $activity->user->getEditUrl() }}" class="audit-log-user">
<div><img class="avatar block" src="{{ $activity->user->getAvatar(40)}}" alt="{{ $activity->user->name }}"></div>
<div>{{ $activity->user->name }}</div>
</a>
@else
[ID: {{ $activity->user_id }}] {{ trans('common.deleted_user') }}
@endif
@include('partials.table-user', ['user' => $activity->user, 'user_id' => $activity->user_id])
</td>
<td>{{ $activity->key }}</td>
<td>
@if($activity->entity)
<a href="{{ $activity->entity->getUrl() }}" class="icon-list-item">
<a href="{{ $activity->entity->getUrl() }}" class="table-entity-item">
<span role="presentation" class="icon text-{{$activity->entity->getType()}}">@icon($activity->entity->getType())</span>
<div class="text-{{ $activity->entity->getType() }}">
{{ $activity->entity->name }}

View File

@ -5,6 +5,24 @@
@include('settings.navbar-with-version', ['selected' => 'maintenance'])
<div class="card content-wrap auto-height pb-xl">
<h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
<div class="grid half gap-xl">
<div>
<p class="small text-muted">{{ trans('settings.maint_recycle_bin_desc') }}</p>
</div>
<div>
<div class="grid half no-gap mb-m">
<p class="mb-xs text-bookshelf">@icon('bookshelf'){{ trans('entities.shelves') }}: {{ $recycleStats['bookshelf'] }}</p>
<p class="mb-xs text-book">@icon('book'){{ trans('entities.books') }}: {{ $recycleStats['book'] }}</p>
<p class="mb-xs text-chapter">@icon('chapter'){{ trans('entities.chapters') }}: {{ $recycleStats['chapter'] }}</p>
<p class="mb-xs text-page">@icon('page'){{ trans('entities.pages') }}: {{ $recycleStats['page'] }}</p>
</div>
<a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('settings.maint_recycle_bin_open') }}</a>
</div>
</div>
</div>
<div id="image-cleanup" class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.maint_image_cleanup') }}</h2>
<div class="grid half gap-xl">
@ -15,7 +33,7 @@
<form method="POST" action="{{ url('/settings/maintenance/cleanup-images') }}">
{!! csrf_field() !!}
<input type="hidden" name="_method" value="DELETE">
<div>
<div class="mb-s">
@if(session()->has('cleanup-images-warning'))
<p class="text-neg">
{{ session()->get('cleanup-images-warning') }}

View File

@ -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

View File

@ -0,0 +1,31 @@
@extends('simple-layout')
@section('body')
<div class="container small">
<div class="grid left-focus v-center no-row-gap">
<div class="py-m">
@include('settings.navbar', ['selected' => 'maintenance'])
</div>
</div>
<div class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.recycle_bin_permanently_delete') }}</h2>
<p class="text-muted">{{ trans('settings.recycle_bin_destroy_confirm') }}</p>
<form action="{{ url('/settings/recycle-bin/' . $deletion->id) }}" method="post">
{!! method_field('DELETE') !!}
{!! csrf_field() !!}
<a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('common.delete_confirm') }}</button>
</form>
@if($deletion->deletable instanceof \BookStack\Entities\Entity)
<hr class="mt-m">
<h5>{{ trans('settings.recycle_bin_destroy_list') }}</h5>
@include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
@endif
</div>
</div>
@stop

View File

@ -0,0 +1,103 @@
@extends('simple-layout')
@section('body')
<div class="container">
<div class="grid left-focus v-center no-row-gap">
<div class="py-m">
@include('settings.navbar', ['selected' => 'maintenance'])
</div>
</div>
<div class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.recycle_bin') }}</h2>
<div class="grid half left-focus">
<div>
<p class="text-muted">{{ trans('settings.recycle_bin_desc') }}</p>
</div>
<div class="text-right">
<div component="dropdown" class="dropdown-container">
<button refs="dropdown@toggle"
type="button"
class="button outline">{{ trans('settings.recycle_bin_empty') }} </button>
<div refs="dropdown@menu" class="dropdown-menu">
<p class="text-neg small px-m mb-xs">{{ trans('settings.recycle_bin_empty_confirm') }}</p>
<form action="{{ url('/settings/recycle-bin/empty') }}" method="POST">
{!! csrf_field() !!}
<button type="submit" class="text-primary small delete">{{ trans('common.confirm') }}</button>
</form>
</div>
</div>
</div>
</div>
<hr class="mt-l mb-s">
{!! $deletions->links() !!}
<table class="table">
<tr>
<th>{{ trans('settings.recycle_bin_deleted_item') }}</th>
<th>{{ trans('settings.recycle_bin_deleted_by') }}</th>
<th>{{ trans('settings.recycle_bin_deleted_at') }}</th>
<th></th>
</tr>
@if(count($deletions) === 0)
<tr>
<td colspan="4">
<p class="text-muted"><em>{{ trans('settings.recycle_bin_contents_empty') }}</em></p>
</td>
</tr>
@endif
@foreach($deletions as $deletion)
<tr>
<td>
<div class="table-entity-item">
<span role="presentation" class="icon text-{{$deletion->deletable->getType()}}">@icon($deletion->deletable->getType())</span>
<div class="text-{{ $deletion->deletable->getType() }}">
{{ $deletion->deletable->name }}
</div>
</div>
@if($deletion->deletable instanceof \BookStack\Entities\Book || $deletion->deletable instanceof \BookStack\Entities\Chapter)
<div class="mb-m"></div>
@endif
@if($deletion->deletable instanceof \BookStack\Entities\Book)
<div class="pl-xl block inline">
<div class="text-chapter">
@icon('chapter') {{ trans_choice('entities.x_chapters', $deletion->deletable->chapters()->withTrashed()->count()) }}
</div>
</div>
@endif
@if($deletion->deletable instanceof \BookStack\Entities\Book || $deletion->deletable instanceof \BookStack\Entities\Chapter)
<div class="pl-xl block inline">
<div class="text-page">
@icon('page') {{ trans_choice('entities.x_pages', $deletion->deletable->pages()->withTrashed()->count()) }}
</div>
</div>
@endif
</td>
<td>@include('partials.table-user', ['user' => $deletion->deleter, 'user_id' => $deletion->deleted_by])</td>
<td width="200">{{ $deletion->created_at }}</td>
<td width="150" class="text-right">
<div component="dropdown" class="dropdown-container">
<button type="button" refs="dropdown@toggle" class="button outline">{{ trans('common.actions') }}</button>
<ul refs="dropdown@menu" class="dropdown-menu">
<li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/restore') }}">{{ trans('settings.recycle_bin_restore') }}</a></li>
<li><a class="block" href="{{ url('/settings/recycle-bin/'.$deletion->id.'/destroy') }}">{{ trans('settings.recycle_bin_permanently_delete') }}</a></li>
</ul>
</div>
</td>
</tr>
@endforeach
</table>
{!! $deletions->links() !!}
</div>
</div>
@stop

View File

@ -0,0 +1,33 @@
@extends('simple-layout')
@section('body')
<div class="container small">
<div class="grid left-focus v-center no-row-gap">
<div class="py-m">
@include('settings.navbar', ['selected' => 'maintenance'])
</div>
</div>
<div class="card content-wrap auto-height">
<h2 class="list-heading">{{ trans('settings.recycle_bin_restore') }}</h2>
<p class="text-muted">{{ trans('settings.recycle_bin_restore_confirm') }}</p>
<form action="{{ url('/settings/recycle-bin/' . $deletion->id . '/restore') }}" method="post">
{!! csrf_field() !!}
<a href="{{ url('/settings/recycle-bin') }}" class="button outline">{{ trans('common.cancel') }}</a>
<button type="submit" class="button">{{ trans('settings.recycle_bin_restore') }}</button>
</form>
@if($deletion->deletable instanceof \BookStack\Entities\Entity)
<hr class="mt-m">
<h5>{{ trans('settings.recycle_bin_restore_list') }}</h5>
@if($deletion->deletable->getParent() && $deletion->deletable->getParent()->trashed())
<p class="text-neg">{{ trans('settings.recycle_bin_restore_deleted_parent') }}</p>
@endif
@include('settings.recycle-bin.deletable-entity-list', ['entity' => $deletion->deletable])
@endif
</div>
</div>
@stop

View File

@ -166,6 +166,14 @@ 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');
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');

View File

@ -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');

View File

@ -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()

34
tests/Entity/BookTest.php Normal file
View File

@ -0,0 +1,34 @@
<?php namespace Tests\Entity;
use BookStack\Entities\Book;
use Tests\TestCase;
class BookTest extends TestCase
{
public function test_book_delete()
{
$book = Book::query()->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');
}
}

View File

@ -0,0 +1,31 @@
<?php namespace Tests\Entity;
use BookStack\Entities\Chapter;
use Tests\TestCase;
class ChapterTest extends TestCase
{
public function test_chapter_delete()
{
$chapter = Chapter::query()->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');
}
}

View File

@ -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' => '<p>updated content</p>',
]);
$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' => '',
]);
}
}

27
tests/Entity/PageTest.php Normal file
View File

@ -0,0 +1,27 @@
<?php namespace Tests\Entity;
use BookStack\Entities\Page;
use Tests\TestCase;
class PageTest extends TestCase
{
public function test_page_delete()
{
$page = Page::query()->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');
}
}

232
tests/RecycleBinTest.php Normal file
View File

@ -0,0 +1,232 @@
<?php namespace Tests;
use BookStack\Entities\Book;
use BookStack\Entities\Deletion;
use BookStack\Entities\Page;
use DB;
use Illuminate\Support\Carbon;
class RecycleBinTest extends TestCase
{
public function test_recycle_bin_routes_permissions()
{
$page = Page::query()->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');
}
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,
]);
}
public function test_auto_clear_functionality_works()
{
config()->set('app.recycle_bin_lifetime', 5);
$page = Page::query()->firstOrFail();
$otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
$this->asEditor()->delete($page->getUrl());
$this->assertDatabaseHas('pages', ['id' => $page->id]);
$this->assertEquals(1, Deletion::query()->count());
Carbon::setTestNow(Carbon::now()->addDays(6));
$this->asEditor()->delete($otherPage->getUrl());
$this->assertEquals(1, Deletion::query()->count());
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
}
public function test_auto_clear_functionality_with_negative_time_keeps_forever()
{
config()->set('app.recycle_bin_lifetime', -1);
$page = Page::query()->firstOrFail();
$otherPage = Page::query()->where('id', '!=', $page->id)->firstOrFail();
$this->asEditor()->delete($page->getUrl());
$this->assertEquals(1, Deletion::query()->count());
Carbon::setTestNow(Carbon::now()->addDays(6000));
$this->asEditor()->delete($otherPage->getUrl());
$this->assertEquals(2, Deletion::query()->count());
$this->assertDatabaseHas('pages', ['id' => $page->id]);
}
public function test_auto_clear_functionality_with_zero_time_deletes_instantly()
{
config()->set('app.recycle_bin_lifetime', 0);
$page = Page::query()->firstOrFail();
$this->asEditor()->delete($page->getUrl());
$this->assertDatabaseMissing('pages', ['id' => $page->id]);
$this->assertEquals(0, Deletion::query()->count());
}
public function test_restore_flow_when_restoring_nested_delete_first()
{
$book = Book::query()->whereHas('pages')->whereHas('chapters')->with(['pages', 'chapters'])->firstOrFail();
$chapter = $book->chapters->first();
$this->asEditor()->delete($chapter->getUrl());
$this->asEditor()->delete($book->getUrl());
$bookDeletion = $book->deletions()->first();
$chapterDeletion = $chapter->deletions()->first();
$chapterRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$chapterDeletion->id}/restore");
$chapterRestoreView->assertStatus(200);
$chapterRestoreView->assertSeeText($chapter->name);
$chapterRestore = $this->post("/settings/recycle-bin/{$chapterDeletion->id}/restore");
$chapterRestore->assertRedirect("/settings/recycle-bin");
$this->assertDatabaseMissing("deletions", ["id" => $chapterDeletion->id]);
$chapter->refresh();
$this->assertNotNull($chapter->deleted_at);
$bookRestoreView = $this->asAdmin()->get("/settings/recycle-bin/{$bookDeletion->id}/restore");
$bookRestoreView->assertStatus(200);
$bookRestoreView->assertSeeText($chapter->name);
$this->post("/settings/recycle-bin/{$bookDeletion->id}/restore");
$chapter->refresh();
$this->assertNull($chapter->deleted_at);
}
}

View File

@ -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;
}
/**

View File

@ -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), '/');

View File

@ -1,5 +1,7 @@
<?php namespace Tests\Uploads;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Uploads\Attachment;
use BookStack\Entities\Page;
use BookStack\Auth\Permissions\PermissionService;
@ -213,7 +215,8 @@ class AttachmentTest extends TestCase
'name' => $fileName
]);
$this->call('DELETE', $page->getUrl());
app(PageRepo::class)->destroy($page);
app(TrashCan::class)->empty();
$this->assertDatabaseMissing('attachments', [
'name' => $fileName