From 4c7d6420ee6ab08c5d1df5221962d84abcb89b21 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Oct 2025 13:14:30 +0100 Subject: [PATCH] DB: Aligned entity structure to a common table As per PR #5800 * DB: Planned out new entity table format via migrations * DB: Created entity migration logic Made some other tweaks/fixes while testing. * DB: Added change of entity relation columns to suit new entities table * DB: Got most view queries working for new structure * Entities: Started logic change to new structure Updated base entity class, and worked through BaseRepo. Need to go through other repos next. Removed a couple of redundant interfaces as part of this since we can move the logic onto the shared ContainerData model as needed. * Entities: Been through repos to update for new format * Entities: Updated repos to act on refreshed clones Changes to core entity models are now done on clones to ensure clean state before save, and those clones are returned back if changes are needed after that action. * Entities: Updated model classes & relations for changes * Entities: Changed from *Data to a common "contents" system Added smart loading from builder instances which should hydrate with "contents()" loaded via join, while keeping the core model original. * Entities: Moved entity description/covers to own non-model classes Added back some interfaces. * Entities: Removed use of contents system for data access * Entities: Got most queries back to working order * Entities: Reverted back to data from contents, fixed various issues * Entities: Started addressing issues from tests * Entities: Addressed further tests/issues * Entities: Been through tests to get all passing in dev Fixed issues and needed test changes along the way. * Entities: Addressed phpstan errors * Entities: Reviewed TODO notes * Entities: Ensured book/shelf relation data removed on destroy * Entities: Been through API responses & adjusted field visibility * Entities: Added type index to massively improve query speed --- app/Access/Mfa/MfaSession.php | 1 - app/Console/Commands/UpdateUrlCommand.php | 6 +- .../Controllers/BookApiController.php | 7 +- .../Controllers/BookshelfApiController.php | 7 +- .../Controllers/BookshelfController.php | 1 + .../Controllers/ChapterApiController.php | 4 +- .../Controllers/ChapterController.php | 2 +- app/Entities/Controllers/PageController.php | 1 + app/Entities/EntityExistsRule.php | 20 +++ app/Entities/Models/Book.php | 91 ++++------ app/Entities/Models/BookChild.php | 5 +- app/Entities/Models/Bookshelf.php | 74 +++----- app/Entities/Models/Chapter.php | 29 ++-- app/Entities/Models/ContainerTrait.php | 26 +++ app/Entities/Models/CoverImageInterface.php | 18 -- app/Entities/Models/Entity.php | 111 +++++++++++- app/Entities/Models/EntityContainerData.php | 52 ++++++ app/Entities/Models/EntityPageData.php | 25 +++ app/Entities/Models/EntityQueryBuilder.php | 38 ++++ app/Entities/Models/EntityScope.php | 27 +++ app/Entities/Models/HasCoverInterface.php | 18 ++ .../Models/HasDefaultTemplateInterface.php | 10 ++ .../Models/HasDescriptionInterface.php | 10 ++ .../Models/HtmlDescriptionInterface.php | 17 -- app/Entities/Models/HtmlDescriptionTrait.php | 35 ---- app/Entities/Models/Page.php | 27 +-- app/Entities/Queries/BookQueries.php | 5 + app/Entities/Queries/BookshelfQueries.php | 5 + app/Entities/Queries/ChapterQueries.php | 10 +- app/Entities/Queries/EntityQueries.php | 11 ++ app/Entities/Queries/PageQueries.php | 21 ++- .../Queries/ProvidesEntityQueries.php | 7 + app/Entities/Repos/BaseRepo.php | 85 +++++---- app/Entities/Repos/BookRepo.php | 20 +-- app/Entities/Repos/BookshelfRepo.php | 7 +- app/Entities/Repos/ChapterRepo.php | 16 +- app/Entities/Repos/DeletionRepo.php | 8 +- app/Entities/Repos/PageRepo.php | 29 ++-- app/Entities/Repos/RevisionRepo.php | 4 +- app/Entities/Tools/BookContents.php | 9 +- app/Entities/Tools/Cloner.php | 6 +- app/Entities/Tools/EntityCover.php | 75 ++++++++ app/Entities/Tools/EntityDefaultTemplate.php | 60 +++++++ app/Entities/Tools/EntityHtmlDescription.php | 60 +++++++ app/Entities/Tools/HierarchyTransformer.php | 1 + app/Entities/Tools/MixedEntityListLoader.php | 10 +- app/Entities/Tools/PageContent.php | 2 +- app/Entities/Tools/TrashCan.php | 27 ++- app/Exports/ExportFormatter.php | 6 +- .../ZipExports/Models/ZipExportBook.php | 6 +- .../ZipExports/Models/ZipExportChapter.php | 2 +- app/Exports/ZipExports/ZipImportRunner.php | 8 +- app/Permissions/PermissionApplicator.php | 34 ++-- app/References/ReferenceFetcher.php | 4 +- app/References/ReferenceUpdater.php | 18 +- app/Sorting/BookSorter.php | 13 +- app/Sorting/SortRule.php | 2 +- app/Sorting/SortRuleController.php | 5 +- .../Controllers/AttachmentApiController.php | 5 +- .../Controllers/AttachmentController.php | 5 +- app/Uploads/ImageService.php | 2 +- app/Users/Controllers/UserApiController.php | 1 + app/Users/UserRepo.php | 13 +- .../Entities/Models/ChapterFactory.php | 3 +- .../factories/Entities/Models/PageFactory.php | 1 + ...625_refactor_joint_permissions_storage.php | 3 - ...025_09_15_132850_create_entities_table.php | 71 ++++++++ .../2025_09_15_134701_migrate_entity_data.php | 90 ++++++++++ ..._134751_update_entity_relation_columns.php | 114 ++++++++++++ ...25_09_15_134813_drop_old_entity_tables.php | 162 ++++++++++++++++++ database/seeders/DummyContentSeeder.php | 45 +++-- dev/api/responses/books-read.json | 2 +- dev/api/responses/pages-create.json | 2 +- dev/api/responses/pages-read.json | 2 +- dev/api/responses/recycle-bin-list.json | 2 +- resources/views/books/parts/form.blade.php | 2 +- .../views/books/parts/list-item.blade.php | 9 +- resources/views/books/show.blade.php | 6 +- resources/views/chapters/show.blade.php | 2 +- resources/views/entities/grid-item.blade.php | 2 +- resources/views/exports/book.blade.php | 2 +- resources/views/exports/chapter.blade.php | 2 +- .../exports/parts/chapter-item.blade.php | 2 +- .../form/description-html-input.blade.php | 2 +- resources/views/shelves/parts/form.blade.php | 18 +- .../views/shelves/parts/list-item.blade.php | 2 +- resources/views/shelves/show.blade.php | 6 +- tests/Api/ApiAuthTest.php | 2 +- tests/Api/BooksApiTest.php | 52 +++--- tests/Api/ChaptersApiTest.php | 12 +- tests/Api/ContentPermissionsApiTest.php | 2 +- tests/Api/PagesApiTest.php | 2 +- tests/Api/RecycleBinApiTest.php | 8 +- tests/Api/ShelvesApiTest.php | 18 +- tests/Auth/MfaConfigurationTest.php | 31 ++++ tests/Commands/UpdateUrlCommandTest.php | 4 +- tests/Entity/BookShelfTest.php | 11 +- tests/Entity/BookTest.php | 8 +- tests/Entity/ConvertTest.php | 10 +- tests/Entity/DefaultTemplateTest.php | 28 +-- tests/Entity/PageDraftTest.php | 4 +- tests/Entity/PageEditorTest.php | 2 +- tests/Entity/PageRevisionTest.php | 2 +- tests/Entity/PageTemplateTest.php | 4 +- tests/Entity/PageTest.php | 4 +- tests/Exports/MarkdownExportTest.php | 2 +- tests/Exports/ZipExportTest.php | 6 +- tests/Exports/ZipImportRunnerTest.php | 2 +- tests/Helpers/EntityProvider.php | 2 +- tests/Meta/OpenGraphTest.php | 4 +- tests/Permissions/EntityOwnerChangeTest.php | 8 +- tests/Permissions/EntityPermissionsTest.php | 6 +- tests/PublicActionTest.php | 2 +- tests/References/ReferencesTest.php | 2 +- tests/Settings/RecycleBinTest.php | 66 +++++-- tests/Sorting/BookSortTest.php | 18 +- tests/Sorting/MoveTest.php | 2 +- tests/Sorting/SortRuleTest.php | 8 +- tests/TestCase.php | 41 ++++- tests/User/UserManagementTest.php | 4 +- 120 files changed, 1598 insertions(+), 595 deletions(-) create mode 100644 app/Entities/EntityExistsRule.php create mode 100644 app/Entities/Models/ContainerTrait.php delete mode 100644 app/Entities/Models/CoverImageInterface.php create mode 100644 app/Entities/Models/EntityContainerData.php create mode 100644 app/Entities/Models/EntityPageData.php create mode 100644 app/Entities/Models/EntityQueryBuilder.php create mode 100644 app/Entities/Models/EntityScope.php create mode 100644 app/Entities/Models/HasCoverInterface.php create mode 100644 app/Entities/Models/HasDefaultTemplateInterface.php create mode 100644 app/Entities/Models/HasDescriptionInterface.php delete mode 100644 app/Entities/Models/HtmlDescriptionInterface.php delete mode 100644 app/Entities/Models/HtmlDescriptionTrait.php create mode 100644 app/Entities/Tools/EntityCover.php create mode 100644 app/Entities/Tools/EntityDefaultTemplate.php create mode 100644 app/Entities/Tools/EntityHtmlDescription.php create mode 100644 database/migrations/2025_09_15_132850_create_entities_table.php create mode 100644 database/migrations/2025_09_15_134701_migrate_entity_data.php create mode 100644 database/migrations/2025_09_15_134751_update_entity_relation_columns.php create mode 100644 database/migrations/2025_09_15_134813_drop_old_entity_tables.php diff --git a/app/Access/Mfa/MfaSession.php b/app/Access/Mfa/MfaSession.php index 09b9e53b8..b12853412 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 71f0b92fe..fd86e0706 100644 --- a/app/Console/Commands/UpdateUrlCommand.php +++ b/app/Console/Commands/UpdateUrlCommand.php @@ -45,10 +45,8 @@ class UpdateUrlCommand extends Command $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 5baea163f..807f5a69c 100644 --- a/app/Entities/Controllers/BookApiController.php +++ b/app/Entities/Controllers/BookApiController.php @@ -122,9 +122,10 @@ class BookApiController extends ApiController $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 f4bd394a9..735742060 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -116,9 +116,10 @@ class BookshelfApiController extends ApiController $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 f47742ffa..8d7ffb8f9 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -116,6 +116,7 @@ class BookshelfController extends Controller ]); $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 80eab7bb8..6aa62f887 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -104,7 +104,7 @@ class ChapterApiController extends ApiController $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 @@ class ChapterApiController extends ApiController $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 9335e0a70..a1af29de2 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -130,7 +130,7 @@ class ChapterController extends Controller $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 67ecb0bb3..603d015ef 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -120,6 +120,7 @@ class PageController extends Controller $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 000000000..da2105446 --- /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 5f54e0f6a..afd50797b 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 @@ use Illuminate\Support\Collection; * 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 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa 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 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa */ public function directPages(): HasMany { - return $this->pages()->where('chapter_id', '=', '0'); + return $this->pages()->whereNull('chapter_id'); } /** @@ -116,7 +67,8 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa */ public function chapters(): HasMany { - return $this->hasMany(Chapter::class); + return $this->hasMany(Chapter::class) + ->where('type', '=', 'chapter'); } /** @@ -137,4 +89,27 @@ class Book extends Entity implements CoverImageInterface, HtmlDescriptionInterfa 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 ad54fb926..4a2e52aed 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 @@ abstract class BookChild extends Entity /** * 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 9ae52abcb..42dcc8f8f 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 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn 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 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn /** * 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 @@ class Bookshelf extends Entity implements CoverImageInterface, HtmlDescriptionIn $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 d70a49e7a..2dd4cb77f 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 @@ class Chapter extends BookChild implements HtmlDescriptionInterface 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 @@ class Chapter extends BookChild implements HtmlDescriptionInterface ->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 000000000..9ef5ca8d4 --- /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 5f781fe02..000000000 --- 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 @@ abstract class Entity extends Model implements 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 @@ abstract class Entity extends Model implements /** * 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 @@ abstract class Entity extends Model implements */ 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 @@ abstract class Entity extends Model implements } /** - * Get this entities restrictions. + * Get this entities assigned permissions. */ public function permissions(): MorphMany { @@ -267,7 +337,7 @@ abstract class Entity extends Model implements } /** - * 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 @@ abstract class Entity extends Model implements { 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 000000000..21bace751 --- /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 000000000..a98b1a982 --- /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 000000000..deb10c5ec --- /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 000000000..a4e79e900 --- /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 000000000..f3af0da48 --- /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 499ef4d72..88c59bd1b 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 @@ use Illuminate\Database\Eloquent\Relations\HasOne; /** * 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 @@ class Page extends BookChild /** * 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 @@ class Page extends BookChild /** * 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 @@ class Page extends BookChild $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 2492f8131..a466f37bc 100644 --- a/app/Entities/Queries/BookQueries.php +++ b/app/Entities/Queries/BookQueries.php @@ -55,6 +55,11 @@ class BookQueries implements ProvidesEntityQueries ->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 842011a87..3fe0a2afc 100644 --- a/app/Entities/Queries/BookshelfQueries.php +++ b/app/Entities/Queries/BookshelfQueries.php @@ -60,6 +60,11 @@ class BookshelfQueries implements ProvidesEntityQueries 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 9bf0ff65b..9ddeb9b58 100644 --- a/app/Entities/Queries/ChapterQueries.php +++ b/app/Entities/Queries/ChapterQueries.php @@ -65,8 +65,14 @@ class ChapterQueries implements ProvidesEntityQueries ->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 0d2cd7acf..a7a037916 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -43,6 +43,17 @@ class EntityQueries 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 ee7b201bc..f4ecee2dc 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 @@ class PageQueries implements ProvidesEntityQueries ->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 @@ class PageQueries implements ProvidesEntityQueries ->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 79fc64b3a..674e96afa 100644 --- a/app/Entities/Queries/ProvidesEntityQueries.php +++ b/app/Entities/Queries/ProvidesEntityQueries.php @@ -35,4 +35,11 @@ interface ProvidesEntityQueries * @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 bfc01a58d..fd88625cd 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 @@ class BaseRepo /** * 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 @@ class BaseRepo $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 @@ class BaseRepo 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(); + $this->imageRepo->destroyImage($entity->coverInfo()->getImage()); + $entity->coverInfo()->setImage(null); $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; - $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 @@ class BaseRepo } } + /** + * 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 6d28d5d6a..b4244b9bb 100644 --- a/app/Entities/Repos/BookRepo.php +++ b/app/Entities/Repos/BookRepo.php @@ -30,19 +30,18 @@ class BookRepo 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 @@ class BookRepo */ 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 @@ class BookRepo * * @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 b870ec377..bb84b51fd 100644 --- a/app/Entities/Repos/BookshelfRepo.php +++ b/app/Entities/Repos/BookshelfRepo.php @@ -25,8 +25,7 @@ class BookshelfRepo 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 @@ class BookshelfRepo */ 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 @@ class BookshelfRepo * * @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 5d4b52978..d5feb30fd 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -33,8 +33,11 @@ class ChapterRepo $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 @@ class ChapterRepo */ 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 @@ class ChapterRepo * * @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 @@ class ChapterRepo } 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 e47192cc2..5b67e5e6b 100644 --- a/app/Entities/Repos/DeletionRepo.php +++ b/app/Entities/Repos/DeletionRepo.php @@ -9,11 +9,9 @@ use BookStack\Facades\Activity; 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 76377f9a6..f2e558210 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -37,7 +37,7 @@ class PageRepo /** * 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 @@ class PageRepo 'updated_by' => user()->id, 'draft' => true, 'editor' => PageEditorType::getSystemDefault()->value, + 'html' => '', + 'markdown' => '', + 'text' => '', ]); if ($parent instanceof Chapter) { @@ -55,17 +58,18 @@ class PageRepo $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 @@ class PageRepo $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 @@ class PageRepo 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 @@ class PageRepo /** * 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 @@ class PageRepo * * @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 @@ class PageRepo 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 d5549a0f1..2d1371b63 100644 --- a/app/Entities/Repos/RevisionRepo.php +++ b/app/Entities/Repos/RevisionRepo.php @@ -23,7 +23,7 @@ class RevisionRepo /** * 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 @@ class RevisionRepo /** * 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 7dd3f3e11..4bbab6265 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 @@ class BookContents { $maxPage = $this->book->pages() ->where('draft', '=', false) - ->where('chapter_id', '=', 0) + ->whereDoesntHave('chapter') ->max('priority'); $maxChapter = $this->book->chapters() @@ -80,11 +77,11 @@ class BookContents 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 05618fef4..ff42ae6e4 100644 --- a/app/Entities/Tools/Cloner.php +++ b/app/Entities/Tools/Cloner.php @@ -6,8 +6,8 @@ use BookStack\Activity\Models\Tag; 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 @@ class Cloner $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 000000000..1e8fce201 --- /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 000000000..d36c3f270 --- /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 000000000..335703c36 --- /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 b0d8880f4..fa45fcd11 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -34,6 +34,7 @@ class HierarchyTransformer /** @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 f9a940b98..0a0f224d8 100644 --- a/app/Entities/Tools/MixedEntityListLoader.php +++ b/app/Entities/Tools/MixedEntityListLoader.php @@ -19,7 +19,7 @@ class MixedEntityListLoader * 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 @@ class MixedEntityListLoader $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 @@ class MixedEntityListLoader * @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 4b1d77db7..c7a59216a 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -284,7 +284,7 @@ class PageContent /** * 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 d457d4f48..cc43b9096 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -6,9 +6,10 @@ use BookStack\Entities\EntityProvider; 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 @@ class TrashCan protected function destroyShelf(Bookshelf $shelf): int { $this->destroyCommonRelations($shelf); + $shelf->books()->detach(); $shelf->forceDelete(); return 1; @@ -167,6 +169,7 @@ class TrashCan } $this->destroyCommonRelations($book); + $book->shelves()->detach(); $book->forceDelete(); return $count + 1; @@ -209,15 +212,19 @@ class TrashCan $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 @@ class TrashCan $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 85ac7d2c9..ad489aba1 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -284,7 +284,7 @@ class ExportFormatter 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 @@ class ExportFormatter { $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 @@ class ExportFormatter $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 6c51ea337..ab3fd90ec 100644 --- a/app/Exports/ZipExports/Models/ZipExportBook.php +++ b/app/Exports/ZipExports/Models/ZipExportBook.php @@ -55,10 +55,10 @@ final class ZipExportBook extends ZipExportModel $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 260191a3e..906ce3d81 100644 --- a/app/Exports/ZipExports/Models/ZipExportChapter.php +++ b/app/Exports/ZipExports/Models/ZipExportChapter.php @@ -40,7 +40,7 @@ final class ZipExportChapter extends ZipExportModel $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 eafb527e8..748acf43f 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -135,8 +135,8 @@ class ZipImportRunner '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 @@ class ZipImportRunner $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 23fdcfda9..c44a18a4d 100644 --- a/app/Permissions/PermissionApplicator.php +++ b/app/Permissions/PermissionApplicator.php @@ -40,10 +40,6 @@ class PermissionApplicator $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 @@ class PermissionApplicator /** @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 @@ class PermissionApplicator { $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 1c9664f45..8588c6e2c 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -20,10 +20,10 @@ class ReferenceFetcher * 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 5f1d711e9..06b3389ba 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 @@ class ReferenceUpdater 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 @@ class ReferenceUpdater $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 @@ class ReferenceUpdater $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 1152101d2..99e307e35 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -33,22 +33,22 @@ class BookSorter */ 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 @@ class BookSorter // 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 45e5514fd..bf53365a2 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -50,7 +50,7 @@ class SortRule extends Model implements Loggable 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 bb5540a2a..65e1cba09 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 @@ class SortRuleController extends Controller 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 b47d6ff8d..ea3c4a962 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 @@ class AttachmentApiController extends ApiController 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 0886193e4..9c60fa415 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 @@ class AttachmentController extends Controller 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 @@ class AttachmentController extends Controller 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 458f0102d..402456e97 100644 --- a/app/Uploads/ImageService.php +++ b/app/Uploads/ImageService.php @@ -184,7 +184,7 @@ class ImageService /** @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 9134b3cc1..25753280f 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 d24f7002e..79d9e1b9e 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -6,12 +6,14 @@ use BookStack\Access\UserInviteException; 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 @@ class UserRepo 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 @@ class UserRepo /** * 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 1fc49933e..abf554ac8 100644 --- a/database/factories/Entities/Models/ChapterFactory.php +++ b/database/factories/Entities/Models/ChapterFactory.php @@ -26,7 +26,8 @@ class ChapterFactory extends Factory '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 811570095..47e5aa5db 100644 --- a/database/factories/Entities/Models/PageFactory.php +++ b/database/factories/Entities/Models/PageFactory.php @@ -31,6 +31,7 @@ class PageFactory extends Factory '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 0e25c1d60..a8f1843ed 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 @@ return new class extends Migration $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 000000000..6c890d719 --- /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 000000000..7b4beef06 --- /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 000000000..267cd49f5 --- /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 000000000..d6360f74d --- /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 a4383be50..5f787259a 100644 --- a/database/seeders/DummyContentSeeder.php +++ b/database/seeders/DummyContentSeeder.php @@ -12,7 +12,10 @@ use BookStack\Permissions\Models\RolePermission; 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 @@ class DummyContentSeeder extends Seeder $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 afeebade6..582744f99 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 11f5ab8c8..705dea6f6 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 2f3538964..22ff2de84 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 853070839..19167ec05 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 44d495c27..bb2cc936f 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 a3ff0971f..0852670fe 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 e28d95648..d510c8fd5 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 da914b32d..585bf8a3b 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 17c54e263..e64d0f42c 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 @@
- +
    @@ -54,7 +58,6 @@
-
- {{ 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 00cacfa70..5fc8a362b 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 633f959f3..9ee14f1bf 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 93e4b02e4..4e446bf5d 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 22ccfb482..e5bd77b67 100644 --- a/tests/Api/BooksApiTest.php +++ b/tests/Api/BooksApiTest.php @@ -47,8 +47,8 @@ class BooksApiTest extends TestCase [ '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 @@ class BooksApiTest extends TestCase ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('books', $expectedDetails); + $this->assertDatabaseHasEntityData('book', $expectedDetails); } public function test_book_name_needed_to_create() @@ -153,23 +153,23 @@ class BooksApiTest extends TestCase $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 @@ class BooksApiTest extends TestCase $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 @@ class BooksApiTest extends TestCase $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 @@ class BooksApiTest extends TestCase $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 @@ class BooksApiTest extends TestCase $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 @@ class BooksApiTest extends TestCase $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 5d7b05308..194140a56 100644 --- a/tests/Api/ChaptersApiTest.php +++ b/tests/Api/ChaptersApiTest.php @@ -91,7 +91,7 @@ class ChaptersApiTest extends TestCase '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 @@ class ChaptersApiTest extends TestCase '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 @@ class ChaptersApiTest extends TestCase $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 @@ class ChaptersApiTest extends TestCase { $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 @@ class ChaptersApiTest extends TestCase $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 a62abacc7..464d62683 100644 --- a/tests/Api/ContentPermissionsApiTest.php +++ b/tests/Api/ContentPermissionsApiTest.php @@ -280,7 +280,7 @@ class ContentPermissionsApiTest extends TestCase ]); $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 ced8954eb..8caf85aff 100644 --- a/tests/Api/PagesApiTest.php +++ b/tests/Api/PagesApiTest.php @@ -286,7 +286,7 @@ class PagesApiTest extends TestCase { $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 d174838c2..6ccc69c35 100644 --- a/tests/Api/RecycleBinApiTest.php +++ b/tests/Api/RecycleBinApiTest.php @@ -144,7 +144,7 @@ class RecycleBinApiTest extends TestCase $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -154,7 +154,7 @@ class RecycleBinApiTest extends TestCase 'restore_count' => 1, ]); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => null, ]); @@ -168,7 +168,7 @@ class RecycleBinApiTest extends TestCase $deletion = Deletion::query()->orderBy('id')->first(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'deleted_at' => $page->deleted_at, ]); @@ -178,6 +178,6 @@ class RecycleBinApiTest extends TestCase '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 ba13c0153..34ce0e4e5 100644 --- a/tests/Api/ShelvesApiTest.php +++ b/tests/Api/ShelvesApiTest.php @@ -48,8 +48,8 @@ class ShelvesApiTest extends TestCase [ '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 @@ class ShelvesApiTest extends TestCase ]); $resp->assertJson($expectedDetails); - $this->assertDatabaseHas('bookshelves', $expectedDetails); + $this->assertDatabaseHasEntityData('bookshelf', $expectedDetails); } public function test_shelf_name_needed_to_create() @@ -181,14 +181,14 @@ class ShelvesApiTest extends TestCase $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 @@ class ShelvesApiTest extends TestCase $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 @@ class ShelvesApiTest extends TestCase $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 @@ class ShelvesApiTest extends TestCase $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 @@ class ShelvesApiTest extends TestCase $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 1f359b41a..5184bf984 100644 --- a/tests/Auth/MfaConfigurationTest.php +++ b/tests/Auth/MfaConfigurationTest.php @@ -6,6 +6,7 @@ use BookStack\Access\Mfa\MfaValue; 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 @@ class MfaConfigurationTest extends TestCase $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 d336e05a2..356a026a8 100644 --- a/tests/Commands/UpdateUrlCommandTest.php +++ b/tests/Commands/UpdateUrlCommandTest.php @@ -19,7 +19,7 @@ class UpdateUrlCommandTest extends TestCase ->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 @@ class UpdateUrlCommandTest extends TestCase ->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 ad1d64e71..3ba2c3e99 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -91,7 +91,7 @@ class BookShelfTest extends TestCase ])); $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 @@ class BookShelfTest extends TestCase $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 @@ class BookShelfTest extends TestCase $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 51bf65d10..543c4e8bb 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -27,7 +27,7 @@ class BookTest extends TestCase $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 @@ class BookTest extends TestCase $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 d9b1ee466..8658e7699 100644 --- a/tests/Entity/ConvertTest.php +++ b/tests/Entity/ConvertTest.php @@ -35,8 +35,8 @@ class ConvertTest extends TestCase /** @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 @@ class ConvertTest extends TestCase // 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 @@ class ConvertTest extends TestCase $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 5369a5430..d3109c8a2 100644 --- a/tests/Entity/DefaultTemplateTest.php +++ b/tests/Entity/DefaultTemplateTest.php @@ -18,7 +18,7 @@ class DefaultTemplateTest extends TestCase ]; $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 @@ class DefaultTemplateTest extends TestCase ]; $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 @@ class DefaultTemplateTest extends TestCase $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 e99ba9b81..2623acd3f 100644 --- a/tests/Entity/PageDraftTest.php +++ b/tests/Entity/PageDraftTest.php @@ -204,7 +204,7 @@ class PageDraftTest extends TestCase ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $draft->id, 'draft' => true, 'name' => 'My updated draft', @@ -235,7 +235,7 @@ class PageDraftTest extends TestCase '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 ad753c966..d98b1f998 100644 --- a/tests/Entity/PageEditorTest.php +++ b/tests/Entity/PageEditorTest.php @@ -85,7 +85,7 @@ class PageEditorTest extends TestCase $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 9040254f7..3828bd06e 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -91,7 +91,7 @@ class PageRevisionTest extends TestCase $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 6a68c3ab1..9c867a534 100644 --- a/tests/Entity/PageTemplateTest.php +++ b/tests/Entity/PageTemplateTest.php @@ -35,7 +35,7 @@ class PageTemplateTest extends TestCase ]; $this->put($page->getUrl(), $pageUpdateData); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'id' => $page->id, 'template' => false, ]); @@ -43,7 +43,7 @@ class PageTemplateTest extends TestCase $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 d2c448bf4..699414462 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -74,7 +74,7 @@ class PageTest extends TestCase $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 @@ class PageTest extends TestCase ]); $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 3bccd4682..6bf585d59 100644 --- a/tests/Exports/MarkdownExportTest.php +++ b/tests/Exports/MarkdownExportTest.php @@ -84,7 +84,7 @@ class MarkdownExportTest extends TestCase $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 1310dcc24..692a5910f 100644 --- a/tests/Exports/ZipExportTest.php +++ b/tests/Exports/ZipExportTest.php @@ -227,7 +227,7 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 @@ class ZipExportTest extends TestCase $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 68ffb4231..2255e16c3 100644 --- a/tests/Exports/ZipImportRunnerTest.php +++ b/tests/Exports/ZipImportRunnerTest.php @@ -109,7 +109,7 @@ class ZipImportRunnerTest extends TestCase // 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 c794f9478..5163cef14 100644 --- a/tests/Helpers/EntityProvider.php +++ b/tests/Helpers/EntityProvider.php @@ -50,7 +50,7 @@ class EntityProvider 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 96e622da0..b35716359 100644 --- a/tests/Meta/OpenGraphTest.php +++ b/tests/Meta/OpenGraphTest.php @@ -49,7 +49,7 @@ class OpenGraphTest extends TestCase $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 @@ class OpenGraphTest extends TestCase $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 f00254922..fd3f27972 100644 --- a/tests/Permissions/EntityOwnerChangeTest.php +++ b/tests/Permissions/EntityOwnerChangeTest.php @@ -13,7 +13,7 @@ class EntityOwnerChangeTest extends TestCase $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 @@ class EntityOwnerChangeTest extends TestCase $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 @@ class EntityOwnerChangeTest extends TestCase $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 @@ class EntityOwnerChangeTest extends TestCase $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 ec2756b12..d399e0c34 100644 --- a/tests/Permissions/EntityPermissionsTest.php +++ b/tests/Permissions/EntityPermissionsTest.php @@ -629,10 +629,8 @@ class EntityPermissionsTest extends TestCase 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 76745aaac..e6fc7a6a3 100644 --- a/tests/PublicActionTest.php +++ b/tests/PublicActionTest.php @@ -104,7 +104,7 @@ class PublicActionTest extends TestCase $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 f8698d028..389f164a9 100644 --- a/tests/References/ReferencesTest.php +++ b/tests/References/ReferencesTest.php @@ -259,7 +259,7 @@ class ReferencesTest extends TestCase } $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 33284b4b3..c17cfed97 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 @@ class RecycleBinTest extends TestCase $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 @@ class RecycleBinTest extends TestCase 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 @@ class RecycleBinTest extends TestCase $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 @@ class RecycleBinTest extends TestCase ]); } + 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 @@ class RecycleBinTest extends TestCase $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 @@ class RecycleBinTest extends TestCase $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 @@ class RecycleBinTest extends TestCase $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 4737ec231..7f31f9c27 100644 --- a/tests/Sorting/BookSortTest.php +++ b/tests/Sorting/BookSortTest.php @@ -66,7 +66,7 @@ class BookSortTest extends TestCase $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 @@ class BookSortTest extends TestCase ]; $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 @@ class BookSortTest extends TestCase ]; $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 @@ class BookSortTest extends TestCase ]; $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 @@ class BookSortTest extends TestCase ]; $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 @@ class BookSortTest extends TestCase ]; $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 @@ class BookSortTest extends TestCase ]; $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 @@ class BookSortTest extends TestCase { $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 @@ class BookSortTest extends TestCase $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 606b23c68..5a341026b 100644 --- a/tests/Sorting/MoveTest.php +++ b/tests/Sorting/MoveTest.php @@ -20,7 +20,7 @@ class MoveTest extends TestCase $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 4a9d3a7b3..a6be9beef 100644 --- a/tests/Sorting/SortRuleTest.php +++ b/tests/Sorting/SortRuleTest.php @@ -142,7 +142,7 @@ class SortRuleTest extends TestCase $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 @@ class SortRuleTest extends TestCase ]); $resp->assertOk(); - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => '1111 page', 'priority' => $book->chapters()->count() + 1, @@ -217,7 +217,7 @@ class SortRuleTest extends TestCase } foreach ($namesToAdd as $index => $name) { - $this->assertDatabaseHas('pages', [ + $this->assertDatabaseHasEntityData('page', [ 'book_id' => $book->id, 'name' => $name, 'priority' => $index + 1, @@ -251,7 +251,7 @@ class SortRuleTest extends TestCase } 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 a8636fb15..239531748 100644 --- a/tests/TestCase.php +++ b/tests/TestCase.php @@ -6,7 +6,7 @@ use BookStack\Entities\Models\Entity; 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\Env; 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 @@ abstract class TestCase extends BaseTestCase $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 d92f13f0b..6d8b4d75a 100644 --- a/tests/User/UserManagementTest.php +++ b/tests/User/UserManagementTest.php @@ -165,8 +165,8 @@ class UserManagementTest extends TestCase $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, ]);