From 5611c8c0da8927cd4831a9dd23a022a2d9bcfa18 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 30 May 2026 21:44:25 +0100 Subject: [PATCH 01/70] Maintenance: Started work on PHPStan Level 5 --- app/Access/ExternalBaseUserProvider.php | 2 +- app/Access/Guards/ExternalBaseSessionGuard.php | 8 ++++++-- app/Access/Guards/LdapSessionGuard.php | 4 ++++ app/Access/Oidc/OidcJwtWithClaims.php | 2 -- app/Activity/Models/Comment.php | 8 +++++++- app/Api/ApiDocsGenerator.php | 4 ++-- app/App/Providers/ThemeServiceProvider.php | 7 ++++++- app/App/helpers.php | 3 ++- app/Entities/Models/Entity.php | 2 +- app/Entities/Queries/QueryPopular.php | 2 ++ app/Entities/Queries/QueryRecentlyViewed.php | 2 ++ app/Entities/Queries/QueryTopFavourites.php | 2 ++ app/Http/Controller.php | 3 ++- app/Util/DatabaseTransaction.php | 3 ++- phpstan.neon.dist | 2 +- 15 files changed, 40 insertions(+), 14 deletions(-) diff --git a/app/Access/ExternalBaseUserProvider.php b/app/Access/ExternalBaseUserProvider.php index 2165fd4591e..2bc15cb0305 100644 --- a/app/Access/ExternalBaseUserProvider.php +++ b/app/Access/ExternalBaseUserProvider.php @@ -42,7 +42,7 @@ public function updateRememberToken(Authenticatable $user, $token) /** * Retrieve a user by the given credentials. */ - public function retrieveByCredentials(array $credentials): ?Authenticatable + public function retrieveByCredentials(array $credentials): ?User { return User::query() ->where('external_auth_id', $credentials['external_auth_id']) diff --git a/app/Access/Guards/ExternalBaseSessionGuard.php b/app/Access/Guards/ExternalBaseSessionGuard.php index 91239599ba9..b7bf174a1de 100644 --- a/app/Access/Guards/ExternalBaseSessionGuard.php +++ b/app/Access/Guards/ExternalBaseSessionGuard.php @@ -50,8 +50,12 @@ class ExternalBaseSessionGuard implements StatefulGuard /** * Create a new authentication guard. */ - public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService) - { + public function __construct( + string $name, + UserProvider $provider, + Session $session, + RegistrationService $registrationService + ) { $this->name = $name; $this->session = $session; $this->provider = $provider; diff --git a/app/Access/Guards/LdapSessionGuard.php b/app/Access/Guards/LdapSessionGuard.php index 9455d530dfe..f3628c897d8 100644 --- a/app/Access/Guards/LdapSessionGuard.php +++ b/app/Access/Guards/LdapSessionGuard.php @@ -83,6 +83,10 @@ public function attempt(array $credentials = [], $remember = false): bool } } + if (!($user instanceof User)) { + throw new LoginAttemptException('Could not find or create a user for LDAP login.'); + } + // Sync LDAP groups if required if ($this->ldapService->shouldSyncGroups()) { $this->ldapService->syncGroups($user, $username); diff --git a/app/Access/Oidc/OidcJwtWithClaims.php b/app/Access/Oidc/OidcJwtWithClaims.php index 9d7eeead1a9..9763ab15864 100644 --- a/app/Access/Oidc/OidcJwtWithClaims.php +++ b/app/Access/Oidc/OidcJwtWithClaims.php @@ -131,8 +131,6 @@ protected function validateTokenSignature(): void } }, $this->keys); - $parsedKeys = array_filter($parsedKeys); - $contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1]; /** @var OidcJwtSigningKey $parsedKey */ foreach ($parsedKeys as $parsedKey) { diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index 3faa76657b6..b49eb456488 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -3,6 +3,7 @@ namespace BookStack\Activity\Models; use BookStack\App\Model; +use BookStack\Entities\Models\Page; use BookStack\Permissions\Models\JointPermission; use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\HasCreatorAndUpdater; @@ -40,6 +41,9 @@ class Comment extends Model implements Loggable, OwnableInterface /** * Get the entity that this comment belongs to. + * It's only pages right now hence the typing below. + * Would need a deeper audit if that changes as many areas assume this is always a page. + * @return MorphTo */ public function entity(): MorphTo { @@ -55,7 +59,9 @@ public function entity(): MorphTo // Ultimately, we could just align the method name to 'commentable' but that would be a potential // breaking change and not really worthwhile in a patch due to the risk of creating extra problems. - return $this->morphTo(null, 'commentable_type', 'commentable_id'); + /** @var MorphTo $relation */ + $relation = $this->morphTo(null, 'commentable_type', 'commentable_id'); + return $relation; } /** diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index 53cb2890a7e..8d1be79c88f 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -138,8 +138,8 @@ protected function getValidationAsString($validation): string return $validation; } - if (is_object($validation) && method_exists($validation, '__toString')) { - return strval($validation); + if (is_object($validation) && $validation instanceof \Stringable) { + return $validation->__toString(); } if ($validation instanceof Password) { diff --git a/app/App/Providers/ThemeServiceProvider.php b/app/App/Providers/ThemeServiceProvider.php index 671e5e1df74..9b0f7001e81 100644 --- a/app/App/Providers/ThemeServiceProvider.php +++ b/app/App/Providers/ThemeServiceProvider.php @@ -7,6 +7,7 @@ use BookStack\Theming\ThemeViews; use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; +use Illuminate\View\FileViewFinder; class ThemeServiceProvider extends ServiceProvider { @@ -27,7 +28,11 @@ public function boot(): void // Boot up the theme system $themeService = $this->app->make(ThemeService::class); $viewFactory = $this->app->make('view'); - $themeViews = new ThemeViews($viewFactory->getFinder()); + $viewFinder = $viewFactory->getFinder(); + if (!($viewFinder instanceof FileViewFinder)) { + throw new \Exception('Only the file view finder is supported for the theme system'); + } + $themeViews = new ThemeViews($viewFinder); // Use a custom include so that we can insert theme views before/after includes. // This is done, even if no theme is active, so that view caching does not create problems diff --git a/app/App/helpers.php b/app/App/helpers.php index 8f210ecafd4..45a84da8db7 100644 --- a/app/App/helpers.php +++ b/app/App/helpers.php @@ -6,6 +6,7 @@ use BookStack\Permissions\Permission; use BookStack\Permissions\PermissionApplicator; use BookStack\Settings\SettingService; +use BookStack\Users\Models\OwnableInterface; use BookStack\Users\Models\User; /** @@ -40,7 +41,7 @@ function user(): User * Check if the current user has a permission. If an ownable element * is passed in the jointPermissions are checked against that particular item. */ -function userCan(string|Permission $permission, ?Model $ownable = null): bool +function userCan(string|Permission $permission, (Model&OwnableInterface)|null $ownable = null): bool { if (is_null($ownable)) { return user()->can($permission); diff --git a/app/Entities/Models/Entity.php b/app/Entities/Models/Entity.php index 27cfccaa836..296bd3a6621 100644 --- a/app/Entities/Models/Entity.php +++ b/app/Entities/Models/Entity.php @@ -46,7 +46,7 @@ * @property int|null $created_by * @property int|null $updated_by * @property int|null $owned_by - * @property Collection $tags + * @property Collection $tags * * @method static Entity|Builder visible() * @method static Builder withLastView() diff --git a/app/Entities/Queries/QueryPopular.php b/app/Entities/Queries/QueryPopular.php index 065ae82ef82..1782070de86 100644 --- a/app/Entities/Queries/QueryPopular.php +++ b/app/Entities/Queries/QueryPopular.php @@ -6,6 +6,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; use Illuminate\Support\Facades\DB; @@ -20,6 +21,7 @@ public function __construct( public function run(int $count, int $page, array $filterModels): Collection { + /** @var Builder $query */ $query = $this->permissions ->restrictEntityRelationQuery(View::query(), 'views', 'viewable_id', 'viewable_type') ->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count')) diff --git a/app/Entities/Queries/QueryRecentlyViewed.php b/app/Entities/Queries/QueryRecentlyViewed.php index f28b8f8652f..8ba1a57badd 100644 --- a/app/Entities/Queries/QueryRecentlyViewed.php +++ b/app/Entities/Queries/QueryRecentlyViewed.php @@ -5,6 +5,7 @@ use BookStack\Activity\Models\View; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Collection; class QueryRecentlyViewed @@ -22,6 +23,7 @@ public function run(int $count, int $page): Collection return collect(); } + /** @var Builder $query */ $query = $this->permissions->restrictEntityRelationQuery( View::query(), 'views', diff --git a/app/Entities/Queries/QueryTopFavourites.php b/app/Entities/Queries/QueryTopFavourites.php index 6340e35ef18..2719e94bc8b 100644 --- a/app/Entities/Queries/QueryTopFavourites.php +++ b/app/Entities/Queries/QueryTopFavourites.php @@ -5,6 +5,7 @@ use BookStack\Activity\Models\Favourite; use BookStack\Entities\Tools\MixedEntityListLoader; use BookStack\Permissions\PermissionApplicator; +use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Query\JoinClause; class QueryTopFavourites @@ -22,6 +23,7 @@ public function run(int $count, int $skip = 0) return collect(); } + /** @var Builder $query */ $query = $this->permissions ->restrictEntityRelationQuery(Favourite::query(), 'favourites', 'favouritable_id', 'favouritable_type') ->select('favourites.*') diff --git a/app/Http/Controller.php b/app/Http/Controller.php index 796505795e5..09c38ee6123 100644 --- a/app/Http/Controller.php +++ b/app/Http/Controller.php @@ -7,6 +7,7 @@ use BookStack\Exceptions\NotifyException; use BookStack\Facades\Activity; use BookStack\Permissions\Permission; +use BookStack\Users\Models\OwnableInterface; use Illuminate\Foundation\Bus\DispatchesJobs; use Illuminate\Foundation\Validation\ValidatesRequests; use Illuminate\Http\JsonResponse; @@ -80,7 +81,7 @@ protected function preventGuestAccess(): void /** * Check the current user's permissions against an ownable item otherwise throw an exception. */ - protected function checkOwnablePermission(string|Permission $permission, Model $ownable, string $redirectLocation = '/'): void + protected function checkOwnablePermission(string|Permission $permission, Model&OwnableInterface $ownable, string $redirectLocation = '/'): void { if (!userCan($permission, $ownable)) { $this->showPermissionError($redirectLocation); diff --git a/app/Util/DatabaseTransaction.php b/app/Util/DatabaseTransaction.php index e36bd2ef310..2e57b6479a1 100644 --- a/app/Util/DatabaseTransaction.php +++ b/app/Util/DatabaseTransaction.php @@ -3,6 +3,7 @@ namespace BookStack\Util; use Closure; +use Illuminate\Database\Connection; use Illuminate\Support\Facades\DB; use Throwable; @@ -24,7 +25,7 @@ class DatabaseTransaction { /** - * @param (Closure(static): TReturn) $callback + * @param (Closure(Connection): TReturn) $callback */ public function __construct( protected Closure $callback diff --git a/phpstan.neon.dist b/phpstan.neon.dist index bab28ea0eb3..54403adfefc 100644 --- a/phpstan.neon.dist +++ b/phpstan.neon.dist @@ -7,7 +7,7 @@ parameters: - app # The level 8 is the highest level - level: 4 + level: 5 phpVersion: min: 80200 From 03c9ddd14fed16177ebe59adaef36fd6eb5bded4 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 31 May 2026 11:12:23 +0100 Subject: [PATCH 02/70] Maintenance: Further PHPStan level 5 type fixes --- app/Access/Oidc/OidcService.php | 1 + app/Activity/ActivityQueries.php | 3 ++ app/Activity/Tools/WebhookFormatter.php | 8 +++- .../Controllers/PageApiController.php | 20 ++++++---- app/Entities/Controllers/PageController.php | 5 ++- .../Controllers/PageRevisionController.php | 2 +- .../Tools/Markdown/CustomListItemRenderer.php | 6 ++- app/Entities/Tools/PageContent.php | 37 +++++++++++++------ app/Entities/Tools/PageIncludeParser.php | 4 +- app/Entities/Tools/SlugGenerator.php | 2 +- 10 files changed, 59 insertions(+), 29 deletions(-) diff --git a/app/Access/Oidc/OidcService.php b/app/Access/Oidc/OidcService.php index a84bd320513..19120e2d3c3 100644 --- a/app/Access/Oidc/OidcService.php +++ b/app/Access/Oidc/OidcService.php @@ -80,6 +80,7 @@ public function processAuthorizeResponse(?string $authorizationCode): User $provider->setPkceCode($pkceCode); // Try to exchange authorization code for access token + /** @var OidcAccessToken $accessToken */ $accessToken = $provider->getAccessToken('authorization_code', [ 'code' => $authorizationCode, ]); diff --git a/app/Activity/ActivityQueries.php b/app/Activity/ActivityQueries.php index d5b047937fb..a3383e80933 100644 --- a/app/Activity/ActivityQueries.php +++ b/app/Activity/ActivityQueries.php @@ -11,6 +11,7 @@ use BookStack\Permissions\PermissionApplicator; use BookStack\Users\Models\User; use Illuminate\Database\Eloquent\Builder; +use Illuminate\Database\Eloquent\Collection; use Illuminate\Database\Eloquent\Relations\MorphTo; use Illuminate\Database\Eloquent\Relations\Relation; @@ -27,6 +28,7 @@ public function __construct( */ public function latest(int $count = 20, int $page = 0): array { + /** @var Collection $activityList */ $activityList = $this->permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') @@ -83,6 +85,7 @@ public function entityActivity(Entity $entity, int $count = 20, int $page = 1): */ public function userActivity(User $user, int $count = 20, int $page = 0): array { + /** @var Collection $activityList */ $activityList = $this->permissions ->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type') ->orderBy('created_at', 'desc') diff --git a/app/Activity/Tools/WebhookFormatter.php b/app/Activity/Tools/WebhookFormatter.php index cb4e9cb0a9c..111efd5d498 100644 --- a/app/Activity/Tools/WebhookFormatter.php +++ b/app/Activity/Tools/WebhookFormatter.php @@ -73,13 +73,17 @@ public function addDefaultModelFormatters(): void // Load entity owner, creator, updater details $this->addModelFormatter( fn ($event, $model) => ($model instanceof Entity), - fn ($model) => $model->load(['ownedBy', 'createdBy', 'updatedBy']) + function ($model) { + $model->load(['ownedBy', 'createdBy', 'updatedBy']); + } ); // Load revision detail for page update and create events $this->addModelFormatter( fn ($event, $model) => ($model instanceof Page && ($event === ActivityType::PAGE_CREATE || $event === ActivityType::PAGE_UPDATE)), - fn ($model) => $model->load('currentRevision') + function ($model) { + $model->load('currentRevision'); + } ); } diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 38042e67058..ca8989f0764 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -1,5 +1,7 @@ queries->visibleForList() ->addSelect(['created_by', 'updated_by', 'revision_count', 'editor']); @@ -69,7 +73,7 @@ public function list() * Any images included via base64 data URIs will be extracted and saved as gallery * images against the page during upload. */ - public function create(Request $request) + public function create(Request $request): JsonResponse { $this->validate($request, $this->rules['create']); @@ -102,9 +106,9 @@ public function create(Request $request) * Comments for the page are provided in a tree-structure representing the hierarchy of top-level * comments and replies, for both archived and active comments. */ - public function read(string $id) + public function read(string $id): JsonResponse { - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $page = $page->forJsonDisplay(); $commentTree = (new CommentTree($page)); @@ -124,11 +128,11 @@ public function read(string $id) * Providing a 'book_id' or 'chapter_id' property will essentially move * the page into that parent element if you have permissions to do so. */ - public function update(Request $request, string $id) + public function update(Request $request, string $id): JsonResponse { $requestData = $this->validate($request, $this->rules['update']); - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::PageUpdate, $page); $parent = null; @@ -161,9 +165,9 @@ public function update(Request $request, string $id) * Delete a page. * This will typically send the page to the recycle bin. */ - public function delete(string $id) + public function delete(string $id): Response { - $page = $this->queries->findVisibleByIdOrFail($id); + $page = $this->queries->findVisibleByIdOrFail(intval($id)); $this->checkOwnablePermission(Permission::PageDelete, $page); $this->pageRepo->destroy($page); diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 82edfbc2763..dd655e7abb3 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -25,6 +25,7 @@ use BookStack\Util\HtmlContentFilterConfig; use Exception; use Illuminate\Database\Eloquent\Relations\BelongsTo; +use Illuminate\Database\Eloquent\Relations\Relation; use Illuminate\Http\Request; use Illuminate\Validation\ValidationException; use Throwable; @@ -358,8 +359,8 @@ public function destroyDraft(string $bookSlug, int $pageId) */ public function showRecentlyUpdated() { - $visibleBelongsScope = function (BelongsTo $query) { - $query->scopes('visible'); + $visibleBelongsScope = function (Relation $relation): void { + $relation->scopes('visible'); }; $pages = $this->queries->visibleForList() diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index cc6b79bfe45..801aeed3ed3 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -177,7 +177,7 @@ public function destroy(string $bookSlug, string $pageSlug, int $revId) */ public function destroyUserDraft(string $pageId) { - $page = $this->pageQueries->findVisibleByIdOrFail($pageId); + $page = $this->pageQueries->findVisibleByIdOrFail(intval($pageId)); $this->revisionRepo->deleteDraftsForCurrentUser($page); return response('', 200); diff --git a/app/Entities/Tools/Markdown/CustomListItemRenderer.php b/app/Entities/Tools/Markdown/CustomListItemRenderer.php index 0c506d7f9b5..14cdaa2eaa4 100644 --- a/app/Entities/Tools/Markdown/CustomListItemRenderer.php +++ b/app/Entities/Tools/Markdown/CustomListItemRenderer.php @@ -25,9 +25,13 @@ public function __construct() */ public function render(Node $node, ChildNodeRendererInterface $childRenderer) { + if (!($node instanceof ListItem)) { + return null; + } + $listItem = $this->baseRenderer->render($node, $childRenderer); - if ($node instanceof ListItem && $this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { + if ($this->startsTaskListItem($node) && $listItem instanceof HtmlElement) { $listItem->setAttribute('class', 'task-list-item'); } diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 9fb4596f5a0..c66dcbe4151 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -421,28 +421,41 @@ public function getNavigation(string $htmlContent): array */ protected function headerNodesToLevelList(DOMNodeList $nodeList): array { - $tree = collect($nodeList)->map(function (DOMElement $header) { + $minLevel = 6; + + $headerDetails = array_map(function (DOMNode $header) use (&$minLevel) { + if (!$header instanceof DOMElement) { + return null; + } + $text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue)); $text = mb_substr($text, 0, 100); + if (empty($text)) { + return null; + } + + $level = intval(str_replace('h', '', $header->nodeName)); + if ($level < $minLevel) { + $minLevel = $level; + } + return [ 'nodeName' => strtolower($header->nodeName), - 'level' => intval(str_replace('h', '', $header->nodeName)), + 'level' => $level, 'link' => '#' . $header->getAttribute('id'), 'text' => $text, ]; - })->filter(function ($header) { - return mb_strlen($header['text']) > 0; - }); + }, [...$nodeList]); - // Shift headers if only smaller headers have been used - $levelChange = ($tree->pluck('level')->min() - 1); - $tree = $tree->map(function ($header) use ($levelChange) { - $header['level'] -= ($levelChange); + $filtered = array_values(array_filter($headerDetails)); - return $header; - }); + // Shift headers if only smaller headers have been used + $levelChange = ($minLevel - 1); + foreach ($filtered as $index => $header) { + $filtered[$index]['level'] -= $levelChange; + } - return $tree->toArray(); + return $filtered; } } diff --git a/app/Entities/Tools/PageIncludeParser.php b/app/Entities/Tools/PageIncludeParser.php index af7ed4fc6a1..16917f41c8e 100644 --- a/app/Entities/Tools/PageIncludeParser.php +++ b/app/Entities/Tools/PageIncludeParser.php @@ -192,10 +192,10 @@ protected function splitNodeAtChildNode(DOMElement $parentNode, DOMNode $domNode /** * Get the parent paragraph of the given node, if existing. */ - protected function getParentParagraph(DOMNode $parent): ?DOMNode + protected function getParentParagraph(DOMNode $parent): ?DOMElement { do { - if (strtolower($parent->nodeName) === 'p') { + if (strtolower($parent->nodeName) === 'p' && $parent instanceof DOMElement) { return $parent; } diff --git a/app/Entities/Tools/SlugGenerator.php b/app/Entities/Tools/SlugGenerator.php index 6eec84a91c1..ac003b97a8f 100644 --- a/app/Entities/Tools/SlugGenerator.php +++ b/app/Entities/Tools/SlugGenerator.php @@ -52,7 +52,7 @@ protected function formatNameAsSlug(string $name): string { $slug = Str::slug($name); if ($slug === '') { - $slug = substr(md5(rand(1, 500)), 0, 5); + $slug = substr(md5(strval(rand(1, 500))), 0, 5); } return $slug; From fa3b7f77b9d430304ac5683a0db374adca2f85d3 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 31 May 2026 15:42:54 +0100 Subject: [PATCH 03/70] Maintenance: Addressed all phpstan level 5 issues --- app/Entities/Models/Book.php | 8 ++++---- app/Entities/Models/Page.php | 4 ++-- app/Exports/ImportRepo.php | 5 +++++ app/Exports/ZipExports/ZipImportRunner.php | 2 +- app/Permissions/JointPermissionBuilder.php | 3 +++ app/References/Reference.php | 2 +- app/References/ReferenceFetcher.php | 4 ++++ app/Search/SearchRunner.php | 4 ++++ app/Settings/SettingService.php | 6 +++--- app/Sorting/SortRule.php | 14 ++++++++++++++ app/Uploads/AttachmentService.php | 2 +- app/Users/Controllers/RoleApiController.php | 2 +- app/Users/Controllers/RoleController.php | 6 +++--- app/Users/Controllers/UserApiController.php | 8 ++++---- 14 files changed, 50 insertions(+), 20 deletions(-) diff --git a/app/Entities/Models/Book.php b/app/Entities/Models/Book.php index 10f04695a5e..abb081d712f 100644 --- a/app/Entities/Models/Book.php +++ b/app/Entities/Models/Book.php @@ -20,10 +20,10 @@ * @property ?int $image_id * @property ?int $default_template_id * @property ?int $sort_rule_id - * @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 \Illuminate\Database\Eloquent\Collection $chapters + * @property \Illuminate\Database\Eloquent\Collection $pages + * @property \Illuminate\Database\Eloquent\Collection $directPages + * @property \Illuminate\Database\Eloquent\Collection $shelves * @property ?SortRule $sortRule */ class Book extends Entity implements HasDescriptionInterface, HasCoverInterface, HasDefaultTemplateInterface diff --git a/app/Entities/Models/Page.php b/app/Entities/Models/Page.php index d3a392da6fa..c38f33af3ca 100644 --- a/app/Entities/Models/Page.php +++ b/app/Entities/Models/Page.php @@ -24,8 +24,8 @@ * @property int $revision_count * @property string $editor * @property Chapter|null $chapter - * @property Collection $attachments - * @property Collection $revisions + * @property Collection $attachments + * @property Collection $revisions * @property PageRevision $currentRevision */ class Page extends BookChild diff --git a/app/Exports/ImportRepo.php b/app/Exports/ImportRepo.php index 79db69fca8f..ed631409be3 100644 --- a/app/Exports/ImportRepo.php +++ b/app/Exports/ImportRepo.php @@ -3,6 +3,8 @@ namespace BookStack\Exports; use BookStack\Activity\ActivityType; +use BookStack\Entities\Models\Book; +use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Entity; use BookStack\Entities\Queries\EntityQueries; use BookStack\Exceptions\FileUploadException; @@ -119,6 +121,9 @@ public function runImport(Import $import, ?string $parent = null): Entity $parentModel = null; if ($import->type === 'page' || $import->type === 'chapter') { $parentModel = $parent ? $this->entityQueries->findVisibleByStringIdentifier($parent) : null; + if ($parentModel && !($parentModel instanceof Book || $parentModel instanceof Chapter)) { + throw new ZipImportException(['Selected parent is not a book or chapter']); + } } DB::beginTransaction(); diff --git a/app/Exports/ZipExports/ZipImportRunner.php b/app/Exports/ZipExports/ZipImportRunner.php index 9fa7dec3afe..37f6c0b8d33 100644 --- a/app/Exports/ZipExports/ZipImportRunner.php +++ b/app/Exports/ZipExports/ZipImportRunner.php @@ -48,7 +48,7 @@ public function __construct( * Returns the top-level entity item which was imported. * @throws ZipImportException */ - public function run(Import $import, ?Entity $parent = null): Entity + public function run(Import $import, Book|Chapter|null $parent = null): Entity { $zipPath = $this->getZipPath($import); $reader = new ZipExportReader($zipPath); diff --git a/app/Permissions/JointPermissionBuilder.php b/app/Permissions/JointPermissionBuilder.php index 94f18916d4a..f2159375df1 100644 --- a/app/Permissions/JointPermissionBuilder.php +++ b/app/Permissions/JointPermissionBuilder.php @@ -101,6 +101,7 @@ public function rebuildForRole(Role $role) /** * Get a query for fetching a book with its children. + * @return Builder */ protected function bookFetchQuery(): Builder { @@ -117,9 +118,11 @@ protected function bookFetchQuery(): Builder /** * Build joint permissions for the given book and role combinations. + * @param EloquentCollection $books */ protected function buildJointPermissionsForBooks(EloquentCollection $books, array $roles, bool $deleteOld = false): void { + /** @var EloquentCollection $entities */ $entities = clone $books; /** @var Book $book */ diff --git a/app/References/Reference.php b/app/References/Reference.php index df8a3a78932..602e19eab59 100644 --- a/app/References/Reference.php +++ b/app/References/Reference.php @@ -2,8 +2,8 @@ namespace BookStack\References; +use BookStack\App\Model; use BookStack\Permissions\Models\JointPermission; -use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Relations\HasMany; use Illuminate\Database\Eloquent\Relations\MorphTo; diff --git a/app/References/ReferenceFetcher.php b/app/References/ReferenceFetcher.php index 8588c6e2c8e..0c76b46982c 100644 --- a/app/References/ReferenceFetcher.php +++ b/app/References/ReferenceFetcher.php @@ -22,6 +22,7 @@ public function __construct( */ public function getReferencesToEntity(Entity $entity, bool $withContents = false): Collection { + /** @var Collection $references */ $references = $this->queryReferencesToEntity($entity)->get(); $this->mixedEntityListLoader->loadIntoRelations($references->all(), 'from', false, $withContents); @@ -37,6 +38,9 @@ public function getReferenceCountToEntity(Entity $entity): int return $this->queryReferencesToEntity($entity)->count(); } + /** + * @return Builder + */ protected function queryReferencesToEntity(Entity $entity): Builder { $baseQuery = Reference::query() diff --git a/app/Search/SearchRunner.php b/app/Search/SearchRunner.php index 3912541723f..c2f3b96c9e6 100644 --- a/app/Search/SearchRunner.php +++ b/app/Search/SearchRunner.php @@ -4,6 +4,7 @@ use BookStack\Entities\EntityProvider; use BookStack\Entities\Models\Entity; +use BookStack\Entities\Models\EntityTable; use BookStack\Entities\Queries\EntityQueries; use BookStack\Entities\Tools\EntityHydrator; use BookStack\Permissions\PermissionApplicator; @@ -90,6 +91,8 @@ public function searchChapter(int $chapterId, string $searchString): Collection /** * Get a page of result data from the given query based on the provided page parameters. + * @param EloquentBuilder $query + * @return Collection */ protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int $count): Collection { @@ -106,6 +109,7 @@ protected function getPageOfDataFromQuery(EloquentBuilder $query, int $page, int /** * Create a search query for an entity. * @param string[] $entityTypes + * @return EloquentBuilder */ protected function buildQuery(SearchOptions $searchOpts, array $entityTypes): EloquentBuilder { diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index e0b13618012..d35f7711795 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -243,9 +243,9 @@ public function putForCurrentUser(string $key, string $value): bool /** * Convert a setting key into a user-specific key. */ - protected function userKey(string $userId, string $key = ''): string + protected function userKey(int $userId, string $key = ''): string { - return 'user:' . $userId . ':' . $key; + return 'user:' . strval($userId) . ':' . $key; } /** @@ -267,7 +267,7 @@ public function remove(string $key): void /** * Delete settings for a given user id. */ - public function deleteUserSettings(string $userId): void + public function deleteUserSettings(int $userId): void { Setting::query() ->where('setting_key', 'like', $this->userKey($userId) . '%') diff --git a/app/Sorting/SortRule.php b/app/Sorting/SortRule.php index bf53365a201..ffe2d411534 100644 --- a/app/Sorting/SortRule.php +++ b/app/Sorting/SortRule.php @@ -38,21 +38,35 @@ public function setOperations(array $options): void $this->sequence = implode(',', $values); } + /** + * @inheritDoc + */ public function logDescriptor(): string { return "({$this->id}) {$this->name}"; } + /** + * Get the URL to where this rule can be managed. + */ public function getUrl(): string { return url("/settings/sorting/rules/{$this->id}"); } + /** + * Get the books which are specifically set to use this sort rule. + * @return HasMany + */ public function books(): HasMany { return $this->hasMany(Book::class, 'entity_container_data.sort_rule_id', 'id'); } + /** + * Get all the available sort rules, ordered by name, with the number of books using each. + * @return Collection + */ public static function allByName(): Collection { return static::query() diff --git a/app/Uploads/AttachmentService.php b/app/Uploads/AttachmentService.php index dabd537292f..3de84bdd0a8 100644 --- a/app/Uploads/AttachmentService.php +++ b/app/Uploads/AttachmentService.php @@ -102,7 +102,7 @@ public function saveNewFromLink(string $name, string $link, int $page_id): Attac /** * Updates the ordering for a listing of attached files. */ - public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId) + public function updateFileOrderWithinPage(array $attachmentOrder, int $pageId) { foreach ($attachmentOrder as $index => $attachmentId) { Attachment::query()->where('uploaded_to', '=', $pageId) diff --git a/app/Users/Controllers/RoleApiController.php b/app/Users/Controllers/RoleApiController.php index 93ecc549bb4..6f0c59651a5 100644 --- a/app/Users/Controllers/RoleApiController.php +++ b/app/Users/Controllers/RoleApiController.php @@ -87,7 +87,7 @@ public function create(Request $request) */ public function read(string $id) { - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $this->singleFormatter($role); return response()->json($role); diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php index b9f06dace84..56a6c7416ca 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -94,7 +94,7 @@ public function store(Request $request) public function edit(string $id) { $this->checkPermission(Permission::UserRolesManage); - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $this->setPageTitle(trans('settings.role_edit')); @@ -129,7 +129,7 @@ public function update(Request $request, string $id) public function showDelete(string $id) { $this->checkPermission(Permission::UserRolesManage); - $role = $this->permissionsRepo->getRoleById($id); + $role = $this->permissionsRepo->getRoleById(intval($id)); $roles = $this->permissionsRepo->getAllRolesExcept($role); $blankRole = $role->newInstance(['display_name' => trans('settings.role_delete_no_migration')]); $roles->prepend($blankRole); @@ -151,7 +151,7 @@ public function delete(Request $request, string $id) try { $migrateRoleId = intval($request->input('migrate_role_id') ?: "0"); - $this->permissionsRepo->deleteRole($id, $migrateRoleId); + $this->permissionsRepo->deleteRole(intval($id), $migrateRoleId); } catch (PermissionsException $e) { $this->showErrorNotification($e->getMessage()); diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index ebc17e262f3..07eefadc7b1 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -110,7 +110,7 @@ public function create(Request $request) */ public function read(string $id) { - $user = $this->userRepo->getById($id); + $user = $this->userRepo->getById(intval($id)); $this->singleFormatter($user); return response()->json($user); @@ -124,8 +124,8 @@ public function read(string $id) */ public function update(Request $request, string $id) { - $data = $this->validate($request, $this->rules($id)['update']); - $user = $this->userRepo->getById($id); + $data = $this->validate($request, $this->rules(intval($id))['update']); + $user = $this->userRepo->getById(intval($id)); $this->userRepo->update($user, $data, userCan(Permission::UsersManage)); $this->singleFormatter($user); @@ -140,7 +140,7 @@ public function update(Request $request, string $id) */ public function delete(Request $request, string $id) { - $user = $this->userRepo->getById($id); + $user = $this->userRepo->getById(intval($id)); $newOwnerId = $request->input('migrate_ownership_id', null); $this->userRepo->destroy($user, $newOwnerId); From 16e60b39398476e1963cf3922da7137fe65b004b Mon Sep 17 00:00:00 2001 From: Mesar Hameed Date: Thu, 16 Jul 2026 06:32:57 +0000 Subject: [PATCH 04/70] Views: Added aria-labels to custom checkbox components for screen readers The custom checkbox components (custom-checkbox.blade.php and toggle-switch.blade.php) use a as the visual/interactive element while hiding the native with display:none. The span had no accessible name, so screen readers would announce "checkbox" without any label context. Added aria-label to the role="checkbox" span in both templates. Also added optional parameter to custom-checkbox that falls back to when not provided, allowing callers to supply a more descriptive accessible name. --- resources/views/form/custom-checkbox.blade.php | 2 ++ resources/views/form/toggle-switch.blade.php | 1 + resources/views/settings/roles/parts/checkbox.blade.php | 3 ++- 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/resources/views/form/custom-checkbox.blade.php b/resources/views/form/custom-checkbox.blade.php index 7750c6f0007..736baf52119 100644 --- a/resources/views/form/custom-checkbox.blade.php +++ b/resources/views/form/custom-checkbox.blade.php @@ -3,11 +3,13 @@ $value $checked $label +$ariaLabel? (optional, falls back to $label) --}} \ No newline at end of file diff --git a/resources/views/form/toggle-switch.blade.php b/resources/views/form/toggle-switch.blade.php index 375eda3d7b5..32ce1092c25 100644 --- a/resources/views/form/toggle-switch.blade.php +++ b/resources/views/form/toggle-switch.blade.php @@ -3,6 +3,7 @@ @icon('check') {{ $label }} \ No newline at end of file diff --git a/resources/views/settings/roles/parts/checkbox.blade.php b/resources/views/settings/roles/parts/checkbox.blade.php index 44cdd22bdf6..5818888d723 100644 --- a/resources/views/settings/roles/parts/checkbox.blade.php +++ b/resources/views/settings/roles/parts/checkbox.blade.php @@ -3,5 +3,6 @@ 'name' => 'permissions[' . $permission . ']', 'value' => 'true', 'checked' => old('permissions'.$permission, false)|| (!old('display_name', false) && (isset($role) && $role->hasPermission($permission))), - 'label' => $label + 'label' => $label, + 'ariaLabel' => $ariaLabel ?? null ]) \ No newline at end of file From 9a242ca44968e0f572dc1b25023c3fc3d8f3aff6 Mon Sep 17 00:00:00 2001 From: Mesar Hameed Date: Thu, 16 Jul 2026 06:33:56 +0000 Subject: [PATCH 05/70] Views: Added translatable aria-labels for role permission checkboxes Role permission checkboxes (shelves, books, chapters, pages, revisions, images, attachments) used short labels like 'Own' and 'All' for their aria-labels. Screen readers would announce these without any context about what resource or action the permission applies to. Added 'role_permission_aria' translation key with the pattern ':action :resource: :value' and updated all permission row templates to use trans() with parameters. This gives translators full control over word order and separators while providing screen readers with descriptive labels like 'Create books: All'. --- lang/en/settings.php | 1 + .../parts/asset-permissions-row.blade.php | 26 ++++++++++++------- .../related-asset-permissions-row.blade.php | 17 +++++++----- .../parts/revisions-permissions-row.blade.php | 3 ++- 4 files changed, 31 insertions(+), 16 deletions(-) diff --git a/lang/en/settings.php b/lang/en/settings.php index 0e5ce84cf21..ac483f8fd10 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -206,6 +206,7 @@ 'role_asset_users_note' => 'These permissions will technically also provide visibility & searching of users in the system.', 'role_all' => 'All', 'role_own' => 'Own', + 'role_permission_aria' => ':action :resource: :value', 'role_controlled_by_asset' => 'Controlled by the asset they are uploaded to', 'role_controlled_by_page_delete' => 'Controlled by page delete permissions', 'role_save' => 'Save Role', diff --git a/resources/views/settings/roles/parts/asset-permissions-row.blade.php b/resources/views/settings/roles/parts/asset-permissions-row.blade.php index 8bff570cb85..200153b1b71 100644 --- a/resources/views/settings/roles/parts/asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/asset-permissions-row.blade.php @@ -6,27 +6,35 @@
{{ trans('common.create') }}
@if($permissionPrefix === 'page' || $permissionPrefix === 'chapter') - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-own', 'label' => trans('settings.role_own')]) + @php $createOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $createOwnAria])
@endif - @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => trans('settings.role_all')]) + @php $createAllAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $createAllAria])
{{ trans('common.view') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-own', 'label' => trans('settings.role_own')]) + @php $viewOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $viewOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) + @php $viewAllAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $viewAllAria])
{{ trans('common.edit') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @php $editOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $editOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @php $editAllAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $editAllAria])
{{ trans('common.delete') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')]) + @php $deleteOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $deleteOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')]) + @php $deleteAllAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $deleteAllAria])
- \ No newline at end of file + diff --git a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php index b9bf5427f83..ac8e7422e43 100644 --- a/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/related-asset-permissions-row.blade.php @@ -5,7 +5,8 @@
{{ trans('common.create') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '']) + @php $createAllAria = trans('settings.role_permission_aria', ['action' => trans('common.create'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-create-all', 'label' => '', 'ariaLabel' => $createAllAria]) @if($permissionPrefix === 'comment')2@endif
@@ -14,16 +15,20 @@
{{ trans('common.edit') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own')]) + @php $editOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $editOwnAria]) @if($permissionPrefix === 'comment')2@endif
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all')]) + @php $editAllAria = trans('settings.role_permission_aria', ['action' => trans('common.edit'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-update-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $editAllAria]) @if($permissionPrefix === 'comment')2@endif
{{ trans('common.delete') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own')]) + @php $deleteOwnAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_own')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-own', 'label' => trans('settings.role_own'), 'ariaLabel' => $deleteOwnAria])
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all')]) + @php $deleteAllAria = trans('settings.role_permission_aria', ['action' => trans('common.delete'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-delete-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $deleteAllAria])
- \ No newline at end of file + diff --git a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php index 326925ef93c..50b714de468 100644 --- a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -9,7 +9,8 @@
{{ trans('common.view') }}
- @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) + @php $viewAllAria = trans('settings.role_permission_aria', ['action' => trans('common.view'), 'resource' => strtolower($title), 'value' => trans('settings.role_all')]); @endphp + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all'), 'ariaLabel' => $viewAllAria])
{{ trans('common.edit') }}
From 26a3cb8b7e36a421c4491ad987e053d96ee84075 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 6 Aug 2026 15:32:17 +0100 Subject: [PATCH 06/70] Views: Started implementation of new sidebar back-end --- .../Providers/ViewTweaksServiceProvider.php | 2 + app/Entities/Controllers/BookController.php | 2 - app/View/SidebarSection.php | 16 ++++++ app/View/SidebarSectionInterface.php | 20 ++++++++ app/View/SidebarSectionManager.php | 51 +++++++++++++++++++ .../SidebarSections/BooksIndexRecents.php | 26 ++++++++++ resources/views/books/index.blade.php | 4 +- 7 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 app/View/SidebarSection.php create mode 100644 app/View/SidebarSectionInterface.php create mode 100644 app/View/SidebarSectionManager.php create mode 100644 app/View/SidebarSections/BooksIndexRecents.php diff --git a/app/App/Providers/ViewTweaksServiceProvider.php b/app/App/Providers/ViewTweaksServiceProvider.php index 6771e513fa6..d2d45abcc5d 100644 --- a/app/App/Providers/ViewTweaksServiceProvider.php +++ b/app/App/Providers/ViewTweaksServiceProvider.php @@ -4,6 +4,7 @@ use BookStack\Entities\BreadcrumbsViewComposer; use BookStack\Util\DateFormatter; +use BookStack\View\SidebarSectionManager; use Illuminate\Pagination\Paginator; use Illuminate\Support\Facades\Blade; use Illuminate\Support\Facades\View; @@ -33,6 +34,7 @@ public function boot(): void // View Globals View::share('dates', $this->app->make(DateFormatter::class)); + View::share('sidebar', $this->app->make(SidebarSectionManager::class)); // Custom blade view directives Blade::directive('icon', function ($expression) { diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 98470d91ce8..9fa64d2934c 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -53,7 +53,6 @@ public function index(Request $request) $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); - $recents = $this->isSignedIn() ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : false; $popular = $this->queries->popularForList()->take(4)->get(); $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); @@ -63,7 +62,6 @@ public function index(Request $request) return view('books.index', [ 'books' => $books, - 'recents' => $recents, 'popular' => $popular, 'new' => $new, 'view' => $view, diff --git a/app/View/SidebarSection.php b/app/View/SidebarSection.php new file mode 100644 index 00000000000..68e1aa29193 --- /dev/null +++ b/app/View/SidebarSection.php @@ -0,0 +1,16 @@ +view; + } +} diff --git a/app/View/SidebarSectionInterface.php b/app/View/SidebarSectionInterface.php new file mode 100644 index 00000000000..84032ab6174 --- /dev/null +++ b/app/View/SidebarSectionInterface.php @@ -0,0 +1,20 @@ + + */ + public function withData(array $viewData, Request $request): array; +} diff --git a/app/View/SidebarSectionManager.php b/app/View/SidebarSectionManager.php new file mode 100644 index 00000000000..82af3721ec4 --- /dev/null +++ b/app/View/SidebarSectionManager.php @@ -0,0 +1,51 @@ +[]>> + */ + protected array $sectionByLocation = [ + 'books-index' => [ + 'left' => [ + BooksIndexRecents::class, + ] + ], + ]; + + /** + * Register a sidebar section to be displayed at the given location and position. + * @param class-string $sectionClass + */ + public function register(string $location, string $position, string $sectionClass): void + { + if (!isset($this->sectionByLocation[$location])) { + $this->sectionByLocation[$location] = []; + } + + if (!isset($this->sectionByLocation[$location][$position])) { + $this->sectionByLocation[$location][$position] = []; + } + + $this->sectionByLocation[$location][$position][] = $sectionClass; + } + + /** + * Get all sidebar sections registered for a given location. + * + * @return SidebarSectionInterface[] + * @throws BindingResolutionException + */ + public function getSectionsForLocation(string $location, string $position): array + { + $sections = array_unique($this->sectionByLocation[$location][$position] ?? []); + return array_map(function (string $className) { + return app()->make($className); + }, $sections); + } +} diff --git a/app/View/SidebarSections/BooksIndexRecents.php b/app/View/SidebarSections/BooksIndexRecents.php new file mode 100644 index 00000000000..485e6d431fc --- /dev/null +++ b/app/View/SidebarSections/BooksIndexRecents.php @@ -0,0 +1,26 @@ +isGuest(); + $recents = $userSignedIn ? $this->queries->recentlyViewedForCurrentUser()->take(4)->get() : null; + return [ + 'recents' => $recents, + ]; + } +} diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 660c008dfb1..60152410cb9 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,7 +5,9 @@ @stop @section('left') - @include('books.parts.index-sidebar-section-recents', ['recents' => $recents]) + @foreach($sidebar->getSectionsForLocation('books-index', 'left') as $section) + @include($section->getView(), $section->withData(get_defined_vars(), request())) + @endforeach @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) @include('books.parts.index-sidebar-section-new', ['new' => $new]) @stop From dead0bfb5fd5b725230a5020973ddd4fa9560a95 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 7 Aug 2026 08:32:27 +0100 Subject: [PATCH 07/70] Views: Extracted books index/show sidebar sections --- app/Entities/Controllers/BookController.php | 15 +-------- app/View/SidebarSectionManager.php | 28 +++++++++++++++- .../SidebarSections/BooksIndexActions.php | 18 +++++++++++ app/View/SidebarSections/BooksIndexNew.php | 29 +++++++++++++++++ .../SidebarSections/BooksIndexPopular.php | 24 ++++++++++++++ app/View/SidebarSections/BooksShowActions.php | 24 ++++++++++++++ .../SidebarSections/BooksShowActivity.php | 28 ++++++++++++++++ app/View/SidebarSections/BooksShowDetails.php | 32 +++++++++++++++++++ .../SidebarSections/BooksShowSearchForm.php | 18 +++++++++++ app/View/SidebarSections/BooksShowShelves.php | 23 +++++++++++++ app/View/SidebarSections/BooksShowTags.php | 18 +++++++++++ resources/views/books/index.blade.php | 8 ++--- .../show-sidebar-section-details.blade.php | 2 +- .../show-sidebar-section-shelves.blade.php | 4 +-- resources/views/books/show.blade.php | 13 +++----- .../views/common/sidebar-sections.blade.php | 11 +++++++ 16 files changed, 262 insertions(+), 33 deletions(-) create mode 100644 app/View/SidebarSections/BooksIndexActions.php create mode 100644 app/View/SidebarSections/BooksIndexNew.php create mode 100644 app/View/SidebarSections/BooksIndexPopular.php create mode 100644 app/View/SidebarSections/BooksShowActions.php create mode 100644 app/View/SidebarSections/BooksShowActivity.php create mode 100644 app/View/SidebarSections/BooksShowDetails.php create mode 100644 app/View/SidebarSections/BooksShowSearchForm.php create mode 100644 app/View/SidebarSections/BooksShowShelves.php create mode 100644 app/View/SidebarSections/BooksShowTags.php create mode 100644 resources/views/common/sidebar-sections.blade.php diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index 9fa64d2934c..aa4f99daa6d 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -2,10 +2,8 @@ namespace BookStack\Entities\Controllers; -use BookStack\Activity\ActivityQueries; use BookStack\Activity\ActivityType; use BookStack\Activity\Models\View; -use BookStack\Activity\Tools\UserEntityWatchOptions; use BookStack\Entities\Queries\BookQueries; use BookStack\Entities\Queries\BookshelfQueries; use BookStack\Entities\Queries\EntityQueries; @@ -19,7 +17,6 @@ use BookStack\Facades\Activity; use BookStack\Http\Controller; use BookStack\Permissions\Permission; -use BookStack\References\ReferenceFetcher; use BookStack\Util\DatabaseTransaction; use BookStack\Util\SimpleListOptions; use Illuminate\Http\Request; @@ -34,7 +31,6 @@ public function __construct( protected BookQueries $queries, protected EntityQueries $entityQueries, protected BookshelfQueries $shelfQueries, - protected ReferenceFetcher $referenceFetcher, ) { } @@ -53,8 +49,6 @@ public function index(Request $request) $books = $this->queries->visibleForListWithCover() ->orderBy($listOptions->getSort(), $listOptions->getOrder()) ->paginate(setting()->getInteger('lists-page-count-books', 18, 1, 1000)); - $popular = $this->queries->popularForList()->take(4)->get(); - $new = $this->queries->visibleForList()->orderBy('created_at', 'desc')->take(4)->get(); $this->shelfContext->clearShelfContext(); @@ -62,8 +56,6 @@ public function index(Request $request) return view('books.index', [ 'books' => $books, - 'popular' => $popular, - 'new' => $new, 'view' => $view, 'listOptions' => $listOptions, ]); @@ -125,7 +117,7 @@ public function store(Request $request, ?string $shelfSlug = null) /** * Display the specified book. */ - public function show(Request $request, ActivityQueries $activities, string $slug) + public function show(Request $request, string $slug) { try { $book = $this->queries->findVisibleBySlugOrFail($slug); @@ -138,7 +130,6 @@ public function show(Request $request, ActivityQueries $activities, string $slug } $bookChildren = (new BookContents($book))->getTree(true); - $bookParentShelves = $book->shelves()->scopes('visible')->get(); View::incrementFor($book); if ($request->has('shelf')) { @@ -151,10 +142,6 @@ public function show(Request $request, ActivityQueries $activities, string $slug 'book' => $book, 'current' => $book, 'bookChildren' => $bookChildren, - 'bookParentShelves' => $bookParentShelves, - 'watchOptions' => new UserEntityWatchOptions(user(), $book), - 'activity' => $activities->entityActivity($book, 20, 1), - 'referenceCount' => $this->referenceFetcher->getReferenceCountToEntity($book), ]); } diff --git a/app/View/SidebarSectionManager.php b/app/View/SidebarSectionManager.php index 82af3721ec4..d83b7afb0ea 100644 --- a/app/View/SidebarSectionManager.php +++ b/app/View/SidebarSectionManager.php @@ -2,7 +2,16 @@ namespace BookStack\View; +use BookStack\View\SidebarSections\BooksIndexActions; +use BookStack\View\SidebarSections\BooksIndexNew; +use BookStack\View\SidebarSections\BooksIndexPopular; use BookStack\View\SidebarSections\BooksIndexRecents; +use BookStack\View\SidebarSections\BooksShowActions; +use BookStack\View\SidebarSections\BooksShowActivity; +use BookStack\View\SidebarSections\BooksShowDetails; +use BookStack\View\SidebarSections\BooksShowSearchForm; +use BookStack\View\SidebarSections\BooksShowShelves; +use BookStack\View\SidebarSections\BooksShowTags; use Illuminate\Contracts\Container\BindingResolutionException; class SidebarSectionManager @@ -14,7 +23,24 @@ class SidebarSectionManager 'books-index' => [ 'left' => [ BooksIndexRecents::class, - ] + BooksIndexPopular::class, + BooksIndexNew::class, + ], + 'right' => [ + BooksIndexActions::class, + ], + ], + 'books-show' => [ + 'left' => [ + BooksShowSearchForm::class, + BooksShowTags::class, + BooksShowShelves::class, + BooksShowActivity::class, + ], + 'right' => [ + BooksShowDetails::class, + BooksShowActions::class, + ], ], ]; diff --git a/app/View/SidebarSections/BooksIndexActions.php b/app/View/SidebarSections/BooksIndexActions.php new file mode 100644 index 00000000000..62f856a651b --- /dev/null +++ b/app/View/SidebarSections/BooksIndexActions.php @@ -0,0 +1,18 @@ + $viewData['view'], + ]; + } +} diff --git a/app/View/SidebarSections/BooksIndexNew.php b/app/View/SidebarSections/BooksIndexNew.php new file mode 100644 index 00000000000..33a57f9d996 --- /dev/null +++ b/app/View/SidebarSections/BooksIndexNew.php @@ -0,0 +1,29 @@ +queries->visibleForList() + ->orderBy('created_at', 'desc') + ->take(4) + ->get(); + + return [ + 'new' => $new, + ]; + } +} diff --git a/app/View/SidebarSections/BooksIndexPopular.php b/app/View/SidebarSections/BooksIndexPopular.php new file mode 100644 index 00000000000..9f5cc613f91 --- /dev/null +++ b/app/View/SidebarSections/BooksIndexPopular.php @@ -0,0 +1,24 @@ + $this->queries->popularForList()->take(4)->get(), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowActions.php b/app/View/SidebarSections/BooksShowActions.php new file mode 100644 index 00000000000..0f97e625c6a --- /dev/null +++ b/app/View/SidebarSections/BooksShowActions.php @@ -0,0 +1,24 @@ + $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowActivity.php b/app/View/SidebarSections/BooksShowActivity.php new file mode 100644 index 00000000000..d892c5ea730 --- /dev/null +++ b/app/View/SidebarSections/BooksShowActivity.php @@ -0,0 +1,28 @@ + $this->activityQueries->entityActivity($book, 20, 1), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowDetails.php b/app/View/SidebarSections/BooksShowDetails.php new file mode 100644 index 00000000000..bd91663b229 --- /dev/null +++ b/app/View/SidebarSections/BooksShowDetails.php @@ -0,0 +1,32 @@ +referenceFetcher->getReferenceCountToEntity($book); + + return [ + 'book' => $book, + 'watchOptions' => new UserEntityWatchOptions(user(), $book), + 'referenceCount' => $referenceCount, + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowSearchForm.php b/app/View/SidebarSections/BooksShowSearchForm.php new file mode 100644 index 00000000000..8fa24134b15 --- /dev/null +++ b/app/View/SidebarSections/BooksShowSearchForm.php @@ -0,0 +1,18 @@ + trans('entities.books_search_this'), + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowShelves.php b/app/View/SidebarSections/BooksShowShelves.php new file mode 100644 index 00000000000..28d2d8aa9f0 --- /dev/null +++ b/app/View/SidebarSections/BooksShowShelves.php @@ -0,0 +1,23 @@ +shelves()->scopes('visible')->get(); + + return [ + 'shelves' => $shelves, + ]; + } +} diff --git a/app/View/SidebarSections/BooksShowTags.php b/app/View/SidebarSections/BooksShowTags.php new file mode 100644 index 00000000000..351686c7f8a --- /dev/null +++ b/app/View/SidebarSections/BooksShowTags.php @@ -0,0 +1,18 @@ + $viewData['book'], + ]; + } +} diff --git a/resources/views/books/index.blade.php b/resources/views/books/index.blade.php index 60152410cb9..020cb096638 100644 --- a/resources/views/books/index.blade.php +++ b/resources/views/books/index.blade.php @@ -5,13 +5,9 @@ @stop @section('left') - @foreach($sidebar->getSectionsForLocation('books-index', 'left') as $section) - @include($section->getView(), $section->withData(get_defined_vars(), request())) - @endforeach - @include('books.parts.index-sidebar-section-popular', ['popular' => $popular]) - @include('books.parts.index-sidebar-section-new', ['new' => $new]) + @include('common.sidebar-sections', ['location' => 'books-index', 'position' => 'left']) @stop @section('right') - @include('books.parts.index-sidebar-section-actions', ['view' => $view]) + @include('common.sidebar-sections', ['location' => 'books-index', 'position' => 'right']) @stop diff --git a/resources/views/books/parts/show-sidebar-section-details.blade.php b/resources/views/books/parts/show-sidebar-section-details.blade.php index 709d0ffd9a1..2c3d6d141dd 100644 --- a/resources/views/books/parts/show-sidebar-section-details.blade.php +++ b/resources/views/books/parts/show-sidebar-section-details.blade.php @@ -1,7 +1,7 @@
{{ trans('common.details') }}