diff --git a/app/App/SluggableInterface.php b/app/App/SluggableInterface.php index 96af49cd323..dd544f5ed21 100644 --- a/app/App/SluggableInterface.php +++ b/app/App/SluggableInterface.php @@ -5,11 +5,9 @@ /** * Assigned to models that can have slugs. * Must have the below properties. + * + * @property string $slug */ interface SluggableInterface { - /** - * Regenerate the slug for this model. - */ - public function refreshSlug(): string; } diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index cbf7ffb7984..0610c2ef5ea 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -8,6 +8,7 @@ use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; +use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Repos\BookRepo; use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\Cloner; @@ -31,6 +32,7 @@ public function __construct( protected ShelfContext $shelfContext, protected BookRepo $bookRepo, protected BookQueries $queries, + protected EntityQueries $entityQueries, protected BookshelfQueries $shelfQueries, protected ReferenceFetcher $referenceFetcher, ) { @@ -127,7 +129,16 @@ public function store(Request $request, ?string $shelfSlug = null) */ public function show(Request $request, ActivityQueries $activities, string $slug) { - $book = $this->queries->findVisibleBySlugOrFail($slug); + try { + $book = $this->queries->findVisibleBySlugOrFail($slug); + } catch (NotFoundException $exception) { + $book = $this->entityQueries->findVisibleByOldSlugs('book', $slug); + if (is_null($book)) { + throw $exception; + } + return redirect($book->getUrl()); + } + $bookChildren = (new BookContents($book))->getTree(true); $bookParentShelves = $book->shelves()->scopes('visible')->get(); diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index 8d7ffb8f9b0..c4b861c9010 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -6,6 +6,7 @@ use BookStack\Activity\Models\View; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; +use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Repos\BookshelfRepo; use BookStack\Entities\Tools\ShelfContext; use BookStack\Exceptions\ImageUploadException; @@ -23,6 +24,7 @@ class BookshelfController extends Controller public function __construct( protected BookshelfRepo $shelfRepo, protected BookshelfQueries $queries, + protected EntityQueries $entityQueries, protected BookQueries $bookQueries, protected ShelfContext $shelfContext, protected ReferenceFetcher $referenceFetcher, @@ -105,7 +107,16 @@ public function store(Request $request) */ public function show(Request $request, ActivityQueries $activities, string $slug) { - $shelf = $this->queries->findVisibleBySlugOrFail($slug); + try { + $shelf = $this->queries->findVisibleBySlugOrFail($slug); + } catch (NotFoundException $exception) { + $shelf = $this->entityQueries->findVisibleByOldSlugs('bookshelf', $slug); + if (is_null($shelf)) { + throw $exception; + } + return redirect($shelf->getUrl()); + } + $this->checkOwnablePermission(Permission::BookshelfView, $shelf); $listOptions = SimpleListOptions::fromRequest($request, 'shelf_books')->withSortOptions([ diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index a1af29de269..878ee42b5ae 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -77,7 +77,15 @@ public function store(Request $request, string $bookSlug) */ public function show(string $bookSlug, string $chapterSlug) { - $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + try { + $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); + } catch (NotFoundException $exception) { + $chapter = $this->entityQueries->findVisibleByOldSlugs('chapter', $chapterSlug, $bookSlug); + if (is_null($chapter)) { + throw $exception; + } + return redirect($chapter->getUrl()); + } $sidebarTree = (new BookContents($chapter->book))->getTree(); $pages = $this->entityQueries->pages->visibleForChapterList($chapter->id)->get(); diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 603d015ef4a..a648bc29882 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -17,7 +17,6 @@ use BookStack\Entities\Tools\PageEditActivity; use BookStack\Entities\Tools\PageEditorData; use BookStack\Exceptions\NotFoundException; -use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\PermissionsException; use BookStack\Http\Controller; use BookStack\Permissions\Permission; @@ -140,9 +139,7 @@ public function show(string $bookSlug, string $pageSlug) try { $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); } catch (NotFoundException $e) { - $revision = $this->entityQueries->revisions->findLatestVersionBySlugs($bookSlug, $pageSlug); - $page = $revision->page ?? null; - + $page = $this->entityQueries->findVisibleByOldSlugs('page', $pageSlug, $bookSlug); if (is_null($page)) { throw $e; } diff --git a/app/Entities/Models/BookChild.php b/app/Entities/Models/BookChild.php index 4a2e52aedd5..9a8493c3a0a 100644 --- a/app/Entities/Models/BookChild.php +++ b/app/Entities/Models/BookChild.php @@ -2,7 +2,6 @@ namespace BookStack\Entities\Models; -use BookStack\References\ReferenceUpdater; use Illuminate\Database\Eloquent\Relations\BelongsTo; /** @@ -17,34 +16,10 @@ abstract class BookChild extends Entity { /** * Get the book this page sits in. + * @return BelongsTo */ public function book(): BelongsTo { return $this->belongsTo(Book::class)->withTrashed(); } - - /** - * Change the book that this entity belongs to. - */ - public function changeBook(int $newBookId): self - { - $oldUrl = $this->getUrl(); - $this->book_id = $newBookId; - $this->unsetRelation('book'); - $this->refreshSlug(); - $this->save(); - - if ($oldUrl !== $this->getUrl()) { - app()->make(ReferenceUpdater::class)->updateEntityReferences($this, $oldUrl); - } - - // Update all child pages if a chapter - if ($this instanceof Chapter) { - foreach ($this->pages()->withTrashed()->get() as $page) { - $page->changeBook($newBookId); - } - } - - return $this; - } } diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 641fe29d546..47e13462691 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -13,7 +13,6 @@ use BookStack\Activity\Models\Watch; use BookStack\App\Model; use BookStack\App\SluggableInterface; -use BookStack\Entities\Tools\SlugGenerator; use BookStack\Permissions\JointPermissionBuilder; use BookStack\Permissions\Models\EntityPermission; use BookStack\Permissions\Models\JointPermission; @@ -405,16 +404,6 @@ public function indexForSearch(): void app()->make(SearchIndex::class)->indexEntity(clone $this); } - /** - * {@inheritdoc} - */ - public function refreshSlug(): string - { - $this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name); - - return $this->slug; - } - /** * {@inheritdoc} */ @@ -441,6 +430,14 @@ public function watches(): MorphMany return $this->morphMany(Watch::class, 'watchable'); } + /** + * Get the related slug history for this entity. + */ + public function slugHistory(): MorphMany + { + return $this->morphMany(SlugHistory::class, 'sluggable'); + } + /** * {@inheritdoc} */ diff --git a/app/Entities/Models/SlugHistory.php b/app/Entities/Models/SlugHistory.php new file mode 100644 index 00000000000..4041cedd959 --- /dev/null +++ b/app/Entities/Models/SlugHistory.php @@ -0,0 +1,28 @@ +hasMany(JointPermission::class, 'entity_id', 'sluggable_id') + ->whereColumn('joint_permissions.entity_type', '=', 'slug_history.sluggable_type'); + } +} diff --git a/app/Entities/Queries/EntityQueries.php b/app/Entities/Queries/EntityQueries.php index 91c6a43633d..3ffa0adf3db 100644 --- a/app/Entities/Queries/EntityQueries.php +++ b/app/Entities/Queries/EntityQueries.php @@ -4,6 +4,7 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\EntityTable; +use BookStack\Entities\Tools\SlugHistory; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\Builder as QueryBuilder; use Illuminate\Database\Query\JoinClause; @@ -18,6 +19,7 @@ public function __construct( public ChapterQueries $chapters, public PageQueries $pages, public PageRevisionQueries $revisions, + protected SlugHistory $slugHistory, ) { } @@ -31,9 +33,30 @@ public function findVisibleByStringIdentifier(string $identifier): ?Entity $explodedId = explode(':', $identifier); $entityType = $explodedId[0]; $entityId = intval($explodedId[1]); - $queries = $this->getQueriesForType($entityType); - return $queries->findVisibleById($entityId); + return $this->findVisibleById($entityType, $entityId); + } + + /** + * Find an entity by its ID. + */ + public function findVisibleById(string $type, int $id): ?Entity + { + $queries = $this->getQueriesForType($type); + return $queries->findVisibleById($id); + } + + /** + * Find an entity by looking up old slugs in the slug history. + */ + public function findVisibleByOldSlugs(string $type, string $slug, string $parentSlug = ''): ?Entity + { + $id = $this->slugHistory->lookupEntityIdUsingSlugs($type, $slug, $parentSlug); + if ($id === null) { + return null; + } + + return $this->findVisibleById($type, $id); } /** diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index fd88625cd9a..717e9c9f82a 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -8,6 +8,8 @@ use BookStack\Entities\Models\HasDescriptionInterface; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\PageQueries; +use BookStack\Entities\Tools\SlugGenerator; +use BookStack\Entities\Tools\SlugHistory; use BookStack\Exceptions\ImageUploadException; use BookStack\References\ReferenceStore; use BookStack\References\ReferenceUpdater; @@ -25,6 +27,8 @@ public function __construct( protected ReferenceStore $referenceStore, protected PageQueries $pageQueries, protected BookSorter $bookSorter, + protected SlugGenerator $slugGenerator, + protected SlugHistory $slugHistory, ) { } @@ -43,7 +47,7 @@ public function create(Entity $entity, array $input): Entity 'updated_by' => user()->id, 'owned_by' => user()->id, ]); - $entity->refreshSlug(); + $this->refreshSlug($entity); if ($entity instanceof HasDescriptionInterface) { $this->updateDescription($entity, $input); @@ -78,7 +82,7 @@ public function update(Entity $entity, array $input): Entity $entity->updated_by = user()->id; if ($entity->isDirty('name') || empty($entity->slug)) { - $entity->refreshSlug(); + $this->refreshSlug($entity); } if ($entity instanceof HasDescriptionInterface) { @@ -155,4 +159,13 @@ protected function updateDescription(Entity $entity, array $input): void $entity->descriptionInfo()->set('', $input['description']); } } + + /** + * Refresh the slug for the given entity. + */ + public function refreshSlug(Entity $entity): void + { + $this->slugHistory->recordForEntity($entity); + $this->slugGenerator->regenerateForEntity($entity); + } } diff --git a/app/Entities/Repos/ChapterRepo.php b/app/Entities/Repos/ChapterRepo.php index d5feb30fdfd..a528eece092 100644 --- a/app/Entities/Repos/ChapterRepo.php +++ b/app/Entities/Repos/ChapterRepo.php @@ -7,6 +7,7 @@ use BookStack\Entities\Models\Chapter; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\BookContents; +use BookStack\Entities\Tools\ParentChanger; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\PermissionsException; @@ -21,6 +22,7 @@ public function __construct( protected BaseRepo $baseRepo, protected EntityQueries $entityQueries, protected TrashCan $trashCan, + protected ParentChanger $parentChanger, ) { } @@ -97,7 +99,7 @@ public function move(Chapter $chapter, string $parentIdentifier): Book } return (new DatabaseTransaction(function () use ($chapter, $parent) { - $chapter = $chapter->changeBook($parent->id); + $this->parentChanger->changeBook($chapter, $parent->id); $chapter->rebuildPermissions(); Activity::add(ActivityType::CHAPTER_MOVE, $chapter); diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index f2e558210ae..bc590785d93 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -12,6 +12,7 @@ use BookStack\Entities\Tools\BookContents; use BookStack\Entities\Tools\PageContent; use BookStack\Entities\Tools\PageEditorType; +use BookStack\Entities\Tools\ParentChanger; use BookStack\Entities\Tools\TrashCan; use BookStack\Exceptions\MoveOperationException; use BookStack\Exceptions\PermissionsException; @@ -31,6 +32,7 @@ public function __construct( protected ReferenceStore $referenceStore, protected ReferenceUpdater $referenceUpdater, protected TrashCan $trashCan, + protected ParentChanger $parentChanger, ) { } @@ -242,7 +244,7 @@ public function restoreRevision(Page $page, int $revisionId): Page } $page->updated_by = user()->id; - $page->refreshSlug(); + $this->baseRepo->refreshSlug($page); $page->save(); $page->indexForSearch(); $this->referenceStore->updateForEntity($page); @@ -284,7 +286,7 @@ public function move(Page $page, string $parentIdentifier): Entity return (new DatabaseTransaction(function () use ($page, $parent) { $page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null; $newBookId = ($parent instanceof Chapter) ? $parent->book->id : $parent->id; - $page = $page->changeBook($newBookId); + $this->parentChanger->changeBook($page, $newBookId); $page->rebuildPermissions(); Activity::add(ActivityType::PAGE_MOVE, $page); diff --git a/app/Entities/Tools/HierarchyTransformer.php b/app/Entities/Tools/HierarchyTransformer.php index fa45fcd116b..c58d29bd073 100644 --- a/app/Entities/Tools/HierarchyTransformer.php +++ b/app/Entities/Tools/HierarchyTransformer.php @@ -17,7 +17,8 @@ public function __construct( protected BookRepo $bookRepo, protected BookshelfRepo $shelfRepo, protected Cloner $cloner, - protected TrashCan $trashCan + protected TrashCan $trashCan, + protected ParentChanger $parentChanger, ) { } @@ -35,7 +36,7 @@ public function transformChapterToBook(Chapter $chapter): Book foreach ($chapter->pages as $page) { $page->chapter_id = 0; $page->save(); - $page->changeBook($book->id); + $this->parentChanger->changeBook($page, $book->id); } $this->trashCan->destroyEntity($chapter); diff --git a/app/Entities/Tools/ParentChanger.php b/app/Entities/Tools/ParentChanger.php new file mode 100644 index 00000000000..00ce42aae7e --- /dev/null +++ b/app/Entities/Tools/ParentChanger.php @@ -0,0 +1,40 @@ +getUrl(); + + $child->book_id = $newBookId; + $child->unsetRelation('book'); + $this->slugGenerator->regenerateForEntity($child); + $child->save(); + + if ($oldUrl !== $child->getUrl()) { + $this->referenceUpdater->updateEntityReferences($child, $oldUrl); + } + + // Update all child pages if a chapter + if ($child instanceof Chapter) { + foreach ($child->pages()->withTrashed()->get() as $page) { + $this->changeBook($page, $newBookId); + } + } + } +} diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php index fb912318750..6eec84a91c1 100644 --- a/app/Entities/Tools/SlugGenerator.php +++ b/app/Entities/Tools/SlugGenerator.php @@ -5,12 +5,14 @@ use BookStack\App\Model; use BookStack\App\SluggableInterface; use BookStack\Entities\Models\BookChild; +use BookStack\Entities\Models\Entity; +use BookStack\Users\Models\User; use Illuminate\Support\Str; class SlugGenerator { /** - * Generate a fresh slug for the given entity. + * Generate a fresh slug for the given item. * The slug will be generated so that it doesn't conflict within the same parent item. */ public function generate(SluggableInterface&Model $model, string $slugSource): string @@ -23,6 +25,26 @@ public function generate(SluggableInterface&Model $model, string $slugSource): s return $slug; } + /** + * Regenerate the slug for the given entity. + */ + public function regenerateForEntity(Entity $entity): string + { + $entity->slug = $this->generate($entity, $entity->name); + + return $entity->slug; + } + + /** + * Regenerate the slug for a user. + */ + public function regenerateForUser(User $user): string + { + $user->slug = $this->generate($user, $user->name); + + return $user->slug; + } + /** * Format a name as a URL slug. */ diff --git a/app/Entities/Tools/SlugHistory.php b/app/Entities/Tools/SlugHistory.php new file mode 100644 index 00000000000..2c8d88129b6 --- /dev/null +++ b/app/Entities/Tools/SlugHistory.php @@ -0,0 +1,97 @@ +id || !$entity->slug) { + return; + } + + $parentSlug = null; + if ($entity instanceof BookChild) { + $parentSlug = $entity->book()->first()?->slug; + } + + $latest = $this->getLatestEntryForEntity($entity); + if ($latest && $latest->slug === $entity->slug && $latest->parent_slug === $parentSlug) { + return; + } + + $info = [ + 'sluggable_type' => $entity->getMorphClass(), + 'sluggable_id' => $entity->id, + 'slug' => $entity->slug, + 'parent_slug' => $parentSlug, + ]; + + $entry = new SlugHistoryModel(); + $entry->forceFill($info); + $entry->save(); + + if ($entity instanceof Book) { + $this->recordForBookChildren($entity); + } + } + + protected function recordForBookChildren(Book $book): void + { + $query = EntityTable::query() + ->select(['type', 'id', 'slug', DB::raw("'{$book->slug}' as parent_slug"), DB::raw('now() as created_at'), DB::raw('now() as updated_at')]) + ->where('book_id', '=', $book->id) + ->whereNotNull('book_id'); + + SlugHistoryModel::query()->insertUsing( + ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'], + $query + ); + } + + /** + * Find the latest visible entry for an entity which uses the given slug(s) in the history. + */ + public function lookupEntityIdUsingSlugs(string $type, string $slug, string $parentSlug = ''): ?int + { + $query = SlugHistoryModel::query() + ->where('sluggable_type', '=', $type) + ->where('slug', '=', $slug); + + if ($parentSlug) { + $query->where('parent_slug', '=', $parentSlug); + } + + $query = $this->permissions->restrictEntityRelationQuery($query, 'slug_history', 'sluggable_id', 'sluggable_type'); + + /** @var SlugHistoryModel|null $result */ + $result = $query->orderBy('created_at', 'desc')->first(); + + return $result?->sluggable_id; + } + + protected function getLatestEntryForEntity(Entity $entity): SlugHistoryModel|null + { + return SlugHistoryModel::query() + ->where('sluggable_type', '=', $entity->getMorphClass()) + ->where('sluggable_id', '=', $entity->id) + ->orderBy('created_at', 'desc') + ->first(); + } +} diff --git a/app/Entities/Tools/TrashCan.php b/app/Entities/Tools/TrashCan.php index c298169c383..96645aebfa6 100644 --- a/app/Entities/Tools/TrashCan.php +++ b/app/Entities/Tools/TrashCan.php @@ -388,7 +388,7 @@ public function destroyEntity(Entity $entity): int /** * Update entity relations to remove or update outstanding connections. */ - protected function destroyCommonRelations(Entity $entity) + protected function destroyCommonRelations(Entity $entity): void { Activity::removeEntity($entity); $entity->views()->delete(); @@ -402,6 +402,7 @@ protected function destroyCommonRelations(Entity $entity) $entity->watches()->delete(); $entity->referencesTo()->delete(); $entity->referencesFrom()->delete(); + $entity->slugHistory()->delete(); if ($entity instanceof HasCoverInterface && $entity->coverInfo()->exists()) { $imageService = app()->make(ImageService::class); diff --git a/app/Sorting/BookSorter.php b/app/Sorting/BookSorter.php index 99e307e35cc..b4f93d47b11 100644 --- a/app/Sorting/BookSorter.php +++ b/app/Sorting/BookSorter.php @@ -8,12 +8,14 @@ use BookStack\Entities\Models\Entity; use BookStack\Entities\Models\Page; use BookStack\Entities\Queries\EntityQueries; +use BookStack\Entities\Tools\ParentChanger; use BookStack\Permissions\Permission; class BookSorter { public function __construct( protected EntityQueries $queries, + protected ParentChanger $parentChanger, ) { } @@ -155,7 +157,7 @@ protected function applySortUpdates(BookSortMapItem $sortMapItem, array $modelMa // Action the required changes if ($bookChanged) { - $model = $model->changeBook($newBook->id); + $this->parentChanger->changeBook($model, $newBook->id); } if ($model instanceof Page && $chapterChanged) { diff --git a/app/Users/Models/User.php b/app/Users/Models/User.php index 8bbf11695e2..50efdcdad60 100644 --- a/app/Users/Models/User.php +++ b/app/Users/Models/User.php @@ -11,7 +11,6 @@ use BookStack\Api\ApiToken; use BookStack\App\Model; use BookStack\App\SluggableInterface; -use BookStack\Entities\Tools\SlugGenerator; use BookStack\Permissions\Permission; use BookStack\Translation\LocaleDefinition; use BookStack\Translation\LocaleManager; @@ -358,14 +357,4 @@ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } - - /** - * {@inheritdoc} - */ - public function refreshSlug(): string - { - $this->slug = app()->make(SlugGenerator::class)->generate($this, $this->name); - - return $this->slug; - } } diff --git a/app/Users/UserRepo.php b/app/Users/UserRepo.php index 894d7c01f7d..2c0897ceffb 100644 --- a/app/Users/UserRepo.php +++ b/app/Users/UserRepo.php @@ -5,6 +5,7 @@ use BookStack\Access\UserInviteException; use BookStack\Access\UserInviteService; use BookStack\Activity\ActivityType; +use BookStack\Entities\Tools\SlugGenerator; use BookStack\Exceptions\NotifyException; use BookStack\Exceptions\UserUpdateException; use BookStack\Facades\Activity; @@ -21,7 +22,8 @@ class UserRepo { public function __construct( protected UserAvatars $userAvatar, - protected UserInviteService $inviteService + protected UserInviteService $inviteService, + protected SlugGenerator $slugGenerator, ) { } @@ -63,7 +65,7 @@ public function createWithoutActivity(array $data, bool $emailConfirmed = false) $user->email_confirmed = $emailConfirmed; $user->external_auth_id = $data['external_auth_id'] ?? ''; - $user->refreshSlug(); + $this->slugGenerator->regenerateForUser($user); $user->save(); if (!empty($data['language'])) { @@ -109,7 +111,7 @@ public function updateWithoutActivity(User $user, array $data, bool $manageUsers { if (!empty($data['name'])) { $user->name = $data['name']; - $user->refreshSlug(); + $this->slugGenerator->regenerateForUser($user); } if (!empty($data['email']) && $manageUsersAllowed) { diff --git a/database/factories/Entities/Models/SlugHistoryFactory.php b/database/factories/Entities/Models/SlugHistoryFactory.php new file mode 100644 index 00000000000..c8c57e09c5f --- /dev/null +++ b/database/factories/Entities/Models/SlugHistoryFactory.php @@ -0,0 +1,29 @@ + + */ +class SlugHistoryFactory extends Factory +{ + protected $model = \BookStack\Entities\Models\SlugHistory::class; + + /** + * Define the model's default state. + * + * @return array + */ + public function definition(): array + { + return [ + 'sluggable_id' => Book::factory(), + 'sluggable_type' => 'book', + 'slug' => $this->faker->slug(), + 'parent_slug' => null, + ]; + } +} diff --git a/database/migrations/2025_11_23_161812_create_slug_history_table.php b/database/migrations/2025_11_23_161812_create_slug_history_table.php new file mode 100644 index 00000000000..df30bf03006 --- /dev/null +++ b/database/migrations/2025_11_23_161812_create_slug_history_table.php @@ -0,0 +1,51 @@ +increments('id'); + $table->string('sluggable_type', 10)->index(); + $table->unsignedBigInteger('sluggable_id')->index(); + $table->string('slug')->index(); + $table->string('parent_slug')->nullable()->index(); + $table->timestamps(); + }); + + // Migrate in slugs from page revisions + $revisionSlugQuery = DB::table('page_revisions') + ->select([ + DB::raw('\'page\' as sluggable_type'), + 'page_id as sluggable_id', + 'slug', + 'book_slug as parent_slug', + DB::raw('min(created_at) as created_at'), + DB::raw('min(updated_at) as updated_at'), + ]) + ->where('type', '=', 'version') + ->groupBy(['sluggable_id', 'slug', 'parent_slug']); + + DB::table('slug_history')->insertUsing( + ['sluggable_type', 'sluggable_id', 'slug', 'parent_slug', 'created_at', 'updated_at'], + $revisionSlugQuery, + ); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('slug_history'); + } +}; diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 543c4e8bbdb..545d6b30578 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -238,30 +238,6 @@ public function test_books_view_shows_view_toggle_option() $this->assertEquals('list', setting()->getUser($editor, 'books_view_type')); } - public function test_slug_multi_byte_url_safe() - { - $book = $this->entities->newBook([ - 'name' => 'информация', - ]); - - $this->assertEquals('informaciia', $book->slug); - - $book = $this->entities->newBook([ - 'name' => '¿Qué?', - ]); - - $this->assertEquals('que', $book->slug); - } - - public function test_slug_format() - { - $book = $this->entities->newBook([ - 'name' => 'PartA / PartB / PartC', - ]); - - $this->assertEquals('parta-partb-partc', $book->slug); - } - public function test_description_limited_to_specific_html() { $book = $this->entities->book(); diff --git a/tests/Entity/PageTest.php b/tests/Entity/PageTest.php index b9e1294e0ec..afe15f4d4a3 100644 --- a/tests/Entity/PageTest.php +++ b/tests/Entity/PageTest.php @@ -269,28 +269,6 @@ public function test_page_can_be_copied_without_edit_permission() ]); } - public function test_old_page_slugs_redirect_to_new_pages() - { - $page = $this->entities->page(); - - // Need to save twice since revisions are not generated in seeder. - $this->asAdmin()->put($page->getUrl(), [ - 'name' => 'super test', - 'html' => '

', - ]); - - $page->refresh(); - $pageUrl = $page->getUrl(); - - $this->put($pageUrl, [ - 'name' => 'super test page', - 'html' => '

', - ]); - - $this->get($pageUrl) - ->assertRedirect("/books/{$page->book->slug}/page/super-test-page"); - } - public function test_page_within_chapter_deletion_returns_to_chapter() { $chapter = $this->entities->chapter(); diff --git a/tests/Entity/SlugTest.php b/tests/Entity/SlugTest.php new file mode 100644 index 00000000000..51cf34e5dfe --- /dev/null +++ b/tests/Entity/SlugTest.php @@ -0,0 +1,212 @@ +entities->newBook([ + 'name' => 'информация', + ]); + + $this->assertEquals('informaciia', $book->slug); + + $book = $this->entities->newBook([ + 'name' => '¿Qué?', + ]); + + $this->assertEquals('que', $book->slug); + } + + public function test_slug_format() + { + $book = $this->entities->newBook([ + 'name' => 'PartA / PartB / PartC', + ]); + + $this->assertEquals('parta-partb-partc', $book->slug); + } + + public function test_old_page_slugs_redirect_to_new_pages() + { + $page = $this->entities->page(); + $pageUrl = $page->getUrl(); + + $this->asAdmin()->put($pageUrl, [ + 'name' => 'super test page', + 'html' => '

', + ]); + + $this->get($pageUrl) + ->assertRedirect("/books/{$page->book->slug}/page/super-test-page"); + } + + public function test_old_shelf_slugs_redirect_to_new_shelf() + { + $shelf = $this->entities->shelf(); + $shelfUrl = $shelf->getUrl(); + + $this->asAdmin()->put($shelf->getUrl(), [ + 'name' => 'super test shelf', + ]); + + $this->get($shelfUrl) + ->assertRedirect("/shelves/super-test-shelf"); + } + + public function test_old_book_slugs_redirect_to_new_book() + { + $book = $this->entities->book(); + $bookUrl = $book->getUrl(); + + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'super test book', + ]); + + $this->get($bookUrl) + ->assertRedirect("/books/super-test-book"); + } + + public function test_old_chapter_slugs_redirect_to_new_chapter() + { + $chapter = $this->entities->chapter(); + $chapterUrl = $chapter->getUrl(); + + $this->asAdmin()->put($chapter->getUrl(), [ + 'name' => 'super test chapter', + ]); + + $this->get($chapterUrl) + ->assertRedirect("/books/{$chapter->book->slug}/chapter/super-test-chapter"); + } + + public function test_old_book_slugs_in_page_urls_redirect_to_current_page_url() + { + $page = $this->entities->page(); + $book = $page->book; + $pageUrl = $page->getUrl(); + + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'super test book', + ]); + + $this->get($pageUrl) + ->assertRedirect("/books/super-test-book/page/{$page->slug}"); + } + + public function test_old_book_slugs_in_chapter_urls_redirect_to_current_chapter_url() + { + $chapter = $this->entities->chapter(); + $book = $chapter->book; + $chapterUrl = $chapter->getUrl(); + + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'super test book', + ]); + + $this->get($chapterUrl) + ->assertRedirect("/books/super-test-book/chapter/{$chapter->slug}"); + } + + public function test_slug_lookup_controlled_by_permissions() + { + $editor = $this->users->editor(); + $pageA = $this->entities->page(); + $pageB = $this->entities->page(); + + SlugHistory::factory()->create(['sluggable_id' => $pageA->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()]); + SlugHistory::factory()->create(['sluggable_id' => $pageB->id, 'sluggable_type' => 'page', 'slug' => 'monkey', 'parent_slug' => 'animals', 'created_at' => now()->subDay()]); + + // Defaults to latest where visible + $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl()); + + $this->permissions->disableEntityInheritedPermissions($pageA); + + // Falls back to other entry where the latest is not visible + $this->actingAs($editor)->get("/books/animals/page/monkey")->assertRedirect($pageB->getUrl()); + + // Original still accessible where permissions allow + $this->asAdmin()->get("/books/animals/page/monkey")->assertRedirect($pageA->getUrl()); + } + + public function test_slugs_recorded_in_history_on_page_update() + { + $page = $this->entities->page(); + $this->asAdmin()->put($page->getUrl(), [ + 'name' => 'new slug', + 'html' => '

', + ]); + + $oldSlug = $page->slug; + $page->refresh(); + $this->assertNotEquals($oldSlug, $page->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $page->id, + 'sluggable_type' => 'page', + 'slug' => $oldSlug, + 'parent_slug' => $page->book->slug, + ]); + } + + public function test_slugs_recorded_in_history_on_chapter_update() + { + $chapter = $this->entities->chapter(); + $this->asAdmin()->put($chapter->getUrl(), [ + 'name' => 'new slug', + ]); + + $oldSlug = $chapter->slug; + $chapter->refresh(); + $this->assertNotEquals($oldSlug, $chapter->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $chapter->id, + 'sluggable_type' => 'chapter', + 'slug' => $oldSlug, + 'parent_slug' => $chapter->book->slug, + ]); + } + + public function test_slugs_recorded_in_history_on_book_update() + { + $book = $this->entities->book(); + $this->asAdmin()->put($book->getUrl(), [ + 'name' => 'new slug', + ]); + + $oldSlug = $book->slug; + $book->refresh(); + $this->assertNotEquals($oldSlug, $book->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $book->id, + 'sluggable_type' => 'book', + 'slug' => $oldSlug, + 'parent_slug' => null, + ]); + } + + public function test_slugs_recorded_in_history_on_shelf_update() + { + $shelf = $this->entities->shelf(); + $this->asAdmin()->put($shelf->getUrl(), [ + 'name' => 'new slug', + ]); + + $oldSlug = $shelf->slug; + $shelf->refresh(); + $this->assertNotEquals($oldSlug, $shelf->slug); + + $this->assertDatabaseHas('slug_history', [ + 'sluggable_id' => $shelf->id, + 'sluggable_type' => 'bookshelf', + 'slug' => $oldSlug, + 'parent_slug' => null, + ]); + } +}