BookStackApp/BookStack

View on GitHub
app/Entities/Tools/TrashCan.php

Summary

Maintainability
A
3 hrs
Test Coverage
<?php

namespace BookStack\Entities\Tools;

use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Deletion;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Models\HasCoverImage;
use BookStack\Entities\Models\Page;
use BookStack\Entities\Queries\EntityQueries;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Carbon;

class TrashCan
{
    public function __construct(
        protected EntityQueries $queries,
    ) {
    }

    /**
     * Send a shelf to the recycle bin.
     *
     * @throws NotifyException
     */
    public function softDestroyShelf(Bookshelf $shelf)
    {
        $this->ensureDeletable($shelf);
        Deletion::createForEntity($shelf);
        $shelf->delete();
    }

    /**
     * Send a book to the recycle bin.
     *
     * @throws Exception
     */
    public function softDestroyBook(Book $book)
    {
        $this->ensureDeletable($book);
        Deletion::createForEntity($book);

        foreach ($book->pages as $page) {
            $this->softDestroyPage($page, false);
        }

        foreach ($book->chapters as $chapter) {
            $this->softDestroyChapter($chapter, false);
        }

        $book->delete();
    }

    /**
     * Send a chapter to the recycle bin.
     *
     * @throws Exception
     */
    public function softDestroyChapter(Chapter $chapter, bool $recordDelete = true)
    {
        if ($recordDelete) {
            $this->ensureDeletable($chapter);
            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) {
            $this->ensureDeletable($page);
            Deletion::createForEntity($page);
        }

        $page->delete();
    }

    /**
     * Ensure the given entity is deletable.
     * Is not for permissions, but logical conditions within the application.
     * Will throw if not deletable.
     *
     * @throws NotifyException
     */
    protected function ensureDeletable(Entity $entity): void
    {
        $customHomeId = intval(explode(':', setting('app-homepage', '0:'))[0]);
        $customHomeActive = setting('app-homepage-type') === 'page';
        $removeCustomHome = false;

        // Check custom homepage usage for pages
        if ($entity instanceof Page && $entity->id === $customHomeId) {
            if ($customHomeActive) {
                throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
            }
            $removeCustomHome = true;
        }

        // Check custom homepage usage within chapters or books
        if ($entity instanceof Chapter || $entity instanceof Book) {
            if ($entity->pages()->where('id', '=', $customHomeId)->exists()) {
                if ($customHomeActive) {
                    throw new NotifyException(trans('errors.page_custom_home_deletion'), $entity->getUrl());
                }
                $removeCustomHome = true;
            }
        }

        if ($removeCustomHome) {
            setting()->remove('app-homepage');
        }
    }

    /**
     * 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();
        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);
        $page->allRevisions()->delete();

        // Delete Attached Files
        $attachmentService = app()->make(AttachmentService::class);
        foreach ($page->attachments as $attachment) {
            $attachmentService->deleteFile($attachment);
        }

        // Remove book template usages
        $this->queries->books->start()
            ->where('default_template_id', '=', $page->id)
            ->update(['default_template_id' => null]);

        // Remove chapter template usages
        $this->queries->chapters->start()
            ->where('default_template_id', '=', $page->id)
            ->update(['default_template_id' => null]);

        $page->forceDelete();

        return 1;
    }

    /**
     * Get the total counts of those that have been trashed
     * but not yet fully deleted (In recycle bin).
     */
    public function getTrashedCounts(): array
    {
        $counts = [];

        foreach ((new EntityProvider())->all() as $key => $instance) {
            /** @var Builder<Entity> $query */
            $query = $instance->newQuery();
            $counts[$key] = $query->onlyTrashed()->count();
        }

        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;

        if ($deletion->deletable instanceof Entity) {
            $parent = $deletion->deletable->getParent();
            if ($parent && $parent->trashed()) {
                $shouldRestore = false;
            }
        }

        if ($deletion->deletable instanceof Entity && $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 instanceof Chapter || $entity instanceof Book) {
            $entity->pages()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
        }

        if ($entity instanceof Book) {
            $entity->chapters()->withTrashed()->withCount('deletions')->get()->each($restoreAction);
        }

        return $count;
    }

    /**
     * Destroy the given entity.
     *
     * @throws Exception
     */
    public function destroyEntity(Entity $entity): int
    {
        if ($entity instanceof Page) {
            return $this->destroyPage($entity);
        }
        if ($entity instanceof Chapter) {
            return $this->destroyChapter($entity);
        }
        if ($entity instanceof Book) {
            return $this->destroyBook($entity);
        }
        if ($entity instanceof Bookshelf) {
            return $this->destroyShelf($entity);
        }

        return 0;
    }

    /**
     * Update entity relations to remove or update outstanding connections.
     */
    protected function destroyCommonRelations(Entity $entity)
    {
        Activity::removeEntity($entity);
        $entity->views()->delete();
        $entity->permissions()->delete();
        $entity->tags()->delete();
        $entity->comments()->delete();
        $entity->jointPermissions()->delete();
        $entity->searchTerms()->delete();
        $entity->deletions()->delete();
        $entity->favourites()->delete();
        $entity->watches()->delete();
        $entity->referencesTo()->delete();
        $entity->referencesFrom()->delete();

        if ($entity instanceof HasCoverImage && $entity->cover()->exists()) {
            $imageService = app()->make(ImageService::class);
            $imageService->destroy($entity->cover()->first());
        }
    }
}