diff --git a/app/Access/Mfa/MfaSession.php b/app/Access/Mfa/MfaSession.php index 09b9e53b8ae..b1285341257 100644 --- a/app/Access/Mfa/MfaSession.php +++ b/app/Access/Mfa/MfaSession.php @@ -11,7 +11,6 @@ class MfaSession */ public function isRequiredForUser(User $user): bool { - // TODO - Test both these cases return $user->mfaValues()->exists() || $this->userRoleEnforcesMfa($user); } diff --git a/app/Console/Commands/UpdateUrlCommand.php b/app/Console/Commands/UpdateUrlCommand.php index 71f0b92fe41..fd86e070667 100644 --- a/app/Console/Commands/UpdateUrlCommand.php +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -45,10 +45,8 @@ public function handle(Connection $db): int $columnsToUpdateByTable = [ 'attachments' => ['path'], - 'pages' => ['html', 'text', 'markdown'], - 'chapters' => ['description_html'], - 'books' => ['description_html'], - 'bookshelves' => ['description_html'], + 'entity_page_data' => ['html', 'text', 'markdown'], + 'entity_container_data' => ['description_html'], 'page_revisions' => ['html', 'text', 'markdown'], 'images' => ['url'], 'settings' => ['value'], diff --git a/app/Entities/Controllers/BookApiController.php b/app/Entities/Controllers/BookApiController.php index 5baea163fd6..807f5a69c8f 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -122,9 +122,10 @@ protected function forJsonDisplay(Book $book): Book $book = clone $book; $book->unsetRelations()->refresh(); - $book->load(['tags', 'cover']); - $book->makeVisible('description_html') - ->setAttribute('description_html', $book->descriptionHtml()); + $book->load(['tags']); + $book->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $book->descriptionInfo()->getHtml()) + ->setAttribute('cover', $book->coverInfo()->getImage()); return $book; } diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index f4bd394a9e7..735742060c5 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -116,9 +116,10 @@ protected function forJsonDisplay(Bookshelf $shelf): Bookshelf $shelf = clone $shelf; $shelf->unsetRelations()->refresh(); - $shelf->load(['tags', 'cover']); - $shelf->makeVisible('description_html') - ->setAttribute('description_html', $shelf->descriptionHtml()); + $shelf->load(['tags']); + $shelf->makeVisible(['cover', 'description_html']) + ->setAttribute('description_html', $shelf->descriptionInfo()->getHtml()) + ->setAttribute('cover', $shelf->coverInfo()->getImage()); return $shelf; } diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index f47742ffaa6..8d7ffb8f9b0 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -116,6 +116,7 @@ public function show(Request $request, ActivityQueries $activities, string $slug ]); $sort = $listOptions->getSort(); + $sortedVisibleShelfBooks = $shelf->visibleBooks() ->reorder($sort === 'default' ? 'order' : $sort, $listOptions->getOrder()) ->get() diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 80eab7bb8c1..6aa62f887c8 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -104,7 +104,7 @@ public function update(Request $request, string $id) $chapter = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); - if ($request->has('book_id') && $chapter->book_id !== intval($requestData['book_id'])) { + if ($request->has('book_id') && $chapter->book_id !== (intval($requestData['book_id']) ?: null)) { $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); try { @@ -144,7 +144,7 @@ protected function forJsonDisplay(Chapter $chapter): Chapter $chapter->load(['tags']); $chapter->makeVisible('description_html'); - $chapter->setAttribute('description_html', $chapter->descriptionHtml()); + $chapter->setAttribute('description_html', $chapter->descriptionInfo()->getHtml()); /** @var Book $book */ $book = $chapter->book()->first(); diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 9335e0a7034..a1af29de269 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -130,7 +130,7 @@ public function update(Request $request, string $bookSlug, string $chapterSlug) $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); - $this->chapterRepo->update($chapter, $validated); + $chapter = $this->chapterRepo->update($chapter, $validated); return redirect($chapter->getUrl()); } diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 67ecb0bb377..603d015ef4a 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -120,6 +120,7 @@ public function store(Request $request, string $bookSlug, int $pageId) $this->validate($request, [ 'name' => ['required', 'string', 'max:255'], ]); + $draftPage = $this->queries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageCreate, $draftPage->getParent()); diff --git a/app/Entities/EntityExistsRule.php b/app/Entities/EntityExistsRule.php new file mode 100644 index 00000000000..da210544611 --- /dev/null +++ b/app/Entities/EntityExistsRule.php @@ -0,0 +1,20 @@ +where('type', $this->type); + return $existsRule->__toString(); + } +} diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 5f54e0f6af6..afd50797b15 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -2,9 +2,10 @@ namespace BookStack\Entities\Models; +use BookStack\Entities\Tools\EntityCover; +use BookStack\Entities\Tools\EntityDefaultTemplate; use BookStack\Sorting\SortRule; use BookStack\Uploads\Image; -use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; @@ -15,26 +16,25 @@ * Class Book. * * @property string $description + * @property string $description_html * @property int $image_id * @property ?int $default_template_id * @property ?int $sort_rule_id - * @property Image|null $cover * @property \Illuminate\Database\Eloquent\Collection $chapters * @property \Illuminate\Database\Eloquent\Collection $pages * @property \Illuminate\Database\Eloquent\Collection $directPages * @property \Illuminate\Database\Eloquent\Collection $shelves - * @property ?Page $defaultTemplate - * @property ?SortRule $sortRule + * @property ?SortRule $sortRule */ -class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterface +class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface { use HasFactory; - use HtmlDescriptionTrait; + use ContainerTrait; public float $searchFactor = 1.2; + protected $hidden = ['pivot', 'deleted_at', 'description_html', 'entity_id', 'entity_type', 'chapter_id', 'book_id', 'priority']; protected $fillable = ['name']; - protected $hidden = ['pivot', 'image_id', 'deleted_at', 'description_html']; /** * Get the url for this book. @@ -44,55 +44,6 @@ public function getUrl(string $path = ''): string return url('/books/' . implode('/', [urlencode($this->slug), trim($path, '/')])); } - /** - * Returns book cover image, if book cover not exists return default cover image. - */ - public function getBookCover(int $width = 440, int $height = 250): string - { - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id || !$this->cover) { - return $default; - } - - try { - return $this->cover->getThumb($width, $height, false) ?? $default; - } catch (Exception $err) { - return $default; - } - } - - /** - * Get the cover image of the book. - */ - 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 the Page that is used as default template for newly created pages within this Book. - */ - public function defaultTemplate(): BelongsTo - { - return $this->belongsTo(Page::class, 'default_template_id'); - } - - /** - * Get the sort set assigned to this book, if existing. - */ - public function sortRule(): BelongsTo - { - return $this->belongsTo(SortRule::class); - } - /** * Get all pages within this book. * @return HasMany @@ -107,7 +58,7 @@ public function pages(): HasMany */ public function directPages(): HasMany { - return $this->pages()->where('chapter_id', '=', '0'); + return $this->pages()->whereNull('chapter_id'); } /** @@ -116,7 +67,8 @@ public function directPages(): HasMany */ public function chapters(): HasMany { - return $this->hasMany(Chapter::class); + return $this->hasMany(Chapter::class) + ->where('type', '=', 'chapter'); } /** @@ -137,4 +89,27 @@ public function getDirectVisibleChildren(): Collection return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft'); } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); + } + + public function coverInfo(): EntityCover + { + return new EntityCover($this); + } + + /** + * Get the sort rule assigned to this container, if existing. + */ + public function sortRule(): BelongsTo + { + return $this->belongsTo(SortRule::class); + } } diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index ad54fb926a9..4a2e52aedd5 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -3,7 +3,6 @@ namespace BookStack\Entities\Models; use BookStack\References\ReferenceUpdater; -use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -27,13 +26,13 @@ public function book(): BelongsTo /** * Change the book that this entity belongs to. */ - public function changeBook(int $newBookId): Entity + public function changeBook(int $newBookId): self { $oldUrl = $this->getUrl(); $this->book_id = $newBookId; + $this->unsetRelation('book'); $this->refreshSlug(); $this->save(); - $this->refresh(); if ($oldUrl !== $this->getUrl()) { app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl); diff --git a/app/Entities/Models/Bookshelf.php b/app/Entities/Models/Bookshelf.php index 9ae52abcb0a..42dcc8f8f2c 100644 --- a/app/Entities/Models/Bookshelf.php +++ b/app/Entities/Models/Bookshelf.php @@ -2,34 +2,34 @@ namespace BookStack\Entities\Models; +use BookStack\Entities\Tools\EntityCover; use BookStack\Uploads\Image; -use Exception; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; use Illuminate\Database\Eloquent\Relations\BelongsToMany; -class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionInterface +/** + * @property string $description + * @property string $description_html + */ +class Bookshelf extends Entity implements HasDescriptionInterface, HasCoverInterface { use HasFactory; - use HtmlDescriptionTrait; - - protected $table = 'bookshelves'; + use ContainerTrait; public float $searchFactor = 1.2; - protected $fillable = ['name', 'description', 'image_id']; - - protected $hidden = ['image_id', 'deleted_at', 'description_html']; + protected $hidden = ['image_id', 'deleted_at', 'description_html', 'priority', 'default_template_id', 'sort_rule_id', 'entity_id', 'entity_type', 'chapter_id', 'book_id']; + protected $fillable = ['name']; /** * Get the books in this shelf. - * Should not be used directly since does not take into account permissions. - * - * @return \Illuminate\Database\Eloquent\Relations\BelongsToMany + * Should not be used directly since it does not take into account permissions. */ - public function books() + public function books(): BelongsToMany { return $this->belongsToMany(Book::class, 'bookshelves_books', 'bookshelf_id', 'book_id') + ->select(['entities.*', 'entity_container_data.*']) ->withPivot('order') ->orderBy('order', 'asc'); } @@ -50,41 +50,6 @@ public function getUrl(string $path = ''): string return url('/shelves/' . implode('/', [urlencode($this->slug), trim($path, '/')])); } - /** - * Returns shelf cover image, if cover not exists return default cover image. - */ - public function getBookCover(int $width = 440, int $height = 250): string - { - // TODO - Make generic, focused on books right now, Perhaps set-up a better image - $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='; - if (!$this->image_id || !$this->cover) { - return $default; - } - - try { - return $this->cover->getThumb($width, $height, false) ?? $default; - } catch (Exception $err) { - return $default; - } - } - - /** - * Get the cover image of the shelf. - * @return BelongsTo - */ - 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_bookshelf'; - } - /** * Check if this shelf contains the given book. */ @@ -96,7 +61,7 @@ public function contains(Book $book): bool /** * Add a book to the end of this shelf. */ - public function appendBook(Book $book) + public function appendBook(Book $book): void { if ($this->contains($book)) { return; @@ -106,12 +71,13 @@ public function appendBook(Book $book) $this->books()->attach($book->id, ['order' => $maxOrder + 1]); } - /** - * Get a visible shelf by its slug. - * @throws \Illuminate\Database\Eloquent\ModelNotFoundException - */ - public static function getBySlug(string $slug): self + public function coverInfo(): EntityCover { - return static::visible()->where('slug', '=', $slug)->firstOrFail(); + return new EntityCover($this); + } + + public function cover(): BelongsTo + { + return $this->belongsTo(Image::class, 'image_id'); } } diff --git a/app/Entities/Models/Chapter.php b/app/Entities/Models/Chapter.php index d70a49e7a98..2dd4cb77f05 100644 --- a/app/Entities/Models/Chapter.php +++ b/app/Entities/Models/Chapter.php @@ -2,27 +2,25 @@ namespace BookStack\Entities\Models; -use Illuminate\Database\Eloquent\Relations\BelongsTo; +use BookStack\Entities\Tools\EntityDefaultTemplate; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Support\Collection; /** - * Class Chapter. - * * @property Collection $pages * @property ?int $default_template_id - * @property ?Page $defaultTemplate + * @property string $description + * @property string $description_html */ -class Chapter extends BookChild implements HtmlDescriptionInterface +class Chapter extends BookChild implements HasDescriptionInterface, HasDefaultTemplateInterface { use HasFactory; - use HtmlDescriptionTrait; + use ContainerTrait; public float $searchFactor = 1.2; - - protected $fillable = ['name', 'description', 'priority']; - protected $hidden = ['pivot', 'deleted_at', 'description_html']; + protected $hidden = ['pivot', 'deleted_at', 'description_html', 'sort_rule_id', 'image_id', 'entity_id', 'entity_type', 'chapter_id']; + protected $fillable = ['name', 'priority']; /** * Get the pages that this chapter contains. @@ -50,14 +48,6 @@ public function getUrl(string $path = ''): string return url('/' . implode('/', $parts)); } - /** - * Get the Page that is used as default template for newly created pages within this Chapter. - */ - public function defaultTemplate(): BelongsTo - { - return $this->belongsTo(Page::class, 'default_template_id'); - } - /** * Get the visible pages in this chapter. * @return Collection @@ -70,4 +60,9 @@ public function getVisiblePages(): Collection ->orderBy('priority', 'asc') ->get(); } + + public function defaultTemplate(): EntityDefaultTemplate + { + return new EntityDefaultTemplate($this); + } } diff --git a/app/Entities/Models/ContainerTrait.php b/app/Entities/Models/ContainerTrait.php new file mode 100644 index 00000000000..9ef5ca8d43a --- /dev/null +++ b/app/Entities/Models/ContainerTrait.php @@ -0,0 +1,26 @@ + + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityContainerData::class, 'entity_id', 'id') + ->where('entity_type', '=', $this->getMorphClass()); + } +} diff --git a/app/Entities/Models/CoverImageInterface.php b/app/Entities/Models/CoverImageInterface.php deleted file mode 100644 index 5f781fe02ab..00000000000 --- a/app/Entities/Models/CoverImageInterface.php +++ /dev/null @@ -1,18 +0,0 @@ -relatedData()->firstOrNew(); + $contentFields = $this->getContentsAttributes(); + + foreach ($contentFields as $key => $value) { + $contents->setAttribute($key, $value); + unset($this->attributes[$key]); + } + + $this->setAttribute('type', $this->getMorphClass()); + $result = parent::save($options); + $contentsResult = true; + + if ($result && $contents->isDirty()) { + $contentsFillData = $contents instanceof EntityPageData ? ['page_id' => $this->id] : ['entity_id' => $this->id, 'entity_type' => $this->getMorphClass()]; + $contents->forceFill($contentsFillData); + $contentsResult = $contents->save(); + $this->touch(); + } + + $this->forceFill($contentFields); + + return $result && $contentsResult; + } + + /** + * Check if this item is a container item. + */ + public function isContainer(): bool + { + return $this instanceof Bookshelf || + $this instanceof Book || + $this instanceof Chapter; + } + /** * Get the entities that are visible to the current user. */ @@ -91,8 +159,8 @@ public function scopeVisible(Builder $query): Builder public function scopeWithLastView(Builder $query) { $viewedAtQuery = View::query()->select('updated_at') - ->whereColumn('viewable_id', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass()) + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') ->where('user_id', '=', user()->id) ->take(1); @@ -102,11 +170,12 @@ public function scopeWithLastView(Builder $query) /** * Query scope to get the total view count of the entities. */ - public function scopeWithViewCount(Builder $query) + public function scopeWithViewCount(Builder $query): void { $viewCountQuery = View::query()->selectRaw('SUM(views) as view_count') - ->whereColumn('viewable_id', '=', $this->getTable() . '.id') - ->where('viewable_type', '=', $this->getMorphClass())->take(1); + ->whereColumn('viewable_id', '=', 'entities.id') + ->whereColumn('viewable_type', '=', 'entities.type') + ->take(1); $query->addSelect(['view_count' => $viewCountQuery]); } @@ -162,7 +231,8 @@ public function views(): MorphMany */ public function tags(): MorphMany { - return $this->morphMany(Tag::class, 'entity')->orderBy('order', 'asc'); + return $this->morphMany(Tag::class, 'entity') + ->orderBy('order', 'asc'); } /** @@ -184,7 +254,7 @@ public function searchTerms(): MorphMany } /** - * Get this entities restrictions. + * Get this entities assigned permissions. */ public function permissions(): MorphMany { @@ -267,7 +337,7 @@ public static function getType(): string } /** - * Gets a limited-length version of the entities name. + * Gets a limited-length version of the entity name. */ public function getShortName(int $length = 25): string { @@ -377,4 +447,27 @@ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } + + /** + * @return HasOne + */ + abstract public function relatedData(): HasOne; + + /** + * Get the attributes that are intended for the related contents model. + * @return array + */ + protected function getContentsAttributes(): array + { + $contentFields = []; + $contentModel = $this instanceof Page ? EntityPageData::class : EntityContainerData::class; + + foreach ($this->attributes as $key => $value) { + if (in_array($key, $contentModel::$fields)) { + $contentFields[$key] = $value; + } + } + + return $contentFields; + } } diff --git a/app/Entities/Models/EntityContainerData.php b/app/Entities/Models/EntityContainerData.php new file mode 100644 index 00000000000..21bace7513f --- /dev/null +++ b/app/Entities/Models/EntityContainerData.php @@ -0,0 +1,52 @@ +where($this->getKeyName(), '=', $this->getKeyForSaveQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } + + /** + * Override the default set keys for a select query method to make it work with composite keys. + */ + protected function setKeysForSelectQuery($query): Builder + { + $query->where($this->getKeyName(), '=', $this->getKeyForSelectQuery()) + ->where('entity_type', '=', $this->entity_type); + + return $query; + } +} diff --git a/app/Entities/Models/EntityPageData.php b/app/Entities/Models/EntityPageData.php new file mode 100644 index 00000000000..a98b1a9823c --- /dev/null +++ b/app/Entities/Models/EntityPageData.php @@ -0,0 +1,25 @@ +withGlobalScope('entity', new EntityScope()); + } + + public function withoutGlobalScope($scope): static + { + // Prevent removal of the entity scope + if ($scope === 'entity') { + return $this; + } + + return parent::withoutGlobalScope($scope); + } + + /** + * Override the default forceDelete method to add type filter onto the query + * since it specifically ignores scopes by default. + */ + public function forceDelete() + { + return $this->query->where('type', '=', $this->model->getMorphClass())->delete(); + } +} diff --git a/app/Entities/Models/EntityScope.php b/app/Entities/Models/EntityScope.php new file mode 100644 index 00000000000..deb10c5ecbc --- /dev/null +++ b/app/Entities/Models/EntityScope.php @@ -0,0 +1,27 @@ +where('type', '=', $model->getMorphClass()); + if ($model instanceof Page) { + $builder->leftJoin('entity_page_data', 'entity_page_data.page_id', '=', 'entities.id'); + } else { + $builder->leftJoin('entity_container_data', function (JoinClause $join) use ($model) { + $join->on('entity_container_data.entity_id', '=', 'entities.id') + ->where('entity_container_data.entity_type', '=', $model->getMorphClass()); + }); + } + } +} diff --git a/app/Entities/Models/HasCoverInterface.php b/app/Entities/Models/HasCoverInterface.php new file mode 100644 index 00000000000..a4e79e9004d --- /dev/null +++ b/app/Entities/Models/HasCoverInterface.php @@ -0,0 +1,18 @@ + + */ + public function cover(): BelongsTo; +} diff --git a/app/Entities/Models/HasDefaultTemplateInterface.php b/app/Entities/Models/HasDefaultTemplateInterface.php new file mode 100644 index 00000000000..f3af0da48ab --- /dev/null +++ b/app/Entities/Models/HasDefaultTemplateInterface.php @@ -0,0 +1,10 @@ +description_html ?: '

' . nl2br(e($this->description)) . '

'; - if ($raw) { - return $html; - } - - return HtmlContentFilter::removeScriptsFromHtmlString($html); - } - - public function setDescriptionHtml(string $html, string|null $plaintext = null): void - { - $this->description_html = $html; - - if ($plaintext !== null) { - $this->description = $plaintext; - } - - if (empty($html) && !empty($plaintext)) { - $this->description_html = $this->descriptionHtml(); - } - } -} diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index 499ef4d7288..88c59bd1bd0 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -3,7 +3,6 @@ namespace BookStack\Entities\Models; use BookStack\Entities\Tools\PageContent; -use BookStack\Entities\Tools\PageEditorType; use BookStack\Permissions\PermissionApplicator; use BookStack\Uploads\Attachment; use Illuminate\Database\Eloquent\Builder; @@ -15,7 +14,7 @@ /** * Class Page. - * + * @property EntityPageData $pageData * @property int $chapter_id * @property string $html * @property string $markdown @@ -33,12 +32,10 @@ class Page extends BookChild { use HasFactory; - protected $fillable = ['name', 'priority']; - public string $textField = 'text'; public string $htmlField = 'html'; - - protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at']; + protected $hidden = ['html', 'markdown', 'text', 'pivot', 'deleted_at', 'entity_id', 'entity_type']; + protected $fillable = ['name', 'priority']; protected $casts = [ 'draft' => 'boolean', @@ -57,10 +54,8 @@ public function scopeVisible(Builder $query): Builder /** * Get the chapter that this page is in, If applicable. - * - * @return BelongsTo */ - public function chapter() + public function chapter(): BelongsTo { return $this->belongsTo(Chapter::class); } @@ -107,10 +102,8 @@ public function allRevisions(): HasMany /** * Get the attachments assigned to this page. - * - * @return HasMany */ - public function attachments() + public function attachments(): HasMany { return $this->hasMany(Attachment::class, 'uploaded_to')->orderBy('order', 'asc'); } @@ -139,8 +132,16 @@ public function forJsonDisplay(): self $refreshed = $this->refresh()->unsetRelations()->load(['tags', 'createdBy', 'updatedBy', 'ownedBy']); $refreshed->setHidden(array_diff($refreshed->getHidden(), ['html', 'markdown'])); $refreshed->setAttribute('raw_html', $refreshed->html); - $refreshed->html = (new PageContent($refreshed))->render(); + $refreshed->setAttribute('html', (new PageContent($refreshed))->render()); return $refreshed; } + + /** + * @return HasOne + */ + public function relatedData(): HasOne + { + return $this->hasOne(EntityPageData::class, 'page_id', 'id'); + } } diff --git a/app/Entities/Queries/BookQueries.php b/app/Entities/Queries/BookQueries.php index 2492f81318a..a466f37bc0f 100644 --- a/app/Entities/Queries/BookQueries.php +++ b/app/Entities/Queries/BookQueries.php @@ -55,6 +55,11 @@ public function visibleForList(): Builder ->select(static::$listAttributes); } + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); diff --git a/app/Entities/Queries/BookshelfQueries.php b/app/Entities/Queries/BookshelfQueries.php index 842011a8781..3fe0a2afcef 100644 --- a/app/Entities/Queries/BookshelfQueries.php +++ b/app/Entities/Queries/BookshelfQueries.php @@ -60,6 +60,11 @@ public function visibleForList(): Builder return $this->start()->scopes('visible')->select(static::$listAttributes); } + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForListWithCover(): Builder { return $this->visibleForList()->with('cover'); diff --git a/app/Entities/Queries/ChapterQueries.php b/app/Entities/Queries/ChapterQueries.php index 9bf0ff65bfd..9ddeb9b5896 100644 --- a/app/Entities/Queries/ChapterQueries.php +++ b/app/Entities/Queries/ChapterQueries.php @@ -65,8 +65,14 @@ public function visibleForList(): Builder ->scopes('visible') ->select(array_merge(static::$listAttributes, ['book_slug' => function ($builder) { $builder->select('slug') - ->from('books') - ->whereColumn('books.id', '=', 'chapters.book_id'); + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); }])); } + + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } } diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index 0d2cd7acf64..a7a037916d5 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -43,6 +43,17 @@ public function visibleForList(string $entityType): Builder return $queries->visibleForList(); } + /** + * Start a query of visible entities of the given type, + * suitable for using the contents of the items. + * @return Builder + */ + public function visibleForContent(string $entityType): Builder + { + $queries = $this->getQueriesForType($entityType); + return $queries->visibleForContent(); + } + protected function getQueriesForType(string $type): ProvidesEntityQueries { $queries = match ($type) { diff --git a/app/Entities/Queries/PageQueries.php b/app/Entities/Queries/PageQueries.php index ee7b201bc1a..f4ecee2dc08 100644 --- a/app/Entities/Queries/PageQueries.php +++ b/app/Entities/Queries/PageQueries.php @@ -13,7 +13,7 @@ class PageQueries implements ProvidesEntityQueries { protected static array $contentAttributes = [ 'name', 'id', 'slug', 'book_id', 'chapter_id', 'draft', - 'template', 'html', 'text', 'created_at', 'updated_at', 'priority', + 'template', 'html', 'markdown', 'text', 'created_at', 'updated_at', 'priority', 'created_by', 'updated_by', 'owned_by', ]; protected static array $listAttributes = [ @@ -82,6 +82,14 @@ public function visibleForList(): Builder ->select($this->mergeBookSlugForSelect(static::$listAttributes)); } + /** + * @return Builder + */ + public function visibleForContent(): Builder + { + return $this->start()->scopes('visible'); + } + public function visibleForChapterList(int $chapterId): Builder { return $this->visibleForList() @@ -104,18 +112,19 @@ public function currentUserDraftsForList(): Builder ->where('created_by', '=', user()->id); } - public function visibleTemplates(): Builder + public function visibleTemplates(bool $includeContents = false): Builder { - return $this->visibleForList() - ->where('template', '=', true); + $base = $includeContents ? $this->visibleWithContents() : $this->visibleForList(); + return $base->where('template', '=', true); } protected function mergeBookSlugForSelect(array $columns): array { return array_merge($columns, ['book_slug' => function ($builder) { $builder->select('slug') - ->from('books') - ->whereColumn('books.id', '=', 'pages.book_id'); + ->from('entities as books') + ->where('type', '=', 'book') + ->whereColumn('books.id', '=', 'entities.book_id'); }]); } } diff --git a/app/Entities/Queries/ProvidesEntityQueries.php b/app/Entities/Queries/ProvidesEntityQueries.php index 79fc64b3ab8..674e96afa24 100644 --- a/app/Entities/Queries/ProvidesEntityQueries.php +++ b/app/Entities/Queries/ProvidesEntityQueries.php @@ -35,4 +35,11 @@ public function findVisibleById(int $id): ?Entity; * @return Builder */ public function visibleForList(): Builder; + + /** + * Start a query for items that are visible, with selection + * configured for using the content of the items found. + * @return Builder + */ + public function visibleForContent(): Builder; } diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index bfc01a58d1d..fd88625cd9a 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -3,13 +3,10 @@ namespace BookStack\Entities\Repos; use BookStack\Activity\TagRepo; -use BookStack\Entities\Models\Book; use BookStack\Entities\Models\BookChild; -use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\HasCoverInterface; +use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\CoverImageInterface; -use BookStack\Entities\Models\HtmlDescriptionInterface; -use BookStack\Entities\Models\HtmlDescriptionTrait; use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\ImageUploadException; use BookStack\References\ReferenceStore; @@ -33,17 +30,25 @@ public function __construct( /** * Create a new entity in the system. + * @template T of Entity + * @param T $entity + * @return T */ - public function create(Entity $entity, array $input) + public function create(Entity $entity, array $input): Entity { + $entity = (clone $entity)->refresh(); $entity->fill($input); - $this->updateDescription($entity, $input); $entity->forceFill([ 'created_by' => user()->id, 'updated_by' => user()->id, 'owned_by' => user()->id, ]); $entity->refreshSlug(); + + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); + } + $entity->save(); if (isset($input['tags'])) { @@ -53,24 +58,33 @@ public function create(Entity $entity, array $input) $entity->refresh(); $entity->rebuildPermissions(); $entity->indexForSearch(); + $this->referenceStore->updateForEntity($entity); + + return $entity; } /** * Update the given entity. + * @template T of Entity + * @param T $entity + * @return T */ - public function update(Entity $entity, array $input) + public function update(Entity $entity, array $input): Entity { $oldUrl = $entity->getUrl(); $entity->fill($input); - $this->updateDescription($entity, $input); $entity->updated_by = user()->id; if ($entity->isDirty('name') || empty($entity->slug)) { $entity->refreshSlug(); } + if ($entity instanceof HasDescriptionInterface) { + $this->updateDescription($entity, $input); + } + $entity->save(); if (isset($input['tags'])) { @@ -84,59 +98,35 @@ public function update(Entity $entity, array $input) if ($oldUrl !== $entity->getUrl()) { $this->referenceUpdater->updateEntityReferences($entity, $oldUrl); } + + return $entity; } /** - * Update the given items' cover image, or clear it. + * Update the given items' cover image or clear it. * * @throws ImageUploadException * @throws \Exception */ - public function updateCoverImage(Entity&CoverImageInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Entity&HasCoverInterface $entity, ?UploadedFile $coverImage, bool $removeImage = false): void { if ($coverImage) { - $imageType = $entity->coverImageTypeKey(); - $this->imageRepo->destroyImage($entity->cover()->first()); + $imageType = 'cover_' . $entity->type; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); $image = $this->imageRepo->saveNew($coverImage, $imageType, $entity->id, 512, 512, true); - $entity->cover()->associate($image); + $entity->coverInfo()->setImage($image); $entity->save(); } if ($removeImage) { - $this->imageRepo->destroyImage($entity->cover()->first()); - $entity->cover()->dissociate(); - $entity->save(); - } - } - - /** - * Update the default page template used for this item. - * Checks that, if changing, the provided value is a valid template and the user - * has visibility of the provided page template id. - */ - public function updateDefaultTemplate(Book|Chapter $entity, int $templateId): void - { - $changing = $templateId !== intval($entity->default_template_id); - if (!$changing) { - return; - } - - if ($templateId === 0) { - $entity->default_template_id = null; + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); + $entity->coverInfo()->setImage(null); $entity->save(); - return; } - - $templateExists = $this->pageQueries->visibleTemplates() - ->where('id', '=', $templateId) - ->exists(); - - $entity->default_template_id = $templateExists ? $templateId : null; - $entity->save(); } /** - * Sort the parent of the given entity, if any auto sort actions are set for it. + * Sort the parent of the given entity if any auto sort actions are set for it. * Typically ran during create/update/insert events. */ public function sortParent(Entity $entity): void @@ -147,19 +137,22 @@ public function sortParent(Entity $entity): void } } + /** + * Update the description of the given entity from input data. + */ protected function updateDescription(Entity $entity, array $input): void { - if (!($entity instanceof HtmlDescriptionInterface)) { + if (!$entity instanceof HasDescriptionInterface) { return; } if (isset($input['description_html'])) { - $entity->setDescriptionHtml( + $entity->descriptionInfo()->set( HtmlDescriptionFilter::filterFromString($input['description_html']), html_entity_decode(strip_tags($input['description_html'])) ); } else if (isset($input['description'])) { - $entity->setDescriptionHtml('', $input['description']); + $entity->descriptionInfo()->set('', $input['description']); } } } diff --git a/app/Entities/Repos/BookRepo.php b/app/Entities/Repos/BookRepo.php index 6d28d5d6aab..b4244b9bb77 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -30,19 +30,18 @@ public function __construct( public function create(array $input): Book { return (new DatabaseTransaction(function () use ($input) { - $book = new Book(); - - $this->baseRepo->create($book, $input); + $book = $this->baseRepo->create(new Book(), $input); $this->baseRepo->updateCoverImage($book, $input['image'] ?? null); - $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'] ?? null)); + $book->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); Activity::add(ActivityType::BOOK_CREATE, $book); $defaultBookSortSetting = intval(setting('sorting-book-default', '0')); if ($defaultBookSortSetting && SortRule::query()->find($defaultBookSortSetting)) { $book->sort_rule_id = $defaultBookSortSetting; - $book->save(); } + $book->save(); + return $book; }))->run(); } @@ -52,28 +51,29 @@ public function create(array $input): Book */ public function update(Book $book, array $input): Book { - $this->baseRepo->update($book, $input); + $book = $this->baseRepo->update($book, $input); if (array_key_exists('default_template_id', $input)) { - $this->baseRepo->updateDefaultTemplate($book, intval($input['default_template_id'])); + $book->defaultTemplate()->setFromId(intval($input['default_template_id'])); } if (array_key_exists('image', $input)) { $this->baseRepo->updateCoverImage($book, $input['image'], $input['image'] === null); } + $book->save(); Activity::add(ActivityType::BOOK_UPDATE, $book); return $book; } /** - * Update the given book's cover image, or clear it. + * Update the given book's cover image or clear it. * * @throws ImageUploadException * @throws Exception */ - public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false) + public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false): void { $this->baseRepo->updateCoverImage($book, $coverImage, $removeImage); } @@ -83,7 +83,7 @@ public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $re * * @throws Exception */ - public function destroy(Book $book) + public function destroy(Book $book): void { $this->trashCan->softDestroyBook($book); Activity::add(ActivityType::BOOK_DELETE, $book); diff --git a/app/Entities/Repos/BookshelfRepo.php b/app/Entities/Repos/BookshelfRepo.php index b870ec37747..bb84b51fd5e 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -25,8 +25,7 @@ public function __construct( public function create(array $input, array $bookIds): Bookshelf { return (new DatabaseTransaction(function () use ($input, $bookIds) { - $shelf = new Bookshelf(); - $this->baseRepo->create($shelf, $input); + $shelf = $this->baseRepo->create(new Bookshelf(), $input); $this->baseRepo->updateCoverImage($shelf, $input['image'] ?? null); $this->updateBooks($shelf, $bookIds); Activity::add(ActivityType::BOOKSHELF_CREATE, $shelf); @@ -39,7 +38,7 @@ public function create(array $input, array $bookIds): Bookshelf */ public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf { - $this->baseRepo->update($shelf, $input); + $shelf = $this->baseRepo->update($shelf, $input); if (!is_null($bookIds)) { $this->updateBooks($shelf, $bookIds); @@ -96,7 +95,7 @@ protected function updateBooks(Bookshelf $shelf, array $bookIds): void * * @throws Exception */ - public function destroy(Bookshelf $shelf) + public function destroy(Bookshelf $shelf): void { $this->trashCan->softDestroyShelf($shelf); Activity::add(ActivityType::BOOKSHELF_DELETE, $shelf); diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index 5d4b5297841..d5feb30fdfd 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -33,8 +33,11 @@ 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); - $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'] ?? null)); + + $chapter = $this->baseRepo->create($chapter, $input); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'] ?? null)); + + $chapter->save(); Activity::add(ActivityType::CHAPTER_CREATE, $chapter); $this->baseRepo->sortParent($chapter); @@ -48,12 +51,13 @@ public function create(array $input, Book $parentBook): Chapter */ public function update(Chapter $chapter, array $input): Chapter { - $this->baseRepo->update($chapter, $input); + $chapter = $this->baseRepo->update($chapter, $input); if (array_key_exists('default_template_id', $input)) { - $this->baseRepo->updateDefaultTemplate($chapter, intval($input['default_template_id'])); + $chapter->defaultTemplate()->setFromId(intval($input['default_template_id'])); } + $chapter->save(); Activity::add(ActivityType::CHAPTER_UPDATE, $chapter); $this->baseRepo->sortParent($chapter); @@ -66,7 +70,7 @@ public function update(Chapter $chapter, array $input): Chapter * * @throws Exception */ - public function destroy(Chapter $chapter) + public function destroy(Chapter $chapter): void { $this->trashCan->softDestroyChapter($chapter); Activity::add(ActivityType::CHAPTER_DELETE, $chapter); @@ -93,7 +97,7 @@ public function move(Chapter $chapter, string $parentIdentifier): Book } return (new DatabaseTransaction(function () use ($chapter, $parent) { - $chapter->changeBook($parent->id); + $chapter = $chapter->changeBook($parent->id); $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); diff --git a/app/Entities/Repos/DeletionRepo.php b/app/Entities/Repos/DeletionRepo.php index e47192cc2f1..5b67e5e6b52 100644 --- a/app/Entities/Repos/DeletionRepo.php +++ b/app/Entities/Repos/DeletionRepo.php @@ -9,11 +9,9 @@ class DeletionRepo { - private TrashCan $trashCan; - - public function __construct(TrashCan $trashCan) - { - $this->trashCan = $trashCan; + public function __construct( + protected TrashCan $trashCan + ) { } public function restore(int $id): int diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index 76377f9a6dd..f2e558210ae 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -37,7 +37,7 @@ public function __construct( /** * Get a new draft page belonging to the given parent entity. */ - public function getNewDraftPage(Entity $parent) + public function getNewDraftPage(Entity $parent): Page { $page = (new Page())->forceFill([ 'name' => trans('entities.pages_initial_name'), @@ -46,6 +46,9 @@ public function getNewDraftPage(Entity $parent) 'updated_by' => user()->id, 'draft' => true, 'editor' => PageEditorType::getSystemDefault()->value, + 'html' => '', + 'markdown' => '', + 'text' => '', ]); if ($parent instanceof Chapter) { @@ -55,17 +58,18 @@ public function getNewDraftPage(Entity $parent) $page->book_id = $parent->id; } - $defaultTemplate = $page->chapter->defaultTemplate ?? $page->book->defaultTemplate; - if ($defaultTemplate && userCan(Permission::PageView, $defaultTemplate)) { + $defaultTemplate = $page->chapter?->defaultTemplate()->get() ?? $page->book?->defaultTemplate()->get(); + if ($defaultTemplate) { $page->forceFill([ 'html' => $defaultTemplate->html, 'markdown' => $defaultTemplate->markdown, ]); + $page->text = (new PageContent($page))->toPlainText(); } (new DatabaseTransaction(function () use ($page) { $page->save(); - $page->refresh()->rebuildPermissions(); + $page->rebuildPermissions(); }))->run(); return $page; @@ -81,7 +85,8 @@ public function publishDraft(Page $draft, array $input): Page $draft->revision_count = 1; $draft->priority = $this->getNewPriority($draft); $this->updateTemplateStatusAndContentFromInput($draft, $input); - $this->baseRepo->update($draft, $input); + + $draft = $this->baseRepo->update($draft, $input); $draft->rebuildPermissions(); $summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision'); @@ -112,12 +117,12 @@ public function setContentFromInput(Page $page, array $input): void public function update(Page $page, array $input): Page { // Hold the old details to compare later - $oldHtml = $page->html; $oldName = $page->name; + $oldHtml = $page->html; $oldMarkdown = $page->markdown; $this->updateTemplateStatusAndContentFromInput($page, $input); - $this->baseRepo->update($page, $input); + $page = $this->baseRepo->update($page, $input); // Update with new details $page->revision_count++; @@ -176,12 +181,12 @@ protected function updateTemplateStatusAndContentFromInput(Page $page, array $in /** * Save a page update draft. */ - public function updatePageDraft(Page $page, array $input) + public function updatePageDraft(Page $page, array $input): Page|PageRevision { - // If the page itself is a draft simply update that + // If the page itself is a draft, simply update that if ($page->draft) { $this->updateTemplateStatusAndContentFromInput($page, $input); - $page->fill($input); + $page->forceFill(array_intersect_key($input, array_flip(['name'])))->save(); $page->save(); return $page; @@ -209,7 +214,7 @@ public function updatePageDraft(Page $page, array $input) * * @throws Exception */ - public function destroy(Page $page) + public function destroy(Page $page): void { $this->trashCan->softDestroyPage($page); Activity::add(ActivityType::PAGE_DELETE, $page); @@ -279,7 +284,7 @@ public function move(Page $page, string $parentIdentifier): Entity return (new DatabaseTransaction(function () use ($page, $parent) { $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; - $page->changeBook($newBookId); + $page = $page->changeBook($newBookId); $page->rebuildPermissions(); Activity::add(ActivityType::PAGE_MOVE, $page); diff --git a/app/Entities/Repos/RevisionRepo.php b/app/Entities/Repos/RevisionRepo.php index d5549a0f14a..2d1371b63bd 100644 --- a/app/Entities/Repos/RevisionRepo.php +++ b/app/Entities/Repos/RevisionRepo.php @@ -23,7 +23,7 @@ public function deleteDraftsForCurrentUser(Page $page): void /** * Get a user update_draft page revision to update for the given page. - * Checks for an existing revisions before providing a fresh one. + * Checks for an existing revision before providing a fresh one. */ public function getNewDraftForCurrentUser(Page $page): PageRevision { @@ -72,7 +72,7 @@ public function storeNewForPage(Page $page, ?string $summary = null): PageRevisi /** * Delete old revisions, for the given page, from the system. */ - protected function deleteOldRevisions(Page $page) + protected function deleteOldRevisions(Page $page): void { $revisionLimit = config('app.revision_limit'); if ($revisionLimit === false) { diff --git a/app/Entities/Tools/BookContents.php b/app/Entities/Tools/BookContents.php index 7dd3f3e11ad..4bbab626520 100644 --- a/app/Entities/Tools/BookContents.php +++ b/app/Entities/Tools/BookContents.php @@ -3,13 +3,10 @@ namespace BookStack\Entities\Tools; use BookStack\Entities\Models\Book; -use BookStack\Entities\Models\BookChild; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; -use BookStack\Sorting\BookSortMap; -use BookStack\Sorting\BookSortMapItem; use Illuminate\Support\Collection; class BookContents @@ -29,7 +26,7 @@ public function getLastPriority(): int { $maxPage = $this->book->pages() ->where('draft', '=', false) - ->where('chapter_id', '=', 0) + ->whereDoesntHave('chapter') ->max('priority'); $maxChapter = $this->book->chapters() @@ -80,11 +77,11 @@ public function getTree(bool $showDrafts = false, bool $renderPages = false): Co protected function bookChildSortFunc(): callable { return function (Entity $entity) { - if (isset($entity['draft']) && $entity['draft']) { + if ($entity->getAttribute('draft') ?? false) { return -100; } - return $entity['priority'] ?? 0; + return $entity->getAttribute('priority') ?? 0; }; } diff --git a/app/Entities/Tools/Cloner.php b/app/Entities/Tools/Cloner.php index 05618fef461..ff42ae6e41b 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -6,8 +6,8 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\HasCoverInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\CoverImageInterface; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Repos\ChapterRepo; @@ -106,8 +106,8 @@ public function entityToInputData(Entity $entity): array $inputData['tags'] = $this->entityTagsToInputArray($entity); // Add a cover to the data if existing on the original entity - if ($entity instanceof CoverImageInterface) { - $cover = $entity->cover()->first(); + if ($entity instanceof HasCoverInterface) { + $cover = $entity->coverInfo()->getImage(); if ($cover) { $inputData['image'] = $this->imageToUploadedFile($cover); } diff --git a/app/Entities/Tools/EntityCover.php b/app/Entities/Tools/EntityCover.php new file mode 100644 index 00000000000..1e8fce201dd --- /dev/null +++ b/app/Entities/Tools/EntityCover.php @@ -0,0 +1,75 @@ +where('id', '=', $this->entity->image_id); + } + + /** + * Check if a cover image exists for this entity. + */ + public function exists(): bool + { + return $this->entity->image_id !== null && $this->imageQuery()->exists(); + } + + /** + * Get the assigned cover image model. + */ + public function getImage(): Image|null + { + if ($this->entity->image_id === null) { + return null; + } + + $cover = $this->imageQuery()->first(); + if ($cover instanceof Image) { + return $cover; + } + + return null; + } + + /** + * Returns a cover image URL, or the given default if none assigned/existing. + */ + public function getUrl(int $width = 440, int $height = 250, string|null $default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw=='): string|null + { + if (!$this->entity->image_id) { + return $default; + } + + try { + return $this->getImage()?->getThumb($width, $height, false) ?? $default; + } catch (Exception $err) { + return $default; + } + } + + /** + * Set the image to use as the cover for this entity. + */ + public function setImage(Image|null $image): void + { + if ($image === null) { + $this->entity->image_id = null; + } else { + $this->entity->image_id = $image->id; + } + } +} diff --git a/app/Entities/Tools/EntityDefaultTemplate.php b/app/Entities/Tools/EntityDefaultTemplate.php new file mode 100644 index 00000000000..d36c3f270e8 --- /dev/null +++ b/app/Entities/Tools/EntityDefaultTemplate.php @@ -0,0 +1,60 @@ +entity->default_template_id); + if (!$changing) { + return; + } + + if ($templateId === 0) { + $this->entity->default_template_id = null; + return; + } + + $pageQueries = app()->make(PageQueries::class); + $templateExists = $pageQueries->visibleTemplates() + ->where('id', '=', $templateId) + ->exists(); + + $this->entity->default_template_id = $templateExists ? $templateId : null; + } + + /** + * Get the default template for this entity (if visible). + */ + public function get(): Page|null + { + if (!$this->entity->default_template_id) { + return null; + } + + $pageQueries = app()->make(PageQueries::class); + $page = $pageQueries->visibleTemplates(true) + ->where('id', '=', $this->entity->default_template_id) + ->first(); + + if ($page instanceof Page) { + return $page; + } + + return null; + } +} diff --git a/app/Entities/Tools/EntityHtmlDescription.php b/app/Entities/Tools/EntityHtmlDescription.php new file mode 100644 index 00000000000..335703c36ad --- /dev/null +++ b/app/Entities/Tools/EntityHtmlDescription.php @@ -0,0 +1,60 @@ +html = $this->entity->description_html ?? ''; + $this->plain = $this->entity->description ?? ''; + } + + /** + * Update the description from HTML code. + * Optionally takes plaintext to use for the model also. + */ + public function set(string $html, string|null $plaintext = null): void + { + $this->html = $html; + $this->entity->description_html = $this->html; + + if ($plaintext !== null) { + $this->plain = $plaintext; + $this->entity->description = $this->plain; + } + + if (empty($html) && !empty($plaintext)) { + $this->html = $this->getHtml(); + $this->entity->description_html = $this->html; + } + } + + /** + * Get the description as HTML. + * Optionally returns the raw HTML if requested. + */ + public function getHtml(bool $raw = false): string + { + $html = $this->html ?: '

' . nl2br(e($this->plain)) . '

'; + if ($raw) { + return $html; + } + + return HtmlContentFilter::removeScriptsFromHtmlString($html); + } + + public function getPlain(): string + { + return $this->plain; + } +} diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php index b0d8880f402..fa45fcd116b 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -34,6 +34,7 @@ public function transformChapterToBook(Chapter $chapter): Book /** @var Page $page */ foreach ($chapter->pages as $page) { $page->chapter_id = 0; + $page->save(); $page->changeBook($book->id); } diff --git a/app/Entities/Tools/MixedEntityListLoader.php b/app/Entities/Tools/MixedEntityListLoader.php index f9a940b981b..0a0f224d86c 100644 --- a/app/Entities/Tools/MixedEntityListLoader.php +++ b/app/Entities/Tools/MixedEntityListLoader.php @@ -19,7 +19,7 @@ public function __construct( * This will look for a model id and type via 'name_id' and 'name_type'. * @param Model[] $relations */ - public function loadIntoRelations(array $relations, string $relationName, bool $loadParents): void + public function loadIntoRelations(array $relations, string $relationName, bool $loadParents, bool $withContents = false): void { $idsByType = []; foreach ($relations as $relation) { @@ -33,7 +33,7 @@ public function loadIntoRelations(array $relations, string $relationName, bool $ $idsByType[$type][] = $id; } - $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents); + $modelMap = $this->idsByTypeToModelMap($idsByType, $loadParents, $withContents); foreach ($relations as $relation) { $type = $relation->getAttribute($relationName . '_type'); @@ -49,13 +49,13 @@ public function loadIntoRelations(array $relations, string $relationName, bool $ * @param array $idsByType * @return array> */ - protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents): array + protected function idsByTypeToModelMap(array $idsByType, bool $eagerLoadParents, bool $withContents): array { $modelMap = []; foreach ($idsByType as $type => $ids) { - $models = $this->queries->visibleForList($type) - ->whereIn('id', $ids) + $base = $withContents ? $this->queries->visibleForContent($type) : $this->queries->visibleForList($type); + $models = $base->whereIn('id', $ids) ->with($eagerLoadParents ? $this->getRelationsToEagerLoad($type) : []) ->get(); diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 4b1d77db720..c7a59216ad0 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -284,7 +284,7 @@ protected function setUniqueId(DOMNode $element, array &$idMap): array /** * Get a plain-text visualisation of this page. */ - protected function toPlainText(): string + public function toPlainText(): string { $html = $this->render(true); diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index d457d4f48db..cc43b909625 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -6,9 +6,10 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Bookshelf; use BookStack\Entities\Models\Chapter; +use BookStack\Entities\Models\EntityContainerData; +use BookStack\Entities\Models\HasCoverInterface; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\CoverImageInterface; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\NotifyException; @@ -140,6 +141,7 @@ protected function ensureDeletable(Entity $entity): void protected function destroyShelf(Bookshelf $shelf): int { $this->destroyCommonRelations($shelf); + $shelf->books()->detach(); $shelf->forceDelete(); return 1; @@ -167,6 +169,7 @@ protected function destroyBook(Book $book): int } $this->destroyCommonRelations($book); + $book->shelves()->detach(); $book->forceDelete(); return $count + 1; @@ -209,15 +212,19 @@ protected function destroyPage(Page $page): int $attachmentService->deleteFile($attachment); } - // Remove book template usages - $this->queries->books->start() + // Remove use as a template + EntityContainerData::query() ->where('default_template_id', '=', $page->id) ->update(['default_template_id' => null]); - // Remove chapter template usages - $this->queries->chapters->start() - ->where('default_template_id', '=', $page->id) - ->update(['default_template_id' => null]); + // TODO - Handle related images (uploaded_to for gallery/drawings). + // Should maybe reset to null + // But does that present visibility/permission issues if they used to retain their old + // unused ID? + // If so, might be better to leave them as-is like before, but ensure the maintenance + // cleanup command/action can find these "orphaned" images and delete them. + // But that would leave potential attachment to new pages on increment reset scenarios. + // Need to review permission scenarios for null field values relative to storage options. $page->forceDelete(); @@ -398,9 +405,11 @@ protected function destroyCommonRelations(Entity $entity) $entity->referencesTo()->delete(); $entity->referencesFrom()->delete(); - if ($entity instanceof CoverImageInterface && $entity->cover()->exists()) { + if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) { $imageService = app()->make(ImageService::class); - $imageService->destroy($entity->cover()->first()); + $imageService->destroy($entity->coverInfo()->getImage()); } + + $entity->relatedData()->delete(); } } diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index 85ac7d2c9e9..ad489aba1cb 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -284,7 +284,7 @@ public function chapterToPlainText(Chapter $chapter): string public function bookToPlainText(Book $book): string { $bookTree = (new BookContents($book))->getTree(false, true); - $text = $book->name . "\n" . $book->description; + $text = $book->name . "\n" . $book->descriptionInfo()->getPlain(); $text = rtrim($text) . "\n\n"; $parts = []; @@ -318,7 +318,7 @@ public function chapterToMarkdown(Chapter $chapter): string { $text = '# ' . $chapter->name . "\n\n"; - $description = (new HtmlToMarkdown($chapter->descriptionHtml()))->convert(); + $description = (new HtmlToMarkdown($chapter->descriptionInfo()->getHtml()))->convert(); if ($description) { $text .= $description . "\n\n"; } @@ -338,7 +338,7 @@ public function bookToMarkdown(Book $book): string $bookTree = (new BookContents($book))->getTree(false, true); $text = '# ' . $book->name . "\n\n"; - $description = (new HtmlToMarkdown($book->descriptionHtml()))->convert(); + $description = (new HtmlToMarkdown($book->descriptionInfo()->getHtml()))->convert(); if ($description) { $text .= $description . "\n\n"; } diff --git a/app/Exports/ZipExports/Models/ZipExportBook.php b/app/Exports/ZipExports/Models/ZipExportBook.php index 6c51ea3379c..ab3fd90ec1c 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -55,10 +55,10 @@ public static function fromModel(Book $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->description_html = $model->descriptionHtml(); + $instance->description_html = $model->descriptionInfo()->getHtml(); - if ($model->cover) { - $instance->cover = $files->referenceForImage($model->cover); + if ($model->coverInfo()->exists()) { + $instance->cover = $files->referenceForImage($model->coverInfo()->getImage()); } $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); diff --git a/app/Exports/ZipExports/Models/ZipExportChapter.php b/app/Exports/ZipExports/Models/ZipExportChapter.php index 260191a3e78..906ce3d8185 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -40,7 +40,7 @@ public static function fromModel(Chapter $model, ZipExportFiles $files): self $instance = new self(); $instance->id = $model->id; $instance->name = $model->name; - $instance->description_html = $model->descriptionHtml(); + $instance->description_html = $model->descriptionInfo()->getHtml(); $instance->priority = $model->priority; $instance->tags = ZipExportTag::fromModelArray($model->tags()->get()->all()); diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index eafb527e87e..748acf43f74 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -135,8 +135,8 @@ protected function importBook(ZipExportBook $exportBook, ZipExportReader $reader 'tags' => $this->exportTagsToInputArray($exportBook->tags ?? []), ]); - if ($book->cover) { - $this->references->addImage($book->cover, null); + if ($book->coverInfo()->getImage()) { + $this->references->addImage($book->coverInfo()->getImage(), null); } $children = [ @@ -197,8 +197,8 @@ protected function importPage(ZipExportPage $exportPage, Book|Chapter $parent, Z $this->pageRepo->publishDraft($page, [ 'name' => $exportPage->name, - 'markdown' => $exportPage->markdown, - 'html' => $exportPage->html, + 'markdown' => $exportPage->markdown ?? '', + 'html' => $exportPage->html ?? '', 'tags' => $this->exportTagsToInputArray($exportPage->tags ?? []), ]); diff --git a/app/Permissions/PermissionApplicator.php b/app/Permissions/PermissionApplicator.php index 23fdcfda9e5..c44a18a4d53 100644 --- a/app/Permissions/PermissionApplicator.php +++ b/app/Permissions/PermissionApplicator.php @@ -40,10 +40,6 @@ public function checkOwnableUserAccess(Model&OwnableInterface $ownable, string|P $ownerField = $ownable->getOwnerFieldName(); $ownableFieldVal = $ownable->getAttribute($ownerField); - if (is_null($ownableFieldVal)) { - throw new InvalidArgumentException("{$ownerField} field used but has not been loaded"); - } - $isOwner = $user->id === $ownableFieldVal; $hasRolePermission = $allRolePermission || ($isOwner && $ownRolePermission); @@ -144,10 +140,10 @@ public function restrictEntityRelationQuery(Builder $query, string $tableName, s /** @var Builder $query */ $query->where($tableDetails['entityTypeColumn'], '!=', $pageMorphClass) ->orWhereExists(function (QueryBuilder $query) use ($tableDetails, $pageMorphClass) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) + $query->select('page_id')->from('entity_page_data') + ->whereColumn('entity_page_data.page_id', '=', $tableDetails['tableName'] . '.' . $tableDetails['entityIdColumn']) ->where($tableDetails['tableName'] . '.' . $tableDetails['entityTypeColumn'], '=', $pageMorphClass) - ->where('pages.draft', '=', false); + ->where('entity_page_data.draft', '=', false); }); }); } @@ -197,18 +193,18 @@ public function restrictPageRelationQuery(Builder $query, string $tableName, str { $fullPageIdColumn = $tableName . '.' . $pageIdColumn; return $this->restrictEntityQuery($query) - ->where(function ($query) use ($fullPageIdColumn) { - /** @var Builder $query */ - $query->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $fullPageIdColumn) - ->where('pages.draft', '=', false); - })->orWhereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { - $query->select('id')->from('pages') - ->whereColumn('pages.id', '=', $fullPageIdColumn) - ->where('pages.draft', '=', true) - ->where('pages.created_by', '=', $this->currentUser()->id); - }); + ->whereExists(function (QueryBuilder $query) use ($fullPageIdColumn) { + $query->select('id')->from('entities') + ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id') + ->whereColumn('entities.id', '=', $fullPageIdColumn) + ->where('entities.type', '=', 'page') + ->where(function (QueryBuilder $query) { + $query->where('entity_page_data.draft', '=', false) + ->orWhere(function (QueryBuilder $query) { + $query->where('entity_page_data.draft', '=', true) + ->where('entities.created_by', '=', $this->currentUser()->id); + }); + }); }); } diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index 1c9664f45a9..8588c6e2c8e 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -20,10 +20,10 @@ public function __construct( * Query and return the references pointing to the given entity. * Loads the commonly required relations while taking permissions into account. */ - public function getReferencesToEntity(Entity $entity): Collection + public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection { $references = $this->queryReferencesToEntity($entity)->get(); - $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', true); + $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents); return $references; } diff --git a/app/References/ReferenceUpdater.php b/app/References/ReferenceUpdater.php index 5f1d711e9c6..06b3389bae5 100644 --- a/app/References/ReferenceUpdater.php +++ b/app/References/ReferenceUpdater.php @@ -3,9 +3,9 @@ namespace BookStack\References; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\Entity; -use BookStack\Entities\Models\HtmlDescriptionInterface; -use BookStack\Entities\Models\HtmlDescriptionTrait; +use BookStack\Entities\Models\EntityContainerData; use BookStack\Entities\Models\Page; use BookStack\Entities\Repos\RevisionRepo; use BookStack\Util\HtmlDocument; @@ -36,7 +36,7 @@ public function updateEntityReferences(Entity $entity, string $oldLink): void protected function getReferencesToUpdate(Entity $entity): array { /** @var Reference[] $references */ - $references = $this->referenceFetcher->getReferencesToEntity($entity)->values()->all(); + $references = $this->referenceFetcher->getReferencesToEntity($entity, true)->values()->all(); if ($entity instanceof Book) { $pages = $entity->pages()->get(['id']); @@ -44,7 +44,7 @@ protected function getReferencesToUpdate(Entity $entity): array $children = $pages->concat($chapters); foreach ($children as $bookChild) { /** @var Reference[] $childRefs */ - $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild)->values()->all(); + $childRefs = $this->referenceFetcher->getReferencesToEntity($bookChild, true)->values()->all(); array_push($references, ...$childRefs); } } @@ -64,16 +64,16 @@ protected function updateReferencesWithinEntity(Entity $entity, string $oldLink, $this->updateReferencesWithinPage($entity, $oldLink, $newLink); } - if ($entity instanceof HtmlDescriptionInterface) { + if ($entity instanceof HasDescriptionInterface) { $this->updateReferencesWithinDescription($entity, $oldLink, $newLink); } } - protected function updateReferencesWithinDescription(Entity&HtmlDescriptionInterface $entity, string $oldLink, string $newLink): void + protected function updateReferencesWithinDescription(Entity&HasDescriptionInterface $entity, string $oldLink, string $newLink): void { - $entity = (clone $entity)->refresh(); - $html = $this->updateLinksInHtml($entity->descriptionHtml(true) ?: '', $oldLink, $newLink); - $entity->setDescriptionHtml($html); + $description = $entity->descriptionInfo(); + $html = $this->updateLinksInHtml($description->getHtml(true) ?: '', $oldLink, $newLink); + $description->set($html); $entity->save(); } diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 1152101d293..99e307e35cc 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -33,22 +33,22 @@ public function runBookAutoSortForAllWithSet(SortRule $set): void */ public function runBookAutoSort(Book $book): void { - $set = $book->sortRule; - if (!$set) { + $rule = $book->sortRule()->first(); + if (!($rule instanceof SortRule)) { return; } $sortFunctions = array_map(function (SortRuleOperation $op) { return $op->getSortFunction(); - }, $set->getOperations()); + }, $rule->getOperations()); $chapters = $book->chapters() - ->with('pages:id,name,priority,created_at,updated_at,chapter_id') + ->with('pages:id,name,book_id,chapter_id,priority,created_at,updated_at') ->get(['id', 'name', 'priority', 'created_at', 'updated_at']); /** @var (Chapter|Book)[] $topItems */ $topItems = [ - ...$book->directPages()->get(['id', 'name', 'priority', 'created_at', 'updated_at']), + ...$book->directPages()->get(['id', 'book_id', 'name', 'priority', 'created_at', 'updated_at']), ...$chapters, ]; @@ -155,11 +155,12 @@ protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMa // Action the required changes if ($bookChanged) { - $model->changeBook($newBook->id); + $model = $model->changeBook($newBook->id); } if ($model instanceof Page && $chapterChanged) { $model->chapter_id = $newChapter->id ?? 0; + $model->unsetRelation('chapter'); } if ($priorityChanged) { diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php index 45e5514fd5f..bf53365a201 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -50,7 +50,7 @@ public function getUrl(): string public function books(): HasMany { - return $this->hasMany(Book::class); + return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id'); } public static function allByName(): Collection diff --git a/app/Sorting/SortRuleController.php b/app/Sorting/SortRuleController.php index bb5540a2a50..65e1cba098a 100644 --- a/app/Sorting/SortRuleController.php +++ b/app/Sorting/SortRuleController.php @@ -3,6 +3,7 @@ namespace BookStack\Sorting; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\EntityContainerData; use BookStack\Http\Controller; use BookStack\Permissions\Permission; use Illuminate\Http\Request; @@ -88,7 +89,9 @@ public function destroy(string $id, Request $request) if ($booksAssigned > 0) { if ($confirmed) { - $rule->books()->update(['sort_rule_id' => null]); + EntityContainerData::query() + ->where('sort_rule_id', $rule->id) + ->update(['sort_rule_id' => null]); } else { $warnings[] = trans('settings.sort_rule_delete_warn_books', ['count' => $booksAssigned]); } diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index b47d6ff8dd1..ea3c4a962b3 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Entities\Queries\PageQueries; use BookStack\Exceptions\FileUploadException; use BookStack\Http\ApiController; @@ -173,13 +174,13 @@ protected function rules(): array return [ 'create' => [ 'name' => ['required', 'string', 'min:1', 'max:255'], - 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'file' => array_merge(['required_without:link'], $this->attachmentService->getFileValidationRules()), 'link' => ['required_without:file', 'string', 'min:1', 'max:2000', 'safe_url'], ], 'update' => [ 'name' => ['string', 'min:1', 'max:255'], - 'uploaded_to' => ['integer', 'exists:pages,id'], + 'uploaded_to' => ['integer', new EntityExistsRule('page')], 'file' => $this->attachmentService->getFileValidationRules(), 'link' => ['string', 'min:1', 'max:2000', 'safe_url'], ], diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 0886193e46d..9c60fa415f8 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -2,6 +2,7 @@ namespace BookStack\Uploads\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Entities\Queries\PageQueries; use BookStack\Entities\Repos\PageRepo; use BookStack\Exceptions\FileUploadException; @@ -34,7 +35,7 @@ public function __construct( public function upload(Request $request) { $this->validate($request, [ - 'uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()), ]); @@ -144,7 +145,7 @@ public function attachLink(Request $request) try { $this->validate($request, [ - 'attachment_link_uploaded_to' => ['required', 'integer', 'exists:pages,id'], + 'attachment_link_uploaded_to' => ['required', 'integer', new EntityExistsRule('page')], 'attachment_link_name' => ['required', 'string', 'min:1', 'max:255'], 'attachment_link_url' => ['required', 'string', 'min:1', 'max:2000', 'safe_url'], ]); diff --git a/app/Uploads/ImageService.php b/app/Uploads/ImageService.php index 458f0102d77..402456e9791 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -184,7 +184,7 @@ public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = t /** @var Image $image */ foreach ($images as $image) { $searchQuery = '%' . basename($image->path) . '%'; - $inPage = DB::table('pages') + $inPage = DB::table('entity_page_data') ->where('html', 'like', $searchQuery)->count() > 0; $inRevision = false; diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index 9134b3cc13b..25753280f17 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -2,6 +2,7 @@ namespace BookStack\Users\Controllers; +use BookStack\Entities\EntityExistsRule; use BookStack\Exceptions\UserUpdateException; use BookStack\Http\ApiController; use BookStack\Permissions\Permission; diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index d24f7002e71..79d9e1b9eb2 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -6,12 +6,14 @@ use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; use BookStack\Entities\EntityProvider; +use BookStack\Entities\Models\Entity; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\UserUpdateException; use BookStack\Facades\Activity; use BookStack\Uploads\UserAvatars; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use DB; use Exception; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Facades\Log; @@ -181,6 +183,7 @@ public function destroy(User $user, ?int $newOwnerId = null) if (!is_null($newOwner)) { $this->migrateOwnership($user, $newOwner); } + // TODO - Should be be nullifying ownership instead? } Activity::add(ActivityType::USER_DELETE, $user); @@ -203,13 +206,11 @@ protected function ensureDeletable(User $user): void /** * Migrate ownership of items in the system from one user to another. */ - protected function migrateOwnership(User $fromUser, User $toUser) + protected function migrateOwnership(User $fromUser, User $toUser): void { - $entities = (new EntityProvider())->all(); - foreach ($entities as $instance) { - $instance->newQuery()->where('owned_by', '=', $fromUser->id) - ->update(['owned_by' => $toUser->id]); - } + DB::table('entities') + ->where('owned_by', '=', $fromUser->id) + ->update(['owned_by' => $toUser->id]); } /** diff --git a/database/factories/Entities/Models/ChapterFactory.php b/database/factories/Entities/Models/ChapterFactory.php index 1fc49933ef7..abf554ac894 100644 --- a/database/factories/Entities/Models/ChapterFactory.php +++ b/database/factories/Entities/Models/ChapterFactory.php @@ -26,7 +26,8 @@ public function definition() 'name' => $this->faker->sentence(), 'slug' => Str::random(10), 'description' => $description, - 'description_html' => '

' . e($description) . '

' + 'description_html' => '

' . e($description) . '

', + 'priority' => 5, ]; } } diff --git a/database/factories/Entities/Models/PageFactory.php b/database/factories/Entities/Models/PageFactory.php index 8115700950c..47e5aa5db2b 100644 --- a/database/factories/Entities/Models/PageFactory.php +++ b/database/factories/Entities/Models/PageFactory.php @@ -31,6 +31,7 @@ public function definition() 'text' => strip_tags($html), 'revision_count' => 1, 'editor' => 'wysiwyg', + 'priority' => 1, ]; } } diff --git a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php index 0e25c1d6052..a8f1843ed92 100644 --- a/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php +++ b/database/migrations/2023_01_24_104625_refactor_joint_permissions_storage.php @@ -25,9 +25,6 @@ public function up(): void $table->unsignedInteger('owner_id')->nullable()->index(); }); } - - // Rebuild permissions - app(JointPermissionBuilder::class)->rebuildForAll(); } /** diff --git a/database/migrations/2025_09_15_132850_create_entities_table.php b/database/migrations/2025_09_15_132850_create_entities_table.php new file mode 100644 index 00000000000..6c890d7191d --- /dev/null +++ b/database/migrations/2025_09_15_132850_create_entities_table.php @@ -0,0 +1,71 @@ +bigIncrements('id'); + $table->string('type', 10)->index(); + $table->string('name'); + $table->string('slug')->index(); + + $table->unsignedBigInteger('book_id')->nullable()->index(); + $table->unsignedBigInteger('chapter_id')->nullable()->index(); + $table->unsignedInteger('priority')->nullable(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable()->index(); + $table->timestamp('deleted_at')->nullable()->index(); + + $table->unsignedInteger('created_by')->nullable(); + $table->unsignedInteger('updated_by')->nullable(); + $table->unsignedInteger('owned_by')->nullable()->index(); + + $table->primary(['id', 'type'], 'entities_pk'); + }); + + Schema::create('entity_container_data', function (Blueprint $table) { + $table->unsignedBigInteger('entity_id'); + $table->string('entity_type', 10); + $table->text('description'); + $table->text('description_html'); + + $table->unsignedBigInteger('default_template_id')->nullable(); + $table->unsignedInteger('image_id')->nullable(); + $table->unsignedInteger('sort_rule_id')->nullable(); + + $table->primary(['entity_id', 'entity_type'], 'entity_container_data_pk'); + }); + + Schema::create('entity_page_data', function (Blueprint $table) { + $table->unsignedBigInteger('page_id')->primary(); + + $table->boolean('draft')->index(); + $table->boolean('template')->index(); + $table->unsignedInteger('revision_count'); + $table->string('editor', 50); + + $table->longText('html'); + $table->longText('text'); + $table->longText('markdown'); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('entities'); + Schema::dropIfExists('entity_container_data'); + Schema::dropIfExists('entity_page_data'); + } +}; diff --git a/database/migrations/2025_09_15_134701_migrate_entity_data.php b/database/migrations/2025_09_15_134701_migrate_entity_data.php new file mode 100644 index 00000000000..7b4beef0673 --- /dev/null +++ b/database/migrations/2025_09_15_134701_migrate_entity_data.php @@ -0,0 +1,90 @@ + 'book', 'bookshelves' => 'bookshelf'] as $table => $type) { + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table($table)->select([ + 'id', DB::raw("'{$type}'"), 'name', 'slug', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + } + + // Migrate chapter data to entities + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table('chapters')->select([ + 'id', DB::raw("'chapter'"), 'name', 'slug', 'book_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + + DB::table('entities')->insertUsing([ + 'id', 'type', 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ], DB::table('pages')->select([ + 'id', DB::raw("'page'"), 'name', 'slug', 'book_id', 'chapter_id', 'priority', 'created_at', 'updated_at', 'deleted_at', 'created_by', 'updated_by', 'owned_by', + ])); + + // Migrate shelf data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'image_id', + ], DB::table('bookshelves')->select([ + 'id', DB::raw("'bookshelf'"), 'description', 'description_html', 'image_id', + ])); + + // Migrate book data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id' + ], DB::table('books')->select([ + 'id', DB::raw("'book'"), 'description', 'description_html', 'default_template_id', 'image_id', 'sort_rule_id' + ])); + + // Migrate chapter data to entity_container_data + DB::table('entity_container_data')->insertUsing([ + 'entity_id', 'entity_type', 'description', 'description_html', 'default_template_id', + ], DB::table('chapters')->select([ + 'id', DB::raw("'chapter'"), 'description', 'description_html', 'default_template_id', + ])); + + // Migrate page data to entity_page_data + DB::table('entity_page_data')->insertUsing([ + 'page_id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown', + ], DB::table('pages')->select([ + 'id', 'draft', 'template', 'revision_count', 'editor', 'html', 'text', 'markdown', + ])); + + // Fix up data - Convert 0 id references to null + DB::table('entities')->where('created_by', '=', 0)->update(['created_by' => null]); + DB::table('entities')->where('updated_by', '=', 0)->update(['updated_by' => null]); + DB::table('entities')->where('owned_by', '=', 0)->update(['owned_by' => null]); + DB::table('entities')->where('chapter_id', '=', 0)->update(['chapter_id' => null]); + + // Fix up data - Convert any missing id-based references to null + $userIdQuery = DB::table('users')->select('id'); + DB::table('entities')->whereNotIn('created_by', $userIdQuery)->update(['created_by' => null]); + DB::table('entities')->whereNotIn('updated_by', $userIdQuery)->update(['updated_by' => null]); + DB::table('entities')->whereNotIn('owned_by', $userIdQuery)->update(['owned_by' => null]); + DB::table('entities')->whereNotIn('chapter_id', DB::table('chapters')->select('id'))->update(['chapter_id' => null]); + + // Commit our changes within our transaction + DB::commit(); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // No action here since the actual data remains in the database for the old tables, + // so data reversion actions are done in a later migration when the old tables are dropped. + } +}; diff --git a/database/migrations/2025_09_15_134751_update_entity_relation_columns.php b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php new file mode 100644 index 00000000000..267cd49f551 --- /dev/null +++ b/database/migrations/2025_09_15_134751_update_entity_relation_columns.php @@ -0,0 +1,114 @@ +> $columnByTable + */ + protected static array $columnByTable = [ + 'activities' => 'loggable_id', + 'attachments' => 'uploaded_to', + 'bookshelves_books' => ['bookshelf_id', 'book_id'], + 'comments' => 'entity_id', + 'deletions' => 'deletable_id', + 'entity_permissions' => 'entity_id', + 'favourites' => 'favouritable_id', + 'images' => 'uploaded_to', + 'joint_permissions' => 'entity_id', + 'page_revisions' => 'page_id', + 'references' => ['from_id', 'to_id'], + 'search_terms' => 'entity_id', + 'tags' => 'entity_id', + 'views' => 'viewable_id', + 'watches' => 'watchable_id', + ]; + + protected static array $nullable = [ + 'activities.loggable_id', + 'images.uploaded_to', + ]; + + /** + * Run the migrations. + */ + public function up(): void + { + // Drop foreign key constraints + Schema::table('bookshelves_books', function (Blueprint $table) { + $table->dropForeign(['book_id']); + $table->dropForeign(['bookshelf_id']); + }); + + // Update column types to unsigned big integers + foreach (static::$columnByTable as $table => $column) { + $tableName = $table; + Schema::table($table, function (Blueprint $table) use ($tableName, $column) { + if (is_string($column)) { + $column = [$column]; + } + + foreach ($column as $col) { + if (in_array($tableName . '.' . $col, static::$nullable)) { + $table->unsignedBigInteger($col)->nullable()->change(); + } else { + $table->unsignedBigInteger($col)->change(); + } + } + }); + } + + // Convert image zero values to null + DB::table('images')->where('uploaded_to', '=', 0)->update(['uploaded_to' => null]); + + // Rebuild joint permissions if needed + // This was moved here from 2023_01_24_104625_refactor_joint_permissions_storage since the changes + // made for this release would mean our current logic would not be compatible with + // the database changes being made. This is based on a count since any joint permissions + // would have been truncated in the previous migration. + if (\Illuminate\Support\Facades\DB::table('joint_permissions')->count() === 0) { + app(JointPermissionBuilder::class)->rebuildForAll(); + } + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Convert image null values back to zeros + DB::table('images')->whereNull('uploaded_to')->update(['uploaded_to' => '0']); + + // Revert columns to standard integers + foreach (static::$columnByTable as $table => $column) { + $tableName = $table; + Schema::table($table, function (Blueprint $table) use ($tableName, $column) { + if (is_string($column)) { + $column = [$column]; + } + + foreach ($column as $col) { + if ($tableName . '.' . $col === 'activities.loggable_id') { + $table->unsignedInteger($col)->nullable()->change(); + } else if ($tableName . '.' . $col === 'images.uploaded_to') { + $table->unsignedInteger($col)->default(0)->change(); + } else { + $table->unsignedInteger($col)->change(); + } + } + }); + } + + // Re-add foreign key constraints + Schema::table('bookshelves_books', function (Blueprint $table) { + $table->foreign('bookshelf_id')->references('id')->on('bookshelves') + ->onUpdate('cascade')->onDelete('cascade'); + $table->foreign('book_id')->references('id')->on('books') + ->onUpdate('cascade')->onDelete('cascade'); + }); + } +}; diff --git a/database/migrations/2025_09_15_134813_drop_old_entity_tables.php b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php new file mode 100644 index 00000000000..d6360f74db1 --- /dev/null +++ b/database/migrations/2025_09_15_134813_drop_old_entity_tables.php @@ -0,0 +1,162 @@ +unsignedInteger('id', true)->primary(); + $table->integer('book_id')->index(); + $table->integer('chapter_id')->index(); + $table->string('name'); + $table->string('slug')->index(); + $table->longText('html'); + $table->longText('text'); + $table->integer('priority')->index(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable()->index(); + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->boolean('draft')->default(0)->index(); + $table->longText('markdown'); + $table->integer('revision_count'); + $table->boolean('template')->default(0)->index(); + $table->timestamp('deleted_at')->nullable(); + + $table->unsignedInteger('owned_by')->index(); + $table->string('editor', 50)->default(''); + }); + + Schema::create('chapters', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->integer('book_id')->index(); + $table->string('slug')->index(); + $table->text('name'); + $table->text('description'); + $table->integer('priority')->index(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->timestamp('deleted_at')->nullable(); + $table->unsignedInteger('owned_by')->index(); + $table->text('description_html'); + $table->integer('default_template_id')->nullable(); + }); + + Schema::create('books', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->string('name'); + $table->string('slug')->index(); + $table->text('description'); + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + + $table->integer('image_id')->nullable(); + $table->timestamp('deleted_at')->nullable(); + $table->unsignedInteger('owned_by')->index(); + + $table->integer('default_template_id')->nullable(); + $table->text('description_html'); + $table->unsignedInteger('sort_rule_id')->nullable(); + }); + + Schema::create('bookshelves', function (Blueprint $table) { + $table->unsignedInteger('id', true)->primary(); + $table->string('name', 180); + $table->string('slug', 180)->index(); + $table->text('description'); + + $table->integer('created_by')->index(); + $table->integer('updated_by')->index(); + $table->integer('image_id')->nullable(); + + $table->timestamp('created_at')->nullable(); + $table->timestamp('updated_at')->nullable(); + $table->timestamp('deleted_at')->nullable(); + + $table->unsignedInteger('owned_by')->index(); + $table->text('description_html'); + }); + + DB::beginTransaction(); + + // Revert nulls back to zeros + DB::table('entities')->whereNull('created_by')->update(['created_by' => 0]); + DB::table('entities')->whereNull('updated_by')->update(['updated_by' => 0]); + DB::table('entities')->whereNull('owned_by')->update(['owned_by' => 0]); + DB::table('entities')->whereNull('chapter_id')->update(['chapter_id' => 0]); + + // Restore data back into pages table + $pageFields = [ + 'id', 'book_id', 'chapter_id', 'name', 'slug', 'html', 'text', 'priority', 'created_at', 'updated_at', + 'created_by', 'updated_by', 'draft', 'markdown', 'revision_count', 'template', 'deleted_at', 'owned_by', 'editor' + ]; + $pageQuery = DB::table('entities')->select($pageFields) + ->leftJoin('entity_page_data', 'entities.id', '=', 'entity_page_data.page_id') + ->where('type', '=', 'page'); + DB::table('pages')->insertUsing($pageFields, $pageQuery); + + // Restore data back into chapters table + $containerJoinClause = function (JoinClause $join) { + return $join->on('entities.id', '=', 'entity_container_data.entity_id') + ->on('entities.type', '=', 'entity_container_data.entity_type'); + }; + $chapterFields = [ + 'id', 'book_id', 'slug', 'name', 'description', 'priority', 'created_at', 'updated_at', 'created_by', 'updated_by', + 'deleted_at', 'owned_by', 'description_html', 'default_template_id' + ]; + $chapterQuery = DB::table('entities')->select($chapterFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'chapter'); + DB::table('chapters')->insertUsing($chapterFields, $chapterQuery); + + // Restore data back into books table + $bookFields = [ + 'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id', + 'deleted_at', 'owned_by', 'default_template_id', 'description_html', 'sort_rule_id' + ]; + $bookQuery = DB::table('entities')->select($bookFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'book'); + DB::table('books')->insertUsing($bookFields, $bookQuery); + + // Restore data back into bookshelves table + $shelfFields = [ + 'id', 'name', 'slug', 'description', 'created_by', 'updated_by', 'image_id', 'created_at', 'updated_at', + 'deleted_at', 'owned_by', 'description_html', + ]; + $shelfQuery = DB::table('entities')->select($shelfFields) + ->leftJoin('entity_container_data', $containerJoinClause) + ->where('type', '=', 'bookshelf'); + DB::table('bookshelves')->insertUsing($shelfFields, $shelfQuery); + + DB::commit(); + } +}; diff --git a/database/seeders/DummyContentSeeder.php b/database/seeders/DummyContentSeeder.php index a4383be50a2..5f787259a18 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -12,7 +12,10 @@ use BookStack\Search\SearchIndex; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Database\Eloquent\Relations\HasMany; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Database\Seeder; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Hash; use Illuminate\Support\Str; @@ -39,40 +42,58 @@ public function run() $byData = ['created_by' => $editorUser->id, 'updated_by' => $editorUser->id, 'owned_by' => $editorUser->id]; - Book::factory()->count(5)->create($byData) + Book::factory()->count(5)->make($byData) ->each(function ($book) use ($byData) { + $book->save(); $chapters = Chapter::factory()->count(3)->create($byData) ->each(function ($chapter) use ($book, $byData) { $pages = Page::factory()->count(3)->make(array_merge($byData, ['book_id' => $book->id])); - $chapter->pages()->saveMany($pages); + $this->saveManyOnRelation($pages, $chapter->pages()); }); $pages = Page::factory()->count(3)->make($byData); - $book->chapters()->saveMany($chapters); - $book->pages()->saveMany($pages); + $this->saveManyOnRelation($chapters, $book->chapters()); + $this->saveManyOnRelation($pages, $book->pages()); }); - $largeBook = Book::factory()->create(array_merge($byData, ['name' => 'Large book' . Str::random(10)])); + $largeBook = Book::factory()->make(array_merge($byData, ['name' => 'Large book' . Str::random(10)])); + $largeBook->save(); + $pages = Page::factory()->count(200)->make($byData); $chapters = Chapter::factory()->count(50)->make($byData); - $largeBook->pages()->saveMany($pages); - $largeBook->chapters()->saveMany($chapters); + $this->saveManyOnRelation($pages, $largeBook->pages()); + $this->saveManyOnRelation($chapters, $largeBook->chapters()); + + $shelves = Bookshelf::factory()->count(10)->make($byData); + foreach ($shelves as $shelf) { + $shelf->save(); + } - $shelves = Bookshelf::factory()->count(10)->create($byData); $largeBook->shelves()->attach($shelves->pluck('id')); // Assign API permission to editor role and create an API key $apiPermission = RolePermission::getByName('access-api'); $editorRole->attachPermission($apiPermission); $token = (new ApiToken())->forceFill([ - 'user_id' => $editorUser->id, - 'name' => 'Testing API key', + 'user_id' => $editorUser->id, + 'name' => 'Testing API key', 'expires_at' => ApiToken::defaultExpiry(), - 'secret' => Hash::make('password'), - 'token_id' => 'apitoken', + 'secret' => Hash::make('password'), + 'token_id' => 'apitoken', ]); $token->save(); app(JointPermissionBuilder::class)->rebuildForAll(); app(SearchIndex::class)->indexAllEntities(); } + + /** + * Inefficient workaround for saving many on a relation since we can't directly insert + * entities since we split them across tables. + */ + protected function saveManyOnRelation(Collection $entities, HasMany $relation): void + { + foreach ($entities as $entity) { + $relation->save($entity); + } + } } diff --git a/dev/api/responses/books-read.json b/dev/api/responses/books-read.json index afeebade619..582744f99a5 100644 --- a/dev/api/responses/books-read.json +++ b/dev/api/responses/books-read.json @@ -52,7 +52,7 @@ "name": "Cool Animals", "slug": "cool-animals", "book_id": 16, - "chapter_id": 0, + "chapter_id": null, "draft": false, "template": false, "created_at": "2021-12-19T18:22:11.000000Z", diff --git a/dev/api/responses/pages-create.json b/dev/api/responses/pages-create.json index 11f5ab8c8a5..705dea6f619 100644 --- a/dev/api/responses/pages-create.json +++ b/dev/api/responses/pages-create.json @@ -1,7 +1,7 @@ { "id": 358, "book_id": 1, - "chapter_id": 0, + "chapter_id": null, "name": "My API Page", "slug": "my-api-page", "html": "

my new API page

", diff --git a/dev/api/responses/pages-read.json b/dev/api/responses/pages-read.json index 2f3538964d4..22ff2de84af 100644 --- a/dev/api/responses/pages-read.json +++ b/dev/api/responses/pages-read.json @@ -1,7 +1,7 @@ { "id": 306, "book_id": 1, - "chapter_id": 0, + "chapter_id": null, "name": "A page written in markdown", "slug": "a-page-written-in-markdown", "html": "

This is my cool page! With some included text

", diff --git a/dev/api/responses/recycle-bin-list.json b/dev/api/responses/recycle-bin-list.json index 853070839e9..19167ec05fa 100644 --- a/dev/api/responses/recycle-bin-list.json +++ b/dev/api/responses/recycle-bin-list.json @@ -10,7 +10,7 @@ "deletable": { "id": 2582, "book_id": 25, - "chapter_id": 0, + "chapter_id": null, "name": "A Wonderful Page", "slug": "a-wonderful-page", "priority": 9, diff --git a/resources/views/books/parts/form.blade.php b/resources/views/books/parts/form.blade.php index 44d495c2777..bb2cc936fe0 100644 --- a/resources/views/books/parts/form.blade.php +++ b/resources/views/books/parts/form.blade.php @@ -18,7 +18,7 @@ @include('form.image-picker', [ 'defaultImage' => url('/book_default_cover.png'), - 'currentImage' => (isset($model) && $model->cover) ? $model->getBookCover() : url('/book_default_cover.png') , + 'currentImage' => (($model ?? null)?->coverInfo()->getUrl(440, 250, null) ?? url('/book_default_cover.png')), 'name' => 'image', 'imageClass' => 'cover' ]) diff --git a/resources/views/books/parts/list-item.blade.php b/resources/views/books/parts/list-item.blade.php index a3ff0971f11..0852670fe6d 100644 --- a/resources/views/books/parts/list-item.blade.php +++ b/resources/views/books/parts/list-item.blade.php @@ -1,11 +1,16 @@ +@php + /** + * @var \BookStack\Entities\Models\Book $book + */ +@endphp -
+
@icon('book')

{{ $book->name }}

-

{{ $book->description }}

+

{{ $book->descriptionInfo()->getPlain() }}

\ No newline at end of file diff --git a/resources/views/books/show.blade.php b/resources/views/books/show.blade.php index e28d9564829..d510c8fd560 100644 --- a/resources/views/books/show.blade.php +++ b/resources/views/books/show.blade.php @@ -8,8 +8,8 @@ @push('social-meta') - @if($book->cover) - + @if($book->coverInfo()->exists()) + @endif @endpush @@ -26,7 +26,7 @@

{{$book->name}}

-
{!! $book->descriptionHtml() !!}
+
{!! $book->descriptionInfo()->getHtml() !!}
@if(count($bookChildren) > 0)
@foreach($bookChildren as $childElement) diff --git a/resources/views/chapters/show.blade.php b/resources/views/chapters/show.blade.php index da914b32d21..585bf8a3b8d 100644 --- a/resources/views/chapters/show.blade.php +++ b/resources/views/chapters/show.blade.php @@ -24,7 +24,7 @@

{{ $chapter->name }}

-
{!! $chapter->descriptionHtml() !!}
+
{!! $chapter->descriptionInfo()->getHtml() !!}
@if(count($pages) > 0)
@foreach($pages as $page) diff --git a/resources/views/entities/grid-item.blade.php b/resources/views/entities/grid-item.blade.php index 17c54e26353..e64d0f42cd8 100644 --- a/resources/views/entities/grid-item.blade.php +++ b/resources/views/entities/grid-item.blade.php @@ -1,7 +1,7 @@
@@ -42,7 +45,8 @@ class="scroll-box configured-option-list">
- +
    @@ -54,7 +58,6 @@ class="scroll-box available-option-list">
-
- {{ trans('common.cancel') }} + {{ trans('common.cancel') }}
diff --git a/resources/views/shelves/parts/list-item.blade.php b/resources/views/shelves/parts/list-item.blade.php index 00cacfa707c..5fc8a362b32 100644 --- a/resources/views/shelves/parts/list-item.blade.php +++ b/resources/views/shelves/parts/list-item.blade.php @@ -1,5 +1,5 @@ -
+
@icon('bookshelf')
diff --git a/resources/views/shelves/show.blade.php b/resources/views/shelves/show.blade.php index 633f959f3c9..9ee14f1bf4e 100644 --- a/resources/views/shelves/show.blade.php +++ b/resources/views/shelves/show.blade.php @@ -2,8 +2,8 @@ @push('social-meta') - @if($shelf->cover) - + @if($shelf->coverInfo()->exists()) + @endif @endpush @@ -28,7 +28,7 @@
-
{!! $shelf->descriptionHtml() !!}
+
{!! $shelf->descriptionInfo()->getHtml() !!}
@if(count($sortedVisibleShelfBooks) > 0) @if($view === 'list')
diff --git a/tests/Api/ApiAuthTest.php b/tests/Api/ApiAuthTest.php index 93e4b02e423..4e446bf5d1a 100644 --- a/tests/Api/ApiAuthTest.php +++ b/tests/Api/ApiAuthTest.php @@ -12,7 +12,7 @@ class ApiAuthTest extends TestCase { use TestsApi; - protected $endpoint = '/api/books'; + protected string $endpoint = '/api/books'; public function test_requests_succeed_with_default_auth() { diff --git a/tests/Api/BooksApiTest.php b/tests/Api/BooksApiTest.php index 22ccfb482c9..e5bd77b67d1 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -47,8 +47,8 @@ public function test_index_endpoint_includes_cover_if_set() [ 'id' => $book->id, 'cover' => [ - 'id' => $book->cover->id, - 'url' => $book->cover->url, + 'id' => $book->coverInfo()->getImage()->id, + 'url' => $book->coverInfo()->getImage()->url, ], ], ]]); @@ -94,7 +94,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('books', $expectedDetails); + $this->assertDatabaseHasEntityData('book', $expectedDetails); } public function test_book_name_needed_to_create() @@ -153,23 +153,23 @@ public function test_read_endpoint_includes_chapter_and_page_contents() $directChildCount = $book->directPages()->count() + $book->chapters()->count(); $resp->assertStatus(200); $resp->assertJsonCount($directChildCount, 'contents'); - $resp->assertJson([ - 'contents' => [ - [ - 'type' => 'chapter', - 'id' => $chapter->id, - 'name' => $chapter->name, - 'slug' => $chapter->slug, - 'pages' => [ - [ - 'id' => $chapterPage->id, - 'name' => $chapterPage->name, - 'slug' => $chapterPage->slug, - ] - ] - ] - ] - ]); + + $contents = $resp->json('contents'); + $respChapter = array_values(array_filter($contents, fn ($item) => ($item['id'] === $chapter->id && $item['type'] === 'chapter')))[0]; + $this->assertArrayMapIncludes([ + 'id' => $chapter->id, + 'type' => 'chapter', + 'name' => $chapter->name, + 'slug' => $chapter->slug, + ], $respChapter); + + $respPage = array_values(array_filter($respChapter['pages'], fn ($item) => ($item['id'] === $chapterPage->id)))[0]; + + $this->assertArrayMapIncludes([ + 'id' => $chapterPage->id, + 'name' => $chapterPage->name, + 'slug' => $chapterPage->slug, + ], $respPage); } public function test_read_endpoint_contents_nested_pages_has_permissions_applied() @@ -224,14 +224,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$book->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('books', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); + $this->assertDatabaseHasEntityData('book', array_merge($details, ['id' => $book->id, 'description' => 'A book updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $book = $this->entities->book(); - DB::table('books')->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); + Book::query()->where('id', '=', $book->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -247,7 +247,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $book */ $book = $this->entities->book(); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -257,7 +257,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -266,7 +266,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNotNull($book->cover); + $this->assertNotNull($book->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$book->id}", [ @@ -275,7 +275,7 @@ public function test_update_cover_image_control() $book->refresh(); $resp->assertStatus(200); - $this->assertNull($book->cover); + $this->assertNull($book->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Api/ChaptersApiTest.php b/tests/Api/ChaptersApiTest.php index 5d7b0530891..194140a569a 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -91,7 +91,7 @@ public function test_create_endpoint_with_html() 'description' => 'A chapter created via the API', ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('chapters', $expectedDetails); + $this->assertDatabaseHasEntityData('chapter', $expectedDetails); } public function test_chapter_name_needed_to_create() @@ -155,7 +155,7 @@ public function test_read_endpoint() 'owned_by' => $page->owned_by, 'created_by' => $page->created_by, 'updated_by' => $page->updated_by, - 'book_id' => $page->id, + 'book_id' => $page->book->id, 'chapter_id' => $chapter->id, 'priority' => $page->priority, 'book_slug' => $chapter->book->slug, @@ -213,7 +213,7 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$chapter->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('chapters', array_merge($details, [ + $this->assertDatabaseHasEntityData('chapter', array_merge($details, [ 'id' => $chapter->id, 'description' => 'A chapter updated via the API' ])); } @@ -222,7 +222,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $chapter = $this->entities->chapter(); - DB::table('chapters')->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $chapter->newQuery()->where('id', '=', $chapter->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -244,8 +244,8 @@ public function test_update_with_book_id_moves_chapter() $resp->assertOk(); $chapter->refresh(); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'book_id' => $newBook->id]); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'book_id' => $newBook->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'book_id' => $newBook->id, 'chapter_id' => $chapter->id]); } public function test_update_with_new_book_id_requires_delete_permission() diff --git a/tests/Api/ContentPermissionsApiTest.php b/tests/Api/ContentPermissionsApiTest.php index a62abacc75e..464d62683ad 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -280,7 +280,7 @@ public function test_update_can_both_provide_owner_and_fallback_permissions() ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', ['id' => $page->id, 'owned_by' => $user->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id, 'owned_by' => $user->id]); $this->assertDatabaseHas('entity_permissions', [ 'entity_id' => $page->id, 'entity_type' => 'page', diff --git a/tests/Api/PagesApiTest.php b/tests/Api/PagesApiTest.php index ced8954eb11..8caf85affbe 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -286,7 +286,7 @@ public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $page = $this->entities->page(); - DB::table('pages')->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $page->newQuery()->where('id', '=', $page->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], diff --git a/tests/Api/RecycleBinApiTest.php b/tests/Api/RecycleBinApiTest.php index d174838c27d..6ccc69c3545 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -144,7 +144,7 @@ public function test_restore_endpoint() $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -154,7 +154,7 @@ public function test_restore_endpoint() 'restore_count' => 1, ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => null, ]); @@ -168,7 +168,7 @@ public function test_destroy_endpoint() $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -178,6 +178,6 @@ public function test_destroy_endpoint() 'delete_count' => 1, ]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } } diff --git a/tests/Api/ShelvesApiTest.php b/tests/Api/ShelvesApiTest.php index ba13c0153b1..34ce0e4e5b2 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -48,8 +48,8 @@ public function test_index_endpoint_includes_cover_if_set() [ 'id' => $shelf->id, 'cover' => [ - 'id' => $shelf->cover->id, - 'url' => $shelf->cover->url, + 'id' => $shelf->coverInfo()->getImage()->id, + 'url' => $shelf->coverInfo()->getImage()->url, ], ], ]]); @@ -102,7 +102,7 @@ public function test_create_endpoint_with_html() ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('bookshelves', $expectedDetails); + $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails); } public function test_shelf_name_needed_to_create() @@ -181,14 +181,14 @@ public function test_update_endpoint_with_html() $resp = $this->putJson($this->baseEndpoint . "/{$shelf->id}", $details); $resp->assertStatus(200); - $this->assertDatabaseHas('bookshelves', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($details, ['id' => $shelf->id, 'description' => 'A shelf updated via the API'])); } public function test_update_increments_updated_date_if_only_tags_are_sent() { $this->actingAsApiEditor(); $shelf = Bookshelf::visible()->first(); - DB::table('bookshelves')->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); + $shelf->newQuery()->where('id', '=', $shelf->id)->update(['updated_at' => Carbon::now()->subWeek()]); $details = [ 'tags' => [['name' => 'Category', 'value' => 'Testing']], @@ -222,7 +222,7 @@ public function test_update_cover_image_control() $this->actingAsApiEditor(); /** @var Book $shelf */ $shelf = Bookshelf::visible()->first(); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); $file = $this->files->uploadedImage('image.png'); // Ensure cover image can be set via API @@ -232,7 +232,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure further updates without image do not clear cover image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -241,7 +241,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNotNull($shelf->cover); + $this->assertNotNull($shelf->coverInfo()->getImage()); // Ensure update with null image property clears image $resp = $this->put($this->baseEndpoint . "/{$shelf->id}", [ @@ -250,7 +250,7 @@ public function test_update_cover_image_control() $shelf->refresh(); $resp->assertStatus(200); - $this->assertNull($shelf->cover); + $this->assertNull($shelf->coverInfo()->getImage()); } public function test_delete_endpoint() diff --git a/tests/Auth/MfaConfigurationTest.php b/tests/Auth/MfaConfigurationTest.php index 1f359b41a10..5184bf9843c 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType; use BookStack\Users\Models\Role; use BookStack\Users\Models\User; +use Illuminate\Support\Facades\Hash; use PragmaRX\Google2FA\Google2FA; use Tests\TestCase; @@ -166,6 +167,36 @@ public function test_remove_mfa_method() $this->assertEquals(0, $admin->mfaValues()->count()); } + public function test_mfa_required_if_set_on_role() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + /** @var Role $role */ + $role = $user->roles()->first(); + $role->mfa_enforced = true; + $role->save(); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + + public function test_mfa_required_if_mfa_option_configured() + { + $user = $this->users->viewer(); + $user->password = Hash::make('password'); + $user->save(); + $user->mfaValues()->create([ + 'method' => MfaValue::METHOD_TOTP, + 'value' => 'test', + ]); + + $resp = $this->post('/login', ['email' => $user->email, 'password' => 'password']); + $this->assertFalse(auth()->check()); + $resp->assertRedirect('/mfa/verify'); + } + public function test_totp_setup_url_shows_correct_user_when_setup_forced_upon_login() { $admin = $this->users->admin(); diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php index d336e05a240..356a026a849 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -19,7 +19,7 @@ public function test_command_updates_page_content() ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y') ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'html' => '', ]); @@ -40,7 +40,7 @@ public function test_command_updates_description_html() ->expectsQuestion('This operation could cause issues if used incorrectly. Have you made a backup of your existing database?', 'y'); foreach ($models as $model) { - $this->assertDatabaseHas($model->getTable(), [ + $this->assertDatabaseHasEntityData($model->getMorphClass(), [ 'id' => $model->id, 'description_html' => '', ]); diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index ad1d64e7126..3ba2c3e99c8 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -91,7 +91,7 @@ public function test_shelves_create() ])); $resp->assertRedirect(); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['created_by' => $editorId, 'updated_by' => $editorId])); $shelf = Bookshelf::where('name', '=', $shelfInfo['name'])->first(); $shelfPage = $this->get($shelf->getUrl()); @@ -117,11 +117,12 @@ public function test_shelves_create_sets_cover_image() $lastImage = Image::query()->orderByDesc('id')->firstOrFail(); $shelf = Bookshelf::query()->where('name', '=', $shelfInfo['name'])->first(); - $this->assertDatabaseHas('bookshelves', [ - 'id' => $shelf->id, + $this->assertDatabaseHas('entity_container_data', [ + 'entity_id' => $shelf->id, + 'entity_type' => 'bookshelf', 'image_id' => $lastImage->id, ]); - $this->assertEquals($lastImage->id, $shelf->cover->id); + $this->assertEquals($lastImage->id, $shelf->coverInfo()->getImage()->id); $this->assertEquals('cover_bookshelf', $lastImage->type); } @@ -247,7 +248,7 @@ public function test_shelf_edit() $this->assertSessionHas('success'); $editorId = $this->users->editor()->id; - $this->assertDatabaseHas('bookshelves', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); + $this->assertDatabaseHasEntityData('bookshelf', array_merge($shelfInfo, ['id' => $shelf->id, 'created_by' => $editorId, 'updated_by' => $editorId])); $shelfPage = $this->get($shelf->getUrl()); $shelfPage->assertSee($shelfInfo['name']); diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 51bf65d10bb..543c4e8bbdb 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -27,7 +27,7 @@ public function test_create() $resp = $this->get('/books/my-first-book'); $resp->assertSee($book->name); - $resp->assertSee($book->description); + $resp->assertSee($book->descriptionInfo()->getPlain()); } public function test_create_uses_different_slugs_when_name_reused() @@ -362,12 +362,12 @@ public function test_copy_clones_cover_image_if_existing() $coverImageFile = $this->files->uploadedImage('cover.png'); $bookRepo->updateCoverImage($book, $coverImageFile); - $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book']); + $this->asEditor()->post($book->getUrl('/copy'), ['name' => 'My copy book'])->assertRedirect(); /** @var Book $copy */ $copy = Book::query()->where('name', '=', 'My copy book')->first(); - $this->assertNotNull($copy->cover); - $this->assertNotEquals($book->cover->id, $copy->cover->id); + $this->assertNotNull($copy->coverInfo()->getImage()); + $this->assertNotEquals($book->coverInfo()->getImage()->id, $copy->coverInfo()->getImage()->id); } public function test_copy_adds_book_to_shelves_if_edit_permissions_allows() diff --git a/tests/Entity/ConvertTest.php b/tests/Entity/ConvertTest.php index d9b1ee466cf..8658e76998b 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -35,8 +35,8 @@ public function test_convert_chapter_to_book() /** @var Book $newBook */ $newBook = Book::query()->orderBy('id', 'desc')->first(); - $this->assertDatabaseMissing('chapters', ['id' => $chapter->id]); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); + $this->assertDatabaseMissing('entities', ['id' => $chapter->id, 'type' => 'chapter']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $newBook->id, 'chapter_id' => 0]); $this->assertCount(1, $newBook->tags); $this->assertEquals('Category', $newBook->tags->first()->name); $this->assertEquals('Penguins', $newBook->tags->first()->value); @@ -100,7 +100,7 @@ public function test_book_convert_to_shelf() // Checks for new shelf $resp->assertRedirectContains('/shelves/'); - $this->assertDatabaseMissing('chapters', ['id' => $childChapter->id]); + $this->assertDatabaseMissing('entities', ['id' => $childChapter->id, 'type' => 'chapter']); $this->assertCount(1, $newShelf->tags); $this->assertEquals('Category', $newShelf->tags->first()->name); $this->assertEquals('Ducks', $newShelf->tags->first()->value); @@ -112,8 +112,8 @@ public function test_book_convert_to_shelf() $this->assertActivityExists(ActivityType::BOOKSHELF_CREATE_FROM_BOOK, $newShelf); // Checks for old book to contain child pages - $this->assertDatabaseHas('books', ['id' => $book->id, 'name' => $book->name . ' Pages']); - $this->assertDatabaseHas('pages', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => 0]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'name' => $book->name . ' Pages']); + $this->assertDatabaseHasEntityData('page', ['id' => $childPage->id, 'book_id' => $book->id, 'chapter_id' => null]); // Checks for nested page $chapterChildPage->refresh(); diff --git a/tests/Entity/DefaultTemplateTest.php b/tests/Entity/DefaultTemplateTest.php index 5369a5430bc..d3109c8a2fe 100644 --- a/tests/Entity/DefaultTemplateTest.php +++ b/tests/Entity/DefaultTemplateTest.php @@ -18,7 +18,7 @@ public function test_creating_book_with_default_template() ]; $this->asEditor()->post('/books', $details); - $this->assertDatabaseHas('books', $details); + $this->assertDatabaseHasEntityData('book', $details); } public function test_creating_chapter_with_default_template() @@ -31,7 +31,7 @@ public function test_creating_chapter_with_default_template() ]; $this->asEditor()->post($book->getUrl('/create-chapter'), $details); - $this->assertDatabaseHas('chapters', $details); + $this->assertDatabaseHasEntityData('chapter', $details); } public function test_updating_book_with_default_template() @@ -40,10 +40,10 @@ public function test_updating_book_with_default_template() $templatePage = $this->entities->templatePage(); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($book->getUrl(), ['name' => $book->name, 'default_template_id' => '']); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_updating_chapter_with_default_template() @@ -52,10 +52,10 @@ public function test_updating_chapter_with_default_template() $templatePage = $this->entities->templatePage(); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => strval($templatePage->id)]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); $this->asEditor()->put($chapter->getUrl(), ['name' => $chapter->name, 'default_template_id' => '']); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_default_book_template_cannot_be_set_if_not_a_template() @@ -65,7 +65,7 @@ public function test_default_book_template_cannot_be_set_if_not_a_template() $this->assertFalse($page->template); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_a_template() @@ -75,7 +75,7 @@ public function test_default_chapter_template_cannot_be_set_if_not_a_template() $this->assertFalse($page->template); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $page->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } @@ -86,7 +86,7 @@ public function test_default_book_template_cannot_be_set_if_not_have_access() $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => null]); } public function test_default_chapter_template_cannot_be_set_if_not_have_access() @@ -96,7 +96,7 @@ public function test_default_chapter_template_cannot_be_set_if_not_have_access() $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => null]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => null]); } public function test_inaccessible_book_default_template_can_be_set_if_unchanged() @@ -106,7 +106,7 @@ public function test_inaccessible_book_default_template_can_be_set_if_unchanged( $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/books/{$book->slug}", ['name' => $book->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('books', ['id' => $book->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('book', ['id' => $book->id, 'default_template_id' => $templatePage->id]); } public function test_inaccessible_chapter_default_template_can_be_set_if_unchanged() @@ -116,7 +116,7 @@ public function test_inaccessible_chapter_default_template_can_be_set_if_unchang $this->permissions->disableEntityInheritedPermissions($templatePage); $this->asEditor()->put("/chapters/{$chapter->slug}", ['name' => $chapter->name, 'default_template_id' => $templatePage->id]); - $this->assertDatabaseHas('chapters', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); + $this->assertDatabaseHasEntityData('chapter', ['id' => $chapter->id, 'default_template_id' => $templatePage->id]); } public function test_default_page_template_option_shows_on_book_form() @@ -173,7 +173,7 @@ public function test_creating_book_page_uses_book_default_template() $templatePage->forceFill(['html' => '

My template page

', 'markdown' => '# My template page'])->save(); $book = $this->bookUsingDefaultTemplate($templatePage); - $this->asEditor()->get($book->getUrl('/create-page')); + $this->asEditor()->get($book->getUrl('/create-page'))->assertRedirect(); $latestPage = $book->pages() ->where('draft', '=', true) ->where('template', '=', false) @@ -251,7 +251,7 @@ public function test_creating_page_as_guest_uses_default_template() $this->post($book->getUrl('/create-guest-page'), [ 'name' => 'My guest page with template' - ]); + ])->assertRedirect(); $latestBookPage = $book->pages() ->where('draft', '=', false) ->where('template', '=', false) diff --git a/tests/Entity/PageDraftTest.php b/tests/Entity/PageDraftTest.php index e99ba9b8189..2623acd3f42 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -204,7 +204,7 @@ public function test_updating_page_draft_with_markdown_retains_markdown_content( ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $draft->id, 'draft' => true, 'name' => 'My updated draft', @@ -235,7 +235,7 @@ public function test_slug_generated_on_draft_publish_to_page_when_no_name_change 'markdown' => '# My markdown page', ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $draft->id, 'draft' => false, 'slug' => 'my-page', diff --git a/tests/Entity/PageEditorTest.php b/tests/Entity/PageEditorTest.php index ad753c96647..d98b1f998df 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -85,7 +85,7 @@ public function test_empty_markdown_still_saves_without_error() $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details); $resp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'markdown' => $details['markdown'], 'id' => $draft->id, 'draft' => false, diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 9040254f78f..3828bd06e4a 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -91,7 +91,7 @@ public function test_page_revision_restore_with_markdown_retains_markdown_conten $restoreReq->assertRedirect($page->getUrl()); $pageView = $this->get($page->getUrl()); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'markdown' => '## New Content def456', ]); diff --git a/tests/Entity/PageTemplateTest.php b/tests/Entity/PageTemplateTest.php index 6a68c3ab18f..9c867a534fa 100644 --- a/tests/Entity/PageTemplateTest.php +++ b/tests/Entity/PageTemplateTest.php @@ -35,7 +35,7 @@ public function test_manage_templates_permission_required_to_change_page_templat ]; $this->put($page->getUrl(), $pageUpdateData); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'template' => false, ]); @@ -43,7 +43,7 @@ public function test_manage_templates_permission_required_to_change_page_templat $this->permissions->grantUserRolePermissions($editor, ['templates-manage']); $this->put($page->getUrl(), $pageUpdateData); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'template' => true, ]); diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index d2c448bf4b7..6994144626e 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -74,7 +74,7 @@ public function test_page_creation_with_markdown_content() $resp = $this->post($book->getUrl("/draft/{$draft->id}"), $details); $resp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'markdown' => $details['markdown'], 'name' => $details['name'], 'id' => $draft->id, @@ -242,7 +242,7 @@ public function test_page_can_be_copied_without_edit_permission() ]); $movePageResp->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'name' => 'My copied test page', 'created_by' => $viewer->id, 'book_id' => $newBook->id, diff --git a/tests/Exports/MarkdownExportTest.php b/tests/Exports/MarkdownExportTest.php index 3bccd468270..6bf585d5903 100644 --- a/tests/Exports/MarkdownExportTest.php +++ b/tests/Exports/MarkdownExportTest.php @@ -84,7 +84,7 @@ public function test_book_markdown_export_concats_immediate_pages_with_newlines( $this->asEditor()->get($book->getUrl('/create-page')); $this->get($book->getUrl('/create-page')); - [$pageA, $pageB] = $book->pages()->where('chapter_id', '=', 0)->get(); + [$pageA, $pageB] = $book->pages()->whereNull('chapter_id')->get(); $pageA->html = '

hello tester

'; $pageA->save(); $pageB->name = 'The second page in this test'; diff --git a/tests/Exports/ZipExportTest.php b/tests/Exports/ZipExportTest.php index 1310dcc2456..692a5910f3c 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -227,7 +227,7 @@ public function test_book_export() $bookData = $zip->data['book']; $this->assertEquals($book->id, $bookData['id']); $this->assertEquals($book->name, $bookData['name']); - $this->assertEquals($book->descriptionHtml(), $bookData['description_html']); + $this->assertEquals($book->descriptionInfo()->getHtml(), $bookData['description_html']); $this->assertCount(2, $bookData['tags']); $this->assertCount($book->directPages()->count(), $bookData['pages']); $this->assertCount($book->chapters()->count(), $bookData['chapters']); @@ -240,7 +240,7 @@ public function test_book_export_with_cover_image() $bookRepo = $this->app->make(BookRepo::class); $coverImageFile = $this->files->uploadedImage('cover.png'); $bookRepo->updateCoverImage($book, $coverImageFile); - $coverImage = $book->cover()->first(); + $coverImage = $book->coverInfo()->getImage(); $zipResp = $this->asEditor()->get($book->getUrl("/export/zip")); $zip = ZipTestHelper::extractFromZipResponse($zipResp); @@ -264,7 +264,7 @@ public function test_chapter_export() $chapterData = $zip->data['chapter']; $this->assertEquals($chapter->id, $chapterData['id']); $this->assertEquals($chapter->name, $chapterData['name']); - $this->assertEquals($chapter->descriptionHtml(), $chapterData['description_html']); + $this->assertEquals($chapter->descriptionInfo()->getHtml(), $chapterData['description_html']); $this->assertCount(2, $chapterData['tags']); $this->assertEquals($chapter->priority, $chapterData['priority']); $this->assertCount($chapter->pages()->count(), $chapterData['pages']); diff --git a/tests/Exports/ZipImportRunnerTest.php b/tests/Exports/ZipImportRunnerTest.php index 68ffb4231bd..2255e16c393 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -109,7 +109,7 @@ public function test_book_import() // Book checks $this->assertEquals('Import test', $book->name); - $this->assertFileExists(public_path($book->cover->path)); + $this->assertFileExists(public_path($book->coverInfo()->getImage()->path)); $this->assertCount(2, $book->tags); $this->assertEquals('Cat', $book->tags()->first()->value); $this->assertCount(2, $book->chapters); diff --git a/tests/Helpers/EntityProvider.php b/tests/Helpers/EntityProvider.php index c794f9478ab..5163cef14a0 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -50,7 +50,7 @@ public function pageWithinChapter(): Page public function pageNotWithinChapter(): Page { - return $this->page(fn(Builder $query) => $query->where('chapter_id', '=', 0)); + return $this->page(fn(Builder $query) => $query->whereNull('chapter_id')); } public function templatePage(): Page diff --git a/tests/Meta/OpenGraphTest.php b/tests/Meta/OpenGraphTest.php index 96e622da052..b3571635996 100644 --- a/tests/Meta/OpenGraphTest.php +++ b/tests/Meta/OpenGraphTest.php @@ -49,7 +49,7 @@ public function test_book_tags() $resp = $this->asEditor()->get($book->getUrl()); $tags = $this->getOpenGraphTags($resp); - $this->assertEquals($book->getBookCover(), $tags['image']); + $this->assertEquals($book->coverInfo()->getUrl(), $tags['image']); } public function test_shelf_tags() @@ -69,7 +69,7 @@ public function test_shelf_tags() $resp = $this->asEditor()->get($shelf->getUrl()); $tags = $this->getOpenGraphTags($resp); - $this->assertEquals($shelf->getBookCover(), $tags['image']); + $this->assertEquals($shelf->coverInfo()->getUrl(), $tags['image']); } /** diff --git a/tests/Permissions/EntityOwnerChangeTest.php b/tests/Permissions/EntityOwnerChangeTest.php index f002549220b..fd3f2797256 100644 --- a/tests/Permissions/EntityOwnerChangeTest.php +++ b/tests/Permissions/EntityOwnerChangeTest.php @@ -13,7 +13,7 @@ public function test_changing_page_owner() $user = User::query()->where('id', '!=', $page->owned_by)->first(); $this->asAdmin()->put($page->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('pages', ['owned_by' => $user->id, 'id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['owned_by' => $user->id, 'id' => $page->id]); } public function test_changing_chapter_owner() @@ -22,7 +22,7 @@ public function test_changing_chapter_owner() $user = User::query()->where('id', '!=', $chapter->owned_by)->first(); $this->asAdmin()->put($chapter->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('chapters', ['owned_by' => $user->id, 'id' => $chapter->id]); + $this->assertDatabaseHasEntityData('chapter', ['owned_by' => $user->id, 'id' => $chapter->id]); } public function test_changing_book_owner() @@ -31,7 +31,7 @@ public function test_changing_book_owner() $user = User::query()->where('id', '!=', $book->owned_by)->first(); $this->asAdmin()->put($book->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('books', ['owned_by' => $user->id, 'id' => $book->id]); + $this->assertDatabaseHasEntityData('book', ['owned_by' => $user->id, 'id' => $book->id]); } public function test_changing_shelf_owner() @@ -40,6 +40,6 @@ public function test_changing_shelf_owner() $user = User::query()->where('id', '!=', $shelf->owned_by)->first(); $this->asAdmin()->put($shelf->getUrl('permissions'), ['owned_by' => $user->id]); - $this->assertDatabaseHas('bookshelves', ['owned_by' => $user->id, 'id' => $shelf->id]); + $this->assertDatabaseHasEntityData('bookshelf', ['owned_by' => $user->id, 'id' => $shelf->id]); } } diff --git a/tests/Permissions/EntityPermissionsTest.php b/tests/Permissions/EntityPermissionsTest.php index ec2756b1213..d399e0c3463 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -629,10 +629,8 @@ public function test_page_visible_if_has_permissions_when_book_not_visible() public function test_book_sort_view_permission() { - /** @var Book $firstBook */ - $firstBook = Book::query()->first(); - /** @var Book $secondBook */ - $secondBook = Book::query()->find(2); + $firstBook = $this->entities->book(); + $secondBook = $this->entities->book(); $this->setRestrictionsForTestRoles($firstBook, ['view', 'update']); $this->setRestrictionsForTestRoles($secondBook, ['view']); diff --git a/tests/PublicActionTest.php b/tests/PublicActionTest.php index 76745aaac72..e6fc7a6a3c5 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -104,7 +104,7 @@ public function test_public_page_creation() $resp->assertRedirect($chapter->book->getUrl('/page/my-guest-page/edit')); $user = $this->users->guest(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'name' => 'My guest page', 'chapter_id' => $chapter->id, 'created_by' => $user->id, diff --git a/tests/References/ReferencesTest.php b/tests/References/ReferencesTest.php index f8698d02885..389f164a9ef 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -259,7 +259,7 @@ public function test_description_links_from_book_chapter_shelf_updated_on_url_ch } $oldUrl = $shelf->getUrl(); - $this->put($shelf->getUrl(), ['name' => 'My updated shelf link']); + $this->put($shelf->getUrl(), ['name' => 'My updated shelf link'])->assertRedirect(); $shelf->refresh(); $this->assertNotEquals($oldUrl, $shelf->getUrl()); diff --git a/tests/Settings/RecycleBinTest.php b/tests/Settings/RecycleBinTest.php index 33284b4b3ff..c17cfed97ea 100644 --- a/tests/Settings/RecycleBinTest.php +++ b/tests/Settings/RecycleBinTest.php @@ -3,6 +3,7 @@ namespace Tests\Settings; use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Deletion; use BookStack\Entities\Models\Page; use Illuminate\Support\Carbon; @@ -82,10 +83,12 @@ public function test_recycle_bin_empty() $emptyReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertDatabaseMissing('books', ['id' => $book->id]); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); - $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); - $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']); + $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']); + $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']); $itemCount = 2 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -95,18 +98,18 @@ public function test_recycle_bin_empty() public function test_entity_restore() { $book = $this->entities->bookHasChaptersAndPages(); - $this->asEditor()->delete($book->getUrl()); + $this->asEditor()->delete($book->getUrl())->assertRedirect(); $deletion = Deletion::query()->firstOrFail(); - $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); - $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + $this->assertEquals($book->pages->count(), Page::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), Chapter::query()->withTrashed()->where('book_id', '=', $book->id)->whereNotNull('deleted_at')->count()); $restoreReq = $this->asAdmin()->post("/settings/recycle-bin/{$deletion->id}/restore"); $restoreReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertEquals($book->pages->count(), DB::table('pages')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); - $this->assertEquals($book->chapters->count(), DB::table('chapters')->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + $this->assertEquals($book->pages->count(), Page::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); + $this->assertEquals($book->chapters->count(), Chapter::query()->where('book_id', '=', $book->id)->whereNull('deleted_at')->count()); $itemCount = 1 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -123,9 +126,12 @@ public function test_permanent_delete() $deleteReq->assertRedirect('/settings/recycle-bin'); $this->assertTrue(Deletion::query()->count() === 0); - $this->assertDatabaseMissing('books', ['id' => $book->id]); - $this->assertDatabaseMissing('pages', ['id' => $book->pages->first()->id]); - $this->assertDatabaseMissing('chapters', ['id' => $book->chapters->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->id, 'type' => 'book']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->id, 'entity_type' => 'book']); + $this->assertDatabaseMissing('entities', ['id' => $book->pages->first()->id, 'type' => 'page']); + $this->assertDatabaseMissing('entity_page_data', ['page_id' => $book->pages->first()->id]); + $this->assertDatabaseMissing('entities', ['id' => $book->chapters->first()->id, 'type' => 'chapter']); + $this->assertDatabaseMissing('entity_container_data', ['entity_id' => $book->chapters->first()->id, 'entity_type' => 'chapter']); $itemCount = 1 + $book->pages->count() + $book->chapters->count(); $redirectReq = $this->get('/settings/recycle-bin'); @@ -173,6 +179,34 @@ public function test_permanent_entity_delete_updates_existing_activity_with_enti ]); } + public function test_permanent_book_delete_removes_shelf_relation_data() + { + $book = $this->entities->book(); + $shelf = $this->entities->shelf(); + $shelf->books()->attach($book); + $this->assertDatabaseHas('bookshelves_books', ['book_id' => $book->id]); + + $this->asEditor()->delete($book->getUrl()); + $deletion = $book->deletions()->firstOrFail(); + $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect(); + + $this->assertDatabaseMissing('bookshelves_books', ['book_id' => $book->id]); + } + + public function test_permanent_shelf_delete_removes_book_relation_data() + { + $book = $this->entities->book(); + $shelf = $this->entities->shelf(); + $shelf->books()->attach($book); + $this->assertDatabaseHas('bookshelves_books', ['bookshelf_id' => $shelf->id]); + + $this->asEditor()->delete($shelf->getUrl()); + $deletion = $shelf->deletions()->firstOrFail(); + $this->asAdmin()->delete("/settings/recycle-bin/{$deletion->id}")->assertRedirect(); + + $this->assertDatabaseMissing('bookshelves_books', ['bookshelf_id' => $shelf->id]); + } + public function test_auto_clear_functionality_works() { config()->set('app.recycle_bin_lifetime', 5); @@ -180,14 +214,14 @@ public function test_auto_clear_functionality_works() $otherPage = $this->entities->page(); $this->asEditor()->delete($page->getUrl()); - $this->assertDatabaseHas('pages', ['id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id]); $this->assertEquals(1, Deletion::query()->count()); Carbon::setTestNow(Carbon::now()->addDays(6)); $this->asEditor()->delete($otherPage->getUrl()); $this->assertEquals(1, Deletion::query()->count()); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); } public function test_auto_clear_functionality_with_negative_time_keeps_forever() @@ -203,7 +237,7 @@ public function test_auto_clear_functionality_with_negative_time_keeps_forever() $this->asEditor()->delete($otherPage->getUrl()); $this->assertEquals(2, Deletion::query()->count()); - $this->assertDatabaseHas('pages', ['id' => $page->id]); + $this->assertDatabaseHasEntityData('page', ['id' => $page->id]); } public function test_auto_clear_functionality_with_zero_time_deletes_instantly() @@ -212,7 +246,7 @@ public function test_auto_clear_functionality_with_zero_time_deletes_instantly() $page = $this->entities->page(); $this->asEditor()->delete($page->getUrl()); - $this->assertDatabaseMissing('pages', ['id' => $page->id]); + $this->assertDatabaseMissing('entities', ['id' => $page->id, 'type' => 'page']); $this->assertEquals(0, Deletion::query()->count()); } diff --git a/tests/Sorting/BookSortTest.php b/tests/Sorting/BookSortTest.php index 4737ec231b6..7f31f9c2739 100644 --- a/tests/Sorting/BookSortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -66,7 +66,7 @@ public function test_book_sort() $sortResp = $this->asEditor()->put($newBook->getUrl() . '/sort', ['sort-tree' => json_encode($reqData)]); $sortResp->assertRedirect($newBook->getUrl()); $sortResp->assertStatus(302); - $this->assertDatabaseHas('chapters', [ + $this->assertDatabaseHasEntityData('chapter', [ 'id' => $chapterToMove->id, 'book_id' => $newBook->id, 'priority' => 0, @@ -93,7 +93,7 @@ public function test_book_sort_makes_no_changes_if_new_chapter_does_not_align_wi ]; $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -114,7 +114,7 @@ public function test_book_sort_makes_no_changes_if_no_view_permissions_on_new_ch ]; $this->asEditor()->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -136,7 +136,7 @@ public function test_book_sort_makes_no_changes_if_no_view_permissions_on_new_bo ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -158,7 +158,7 @@ public function test_book_sort_makes_no_changes_if_no_update_or_create_permissio ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -180,7 +180,7 @@ public function test_book_sort_makes_no_changes_if_no_update_permissions_on_move ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -202,7 +202,7 @@ public function test_book_sort_makes_no_changes_if_no_delete_permissions_on_move ]; $this->actingAs($editor)->put($page->book->getUrl('/sort'), ['sort-tree' => json_encode([$sortData])])->assertRedirect(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'chapter_id' => $page->chapter_id, 'book_id' => $page->book_id, ]); } @@ -211,7 +211,7 @@ public function test_book_sort_does_not_change_timestamps_on_just_order_changes( { $book = $this->entities->bookHasChaptersAndPages(); $chapter = $book->chapters()->first(); - \DB::table('chapters')->where('id', '=', $chapter->id)->update([ + Chapter::query()->where('id', '=', $chapter->id)->update([ 'priority' => 10001, 'updated_at' => \Carbon\Carbon::now()->subYear(5), ]); @@ -299,7 +299,7 @@ public function test_pages_in_book_show_sorted_by_priority() $book = $this->entities->bookHasChaptersAndPages(); $book->chapters()->forceDelete(); /** @var Page[] $pages */ - $pages = $book->pages()->where('chapter_id', '=', 0)->take(2)->get(); + $pages = $book->pages()->whereNull('chapter_id')->take(2)->get(); $book->pages()->whereNotIn('id', $pages->pluck('id'))->delete(); $resp = $this->asEditor()->get($book->getUrl()); diff --git a/tests/Sorting/MoveTest.php b/tests/Sorting/MoveTest.php index 606b23c680f..5a341026bf2 100644 --- a/tests/Sorting/MoveTest.php +++ b/tests/Sorting/MoveTest.php @@ -20,7 +20,7 @@ public function test_page_move_into_book() $movePageResp = $this->put($page->getUrl('/move'), [ 'entity_selection' => 'book:' . $newBook->id, - ]); + ])->assertRedirect(); $page->refresh(); $movePageResp->assertRedirect($page->getUrl()); diff --git a/tests/Sorting/SortRuleTest.php b/tests/Sorting/SortRuleTest.php index 4a9d3a7b33f..a6be9beef1f 100644 --- a/tests/Sorting/SortRuleTest.php +++ b/tests/Sorting/SortRuleTest.php @@ -142,7 +142,7 @@ public function test_delete_requires_confirmation_if_books_assigned() $resp = $this->delete("settings/sorting/rules/{$rule->id}", ['confirm' => 'true']); $resp->assertRedirect('/settings/sorting'); $this->assertDatabaseMissing('sort_rules', ['id' => $rule->id]); - $this->assertDatabaseMissing('books', ['sort_rule_id' => $rule->id]); + $this->assertDatabaseMissing('entity_container_data', ['sort_rule_id' => $rule->id]); } public function test_page_create_triggers_book_sort() @@ -159,7 +159,7 @@ public function test_page_create_triggers_book_sort() ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => '1111 page', 'priority' => $book->chapters()->count() + 1, @@ -217,7 +217,7 @@ public function test_name_alphabetical_ordering() } foreach ($namesToAdd as $index => $name) { - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => $name, 'priority' => $index + 1, @@ -251,7 +251,7 @@ public function test_name_numeric_ordering() } foreach ($namesToAdd as $index => $name) { - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => $name, 'priority' => $index + 1, diff --git a/tests/TestCase.php b/tests/TestCase.php index a8636fb158e..2395317482e 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ use BookStack\Http\HttpClientHistory; use BookStack\Http\HttpRequestService; use BookStack\Settings\SettingService; -use BookStack\Users\Models\User; +use Exception; use Illuminate\Contracts\Console\Kernel; use Illuminate\Foundation\Testing\DatabaseTransactions; use Illuminate\Foundation\Testing\TestCase as BaseTestCase; @@ -15,6 +15,7 @@ use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; use Illuminate\Testing\Assert as PHPUnit; +use Illuminate\Testing\Constraints\HasInDatabase; use Monolog\Handler\TestHandler; use Monolog\Logger; use Ssddanbrown\AssertHtml\TestsHtml; @@ -267,4 +268,42 @@ protected function assertActivityExists(string $type, ?Entity $entity = null, st $this->assertDatabaseHas('activities', $detailsToCheck); } + + /** + * Assert the database has the given data for an entity type. + */ + protected function assertDatabaseHasEntityData(string $type, array $data = []): self + { + $entityFields = array_intersect_key($data, array_flip(Entity::$commonFields)); + $extraFields = array_diff_key($data, $entityFields); + $extraTable = $type === 'page' ? 'entity_page_data' : 'entity_container_data'; + $entityFields['type'] = $type; + + $this->assertThat( + $this->getTable('entities'), + new HasInDatabase($this->getConnection(null, 'entities'), $entityFields) + ); + + if (!empty($extraFields)) { + $id = $entityFields['id'] ?? DB::table($this->getTable('entities')) + ->where($entityFields)->orderByDesc('id')->first()->id ?? null; + if (is_null($id)) { + throw new Exception('Failed to find entity id for asserting database data'); + } + + if ($type !== 'page') { + $extraFields['entity_id'] = $id; + $extraFields['entity_type'] = $type; + } else { + $extraFields['page_id'] = $id; + } + + $this->assertThat( + $this->getTable($extraTable), + new HasInDatabase($this->getConnection(null, $extraTable), $extraFields) + ); + } + + return $this; + } } diff --git a/tests/User/UserManagementTest.php b/tests/User/UserManagementTest.php index d92f13f0b3f..6d8b4d75a53 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -165,8 +165,8 @@ public function test_delete_with_new_owner_id_changes_ownership() $owner = $page->ownedBy; $newOwner = User::query()->where('id', '!=', $owner->id)->first(); - $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id]); - $this->assertDatabaseHas('pages', [ + $this->asAdmin()->delete("settings/users/{$owner->id}", ['new_owner_id' => $newOwner->id])->assertRedirect(); + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'owned_by' => $newOwner->id, ]);