From 31f5786e01fc5ed439f347c6979679612baca4fb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 5 Oct 2019 12:55:01 +0100 Subject: [PATCH] Entity Repo & Controller Refactor (#1690) * Started mass-refactoring of the current entity repos * Rewrote book tree logic - Now does two simple queries instead of one really complex one. - Extracted logic into its own class. - Remove model-level akward union field listing. - Logic now more readable than being large separate query and compilation functions. * Extracted and split book sort logic * Finished up Book controller/repo organisation * Refactored bookshelves controllers and repo parts * Fixed issues found via phpunit * Refactored Chapter controller * Updated Chapter export controller * Started Page controller/repo refactor * Refactored another chunk of PageController * Completed initial pagecontroller refactor pass * Fixed tests and continued reduction of old repos * Removed old page remove and further reduced entity repo * Removed old entity repo, split out page controller * Ran phpcbf and split out some page content methods * Tidied up some EntityProvider elements * Fixed issued caused by viewservice change --- app/Actions/ActivityService.php | 5 +- app/Actions/ViewService.php | 5 +- app/Auth/Permissions/PermissionService.php | 64 +- app/Auth/Role.php | 15 +- app/Auth/User.php | 13 +- app/Auth/UserRepo.php | 68 +- app/Entities/Book.php | 57 +- app/Entities/BookChild.php | 41 +- app/Entities/Bookshelf.php | 53 +- app/Entities/BreadcrumbsViewComposer.php | 5 +- app/Entities/Chapter.php | 36 +- app/Entities/Entity.php | 84 +- app/Entities/EntityProvider.php | 20 +- app/Entities/ExportService.php | 91 +- app/Entities/HasCoverImage.php | 20 + app/Entities/Managers/BookContents.php | 204 +++++ .../EntityContext.php} | 32 +- app/Entities/Managers/PageContent.php | 304 +++++++ app/Entities/Managers/PageEditActivity.php | 74 ++ app/Entities/Managers/TrashCan.php | 109 +++ app/Entities/Page.php | 54 +- app/Entities/PageRevision.php | 25 +- app/Entities/Repos/BaseRepo.php | 118 +++ app/Entities/Repos/BookRepo.php | 142 ++- app/Entities/Repos/BookshelfRepo.php | 173 ++++ app/Entities/Repos/ChapterRepo.php | 108 +++ app/Entities/Repos/EntityRepo.php | 843 ------------------ app/Entities/Repos/PageRepo.php | 715 +++++++-------- app/Entities/SlugGenerator.php | 4 +- app/Exceptions/MoveOperationException.php | 8 + app/Exceptions/SortOperationException.php | 8 + app/Http/Controllers/AttachmentController.php | 73 +- .../Auth/ConfirmEmailController.php | 10 +- .../Auth/ForgotPasswordController.php | 2 +- .../Auth/ResetPasswordController.php | 2 +- .../Controllers/Auth/UserInviteController.php | 4 +- app/Http/Controllers/BookController.php | 251 +----- app/Http/Controllers/BookExportController.php | 20 +- app/Http/Controllers/BookSortController.php | 82 ++ app/Http/Controllers/BookshelfController.php | 177 ++-- app/Http/Controllers/ChapterController.php | 169 ++-- .../Controllers/ChapterExportController.php | 38 +- app/Http/Controllers/CommentController.php | 33 +- app/Http/Controllers/Controller.php | 12 +- app/Http/Controllers/HomeController.php | 46 +- .../Controllers/Images/ImageController.php | 19 +- app/Http/Controllers/PageController.php | 496 ++++------- app/Http/Controllers/PageExportController.php | 29 +- .../Controllers/PageRevisionController.php | 128 +++ .../Controllers/PageTemplateController.php | 13 +- app/Http/Controllers/PermissionController.php | 8 +- app/Http/Controllers/SearchController.php | 49 +- app/Http/Controllers/SettingController.php | 6 +- app/Http/Controllers/UserController.php | 10 +- app/Http/Middleware/GlobalViewData.php | 3 +- resources/views/books/export.blade.php | 2 +- resources/views/errors/404.blade.php | 6 +- .../views/form/entity-permissions.blade.php | 2 +- resources/views/shelves/form.blade.php | 6 +- resources/views/shelves/show.blade.php | 4 +- routes/web.php | 20 +- tests/CommandsTest.php | 3 +- tests/Entity/EntityTest.php | 11 +- tests/Entity/PageContentTest.php | 65 +- tests/Entity/PageDraftTest.php | 11 +- tests/Entity/PageRevisionTest.php | 8 +- tests/Entity/SortTest.php | 4 +- tests/Permissions/RestrictionsTest.php | 15 +- tests/SharedTestHelpers.php | 35 +- tests/Unit/PageRepoTest.php | 78 -- tests/Uploads/AttachmentTest.php | 4 +- tests/Uploads/ImageTest.php | 4 +- 72 files changed, 2705 insertions(+), 2751 deletions(-) create mode 100644 app/Entities/HasCoverImage.php create mode 100644 app/Entities/Managers/BookContents.php rename app/Entities/{EntityContextManager.php => Managers/EntityContext.php} (53%) create mode 100644 app/Entities/Managers/PageContent.php create mode 100644 app/Entities/Managers/PageEditActivity.php create mode 100644 app/Entities/Managers/TrashCan.php create mode 100644 app/Entities/Repos/BaseRepo.php create mode 100644 app/Entities/Repos/BookshelfRepo.php create mode 100644 app/Entities/Repos/ChapterRepo.php delete mode 100644 app/Entities/Repos/EntityRepo.php create mode 100644 app/Exceptions/MoveOperationException.php create mode 100644 app/Exceptions/SortOperationException.php create mode 100644 app/Http/Controllers/BookSortController.php create mode 100644 app/Http/Controllers/PageRevisionController.php delete mode 100644 tests/Unit/PageRepoTest.php diff --git a/app/Actions/ActivityService.php b/app/Actions/ActivityService.php index d092a35c2..f56f1ca57 100644 --- a/app/Actions/ActivityService.php +++ b/app/Actions/ActivityService.php @@ -1,6 +1,7 @@ views()->save($this->view->create([ + $entity->views()->save($this->view->newInstance([ 'user_id' => $user->id, 'views' => 1 ])); @@ -60,7 +61,7 @@ class ViewService * @param string $action - used for permission checking * @return Collection */ - public function getPopular(int $count = 10, int $page = 0, $filterModels = null, string $action = 'view') + public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view') { $skipCount = $count * $page; $query = $this->permissionService diff --git a/app/Auth/Permissions/PermissionService.php b/app/Auth/Permissions/PermissionService.php index 9e1876c90..97cc1ca24 100644 --- a/app/Auth/Permissions/PermissionService.php +++ b/app/Auth/Permissions/PermissionService.php @@ -633,42 +633,40 @@ class PermissionService } /** - * Get the children of a book in an efficient single query, Filtered by the permission system. - * @param integer $book_id - * @param bool $filterDrafts - * @param bool $fetchPageContent - * @return QueryBuilder + * Limited the given entity query so that the query will only + * return items that the user has permission for the given ability. */ - public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false) + public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder { - $entities = $this->entityProvider; - $pageSelect = $this->db->table('pages')->selectRaw($entities->page->entityRawQuery($fetchPageContent)) - ->where('book_id', '=', $book_id)->where(function ($query) use ($filterDrafts) { - $query->where('draft', '=', 0); - if (!$filterDrafts) { - $query->orWhere(function ($query) { - $query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id); - }); - } - }); - $chapterSelect = $this->db->table('chapters')->selectRaw($entities->chapter->entityRawQuery())->where('book_id', '=', $book_id); - $query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U")) - ->mergeBindings($pageSelect)->mergeBindings($chapterSelect); - - // Add joint permission filter - $whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)') - ->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type') - ->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles()) - ->where(function ($query) { - $query->where('jp.has_permission', '=', 1)->orWhere(function ($query) { - $query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id); - }); - }); - $query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery); - - $query->orderBy('draft', 'desc')->orderBy('priority', 'asc'); $this->clean(); - return $query; + return $query->where(function (Builder $parentQuery) use ($ability) { + $parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) { + $permissionQuery->whereIn('role_id', $this->getRoles()) + ->where('action', '=', $ability) + ->where(function (Builder $query) { + $query->where('has_permission', '=', true) + ->orWhere(function (Builder $query) { + $query->where('has_permission_own', '=', true) + ->where('created_by', '=', $this->currentUser()->id); + }); + }); + }); + }); + } + + /** + * Extend the given page query to ensure draft items are not visible + * unless created by the given user. + */ + public function enforceDraftVisiblityOnQuery(Builder $query): Builder + { + return $query->where(function (Builder $query) { + $query->where('draft', '=', false) + ->orWhere(function (Builder $query) { + $query->where('draft', '=', true) + ->where('created_by', '=', $this->currentUser()->id); + }); + }); } /** diff --git a/app/Auth/Role.php b/app/Auth/Role.php index 917d8aa26..712f5299b 100644 --- a/app/Auth/Role.php +++ b/app/Auth/Role.php @@ -75,7 +75,7 @@ class Role extends Model */ public static function getRole($roleName) { - return static::where('name', '=', $roleName)->first(); + return static::query()->where('name', '=', $roleName)->first(); } /** @@ -85,7 +85,7 @@ class Role extends Model */ public static function getSystemRole($roleName) { - return static::where('system_name', '=', $roleName)->first(); + return static::query()->where('system_name', '=', $roleName)->first(); } /** @@ -94,6 +94,15 @@ class Role extends Model */ public static function visible() { - return static::where('hidden', '=', false)->orderBy('name')->get(); + return static::query()->where('hidden', '=', false)->orderBy('name')->get(); + } + + /** + * Get the roles that can be restricted. + * @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection + */ + public static function restrictable() + { + return static::query()->where('system_name', '!=', 'admin')->get(); } } diff --git a/app/Auth/User.php b/app/Auth/User.php index 7ad14d9f0..bce418a74 100644 --- a/app/Auth/User.php +++ b/app/Auth/User.php @@ -53,13 +53,24 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon */ protected $permissions; + /** + * This holds the default user when loaded. + * @var null|User + */ + protected static $defaultUser = null; + /** * Returns the default public user. * @return User */ public static function getDefault() { - return static::where('system_name', '=', 'public')->first(); + if (!is_null(static::$defaultUser)) { + return static::$defaultUser; + } + + static::$defaultUser = static::where('system_name', '=', 'public')->first(); + return static::$defaultUser; } /** diff --git a/app/Auth/UserRepo.php b/app/Auth/UserRepo.php index dec973f6c..a903e2c38 100644 --- a/app/Auth/UserRepo.php +++ b/app/Auth/UserRepo.php @@ -1,32 +1,31 @@ user = $user; $this->role = $role; - $this->entityRepo = $entityRepo; } /** @@ -81,7 +80,7 @@ class UserRepo * Creates a new user and attaches a role to them. * @param array $data * @param boolean $verifyEmail - * @return \BookStack\Auth\User + * @return User */ public function registerNew(array $data, $verifyEmail = false) { @@ -121,7 +120,7 @@ class UserRepo /** * Checks if the give user is the only admin. - * @param \BookStack\Auth\User $user + * @param User $user * @return bool */ public function isOnlyAdmin(User $user) @@ -175,7 +174,7 @@ class UserRepo * Create a new basic instance of user. * @param array $data * @param boolean $verifyEmail - * @return \BookStack\Auth\User + * @return User */ public function create(array $data, $verifyEmail = false) { @@ -189,7 +188,7 @@ class UserRepo /** * Remove the given user from storage, Delete all related content. - * @param \BookStack\Auth\User $user + * @param User $user * @throws Exception */ public function destroy(User $user) @@ -206,7 +205,7 @@ class UserRepo /** * Get the latest activity for a user. - * @param \BookStack\Auth\User $user + * @param User $user * @param int $count * @param int $page * @return array @@ -218,36 +217,35 @@ class UserRepo /** * Get the recently created content for this given user. - * @param \BookStack\Auth\User $user - * @param int $count - * @return mixed */ - public function getRecentlyCreated(User $user, $count = 20) + public function getRecentlyCreated(User $user, int $count = 20): array { - $createdByUserQuery = function (Builder $query) use ($user) { - $query->where('created_by', '=', $user->id); + $query = function (Builder $query) use ($user, $count) { + return $query->orderBy('created_at', 'desc') + ->where('created_by', '=', $user->id) + ->take($count) + ->get(); }; return [ - 'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery), - 'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery), - 'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery), - 'shelves' => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery) + 'pages' => $query(Page::visible()->where('draft', '=', false)), + 'chapters' => $query(Chapter::visible()), + 'books' => $query(Book::visible()), + 'shelves' => $query(Bookshelf::visible()), ]; } /** * Get asset created counts for the give user. - * @param \BookStack\Auth\User $user - * @return array */ - public function getAssetCounts(User $user) + public function getAssetCounts(User $user): array { + $createdBy = ['created_by' => $user->id]; return [ - 'pages' => $this->entityRepo->getUserTotalCreated('page', $user), - 'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user), - 'books' => $this->entityRepo->getUserTotalCreated('book', $user), - 'shelves' => $this->entityRepo->getUserTotalCreated('bookshelf', $user), + 'pages' => Page::visible()->where($createdBy)->count(), + 'chapters' => Chapter::visible()->where($createdBy)->count(), + 'books' => Book::visible()->where($createdBy)->count(), + 'shelves' => Bookshelf::visible()->where($createdBy)->count(), ]; } @@ -260,16 +258,6 @@ class UserRepo return $this->role->newQuery()->orderBy('name', 'asc')->get(); } - /** - * Get all the roles which can be given restricted access to - * other entities in the system. - * @return mixed - */ - public function getRestrictableRoles() - { - return $this->role->where('system_name', '!=', 'admin')->get(); - } - /** * Get an avatar image for a user and set it as their avatar. * Returns early if avatars disabled or not set in config. @@ -288,7 +276,7 @@ class UserRepo $user->save(); return true; } catch (Exception $e) { - \Log::error('Failed to save user avatar image'); + Log::error('Failed to save user avatar image'); return false; } } diff --git a/app/Entities/Book.php b/app/Entities/Book.php index ce4c4b90e..4e54457b8 100644 --- a/app/Entities/Book.php +++ b/app/Entities/Book.php @@ -1,6 +1,11 @@ cover ? url($this->cover->getThumb($width, $height, false)) : $default; - } catch (\Exception $err) { + } catch (Exception $err) { $cover = $default; } return $cover; @@ -60,16 +56,23 @@ class Book extends Entity /** * Get the cover image of the book - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function cover() + public function cover(): BelongsTo { return $this->belongsTo(Image::class, 'image_id'); } + /** + * Get the type of the image model that is used when storing a cover image. + */ + public function coverImageTypeKey(): string + { + return 'cover_book'; + } + /** * Get all pages within this book. - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function pages() { @@ -78,7 +81,7 @@ class Book extends Entity /** * Get the direct child pages of this book. - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function directPages() { @@ -87,7 +90,7 @@ class Book extends Entity /** * Get all chapters within this book. - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function chapters() { @@ -96,13 +99,24 @@ class Book extends Entity /** * Get the shelves this book is contained within. - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * @return BelongsToMany */ public function shelves() { return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id'); } + /** + * Get the direct child items within this book. + * @return Collection + */ + public function getDirectChildren(): Collection + { + $pages = $this->directPages()->visible()->get(); + $chapters = $this->chapters()->visible()->get(); + return $pages->contact($chapters)->sortBy('priority')->sortByDesc('draft'); + } + /** * Get an excerpt of this book's description to the specified length or less. * @param int $length @@ -113,13 +127,4 @@ class Book extends Entity $description = $this->description; return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description; } - - /** - * Return a generalised, common raw query that can be 'unioned' across entities. - * @return string - */ - public function entityRawQuery() - { - return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at"; - } } diff --git a/app/Entities/BookChild.php b/app/Entities/BookChild.php index c76baf29a..6eac4375d 100644 --- a/app/Entities/BookChild.php +++ b/app/Entities/BookChild.php @@ -1,14 +1,31 @@ with('book') + ->whereHas('book', function (Builder $query) use ($bookSlug) { + $query->where('slug', '=', $bookSlug); + }) + ->where('slug', '=', $childSlug); + } + /** * Get the book this page sits in. * @return BelongsTo @@ -18,4 +35,26 @@ class BookChild extends Entity return $this->belongsTo(Book::class); } -} \ No newline at end of file + /** + * Change the book that this entity belongs to. + */ + public function changeBook(int $newBookId): Entity + { + $this->book_id = $newBookId; + $this->refreshSlug(); + $this->save(); + $this->refresh(); + + // Update related activity + $this->activity()->update(['book_id' => $newBookId]); + + // Update all child pages if a chapter + if ($this instanceof Chapter) { + foreach ($this->pages as $page) { + $page->changeBook($newBookId); + } + } + + return $this; + } +} diff --git a/app/Entities/Bookshelf.php b/app/Entities/Bookshelf.php index 7ad2415ed..62c7e2fe4 100644 --- a/app/Entities/Bookshelf.php +++ b/app/Entities/Bookshelf.php @@ -1,8 +1,10 @@ orderBy('order', 'asc'); } + /** + * Related books that are visible to the current user. + */ + public function visibleBooks(): BelongsToMany + { + return $this->books()->visible(); + } + /** * Get the url for this bookshelf. * @param string|bool $path @@ -68,13 +69,20 @@ class Bookshelf extends Entity /** * Get the cover image of the shelf - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo */ - public function cover() + public function cover(): BelongsTo { return $this->belongsTo(Image::class, 'image_id'); } + /** + * Get the type of the image model that is used when storing a cover image. + */ + public function coverImageTypeKey(): string + { + return 'cover_shelf'; + } + /** * Get an excerpt of this book's description to the specified length or less. * @param int $length @@ -86,21 +94,12 @@ class Bookshelf extends Entity return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description; } - /** - * Return a generalised, common raw query that can be 'unioned' across entities. - * @return string - */ - public function entityRawQuery() - { - return "'BookStack\\\\BookShelf' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at"; - } - /** * Check if this shelf contains the given book. * @param Book $book * @return bool */ - public function contains(Book $book): bool + public function contains(Book $book): bool { return $this->books()->where('id', '=', $book->id)->count() > 0; } @@ -111,11 +110,11 @@ class Bookshelf extends Entity */ public function appendBook(Book $book) { - if ($this->contains($book)) { - return; - } + if ($this->contains($book)) { + return; + } - $maxOrder = $this->books()->max('order'); - $this->books()->attach($book->id, ['order' => $maxOrder + 1]); + $maxOrder = $this->books()->max('order'); + $this->books()->attach($book->id, ['order' => $maxOrder + 1]); } } diff --git a/app/Entities/BreadcrumbsViewComposer.php b/app/Entities/BreadcrumbsViewComposer.php index e46d54ec2..43d63d026 100644 --- a/app/Entities/BreadcrumbsViewComposer.php +++ b/app/Entities/BreadcrumbsViewComposer.php @@ -1,5 +1,6 @@ entityContextManager = $entityContextManager; } diff --git a/app/Entities/Chapter.php b/app/Entities/Chapter.php index d121432fa..848bc6448 100644 --- a/app/Entities/Chapter.php +++ b/app/Entities/Chapter.php @@ -1,22 +1,18 @@ $pages + * @package BookStack\Entities + */ class Chapter extends BookChild { public $searchFactor = 1.3; protected $fillable = ['name', 'description', 'priority', 'book_id']; - /** - * Get the morph class for this model. - * @return string - */ - public function getMorphClass() - { - return 'BookStack\\Chapter'; - } - /** * Get the pages that this chapter contains. * @param string $dir @@ -55,15 +51,6 @@ class Chapter extends BookChild return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description; } - /** - * Return a generalised, common raw query that can be 'unioned' across entities. - * @return string - */ - public function entityRawQuery() - { - return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at"; - } - /** * Check if this chapter has any child pages. * @return bool @@ -72,4 +59,15 @@ class Chapter extends BookChild { return count($this->pages) > 0; } + + /** + * Get the visible pages in this chapter. + */ + public function getVisiblePages(): Collection + { + return $this->pages()->visible() + ->orderBy('draft', 'desc') + ->orderBy('priority', 'asc') + ->get(); + } } diff --git a/app/Entities/Entity.php b/app/Entities/Entity.php index 4e54a9e27..5013c39cf 100644 --- a/app/Entities/Entity.php +++ b/app/Entities/Entity.php @@ -9,6 +9,8 @@ use BookStack\Auth\Permissions\JointPermission; use BookStack\Facades\Permissions; use BookStack\Ownable; use Carbon\Carbon; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\MorphMany; /** @@ -24,6 +26,11 @@ use Illuminate\Database\Eloquent\Relations\MorphMany; * @property int $created_by * @property int $updated_by * @property boolean $restricted + * @property Collection $tags + * @method static Entity|Builder visible() + * @method static Entity|Builder hasPermission(string $permission) + * @method static Builder withLastView() + * @method static Builder withViewCount() * * @package BookStack\Entities */ @@ -41,14 +48,45 @@ class Entity extends Ownable public $searchFactor = 1.0; /** - * Get the morph class for this model. - * Set here since, due to folder changes, the namespace used - * in the database no longer matches the class namespace. - * @return string + * Get the entities that are visible to the current user. */ - public function getMorphClass() + public function scopeVisible(Builder $query) { - return 'BookStack\\Entity'; + return $this->scopeHasPermission($query, 'view'); + } + + /** + * Scope the query to those entities that the current user has the given permission for. + */ + public function scopeHasPermission(Builder $query, string $permission) + { + return Permissions::restrictEntityQuery($query, $permission); + } + + /** + * Query scope to get the last view from the current user. + */ + public function scopeWithLastView(Builder $query) + { + $viewedAtQuery = View::query()->select('updated_at') + ->whereColumn('viewable_id', '=', $this->getTable() . '.id') + ->where('viewable_type', '=', $this->getMorphClass()) + ->where('user_id', '=', user()->id) + ->take(1); + + return $query->addSelect(['last_viewed_at' => $viewedAtQuery]); + } + + /** + * Query scope to get the total view count of the entities. + */ + public function scopeWithViewCount(Builder $query) + { + $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') + ->whereColumn('viewable_id', '=', $this->getTable() . '.id') + ->where('viewable_type', '=', $this->getMorphClass())->take(1); + + $query->addSelect(['view_count' => $viewCountQuery]); } /** @@ -88,7 +126,7 @@ class Entity extends Ownable /** * Gets the activity objects for this entity. - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return MorphMany */ public function activity() { @@ -106,7 +144,7 @@ class Entity extends Ownable /** * Get the Tag models that have been user assigned to this entity. - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return MorphMany */ public function tags() { @@ -126,7 +164,7 @@ class Entity extends Ownable /** * Get the related search terms. - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return MorphMany */ public function searchTerms() { @@ -155,7 +193,7 @@ class Entity extends Ownable /** * Get the entity jointPermissions this is connected to. - * @return \Illuminate\Database\Eloquent\Relations\MorphMany + * @return MorphMany */ public function jointPermissions() { @@ -182,14 +220,6 @@ class Entity extends Ownable return strtolower(static::getClassName()); } - /** - * Get the type of this entity. - */ - public function type(): string - { - return static::getType(); - } - /** * Get an instance of an entity of the given type. * @param $type @@ -242,15 +272,6 @@ class Entity extends Ownable return trim($text); } - /** - * Return a generalised, common raw query that can be 'unioned' across entities. - * @return string - */ - public function entityRawQuery() - { - return ''; - } - /** * Get the url of this entity * @param $path @@ -270,6 +291,15 @@ class Entity extends Ownable Permissions::buildJointPermissionsForEntity($this); } + /** + * Index the current entity for search + */ + public function indexForSearch() + { + $searchService = app()->make(SearchService::class); + $searchService->indexEntity($this); + } + /** * Generate and set a new URL slug for this model. */ diff --git a/app/Entities/EntityProvider.php b/app/Entities/EntityProvider.php index d0d4a7ad6..6bf923b31 100644 --- a/app/Entities/EntityProvider.php +++ b/app/Entities/EntityProvider.php @@ -39,11 +39,6 @@ class EntityProvider /** * EntityProvider constructor. - * @param Bookshelf $bookshelf - * @param Book $book - * @param Chapter $chapter - * @param Page $page - * @param PageRevision $pageRevision */ public function __construct( Bookshelf $bookshelf, @@ -62,9 +57,8 @@ class EntityProvider /** * Fetch all core entity types as an associated array * with their basic names as the keys. - * @return Entity[] */ - public function all() + public function all(): array { return [ 'bookshelf' => $this->bookshelf, @@ -76,10 +70,8 @@ class EntityProvider /** * Get an entity instance by it's basic name. - * @param string $type - * @return Entity */ - public function get(string $type) + public function get(string $type): Entity { $type = strtolower($type); return $this->all()[$type]; @@ -87,15 +79,9 @@ class EntityProvider /** * Get the morph classes, as an array, for a single or multiple types. - * @param string|array $types - * @return array */ - public function getMorphClasses($types) + public function getMorphClasses(array $types): array { - if (is_string($types)) { - $types = [$types]; - } - $morphClasses = []; foreach ($types as $type) { $model = $this->get($type); diff --git a/app/Entities/ExportService.php b/app/Entities/ExportService.php index 09635aa21..3ec867959 100644 --- a/app/Entities/ExportService.php +++ b/app/Entities/ExportService.php @@ -1,35 +1,34 @@ entityRepo = $entityRepo; $this->imageService = $imageService; } /** * Convert a page to a self-contained HTML file. * Includes required CSS & image content. Images are base64 encoded into the HTML. - * @param \BookStack\Entities\Page $page - * @return mixed|string - * @throws \Throwable + * @throws Throwable */ public function pageToContainedHtml(Page $page) { - $this->entityRepo->renderPage($page); + $page->html = (new PageContent($page))->render(); $pageHtml = view('pages/export', [ 'page' => $page ])->render(); @@ -38,15 +37,13 @@ class ExportService /** * Convert a chapter to a self-contained HTML file. - * @param \BookStack\Entities\Chapter $chapter - * @return mixed|string - * @throws \Throwable + * @throws Throwable */ public function chapterToContainedHtml(Chapter $chapter) { - $pages = $this->entityRepo->getChapterChildren($chapter); + $pages = $chapter->getVisiblePages(); $pages->each(function ($page) { - $page->html = $this->entityRepo->renderPage($page); + $page->html = (new PageContent($page))->render(); }); $html = view('chapters/export', [ 'chapter' => $chapter, @@ -57,13 +54,11 @@ class ExportService /** * Convert a book to a self-contained HTML file. - * @param Book $book - * @return mixed|string - * @throws \Throwable + * @throws Throwable */ public function bookToContainedHtml(Book $book) { - $bookTree = $this->entityRepo->getBookChildren($book, true, true); + $bookTree = (new BookContents($book))->getTree(false, true); $html = view('books/export', [ 'book' => $book, 'bookChildren' => $bookTree @@ -73,13 +68,11 @@ class ExportService /** * Convert a page to a PDF file. - * @param Page $page - * @return mixed|string - * @throws \Throwable + * @throws Throwable */ public function pageToPdf(Page $page) { - $this->entityRepo->renderPage($page); + $page->html = (new PageContent($page))->render(); $html = view('pages/pdf', [ 'page' => $page ])->render(); @@ -88,32 +81,30 @@ class ExportService /** * Convert a chapter to a PDF file. - * @param \BookStack\Entities\Chapter $chapter - * @return mixed|string - * @throws \Throwable + * @throws Throwable */ public function chapterToPdf(Chapter $chapter) { - $pages = $this->entityRepo->getChapterChildren($chapter); + $pages = $chapter->getVisiblePages(); $pages->each(function ($page) { - $page->html = $this->entityRepo->renderPage($page); + $page->html = (new PageContent($page))->render(); }); + $html = view('chapters/export', [ 'chapter' => $chapter, 'pages' => $pages ])->render(); + return $this->htmlToPdf($html); } /** - * Convert a book to a PDF file - * @param \BookStack\Entities\Book $book - * @return string - * @throws \Throwable + * Convert a book to a PDF file. + * @throws Throwable */ public function bookToPdf(Book $book) { - $bookTree = $this->entityRepo->getBookChildren($book, true, true); + $bookTree = (new BookContents($book))->getTree(false, true); $html = view('books/export', [ 'book' => $book, 'bookChildren' => $bookTree @@ -122,31 +113,27 @@ class ExportService } /** - * Convert normal webpage HTML to a PDF. - * @param $html - * @return string - * @throws \Exception + * Convert normal web-page HTML to a PDF. + * @throws Exception */ - protected function htmlToPdf($html) + protected function htmlToPdf(string $html): string { $containedHtml = $this->containHtml($html); $useWKHTML = config('snappy.pdf.binary') !== false; if ($useWKHTML) { - $pdf = \SnappyPDF::loadHTML($containedHtml); + $pdf = SnappyPDF::loadHTML($containedHtml); $pdf->setOption('print-media-type', true); } else { - $pdf = \DomPDF::loadHTML($containedHtml); + $pdf = DomPDF::loadHTML($containedHtml); } return $pdf->output(); } /** * Bundle of the contents of a html file to be self-contained. - * @param $htmlContent - * @return mixed|string - * @throws \Exception + * @throws Exception */ - protected function containHtml($htmlContent) + protected function containHtml(string $htmlContent): string { $imageTagsOutput = []; preg_match_all("/\/i", $htmlContent, $imageTagsOutput); @@ -188,12 +175,10 @@ class ExportService /** * Converts the page contents into simple plain text. * This method filters any bad looking content to provide a nice final output. - * @param Page $page - * @return mixed */ - public function pageToPlainText(Page $page) + public function pageToPlainText(Page $page): string { - $html = $this->entityRepo->renderPage($page); + $html = (new PageContent($page))->render(); $text = strip_tags($html); // Replace multiple spaces with single spaces $text = preg_replace('/\ {2,}/', ' ', $text); @@ -207,10 +192,8 @@ class ExportService /** * Convert a chapter into a plain text string. - * @param \BookStack\Entities\Chapter $chapter - * @return string */ - public function chapterToPlainText(Chapter $chapter) + public function chapterToPlainText(Chapter $chapter): string { $text = $chapter->name . "\n\n"; $text .= $chapter->description . "\n\n"; @@ -222,12 +205,10 @@ class ExportService /** * Convert a book into a plain text string. - * @param Book $book - * @return string */ - public function bookToPlainText(Book $book) + public function bookToPlainText(Book $book): string { - $bookTree = $this->entityRepo->getBookChildren($book, true, true); + $bookTree = (new BookContents($book))->getTree(false, true); $text = $book->name . "\n\n"; foreach ($bookTree as $bookChild) { if ($bookChild->isA('chapter')) { diff --git a/app/Entities/HasCoverImage.php b/app/Entities/HasCoverImage.php new file mode 100644 index 000000000..31277f4b6 --- /dev/null +++ b/app/Entities/HasCoverImage.php @@ -0,0 +1,20 @@ +book = $book; + } + + /** + * Get the current priority of the last item + * at the top-level of the book. + */ + public function getLastPriority(): int + { + $maxPage = Page::visible()->where('book_id', '=', $this->book->id) + ->where('draft', '=', false) + ->where('chapter_id', '=', 0)->max('priority'); + $maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id) + ->max('priority'); + return max($maxChapter, $maxPage, 1); + } + + /** + * Get the contents as a sorted collection tree. + * TODO - Support $renderPages option + */ + public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection + { + $pages = $this->getPages($showDrafts); + $chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get(); + $all = collect()->concat($pages)->concat($chapters); + $chapterMap = $chapters->keyBy('id'); + $lonePages = collect(); + + $pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) { + $chapter = $chapterMap->get($chapter_id); + if ($chapter) { + $chapter->setAttribute('pages', collect($pages)->sortBy($this->bookChildSortFunc())); + } else { + $lonePages = $lonePages->concat($pages); + } + }); + + $all->each(function (Entity $entity) { + $entity->setRelation('book', $this->book); + }); + + return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc()); + } + + /** + * Function for providing a sorting score for an entity in relation to the + * other items within the book. + */ + protected function bookChildSortFunc(): callable + { + return function (Entity $entity) { + if (isset($entity['draft']) && $entity['draft']) { + return -100; + } + return $entity['priority'] ?? 0; + }; + } + + /** + * Get the visible pages within this book. + */ + protected function getPages(bool $showDrafts = false): Collection + { + $query = Page::visible()->where('book_id', '=', $this->book->id); + + if (!$showDrafts) { + $query->where('draft', '=', false); + } + + return $query->get(); + } + + /** + * Sort the books content using the given map. + * The map is a single-dimension collection of objects in the following format: + * { + * +"id": "294" (ID of item) + * +"sort": 1 (Sort order index) + * +"parentChapter": false (ID of parent chapter, as string, or false) + * +"type": "page" (Entity type of item) + * +"book": "1" (Id of book to place item in) + * } + * + * Returns a list of books that were involved in the operation. + * @throws SortOperationException + */ + public function sortUsingMap(Collection $sortMap): Collection + { + // Load models into map + $this->loadModelsIntoSortMap($sortMap); + $booksInvolved = $this->getBooksInvolvedInSort($sortMap); + + // Perform the sort + $sortMap->each(function ($mapItem) { + $this->applySortUpdates($mapItem); + }); + + // Update permissions and activity. + $booksInvolved->each(function (Book $book) { + $book->rebuildPermissions(); + }); + + return $booksInvolved; + } + + /** + * Using the given sort map item, detect changes for the related model + * and update it if required. + */ + protected function applySortUpdates(\stdClass $sortMapItem) + { + /** @var BookChild $model */ + $model = $sortMapItem->model; + + $priorityChanged = intval($model->priority) !== intval($sortMapItem->sort); + $bookChanged = intval($model->book_id) !== intval($sortMapItem->book); + $chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter; + + if ($bookChanged) { + $model->changeBook($sortMapItem->book); + } + + if ($chapterChanged) { + $model->chapter_id = intval($sortMapItem->parentChapter); + $model->save(); + } + + if ($priorityChanged) { + $model->priority = intval($sortMapItem->sort); + $model->save(); + } + } + + /** + * Load models from the database into the given sort map. + */ + protected function loadModelsIntoSortMap(Collection $sortMap): void + { + $keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) { + return $sortMapItem->type . ':' . $sortMapItem->id; + }); + $pageIds = $sortMap->where('type', '=', 'page')->pluck('id'); + $chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id'); + + $pages = Page::visible()->whereIn('id', $pageIds)->get(); + $chapters = Chapter::visible()->whereIn('id', $chapterIds)->get(); + + foreach ($pages as $page) { + $sortItem = $keyMap->get('page:' . $page->id); + $sortItem->model = $page; + } + + foreach ($chapters as $chapter) { + $sortItem = $keyMap->get('chapter:' . $chapter->id); + $sortItem->model = $chapter; + } + } + + /** + * Get the books involved in a sort. + * The given sort map should have its models loaded first. + * @throws SortOperationException + */ + protected function getBooksInvolvedInSort(Collection $sortMap): Collection + { + $bookIdsInvolved = collect([$this->book->id]); + $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book')); + $bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id')); + $bookIdsInvolved = $bookIdsInvolved->unique()->toArray(); + + $books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get(); + + if (count($books) !== count($bookIdsInvolved)) { + throw new SortOperationException("Could not find all books requested in sort operation"); + } + + return $books; + } +} diff --git a/app/Entities/EntityContextManager.php b/app/Entities/Managers/EntityContext.php similarity index 53% rename from app/Entities/EntityContextManager.php rename to app/Entities/Managers/EntityContext.php index 20be0de2b..551cd1a10 100644 --- a/app/Entities/EntityContextManager.php +++ b/app/Entities/Managers/EntityContext.php @@ -1,44 +1,38 @@ -session = $session; - $this->entityRepo = $entityRepo; } /** * Get the current bookshelf context for the given book. - * @param Book $book - * @return Bookshelf|null */ - public function getContextualShelfForBook(Book $book) + public function getContextualShelfForBook(Book $book): ?Bookshelf { $contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null); - if (is_int($contextBookshelfId)) { - /** @var Bookshelf $shelf */ - $shelf = $this->entityRepo->getById('bookshelf', $contextBookshelfId); - - if ($shelf && $shelf->contains($book)) { - return $shelf; - } + if (!is_int($contextBookshelfId)) { + return null; } - return null; + + $shelf = Bookshelf::visible()->find($contextBookshelfId); + $shelfContainsBook = $shelf && $shelf->contains($book); + + return $shelfContainsBook ? $shelf : null; } /** diff --git a/app/Entities/Managers/PageContent.php b/app/Entities/Managers/PageContent.php new file mode 100644 index 000000000..36bc2445c --- /dev/null +++ b/app/Entities/Managers/PageContent.php @@ -0,0 +1,304 @@ +page = $page; + } + + /** + * Update the content of the page with new provided HTML. + */ + public function setNewHTML(string $html) + { + $this->page->html = $this->formatHtml($html); + $this->page->text = $this->toPlainText(); + } + + /** + * Formats a page's html to be tagged correctly within the system. + */ + protected function formatHtml(string $htmlText): string + { + if ($htmlText == '') { + return $htmlText; + } + + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); + + $container = $doc->documentElement; + $body = $container->childNodes->item(0); + $childNodes = $body->childNodes; + + // Set ids on top-level nodes + $idMap = []; + foreach ($childNodes as $index => $childNode) { + $this->setUniqueId($childNode, $idMap); + } + + // Ensure no duplicate ids within child items + $xPath = new DOMXPath($doc); + $idElems = $xPath->query('//body//*//*[@id]'); + foreach ($idElems as $domElem) { + $this->setUniqueId($domElem, $idMap); + } + + // Generate inner html as a string + $html = ''; + foreach ($childNodes as $childNode) { + $html .= $doc->saveHTML($childNode); + } + + return $html; + } + + /** + * Set a unique id on the given DOMElement. + * A map for existing ID's should be passed in to check for current existence. + * @param DOMElement $element + * @param array $idMap + */ + protected function setUniqueId($element, array &$idMap) + { + if (get_class($element) !== 'DOMElement') { + return; + } + + // Overwrite id if not a BookStack custom id + $existingId = $element->getAttribute('id'); + if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) { + $idMap[$existingId] = true; + return; + } + + // Create an unique id for the element + // Uses the content as a basis to ensure output is the same every time + // the same content is passed through. + $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20); + $newId = urlencode($contentId); + $loopIndex = 0; + + while (isset($idMap[$newId])) { + $newId = urlencode($contentId . '-' . $loopIndex); + $loopIndex++; + } + + $element->setAttribute('id', $newId); + $idMap[$newId] = true; + } + + /** + * Get a plain-text visualisation of this page. + */ + protected function toPlainText(): string + { + $html = $this->render(true); + return strip_tags($html); + } + + /** + * Render the page for viewing + */ + public function render(bool $blankIncludes = false) : string + { + $content = $this->page->html; + + if (!config('app.allow_content_scripts')) { + $content = $this->escapeScripts($content); + } + + if ($blankIncludes) { + $content = $this->blankPageIncludes($content); + } else { + $content = $this->parsePageIncludes($content); + } + + return $content; + } + + /** + * Parse the headers on the page to get a navigation menu + */ + public function getNavigation(string $htmlContent): array + { + if (empty($htmlContent)) { + return []; + } + + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8')); + $xPath = new DOMXPath($doc); + $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6"); + + return $headers ? $this->headerNodesToLevelList($headers) : []; + } + + /** + * Convert a DOMNodeList into an array of readable header attributes + * with levels normalised to the lower header level. + */ + protected function headerNodesToLevelList(DOMNodeList $nodeList): array + { + $tree = collect($nodeList)->map(function ($header) { + $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue)); + $text = mb_substr($text, 0, 100); + + return [ + 'nodeName' => strtolower($header->nodeName), + 'level' => intval(str_replace('h', '', $header->nodeName)), + 'link' => '#' . $header->getAttribute('id'), + 'text' => $text, + ]; + })->filter(function ($header) { + return mb_strlen($header['text']) > 0; + }); + + // Shift headers if only smaller headers have been used + $levelChange = ($tree->pluck('level')->min() - 1); + $tree = $tree->map(function ($header) use ($levelChange) { + $header['level'] -= ($levelChange); + return $header; + }); + + return $tree->toArray(); + } + + /** + * Remove any page include tags within the given HTML. + */ + protected function blankPageIncludes(string $html) : string + { + return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html); + } + + /** + * Parse any include tags "{{@#section}}" to be part of the page. + */ + protected function parsePageIncludes(string $html) : string + { + $matches = []; + preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches); + + foreach ($matches[1] as $index => $includeId) { + $fullMatch = $matches[0][$index]; + $splitInclude = explode('#', $includeId, 2); + + // Get page id from reference + $pageId = intval($splitInclude[0]); + if (is_nan($pageId)) { + continue; + } + + // Find page and skip this if page not found + $matchedPage = Page::visible()->find($pageId); + if ($matchedPage === null) { + $html = str_replace($fullMatch, '', $html); + continue; + } + + // If we only have page id, just insert all page html and continue. + if (count($splitInclude) === 1) { + $html = str_replace($fullMatch, $matchedPage->html, $html); + continue; + } + + // Create and load HTML into a document + $innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]); + $html = str_replace($fullMatch, trim($innerContent), $html); + } + + return $html; + } + + + /** + * Fetch the content from a specific section of the given page. + */ + protected function fetchSectionOfPage(Page $page, string $sectionId): string + { + $topLevelTags = ['table', 'ul', 'ol']; + $doc = new DOMDocument(); + libxml_use_internal_errors(true); + $doc->loadHTML(mb_convert_encoding(''.$page->html.'', 'HTML-ENTITIES', 'UTF-8')); + + // Search included content for the id given and blank out if not exists. + $matchingElem = $doc->getElementById($sectionId); + if ($matchingElem === null) { + return ''; + } + + // Otherwise replace the content with the found content + // Checks if the top-level wrapper should be included by matching on tag types + $innerContent = ''; + $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags); + if ($isTopLevel) { + $innerContent .= $doc->saveHTML($matchingElem); + } else { + foreach ($matchingElem->childNodes as $childNode) { + $innerContent .= $doc->saveHTML($childNode); + } + } + libxml_clear_errors(); + + return $innerContent; + } + + /** + * Escape script tags within HTML content. + */ + protected function escapeScripts(string $html) : string + { + if (empty($html)) { + return $html; + } + + libxml_use_internal_errors(true); + $doc = new DOMDocument(); + $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); + $xPath = new DOMXPath($doc); + + // Remove standard script tags + $scriptElems = $xPath->query('//script'); + foreach ($scriptElems as $scriptElem) { + $scriptElem->parentNode->removeChild($scriptElem); + } + + // Remove data or JavaScript iFrames + $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]'); + foreach ($badIframes as $badIframe) { + $badIframe->parentNode->removeChild($badIframe); + } + + // Remove 'on*' attributes + $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]'); + foreach ($onAttributes as $attr) { + /** @var \DOMAttr $attr*/ + $attrName = $attr->nodeName; + $attr->parentNode->removeAttribute($attrName); + } + + $html = ''; + $topElems = $doc->documentElement->childNodes->item(0)->childNodes; + foreach ($topElems as $child) { + $html .= $doc->saveHTML($child); + } + + return $html; + } +} diff --git a/app/Entities/Managers/PageEditActivity.php b/app/Entities/Managers/PageEditActivity.php new file mode 100644 index 000000000..cebbf8720 --- /dev/null +++ b/app/Entities/Managers/PageEditActivity.php @@ -0,0 +1,74 @@ +page = $page; + } + + /** + * Check if there's active editing being performed on this page. + * @return bool + */ + public function hasActiveEditing(): bool + { + return $this->activePageEditingQuery(60)->count() > 0; + } + + /** + * Get a notification message concerning the editing activity on the page. + */ + public function activeEditingMessage(): string + { + $pageDraftEdits = $this->activePageEditingQuery(60)->get(); + $count = $pageDraftEdits->count(); + + $userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]); + $timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]); + return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]); + } + + /** + * Get the message to show when the user will be editing one of their drafts. + * @param PageRevision $draft + * @return string + */ + public function getEditingActiveDraftMessage(PageRevision $draft): string + { + $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]); + if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) { + return $message; + } + return $message . "\n" . trans('entities.pages_draft_edited_notification'); + } + + /** + * A query to check for active update drafts on a particular page + * within the last given many minutes. + */ + protected function activePageEditingQuery(int $withinMinutes): Builder + { + $checkTime = Carbon::now()->subMinutes($withinMinutes); + $query = PageRevision::query() + ->where('type', '=', 'update_draft') + ->where('page_id', '=', $this->page->id) + ->where('updated_at', '>', $this->page->updated_at) + ->where('created_by', '!=', user()->id) + ->where('updated_at', '>=', $checkTime) + ->with('createdBy'); + + return $query; + } +} diff --git a/app/Entities/Managers/TrashCan.php b/app/Entities/Managers/TrashCan.php new file mode 100644 index 000000000..1a32294fc --- /dev/null +++ b/app/Entities/Managers/TrashCan.php @@ -0,0 +1,109 @@ +destroyCommonRelations($shelf); + $shelf->delete(); + } + + /** + * Remove a book from the system. + * @throws NotifyException + * @throws BindingResolutionException + */ + public function destroyBook(Book $book) + { + foreach ($book->pages as $page) { + $this->destroyPage($page); + } + + foreach ($book->chapters as $chapter) { + $this->destroyChapter($chapter); + } + + $this->destroyCommonRelations($book); + $book->delete(); + } + + /** + * Remove a page from the system. + * @throws NotifyException + */ + public function destroyPage(Page $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])) { + if (setting('app-homepage-type') === 'page') { + throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl()); + } + setting()->remove('app-homepage'); + } + + $this->destroyCommonRelations($page); + + // Delete Attached Files + $attachmentService = app(AttachmentService::class); + foreach ($page->attachments as $attachment) { + $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(); + } + + /** + * 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(); + + if ($entity instanceof HasCoverImage && $entity->cover) { + $imageService = app()->make(ImageService::class); + $imageService->destroy($entity->cover); + } + } +} diff --git a/app/Entities/Page.php b/app/Entities/Page.php index 752b3c9dd..76dc628fb 100644 --- a/app/Entities/Page.php +++ b/app/Entities/Page.php @@ -1,7 +1,24 @@ chapter_id ? $this->chapter() : $this->book(); + return $this->chapter_id ? $this->chapter : $this->book; } /** * Get the chapter that this page is in, If applicable. - * @return \Illuminate\Database\Eloquent\Relations\BelongsTo + * @return BelongsTo */ public function chapter() { @@ -63,12 +79,12 @@ class Page extends BookChild */ public function revisions() { - return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc'); + return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc'); } /** * Get the attachments assigned to this page. - * @return \Illuminate\Database\Eloquent\Relations\HasMany + * @return HasMany */ public function attachments() { @@ -86,27 +102,17 @@ class Page extends BookChild $midText = $this->draft ? '/draft/' : '/page/'; $idComponent = $this->draft ? $this->id : urlencode($this->slug); + $url = '/books/' . urlencode($bookSlug) . $midText . $idComponent; if ($path !== false) { - return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/')); + $url .= '/' . trim($path, '/'); } - return url('/books/' . urlencode($bookSlug) . $midText . $idComponent); - } - - /** - * Return a generalised, common raw query that can be 'unioned' across entities. - * @param bool $withContent - * @return string - */ - public function entityRawQuery($withContent = false) - { - $htmlQuery = $withContent ? 'html' : "'' as html"; - return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at"; + return url($url); } /** * Get the current revision for the page if existing - * @return \BookStack\Entities\PageRevision|null + * @return PageRevision|null */ public function getCurrentRevision() { diff --git a/app/Entities/PageRevision.php b/app/Entities/PageRevision.php index d30147bfc..13dc713ba 100644 --- a/app/Entities/PageRevision.php +++ b/app/Entities/PageRevision.php @@ -2,7 +2,21 @@ use BookStack\Auth\User; use BookStack\Model; +use Carbon\Carbon; +/** + * Class PageRevision + * @property int $page_id + * @property string $slug + * @property string $book_slug + * @property int $created_by + * @property Carbon $created_at + * @property string $type + * @property string $summary + * @property string $markdown + * @property string $html + * @property int $revision_number + */ class PageRevision extends Model { protected $fillable = ['name', 'html', 'text', 'markdown', 'summary']; @@ -41,13 +55,18 @@ class PageRevision extends Model /** * Get the previous revision for the same page if existing - * @return \BookStack\PageRevision|null + * @return \BookStack\Entities\PageRevision|null */ public function getPrevious() { - if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) { - return static::find($id); + $id = static::newQuery()->where('page_id', '=', $this->page_id) + ->where('id', '<', $this->id) + ->max('id'); + + if ($id) { + return static::query()->find($id); } + return null; } diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php new file mode 100644 index 000000000..78ce505b9 --- /dev/null +++ b/app/Entities/Repos/BaseRepo.php @@ -0,0 +1,118 @@ +tagRepo = $tagRepo; + $this->imageRepo = $imageRepo; + } + + /** + * Create a new entity in the system + */ + public function create(Entity $entity, array $input) + { + $entity->fill($input); + $entity->forceFill([ + 'created_by' => user()->id, + 'updated_by' => user()->id, + ]); + $entity->refreshSlug(); + $entity->save(); + + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($entity, $input['tags']); + } + + $entity->rebuildPermissions(); + $entity->indexForSearch(); + } + + /** + * Update the given entity. + */ + public function update(Entity $entity, array $input) + { + $entity->fill($input); + $entity->updated_by = user()->id; + + if ($entity->isDirty('name')) { + $entity->refreshSlug(); + } + + $entity->save(); + + if (isset($input['tags'])) { + $this->tagRepo->saveTagsToEntity($entity, $input['tags']); + } + + $entity->rebuildPermissions(); + $entity->indexForSearch(); + } + + /** + * Update the given items' cover image, or clear it. + * @throws ImageUploadException + * @throws \Exception + */ + public function updateCoverImage(HasCoverImage $entity, UploadedFile $coverImage = null, bool $removeImage = false) + { + if ($coverImage) { + $this->imageRepo->destroyImage($entity->cover); + $image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true); + $entity->cover()->associate($image); + } + + if ($removeImage) { + $this->imageRepo->destroyImage($entity->cover); + $entity->image_id = 0; + $entity->save(); + } + } + + /** + * Update the permissions of an entity. + */ + public function updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null) + { + $entity->restricted = $restricted; + $entity->permissions()->delete(); + + if (!is_null($permissions)) { + $entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) { + return collect($restrictions)->keys()->map(function ($action) use ($roleId) { + return [ + 'role_id' => $roleId, + 'action' => strtolower($action), + ] ; + }); + }); + + $entity->permissions()->createMany($entityPermissionData); + } + + $entity->save(); + $entity->rebuildPermissions(); + } +} diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 91bc9a1b4..7fcc80fac 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -1,46 +1,134 @@ -baseRepo = $baseRepo; + $this->tagRepo = $tagRepo; + $this->imageRepo = $imageRepo; + } + + /** + * Get all books in a paginated format. + */ + public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator + { + return Book::visible()->orderBy($sort, $order)->paginate($count); + } + + /** + * Get the books that were most recently viewed by this user. + */ + public function getRecentlyViewed(int $count = 20): Collection + { + return Book::visible()->withLastView() + ->having('last_viewed_at', '>', 0) + ->orderBy('last_viewed_at', 'desc') + ->take($count)->get(); + } + + /** + * Get the most popular books in the system. + */ + public function getPopular(int $count = 20): Collection + { + return Book::visible()->withViewCount() + ->having('view_count', '>', 0) + ->orderBy('view_count', 'desc') + ->take($count)->get(); + } + + /** + * Get the most recently created books from the system. + */ + public function getRecentlyCreated(int $count = 20): Collection + { + return Book::visible()->orderBy('created_at', 'desc') + ->take($count)->get(); + } + + /** + * Get a book by its slug. */ public function getBySlug(string $slug): Book { - /** @var Book $book */ - $book = $this->getEntityBySlug('book', $slug); + $book = Book::visible()->where('slug', '=', $slug)->first(); + + if ($book === null) { + throw new NotFoundException(trans('errors.book_not_found')); + } + return $book; } /** - * Destroy the provided book and all its child entities. - * @param Book $book - * @throws NotifyException - * @throws \Throwable + * Create a new book in the system */ - public function destroyBook(Book $book) + public function create(array $input): Book { - foreach ($book->pages as $page) { - $this->destroyPage($page); - } - - foreach ($book->chapters as $chapter) { - $this->destroyChapter($chapter); - } - - $this->destroyEntityCommonRelations($book); - $book->delete(); + $book = new Book(); + $this->baseRepo->create($book, $input); + return $book; } -} \ No newline at end of file + /** + * Update the given book. + */ + public function update(Book $book, array $input): Book + { + $this->baseRepo->update($book, $input); + return $book; + } + + /** + * Update the given book's cover image, or clear it. + * @throws ImageUploadException + * @throws Exception + */ + public function updateCoverImage(Book $book, UploadedFile $coverImage = null, bool $removeImage = false) + { + $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); + } + + /** + * Update the permissions of a book. + */ + public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null) + { + $this->baseRepo->updatePermissions($book, $restricted, $permissions); + } + + /** + * Remove a book from the system. + * @throws NotifyException + * @throws BindingResolutionException + */ + public function destroy(Book $book) + { + $trashCan = new TrashCan(); + $trashCan->destroyBook($book); + } +} diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php new file mode 100644 index 000000000..ab4a51805 --- /dev/null +++ b/app/Entities/Repos/BookshelfRepo.php @@ -0,0 +1,173 @@ +baseRepo = $baseRepo; + } + + /** + * Get all bookshelves in a paginated format. + */ + public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator + { + return Bookshelf::visible()->with('visibleBooks') + ->orderBy($sort, $order)->paginate($count); + } + + /** + * Get the bookshelves that were most recently viewed by this user. + */ + public function getRecentlyViewed(int $count = 20): Collection + { + return Bookshelf::visible()->withLastView() + ->having('last_viewed_at', '>', 0) + ->orderBy('last_viewed_at', 'desc') + ->take($count)->get(); + } + + /** + * Get the most popular bookshelves in the system. + */ + public function getPopular(int $count = 20): Collection + { + return Bookshelf::visible()->withViewCount() + ->having('view_count', '>', 0) + ->orderBy('view_count', 'desc') + ->take($count)->get(); + } + + /** + * Get the most recently created bookshelves from the system. + */ + public function getRecentlyCreated(int $count = 20): Collection + { + return Bookshelf::visible()->orderBy('created_at', 'desc') + ->take($count)->get(); + } + + /** + * Get a shelf by its slug. + */ + public function getBySlug(string $slug): Bookshelf + { + $shelf = Bookshelf::visible()->where('slug', '=', $slug)->first(); + + if ($shelf === null) { + throw new NotFoundException(trans('errors.bookshelf_not_found')); + } + + return $shelf; + } + + /** + * Create a new shelf in the system. + */ + public function create(array $input, array $bookIds): Bookshelf + { + $shelf = new Bookshelf(); + $this->baseRepo->create($shelf, $input); + $this->updateBooks($shelf, $bookIds); + return $shelf; + } + + /** + * Create a new shelf in the system. + */ + public function update(Bookshelf $shelf, array $input, array $bookIds): Bookshelf + { + $this->baseRepo->update($shelf, $input); + $this->updateBooks($shelf, $bookIds); + return $shelf; + } + + /** + * Update which books are assigned to this shelf by + * syncing the given book ids. + * Function ensures the books are visible to the current user and existing. + */ + protected function updateBooks(Bookshelf $shelf, array $bookIds) + { + $numericIDs = collect($bookIds)->map(function ($id) { + return intval($id); + }); + + $syncData = Book::visible() + ->whereIn('id', $bookIds) + ->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) { + return [$bookId => ['order' => $numericIDs->search($bookId)]]; + }); + + $shelf->books()->sync($syncData); + } + + /** + * Update the given shelf cover image, or clear it. + * @throws ImageUploadException + * @throws Exception + */ + public function updateCoverImage(Bookshelf $shelf, UploadedFile $coverImage = null, bool $removeImage = false) + { + $this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage); + } + + /** + * Update the permissions of a bookshelf. + */ + public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null) + { + $this->baseRepo->updatePermissions($shelf, $restricted, $permissions); + } + + /** + * Copy down the permissions of the given shelf to all child books. + */ + public function copyDownPermissions(Bookshelf $shelf): int + { + $shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray(); + $shelfBooks = $shelf->books()->get(); + $updatedBookCount = 0; + + /** @var Book $book */ + foreach ($shelfBooks as $book) { + if (!userCan('restrictions-manage', $book)) { + continue; + } + $book->permissions()->delete(); + $book->restricted = $shelf->restricted; + $book->permissions()->createMany($shelfPermissions); + $book->save(); + $book->rebuildPermissions(); + $updatedBookCount++; + } + + return $updatedBookCount; + } + + /** + * Remove a bookshelf from the system. + * @throws Exception + */ + public function destroy(Bookshelf $shelf) + { + $trashCan = new TrashCan(); + $trashCan->destroyShelf($shelf); + } +} diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php new file mode 100644 index 000000000..c6f3a2d2f --- /dev/null +++ b/app/Entities/Repos/ChapterRepo.php @@ -0,0 +1,108 @@ +baseRepo = $baseRepo; + } + + /** + * Get a chapter via the slug. + * @throws NotFoundException + */ + public function getBySlug(string $bookSlug, string $chapterSlug): Chapter + { + $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first(); + + if ($chapter === null) { + throw new NotFoundException(trans('errors.chapter_not_found')); + } + + return $chapter; + } + + /** + * Create a new chapter in the system. + */ + public function create(array $input, Book $parentBook): Chapter + { + $chapter = new Chapter(); + $chapter->book_id = $parentBook->id; + $chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1; + $this->baseRepo->create($chapter, $input); + return $chapter; + } + + /** + * Update the given chapter. + */ + public function update(Chapter $chapter, array $input): Chapter + { + $this->baseRepo->update($chapter, $input); + return $chapter; + } + + /** + * Update the permissions of a chapter. + */ + public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null) + { + $this->baseRepo->updatePermissions($chapter, $restricted, $permissions); + } + + /** + * Remove a chapter from the system. + * @throws Exception + */ + public function destroy(Chapter $chapter) + { + $trashCan = new TrashCan(); + $trashCan->destroyChapter($chapter); + } + + /** + * Move the given chapter into a new parent book. + * The $parentIdentifier must be a string of the following format: + * 'book:' (book:5) + * @throws MoveOperationException + */ + public function move(Chapter $chapter, string $parentIdentifier): Book + { + $stringExploded = explode(':', $parentIdentifier); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); + + if ($entityType !== 'book') { + throw new MoveOperationException('Chapters can only be moved into books'); + } + + $parent = Book::visible()->where('id', '=', $entityId)->first(); + if ($parent === null) { + throw new MoveOperationException('Book to move chapter into not found'); + } + + $chapter->changeBook($parent->id); + $chapter->rebuildPermissions(); + return $parent; + } +} diff --git a/app/Entities/Repos/EntityRepo.php b/app/Entities/Repos/EntityRepo.php deleted file mode 100644 index 13a335ea0..000000000 --- a/app/Entities/Repos/EntityRepo.php +++ /dev/null @@ -1,843 +0,0 @@ -entityProvider = $entityProvider; - $this->viewService = $viewService; - $this->permissionService = $permissionService; - $this->tagRepo = $tagRepo; - $this->searchService = $searchService; - } - - /** - * Base query for searching entities via permission system - * @param string $type - * @param bool $allowDrafts - * @param string $permission - * @return QueryBuilder - */ - protected function entityQuery($type, $allowDrafts = false, $permission = 'view') - { - $q = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type), $permission); - if (strtolower($type) === 'page' && !$allowDrafts) { - $q = $q->where('draft', '=', false); - } - return $q; - } - - /** - * Check if an entity with the given id exists. - * @param $type - * @param $id - * @return bool - */ - public function exists($type, $id) - { - return $this->entityQuery($type)->where('id', '=', $id)->exists(); - } - - /** - * Get an entity by ID - * @param string $type - * @param integer $id - * @param bool $allowDrafts - * @param bool $ignorePermissions - * @return Entity - */ - public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false) - { - $query = $this->entityQuery($type, $allowDrafts); - - if ($ignorePermissions) { - $query = $this->entityProvider->get($type)->newQuery(); - } - - return $query->find($id); - } - - /** - * @param string $type - * @param []int $ids - * @param bool $allowDrafts - * @param bool $ignorePermissions - * @return Builder[]|\Illuminate\Database\Eloquent\Collection|Collection - */ - public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false) - { - $query = $this->entityQuery($type, $allowDrafts); - - if ($ignorePermissions) { - $query = $this->entityProvider->get($type)->newQuery(); - } - - return $query->whereIn('id', $ids)->get(); - } - - /** - * Get an entity by its url slug. - * @param string $type - * @param string $slug - * @param string|null $bookSlug - * @return Entity - * @throws NotFoundException - */ - public function getEntityBySlug(string $type, string $slug, string $bookSlug = null): Entity - { - $type = strtolower($type); - $query = $this->entityQuery($type)->where('slug', '=', $slug); - - if ($type === 'chapter' || $type === 'page') { - $query = $query->where('book_id', '=', function (QueryBuilder $query) use ($bookSlug) { - $query->select('id') - ->from($this->entityProvider->book->getTable()) - ->where('slug', '=', $bookSlug)->limit(1); - }); - } - - $entity = $query->first(); - - if ($entity === null) { - throw new NotFoundException(trans('errors.' . $type . '_not_found')); - } - - return $entity; - } - - - /** - * Get all entities of a type with the given permission, limited by count unless count is false. - * @param string $type - * @param integer|bool $count - * @param string $permission - * @return Collection - */ - public function getAll($type, $count = 20, $permission = 'view') - { - $q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc'); - if ($count !== false) { - $q = $q->take($count); - } - return $q->get(); - } - - /** - * Get all entities in a paginated format - * @param $type - * @param int $count - * @param string $sort - * @param string $order - * @param null|callable $queryAddition - * @return LengthAwarePaginator - */ - public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc', $queryAddition = null) - { - $query = $this->entityQuery($type); - $query = $this->addSortToQuery($query, $sort, $order); - if ($queryAddition) { - $queryAddition($query); - } - return $query->paginate($count); - } - - /** - * Add sorting operations to an entity query. - * @param Builder $query - * @param string $sort - * @param string $order - * @return Builder - */ - protected function addSortToQuery(Builder $query, string $sort = 'name', string $order = 'asc') - { - $order = ($order === 'asc') ? 'asc' : 'desc'; - $propertySorts = ['name', 'created_at', 'updated_at']; - - if (in_array($sort, $propertySorts)) { - return $query->orderBy($sort, $order); - } - - return $query; - } - - /** - * Get the most recently created entities of the given type. - * @param string $type - * @param int $count - * @param int $page - * @param bool|callable $additionalQuery - * @return Collection - */ - public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false) - { - $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type)) - ->orderBy('created_at', 'desc'); - if (strtolower($type) === 'page') { - $query = $query->where('draft', '=', false); - } - if ($additionalQuery !== false && is_callable($additionalQuery)) { - $additionalQuery($query); - } - return $query->skip($page * $count)->take($count)->get(); - } - - /** - * Get the most recently updated entities of the given type. - * @param string $type - * @param int $count - * @param int $page - * @param bool|callable $additionalQuery - * @return Collection - */ - public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false) - { - $query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type)) - ->orderBy('updated_at', 'desc'); - if (strtolower($type) === 'page') { - $query = $query->where('draft', '=', false); - } - if ($additionalQuery !== false && is_callable($additionalQuery)) { - $additionalQuery($query); - } - return $query->skip($page * $count)->take($count)->get(); - } - - /** - * Get the most recently viewed entities. - * @param string|bool $type - * @param int $count - * @param int $page - * @return mixed - */ - public function getRecentlyViewed($type, $count = 10, $page = 0) - { - $filter = is_bool($type) ? false : $this->entityProvider->get($type); - return $this->viewService->getUserRecentlyViewed($count, $page, $filter); - } - - /** - * Get the latest pages added to the system with pagination. - * @param string $type - * @param int $count - * @return mixed - */ - public function getRecentlyCreatedPaginated($type, $count = 20) - { - return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count); - } - - /** - * Get the latest pages added to the system with pagination. - * @param string $type - * @param int $count - * @return mixed - */ - public function getRecentlyUpdatedPaginated($type, $count = 20) - { - return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count); - } - - /** - * Get the most popular entities base on all views. - * @param string $type - * @param int $count - * @param int $page - * @return mixed - */ - public function getPopular(string $type, int $count = 10, int $page = 0) - { - return $this->viewService->getPopular($count, $page, $type); - } - - /** - * Get draft pages owned by the current user. - * @param int $count - * @param int $page - * @return Collection - */ - public function getUserDraftPages($count = 20, $page = 0) - { - return $this->entityProvider->page->where('draft', '=', true) - ->where('created_by', '=', user()->id) - ->orderBy('updated_at', 'desc') - ->skip($count * $page)->take($count)->get(); - } - - /** - * Get the number of entities the given user has created. - * @param string $type - * @param User $user - * @return int - */ - public function getUserTotalCreated(string $type, User $user) - { - return $this->entityProvider->get($type) - ->where('created_by', '=', $user->id)->count(); - } - - /** - * Get the child items for a chapter sorted by priority but - * with draft items floated to the top. - * @param Bookshelf $bookshelf - * @return \Illuminate\Database\Eloquent\Collection|static[] - */ - public function getBookshelfChildren(Bookshelf $bookshelf) - { - return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get(); - } - - /** - * Get the direct children of a book. - * @param Book $book - * @return \Illuminate\Database\Eloquent\Collection - */ - public function getBookDirectChildren(Book $book) - { - $pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get(); - $chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get(); - return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft'); - } - - /** - * Get all child objects of a book. - * Returns a sorted collection of Pages and Chapters. - * Loads the book slug onto child elements to prevent access database access for getting the slug. - * @param Book $book - * @param bool $filterDrafts - * @param bool $renderPages - * @return mixed - */ - public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false) - { - $q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get(); - $entities = []; - $parents = []; - $tree = []; - - foreach ($q as $index => $rawEntity) { - if ($rawEntity->entity_type === $this->entityProvider->page->getMorphClass()) { - $entities[$index] = $this->entityProvider->page->newFromBuilder($rawEntity); - if ($renderPages) { - $entities[$index]->html = $rawEntity->html; - $entities[$index]->html = $this->renderPage($entities[$index]); - }; - } else if ($rawEntity->entity_type === $this->entityProvider->chapter->getMorphClass()) { - $entities[$index] = $this->entityProvider->chapter->newFromBuilder($rawEntity); - $key = $entities[$index]->entity_type . ':' . $entities[$index]->id; - $parents[$key] = $entities[$index]; - $parents[$key]->setAttribute('pages', collect()); - } - if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') { - $tree[] = $entities[$index]; - } - $entities[$index]->book = $book; - } - - foreach ($entities as $entity) { - if ($entity->chapter_id === 0 || $entity->chapter_id === '0') { - continue; - } - $parentKey = $this->entityProvider->chapter->getMorphClass() . ':' . $entity->chapter_id; - if (!isset($parents[$parentKey])) { - $tree[] = $entity; - continue; - } - $chapter = $parents[$parentKey]; - $chapter->pages->push($entity); - } - - return collect($tree); - } - - /** - * Get the child items for a chapter sorted by priority but - * with draft items floated to the top. - * @param Chapter $chapter - * @return \Illuminate\Database\Eloquent\Collection|static[] - */ - public function getChapterChildren(Chapter $chapter) - { - return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages()) - ->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get(); - } - - - /** - * Get the next sequential priority for a new child element in the given book. - * @param Book $book - * @return int - */ - public function getNewBookPriority(Book $book) - { - $lastElem = $this->getBookChildren($book)->pop(); - return $lastElem ? $lastElem->priority + 1 : 0; - } - - /** - * Get a new priority for a new page to be added to the given chapter. - * @param Chapter $chapter - * @return int - */ - public function getNewChapterPriority(Chapter $chapter) - { - $lastPage = $chapter->pages('DESC')->first(); - return $lastPage !== null ? $lastPage->priority + 1 : 0; - } - - /** - * Find a suitable slug for an entity. - * @param string $type - * @param string $name - * @param bool|integer $currentId - * @param bool|integer $bookId Only pass if type is not a book - * @return string - */ - public function findSuitableSlug($type, $name, $currentId = false, $bookId = false) - { - $slug = $this->nameToSlug($name); - while ($this->slugExists($type, $slug, $currentId, $bookId)) { - $slug .= '-' . substr(md5(rand(1, 500)), 0, 3); - } - return $slug; - } - - - /** - * Updates entity restrictions from a request - * @param Request $request - * @param Entity $entity - * @throws Throwable - */ - public function updateEntityPermissionsFromRequest(Request $request, Entity $entity) - { - $entity->restricted = $request->get('restricted', '') === 'true'; - $entity->permissions()->delete(); - - if ($request->filled('restrictions')) { - $entityPermissionData = collect($request->get('restrictions'))->flatMap(function($restrictions, $roleId) { - return collect($restrictions)->keys()->map(function($action) use ($roleId) { - return [ - 'role_id' => $roleId, - 'action' => strtolower($action), - ] ; - }); - }); - - $entity->permissions()->createMany($entityPermissionData); - } - - $entity->save(); - $entity->rebuildPermissions(); - } - - - /** - * Create a new entity from request input. - * Used for books and chapters. - * @param string $type - * @param array $input - * @param Book|null $book - * @return Entity - */ - public function createFromInput(string $type, array $input = [], Book $book = null) - { - $entityModel = $this->entityProvider->get($type)->newInstance($input); - $entityModel->created_by = user()->id; - $entityModel->updated_by = user()->id; - - if ($book) { - $entityModel->book_id = $book->id; - } - - $entityModel->refreshSlug(); - $entityModel->save(); - - if (isset($input['tags'])) { - $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']); - } - - $entityModel->rebuildPermissions(); - $this->searchService->indexEntity($entityModel); - return $entityModel; - } - - /** - * Update entity details from request input. - * Used for shelves, books and chapters. - */ - public function updateFromInput(Entity $entityModel, array $input): Entity - { - $entityModel->fill($input); - $entityModel->updated_by = user()->id; - - if ($entityModel->isDirty('name')) { - $entityModel->refreshSlug(); - } - - $entityModel->save(); - - if (isset($input['tags'])) { - $this->tagRepo->saveTagsToEntity($entityModel, $input['tags']); - } - - $entityModel->rebuildPermissions(); - $this->searchService->indexEntity($entityModel); - return $entityModel; - } - - /** - * Sync the books assigned to a shelf from a comma-separated list - * of book IDs. - * @param Bookshelf $shelf - * @param string $books - */ - public function updateShelfBooks(Bookshelf $shelf, string $books) - { - $ids = explode(',', $books); - - // Check books exist and match ordering - $bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id'); - $syncData = []; - foreach ($ids as $index => $id) { - if ($bookIds->contains($id)) { - $syncData[$id] = ['order' => $index]; - } - } - - $shelf->books()->sync($syncData); - } - - /** - * Change the book that an entity belongs to. - */ - public function changeBook(BookChild $bookChild, int $newBookId): Entity - { - $bookChild->book_id = $newBookId; - $bookChild->refreshSlug(); - $bookChild->save(); - - // Update related activity - $bookChild->activity()->update(['book_id' => $newBookId]); - - // Update all child pages if a chapter - if ($bookChild->isA('chapter')) { - foreach ($bookChild->pages as $page) { - $this->changeBook($page, $newBookId); - } - } - - return $bookChild; - } - - /** - * Render the page for viewing - * @param Page $page - * @param bool $blankIncludes - * @return string - */ - public function renderPage(Page $page, bool $blankIncludes = false) : string - { - $content = $page->html; - - if (!config('app.allow_content_scripts')) { - $content = $this->escapeScripts($content); - } - - if ($blankIncludes) { - $content = $this->blankPageIncludes($content); - } else { - $content = $this->parsePageIncludes($content); - } - - return $content; - } - - /** - * Remove any page include tags within the given HTML. - * @param string $html - * @return string - */ - protected function blankPageIncludes(string $html) : string - { - return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html); - } - - /** - * Parse any include tags "{{@#section}}" to be part of the page. - * @param string $html - * @return mixed|string - */ - protected function parsePageIncludes(string $html) : string - { - $matches = []; - preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches); - - $topLevelTags = ['table', 'ul', 'ol']; - foreach ($matches[1] as $index => $includeId) { - $splitInclude = explode('#', $includeId, 2); - $pageId = intval($splitInclude[0]); - if (is_nan($pageId)) { - continue; - } - - $matchedPage = $this->getById('page', $pageId); - if ($matchedPage === null) { - $html = str_replace($matches[0][$index], '', $html); - continue; - } - - if (count($splitInclude) === 1) { - $html = str_replace($matches[0][$index], $matchedPage->html, $html); - continue; - } - - $doc = new DOMDocument(); - libxml_use_internal_errors(true); - $doc->loadHTML(mb_convert_encoding(''.$matchedPage->html.'', 'HTML-ENTITIES', 'UTF-8')); - $matchingElem = $doc->getElementById($splitInclude[1]); - if ($matchingElem === null) { - $html = str_replace($matches[0][$index], '', $html); - continue; - } - $innerContent = ''; - $isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags); - if ($isTopLevel) { - $innerContent .= $doc->saveHTML($matchingElem); - } else { - foreach ($matchingElem->childNodes as $childNode) { - $innerContent .= $doc->saveHTML($childNode); - } - } - libxml_clear_errors(); - $html = str_replace($matches[0][$index], trim($innerContent), $html); - } - - return $html; - } - - /** - * Escape script tags within HTML content. - * @param string $html - * @return string - */ - protected function escapeScripts(string $html) : string - { - if ($html == '') { - return $html; - } - - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8')); - $xPath = new DOMXPath($doc); - - // Remove standard script tags - $scriptElems = $xPath->query('//script'); - foreach ($scriptElems as $scriptElem) { - $scriptElem->parentNode->removeChild($scriptElem); - } - - // Remove data or JavaScript iFrames - $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]'); - foreach ($badIframes as $badIframe) { - $badIframe->parentNode->removeChild($badIframe); - } - - // Remove 'on*' attributes - $onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]'); - foreach ($onAttributes as $attr) { - /** @var \DOMAttr $attr*/ - $attrName = $attr->nodeName; - $attr->parentNode->removeAttribute($attrName); - } - - $html = ''; - $topElems = $doc->documentElement->childNodes->item(0)->childNodes; - foreach ($topElems as $child) { - $html .= $doc->saveHTML($child); - } - - return $html; - } - - /** - * Search for image usage within page content. - * @param $imageString - * @return mixed - */ - public function searchForImage($imageString) - { - $pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get(['id', 'name', 'slug', 'book_id']); - foreach ($pages as $page) { - $page->url = $page->getUrl(); - $page->html = ''; - $page->text = ''; - } - return count($pages) > 0 ? $pages : false; - } - - /** - * Destroy a bookshelf instance - * @param Bookshelf $shelf - * @throws Throwable - */ - public function destroyBookshelf(Bookshelf $shelf) - { - $this->destroyEntityCommonRelations($shelf); - $shelf->delete(); - } - - /** - * Destroy a chapter and its relations. - * @param Chapter $chapter - * @throws Throwable - */ - public function destroyChapter(Chapter $chapter) - { - if (count($chapter->pages) > 0) { - foreach ($chapter->pages as $page) { - $page->chapter_id = 0; - $page->save(); - } - } - $this->destroyEntityCommonRelations($chapter); - $chapter->delete(); - } - - /** - * Destroy a given page along with its dependencies. - * @param Page $page - * @throws NotifyException - * @throws Throwable - */ - public function destroyPage(Page $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])) { - if (setting('app-homepage-type') === 'page') { - throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl()); - } - setting()->remove('app-homepage'); - } - - $this->destroyEntityCommonRelations($page); - - // Delete Attached Files - $attachmentService = app(AttachmentService::class); - foreach ($page->attachments as $attachment) { - $attachmentService->deleteFile($attachment); - } - - $page->delete(); - } - - /** - * Destroy or handle the common relations connected to an entity. - * @param Entity $entity - * @throws Throwable - */ - protected function destroyEntityCommonRelations(Entity $entity) - { - Activity::removeEntity($entity); - $entity->views()->delete(); - $entity->permissions()->delete(); - $entity->tags()->delete(); - $entity->comments()->delete(); - $this->permissionService->deleteJointPermissionsForEntity($entity); - $this->searchService->deleteEntityTerms($entity); - } - - /** - * Copy the permissions of a bookshelf to all child books. - * Returns the number of books that had permissions updated. - * @param Bookshelf $bookshelf - * @return int - * @throws Throwable - */ - public function copyBookshelfPermissions(Bookshelf $bookshelf) - { - $shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray(); - $shelfBooks = $bookshelf->books()->get(); - $updatedBookCount = 0; - - /** @var Book $book */ - foreach ($shelfBooks as $book) { - if (!userCan('restrictions-manage', $book)) { - continue; - } - $book->permissions()->delete(); - $book->restricted = $bookshelf->restricted; - $book->permissions()->createMany($shelfPermissions); - $book->save(); - $book->rebuildPermissions(); - $updatedBookCount++; - } - - return $updatedBookCount; - } -} diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 0e0585a85..0fc68f953 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -3,91 +3,199 @@ use BookStack\Entities\Book; use BookStack\Entities\Chapter; use BookStack\Entities\Entity; +use BookStack\Entities\Managers\BookContents; +use BookStack\Entities\Managers\PageContent; +use BookStack\Entities\Managers\TrashCan; use BookStack\Entities\Page; use BookStack\Entities\PageRevision; -use Carbon\Carbon; -use DOMDocument; -use DOMElement; -use DOMXPath; +use BookStack\Exceptions\MoveOperationException; +use BookStack\Exceptions\NotFoundException; +use BookStack\Exceptions\NotifyException; +use BookStack\Exceptions\PermissionsException; +use Illuminate\Database\Eloquent\Builder; +use Illuminate\Pagination\LengthAwarePaginator; +use Illuminate\Support\Collection; -class PageRepo extends EntityRepo +class PageRepo { + protected $baseRepo; + /** - * Get page by slug. - * @param string $pageSlug - * @param string $bookSlug - * @return Page - * @throws \BookStack\Exceptions\NotFoundException + * PageRepo constructor. */ - public function getBySlug(string $pageSlug, string $bookSlug) + public function __construct(BaseRepo $baseRepo) { - return $this->getEntityBySlug('page', $pageSlug, $bookSlug); + $this->baseRepo = $baseRepo; } /** - * Search through page revisions and retrieve the last page in the - * current book that has a slug equal to the one given. - * @param string $pageSlug - * @param string $bookSlug - * @return null|Page + * Get a page by ID. + * @throws NotFoundException */ - public function getPageByOldSlug(string $pageSlug, string $bookSlug) + public function getById(int $id): Page { - $revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug) - ->whereHas('page', function ($query) { - $this->permissionService->enforceEntityRestrictions('page', $query); + $page = Page::visible()->with(['book'])->find($id); + + if (!$page) { + throw new NotFoundException(trans('errors.page_not_found')); + } + + return $page; + } + + /** + * Get a page its book and own slug. + * @throws NotFoundException + */ + public function getBySlug(string $bookSlug, string $pageSlug): Page + { + $page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first(); + + if (!$page) { + throw new NotFoundException(trans('errors.page_not_found')); + } + + return $page; + } + + /** + * Get a page by its old slug but checking the revisions table + * for the last revision that matched the given page and book slug. + */ + public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page + { + $revision = PageRevision::query() + ->whereHas('page', function (Builder $query) { + $query->visible(); }) + ->where('slug', '=', $pageSlug) ->where('type', '=', 'version') ->where('book_slug', '=', $bookSlug) ->orderBy('created_at', 'desc') - ->with('page')->first(); - return $revision !== null ? $revision->page : null; + ->with('page') + ->first(); + return $revision ? $revision->page : null; } /** - * Updates a page with any fillable data and saves it into the database. - * @param Page $page - * @param int $book_id - * @param array $input - * @return Page - * @throws \Exception + * Get pages that have been marked as a template. */ - public function updatePage(Page $page, int $book_id, array $input) + public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator + { + $query = Page::visible() + ->where('template', '=', true) + ->orderBy('name', 'asc') + ->skip(($page - 1) * $count) + ->take($count); + + if ($search) { + $query->where('name', 'like', '%' . $search . '%'); + } + + $paginator = $query->paginate($count, ['*'], 'page', $page); + $paginator->withPath('/templates'); + + return $paginator; + } + + /** + * Get a parent item via slugs. + */ + public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity + { + if ($chapterSlug !== null) { + return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail(); + } + + return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail(); + } + + /** + * Get the draft copy of the given page for the current user. + */ + public function getUserDraft(Page $page): ?PageRevision + { + $revision = $this->getUserDraftQuery($page)->first(); + return $revision; + } + + /** + * Get a new draft page belonging to the given parent entity. + */ + public function getNewDraftPage(Entity $parent) + { + $page = (new Page())->forceFill([ + 'name' => trans('entities.pages_initial_name'), + 'created_by' => user()->id, + 'updated_by' => user()->id, + 'draft' => true, + ]); + + if ($parent instanceof Chapter) { + $page->chapter_id = $parent->id; + $page->book_id = $parent->book_id; + } else { + $page->book_id = $parent->id; + } + + $page->save(); + $page->refresh()->rebuildPermissions(); + return $page; + } + + /** + * Publish a draft page to make it a live, non-draft page. + */ + public function publishDraft(Page $draft, array $input): Page + { + $this->baseRepo->update($draft, $input); + if (isset($input['template']) && userCan('templates-manage')) { + $draft->template = ($input['template'] === 'true'); + } + + $pageContent = new PageContent($draft); + $pageContent->setNewHTML($input['html']); + $draft->draft = false; + $draft->revision_count = 1; + $draft->priority = $this->getNewPriority($draft); + $draft->refreshSlug(); + $draft->save(); + + $this->savePageRevision($draft, trans('entities.pages_initial_revision')); + $draft->indexForSearch(); + return $draft->refresh(); + } + + /** + * Update a page in the system. + */ + public function update(Page $page, array $input): Page { // Hold the old details to compare later $oldHtml = $page->html; $oldName = $page->name; - // Save page tags if present - if (isset($input['tags'])) { - $this->tagRepo->saveTagsToEntity($page, $input['tags']); - } - if (isset($input['template']) && userCan('templates-manage')) { $page->template = ($input['template'] === 'true'); } + $this->baseRepo->update($page, $input); + // Update with new details - $userId = user()->id; $page->fill($input); - $page->html = $this->formatHtml($input['html']); - $page->text = $this->pageToPlainText($page); - $page->updated_by = $userId; + $pageContent = new PageContent($page); + $pageContent->setNewHTML($input['html']); $page->revision_count++; if (setting('app-editor') !== 'markdown') { $page->markdown = ''; } - if ($page->isDirty('name')) { - $page->refreshSlug(); - } - $page->save(); // Remove all update drafts for this user & page. - $this->userUpdatePageDraftsQuery($page, $userId)->delete(); + $this->getUserDraftQuery($page)->delete(); // Save a revision after updating $summary = $input['summary'] ?? null; @@ -95,24 +203,20 @@ class PageRepo extends EntityRepo $this->savePageRevision($page, $summary); } - $this->searchService->indexEntity($page); - return $page; } /** * Saves a page revision into the system. - * @param Page $page - * @param null|string $summary - * @return PageRevision - * @throws \Exception */ - public function savePageRevision(Page $page, string $summary = null) + protected function savePageRevision(Page $page, string $summary = null) { - $revision = $this->entityProvider->pageRevision->newInstance($page->toArray()); + $revision = new PageRevision($page->toArray()); + if (setting('app-editor') !== 'markdown') { $revision->markdown = ''; } + $revision->page_id = $page->id; $revision->slug = $page->slug; $revision->book_slug = $page->book->slug; @@ -123,163 +227,29 @@ class PageRepo extends EntityRepo $revision->revision_number = $page->revision_count; $revision->save(); - $revisionLimit = config('app.revision_limit'); - if ($revisionLimit !== false) { - $revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']); - if ($revisionsToDelete->count() > 0) { - $this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); - } - } - + $this->deleteOldRevisions($page); return $revision; } - /** - * Formats a page's html to be tagged correctly within the system. - * @param string $htmlText - * @return string - */ - protected function formatHtml(string $htmlText) - { - if ($htmlText == '') { - return $htmlText; - } - - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8')); - - $container = $doc->documentElement; - $body = $container->childNodes->item(0); - $childNodes = $body->childNodes; - - // Set ids on top-level nodes - $idMap = []; - foreach ($childNodes as $index => $childNode) { - $this->setUniqueId($childNode, $idMap); - } - - // Ensure no duplicate ids within child items - $xPath = new DOMXPath($doc); - $idElems = $xPath->query('//body//*//*[@id]'); - foreach ($idElems as $domElem) { - $this->setUniqueId($domElem, $idMap); - } - - // Generate inner html as a string - $html = ''; - foreach ($childNodes as $childNode) { - $html .= $doc->saveHTML($childNode); - } - - return $html; - } - - /** - * Set a unique id on the given DOMElement. - * A map for existing ID's should be passed in to check for current existence. - * @param DOMElement $element - * @param array $idMap - */ - protected function setUniqueId($element, array &$idMap) - { - if (get_class($element) !== 'DOMElement') { - return; - } - - // Overwrite id if not a BookStack custom id - $existingId = $element->getAttribute('id'); - if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) { - $idMap[$existingId] = true; - return; - } - - // Create an unique id for the element - // Uses the content as a basis to ensure output is the same every time - // the same content is passed through. - $contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20); - $newId = urlencode($contentId); - $loopIndex = 0; - - while (isset($idMap[$newId])) { - $newId = urlencode($contentId . '-' . $loopIndex); - $loopIndex++; - } - - $element->setAttribute('id', $newId); - $idMap[$newId] = true; - } - - /** - * Get the plain text version of a page's content. - * @param \BookStack\Entities\Page $page - * @return string - */ - protected function pageToPlainText(Page $page) : string - { - $html = $this->renderPage($page, true); - return strip_tags($html); - } - - /** - * Get a new draft page instance. - * @param Book $book - * @param Chapter|null $chapter - * @return \BookStack\Entities\Page - * @throws \Throwable - */ - public function getDraftPage(Book $book, Chapter $chapter = null) - { - $page = $this->entityProvider->page->newInstance(); - $page->name = trans('entities.pages_initial_name'); - $page->created_by = user()->id; - $page->updated_by = user()->id; - $page->draft = true; - - if ($chapter) { - $page->chapter_id = $chapter->id; - } - - $book->pages()->save($page); - $page->refresh()->rebuildPermissions(); - return $page; - } - /** * Save a page update draft. - * @param Page $page - * @param array $data - * @return PageRevision|Page */ - public function updatePageDraft(Page $page, array $data = []) + public function updatePageDraft(Page $page, array $input) { // If the page itself is a draft simply update that if ($page->draft) { - $page->fill($data); - if (isset($data['html'])) { - $page->text = $this->pageToPlainText($page); + $page->fill($input); + if (isset($input['html'])) { + $content = new PageContent($page); + $content->setNewHTML($input['html']); } $page->save(); return $page; } // Otherwise save the data to a revision - $userId = user()->id; - $drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get(); - - if ($drafts->count() > 0) { - $draft = $drafts->first(); - } else { - $draft = $this->entityProvider->pageRevision->newInstance(); - $draft->page_id = $page->id; - $draft->slug = $page->slug; - $draft->book_slug = $page->book->slug; - $draft->created_by = $userId; - $draft->type = 'update_draft'; - } - - $draft->fill($data); + $draft = $this->getPageRevisionToUpdate($page); + $draft->fill($input); if (setting('app-editor') !== 'markdown') { $draft->markdown = ''; } @@ -289,227 +259,76 @@ class PageRepo extends EntityRepo } /** - * Publish a draft page to make it a normal page. - * Sets the slug and updates the content. - * @param Page $draftPage - * @param array $input - * @return Page - * @throws \Exception + * Destroy a page from the system. + * @throws NotifyException */ - public function publishPageDraft(Page $draftPage, array $input) + public function destroy(Page $page) { - $draftPage->fill($input); - - // Save page tags if present - if (isset($input['tags'])) { - $this->tagRepo->saveTagsToEntity($draftPage, $input['tags']); - } - - if (isset($input['template']) && userCan('templates-manage')) { - $draftPage->template = ($input['template'] === 'true'); - } - - $draftPage->html = $this->formatHtml($input['html']); - $draftPage->text = $this->pageToPlainText($draftPage); - $draftPage->draft = false; - $draftPage->revision_count = 1; - $draftPage->refreshSlug(); - $draftPage->save(); - $this->savePageRevision($draftPage, trans('entities.pages_initial_revision')); - $this->searchService->indexEntity($draftPage); - return $draftPage; - } - - /** - * The base query for getting user update drafts. - * @param Page $page - * @param $userId - * @return mixed - */ - protected function userUpdatePageDraftsQuery(Page $page, int $userId) - { - return $this->entityProvider->pageRevision->where('created_by', '=', $userId) - ->where('type', 'update_draft') - ->where('page_id', '=', $page->id) - ->orderBy('created_at', 'desc'); - } - - /** - * Get the latest updated draft revision for a particular page and user. - * @param Page $page - * @param $userId - * @return PageRevision|null - */ - public function getUserPageDraft(Page $page, int $userId) - { - return $this->userUpdatePageDraftsQuery($page, $userId)->first(); - } - - /** - * Get the notification message that informs the user that they are editing a draft page. - * @param PageRevision $draft - * @return string - */ - public function getUserPageDraftMessage(PageRevision $draft) - { - $message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]); - if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) { - return $message; - } - return $message . "\n" . trans('entities.pages_draft_edited_notification'); - } - - /** - * A query to check for active update drafts on a particular page. - * @param Page $page - * @param int $minRange - * @return mixed - */ - protected function activePageEditingQuery(Page $page, int $minRange = null) - { - $query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft') - ->where('page_id', '=', $page->id) - ->where('updated_at', '>', $page->updated_at) - ->where('created_by', '!=', user()->id) - ->with('createdBy'); - - if ($minRange !== null) { - $query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange)); - } - - return $query; - } - - /** - * Check if a page is being actively editing. - * Checks for edits since last page updated. - * Passing in a minuted range will check for edits - * within the last x minutes. - * @param Page $page - * @param int $minRange - * @return bool - */ - public function isPageEditingActive(Page $page, int $minRange = null) - { - $draftSearch = $this->activePageEditingQuery($page, $minRange); - return $draftSearch->count() > 0; - } - - /** - * Get a notification message concerning the editing activity on a particular page. - * @param Page $page - * @param int $minRange - * @return string - */ - public function getPageEditingActiveMessage(Page $page, int $minRange = null) - { - $pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get(); - - $userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]); - $timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]); - return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]); - } - - /** - * Parse the headers on the page to get a navigation menu - * @param string $pageContent - * @return array - */ - public function getPageNav(string $pageContent) - { - if ($pageContent == '') { - return []; - } - libxml_use_internal_errors(true); - $doc = new DOMDocument(); - $doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8')); - $xPath = new DOMXPath($doc); - $headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6"); - - if (is_null($headers)) { - return []; - } - - $tree = collect($headers)->map(function ($header) { - $text = trim(str_replace("\xc2\xa0", '', $header->nodeValue)); - $text = mb_substr($text, 0, 100); - - return [ - 'nodeName' => strtolower($header->nodeName), - 'level' => intval(str_replace('h', '', $header->nodeName)), - 'link' => '#' . $header->getAttribute('id'), - 'text' => $text, - ]; - })->filter(function ($header) { - return mb_strlen($header['text']) > 0; - }); - - // Shift headers if only smaller headers have been used - $levelChange = ($tree->pluck('level')->min() - 1); - $tree = $tree->map(function ($header) use ($levelChange) { - $header['level'] -= ($levelChange); - return $header; - }); - - return $tree->toArray(); + $trashCan = new TrashCan(); + $trashCan->destroyPage($page); } /** * Restores a revision's content back into a page. - * @param Page $page - * @param Book $book - * @param int $revisionId - * @return Page - * @throws \Exception */ - public function restorePageRevision(Page $page, Book $book, int $revisionId) + public function restoreRevision(Page $page, int $revisionId): Page { $page->revision_count++; $this->savePageRevision($page); $revision = $page->revisions()->where('id', '=', $revisionId)->first(); $page->fill($revision->toArray()); - $page->text = $this->pageToPlainText($page); + $content = new PageContent($page); + $content->setNewHTML($page->html); $page->updated_by = user()->id; $page->refreshSlug(); $page->save(); - $this->searchService->indexEntity($page); + $page->indexForSearch(); return $page; } /** - * Change the page's parent to the given entity. - * @param Page $page - * @param Entity $parent + * Move the given page into a new parent book or chapter. + * The $parentIdentifier must be a string of the following format: + * 'book:' (book:5) + * @throws MoveOperationException + * @throws PermissionsException */ - public function changePageParent(Page $page, Entity $parent) + public function move(Page $page, string $parentIdentifier): Book { - $book = $parent->isA('book') ? $parent : $parent->book; - $page->chapter_id = $parent->isA('chapter') ? $parent->id : 0; - $page->save(); - - if ($page->book->id !== $book->id) { - $page = $this->changeBook($page, $book->id); + $parent = $this->findParentByIdentifier($parentIdentifier); + if ($parent === null) { + throw new MoveOperationException('Book or chapter to move page into not found'); } - $page->load('book'); - $book->rebuildPermissions(); + if (!userCan('page-create', $parent)) { + throw new PermissionsException('User does not have permission to create a page within the new parent'); + } + + $page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id); + $page->rebuildPermissions(); + return $parent; } /** - * Create a copy of a page in a new location with a new name. - * @param \BookStack\Entities\Page $page - * @param \BookStack\Entities\Entity $newParent - * @param string $newName - * @return \BookStack\Entities\Page - * @throws \Throwable + * Copy an existing page in the system. + * Optionally providing a new parent via string identifier and a new name. + * @throws MoveOperationException + * @throws PermissionsException */ - public function copyPage(Page $page, Entity $newParent, string $newName = '') + public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page { - $newBook = $newParent->isA('book') ? $newParent : $newParent->book; - $newChapter = $newParent->isA('chapter') ? $newParent : null; - $copyPage = $this->getDraftPage($newBook, $newChapter); + $parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent(); + if ($parent === null) { + throw new MoveOperationException('Book or chapter to move page into not found'); + } + + if (!userCan('page-create', $parent)) { + throw new PermissionsException('User does not have permission to create a page within the new parent'); + } + + $copyPage = $this->getNewDraftPage($parent); $pageData = $page->getAttributes(); // Update name @@ -525,38 +344,116 @@ class PageRepo extends EntityRepo } } - // Set priority - if ($newParent->isA('chapter')) { - $pageData['priority'] = $this->getNewChapterPriority($newParent); - } else { - $pageData['priority'] = $this->getNewBookPriority($newParent); - } - - return $this->publishPageDraft($copyPage, $pageData); + return $this->publishDraft($copyPage, $pageData); } /** - * Get pages that have been marked as templates. - * @param int $count - * @param int $page - * @param string $search - * @return \Illuminate\Contracts\Pagination\LengthAwarePaginator + * Find a page parent entity via a identifier string in the format: + * {type}:{id} + * Example: (book:5) + * @throws MoveOperationException */ - public function getPageTemplates(int $count = 10, int $page = 1, string $search = '') + protected function findParentByIdentifier(string $identifier): ?Entity { - $query = $this->entityQuery('page') - ->where('template', '=', true) - ->orderBy('name', 'asc') - ->skip(($page - 1) * $count) - ->take($count); + $stringExploded = explode(':', $identifier); + $entityType = $stringExploded[0]; + $entityId = intval($stringExploded[1]); - if ($search) { - $query->where('name', 'like', '%' . $search . '%'); + if ($entityType !== 'book' && $entityType !== 'chapter') { + throw new MoveOperationException('Pages can only be in books or chapters'); } - $paginator = $query->paginate($count, ['*'], 'page', $page); - $paginator->withPath('/templates'); + $parentClass = $entityType === 'book' ? Book::class : Chapter::class; + return $parentClass::visible()->where('id', '=', $entityId)->first(); + } - return $paginator; + /** + * Update the permissions of a page. + */ + public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null) + { + $this->baseRepo->updatePermissions($page, $restricted, $permissions); + } + + /** + * Change the page's parent to the given entity. + */ + protected function changeParent(Page $page, Entity $parent) + { + $book = ($parent instanceof Book) ? $parent : $parent->book; + $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0; + $page->save(); + + if ($page->book->id !== $book->id) { + $page->changeBook($book->id); + } + + $page->load('book'); + $book->rebuildPermissions(); + } + + /** + * Get a page revision to update for the given page. + * Checks for an existing revisions before providing a fresh one. + */ + protected function getPageRevisionToUpdate(Page $page): PageRevision + { + $drafts = $this->getUserDraftQuery($page)->get(); + if ($drafts->count() > 0) { + return $drafts->first(); + } + + $draft = new PageRevision(); + $draft->page_id = $page->id; + $draft->slug = $page->slug; + $draft->book_slug = $page->book->slug; + $draft->created_by = user()->id; + $draft->type = 'update_draft'; + return $draft; + } + + /** + * Delete old revisions, for the given page, from the system. + */ + protected function deleteOldRevisions(Page $page) + { + $revisionLimit = config('app.revision_limit'); + if ($revisionLimit === false) { + return; + } + + $revisionsToDelete = PageRevision::query() + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc') + ->skip(intval($revisionLimit)) + ->take(10) + ->get(['id']); + if ($revisionsToDelete->count() > 0) { + PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete(); + } + } + + /** + * Get a new priority for a page + */ + protected function getNewPriority(Page $page): int + { + if ($page->parent() instanceof Chapter) { + $lastPage = $page->parent()->pages('desc')->first(); + return $lastPage ? $lastPage->priority + 1 : 0; + } + + return (new BookContents($page->book))->getLastPriority() + 1; + } + + /** + * Get the query to find the user's draft copies of the given page. + */ + protected function getUserDraftQuery(Page $page) + { + return PageRevision::query()->where('created_by', '=', user()->id) + ->where('type', 'update_draft') + ->where('page_id', '=', $page->id) + ->orderBy('created_at', 'desc'); } } diff --git a/app/Entities/SlugGenerator.php b/app/Entities/SlugGenerator.php index e68e00b06..459a5264a 100644 --- a/app/Entities/SlugGenerator.php +++ b/app/Entities/SlugGenerator.php @@ -59,6 +59,4 @@ class SlugGenerator return $query->count() > 0; } - - -} \ No newline at end of file +} diff --git a/app/Exceptions/MoveOperationException.php b/app/Exceptions/MoveOperationException.php new file mode 100644 index 000000000..c237dfad3 --- /dev/null +++ b/app/Exceptions/MoveOperationException.php @@ -0,0 +1,8 @@ +attachmentService = $attachmentService; $this->attachment = $attachment; - $this->entityRepo = $entityRepo; + $this->pageRepo = $pageRepo; parent::__construct(); } /** * Endpoint at which attachments are uploaded to. - * @param Request $request - * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response + * @throws ValidationException + * @throws NotFoundException */ public function upload(Request $request) { @@ -41,7 +41,7 @@ class AttachmentController extends Controller ]); $pageId = $request->get('uploaded_to'); - $page = $this->entityRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $this->checkPermission('attachment-create-all'); $this->checkOwnablePermission('page-update', $page); @@ -59,10 +59,8 @@ class AttachmentController extends Controller /** * Update an uploaded attachment. - * @param Request $request - * @param int $attachmentId - * @return mixed - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException + * @throws NotFoundException */ public function uploadUpdate(Request $request, $attachmentId) { @@ -72,7 +70,7 @@ class AttachmentController extends Controller ]); $pageId = $request->get('uploaded_to'); - $page = $this->entityRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $attachment = $this->attachment->findOrFail($attachmentId); $this->checkOwnablePermission('page-update', $page); @@ -95,10 +93,8 @@ class AttachmentController extends Controller /** * Update the details of an existing file. - * @param Request $request - * @param $attachmentId - * @return Attachment|mixed - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException + * @throws NotFoundException */ public function update(Request $request, $attachmentId) { @@ -109,7 +105,7 @@ class AttachmentController extends Controller ]); $pageId = $request->get('uploaded_to'); - $page = $this->entityRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $attachment = $this->attachment->findOrFail($attachmentId); $this->checkOwnablePermission('page-update', $page); @@ -125,8 +121,8 @@ class AttachmentController extends Controller /** * Attach a link to a page. - * @param Request $request - * @return mixed + * @throws ValidationException + * @throws NotFoundException */ public function attachLink(Request $request) { @@ -137,7 +133,7 @@ class AttachmentController extends Controller ]); $pageId = $request->get('uploaded_to'); - $page = $this->entityRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $this->checkPermission('attachment-create-all'); $this->checkOwnablePermission('page-update', $page); @@ -151,30 +147,26 @@ class AttachmentController extends Controller /** * Get the attachments for a specific page. - * @param $pageId - * @return mixed */ - public function listForPage($pageId) + public function listForPage(int $pageId) { - $page = $this->entityRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-view', $page); return response()->json($page->attachments); } /** * Update the attachment sorting. - * @param Request $request - * @param $pageId - * @return mixed - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException + * @throws NotFoundException */ - public function sortForPage(Request $request, $pageId) + public function sortForPage(Request $request, int $pageId) { $this->validate($request, [ 'files' => 'required|array', 'files.*.id' => 'required|integer', ]); - $page = $this->entityRepo->getById('page', $pageId); + $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-update', $page); $attachments = $request->get('files'); @@ -184,16 +176,15 @@ class AttachmentController extends Controller /** * Get an attachment from storage. - * @param $attachmentId - * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response - * @throws \Illuminate\Contracts\Filesystem\FileNotFoundException + * @throws FileNotFoundException * @throws NotFoundException */ - public function get($attachmentId) + public function get(int $attachmentId) { $attachment = $this->attachment->findOrFail($attachmentId); - $page = $this->entityRepo->getById('page', $attachment->uploaded_to); - if ($page === null) { + try { + $page = $this->pageRepo->getById($attachment->uploaded_to); + } catch (NotFoundException $exception) { throw new NotFoundException(trans('errors.attachment_not_found')); } @@ -211,9 +202,9 @@ class AttachmentController extends Controller * Delete a specific attachment in the system. * @param $attachmentId * @return mixed - * @throws \Exception + * @throws Exception */ - public function delete($attachmentId) + public function delete(int $attachmentId) { $attachment = $this->attachment->findOrFail($attachmentId); $this->checkOwnablePermission('attachment-delete', $attachment); diff --git a/app/Http/Controllers/Auth/ConfirmEmailController.php b/app/Http/Controllers/Auth/ConfirmEmailController.php index 3959fe685..099558eb7 100644 --- a/app/Http/Controllers/Auth/ConfirmEmailController.php +++ b/app/Http/Controllers/Auth/ConfirmEmailController.php @@ -65,14 +65,14 @@ class ConfirmEmailController extends Controller $userId = $this->emailConfirmationService->checkTokenAndGetUserId($token); } catch (Exception $exception) { if ($exception instanceof UserTokenNotFoundException) { - $this->showErrorNotification( trans('errors.email_confirmation_invalid')); + $this->showErrorNotification(trans('errors.email_confirmation_invalid')); return redirect('/register'); } if ($exception instanceof UserTokenExpiredException) { $user = $this->userRepo->getById($exception->userId); $this->emailConfirmationService->sendConfirmation($user); - $this->showErrorNotification( trans('errors.email_confirmation_expired')); + $this->showErrorNotification(trans('errors.email_confirmation_expired')); return redirect('/register/confirm'); } @@ -84,7 +84,7 @@ class ConfirmEmailController extends Controller $user->save(); auth()->login($user); - $this->showSuccessNotification( trans('auth.email_confirm_success')); + $this->showSuccessNotification(trans('auth.email_confirm_success')); $this->emailConfirmationService->deleteByUser($user); return redirect('/'); @@ -106,11 +106,11 @@ class ConfirmEmailController extends Controller try { $this->emailConfirmationService->sendConfirmation($user); } catch (Exception $e) { - $this->showErrorNotification( trans('auth.email_confirm_send_error')); + $this->showErrorNotification(trans('auth.email_confirm_send_error')); return redirect('/register/confirm'); } - $this->showSuccessNotification( trans('auth.email_confirm_resent')); + $this->showSuccessNotification(trans('auth.email_confirm_resent')); return redirect('/register/confirm'); } } diff --git a/app/Http/Controllers/Auth/ForgotPasswordController.php b/app/Http/Controllers/Auth/ForgotPasswordController.php index 4a0a69ae4..a3c0433a5 100644 --- a/app/Http/Controllers/Auth/ForgotPasswordController.php +++ b/app/Http/Controllers/Auth/ForgotPasswordController.php @@ -53,7 +53,7 @@ class ForgotPasswordController extends Controller if ($response === Password::RESET_LINK_SENT) { $message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]); - $this->showSuccessNotification( $message); + $this->showSuccessNotification($message); return back()->with('status', trans($response)); } diff --git a/app/Http/Controllers/Auth/ResetPasswordController.php b/app/Http/Controllers/Auth/ResetPasswordController.php index 540d2e679..4d98eca59 100644 --- a/app/Http/Controllers/Auth/ResetPasswordController.php +++ b/app/Http/Controllers/Auth/ResetPasswordController.php @@ -44,7 +44,7 @@ class ResetPasswordController extends Controller protected function sendResetResponse(Request $request, $response) { $message = trans('auth.reset_password_success'); - $this->showSuccessNotification( $message); + $this->showSuccessNotification($message); return redirect($this->redirectPath()) ->with('status', trans($response)); } diff --git a/app/Http/Controllers/Auth/UserInviteController.php b/app/Http/Controllers/Auth/UserInviteController.php index 313faf5bc..c361b0a9b 100644 --- a/app/Http/Controllers/Auth/UserInviteController.php +++ b/app/Http/Controllers/Auth/UserInviteController.php @@ -77,7 +77,7 @@ class UserInviteController extends Controller $user->save(); auth()->login($user); - $this->showSuccessNotification( trans('auth.user_invite_success', ['appName' => setting('app-name')])); + $this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')])); $this->inviteService->deleteByUser($user); return redirect('/'); @@ -96,7 +96,7 @@ class UserInviteController extends Controller } if ($exception instanceof UserTokenExpiredException) { - $this->showErrorNotification( trans('errors.invite_token_expired')); + $this->showErrorNotification(trans('errors.invite_token_expired')); return redirect('/password/email'); } diff --git a/app/Http/Controllers/BookController.php b/app/Http/Controllers/BookController.php index a9a24d2ff..e7d788d91 100644 --- a/app/Http/Controllers/BookController.php +++ b/app/Http/Controllers/BookController.php @@ -1,22 +1,14 @@ bookRepo = $bookRepo; - $this->userRepo = $userRepo; $this->entityContextManager = $entityContextManager; - $this->imageRepo = $imageRepo; parent::__construct(); } /** * Display a listing of the book. - * @return Response */ public function index() { @@ -58,10 +37,10 @@ class BookController extends Controller $sort = setting()->getForCurrentUser('books_sort', 'name'); $order = setting()->getForCurrentUser('books_sort_order', 'asc'); - $books = $this->bookRepo->getAllPaginated('book', 18, $sort, $order); - $recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed('book', 4, 0) : false; - $popular = $this->bookRepo->getPopular('book', 4, 0); - $new = $this->bookRepo->getRecentlyCreated('book', 4, 0); + $books = $this->bookRepo->getAllPaginated(18, $sort, $order); + $recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false; + $popular = $this->bookRepo->getPopular(4); + $new = $this->bookRepo->getRecentlyCreated(4); $this->entityContextManager->clearShelfContext(); @@ -79,19 +58,17 @@ class BookController extends Controller /** * Show the form for creating a new book. - * @param string $shelfSlug - * @return Response - * @throws NotFoundException */ public function create(string $shelfSlug = null) { + $this->checkPermission('book-create-all'); + $bookshelf = null; if ($shelfSlug !== null) { - $bookshelf = $this->bookRepo->getEntityBySlug('bookshelf', $shelfSlug); + $bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail(); $this->checkOwnablePermission('bookshelf-update', $bookshelf); } - $this->checkPermission('book-create-all'); $this->setPageTitle(trans('entities.books_create')); return view('books.create', [ 'bookshelf' => $bookshelf @@ -100,11 +77,6 @@ class BookController extends Controller /** * Store a newly created book in storage. - * - * @param Request $request - * @param string $shelfSlug - * @return Response - * @throws NotFoundException * @throws ImageUploadException * @throws ValidationException */ @@ -114,19 +86,17 @@ class BookController extends Controller $this->validate($request, [ 'name' => 'required|string|max:255', 'description' => 'string|max:1000', - 'image' => $this->imageRepo->getImageValidationRules(), + 'image' => $this->getImageValidationRules(), ]); $bookshelf = null; if ($shelfSlug !== null) { - /** @var Bookshelf $bookshelf */ - $bookshelf = $this->bookRepo->getEntityBySlug('bookshelf', $shelfSlug); + $bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail(); $this->checkOwnablePermission('bookshelf-update', $bookshelf); } - /** @var Book $book */ - $book = $this->bookRepo->createFromInput('book', $request->all()); - $this->bookUpdateActions($book, $request); + $book = $this->bookRepo->create($request->all()); + $this->bookRepo->updateCoverImage($book, $request->file('image', null)); Activity::add($book, 'book_create', $book->id); if ($bookshelf) { @@ -139,17 +109,11 @@ class BookController extends Controller /** * Display the specified book. - * @param Request $request - * @param string $slug - * @return Response - * @throws NotFoundException */ public function show(Request $request, string $slug) { $book = $this->bookRepo->getBySlug($slug); - $this->checkOwnablePermission('book-view', $book); - - $bookChildren = $this->bookRepo->getBookChildren($book); + $bookChildren = (new BookContents($book))->getTree(true); Views::add($book); if ($request->has('shelf')) { @@ -167,9 +131,6 @@ class BookController extends Controller /** * Show the form for editing the specified book. - * @param string $slug - * @return Response - * @throws NotFoundException */ public function edit(string $slug) { @@ -181,11 +142,7 @@ class BookController extends Controller /** * Update the specified book in storage. - * @param Request $request - * @param string $slug - * @return Response * @throws ImageUploadException - * @throws NotFoundException * @throws ValidationException * @throws Throwable */ @@ -196,22 +153,20 @@ class BookController extends Controller $this->validate($request, [ 'name' => 'required|string|max:255', 'description' => 'string|max:1000', - 'image' => $this->imageRepo->getImageValidationRules(), + 'image' => $this->getImageValidationRules(), ]); - $book = $this->bookRepo->updateFromInput($book, $request->all()); - $this->bookUpdateActions($book, $request); + $book = $this->bookRepo->update($book, $request->all()); + $resetCover = $request->has('image_reset'); + $this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover); - Activity::add($book, 'book_update', $book->id); + Activity::add($book, 'book_update', $book->id); - return redirect($book->getUrl()); + return redirect($book->getUrl()); } /** - * Shows the page to confirm deletion - * @param string $bookSlug - * @return View - * @throws NotFoundException + * Shows the page to confirm deletion. */ public function showDelete(string $bookSlug) { @@ -222,115 +177,7 @@ class BookController extends Controller } /** - * Shows the view which allows pages to be re-ordered and sorted. - * @param string $bookSlug - * @return View - * @throws NotFoundException - */ - public function sort(string $bookSlug) - { - $book = $this->bookRepo->getBySlug($bookSlug); - $this->checkOwnablePermission('book-update', $book); - - $bookChildren = $this->bookRepo->getBookChildren($book, true); - - $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()])); - return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]); - } - - /** - * Shows the sort box for a single book. - * Used via AJAX when loading in extra books to a sort. - * @param string $bookSlug - * @return Factory|View - * @throws NotFoundException - */ - public function sortItem(string $bookSlug) - { - $book = $this->bookRepo->getBySlug($bookSlug); - $bookChildren = $this->bookRepo->getBookChildren($book); - return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]); - } - - /** - * Saves an array of sort mapping to pages and chapters. - * @param Request $request - * @param string $bookSlug - * @return RedirectResponse|Redirector - * @throws NotFoundException - */ - public function saveSort(Request $request, string $bookSlug) - { - $book = $this->bookRepo->getBySlug($bookSlug); - $this->checkOwnablePermission('book-update', $book); - - // Return if no map sent - if (!$request->filled('sort-tree')) { - return redirect($book->getUrl()); - } - - // Sort pages and chapters - $sortMap = collect(json_decode($request->get('sort-tree'))); - $bookIdsInvolved = collect([$book->id]); - - // Load models into map - $sortMap->each(function ($mapItem) use ($bookIdsInvolved) { - $mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter'); - $mapItem->model = $this->bookRepo->getById($mapItem->type, $mapItem->id); - // Store source and target books - $bookIdsInvolved->push(intval($mapItem->model->book_id)); - $bookIdsInvolved->push(intval($mapItem->book)); - }); - - // Get the books involved in the sort - $bookIdsInvolved = $bookIdsInvolved->unique()->toArray(); - $booksInvolved = $this->bookRepo->getManyById('book', $bookIdsInvolved, false, true); - - // Throw permission error if invalid ids or inaccessible books given. - if (count($bookIdsInvolved) !== count($booksInvolved)) { - $this->showPermissionError(); - } - - // Check permissions of involved books - $booksInvolved->each(function (Book $book) { - $this->checkOwnablePermission('book-update', $book); - }); - - // Perform the sort - $sortMap->each(function ($mapItem) { - $model = $mapItem->model; - - $priorityChanged = intval($model->priority) !== intval($mapItem->sort); - $bookChanged = intval($model->book_id) !== intval($mapItem->book); - $chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter; - - if ($bookChanged) { - $this->bookRepo->changeBook($model, $mapItem->book); - } - if ($chapterChanged) { - $model->chapter_id = intval($mapItem->parentChapter); - $model->save(); - } - if ($priorityChanged) { - $model->priority = intval($mapItem->sort); - $model->save(); - } - }); - - // Rebuild permissions and add activity for involved books. - $booksInvolved->each(function (Book $book) { - $book->rebuildPermissions(); - Activity::add($book, 'book_sort', $book->id); - }); - - return redirect($book->getUrl()); - } - - /** - * Remove the specified book from storage. - * @param string $bookSlug - * @return Response - * @throws NotFoundException + * Remove the specified book from the system. * @throws Throwable * @throws NotifyException */ @@ -338,72 +185,40 @@ class BookController extends Controller { $book = $this->bookRepo->getBySlug($bookSlug); $this->checkOwnablePermission('book-delete', $book); - Activity::addMessage('book_delete', $book->name); - if ($book->cover) { - $this->imageRepo->destroyImage($book->cover); - } - $this->bookRepo->destroyBook($book); + Activity::addMessage('book_delete', $book->name); + $this->bookRepo->destroy($book); return redirect('/books'); } /** - * Show the Restrictions view. - * @param string $bookSlug - * @return Factory|View - * @throws NotFoundException + * Show the permissions view. */ public function showPermissions(string $bookSlug) { $book = $this->bookRepo->getBySlug($bookSlug); $this->checkOwnablePermission('restrictions-manage', $book); - $roles = $this->userRepo->getRestrictableRoles(); + return view('books.permissions', [ 'book' => $book, - 'roles' => $roles ]); } /** * Set the restrictions for this book. - * @param Request $request - * @param string $bookSlug - * @return RedirectResponse|Redirector - * @throws NotFoundException * @throws Throwable */ public function permissions(Request $request, string $bookSlug) { $book = $this->bookRepo->getBySlug($bookSlug); $this->checkOwnablePermission('restrictions-manage', $book); - $this->bookRepo->updateEntityPermissionsFromRequest($request, $book); + + $restricted = $request->get('restricted') === 'true'; + $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; + $this->bookRepo->updatePermissions($book, $restricted, $permissions); + $this->showSuccessNotification(trans('entities.books_permissions_updated')); return redirect($book->getUrl()); } - - /** - * Common actions to run on book update. - * Handles updating the cover image. - * @param Book $book - * @param Request $request - * @throws ImageUploadException - */ - protected function bookUpdateActions(Book $book, Request $request) - { - // Update the cover image if in request - if ($request->has('image')) { - $this->imageRepo->destroyImage($book->cover); - $newImage = $request->file('image'); - $image = $this->imageRepo->saveNew($newImage, 'cover_book', $book->id, 512, 512, true); - $book->image_id = $image->id; - $book->save(); - } - - if ($request->has('image_reset')) { - $this->imageRepo->destroyImage($book->cover); - $book->image_id = 0; - $book->save(); - } - } } diff --git a/app/Http/Controllers/BookExportController.php b/app/Http/Controllers/BookExportController.php index ae3f56b81..cfa3d6a3a 100644 --- a/app/Http/Controllers/BookExportController.php +++ b/app/Http/Controllers/BookExportController.php @@ -4,25 +4,16 @@ namespace BookStack\Http\Controllers; use BookStack\Entities\ExportService; use BookStack\Entities\Repos\BookRepo; -use BookStack\Exceptions\NotFoundException; use Throwable; class BookExportController extends Controller { - /** - * @var BookRepo - */ - protected $bookRepo; - /** - * @var ExportService - */ + protected $bookRepo; protected $exportService; /** * BookExportController constructor. - * @param BookRepo $bookRepo - * @param ExportService $exportService */ public function __construct(BookRepo $bookRepo, ExportService $exportService) { @@ -33,9 +24,6 @@ class BookExportController extends Controller /** * Export a book as a PDF file. - * @param string $bookSlug - * @return mixed - * @throws NotFoundException * @throws Throwable */ public function pdf(string $bookSlug) @@ -47,9 +35,6 @@ class BookExportController extends Controller /** * Export a book as a contained HTML file. - * @param string $bookSlug - * @return mixed - * @throws NotFoundException * @throws Throwable */ public function html(string $bookSlug) @@ -61,9 +46,6 @@ class BookExportController extends Controller /** * Export a book as a plain text file. - * @param $bookSlug - * @return mixed - * @throws NotFoundException */ public function plainText(string $bookSlug) { diff --git a/app/Http/Controllers/BookSortController.php b/app/Http/Controllers/BookSortController.php new file mode 100644 index 000000000..f5fb6f255 --- /dev/null +++ b/app/Http/Controllers/BookSortController.php @@ -0,0 +1,82 @@ +bookRepo = $bookRepo; + parent::__construct(); + } + + /** + * Shows the view which allows pages to be re-ordered and sorted. + */ + public function show(string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-update', $book); + + $bookChildren = (new BookContents($book))->getTree(false); + + $this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()])); + return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]); + } + + /** + * Shows the sort box for a single book. + * Used via AJAX when loading in extra books to a sort. + */ + public function showItem(string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $bookChildren = (new BookContents($book))->getTree(); + return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]); + } + + /** + * Sorts a book using a given mapping array. + */ + public function update(Request $request, string $bookSlug) + { + $book = $this->bookRepo->getBySlug($bookSlug); + $this->checkOwnablePermission('book-update', $book); + + // Return if no map sent + if (!$request->filled('sort-tree')) { + return redirect($book->getUrl()); + } + + $sortMap = collect(json_decode($request->get('sort-tree'))); + $bookContents = new BookContents($book); + $booksInvolved = collect(); + + try { + $booksInvolved = $bookContents->sortUsingMap($sortMap); + } catch (SortOperationException $exception) { + $this->showPermissionError(); + } + + // Rebuild permissions and add activity for involved books. + $booksInvolved->each(function (Book $book) { + Activity::add($book, 'book_sort', $book->id); + }); + + return redirect($book->getUrl()); + } +} diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index bef96dd01..57e67dc00 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -1,34 +1,30 @@ entityRepo = $entityRepo; - $this->userRepo = $userRepo; + $this->bookshelfRepo = $bookshelfRepo; $this->entityContextManager = $entityContextManager; $this->imageRepo = $imageRepo; parent::__construct(); @@ -36,7 +32,6 @@ class BookshelfController extends Controller /** * Display a listing of the book. - * @return Response */ public function index() { @@ -49,14 +44,10 @@ class BookshelfController extends Controller 'updated_at' => trans('common.sort_updated_at'), ]; - $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18, $sort, $order); - foreach ($shelves as $shelf) { - $shelf->books = $this->entityRepo->getBookshelfChildren($shelf); - } - - $recents = $this->isSignedIn() ? $this->entityRepo->getRecentlyViewed('bookshelf', 4, 0) : false; - $popular = $this->entityRepo->getPopular('bookshelf', 4, 0); - $new = $this->entityRepo->getRecentlyCreated('bookshelf', 4, 0); + $shelves = $this->bookshelfRepo->getAllPaginated(18, $sort, $order); + $recents = $this->isSignedIn() ? $this->bookshelfRepo->getRecentlyViewed(4) : false; + $popular = $this->bookshelfRepo->getPopular(4); + $new = $this->bookshelfRepo->getRecentlyCreated(4); $this->entityContextManager->clearShelfContext(); $this->setPageTitle(trans('entities.shelves')); @@ -74,21 +65,19 @@ class BookshelfController extends Controller /** * Show the form for creating a new bookshelf. - * @return Response */ public function create() { $this->checkPermission('bookshelf-create-all'); - $books = $this->entityRepo->getAll('book', false, 'update'); + $books = Book::hasPermission('update')->get(); $this->setPageTitle(trans('entities.shelves_create')); return view('shelves.create', ['books' => $books]); } /** * Store a newly created bookshelf in storage. - * @param Request $request - * @return Response - * @throws \BookStack\Exceptions\ImageUploadException + * @throws ValidationException + * @throws ImageUploadException */ public function store(Request $request) { @@ -96,80 +85,63 @@ class BookshelfController extends Controller $this->validate($request, [ 'name' => 'required|string|max:255', 'description' => 'string|max:1000', - 'image' => $this->imageRepo->getImageValidationRules(), + 'image' => $this->getImageValidationRules(), ]); - $shelf = $this->entityRepo->createFromInput('bookshelf', $request->all()); - $this->shelfUpdateActions($shelf, $request); + $bookIds = explode(',', $request->get('books', '')); + $shelf = $this->bookshelfRepo->create($request->all(), $bookIds); + $this->bookshelfRepo->updateCoverImage($shelf); Activity::add($shelf, 'bookshelf_create'); return redirect($shelf->getUrl()); } - /** - * Display the specified bookshelf. - * @param String $slug - * @return Response - * @throws \BookStack\Exceptions\NotFoundException + * Display the bookshelf of the given slug. + * @throws NotFoundException */ public function show(string $slug) { - /** @var Bookshelf $shelf */ - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('book-view', $shelf); - $books = $this->entityRepo->getBookshelfChildren($shelf); Views::add($shelf); $this->entityContextManager->setShelfContext($shelf->id); $this->setPageTitle($shelf->getShortName()); - return view('shelves.show', [ 'shelf' => $shelf, - 'books' => $books, 'activity' => Activity::entityActivity($shelf, 20, 1) ]); } /** * Show the form for editing the specified bookshelf. - * @param $slug - * @return Response - * @throws \BookStack\Exceptions\NotFoundException */ public function edit(string $slug) { - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */ + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('bookshelf-update', $shelf); - $shelfBooks = $this->entityRepo->getBookshelfChildren($shelf); - $shelfBookIds = $shelfBooks->pluck('id'); - $books = $this->entityRepo->getAll('book', false, 'update'); - $books = $books->filter(function ($book) use ($shelfBookIds) { - return !$shelfBookIds->contains($book->id); - }); + $shelfBookIds = $shelf->books()->get(['id'])->pluck('id'); + $books = Book::hasPermission('update')->whereNotIn('id', $shelfBookIds)->get(); $this->setPageTitle(trans('entities.shelves_edit_named', ['name' => $shelf->getShortName()])); return view('shelves.edit', [ 'shelf' => $shelf, 'books' => $books, - 'shelfBooks' => $shelfBooks, ]); } - /** * Update the specified bookshelf in storage. - * @param Request $request - * @param string $slug - * @return Response - * @throws \BookStack\Exceptions\NotFoundException - * @throws \BookStack\Exceptions\ImageUploadException + * @throws ValidationException + * @throws ImageUploadException + * @throws NotFoundException */ public function update(Request $request, string $slug) { - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $bookshelf Bookshelf */ + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('bookshelf-update', $shelf); $this->validate($request, [ 'name' => 'required|string|max:255', @@ -177,24 +149,22 @@ class BookshelfController extends Controller 'image' => $this->imageRepo->getImageValidationRules(), ]); - $shelf = $this->entityRepo->updateFromInput($shelf, $request->all()); - $this->shelfUpdateActions($shelf, $request); - Activity::add($shelf, 'bookshelf_update'); + $bookIds = explode(',', $request->get('books', '')); + $shelf = $this->bookshelfRepo->update($shelf, $request->all(), $bookIds); + $resetCover = $request->has('image_reset'); + $this->bookshelfRepo->updateCoverImage($shelf, $request->file('image', null), $resetCover); + Activity::add($shelf, 'bookshelf_update'); - return redirect($shelf->getUrl()); + return redirect($shelf->getUrl()); } - /** * Shows the page to confirm deletion - * @param $slug - * @return \Illuminate\View\View - * @throws \BookStack\Exceptions\NotFoundException */ public function showDelete(string $slug) { - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */ + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('bookshelf-delete', $shelf); $this->setPageTitle(trans('entities.shelves_delete_named', ['name' => $shelf->getShortName()])); @@ -203,101 +173,58 @@ class BookshelfController extends Controller /** * Remove the specified bookshelf from storage. - * @param string $slug - * @return Response - * @throws \BookStack\Exceptions\NotFoundException - * @throws \Throwable + * @throws Exception */ public function destroy(string $slug) { - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); /** @var $shelf Bookshelf */ + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('bookshelf-delete', $shelf); - Activity::addMessage('bookshelf_delete', $shelf->name); - if ($shelf->cover) { - $this->imageRepo->destroyImage($shelf->cover); - } - $this->entityRepo->destroyBookshelf($shelf); + Activity::addMessage('bookshelf_delete', $shelf->name); + $this->bookshelfRepo->destroy($shelf); return redirect('/shelves'); } /** * Show the permissions view. - * @param string $slug - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View - * @throws \BookStack\Exceptions\NotFoundException */ public function showPermissions(string $slug) { - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('restrictions-manage', $shelf); - $roles = $this->userRepo->getRestrictableRoles(); return view('shelves.permissions', [ 'shelf' => $shelf, - 'roles' => $roles ]); } /** * Set the permissions for this bookshelf. - * @param Request $request - * @param string $slug - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector - * @throws \BookStack\Exceptions\NotFoundException - * @throws \Throwable */ public function permissions(Request $request, string $slug) { - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('restrictions-manage', $shelf); - $this->entityRepo->updateEntityPermissionsFromRequest($request, $shelf); - $this->showSuccessNotification( trans('entities.shelves_permissions_updated')); + $restricted = $request->get('restricted') === 'true'; + $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; + $this->bookshelfRepo->updatePermissions($shelf, $restricted, $permissions); + + $this->showSuccessNotification(trans('entities.shelves_permissions_updated')); return redirect($shelf->getUrl()); } /** * Copy the permissions of a bookshelf to the child books. - * @param string $slug - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector - * @throws \BookStack\Exceptions\NotFoundException */ public function copyPermissions(string $slug) { - $shelf = $this->entityRepo->getEntityBySlug('bookshelf', $slug); + $shelf = $this->bookshelfRepo->getBySlug($slug); $this->checkOwnablePermission('restrictions-manage', $shelf); - $updateCount = $this->entityRepo->copyBookshelfPermissions($shelf); - $this->showSuccessNotification( trans('entities.shelves_copy_permission_success', ['count' => $updateCount])); + $updateCount = $this->bookshelfRepo->copyDownPermissions($shelf); + $this->showSuccessNotification(trans('entities.shelves_copy_permission_success', ['count' => $updateCount])); return redirect($shelf->getUrl()); } - - /** - * Common actions to run on bookshelf update. - * @param Bookshelf $shelf - * @param Request $request - * @throws \BookStack\Exceptions\ImageUploadException - */ - protected function shelfUpdateActions(Bookshelf $shelf, Request $request) - { - // Update the books that the shelf references - $this->entityRepo->updateShelfBooks($shelf, $request->get('books', '')); - - // Update the cover image if in request - if ($request->has('image')) { - $newImage = $request->file('image'); - $this->imageRepo->destroyImage($shelf->cover); - $image = $this->imageRepo->saveNew($newImage, 'cover_shelf', $shelf->id, 512, 512, true); - $shelf->image_id = $image->id; - $shelf->save(); - } - - if ($request->has('image_reset')) { - $this->imageRepo->destroyImage($shelf->cover); - $shelf->image_id = 0; - $shelf->save(); - } - } } diff --git a/app/Http/Controllers/ChapterController.php b/app/Http/Controllers/ChapterController.php index f728d1313..135597910 100644 --- a/app/Http/Controllers/ChapterController.php +++ b/app/Http/Controllers/ChapterController.php @@ -1,50 +1,45 @@ entityRepo = $entityRepo; - $this->userRepo = $userRepo; + $this->chapterRepo = $chapterRepo; parent::__construct(); } /** * Show the form for creating a new chapter. - * @param $bookSlug - * @return Response */ - public function create($bookSlug) + public function create(string $bookSlug) { - $book = $this->entityRepo->getEntityBySlug('book', $bookSlug); + $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail(); $this->checkOwnablePermission('chapter-create', $book); + $this->setPageTitle(trans('entities.chapters_create')); return view('chapters.create', ['book' => $book, 'current' => $book]); } /** * Store a newly created chapter in storage. - * @param Request $request - * @param string $bookSlug - * @return Response - * @throws \BookStack\Exceptions\NotFoundException - * @throws \Illuminate\Validation\ValidationException + * @throws ValidationException */ public function store(Request $request, string $bookSlug) { @@ -52,30 +47,28 @@ class ChapterController extends Controller 'name' => 'required|string|max:255' ]); - $book = $this->entityRepo->getEntityBySlug('book', $bookSlug); + $book = Book::visible()->where('slug', '=', $bookSlug)->firstOrFail(); $this->checkOwnablePermission('chapter-create', $book); - $input = $request->all(); - $input['priority'] = $this->entityRepo->getNewBookPriority($book); - $chapter = $this->entityRepo->createFromInput('chapter', $input, $book); + $chapter = $this->chapterRepo->create($request->all(), $book); Activity::add($chapter, 'chapter_create', $book->id); + return redirect($chapter->getUrl()); } /** * Display the specified chapter. - * @param $bookSlug - * @param $chapterSlug - * @return Response */ - public function show($bookSlug, $chapterSlug) + public function show(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-view', $chapter); - $sidebarTree = $this->entityRepo->getBookChildren($chapter->book); + + $sidebarTree = (new BookContents($chapter->book))->getTree(); + $pages = $chapter->getVisiblePages(); Views::add($chapter); + $this->setPageTitle($chapter->getShortName()); - $pages = $this->entityRepo->getChapterChildren($chapter); return view('chapters.show', [ 'book' => $chapter->book, 'chapter' => $chapter, @@ -87,79 +80,71 @@ class ChapterController extends Controller /** * Show the form for editing the specified chapter. - * @param $bookSlug - * @param $chapterSlug - * @return Response */ - public function edit($bookSlug, $chapterSlug) + public function edit(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-update', $chapter); + $this->setPageTitle(trans('entities.chapters_edit_named', ['chapterName' => $chapter->getShortName()])); return view('chapters.edit', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]); } /** * Update the specified chapter in storage. - * @param Request $request - * @param string $bookSlug - * @param string $chapterSlug - * @return Response - * @throws \BookStack\Exceptions\NotFoundException + * @throws NotFoundException */ public function update(Request $request, string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-update', $chapter); - $this->entityRepo->updateFromInput($chapter, $request->all()); + $this->chapterRepo->update($chapter, $request->all()); Activity::add($chapter, 'chapter_update', $chapter->book->id); + return redirect($chapter->getUrl()); } /** * Shows the page to confirm deletion of this chapter. - * @param $bookSlug - * @param $chapterSlug - * @return \Illuminate\View\View + * @throws NotFoundException */ - public function showDelete($bookSlug, $chapterSlug) + public function showDelete(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-delete', $chapter); + $this->setPageTitle(trans('entities.chapters_delete_named', ['chapterName' => $chapter->getShortName()])); return view('chapters.delete', ['book' => $chapter->book, 'chapter' => $chapter, 'current' => $chapter]); } /** * Remove the specified chapter from storage. - * @param $bookSlug - * @param $chapterSlug - * @return Response + * @throws NotFoundException + * @throws Throwable */ - public function destroy($bookSlug, $chapterSlug) + public function destroy(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); - $book = $chapter->book; + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-delete', $chapter); - Activity::addMessage('chapter_delete', $chapter->name, $book->id); - $this->entityRepo->destroyChapter($chapter); - return redirect($book->getUrl()); + + Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id); + $this->chapterRepo->destroy($chapter); + + return redirect($chapter->book->getUrl()); } /** * Show the page for moving a chapter. - * @param $bookSlug - * @param $chapterSlug - * @return mixed - * @throws \BookStack\Exceptions\NotFoundException + * @throws NotFoundException */ - public function showMove($bookSlug, $chapterSlug) + public function showMove(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->setPageTitle(trans('entities.chapters_move_named', ['chapterName' => $chapter->getShortName()])); $this->checkOwnablePermission('chapter-update', $chapter); $this->checkOwnablePermission('chapter-delete', $chapter); + return view('chapters.move', [ 'chapter' => $chapter, 'book' => $chapter->book @@ -168,15 +153,11 @@ class ChapterController extends Controller /** * Perform the move action for a chapter. - * @param Request $request - * @param string $bookSlug - * @param string $chapterSlug - * @return mixed - * @throws \BookStack\Exceptions\NotFoundException + * @throws NotFoundException */ public function move(Request $request, string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('chapter-update', $chapter); $this->checkOwnablePermission('chapter-delete', $chapter); @@ -185,63 +166,47 @@ class ChapterController extends Controller return redirect($chapter->getUrl()); } - $stringExploded = explode(':', $entitySelection); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); - - $parent = false; - - if ($entityType == 'book') { - $parent = $this->entityRepo->getById('book', $entityId); - } - - if ($parent === false || $parent === null) { - $this->showErrorNotification( trans('errors.selected_book_not_found')); + try { + $newBook = $this->chapterRepo->move($chapter, $entitySelection); + } catch (MoveOperationException $exception) { + $this->showErrorNotification(trans('errors.selected_book_not_found')); return redirect()->back(); } - $this->entityRepo->changeBook($chapter, $parent->id); - $chapter->rebuildPermissions(); - - Activity::add($chapter, 'chapter_move', $chapter->book->id); - $this->showSuccessNotification( trans('entities.chapter_move_success', ['bookName' => $parent->name])); + Activity::add($chapter, 'chapter_move', $newBook->id); + $this->showSuccessNotification(trans('entities.chapter_move_success', ['bookName' => $newBook->name])); return redirect($chapter->getUrl()); } /** * Show the Restrictions view. - * @param $bookSlug - * @param $chapterSlug - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View - * @throws \BookStack\Exceptions\NotFoundException + * @throws NotFoundException */ - public function showPermissions($bookSlug, $chapterSlug) + public function showPermissions(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('restrictions-manage', $chapter); - $roles = $this->userRepo->getRestrictableRoles(); + return view('chapters.permissions', [ 'chapter' => $chapter, - 'roles' => $roles ]); } /** * Set the restrictions for this chapter. - * @param Request $request - * @param string $bookSlug - * @param string $chapterSlug - * @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector - * @throws \BookStack\Exceptions\NotFoundException - * @throws \Throwable + * @throws NotFoundException */ public function permissions(Request $request, string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $this->checkOwnablePermission('restrictions-manage', $chapter); - $this->entityRepo->updateEntityPermissionsFromRequest($request, $chapter); - $this->showSuccessNotification( trans('entities.chapters_permissions_success')); + + $restricted = $request->get('restricted') === 'true'; + $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; + $this->chapterRepo->updatePermissions($chapter, $restricted, $permissions); + + $this->showSuccessNotification(trans('entities.chapters_permissions_success')); return redirect($chapter->getUrl()); } } diff --git a/app/Http/Controllers/ChapterExportController.php b/app/Http/Controllers/ChapterExportController.php index 15d67d539..0c86f8548 100644 --- a/app/Http/Controllers/ChapterExportController.php +++ b/app/Http/Controllers/ChapterExportController.php @@ -1,77 +1,57 @@ -entityRepo = $entityRepo; + $this->chapterRepo = $chapterRepo; $this->exportService = $exportService; parent::__construct(); } /** - * Exports a chapter to pdf . - * @param string $bookSlug - * @param string $chapterSlug - * @return Response + * Exports a chapter to pdf. * @throws NotFoundException * @throws Throwable */ public function pdf(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $pdfContent = $this->exportService->chapterToPdf($chapter); return $this->downloadResponse($pdfContent, $chapterSlug . '.pdf'); } /** * Export a chapter to a self-contained HTML file. - * @param string $bookSlug - * @param string $chapterSlug - * @return Response * @throws NotFoundException * @throws Throwable */ public function html(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $containedHtml = $this->exportService->chapterToContainedHtml($chapter); return $this->downloadResponse($containedHtml, $chapterSlug . '.html'); } /** * Export a chapter to a simple plaintext .txt file. - * @param string $bookSlug - * @param string $chapterSlug - * @return Response * @throws NotFoundException */ public function plainText(string $bookSlug, string $chapterSlug) { - $chapter = $this->entityRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); + $chapter = $this->chapterRepo->getBySlug($bookSlug, $chapterSlug); $chapterText = $this->exportService->chapterToPlainText($chapter); return $this->downloadResponse($chapterText, $chapterSlug . '.txt'); } diff --git a/app/Http/Controllers/CommentController.php b/app/Http/Controllers/CommentController.php index 860b50762..068358d72 100644 --- a/app/Http/Controllers/CommentController.php +++ b/app/Http/Controllers/CommentController.php @@ -2,44 +2,36 @@ use Activity; use BookStack\Actions\CommentRepo; -use BookStack\Entities\Repos\EntityRepo; -use Illuminate\Database\Eloquent\ModelNotFoundException; +use BookStack\Entities\Page; use Illuminate\Http\Request; +use Illuminate\Validation\ValidationException; class CommentController extends Controller { - protected $entityRepo; protected $commentRepo; /** * CommentController constructor. - * @param \BookStack\Entities\Repos\EntityRepo $entityRepo - * @param \BookStack\Actions\CommentRepo $commentRepo */ - public function __construct(EntityRepo $entityRepo, CommentRepo $commentRepo) + public function __construct(CommentRepo $commentRepo) { - $this->entityRepo = $entityRepo; $this->commentRepo = $commentRepo; parent::__construct(); } /** * Save a new comment for a Page - * @param Request $request - * @param integer $pageId - * @param null|integer $commentId - * @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response + * @throws ValidationException */ - public function savePageComment(Request $request, $pageId, $commentId = null) + public function savePageComment(Request $request, int $pageId, int $commentId = null) { $this->validate($request, [ 'text' => 'required|string', 'html' => 'required|string', ]); - try { - $page = $this->entityRepo->getById('page', $pageId, true); - } catch (ModelNotFoundException $e) { + $page = Page::visible()->find($pageId); + if ($page === null) { return response('Not found', 404); } @@ -59,11 +51,9 @@ class CommentController extends Controller /** * Update an existing comment. - * @param Request $request - * @param integer $commentId - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View + * @throws ValidationException */ - public function update(Request $request, $commentId) + public function update(Request $request, int $commentId) { $this->validate($request, [ 'text' => 'required|string', @@ -80,13 +70,12 @@ class CommentController extends Controller /** * Delete a comment from the system. - * @param integer $id - * @return \Illuminate\Http\JsonResponse */ - public function destroy($id) + public function destroy(int $id) { $comment = $this->commentRepo->getById($id); $this->checkOwnablePermission('comment-delete', $comment); + $this->commentRepo->delete($comment); return response()->json(['message' => trans('entities.comment_deleted')]); } diff --git a/app/Http/Controllers/Controller.php b/app/Http/Controllers/Controller.php index 034c852de..b9576f2fe 100644 --- a/app/Http/Controllers/Controller.php +++ b/app/Http/Controllers/Controller.php @@ -59,7 +59,7 @@ abstract class Controller extends BaseController $response = response()->json(['error' => trans('errors.permissionJson')], 403); } else { $response = redirect('/'); - $this->showErrorNotification( trans('errors.permission')); + $this->showErrorNotification(trans('errors.permission')); } throw new HttpResponseException($response); @@ -129,7 +129,7 @@ abstract class Controller extends BaseController */ protected function jsonError($messageText = "", $statusCode = 500) { - return response()->json(['message' => $messageText], $statusCode); + return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode); } /** @@ -189,4 +189,12 @@ abstract class Controller extends BaseController { session()->flash('error', $message); } + + /** + * Get the validation rules for image files. + */ + protected function getImageValidationRules(): string + { + return 'image_extension|no_double_extension|mimes:jpeg,png,gif,bmp,webp,tiff'; + } } diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index a37371d3a..260952fd1 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -1,23 +1,16 @@ entityRepo = $entityRepo; - parent::__construct(); - } /** * Display the homepage. @@ -26,10 +19,20 @@ class HomeController extends Controller public function index() { $activity = Activity::latest(10); - $draftPages = $this->isSignedIn() ? $this->entityRepo->getUserDraftPages(6) : []; + $draftPages = []; + + if ($this->isSignedIn()) { + $draftPages = Page::visible()->where('draft', '=', true) + ->where('created_by', '=', user()->id) + ->orderBy('updated_at', 'desc')->take(6)->get(); + } + $recentFactor = count($draftPages) > 0 ? 0.5 : 1; - $recents = $this->isSignedIn() ? Views::getUserRecentlyViewed(12*$recentFactor, 0) : $this->entityRepo->getRecentlyCreated('book', 12*$recentFactor); - $recentlyUpdatedPages = $this->entityRepo->getRecentlyUpdated('page', 12); + $recents = $this->isSignedIn() ? + Views::getUserRecentlyViewed(12*$recentFactor, 0) + : Book::visible()->orderBy('created_at', 'desc')->take(12 * $recentFactor)->get(); + $recentlyUpdatedPages = Page::visible()->where('draft', false) + ->orderBy('updated_at', 'desc')->take(12)->get(); $homepageOptions = ['default', 'books', 'bookshelves', 'page']; $homepageOption = setting('app-homepage-type', 'default'); @@ -66,16 +69,18 @@ class HomeController extends Controller } if ($homepageOption === 'bookshelves') { - $shelves = $this->entityRepo->getAllPaginated('bookshelf', 18, $commonData['sort'], $commonData['order']); + $shelfRepo = app(BookshelfRepo::class); + $shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']); foreach ($shelves as $shelf) { - $shelf->books = $this->entityRepo->getBookshelfChildren($shelf); + $shelf->books = $shelf->visibleBooks; } $data = array_merge($commonData, ['shelves' => $shelves]); return view('common.home-shelves', $data); } if ($homepageOption === 'books') { - $books = $this->entityRepo->getAllPaginated('book', 18, $commonData['sort'], $commonData['order']); + $bookRepo = app(BookRepo::class); + $books = $bookRepo->getAllPaginated(18, $commonData['sort'], $commonData['order']); $data = array_merge($commonData, ['books' => $books]); return view('common.home-book', $data); } @@ -83,8 +88,9 @@ class HomeController extends Controller if ($homepageOption === 'page') { $homepageSetting = setting('app-homepage', '0:'); $id = intval(explode(':', $homepageSetting)[0]); - $customHomepage = $this->entityRepo->getById('page', $id, false, true); - $this->entityRepo->renderPage($customHomepage, true); + $customHomepage = Page::query()->where('draft', '=', false)->findOrFail($id); + $pageContent = new PageContent($customHomepage); + $customHomepage->html = $pageContent->render(true); return view('common.home-custom', array_merge($commonData, ['customHomepage' => $customHomepage])); } diff --git a/app/Http/Controllers/Images/ImageController.php b/app/Http/Controllers/Images/ImageController.php index 79a23df27..9c67704dd 100644 --- a/app/Http/Controllers/Images/ImageController.php +++ b/app/Http/Controllers/Images/ImageController.php @@ -1,6 +1,6 @@ imageRepo->getById($id); $this->checkImagePermission($image); - $pageSearch = $entityRepo->searchForImage($image->url); - return response()->json($pageSearch); + + $pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']); + foreach ($pages as $page) { + $page->url = $page->getUrl(); + $page->html = ''; + $page->text = ''; + } + $result = count($pages) > 0 ? $pages : false; + + return response()->json($result); } /** diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 736fcf4f6..630f888ed 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -1,18 +1,17 @@ pageRepo = $pageRepo; - $this->userRepo = $userRepo; parent::__construct(); } /** * Show the form for creating a new page. - * @param string $bookSlug - * @param string $chapterSlug - * @return Response - * @internal param bool $pageSlug - * @throws NotFoundException + * @throws Throwable */ - public function create($bookSlug, $chapterSlug = null) + public function create(string $bookSlug, string $chapterSlug = null) { - if ($chapterSlug !== null) { - $chapter = $this->pageRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); - $book = $chapter->book; - } else { - $chapter = null; - $book = $this->pageRepo->getEntityBySlug('book', $bookSlug); - } - - $parent = $chapter ? $chapter : $book; + $parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug); $this->checkOwnablePermission('page-create', $parent); // Redirect to draft edit screen if signed in if ($this->isSignedIn()) { - $draft = $this->pageRepo->getDraftPage($book, $chapter); + $draft = $this->pageRepo->getNewDraftPage($parent); return redirect($draft->getUrl()); } @@ -68,51 +51,38 @@ class PageController extends Controller /** * Create a new page as a guest user. - * @param Request $request - * @param string $bookSlug - * @param string|null $chapterSlug - * @return mixed - * @throws NotFoundException + * @throws ValidationException */ - public function createAsGuest(Request $request, $bookSlug, $chapterSlug = null) + public function createAsGuest(Request $request, string $bookSlug, string $chapterSlug = null) { $this->validate($request, [ 'name' => 'required|string|max:255' ]); - if ($chapterSlug !== null) { - $chapter = $this->pageRepo->getEntityBySlug('chapter', $chapterSlug, $bookSlug); - $book = $chapter->book; - } else { - $chapter = null; - $book = $this->pageRepo->getEntityBySlug('book', $bookSlug); - } - - $parent = $chapter ? $chapter : $book; + $parent = $this->pageRepo->getParentFromSlugs($bookSlug, $chapterSlug); $this->checkOwnablePermission('page-create', $parent); - $page = $this->pageRepo->getDraftPage($book, $chapter); - $this->pageRepo->publishPageDraft($page, [ + $page = $this->pageRepo->getNewDraftPage($parent); + $this->pageRepo->publishDraft($page, [ 'name' => $request->get('name'), 'html' => '' ]); + return redirect($page->getUrl('/edit')); } /** * Show form to continue editing a draft page. - * @param string $bookSlug - * @param int $pageId - * @return Factory|View + * @throws NotFoundException */ - public function editDraft($bookSlug, $pageId) + public function editDraft(string $bookSlug, int $pageId) { - $draft = $this->pageRepo->getById('page', $pageId, true); - $this->checkOwnablePermission('page-create', $draft->parent); + $draft = $this->pageRepo->getById($pageId); + $this->checkOwnablePermission('page-create', $draft->parent()); $this->setPageTitle(trans('entities.pages_edit_draft')); $draftsEnabled = $this->isSignedIn(); - $templates = $this->pageRepo->getPageTemplates(10); + $templates = $this->pageRepo->getTemplates(10); return view('pages.edit', [ 'page' => $draft, @@ -125,63 +95,50 @@ class PageController extends Controller /** * Store a new page by changing a draft into a page. - * @param Request $request - * @param string $bookSlug - * @param int $pageId - * @return Response + * @throws NotFoundException + * @throws ValidationException */ - public function store(Request $request, $bookSlug, $pageId) + public function store(Request $request, string $bookSlug, int $pageId) { $this->validate($request, [ 'name' => 'required|string|max:255' ]); + $draftPage = $this->pageRepo->getById($pageId); + $this->checkOwnablePermission('page-create', $draftPage->parent()); - $input = $request->all(); - $draftPage = $this->pageRepo->getById('page', $pageId, true); - $book = $draftPage->book; + $page = $this->pageRepo->publishDraft($draftPage, $request->all()); + Activity::add($page, 'page_create', $draftPage->book->id); - $parent = $draftPage->parent; - $this->checkOwnablePermission('page-create', $parent); - - if ($parent->isA('chapter')) { - $input['priority'] = $this->pageRepo->getNewChapterPriority($parent); - } else { - $input['priority'] = $this->pageRepo->getNewBookPriority($parent); - } - - $page = $this->pageRepo->publishPageDraft($draftPage, $input); - - Activity::add($page, 'page_create', $book->id); return redirect($page->getUrl()); } /** * Display the specified page. * If the page is not found via the slug the revisions are searched for a match. - * @param string $bookSlug - * @param string $pageSlug - * @return Response * @throws NotFoundException */ - public function show($bookSlug, $pageSlug) + public function show(string $bookSlug, string $pageSlug) { try { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); } catch (NotFoundException $e) { - $page = $this->pageRepo->getPageByOldSlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getByOldSlug($bookSlug, $pageSlug); + if ($page === null) { throw $e; } + return redirect($page->getUrl()); } $this->checkOwnablePermission('page-view', $page); - $page->html = $this->pageRepo->renderPage($page); - $sidebarTree = $this->pageRepo->getBookChildren($page->book); - $pageNav = $this->pageRepo->getPageNav($page->html); + $pageContent = (new PageContent($page)); + $page->html = $pageContent->render(); + $sidebarTree = (new BookContents($page->book))->getTree(); + $pageNav = $pageContent->getNavigation($page->html); - // check if the comment's are enabled + // Check if page comments are enabled $commentsEnabled = !setting('app-disable-comments'); if ($commentsEnabled) { $page->load(['comments.createdBy']); @@ -190,7 +147,8 @@ class PageController extends Controller Views::add($page); $this->setPageTitle($page->getShortName()); return view('pages.show', [ - 'page' => $page,'book' => $page->book, + 'page' => $page, + 'book' => $page->book, 'current' => $page, 'sidebarTree' => $sidebarTree, 'commentsEnabled' => $commentsEnabled, @@ -200,52 +158,47 @@ class PageController extends Controller /** * Get page from an ajax request. - * @param int $pageId - * @return JsonResponse + * @throws NotFoundException */ - public function getPageAjax($pageId) + public function getPageAjax(int $pageId) { - $page = $this->pageRepo->getById('page', $pageId); + $page = $this->pageRepo->getById($pageId); return response()->json($page); } /** * Show the form for editing the specified page. - * @param string $bookSlug - * @param string $pageSlug - * @return Response * @throws NotFoundException */ - public function edit($bookSlug, $pageSlug) + public function edit(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-update', $page); - $this->setPageTitle(trans('entities.pages_editing_named', ['pageName'=>$page->getShortName()])); + $page->isDraft = false; + $editActivity = new PageEditActivity($page); // Check for active editing $warnings = []; - if ($this->pageRepo->isPageEditingActive($page, 60)) { - $warnings[] = $this->pageRepo->getPageEditingActiveMessage($page, 60); + if ($editActivity->hasActiveEditing()) { + $warnings[] = $editActivity->activeEditingMessage(); } // Check for a current draft version for this user - $userPageDraft = $this->pageRepo->getUserPageDraft($page, user()->id); - if ($userPageDraft !== null) { - $page->name = $userPageDraft->name; - $page->html = $userPageDraft->html; - $page->markdown = $userPageDraft->markdown; + $userDraft = $this->pageRepo->getUserDraft($page); + if ($userDraft !== null) { + $page->forceFill($userDraft->only(['name', 'html', 'markdown'])); $page->isDraft = true; - $warnings [] = $this->pageRepo->getUserPageDraftMessage($userPageDraft); + $warnings[] = $editActivity->getEditingActiveDraftMessage($userDraft); } if (count($warnings) > 0) { - $this->showWarningNotification( implode("\n", $warnings)); + $this->showWarningNotification(implode("\n", $warnings)); } + $templates = $this->pageRepo->getTemplates(10); $draftsEnabled = $this->isSignedIn(); - $templates = $this->pageRepo->getPageTemplates(10); - + $this->setPageTitle(trans('entities.pages_editing_named', ['pageName' => $page->getShortName()])); return view('pages.edit', [ 'page' => $page, 'book' => $page->book, @@ -257,39 +210,34 @@ class PageController extends Controller /** * Update the specified page in storage. - * @param Request $request - * @param string $bookSlug - * @param string $pageSlug - * @return Response + * @throws ValidationException + * @throws NotFoundException */ - public function update(Request $request, $bookSlug, $pageSlug) + public function update(Request $request, string $bookSlug, string $pageSlug) { $this->validate($request, [ 'name' => 'required|string|max:255' ]); - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-update', $page); - $this->pageRepo->updatePage($page, $page->book->id, $request->all()); + + $this->pageRepo->update($page, $request->all()); Activity::add($page, 'page_update', $page->book->id); + return redirect($page->getUrl()); } /** * Save a draft update as a revision. - * @param Request $request - * @param int $pageId - * @return JsonResponse + * @throws NotFoundException */ - public function saveDraft(Request $request, $pageId) + public function saveDraft(Request $request, int $pageId) { - $page = $this->pageRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-update', $page); if (!$this->isSignedIn()) { - return response()->json([ - 'status' => 'error', - 'message' => trans('errors.guests_cannot_save_drafts'), - ], 500); + return $this->jsonError(trans('errors.guests_cannot_save_drafts'), 500); } $draft = $this->pageRepo->updatePageDraft($page, $request->only(['name', 'html', 'markdown'])); @@ -303,211 +251,98 @@ class PageController extends Controller } /** - * Redirect from a special link url which - * uses the page id rather than the name. - * @param int $pageId - * @return RedirectResponse|Redirector + * Redirect from a special link url which uses the page id rather than the name. + * @throws NotFoundException */ - public function redirectFromLink($pageId) + public function redirectFromLink(int $pageId) { - $page = $this->pageRepo->getById('page', $pageId); + $page = $this->pageRepo->getById($pageId); return redirect($page->getUrl()); } /** * Show the deletion page for the specified page. - * @param string $bookSlug - * @param string $pageSlug - * @return View + * @throws NotFoundException */ - public function showDelete($bookSlug, $pageSlug) + public function showDelete(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-delete', $page); $this->setPageTitle(trans('entities.pages_delete_named', ['pageName'=>$page->getShortName()])); - return view('pages.delete', ['book' => $page->book, 'page' => $page, 'current' => $page]); + return view('pages.delete', [ + 'book' => $page->book, + 'page' => $page, + 'current' => $page + ]); } - /** * Show the deletion page for the specified page. - * @param string $bookSlug - * @param int $pageId - * @return View * @throws NotFoundException */ - public function showDeleteDraft($bookSlug, $pageId) + public function showDeleteDraft(string $bookSlug, int $pageId) { - $page = $this->pageRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $this->checkOwnablePermission('page-update', $page); $this->setPageTitle(trans('entities.pages_delete_draft_named', ['pageName'=>$page->getShortName()])); - return view('pages.delete', ['book' => $page->book, 'page' => $page, 'current' => $page]); + return view('pages.delete', [ + 'book' => $page->book, + 'page' => $page, + 'current' => $page + ]); } /** * Remove the specified page from storage. - * @param string $bookSlug - * @param string $pageSlug - * @return Response - * @internal param int $id + * @throws NotFoundException + * @throws Throwable + * @throws NotifyException */ - public function destroy($bookSlug, $pageSlug) + public function destroy(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $book = $page->book; + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-delete', $page); - $this->pageRepo->destroyPage($page); + $book = $page->book; + $this->pageRepo->destroy($page); Activity::addMessage('page_delete', $page->name, $book->id); - $this->showSuccessNotification( trans('entities.pages_delete_success')); + + $this->showSuccessNotification(trans('entities.pages_delete_success')); return redirect($book->getUrl()); } /** * Remove the specified draft page from storage. - * @param string $bookSlug - * @param int $pageId - * @return Response * @throws NotFoundException + * @throws NotifyException + * @throws Throwable */ - public function destroyDraft($bookSlug, $pageId) + public function destroyDraft(string $bookSlug, int $pageId) { - $page = $this->pageRepo->getById('page', $pageId, true); + $page = $this->pageRepo->getById($pageId); $book = $page->book; + $chapter = $page->chapter; $this->checkOwnablePermission('page-update', $page); - $this->showSuccessNotification( trans('entities.pages_delete_draft_success')); - $this->pageRepo->destroyPage($page); + + $this->pageRepo->destroy($page); + + $this->showSuccessNotification(trans('entities.pages_delete_draft_success')); + + if ($chapter && userCan('view', $chapter)) { + return redirect($chapter->getUrl()); + } return redirect($book->getUrl()); } /** - * Shows the last revisions for this page. - * @param string $bookSlug - * @param string $pageSlug - * @return View - * @throws NotFoundException - */ - public function showRevisions($bookSlug, $pageSlug) - { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()])); - return view('pages.revisions', ['page' => $page, 'current' => $page]); - } - - /** - * Shows a preview of a single revision - * @param string $bookSlug - * @param string $pageSlug - * @param int $revisionId - * @return View - */ - public function showRevision($bookSlug, $pageSlug, $revisionId) - { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $revision = $page->revisions()->where('id', '=', $revisionId)->first(); - if ($revision === null) { - abort(404); - } - - $page->fill($revision->toArray()); - $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()])); - - return view('pages.revision', [ - 'page' => $page, - 'book' => $page->book, - 'diff' => null, - 'revision' => $revision - ]); - } - - /** - * Shows the changes of a single revision - * @param string $bookSlug - * @param string $pageSlug - * @param int $revisionId - * @return View - */ - public function showRevisionChanges($bookSlug, $pageSlug, $revisionId) - { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $revision = $page->revisions()->where('id', '=', $revisionId)->first(); - if ($revision === null) { - abort(404); - } - - $prev = $revision->getPrevious(); - $prevContent = ($prev === null) ? '' : $prev->html; - $diff = (new Htmldiff)->diff($prevContent, $revision->html); - - $page->fill($revision->toArray()); - $this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()])); - - return view('pages.revision', [ - 'page' => $page, - 'book' => $page->book, - 'diff' => $diff, - 'revision' => $revision - ]); - } - - /** - * Restores a page using the content of the specified revision. - * @param string $bookSlug - * @param string $pageSlug - * @param int $revisionId - * @return RedirectResponse|Redirector - */ - public function restoreRevision($bookSlug, $pageSlug, $revisionId) - { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $this->checkOwnablePermission('page-update', $page); - $page = $this->pageRepo->restorePageRevision($page, $page->book, $revisionId); - Activity::add($page, 'page_restore', $page->book->id); - return redirect($page->getUrl()); - } - - - /** - * Deletes a revision using the id of the specified revision. - * @param string $bookSlug - * @param string $pageSlug - * @param int $revId - * @return RedirectResponse|Redirector - *@throws BadRequestException - * @throws NotFoundException - */ - public function destroyRevision($bookSlug, $pageSlug, $revId) - { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $this->checkOwnablePermission('page-delete', $page); - - $revision = $page->revisions()->where('id', '=', $revId)->first(); - if ($revision === null) { - throw new NotFoundException("Revision #{$revId} not found"); - } - - // Get the current revision for the page - $currentRevision = $page->getCurrentRevision(); - - // Check if its the latest revision, cannot delete latest revision. - if (intval($currentRevision->id) === intval($revId)) { - $this->showErrorNotification( trans('entities.revision_cannot_delete_latest')); - return response()->view('pages.revisions', ['page' => $page, 'book' => $page->book, 'current' => $page], 400); - } - - $revision->delete(); - $this->showSuccessNotification( trans('entities.revision_delete_success')); - return redirect($page->getUrl('/revisions')); - } - - /** - * Show a listing of recently created pages - * @return Factory|View + * Show a listing of recently created pages. */ public function showRecentlyUpdated() { - // TODO - Still exist? - $pages = $this->pageRepo->getRecentlyUpdatedPaginated('page', 20)->setPath(url('/pages/recently-updated')); + $pages = Page::visible()->orderBy('updated_at', 'desc') + ->paginate(20) + ->setPath(url('/pages/recently-updated')); + return view('pages.detailed-listing', [ 'title' => trans('entities.recently_updated_pages'), 'pages' => $pages @@ -516,14 +351,11 @@ class PageController extends Controller /** * Show the view to choose a new parent to move a page into. - * @param string $bookSlug - * @param string $pageSlug - * @return mixed * @throws NotFoundException */ - public function showMove($bookSlug, $pageSlug) + public function showMove(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-update', $page); $this->checkOwnablePermission('page-delete', $page); return view('pages.move', [ @@ -533,17 +365,13 @@ class PageController extends Controller } /** - * Does the action of moving the location of a page - * @param Request $request - * @param string $bookSlug - * @param string $pageSlug - * @return mixed + * Does the action of moving the location of a page. * @throws NotFoundException * @throws Throwable */ public function move(Request $request, string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-update', $page); $this->checkOwnablePermission('page-delete', $page); @@ -552,37 +380,29 @@ class PageController extends Controller return redirect($page->getUrl()); } - $stringExploded = explode(':', $entitySelection); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); - - try { - $parent = $this->pageRepo->getById($entityType, $entityId); - } catch (Exception $e) { - session()->flash(trans('entities.selected_book_chapter_not_found')); + $parent = $this->pageRepo->move($page, $entitySelection); + } catch (Exception $exception) { + if ($exception instanceof PermissionsException) { + $this->showPermissionError(); + } + + $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); return redirect()->back(); } - $this->checkOwnablePermission('page-create', $parent); - - $this->pageRepo->changePageParent($page, $parent); Activity::add($page, 'page_move', $page->book->id); - $this->showSuccessNotification( trans('entities.pages_move_success', ['parentName' => $parent->name])); - + $this->showSuccessNotification(trans('entities.pages_move_success', ['parentName' => $parent->name])); return redirect($page->getUrl()); } /** * Show the view to copy a page. - * @param string $bookSlug - * @param string $pageSlug - * @return mixed * @throws NotFoundException */ - public function showCopy($bookSlug, $pageSlug) + public function showCopy(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-view', $page); session()->flashInput(['name' => $page->name]); return view('pages.copy', [ @@ -591,79 +411,65 @@ class PageController extends Controller ]); } + /** * Create a copy of a page within the requested target destination. - * @param Request $request - * @param string $bookSlug - * @param string $pageSlug - * @return mixed * @throws NotFoundException * @throws Throwable */ public function copy(Request $request, string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('page-view', $page); - $entitySelection = $request->get('entity_selection', null); - if ($entitySelection === null || $entitySelection === '') { - $parent = $page->chapter ? $page->chapter : $page->book; - } else { - $stringExploded = explode(':', $entitySelection); - $entityType = $stringExploded[0]; - $entityId = intval($stringExploded[1]); + $entitySelection = $request->get('entity_selection', null) ?? null; + $newName = $request->get('name', null); - try { - $parent = $this->pageRepo->getById($entityType, $entityId); - } catch (Exception $e) { - $this->showErrorNotification(trans('entities.selected_book_chapter_not_found')); - return redirect()->back(); + try { + $pageCopy = $this->pageRepo->copy($page, $entitySelection, $newName); + } catch (Exception $exception) { + if ($exception instanceof PermissionsException) { + $this->showPermissionError(); } + + $this->showErrorNotification(trans('errors.selected_book_chapter_not_found')); + return redirect()->back(); } - $this->checkOwnablePermission('page-create', $parent); - - $pageCopy = $this->pageRepo->copyPage($page, $parent, $request->get('name', '')); - Activity::add($pageCopy, 'page_create', $pageCopy->book->id); - $this->showSuccessNotification( trans('entities.pages_copy_success')); + $this->showSuccessNotification(trans('entities.pages_copy_success')); return redirect($pageCopy->getUrl()); } /** * Show the Permissions view. - * @param string $bookSlug - * @param string $pageSlug - * @return Factory|View * @throws NotFoundException */ - public function showPermissions($bookSlug, $pageSlug) + public function showPermissions(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('restrictions-manage', $page); - $roles = $this->userRepo->getRestrictableRoles(); return view('pages.permissions', [ 'page' => $page, - 'roles' => $roles ]); } /** * Set the permissions for this page. - * @param string $bookSlug - * @param string $pageSlug - * @param Request $request - * @return RedirectResponse|Redirector * @throws NotFoundException * @throws Throwable */ public function permissions(Request $request, string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $this->checkOwnablePermission('restrictions-manage', $page); - $this->pageRepo->updateEntityPermissionsFromRequest($request, $page); - $this->showSuccessNotification( trans('entities.pages_permissions_success')); + + $restricted = $request->get('restricted') === 'true'; + $permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null; + $this->pageRepo->updatePermissions($page, $restricted, $permissions); + + $this->showSuccessNotification(trans('entities.pages_permissions_success')); return redirect($page->getUrl()); } } diff --git a/app/Http/Controllers/PageExportController.php b/app/Http/Controllers/PageExportController.php index addcc5513..3b02ea224 100644 --- a/app/Http/Controllers/PageExportController.php +++ b/app/Http/Controllers/PageExportController.php @@ -3,21 +3,15 @@ namespace BookStack\Http\Controllers; use BookStack\Entities\ExportService; +use BookStack\Entities\Managers\PageContent; use BookStack\Entities\Repos\PageRepo; use BookStack\Exceptions\NotFoundException; -use Illuminate\Http\Response; use Throwable; class PageExportController extends Controller { - /** - * @var PageRepo - */ - protected $pageRepo; - /** - * @var ExportService - */ + protected $pageRepo; protected $exportService; /** @@ -35,46 +29,37 @@ class PageExportController extends Controller /** * Exports a page to a PDF. * https://github.com/barryvdh/laravel-dompdf - * @param string $bookSlug - * @param string $pageSlug - * @return Response * @throws NotFoundException * @throws Throwable */ public function pdf(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $page->html = $this->pageRepo->renderPage($page); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); + $page->html = (new PageContent($page))->render(); $pdfContent = $this->exportService->pageToPdf($page); return $this->downloadResponse($pdfContent, $pageSlug . '.pdf'); } /** * Export a page to a self-contained HTML file. - * @param string $bookSlug - * @param string $pageSlug - * @return Response * @throws NotFoundException * @throws Throwable */ public function html(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); - $page->html = $this->pageRepo->renderPage($page); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); + $page->html = (new PageContent($page))->render(); $containedHtml = $this->exportService->pageToContainedHtml($page); return $this->downloadResponse($containedHtml, $pageSlug . '.html'); } /** * Export a page to a simple plaintext .txt file. - * @param string $bookSlug - * @param string $pageSlug - * @return Response * @throws NotFoundException */ public function plainText(string $bookSlug, string $pageSlug) { - $page = $this->pageRepo->getBySlug($pageSlug, $bookSlug); + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); $pageText = $this->exportService->pageToPlainText($page); return $this->downloadResponse($pageText, $pageSlug . '.txt'); } diff --git a/app/Http/Controllers/PageRevisionController.php b/app/Http/Controllers/PageRevisionController.php new file mode 100644 index 000000000..3c65b50ac --- /dev/null +++ b/app/Http/Controllers/PageRevisionController.php @@ -0,0 +1,128 @@ +pageRepo = $pageRepo; + parent::__construct(); + } + + /** + * Shows the last revisions for this page. + * @throws NotFoundException + */ + public function index(string $bookSlug, string $pageSlug) + { + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); + $this->setPageTitle(trans('entities.pages_revisions_named', ['pageName'=>$page->getShortName()])); + return view('pages.revisions', [ + 'page' => $page, + 'current' => $page + ]); + } + + /** + * Shows a preview of a single revision. + * @throws NotFoundException + */ + public function show(string $bookSlug, string $pageSlug, int $revisionId) + { + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); + $revision = $page->revisions()->where('id', '=', $revisionId)->first(); + if ($revision === null) { + throw new NotFoundException(); + } + + $page->fill($revision->toArray()); + + $this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()])); + return view('pages.revision', [ + 'page' => $page, + 'book' => $page->book, + 'diff' => null, + 'revision' => $revision + ]); + } + + /** + * Shows the changes of a single revision. + * @throws NotFoundException + */ + public function changes(string $bookSlug, string $pageSlug, int $revisionId) + { + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); + $revision = $page->revisions()->where('id', '=', $revisionId)->first(); + if ($revision === null) { + throw new NotFoundException(); + } + + $prev = $revision->getPrevious(); + $prevContent = $prev->html ?? ''; + $diff = (new Htmldiff)->diff($prevContent, $revision->html); + + $page->fill($revision->toArray()); + $this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()])); + + return view('pages.revision', [ + 'page' => $page, + 'book' => $page->book, + 'diff' => $diff, + 'revision' => $revision + ]); + } + + /** + * Restores a page using the content of the specified revision. + * @throws NotFoundException + */ + public function restore(string $bookSlug, string $pageSlug, int $revisionId) + { + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); + $this->checkOwnablePermission('page-update', $page); + + $page = $this->pageRepo->restoreRevision($page, $revisionId); + + Activity::add($page, 'page_restore', $page->book->id); + return redirect($page->getUrl()); + } + + /** + * Deletes a revision using the id of the specified revision. + * @throws NotFoundException + */ + public function destroy(string $bookSlug, string $pageSlug, int $revId) + { + $page = $this->pageRepo->getBySlug($bookSlug, $pageSlug); + $this->checkOwnablePermission('page-delete', $page); + + $revision = $page->revisions()->where('id', '=', $revId)->first(); + if ($revision === null) { + throw new NotFoundException("Revision #{$revId} not found"); + } + + // Get the current revision for the page + $currentRevision = $page->getCurrentRevision(); + + // Check if its the latest revision, cannot delete latest revision. + if (intval($currentRevision->id) === intval($revId)) { + $this->showErrorNotification(trans('entities.revision_cannot_delete_latest')); + return redirect($page->getUrl('/revisions')); + } + + $revision->delete(); + $this->showSuccessNotification(trans('entities.revision_delete_success')); + return redirect($page->getUrl('/revisions')); + } +} diff --git a/app/Http/Controllers/PageTemplateController.php b/app/Http/Controllers/PageTemplateController.php index b47205a1b..eaa1a8ae2 100644 --- a/app/Http/Controllers/PageTemplateController.php +++ b/app/Http/Controllers/PageTemplateController.php @@ -11,8 +11,7 @@ class PageTemplateController extends Controller protected $pageRepo; /** - * PageTemplateController constructor. - * @param $pageRepo + * PageTemplateController constructor */ public function __construct(PageRepo $pageRepo) { @@ -22,14 +21,12 @@ class PageTemplateController extends Controller /** * Fetch a list of templates from the system. - * @param Request $request - * @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View */ public function list(Request $request) { $page = $request->get('page', 1); $search = $request->get('search', ''); - $templates = $this->pageRepo->getPageTemplates(10, $page, $search); + $templates = $this->pageRepo->getTemplates(10, $page, $search); if ($search) { $templates->appends(['search' => $search]); @@ -42,13 +39,11 @@ class PageTemplateController extends Controller /** * Get the content of a template. - * @param $templateId - * @return \Illuminate\Contracts\Routing\ResponseFactory|\Symfony\Component\HttpFoundation\Response * @throws NotFoundException */ - public function get($templateId) + public function get(int $templateId) { - $page = $this->pageRepo->getById('page', $templateId); + $page = $this->pageRepo->getById($templateId); if (!$page->template) { throw new NotFoundException(); diff --git a/app/Http/Controllers/PermissionController.php b/app/Http/Controllers/PermissionController.php index b8ca5a646..148ae5cd6 100644 --- a/app/Http/Controllers/PermissionController.php +++ b/app/Http/Controllers/PermissionController.php @@ -53,7 +53,7 @@ class PermissionController extends Controller ]); $this->permissionsRepo->saveNewRole($request->all()); - $this->showSuccessNotification( trans('settings.role_create_success')); + $this->showSuccessNotification(trans('settings.role_create_success')); return redirect('/settings/roles'); } @@ -90,7 +90,7 @@ class PermissionController extends Controller ]); $this->permissionsRepo->updateRole($id, $request->all()); - $this->showSuccessNotification( trans('settings.role_update_success')); + $this->showSuccessNotification(trans('settings.role_update_success')); return redirect('/settings/roles'); } @@ -124,11 +124,11 @@ class PermissionController extends Controller try { $this->permissionsRepo->deleteRole($id, $request->get('migrate_role_id')); } catch (PermissionsException $e) { - $this->showErrorNotification( $e->getMessage()); + $this->showErrorNotification($e->getMessage()); return redirect()->back(); } - $this->showSuccessNotification( trans('settings.role_delete_success')); + $this->showSuccessNotification(trans('settings.role_delete_success')); return redirect('/settings/roles'); } } diff --git a/app/Http/Controllers/SearchController.php b/app/Http/Controllers/SearchController.php index 1691ee9b0..a5cd7ad6b 100644 --- a/app/Http/Controllers/SearchController.php +++ b/app/Http/Controllers/SearchController.php @@ -1,35 +1,27 @@ entityRepo = $entityRepo; $this->viewService = $viewService; $this->searchService = $searchService; $this->entityContextManager = $entityContextManager; @@ -38,9 +30,6 @@ class SearchController extends Controller /** * Searches all entities. - * @param Request $request - * @return View - * @internal param string $searchTerm */ public function search(Request $request) { @@ -64,12 +53,8 @@ class SearchController extends Controller /** * Searches all entities within a book. - * @param Request $request - * @param integer $bookId - * @return View - * @internal param string $searchTerm */ - public function searchBook(Request $request, $bookId) + public function searchBook(Request $request, int $bookId) { $term = $request->get('term', ''); $results = $this->searchService->searchBook($bookId, $term); @@ -78,12 +63,8 @@ class SearchController extends Controller /** * Searches all entities within a chapter. - * @param Request $request - * @param integer $chapterId - * @return View - * @internal param string $searchTerm */ - public function searchChapter(Request $request, $chapterId) + public function searchChapter(Request $request, int $chapterId) { $term = $request->get('term', ''); $results = $this->searchService->searchChapter($chapterId, $term); @@ -93,8 +74,6 @@ class SearchController extends Controller /** * Search for a list of entities and return a partial HTML response of matching entities. * Returns the most popular entities if no search is provided. - * @param Request $request - * @return mixed */ public function searchEntitiesAjax(Request $request) { @@ -115,15 +94,13 @@ class SearchController extends Controller /** * Search siblings items in the system. - * @param Request $request - * @return Factory|View|mixed */ public function searchSiblings(Request $request) { $type = $request->get('entity_type', null); $id = $request->get('entity_id', null); - $entity = $this->entityRepo->getById($type, $id); + $entity = Entity::getEntityInstance($type)->newQuery()->visible()->find($id); if (!$entity) { return $this->jsonError(trans('errors.entity_not_found'), 404); } @@ -132,12 +109,12 @@ class SearchController extends Controller // Page in chapter if ($entity->isA('page') && $entity->chapter) { - $entities = $this->entityRepo->getChapterChildren($entity->chapter); + $entities = $entity->chapter->visiblePages(); } // Page in book or chapter if (($entity->isA('page') && !$entity->chapter) || $entity->isA('chapter')) { - $entities = $this->entityRepo->getBookDirectChildren($entity->book); + $entities = $entity->book->getDirectChildren(); } // Book @@ -145,15 +122,15 @@ class SearchController extends Controller if ($entity->isA('book')) { $contextShelf = $this->entityContextManager->getContextualShelfForBook($entity); if ($contextShelf) { - $entities = $this->entityRepo->getBookshelfChildren($contextShelf); + $entities = $contextShelf->visibleBooks()->get(); } else { - $entities = $this->entityRepo->getAll('book'); + $entities = Book::visible()->get(); } } // Shelve if ($entity->isA('bookshelf')) { - $entities = $this->entityRepo->getAll('bookshelf'); + $entities = Bookshelf::visible()->get(); } return view('partials.entity-list-basic', ['entities' => $entities, 'style' => 'compact']); diff --git a/app/Http/Controllers/SettingController.php b/app/Http/Controllers/SettingController.php index 68687dc95..1146f22c7 100644 --- a/app/Http/Controllers/SettingController.php +++ b/app/Http/Controllers/SettingController.php @@ -76,7 +76,7 @@ class SettingController extends Controller setting()->remove('app-logo'); } - $this->showSuccessNotification( trans('settings.settings_save_success')); + $this->showSuccessNotification(trans('settings.settings_save_success')); return redirect('/settings'); } @@ -111,14 +111,14 @@ class SettingController extends Controller $imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun); $deleteCount = count($imagesToDelete); if ($deleteCount === 0) { - $this->showWarningNotification( trans('settings.maint_image_cleanup_nothing_found')); + $this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found')); return redirect('/settings/maintenance')->withInput(); } if ($dryRun) { session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount])); } else { - $this->showSuccessNotification( trans('settings.maint_image_cleanup_success', ['count' => $deleteCount])); + $this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount])); } return redirect('/settings/maintenance#image-cleanup')->withInput(); diff --git a/app/Http/Controllers/UserController.php b/app/Http/Controllers/UserController.php index c787e78ad..b55398d2f 100644 --- a/app/Http/Controllers/UserController.php +++ b/app/Http/Controllers/UserController.php @@ -202,7 +202,7 @@ class UserController extends Controller } $user->save(); - $this->showSuccessNotification( trans('settings.users_edit_success')); + $this->showSuccessNotification(trans('settings.users_edit_success')); $redirectUrl = userCan('users-manage') ? '/settings/users' : ('/settings/users/' . $user->id); return redirect($redirectUrl); @@ -236,17 +236,17 @@ class UserController extends Controller $user = $this->userRepo->getById($id); if ($this->userRepo->isOnlyAdmin($user)) { - $this->showErrorNotification( trans('errors.users_cannot_delete_only_admin')); + $this->showErrorNotification(trans('errors.users_cannot_delete_only_admin')); return redirect($user->getEditUrl()); } if ($user->system_name === 'public') { - $this->showErrorNotification( trans('errors.users_cannot_delete_guest')); + $this->showErrorNotification(trans('errors.users_cannot_delete_guest')); return redirect($user->getEditUrl()); } $this->userRepo->destroy($user); - $this->showSuccessNotification( trans('settings.users_delete_success')); + $this->showSuccessNotification(trans('settings.users_delete_success')); return redirect('/settings/users'); } @@ -261,7 +261,7 @@ class UserController extends Controller $user = $this->userRepo->getById($id); $userActivity = $this->userRepo->getActivity($user); - $recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5, 0); + $recentlyCreated = $this->userRepo->getRecentlyCreated($user, 5); $assetCounts = $this->userRepo->getAssetCounts($user); return view('users.profile', [ diff --git a/app/Http/Middleware/GlobalViewData.php b/app/Http/Middleware/GlobalViewData.php index 0c2419016..bc132dfc3 100644 --- a/app/Http/Middleware/GlobalViewData.php +++ b/app/Http/Middleware/GlobalViewData.php @@ -24,5 +24,4 @@ class GlobalViewData return $next($request); } - -} \ No newline at end of file +} diff --git a/resources/views/books/export.blade.php b/resources/views/books/export.blade.php index 4a7c45e0b..1cf91046d 100644 --- a/resources/views/books/export.blade.php +++ b/resources/views/books/export.blade.php @@ -58,7 +58,7 @@

{{ $bookChild->name }}

@if($bookChild->isA('chapter')) -

{{ $bookChild->text }}

+

{{ $bookChild->description }}

@if(count($bookChild->pages) > 0) @foreach($bookChild->pages as $page) diff --git a/resources/views/errors/404.blade.php b/resources/views/errors/404.blade.php index c1937ff23..9c599307e 100644 --- a/resources/views/errors/404.blade.php +++ b/resources/views/errors/404.blade.php @@ -22,7 +22,7 @@

{{ trans('entities.pages_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'page'), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['page']), 'style' => 'compact'])
@@ -30,7 +30,7 @@

{{ trans('entities.books_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'book'), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['book']), 'style' => 'compact'])
@@ -38,7 +38,7 @@

{{ trans('entities.chapters_popular') }}

- @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, 'chapter'), 'style' => 'compact']) + @include('partials.entity-list', ['entities' => Views::getPopular(10, 0, ['chapter']), 'style' => 'compact'])
diff --git a/resources/views/form/entity-permissions.blade.php b/resources/views/form/entity-permissions.blade.php index f27209c48..3581a545b 100644 --- a/resources/views/form/entity-permissions.blade.php +++ b/resources/views/form/entity-permissions.blade.php @@ -19,7 +19,7 @@ {{ trans('common.toggle_all') }} - @foreach($roles as $role) + @foreach(\BookStack\Auth\Role::restrictable() as $role) {{ $role->display_name }} diff --git a/resources/views/shelves/form.blade.php b/resources/views/shelves/form.blade.php index 5125e7e19..19c5bbecd 100644 --- a/resources/views/shelves/form.blade.php +++ b/resources/views/shelves/form.blade.php @@ -14,10 +14,10 @@
+ value="{{ isset($shelf) ? $shelf->visibleBooks->implode('id', ',') : '' }}">
- @if (isset($shelfBooks) && count($shelfBooks) > 0) - @foreach ($shelfBooks as $book) + @if (count($shelf->visibleBooks ?? []) > 0) + @foreach ($shelf->visibleBooks as $book) diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 6bfc525a5..2212e1c1e 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -12,9 +12,9 @@

{{$shelf->name}}

{!! nl2br(e($shelf->description)) !!}

- @if(count($books) > 0) + @if(count($shelf->visibleBooks) > 0)
- @foreach($books as $book) + @foreach($shelf->visibleBooks as $book) @include('books.list-item', ['book' => $book]) @endforeach
diff --git a/routes/web.php b/routes/web.php index be729f566..5dee447a4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -9,9 +9,7 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/uploads/images/{path}', 'Images\ImageController@showImage') ->where('path', '.*$'); - Route::group(['prefix' => 'pages'], function() { - Route::get('/recently-updated', 'PageController@showRecentlyUpdated'); - }); + Route::get('/pages/recently-updated', 'PageController@showRecentlyUpdated'); // Shelves Route::get('/create-shelf', 'BookshelfController@create'); @@ -40,13 +38,13 @@ Route::group(['middleware' => 'auth'], function () { Route::get('/{slug}/edit', 'BookController@edit'); Route::put('/{slug}', 'BookController@update'); Route::delete('/{id}', 'BookController@destroy'); - Route::get('/{slug}/sort-item', 'BookController@sortItem'); + Route::get('/{slug}/sort-item', 'BookSortController@showItem'); Route::get('/{slug}', 'BookController@show'); Route::get('/{bookSlug}/permissions', 'BookController@showPermissions'); Route::put('/{bookSlug}/permissions', 'BookController@permissions'); Route::get('/{slug}/delete', 'BookController@showDelete'); - Route::get('/{bookSlug}/sort', 'BookController@sort'); - Route::put('/{bookSlug}/sort', 'BookController@saveSort'); + Route::get('/{bookSlug}/sort', 'BookSortController@show'); + Route::put('/{bookSlug}/sort', 'BookSortController@update'); Route::get('/{bookSlug}/export/html', 'BookExportController@html'); Route::get('/{bookSlug}/export/pdf', 'BookExportController@pdf'); Route::get('/{bookSlug}/export/plaintext', 'BookExportController@plainText'); @@ -74,11 +72,11 @@ Route::group(['middleware' => 'auth'], function () { Route::delete('/{bookSlug}/draft/{pageId}', 'PageController@destroyDraft'); // Revisions - Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageController@showRevisions'); - Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageController@showRevision'); - Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageController@showRevisionChanges'); - Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageController@restoreRevision'); - Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageController@destroyRevision'); + Route::get('/{bookSlug}/page/{pageSlug}/revisions', 'PageRevisionController@index'); + Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}', 'PageRevisionController@show'); + Route::get('/{bookSlug}/page/{pageSlug}/revisions/{revId}/changes', 'PageRevisionController@changes'); + Route::put('/{bookSlug}/page/{pageSlug}/revisions/{revId}/restore', 'PageRevisionController@restore'); + Route::delete('/{bookSlug}/page/{pageSlug}/revisions/{revId}/delete', 'PageRevisionController@destroy'); // Chapters Route::get('/{bookSlug}/chapter/{chapterSlug}/create-page', 'PageController@create'); diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php index a88480969..4aef0ed26 100644 --- a/tests/CommandsTest.php +++ b/tests/CommandsTest.php @@ -2,7 +2,6 @@ use BookStack\Auth\Permissions\JointPermission; use BookStack\Entities\Page; -use BookStack\Entities\Repos\EntityRepo; use BookStack\Auth\User; use BookStack\Entities\Repos\PageRepo; @@ -56,7 +55,7 @@ class CommandsTest extends TestCase $this->asEditor(); $pageRepo = app(PageRepo::class); $page = Page::first(); - $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '

new content

', 'summary' => 'page revision testing']); + $pageRepo->update($page, ['name' => 'updated page', 'html' => '

new content

', 'summary' => 'page revision testing']); $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '

new content in draft

', 'summary' => 'page revision testing']); $this->assertDatabaseHas('page_revisions', [ diff --git a/tests/Entity/EntityTest.php b/tests/Entity/EntityTest.php index a3fb1cfe1..b506da2aa 100644 --- a/tests/Entity/EntityTest.php +++ b/tests/Entity/EntityTest.php @@ -3,7 +3,6 @@ use BookStack\Entities\Book; use BookStack\Entities\Chapter; use BookStack\Entities\Page; -use BookStack\Entities\Repos\EntityRepo; use BookStack\Auth\UserRepo; use BookStack\Entities\Repos\PageRepo; use Carbon\Carbon; @@ -192,7 +191,7 @@ class EntityTest extends BrowserKitTest $entities = $this->createEntityChainBelongingToUser($creator, $updater); $this->actingAs($creator); app(UserRepo::class)->destroy($creator); - app(PageRepo::class)->savePageRevision($entities['page']); + app(PageRepo::class)->update($entities['page'], ['html' => '

hello!

>']); $this->checkEntitiesViewable($entities); } @@ -205,7 +204,7 @@ class EntityTest extends BrowserKitTest $entities = $this->createEntityChainBelongingToUser($creator, $updater); $this->actingAs($updater); app(UserRepo::class)->destroy($updater); - app(PageRepo::class)->savePageRevision($entities['page']); + app(PageRepo::class)->update($entities['page'], ['html' => '

Hello there!

']); $this->checkEntitiesViewable($entities); } @@ -273,8 +272,7 @@ class EntityTest extends BrowserKitTest public function test_slug_multi_byte_lower_casing() { - $entityRepo = app(EntityRepo::class); - $book = $entityRepo->createFromInput('book', [ + $book = $this->newBook([ 'name' => 'КНИГА' ]); @@ -284,8 +282,7 @@ class EntityTest extends BrowserKitTest public function test_slug_format() { - $entityRepo = app(EntityRepo::class); - $book = $entityRepo->createFromInput('book', [ + $book = $this->newBook([ 'name' => 'PartA / PartB / PartC' ]); diff --git a/tests/Entity/PageContentTest.php b/tests/Entity/PageContentTest.php index a0fcb5ca8..8a78c8ac0 100644 --- a/tests/Entity/PageContentTest.php +++ b/tests/Entity/PageContentTest.php @@ -1,8 +1,7 @@ id)->first(); $this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1); } + + public function test_get_page_nav_sets_correct_properties() + { + $content = '

Hello

There

Donkey

'; + $pageContent = new PageContent(new Page(['html' => $content])); + $navMap = $pageContent->getNavigation($content); + + $this->assertCount(3, $navMap); + $this->assertArrayMapIncludes([ + 'nodeName' => 'h1', + 'link' => '#testa', + 'text' => 'Hello', + 'level' => 1, + ], $navMap[0]); + $this->assertArrayMapIncludes([ + 'nodeName' => 'h2', + 'link' => '#testb', + 'text' => 'There', + 'level' => 2, + ], $navMap[1]); + $this->assertArrayMapIncludes([ + 'nodeName' => 'h3', + 'link' => '#testc', + 'text' => 'Donkey', + 'level' => 3, + ], $navMap[2]); + } + + public function test_get_page_nav_does_not_show_empty_titles() + { + $content = '

Hello

 

'; + $pageContent = new PageContent(new Page(['html' => $content])); + $navMap = $pageContent->getNavigation($content); + + $this->assertCount(1, $navMap); + $this->assertArrayMapIncludes([ + 'nodeName' => 'h1', + 'link' => '#testa', + 'text' => 'Hello' + ], $navMap[0]); + } + + public function test_get_page_nav_shifts_headers_if_only_smaller_ones_are_used() + { + $content = '

Hello

There
Donkey
'; + $pageContent = new PageContent(new Page(['html' => $content])); + $navMap = $pageContent->getNavigation($content); + + $this->assertCount(3, $navMap); + $this->assertArrayMapIncludes([ + 'nodeName' => 'h4', + 'level' => 1, + ], $navMap[0]); + $this->assertArrayMapIncludes([ + 'nodeName' => 'h5', + 'level' => 2, + ], $navMap[1]); + $this->assertArrayMapIncludes([ + 'nodeName' => 'h6', + 'level' => 3, + ], $navMap[2]); + } } diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index e374575f5..e83f78a10 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -1,11 +1,14 @@ getEditor(); $this->actingAs($newUser)->visit('/') - ->visit($book->getUrl() . '/create-page') - ->visit($chapter->getUrl() . '/create-page') + ->visit($book->getUrl('/create-page')) + ->visit($chapter->getUrl('/create-page')) ->visit($book->getUrl()) ->seeInElement('.book-contents', 'New Page'); - + $this->asAdmin() ->visit($book->getUrl()) ->dontSeeInElement('.book-contents', 'New Page') diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 140f67fe8..38193ec1a 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -12,7 +12,7 @@ class PageRevisionTest extends TestCase $pageRepo = app(PageRepo::class); $page = Page::first(); - $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page', 'html' => '

new content

', 'summary' => 'page revision testing']); + $pageRepo->update($page, ['name' => 'updated page', 'html' => '

new content

', 'summary' => 'page revision testing']); $pageRevision = $page->revisions->last(); $revisionView = $this->get($page->getUrl() . '/revisions/' . $pageRevision->id); @@ -30,8 +30,8 @@ class PageRevisionTest extends TestCase $pageRepo = app(PageRepo::class); $page = Page::first(); - $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page abc123', 'html' => '

new contente def456

', 'summary' => 'initial page revision testing']); - $pageRepo->updatePage($page, $page->book_id, ['name' => 'updated page again', 'html' => '

new content

', 'summary' => 'page revision testing']); + $pageRepo->update($page, ['name' => 'updated page abc123', 'html' => '

new contente def456

', 'summary' => 'initial page revision testing']); + $pageRepo->update($page, ['name' => 'updated page again', 'html' => '

new content

', 'summary' => 'page revision testing']); $page = Page::find($page->id); @@ -98,7 +98,7 @@ class PageRevisionTest extends TestCase $beforeRevisionCount = $page->revisions->count(); $currentRevision = $page->getCurrentRevision(); $resp = $this->asEditor()->delete($currentRevision->getUrl('/delete/')); - $resp->assertStatus(400); + $resp->assertRedirect($page->getUrl('/revisions')); $page = Page::find($page->id); $afterRevisionCount = $page->revisions->count(); diff --git a/tests/Entity/SortTest.php b/tests/Entity/SortTest.php index cad6d3c01..3c83d626a 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Entity/SortTest.php @@ -1,6 +1,5 @@ asAdmin(); $pageRepo = app(PageRepo::class); - $draft = $pageRepo->getDraftPage($this->book); + $draft = $pageRepo->getNewDraftPage($this->book); $resp = $this->get($this->book->getUrl()); $resp->assertSee($draft->name); @@ -214,7 +213,6 @@ class SortTest extends TestCase 'entity_selection' => 'book:' . $newBook->id, 'name' => 'My copied test page' ]); - $pageCopy = Page::where('name', '=', 'My copied test page')->first(); $movePageResp->assertRedirect($pageCopy->getUrl()); diff --git a/tests/Permissions/RestrictionsTest.php b/tests/Permissions/RestrictionsTest.php index f6e07c0f1..d899c6396 100644 --- a/tests/Permissions/RestrictionsTest.php +++ b/tests/Permissions/RestrictionsTest.php @@ -5,14 +5,13 @@ use BookStack\Entities\Bookshelf; use BookStack\Entities\Chapter; use BookStack\Entities\Entity; use BookStack\Auth\User; -use BookStack\Entities\Repos\EntityRepo; use BookStack\Entities\Page; class RestrictionsTest extends BrowserKitTest { /** - * @var \BookStack\Auth\User + * @var User */ protected $user; @@ -327,7 +326,7 @@ class RestrictionsTest extends BrowserKitTest public function test_page_view_restriction() { - $page = \BookStack\Entities\Page::first(); + $page = Page::first(); $pageUrl = $page->getUrl(); $this->actingAs($this->user) @@ -367,7 +366,7 @@ class RestrictionsTest extends BrowserKitTest public function test_page_delete_restriction() { - $page = \BookStack\Entities\Page::first(); + $page = Page::first(); $pageUrl = $page->getUrl(); $this->actingAs($this->user) @@ -438,7 +437,7 @@ class RestrictionsTest extends BrowserKitTest public function test_page_restriction_form() { - $page = \BookStack\Entities\Page::first(); + $page = Page::first(); $this->asAdmin()->visit($page->getUrl() . '/permissions') ->see('Page Permissions') ->check('restricted') @@ -665,10 +664,8 @@ class RestrictionsTest extends BrowserKitTest $this->setEntityRestrictions($firstBook, ['view', 'update']); $this->setEntityRestrictions($secondBook, ['view']); - $firstBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter', - ['name' => 'first book chapter'], $firstBook); - $secondBookChapter = $this->app[EntityRepo::class]->createFromInput('chapter', - ['name' => 'second book chapter'], $secondBook); + $firstBookChapter = $this->newChapter(['name' => 'first book chapter'], $firstBook); + $secondBookChapter = $this->newChapter(['name' => 'second book chapter'], $secondBook); // Create request data $reqData = [ diff --git a/tests/SharedTestHelpers.php b/tests/SharedTestHelpers.php index 358bf6ee3..3433f3b83 100644 --- a/tests/SharedTestHelpers.php +++ b/tests/SharedTestHelpers.php @@ -1,9 +1,14 @@ users()->first(); + $user = Role::getRole('viewer')->users()->first(); if (!empty($attributes)) $user->forceFill($attributes)->save(); return $user; } @@ -76,7 +83,7 @@ trait SharedTestHelpers /** * Regenerate the permission for an entity. * @param Entity $entity - * @throws \Throwable + * @throws Throwable */ protected function regenEntityPermissions(Entity $entity) { @@ -87,10 +94,10 @@ trait SharedTestHelpers /** * Create and return a new bookshelf. * @param array $input - * @return \BookStack\Entities\Bookshelf + * @return Bookshelf */ public function newShelf($input = ['name' => 'test shelf', 'description' => 'My new test shelf']) { - return app(EntityRepo::class)->createFromInput('bookshelf', $input); + return app(BookshelfRepo::class)->create($input, []); } /** @@ -99,30 +106,30 @@ trait SharedTestHelpers * @return Book */ public function newBook($input = ['name' => 'test book', 'description' => 'My new test book']) { - return app(EntityRepo::class)->createFromInput('book', $input); + return app(BookRepo::class)->create($input); } /** * Create and return a new test chapter * @param array $input * @param Book $book - * @return \BookStack\Entities\Chapter + * @return Chapter */ public function newChapter($input = ['name' => 'test chapter', 'description' => 'My new test chapter'], Book $book) { - return app(EntityRepo::class)->createFromInput('chapter', $input, $book); + return app(ChapterRepo::class)->create($input, $book); } /** * Create and return a new test page * @param array $input * @return Page - * @throws \Throwable + * @throws Throwable */ public function newPage($input = ['name' => 'test page', 'html' => 'My new test page']) { $book = Book::first(); $pageRepo = app(PageRepo::class); - $draftPage = $pageRepo->getDraftPage($book); - return $pageRepo->publishPageDraft($draftPage, $input); + $draftPage = $pageRepo->getNewDraftPage($book); + return $pageRepo->publishDraft($draftPage, $input); } /** @@ -167,10 +174,10 @@ trait SharedTestHelpers /** * Give the given user some permissions. - * @param \BookStack\Auth\User $user + * @param User $user * @param array $permissions */ - protected function giveUserPermissions(\BookStack\Auth\User $user, $permissions = []) + protected function giveUserPermissions(User $user, $permissions = []) { $newRole = $this->createNewRole($permissions); $user->attachRole($newRole); @@ -198,7 +205,7 @@ trait SharedTestHelpers */ protected function mockHttpFetch($returnData, int $times = 1) { - $mockHttp = \Mockery::mock(HttpFetcher::class); + $mockHttp = Mockery::mock(HttpFetcher::class); $this->app[HttpFetcher::class] = $mockHttp; $mockHttp->shouldReceive('fetch') ->times($times) diff --git a/tests/Unit/PageRepoTest.php b/tests/Unit/PageRepoTest.php deleted file mode 100644 index 38ffbf616..000000000 --- a/tests/Unit/PageRepoTest.php +++ /dev/null @@ -1,78 +0,0 @@ -pageRepo = app()->make(PageRepo::class); - } - - public function test_get_page_nav_sets_correct_properties() - { - $content = '

Hello

There

Donkey

'; - $navMap = $this->pageRepo->getPageNav($content); - - $this->assertCount(3, $navMap); - $this->assertArrayMapIncludes([ - 'nodeName' => 'h1', - 'link' => '#testa', - 'text' => 'Hello', - 'level' => 1, - ], $navMap[0]); - $this->assertArrayMapIncludes([ - 'nodeName' => 'h2', - 'link' => '#testb', - 'text' => 'There', - 'level' => 2, - ], $navMap[1]); - $this->assertArrayMapIncludes([ - 'nodeName' => 'h3', - 'link' => '#testc', - 'text' => 'Donkey', - 'level' => 3, - ], $navMap[2]); - } - - public function test_get_page_nav_does_not_show_empty_titles() - { - $content = '

Hello

 

'; - $navMap = $this->pageRepo->getPageNav($content); - - $this->assertCount(1, $navMap); - $this->assertArrayMapIncludes([ - 'nodeName' => 'h1', - 'link' => '#testa', - 'text' => 'Hello' - ], $navMap[0]); - } - - public function test_get_page_nav_shifts_headers_if_only_smaller_ones_are_used() - { - $content = '

Hello

There
Donkey
'; - $navMap = $this->pageRepo->getPageNav($content); - - $this->assertCount(3, $navMap); - $this->assertArrayMapIncludes([ - 'nodeName' => 'h4', - 'level' => 1, - ], $navMap[0]); - $this->assertArrayMapIncludes([ - 'nodeName' => 'h5', - 'level' => 2, - ], $navMap[1]); - $this->assertArrayMapIncludes([ - 'nodeName' => 'h6', - 'level' => 3, - ], $navMap[2]); - } - -} \ No newline at end of file diff --git a/tests/Uploads/AttachmentTest.php b/tests/Uploads/AttachmentTest.php index 0d51e050f..12b254d00 100644 --- a/tests/Uploads/AttachmentTest.php +++ b/tests/Uploads/AttachmentTest.php @@ -223,7 +223,7 @@ class AttachmentTest extends TestCase { $admin = $this->getAdmin(); $viewer = $this->getViewer(); - $page = Page::first(); + $page = Page::first(); /** @var Page $page */ $this->actingAs($admin); $fileName = 'permission_test.txt'; @@ -233,7 +233,7 @@ class AttachmentTest extends TestCase $page->restricted = true; $page->permissions()->delete(); $page->save(); - $this->app[PermissionService::class]->buildJointPermissionsForEntity($page); + $page->rebuildPermissions(); $page->load('jointPermissions'); $this->actingAs($viewer); diff --git a/tests/Uploads/ImageTest.php b/tests/Uploads/ImageTest.php index 4d3e8a498..0615a95ce 100644 --- a/tests/Uploads/ImageTest.php +++ b/tests/Uploads/ImageTest.php @@ -367,7 +367,7 @@ class ImageTest extends TestCase $image = Image::where('type', '=', 'gallery')->first(); $pageRepo = app(PageRepo::class); - $pageRepo->updatePage($page, $page->book_id, [ + $pageRepo->update($page, [ 'name' => $page->name, 'html' => $page->html . "url}\">", 'summary' => '' @@ -379,7 +379,7 @@ class ImageTest extends TestCase $this->assertCount(0, $toDelete); // Save a revision of our page without the image; - $pageRepo->updatePage($page, $page->book_id, [ + $pageRepo->update($page, [ 'name' => $page->name, 'html' => "

Hello

", 'summary' => ''