From c7d3775bb999bda0d134de900b28f2ebeb4cec67 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 00:05:10 +0100 Subject: [PATCH 01/16] Plain text: Created a new HTML to plain text converter To centralise logic to be more consistent, and to have smarter logic which avoids just following newline format from input, preventing smushing HTML elements (like list elements) next to eachother --- app/Activity/Models/Comment.php | 7 +++ .../Messages/CommentCreationNotification.php | 2 +- .../Messages/CommentMentionNotification.php | 2 +- app/Entities/Repos/BaseRepo.php | 4 +- app/Entities/Tools/PageContent.php | 5 +- app/Util/HtmlToPlainText.php | 47 ++++++++++++++ tests/Util/HtmlToPlainTextTest.php | 63 +++++++++++++++++++ 7 files changed, 125 insertions(+), 5 deletions(-) create mode 100644 app/Util/HtmlToPlainText.php create mode 100644 tests/Util/HtmlToPlainTextTest.php diff --git a/app/Activity/Models/Comment.php b/app/Activity/Models/Comment.php index ab7d917729c..3faa76657b6 100644 --- a/app/Activity/Models/Comment.php +++ b/app/Activity/Models/Comment.php @@ -9,6 +9,7 @@ use BookStack\Users\Models\OwnableInterface; use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilterConfig; +use BookStack\Util\HtmlToPlainText; use Illuminate\Database\Eloquent\Builder; use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Relations\BelongsTo; @@ -87,6 +88,12 @@ public function safeHtml(): string return $filter->filterString($this->html ?? ''); } + public function getPlainText(): string + { + $converter = new HtmlToPlainText(); + return $converter->convert($this->html ?? ''); + } + public function jointPermissions(): HasMany { return $this->hasMany(JointPermission::class, 'entity_id', 'commentable_id') diff --git a/app/Activity/Notifications/Messages/CommentCreationNotification.php b/app/Activity/Notifications/Messages/CommentCreationNotification.php index 30d0ffa2be4..d739f4aabbf 100644 --- a/app/Activity/Notifications/Messages/CommentCreationNotification.php +++ b/app/Activity/Notifications/Messages/CommentCreationNotification.php @@ -24,7 +24,7 @@ public function toMail(User $notifiable): MailMessage $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), $locale->trans('notifications.detail_commenter') => $this->user->name, - $locale->trans('notifications.detail_comment') => strip_tags($comment->html), + $locale->trans('notifications.detail_comment') => $comment->getPlainText(), ]); return $this->newMailMessage($locale) diff --git a/app/Activity/Notifications/Messages/CommentMentionNotification.php b/app/Activity/Notifications/Messages/CommentMentionNotification.php index de9e719633d..4c8ee5bab8b 100644 --- a/app/Activity/Notifications/Messages/CommentMentionNotification.php +++ b/app/Activity/Notifications/Messages/CommentMentionNotification.php @@ -24,7 +24,7 @@ public function toMail(User $notifiable): MailMessage $locale->trans('notifications.detail_page_name') => new EntityLinkMessageLine($page), $locale->trans('notifications.detail_page_path') => $this->buildPagePathLine($page, $notifiable), $locale->trans('notifications.detail_commenter') => $this->user->name, - $locale->trans('notifications.detail_comment') => strip_tags($comment->html), + $locale->trans('notifications.detail_comment') => $comment->getPlainText(), ]); return $this->newMailMessage($locale) diff --git a/app/Entities/Repos/BaseRepo.php b/app/Entities/Repos/BaseRepo.php index 717e9c9f82a..44baeaccfdc 100644 --- a/app/Entities/Repos/BaseRepo.php +++ b/app/Entities/Repos/BaseRepo.php @@ -16,6 +16,7 @@ use BookStack\Sorting\BookSorter; use BookStack\Uploads\ImageRepo; use BookStack\Util\HtmlDescriptionFilter; +use BookStack\Util\HtmlToPlainText; use Illuminate\Http\UploadedFile; class BaseRepo @@ -151,9 +152,10 @@ protected function updateDescription(Entity $entity, array $input): void } if (isset($input['description_html'])) { + $plainTextConverter = new HtmlToPlainText(); $entity->descriptionInfo()->set( HtmlDescriptionFilter::filterFromString($input['description_html']), - html_entity_decode(strip_tags($input['description_html'])) + $plainTextConverter->convert($input['description_html']), ); } else if (isset($input['description'])) { $entity->descriptionInfo()->set('', $input['description']); diff --git a/app/Entities/Tools/PageContent.php b/app/Entities/Tools/PageContent.php index 8d89a86cff4..b86fbbe8bdd 100644 --- a/app/Entities/Tools/PageContent.php +++ b/app/Entities/Tools/PageContent.php @@ -16,6 +16,7 @@ use BookStack\Util\HtmlContentFilter; use BookStack\Util\HtmlContentFilterConfig; use BookStack\Util\HtmlDocument; +use BookStack\Util\HtmlToPlainText; use BookStack\Util\WebSafeMimeSniffer; use Closure; use DOMElement; @@ -303,8 +304,8 @@ protected function setUniqueId(DOMNode $element, array &$idMap): array public function toPlainText(): string { $html = $this->render(true); - - return html_entity_decode(strip_tags($html)); + $converter = new HtmlToPlainText(); + return $converter->convert($html); } /** diff --git a/app/Util/HtmlToPlainText.php b/app/Util/HtmlToPlainText.php new file mode 100644 index 00000000000..79da9e3d862 --- /dev/null +++ b/app/Util/HtmlToPlainText.php @@ -0,0 +1,47 @@ +nodeToText($doc->getBody()); + + // Remove repeated newlines + $text = preg_replace('/\n+/', "\n", $text); + // Remove leading/trailing whitespace + $text = trim($text); + + return $text; + } + + protected function nodeToText(\DOMNode $node): string + { + if ($node->nodeType === XML_TEXT_NODE) { + return $node->textContent; + } + + $text = ''; + if (!in_array($node->nodeName, $this->inlineTags)) { + $text .= "\n"; + } + + foreach ($node->childNodes as $childNode) { + $text .= $this->nodeToText($childNode); + } + + return $text; + } +} diff --git a/tests/Util/HtmlToPlainTextTest.php b/tests/Util/HtmlToPlainTextTest.php new file mode 100644 index 00000000000..e522e486360 --- /dev/null +++ b/tests/Util/HtmlToPlainTextTest.php @@ -0,0 +1,63 @@ +This is a test

+ +

A Header

+

more <©> text with bold

+HTML; + $expected = << text with bold +TEXT; + + $this->runTest($html, $expected); + } + + public function test_adjacent_list_items_are_separated_by_newline() + { + $html = <<
  • Item A
  • Item B
  • +HTML; + $expected = <<runTest($html, $expected); + } + + public function test_inline_formats_dont_cause_newlines() + { + $html = <<Hello

    +HTML; + $expected = <<runTest($html, $expected); + } + + protected function runTest(string $html, string $expected): void + { + $converter = new HtmlToPlainText(); + $result = $converter->convert(trim($html)); + $this->assertEquals(trim($expected), $result); + } +} From abed4eae0c541a44990d260796e21e420d8243ad Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 5 Apr 2026 17:51:19 +0100 Subject: [PATCH 02/16] Exports: Updated plaintext export to use new converter --- app/Exports/ExportFormatter.php | 20 +++++--------------- tests/Exports/TextExportTest.php | 4 ++-- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/app/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index c5973eace29..dec8aa23d8f 100644 --- a/app/Exports/ExportFormatter.php +++ b/app/Exports/ExportFormatter.php @@ -11,6 +11,7 @@ use BookStack\Uploads\ImageService; use BookStack\Util\CspService; use BookStack\Util\HtmlDocument; +use BookStack\Util\HtmlToPlainText; use DOMElement; use Exception; use Throwable; @@ -242,24 +243,13 @@ protected function containHtml(string $htmlContent): string /** * Converts the page contents into simple plain text. - * This method filters any bad looking content to provide a nice final output. + * We re-generate the plain text from HTML at this point, post-page-content rendering. */ public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string { $html = $pageRendered ? $page->html : (new PageContent($page))->render(); - // Add proceeding spaces before tags so spaces remain between - // text within elements after stripping tags. - $html = str_replace('<', " <", $html); - $text = trim(strip_tags($html)); - // Replace multiple spaces with single spaces - $text = preg_replace('/ {2,}/', ' ', $text); - // Reduce multiple horrid whitespace characters. - $text = preg_replace('/(\x0A|\xA0|\x0A|\r|\n){2,}/su', "\n\n", $text); - $text = html_entity_decode($text); - // Add title - $text = $page->name . ($fromParent ? "\n" : "\n\n") . $text; - - return $text; + $contentText = (new HtmlToPlainText())->convert($html); + return $page->name . ($fromParent ? "\n" : "\n\n") . $contentText; } /** @@ -267,7 +257,7 @@ public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fr */ public function chapterToPlainText(Chapter $chapter): string { - $text = $chapter->name . "\n" . $chapter->description; + $text = $chapter->name . "\n" . $chapter->descriptionInfo()->getPlain(); $text = trim($text) . "\n\n"; $parts = []; diff --git a/tests/Exports/TextExportTest.php b/tests/Exports/TextExportTest.php index 4b2d6288775..26298c185da 100644 --- a/tests/Exports/TextExportTest.php +++ b/tests/Exports/TextExportTest.php @@ -52,7 +52,7 @@ public function test_book_text_export_format() $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); $expected = "Export Book\nThis is a book with stuff to export\n\nExport chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $expected .= "My wonderful page!\nMy great page\nFull of great stuff"; $resp->assertSee($expected); } @@ -82,7 +82,7 @@ public function test_chapter_text_export_format() $resp = $this->asEditor()->get($entities['book']->getUrl('/export/plaintext')); $expected = "Export chapter\nA test chapter to be exported\nIt has loads of info within\n\n"; - $expected .= "My wonderful page!\nMy great page Full of great stuff"; + $expected .= "My wonderful page!\nMy great page\nFull of great stuff"; $resp->assertSee($expected); } } From c7e2b487c14133ef3fa62321fb226501a0f3fc88 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 15:17:31 +0100 Subject: [PATCH 03/16] Attachments: Aligned ZipExportAttachment link validation With controller routes. Don't consider this as a security issue, since the filtered URLs by that validation are very likely to be blocked by browser security or CSP, and there's a level of assumed privilege to the users that are able to create such attachments links already. Closes #6093 --- .../ZipExports/Models/ZipExportAttachment.php | 2 +- tests/Exports/ZipExportValidatorTest.php | 25 +++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 97995738ffe..88c20e4d3d4 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -45,7 +45,7 @@ public static function validate(ZipValidationHelper $context, array $data): arra $rules = [ 'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')], 'name' => ['required', 'string', 'min:1'], - 'link' => ['required_without:file', 'nullable', 'string'], + 'link' => ['required_without:file', 'nullable', 'string', 'safe_url'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; diff --git a/tests/Exports/ZipExportValidatorTest.php b/tests/Exports/ZipExportValidatorTest.php index c453ef294d4..e801705be1f 100644 --- a/tests/Exports/ZipExportValidatorTest.php +++ b/tests/Exports/ZipExportValidatorTest.php @@ -90,4 +90,29 @@ public function test_image_files_need_to_be_a_valid_detected_image_file() $this->assertEquals('The file needs to reference a file of type image/png,image/jpeg,image/gif,image/webp, found text/plain.', $results['page.images.0.file']); } + + public function test_page_link_attachments_cant_be_data_or_js() + { + $validateResultCountByLink = [ + 'data:text/html,

    hi

    ' => 1, + 'javascript:alert(\'hi\')' => 1, + 'mailto:email@example.com' => 0, + ]; + + foreach ($validateResultCountByLink as $link => $count) { + $validator = $this->getValidatorForData([ + 'page' => [ + 'id' => 4, + 'name' => 'My page', + 'markdown' => 'hello', + 'attachments' => [ + ['id' => 4, 'name' => 'Attachment A', 'link' => $link], + ], + ] + ]); + + $results = $validator->validate(); + $this->assertCount($count, $results); + } + } } From 4feb50e7ee06206b2ef037090673cabb70a17c73 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 15:29:00 +0100 Subject: [PATCH 04/16] Attachments: Aligned attachment validation a little more --- app/Exports/ZipExports/Models/ZipExportAttachment.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 88c20e4d3d4..1611866875b 100644 --- a/app/Exports/ZipExports/Models/ZipExportAttachment.php +++ b/app/Exports/ZipExports/Models/ZipExportAttachment.php @@ -45,7 +45,7 @@ public static function validate(ZipValidationHelper $context, array $data): arra $rules = [ 'id' => ['nullable', 'int', $context->uniqueIdRule('attachment')], 'name' => ['required', 'string', 'min:1'], - 'link' => ['required_without:file', 'nullable', 'string', 'safe_url'], + 'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; From f14fc68b6697c203f4012c5bf537dcb49db85f87 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 18:26:00 +0100 Subject: [PATCH 05/16] API: Added new tags API endpoints --- app/Activity/Controllers/TagApiController.php | 51 +++++++++++++++++++ app/Activity/Controllers/TagController.php | 4 +- app/Activity/TagRepo.php | 34 ++++++++++--- app/Api/ListingResponseBuilder.php | 32 +++++++++--- app/Http/ApiController.php | 6 ++- routes/api.php | 4 ++ 6 files changed, 112 insertions(+), 19 deletions(-) create mode 100644 app/Activity/Controllers/TagApiController.php diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php new file mode 100644 index 00000000000..c1945ba5c62 --- /dev/null +++ b/app/Activity/Controllers/TagApiController.php @@ -0,0 +1,51 @@ +tagRepo + ->queryWithTotalsForApi(''); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'name' + ]); + } + + /** + * Get a list of tag values used in the system, which have been used for the given tag name. + * You'll only see results based on tags applied to content you have access to. + * Only the value field can be used in filters. + */ + public function listValues(string $name): JsonResponse + { + $tagQuery = $this->tagRepo + ->queryWithTotalsForApi($name); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + 'name', 'value', + ]); + } +} diff --git a/app/Activity/Controllers/TagController.php b/app/Activity/Controllers/TagController.php index 0af8835ca77..723dc4ab474 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -24,9 +24,9 @@ public function index(Request $request) 'usages' => trans('entities.tags_usages'), ]); - $nameFilter = $request->get('name', ''); + $nameFilter = $request->input('name', ''); $tags = $this->tagRepo - ->queryWithTotals($listOptions, $nameFilter) + ->queryWithTotalsForList($listOptions, $nameFilter) ->paginate(50) ->appends(array_filter(array_merge($listOptions->getPaginationAppends(), [ 'name' => $nameFilter, diff --git a/app/Activity/TagRepo.php b/app/Activity/TagRepo.php index 82c26b00e28..3e8d5545ab6 100644 --- a/app/Activity/TagRepo.php +++ b/app/Activity/TagRepo.php @@ -18,9 +18,10 @@ public function __construct( } /** - * Start a query against all tags in the system. + * Start a query against all tags in the system, with total counts for their usage, + * suitable for a system interface list with listing options. */ - public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilter): Builder + public function queryWithTotalsForList(SimpleListOptions $listOptions, string $nameFilter): Builder { $searchTerm = $listOptions->getSearch(); $sort = $listOptions->getSort(); @@ -28,17 +29,34 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt $sort = 'value'; } + $query = $this->baseQueryWithTotals($nameFilter, $searchTerm) + ->orderBy($sort, $listOptions->getOrder()); + + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + /** + * Start a query against all tags in the system, with total counts for their usage, + * which can be used via the API. + */ + public function queryWithTotalsForApi(string $nameFilter): Builder + { + $query = $this->baseQueryWithTotals($nameFilter, ''); + return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + } + + protected function baseQueryWithTotals(string $nameFilter, string $searchTerm): Builder + { $query = Tag::query() ->select([ 'name', ($searchTerm || $nameFilter) ? 'value' : DB::raw('COUNT(distinct value) as `values`'), DB::raw('COUNT(id) as usages'), - DB::raw('SUM(IF(entity_type = \'page\', 1, 0)) as page_count'), - DB::raw('SUM(IF(entity_type = \'chapter\', 1, 0)) as chapter_count'), - DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'), - DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'), + DB::raw('CAST(SUM(IF(entity_type = \'page\', 1, 0)) as UNSIGNED) as page_count'), + DB::raw('CAST(SUM(IF(entity_type = \'chapter\', 1, 0)) as UNSIGNED) as chapter_count'), + DB::raw('CAST(SUM(IF(entity_type = \'book\', 1, 0)) as UNSIGNED) as book_count'), + DB::raw('CAST(SUM(IF(entity_type = \'bookshelf\', 1, 0)) as UNSIGNED) as shelf_count'), ]) - ->orderBy($sort, $listOptions->getOrder()) ->whereHas('entity'); if ($nameFilter) { @@ -57,7 +75,7 @@ public function queryWithTotals(SimpleListOptions $listOptions, string $nameFilt }); } - return $this->permissions->restrictEntityRelationQuery($query, 'tags', 'entity_id', 'entity_type'); + return $query; } /** diff --git a/app/Api/ListingResponseBuilder.php b/app/Api/ListingResponseBuilder.php index 44117bad975..6b9cfdd7d0d 100644 --- a/app/Api/ListingResponseBuilder.php +++ b/app/Api/ListingResponseBuilder.php @@ -18,6 +18,13 @@ class ListingResponseBuilder */ protected array $fields; + /** + * Which fields are filterable. + * When null, the $fields above are used instead (Allow all fields). + * @var string[]|null + */ + protected array|null $filterableFields = null; + /** * @var array */ @@ -54,7 +61,7 @@ public function toResponse(): JsonResponse { $filteredQuery = $this->filterQuery($this->query); - $total = $filteredQuery->count(); + $total = $filteredQuery->getCountForPagination(); $data = $this->fetchData($filteredQuery)->each(function ($model) { foreach ($this->resultModifiers as $modifier) { $modifier($model); @@ -77,6 +84,14 @@ public function modifyResults(callable $modifier): void $this->resultModifiers[] = $modifier; } + /** + * Limit filtering to just the given set of fields. + */ + public function setFilterableFields(array $fields): void + { + $this->filterableFields = $fields; + } + /** * Fetch the data to return within the response. */ @@ -94,7 +109,7 @@ protected function fetchData(Builder $query): Collection protected function filterQuery(Builder $query): Builder { $query = clone $query; - $requestFilters = $this->request->get('filter', []); + $requestFilters = $this->request->input('filter', []); if (!is_array($requestFilters)) { return $query; } @@ -114,10 +129,11 @@ protected function filterQuery(Builder $query): Builder protected function requestFilterToQueryFilter($fieldKey, $value): ?array { $splitKey = explode(':', $fieldKey); - $field = $splitKey[0]; + $field = strtolower($splitKey[0]); $filterOperator = $splitKey[1] ?? 'eq'; - if (!in_array($field, $this->fields)) { + $filterFields = $this->filterableFields ?? $this->fields; + if (!in_array($field, $filterFields)) { return null; } @@ -140,8 +156,8 @@ protected function sortQuery(Builder $query): Builder $defaultSortName = $this->fields[0]; $direction = 'asc'; - $sort = $this->request->get('sort', ''); - if (strpos($sort, '-') === 0) { + $sort = $this->request->input('sort', ''); + if (str_starts_with($sort, '-')) { $direction = 'desc'; } @@ -160,9 +176,9 @@ protected function sortQuery(Builder $query): Builder protected function countAndOffsetQuery(Builder $query): Builder { $query = clone $query; - $offset = max(0, $this->request->get('offset', 0)); + $offset = max(0, $this->request->input('offset', 0)); $maxCount = config('api.max_item_count'); - $count = $this->request->get('count', config('api.default_item_count')); + $count = $this->request->input('count', config('api.default_item_count')); $count = max(min($maxCount, $count), 1); return $query->skip($offset)->take($count); diff --git a/app/Http/ApiController.php b/app/Http/ApiController.php index 8c0f206d0d5..f1b74783f8a 100644 --- a/app/Http/ApiController.php +++ b/app/Http/ApiController.php @@ -20,10 +20,14 @@ abstract class ApiController extends Controller * Provide a paginated listing JSON response in a standard format * taking into account any pagination parameters passed by the user. */ - protected function apiListingResponse(Builder $query, array $fields, array $modifiers = []): JsonResponse + protected function apiListingResponse(Builder $query, array $fields, array $modifiers = [], array $filterableFields = []): JsonResponse { $listing = new ListingResponseBuilder($query, request(), $fields); + if (count($filterableFields) > 0) { + $listing->setFilterableFields($filterableFields); + } + foreach ($modifiers as $modifier) { $listing->modifyResults($modifier); } diff --git a/routes/api.php b/routes/api.php index 308a95d8c28..9f45cefb985 100644 --- a/routes/api.php +++ b/routes/api.php @@ -7,6 +7,7 @@ */ use BookStack\Activity\Controllers as ActivityControllers; +use BookStack\Activity\Controllers\TagApiController; use BookStack\Api\ApiDocsController; use BookStack\App\SystemApiController; use BookStack\Entities\Controllers as EntityControllers; @@ -109,6 +110,9 @@ Route::get('system', [SystemApiController::class, 'read']); +Route::get('tags/names', [TagApiController::class, 'listNames']); +Route::get('tags/name/{name}/values', [TagApiController::class, 'listValues']); + Route::get('users', [UserApiController::class, 'list']); Route::post('users', [UserApiController::class, 'create']); Route::get('users/{id}', [UserApiController::class, 'read']); From 1c1ad1d1b739d2558d31478610b1ca4af553f3eb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 12 Apr 2026 20:45:18 +0100 Subject: [PATCH 06/16] Tags API: Reviewed docs and added examples --- app/Activity/Controllers/TagApiController.php | 10 ++++-- app/Api/ApiDocsGenerator.php | 7 ++-- dev/api/responses/tags-list-names.json | 32 +++++++++++++++++++ dev/api/responses/tags-list-values.json | 32 +++++++++++++++++++ .../views/api-docs/parts/endpoint.blade.php | 2 +- .../api-docs/parts/getting-started.blade.php | 2 +- 6 files changed, 77 insertions(+), 8 deletions(-) create mode 100644 dev/api/responses/tags-list-names.json create mode 100644 dev/api/responses/tags-list-values.json diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php index c1945ba5c62..1fdffa007a5 100644 --- a/app/Activity/Controllers/TagApiController.php +++ b/app/Activity/Controllers/TagApiController.php @@ -8,6 +8,12 @@ use BookStack\Http\ApiController; use Illuminate\Http\JsonResponse; +/** + * Endpoints to query data about tags in the system. + * You'll only see results based on tags applied to content you have access to. + * There are no general create/update/delete endpoints here since tags do not exist + * by themselves, they are managed via the items they are assigned to. + */ class TagApiController extends ApiController { public function __construct( @@ -17,7 +23,6 @@ public function __construct( /** * Get a list of tag names used in the system. - * You'll only see results based on tags applied to content you have access to. * Only the name field can be used in filters. */ public function listNames(): JsonResponse @@ -33,8 +38,7 @@ public function listNames(): JsonResponse } /** - * Get a list of tag values used in the system, which have been used for the given tag name. - * You'll only see results based on tags applied to content you have access to. + * Get a list of tag values, which have been set for the given tag name. * Only the value field can be used in filters. */ public function listValues(string $name): JsonResponse diff --git a/app/Api/ApiDocsGenerator.php b/app/Api/ApiDocsGenerator.php index a59cb8198e2..53cb2890a7e 100644 --- a/app/Api/ApiDocsGenerator.php +++ b/app/Api/ApiDocsGenerator.php @@ -195,11 +195,12 @@ protected function getReflectionClass(string $className): ReflectionClass protected function getFlatApiRoutes(): Collection { return collect(Route::getRoutes()->getRoutes())->filter(function ($route) { - return strpos($route->uri, 'api/') === 0; + return str_starts_with($route->uri, 'api/'); })->map(function ($route) { [$controller, $controllerMethod] = explode('@', $route->action['uses']); $baseModelName = explode('.', explode('/', $route->uri)[1])[0]; - $shortName = $baseModelName . '-' . $controllerMethod; + $controllerMethodKebab = Str::kebab($controllerMethod); + $shortName = $baseModelName . '-' . $controllerMethodKebab; return [ 'name' => $shortName, @@ -207,7 +208,7 @@ protected function getFlatApiRoutes(): Collection 'method' => $route->methods[0], 'controller' => $controller, 'controller_method' => $controllerMethod, - 'controller_method_kebab' => Str::kebab($controllerMethod), + 'controller_method_kebab' => $controllerMethodKebab, 'base_model' => $baseModelName, ]; }); diff --git a/dev/api/responses/tags-list-names.json b/dev/api/responses/tags-list-names.json new file mode 100644 index 00000000000..c0c8e7b2231 --- /dev/null +++ b/dev/api/responses/tags-list-names.json @@ -0,0 +1,32 @@ +{ + "data": [ + { + "name": "Category", + "values": 8, + "usages": 184, + "page_count": 3, + "chapter_count": 8, + "book_count": 171, + "shelf_count": 2 + }, + { + "name": "Review Due", + "values": 2, + "usages": 2, + "page_count": 1, + "chapter_count": 0, + "book_count": 1, + "shelf_count": 0 + }, + { + "name": "Type", + "values": 2, + "usages": 2, + "page_count": 0, + "chapter_count": 1, + "book_count": 1, + "shelf_count": 0 + } + ], + "total": 3 +} \ No newline at end of file diff --git a/dev/api/responses/tags-list-values.json b/dev/api/responses/tags-list-values.json new file mode 100644 index 00000000000..37926b8463c --- /dev/null +++ b/dev/api/responses/tags-list-values.json @@ -0,0 +1,32 @@ +{ + "data": [ + { + "name": "Category", + "value": "Cool Stuff", + "usages": 3, + "page_count": 1, + "chapter_count": 0, + "book_count": 2, + "shelf_count": 0 + }, + { + "name": "Category", + "value": "Top Content", + "usages": 168, + "page_count": 0, + "chapter_count": 3, + "book_count": 165, + "shelf_count": 0 + }, + { + "name": "Category", + "value": "Learning", + "usages": 2, + "page_count": 0, + "chapter_count": 0, + "book_count": 0, + "shelf_count": 2 + } + ], + "total": 3 +} \ No newline at end of file diff --git a/resources/views/api-docs/parts/endpoint.blade.php b/resources/views/api-docs/parts/endpoint.blade.php index 024a5ecdf04..543ef092ee5 100644 --- a/resources/views/api-docs/parts/endpoint.blade.php +++ b/resources/views/api-docs/parts/endpoint.blade.php @@ -1,7 +1,7 @@
    {{ $endpoint['method'] }}
    - @if($endpoint['controller_method_kebab'] === 'list') + @if(str_starts_with($endpoint['controller_method_kebab'], 'list') && !str_contains($endpoint['uri'], '{')) {{ url($endpoint['uri']) }} @else {{ url($endpoint['uri']) }} diff --git a/resources/views/api-docs/parts/getting-started.blade.php b/resources/views/api-docs/parts/getting-started.blade.php index 663389047ce..ebe3838ef1f 100644 --- a/resources/views/api-docs/parts/getting-started.blade.php +++ b/resources/views/api-docs/parts/getting-started.blade.php @@ -2,7 +2,7 @@

    This documentation covers use of the REST API.
    - Examples of API usage, in a variety of programming languages, can be found in the BookStack api-scripts repo on GitHub. + Examples of API usage, in a variety of programming languages, can be found in the BookStack api-scripts repo on Codeberg.

    Some alternative options for extension and customization can be found below: From 346dc27979a05a7b82e7fef5f32c00be2578f77f Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Apr 2026 11:31:34 +0100 Subject: [PATCH 07/16] API: Added testing to cover tags API endpoints --- tests/Api/TagsApiTest.php | 109 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 tests/Api/TagsApiTest.php diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php new file mode 100644 index 00000000000..baf7b085956 --- /dev/null +++ b/tests/Api/TagsApiTest.php @@ -0,0 +1,109 @@ + 'MyGreatApiTag', 'value' => 'cat']; + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag($tagInfo))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag($tagInfo))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag($tagInfo))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 1, + 'usages' => 18, + 'page_count' => 10, + 'book_count' => 3, + 'chapter_count' => 5, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_names_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/names?filter[name]=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson([ + 'data' => [ + [ + 'name' => 'MyGreatApiTag', + 'values' => 8, + 'usages' => 8, + 'page_count' => 8, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ], + 'total' => 1, + ]); + } + + public function test_list_values_returns_values_for_set_tag() + { + $pagesToTag = Page::query()->take(10)->get(); + $booksToTag = Book::query()->take(3)->get(); + $chaptersToTag = Chapter::query()->take(5)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-page' . $page->id]))); + $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id]))); + $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id]))); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyValueApiTag/values'); + + $resp->assertStatus(200); + $resp->assertJson(['total' => 18]); + $resp->assertJsonFragment([ + [ + 'name' => 'MyValueApiTag', + 'value' => 'tag-page' . $pagesToTag[0]->id, + 'usages' => 1, + 'page_count' => 1, + 'book_count' => 0, + 'chapter_count' => 0, + 'shelf_count' => 0, + ] + ]); + } + + public function test_list_values_is_limited_by_permission_visibility(): void + { + $pagesToTag = Page::query()->take(10)->get(); + $pagesToTag->each(fn (Page $page) => $page->tags()->save(new Tag(['name' => 'MyGreatApiTag', 'value' => 'cat' . $page->id]))); + + $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); + $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); + + $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyGreatApiTag/values'); + $resp->assertStatus(200); + $resp->assertJson(['total' => 8]); + $resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]); + } +} From 208629ee1fce280c31f9baf30a6ed79c3cd17df0 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Tue, 14 Apr 2026 12:03:29 +0100 Subject: [PATCH 08/16] API: Some changes to tag API endpoints - Updated tag values endpoint to use query param instead of path argument, so a better range of values can be provided (including those with slashes). - Updated image gallery example request to align with docs use changes. --- app/Activity/Controllers/TagApiController.php | 23 +++++++++++++++---- ...p => image-gallery-read-data-for-url.http} | 0 dev/api/requests/tags-list-values.http | 1 + routes/api.php | 2 +- tests/Api/TagsApiTest.php | 4 ++-- 5 files changed, 22 insertions(+), 8 deletions(-) rename dev/api/requests/{image-gallery-readDataForUrl.http => image-gallery-read-data-for-url.http} (100%) create mode 100644 dev/api/requests/tags-list-values.http diff --git a/app/Activity/Controllers/TagApiController.php b/app/Activity/Controllers/TagApiController.php index 1fdffa007a5..f5c5e95d420 100644 --- a/app/Activity/Controllers/TagApiController.php +++ b/app/Activity/Controllers/TagApiController.php @@ -7,6 +7,7 @@ use BookStack\Activity\TagRepo; use BookStack\Http\ApiController; use Illuminate\Http\JsonResponse; +use Illuminate\Http\Request; /** * Endpoints to query data about tags in the system. @@ -21,6 +22,15 @@ public function __construct( ) { } + protected function rules(): array + { + return [ + 'listValues' => [ + 'name' => ['required', 'string'], + ], + ]; + } + /** * Get a list of tag names used in the system. * Only the name field can be used in filters. @@ -38,18 +48,21 @@ public function listNames(): JsonResponse } /** - * Get a list of tag values, which have been set for the given tag name. + * Get a list of tag values, which have been set for the given tag name, + * which must be provided as a query parameter on the request. * Only the value field can be used in filters. */ - public function listValues(string $name): JsonResponse + public function listValues(Request $request): JsonResponse { - $tagQuery = $this->tagRepo - ->queryWithTotalsForApi($name); + $data = $this->validate($request, $this->rules()['listValues']); + $name = $data['name']; + + $tagQuery = $this->tagRepo->queryWithTotalsForApi($name); return $this->apiListingResponse($tagQuery, [ 'name', 'value', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', ], [], [ - 'name', 'value', + 'value', ]); } } diff --git a/dev/api/requests/image-gallery-readDataForUrl.http b/dev/api/requests/image-gallery-read-data-for-url.http similarity index 100% rename from dev/api/requests/image-gallery-readDataForUrl.http rename to dev/api/requests/image-gallery-read-data-for-url.http diff --git a/dev/api/requests/tags-list-values.http b/dev/api/requests/tags-list-values.http new file mode 100644 index 00000000000..6dd3f49fc0b --- /dev/null +++ b/dev/api/requests/tags-list-values.http @@ -0,0 +1 @@ +GET /api/tags/values-for-name?name=Category diff --git a/routes/api.php b/routes/api.php index 9f45cefb985..5a9df3cc422 100644 --- a/routes/api.php +++ b/routes/api.php @@ -111,7 +111,7 @@ Route::get('system', [SystemApiController::class, 'read']); Route::get('tags/names', [TagApiController::class, 'listNames']); -Route::get('tags/name/{name}/values', [TagApiController::class, 'listValues']); +Route::get('tags/values-for-name', [TagApiController::class, 'listValues']); Route::get('users', [UserApiController::class, 'list']); Route::post('users', [UserApiController::class, 'create']); diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php index baf7b085956..a079fa63915 100644 --- a/tests/Api/TagsApiTest.php +++ b/tests/Api/TagsApiTest.php @@ -76,7 +76,7 @@ public function test_list_values_returns_values_for_set_tag() $booksToTag->each(fn (Book $book) => $book->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-book' . $book->id]))); $chaptersToTag->each(fn (Chapter $chapter) => $chapter->tags()->save(new Tag(['name' => 'MyValueApiTag', 'value' => 'tag-chapter' . $chapter->id]))); - $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyValueApiTag/values'); + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyValueApiTag'); $resp->assertStatus(200); $resp->assertJson(['total' => 18]); @@ -101,7 +101,7 @@ public function test_list_values_is_limited_by_permission_visibility(): void $this->permissions->disableEntityInheritedPermissions($pagesToTag[3]); $this->permissions->disableEntityInheritedPermissions($pagesToTag[6]); - $resp = $this->actingAsApiEditor()->getJson('api/tags/name/MyGreatApiTag/values'); + $resp = $this->actingAsApiEditor()->getJson('api/tags/values-for-name?name=MyGreatApiTag'); $resp->assertStatus(200); $resp->assertJson(['total' => 8]); $resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]); From 18364d1e6e235ab129a03e77279201436bcd954a Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Thu, 16 Apr 2026 11:11:06 +0100 Subject: [PATCH 09/16] WYSIWYG: Added inline code support to minimal editor Used for comments and descriptions. Also updated shortcut handling that we're not registering shortcuts for edits which can't use the related formatting types. For #6003 --- app/Util/HtmlDescriptionFilter.php | 1 + resources/js/wysiwyg/index.ts | 6 +- resources/js/wysiwyg/services/shortcuts.ts | 59 +++++++++++--------- resources/js/wysiwyg/ui/defaults/toolbars.ts | 1 + tests/Entity/BookTest.php | 4 +- 5 files changed, 40 insertions(+), 31 deletions(-) diff --git a/app/Util/HtmlDescriptionFilter.php b/app/Util/HtmlDescriptionFilter.php index 1baa11ffcfa..ba145460381 100644 --- a/app/Util/HtmlDescriptionFilter.php +++ b/app/Util/HtmlDescriptionFilter.php @@ -27,6 +27,7 @@ class HtmlDescriptionFilter 'span' => [], 'em' => [], 'br' => [], + 'code' => [], ]; public static function filterFromString(string $html): string diff --git a/resources/js/wysiwyg/index.ts b/resources/js/wysiwyg/index.ts index 01964b066c6..dc0ea211f59 100644 --- a/resources/js/wysiwyg/index.ts +++ b/resources/js/wysiwyg/index.ts @@ -59,7 +59,7 @@ export function createPageEditorInstance(container: HTMLElement, htmlContent: st mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(context), + registerShortcuts(context, true), registerKeyboardHandling(context), registerMouseHandling(context), registerSelectionHandling(context), @@ -123,7 +123,7 @@ export function createBasicEditorInstance(container: HTMLElement, htmlContent: s const editorTeardown = mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(context), + registerShortcuts(context, false), registerAutoLinks(editor), ); @@ -157,7 +157,7 @@ export function createCommentEditorInstance(container: HTMLElement, htmlContent: const editorTeardown = mergeRegister( registerRichText(editor), registerHistory(editor, createEmptyHistoryState(), 300), - registerShortcuts(context), + registerShortcuts(context, false), registerAutoLinks(editor), registerMentions(context), ); diff --git a/resources/js/wysiwyg/services/shortcuts.ts b/resources/js/wysiwyg/services/shortcuts.ts index c4be0f3cf2f..00abe0c6d2f 100644 --- a/resources/js/wysiwyg/services/shortcuts.ts +++ b/resources/js/wysiwyg/services/shortcuts.ts @@ -38,29 +38,9 @@ type ShortcutAction = (editor: LexicalEditor, context: EditorUiContext) => boole * List of action functions by their shortcut combo. * We use "meta" as an abstraction for ctrl/cmd depending on platform. */ -const actionsByKeys: Record = { - 'meta+s': () => { - window.$events.emit('editor-save-draft'); - return true; - }, - 'meta+enter': () => { - window.$events.emit('editor-save-page'); - return true; - }, - 'meta+1': (editor, context) => headerHandler(context, 'h2'), - 'meta+2': (editor, context) => headerHandler(context, 'h3'), - 'meta+3': (editor, context) => headerHandler(context, 'h4'), - 'meta+4': (editor, context) => headerHandler(context, 'h5'), - 'meta+5': wrapFormatAction(toggleSelectionAsParagraph), - 'meta+d': wrapFormatAction(toggleSelectionAsParagraph), - 'meta+6': wrapFormatAction(toggleSelectionAsBlockquote), - 'meta+q': wrapFormatAction(toggleSelectionAsBlockquote), - 'meta+7': wrapFormatAction(formatCodeBlock), - 'meta+e': wrapFormatAction(formatCodeBlock), +const baseActionsByKeys: Record = { 'meta+8': toggleInlineCode, 'meta+shift+e': toggleInlineCode, - 'meta+9': wrapFormatAction(cycleSelectionCalloutFormats), - 'meta+o': wrapFormatAction((e) => toggleSelectionAsList(e, 'number')), 'meta+p': wrapFormatAction((e) => toggleSelectionAsList(e, 'bullet')), 'meta+k': (editor, context) => { @@ -87,12 +67,39 @@ const actionsByKeys: Record = { }, }; -function createKeyDownListener(context: EditorUiContext): (e: KeyboardEvent) => void { +/** + * An extended set of the above, used for fuller-featured editors with heavier block-level formatting. + */ +const extendedActionsByKeys: Record = { + ...baseActionsByKeys, + 'meta+s': () => { + window.$events.emit('editor-save-draft'); + return true; + }, + 'meta+enter': () => { + window.$events.emit('editor-save-page'); + return true; + }, + 'meta+1': (editor, context) => headerHandler(context, 'h2'), + 'meta+2': (editor, context) => headerHandler(context, 'h3'), + 'meta+3': (editor, context) => headerHandler(context, 'h4'), + 'meta+4': (editor, context) => headerHandler(context, 'h5'), + 'meta+5': wrapFormatAction(toggleSelectionAsParagraph), + 'meta+d': wrapFormatAction(toggleSelectionAsParagraph), + 'meta+6': wrapFormatAction(toggleSelectionAsBlockquote), + 'meta+7': wrapFormatAction(formatCodeBlock), + 'meta+e': wrapFormatAction(formatCodeBlock), + 'meta+q': wrapFormatAction(toggleSelectionAsBlockquote), + 'meta+9': wrapFormatAction(cycleSelectionCalloutFormats), +}; + +function createKeyDownListener(context: EditorUiContext, useExtended: boolean): (e: KeyboardEvent) => void { + const keySetToUse = useExtended ? extendedActionsByKeys : baseActionsByKeys; return (event: KeyboardEvent) => { const combo = keyboardEventToKeyComboString(event); // console.log(`pressed: ${combo}`); - if (actionsByKeys[combo]) { - const handled = actionsByKeys[combo](context.editor, context); + if (keySetToUse[combo]) { + const handled = keySetToUse[combo](context.editor, context); if (handled) { event.stopPropagation(); event.preventDefault(); @@ -127,8 +134,8 @@ function overrideDefaultCommands(editor: LexicalEditor) { }, COMMAND_PRIORITY_HIGH); } -export function registerShortcuts(context: EditorUiContext) { - const listener = createKeyDownListener(context); +export function registerShortcuts(context: EditorUiContext, useExtended: boolean) { + const listener = createKeyDownListener(context, useExtended); overrideDefaultCommands(context.editor); return context.editor.registerRootListener((rootElement: null | HTMLElement, prevRootElement: null | HTMLElement) => { diff --git a/resources/js/wysiwyg/ui/defaults/toolbars.ts b/resources/js/wysiwyg/ui/defaults/toolbars.ts index d6af996384b..a3ada5c89f6 100644 --- a/resources/js/wysiwyg/ui/defaults/toolbars.ts +++ b/resources/js/wysiwyg/ui/defaults/toolbars.ts @@ -227,6 +227,7 @@ export function getBasicEditorToolbar(context: EditorUiContext): EditorContainer new EditorButton(bold), new EditorButton(italic), new EditorButton(link), + new EditorButton(code), new EditorButton(bulletList), new EditorButton(numberList), ]) diff --git a/tests/Entity/BookTest.php b/tests/Entity/BookTest.php index 6082c59de61..c0d4fbc63e6 100644 --- a/tests/Entity/BookTest.php +++ b/tests/Entity/BookTest.php @@ -256,8 +256,8 @@ public function test_description_limited_to_specific_html() { $book = $this->entities->book(); - $input = '

    Test

    Contenta

    Hello

    '; - $expected = '

    Contenta

    '; + $input = '

    Test

    Contenta

    Hello
    code

    '; + $expected = '

    Contentacode

    '; $this->asEditor()->put($book->getUrl(), [ 'name' => $book->name, From a2bb5bdf10bbee40a6eb12e40101ea61f0a92021 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Fri, 17 Apr 2026 21:22:04 +0100 Subject: [PATCH 10/16] Meta: Updated COC, templates, PR template for community rules Added reference to new community rules page where sensible. --- .github/CODE_OF_CONDUCT.md | 86 +--------------------- .github/ISSUE_TEMPLATE/feature_request.yml | 10 +++ .github/pull_request_template.md | 11 +++ readme.md | 9 ++- 4 files changed, 28 insertions(+), 88 deletions(-) create mode 100644 .github/pull_request_template.md diff --git a/.github/CODE_OF_CONDUCT.md b/.github/CODE_OF_CONDUCT.md index 2c6317af654..7a02656725d 100644 --- a/.github/CODE_OF_CONDUCT.md +++ b/.github/CODE_OF_CONDUCT.md @@ -1,84 +1,2 @@ -# Contributor Covenant Code of Conduct - -## Our Pledge - -In the interest of fostering an open and welcoming environment, we as -contributors and maintainers pledge to making participation in our project and -our community a harassment-free experience for everyone, regardless of age, body -size, disability, ethnicity, gender identity and expression, level of experience, -education, socio-economic status, nationality, personal appearance, race, -religion, or sexual identity and orientation. - -## Our Standards - -Examples of behavior that contributes to creating a positive environment -include: - -* Being respectful of differing viewpoints and experiences -* Gracefully accepting constructive criticism -* Focusing on what is best for the community -* Showing empathy towards other community members - -Examples of unacceptable behavior by participants include: - -* The use of sexualized language or imagery and unwelcome sexual attention or - advances -* Trolling, insulting/derogatory comments, and personal or political attacks -* Public or private harassment -* Publishing others' private information, such as a physical or electronic - address, without explicit permission -* Other conduct which could reasonably be considered inappropriate in a - professional setting - -### Project Maintainer Standards - -Project maintainers should generally follow these additional standards: - -* Avoid using a negative or harsh tone in communication, Even if the other party -is being negative themselves. -* When providing criticism, try to make it constructive to lead the other person -down the correct path. -* Keep the [project definition](https://github.com/BookStackApp/BookStack#project-definition) -in mind when deciding what's in scope of the Project. - -## Our Responsibilities - -Project maintainers are responsible for clarifying the standards of acceptable -behavior and are expected to take appropriate and fair corrective action in -response to any instances of unacceptable behavior. In addition, Project -maintainers are responsible for following the standards themselves. - -Project maintainers have the right and responsibility to remove, edit, or -reject comments, commits, code, wiki edits, issues, and other contributions -that are not aligned to this Code of Conduct, or to ban temporarily or -permanently any contributor for other behaviors that they deem inappropriate, -threatening, offensive, or harmful. - -## Scope - -This Code of Conduct applies both within project spaces and in public spaces -when an individual is representing the project or its community. Examples of -representing a project or community include using an official project e-mail -address, posting via an official social media account, or acting as an appointed -representative at an online or offline event. Representation of a project may be -further defined and clarified by project maintainers. - -## Enforcement - -Instances of abusive, harassing, or otherwise unacceptable behavior may be -reported by contacting the project team at the email address shown on [the profile here](https://github.com/ssddanbrown). All -complaints will be reviewed and investigated and will result in a response that -is deemed necessary and appropriate to the circumstances. The project team is -obligated to maintain confidentiality with regard to the reporter of an incident. -Further details of specific enforcement policies may be posted separately. - -Project maintainers who do not follow or enforce the Code of Conduct in good -faith may face temporary or permanent repercussions as determined by other -members of the project's leadership. - -## Attribution - -This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, -available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html - -[homepage]: https://www.contributor-covenant.org +Please find our community rules on our website here: +https://www.bookstackapp.com/about/community-rules/ \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 0ebb8e72f29..ca1f2b8301c 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -56,3 +56,13 @@ body: description: Add any other context or screenshots about the feature request here. validations: required: false + - type: checkboxes + id: ai-thoughts + attributes: + label: Have you used generative AI/LLMs to create any thoughts in this request? + description: | + We ask that no machine generated thoughts or ideas are provided, to avoid us spending time considering the ideas + of a machine instead of a human. Further guidance on this can be found [in the BookStack community rules](https://www.bookstackapp.com/about/community-rules/#use-of-llmsai). + options: + - label: This request only contains the thoughts & ideas of a human + required: true diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000000..70f1058748c --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,11 @@ +## Details + + + + +## Checklist + + + +- [ ] I have read the [BookStack community rules](https://www.bookstackapp.com/about/community-rules/). +- [ ] This PR does not feature significant use of LLM/AI generation as per the community rules above. diff --git a/readme.md b/readme.md index 00eb135046e..d3a408ad02c 100644 --- a/readme.md +++ b/readme.md @@ -9,8 +9,9 @@
    [![Alternate Source](https://img.shields.io/static/v1?label=Alt+Source&message=Git&color=ef391a&logo=git)](https://source.bookstackapp.com/) [![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/) -[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://www.bookstackapp.com/links/discord) +[![Community Discussions](https://img.shields.io/static/v1?label=Community&message=Discussions&color=4d36c4&logo=zulip)](https://community.bookstackapp.com/) [![Mastodon](https://img.shields.io/static/v1?label=Mastodon&message=@bookstack&color=595aff&logo=mastodon)](https://www.bookstackapp.com/links/mastodon) +[![Discord](https://img.shields.io/static/v1?label=Discord&message=chat&color=738adb&logo=discord)](https://www.bookstackapp.com/links/discord)
    [![PeerTube](https://img.shields.io/static/v1?label=PeerTube&message=bookstack@foss.video&color=f2690d&logo=peertube)](https://foss.video/c/bookstack) [![YouTube](https://img.shields.io/static/v1?label=YouTube&message=bookstackapp&color=ff0000&logo=youtube)](https://www.youtube.com/bookstackapp) @@ -20,11 +21,10 @@ A platform for storing and organising information and documentation. Details for * [Installation Instructions](https://www.bookstackapp.com/docs/admin/installation) * [Documentation](https://www.bookstackapp.com/docs) * [Demo Instance](https://demo.bookstackapp.com) - * [Admin Login](https://demo.bookstackapp.com/login?email=admin@example.com&password=password) * [Screenshots](https://www.bookstackapp.com/#screenshots) * [BookStack Blog](https://www.bookstackapp.com/blog) * [Issue List](https://github.com/BookStackApp/BookStack/issues) -* [Discord Chat](https://www.bookstackapp.com/links/discord) +* [Community Discussions](https://community.bookstackapp.com/) * [Support Options](https://www.bookstackapp.com/support/) ## 📚 Project Definition @@ -124,8 +124,9 @@ Feel free to [create issues](https://github.com/BookStackApp/BookStack/issues/ne Pull requests are welcome but, unless it's a small tweak, it may be best to open the pull request early or create an issue for your intended change to discuss how it will fit into the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project. Pull requests should be created from the `development` branch since they will be merged back into `development` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly. +See the [Development & Testing](#-development--testing) section above for further development guidance. -The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/development/.github/CODE_OF_CONDUCT.md). +The project's community rules, including those for raising issues and making code contributions, [can be found here](https://www.bookstackapp.com/about/community-rules/). ## 🔒 Security From 083fb1a600f1eeb8acb719675bdeb089c86ceee1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sat, 18 Apr 2026 20:43:27 +0100 Subject: [PATCH 11/16] Maintenance: Updated $request->get instance to use input --- .../Controllers/ForgotPasswordController.php | 4 ++-- app/Access/Controllers/LoginController.php | 8 ++++---- .../Controllers/MfaBackupCodesController.php | 2 +- app/Access/Controllers/MfaController.php | 4 ++-- .../Controllers/ResetPasswordController.php | 4 ++-- app/Access/Controllers/Saml2Controller.php | 4 ++-- app/Access/Controllers/SocialController.php | 2 +- .../Controllers/UserInviteController.php | 2 +- .../Controllers/AuditLogController.php | 14 ++++++------- .../Controllers/FavouriteController.php | 2 +- app/Activity/Controllers/TagController.php | 6 +++--- app/Api/UserApiTokenController.php | 8 ++++---- app/Entities/Controllers/BookController.php | 4 ++-- .../Controllers/BookshelfApiController.php | 4 ++-- .../Controllers/BookshelfController.php | 4 ++-- .../Controllers/ChapterApiController.php | 2 +- .../Controllers/ChapterController.php | 6 +++--- .../Controllers/PageApiController.php | 8 ++++---- app/Entities/Controllers/PageController.php | 8 ++++---- .../Controllers/PageTemplateController.php | 4 ++-- app/Entities/Tools/PermissionsUpdater.php | 4 ++-- app/Search/SearchApiController.php | 6 +++--- app/Search/SearchController.php | 20 +++++++++---------- app/Search/SearchOptions.php | 2 +- app/Settings/AppSettingsStore.php | 4 ++-- app/Settings/MaintenanceController.php | 2 +- app/Sorting/BookSortController.php | 4 ++-- .../Controllers/AttachmentApiController.php | 4 ++-- .../Controllers/AttachmentController.php | 16 +++++++-------- .../Controllers/DrawioImageController.php | 12 +++++------ .../Controllers/GalleryImageController.php | 10 +++++----- app/Users/Controllers/RoleController.php | 4 ++-- .../Controllers/UserAccountController.php | 6 +++--- app/Users/Controllers/UserApiController.php | 2 +- app/Users/Controllers/UserController.php | 4 ++-- .../Controllers/UserPreferencesController.php | 8 ++++---- .../Controllers/UserSearchController.php | 4 ++-- app/Util/SimpleListOptions.php | 2 +- 38 files changed, 107 insertions(+), 107 deletions(-) diff --git a/app/Access/Controllers/ForgotPasswordController.php b/app/Access/Controllers/ForgotPasswordController.php index 36dd977558b..e8127e6173a 100644 --- a/app/Access/Controllers/ForgotPasswordController.php +++ b/app/Access/Controllers/ForgotPasswordController.php @@ -45,11 +45,11 @@ public function sendResetLinkEmail(Request $request) ); if ($response === Password::RESET_LINK_SENT) { - $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->get('email')); + $this->logActivity(ActivityType::AUTH_PASSWORD_RESET, $request->input('email')); } if (in_array($response, [Password::RESET_LINK_SENT, Password::INVALID_USER, Password::RESET_THROTTLED])) { - $message = trans('auth.reset_password_sent', ['email' => $request->get('email')]); + $message = trans('auth.reset_password_sent', ['email' => $request->input('email')]); $this->showSuccessNotification($message); return redirect('/password/email')->with('status', trans($response)); diff --git a/app/Access/Controllers/LoginController.php b/app/Access/Controllers/LoginController.php index ce872ba88dc..4694f22e4d3 100644 --- a/app/Access/Controllers/LoginController.php +++ b/app/Access/Controllers/LoginController.php @@ -32,12 +32,12 @@ public function getLogin(Request $request) { $socialDrivers = $this->socialDriverManager->getActive(); $authMethod = config('auth.method'); - $preventInitiation = $request->get('prevent_auto_init') === 'true'; + $preventInitiation = $request->input('prevent_auto_init') === 'true'; if ($request->has('email')) { session()->flashInput([ - 'email' => $request->get('email'), - 'password' => (config('app.env') === 'demo') ? $request->get('password', '') : '', + 'email' => $request->input('email'), + 'password' => (config('app.env') === 'demo') ? $request->input('password', '') : '', ]); } @@ -62,7 +62,7 @@ public function getLogin(Request $request) public function login(Request $request) { $this->validateLogin($request); - $username = $request->get($this->username()); + $username = $request->input($this->username()); // Check login throttling attempts to see if they've gone over the limit if ($this->hasTooManyLoginAttempts($request)) { diff --git a/app/Access/Controllers/MfaBackupCodesController.php b/app/Access/Controllers/MfaBackupCodesController.php index 5c334674e7d..0a6416a1795 100644 --- a/app/Access/Controllers/MfaBackupCodesController.php +++ b/app/Access/Controllers/MfaBackupCodesController.php @@ -84,7 +84,7 @@ function ($attribute, $value, $fail) use ($codeService, $codes) { ], ]); - $updatedCodes = $codeService->removeInputCodeFromSet($request->get('code'), $codes); + $updatedCodes = $codeService->removeInputCodeFromSet($request->input('code'), $codes); MfaValue::upsertWithValue($user, MfaValue::METHOD_BACKUP_CODES, $updatedCodes); $mfaSession->markVerifiedForUser($user); diff --git a/app/Access/Controllers/MfaController.php b/app/Access/Controllers/MfaController.php index c9100ef9120..181cfc0b84b 100644 --- a/app/Access/Controllers/MfaController.php +++ b/app/Access/Controllers/MfaController.php @@ -51,14 +51,14 @@ public function remove(string $method) */ public function verify(Request $request) { - $desiredMethod = $request->get('method'); + $desiredMethod = $request->input('method'); $userMethods = $this->currentOrLastAttemptedUser() ->mfaValues() ->get(['id', 'method']) ->groupBy('method'); // Basic search for the default option for a user. - // (Prioritises totp over backup codes) + // (Prioritises TOTP over backup codes) $method = $userMethods->has($desiredMethod) ? $desiredMethod : $userMethods->keys()->sort()->reverse()->first(); $otherMethods = $userMethods->keys()->filter(function ($userMethod) use ($method) { return $method !== $userMethod; diff --git a/app/Access/Controllers/ResetPasswordController.php b/app/Access/Controllers/ResetPasswordController.php index 3af65d17fb6..e81c98b288c 100644 --- a/app/Access/Controllers/ResetPasswordController.php +++ b/app/Access/Controllers/ResetPasswordController.php @@ -48,7 +48,7 @@ public function reset(Request $request) // Here we will attempt to reset the user's password. If it is successful we // will update the password on an actual user model and persist it to the - // database. Otherwise we will parse the error and return the response. + // database. Otherwise, we will parse the error and return the response. $credentials = $request->only('email', 'password', 'password_confirmation', 'token'); $response = Password::broker()->reset($credentials, function (User $user, string $password) { $user->password = Hash::make($password); @@ -63,7 +63,7 @@ public function reset(Request $request) // redirect them back to where they came from with their error message. return $response === Password::PASSWORD_RESET ? $this->sendResetResponse() - : $this->sendResetFailedResponse($request, $response, $request->get('token')); + : $this->sendResetFailedResponse($request, $response, $request->input('token')); } /** diff --git a/app/Access/Controllers/Saml2Controller.php b/app/Access/Controllers/Saml2Controller.php index 6f802370e9c..39598b1435a 100644 --- a/app/Access/Controllers/Saml2Controller.php +++ b/app/Access/Controllers/Saml2Controller.php @@ -78,7 +78,7 @@ public function sls() */ public function startAcs(Request $request) { - $samlResponse = $request->get('SAMLResponse', null); + $samlResponse = $request->input('SAMLResponse', null); if (empty($samlResponse)) { $this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')])); @@ -100,7 +100,7 @@ public function startAcs(Request $request) */ public function processAcs(Request $request) { - $acsId = $request->get('id', null); + $acsId = $request->input('id', null); $cacheKey = 'saml2_acs:' . $acsId; $samlResponse = null; diff --git a/app/Access/Controllers/SocialController.php b/app/Access/Controllers/SocialController.php index 07f57062d01..5a090c7ca2a 100644 --- a/app/Access/Controllers/SocialController.php +++ b/app/Access/Controllers/SocialController.php @@ -67,7 +67,7 @@ public function callback(Request $request, string $socialDriver) if ($request->has('error') && $request->has('error_description')) { throw new SocialSignInException(trans('errors.social_login_bad_response', [ 'socialAccount' => $socialDriver, - 'error' => $request->get('error_description'), + 'error' => $request->input('error_description'), ]), '/login'); } diff --git a/app/Access/Controllers/UserInviteController.php b/app/Access/Controllers/UserInviteController.php index 9ee05b84fa9..091b68e5594 100644 --- a/app/Access/Controllers/UserInviteController.php +++ b/app/Access/Controllers/UserInviteController.php @@ -67,7 +67,7 @@ public function setPassword(Request $request, string $token) } $user = $this->userRepo->getById($userId); - $user->password = Hash::make($request->get('password')); + $user->password = Hash::make($request->input('password')); $user->email_confirmed = true; $user->save(); diff --git a/app/Activity/Controllers/AuditLogController.php b/app/Activity/Controllers/AuditLogController.php index c4f9b91edb8..ed1421c0d01 100644 --- a/app/Activity/Controllers/AuditLogController.php +++ b/app/Activity/Controllers/AuditLogController.php @@ -17,19 +17,19 @@ public function index(Request $request) $this->checkPermission(Permission::SettingsManage); $this->checkPermission(Permission::UsersManage); - $sort = $request->get('sort', 'activity_date'); - $order = $request->get('order', 'desc'); + $sort = $request->input('sort', 'activity_date'); + $order = $request->input('order', 'desc'); $listOptions = (new SimpleListOptions('', $sort, $order))->withSortOptions([ 'created_at' => trans('settings.audit_table_date'), 'type' => trans('settings.audit_table_event'), ]); $filters = [ - 'event' => $request->get('event', ''), - 'date_from' => $request->get('date_from', ''), - 'date_to' => $request->get('date_to', ''), - 'user' => $request->get('user', ''), - 'ip' => $request->get('ip', ''), + 'event' => $request->input('event', ''), + 'date_from' => $request->input('date_from', ''), + 'date_to' => $request->input('date_to', ''), + 'user' => $request->input('user', ''), + 'ip' => $request->input('ip', ''), ]; $query = Activity::query() diff --git a/app/Activity/Controllers/FavouriteController.php b/app/Activity/Controllers/FavouriteController.php index deeb4b0afb4..65bae276d28 100644 --- a/app/Activity/Controllers/FavouriteController.php +++ b/app/Activity/Controllers/FavouriteController.php @@ -20,7 +20,7 @@ public function __construct( public function index(Request $request, QueryTopFavourites $topFavourites) { $viewCount = 20; - $page = intval($request->get('page', 1)); + $page = intval($request->input('page', 1)); $favourites = $topFavourites->run($viewCount + 1, (($page - 1) * $viewCount)); $hasMoreLink = ($favourites->count() > $viewCount) ? url('/favourites?page=' . ($page + 1)) : null; diff --git a/app/Activity/Controllers/TagController.php b/app/Activity/Controllers/TagController.php index 723dc4ab474..b57c798254a 100644 --- a/app/Activity/Controllers/TagController.php +++ b/app/Activity/Controllers/TagController.php @@ -46,7 +46,7 @@ public function index(Request $request) */ public function getNameSuggestions(Request $request) { - $searchTerm = $request->get('search', ''); + $searchTerm = $request->input('search', ''); $suggestions = $this->tagRepo->getNameSuggestions($searchTerm); return response()->json($suggestions); @@ -57,8 +57,8 @@ public function getNameSuggestions(Request $request) */ public function getValueSuggestions(Request $request) { - $searchTerm = $request->get('search', ''); - $tagName = $request->get('name', ''); + $searchTerm = $request->input('search', ''); + $tagName = $request->input('name', ''); $suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName); return response()->json($suggestions); diff --git a/app/Api/UserApiTokenController.php b/app/Api/UserApiTokenController.php index 2ca9e22352e..2894ede3aa5 100644 --- a/app/Api/UserApiTokenController.php +++ b/app/Api/UserApiTokenController.php @@ -48,11 +48,11 @@ public function store(Request $request, int $userId) $secret = Str::random(32); $token = (new ApiToken())->forceFill([ - 'name' => $request->get('name'), + 'name' => $request->input('name'), 'token_id' => Str::random(32), 'secret' => Hash::make($secret), 'user_id' => $user->id, - 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), + 'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(), ]); while (ApiToken::query()->where('token_id', '=', $token->token_id)->exists()) { @@ -100,8 +100,8 @@ public function update(Request $request, int $userId, int $tokenId) [$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId); $token->fill([ - 'name' => $request->get('name'), - 'expires_at' => $request->get('expires_at') ?: ApiToken::defaultExpiry(), + 'name' => $request->input('name'), + 'expires_at' => $request->input('expires_at') ?: ApiToken::defaultExpiry(), ])->save(); $this->logActivity(ActivityType::API_TOKEN_UPDATE, $token); diff --git a/app/Entities/Controllers/BookController.php b/app/Entities/Controllers/BookController.php index fca530f8adf..98470d91ce8 100644 --- a/app/Entities/Controllers/BookController.php +++ b/app/Entities/Controllers/BookController.php @@ -144,7 +144,7 @@ public function show(Request $request, ActivityQueries $activities, string $slug View::incrementFor($book); if ($request->has('shelf')) { - $this->shelfContext->setShelfContext(intval($request->get('shelf'))); + $this->shelfContext->setShelfContext(intval($request->input('shelf'))); } $this->setPageTitle($book->getShortName()); @@ -263,7 +263,7 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug) $this->checkOwnablePermission(Permission::BookView, $book); $this->checkPermission(Permission::BookCreateAll); - $newName = $request->get('name') ?: $book->name; + $newName = $request->input('name') ?: $book->name; $bookCopy = $cloner->cloneBook($book, $newName); $this->showSuccessNotification(trans('entities.books_copy_success')); diff --git a/app/Entities/Controllers/BookshelfApiController.php b/app/Entities/Controllers/BookshelfApiController.php index 735742060c5..e620eb59c29 100644 --- a/app/Entities/Controllers/BookshelfApiController.php +++ b/app/Entities/Controllers/BookshelfApiController.php @@ -49,7 +49,7 @@ public function create(Request $request) $this->checkPermission(Permission::BookshelfCreateAll); $requestData = $this->validate($request, $this->rules()['create']); - $bookIds = $request->get('books', []); + $bookIds = $request->input('books', []); $shelf = $this->bookshelfRepo->create($requestData, $bookIds); return response()->json($this->forJsonDisplay($shelf)); @@ -88,7 +88,7 @@ public function update(Request $request, string $id) $this->checkOwnablePermission(Permission::BookshelfUpdate, $shelf); $requestData = $this->validate($request, $this->rules()['update']); - $bookIds = $request->get('books', null); + $bookIds = $request->input('books', null); $shelf = $this->bookshelfRepo->update($shelf, $requestData, $bookIds); diff --git a/app/Entities/Controllers/BookshelfController.php b/app/Entities/Controllers/BookshelfController.php index f5f4a90bfe9..1e8b26b5156 100644 --- a/app/Entities/Controllers/BookshelfController.php +++ b/app/Entities/Controllers/BookshelfController.php @@ -94,7 +94,7 @@ public function store(Request $request) 'tags' => ['array'], ]); - $bookIds = explode(',', $request->get('books', '')); + $bookIds = explode(',', $request->input('books', '')); $shelf = $this->shelfRepo->create($validated, $bookIds); return redirect($shelf->getUrl()); @@ -196,7 +196,7 @@ public function update(Request $request, string $slug) unset($validated['image']); } - $bookIds = explode(',', $request->get('books', '')); + $bookIds = explode(',', $request->input('books', '')); $shelf = $this->shelfRepo->update($shelf, $validated, $bookIds); return redirect($shelf->getUrl()); diff --git a/app/Entities/Controllers/ChapterApiController.php b/app/Entities/Controllers/ChapterApiController.php index 6aa62f887c8..9e0c69b1776 100644 --- a/app/Entities/Controllers/ChapterApiController.php +++ b/app/Entities/Controllers/ChapterApiController.php @@ -64,7 +64,7 @@ public function create(Request $request) { $requestData = $this->validate($request, $this->rules['create']); - $bookId = $request->get('book_id'); + $bookId = $request->input('book_id'); $book = $this->entityQueries->books->findVisibleByIdOrFail(intval($bookId)); $this->checkOwnablePermission(Permission::ChapterCreate, $book); diff --git a/app/Entities/Controllers/ChapterController.php b/app/Entities/Controllers/ChapterController.php index 878ee42b5ae..db2391599ab 100644 --- a/app/Entities/Controllers/ChapterController.php +++ b/app/Entities/Controllers/ChapterController.php @@ -203,7 +203,7 @@ public function move(Request $request, string $bookSlug, string $chapterSlug) $this->checkOwnablePermission(Permission::ChapterUpdate, $chapter); $this->checkOwnablePermission(Permission::ChapterDelete, $chapter); - $entitySelection = $request->get('entity_selection', null); + $entitySelection = $request->input('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { return redirect($chapter->getUrl()); } @@ -248,7 +248,7 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string { $chapter = $this->queries->findVisibleBySlugsOrFail($bookSlug, $chapterSlug); - $entitySelection = $request->get('entity_selection') ?: null; + $entitySelection = $request->input('entity_selection') ?: null; $newParentBook = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $chapter->getParent(); if (!$newParentBook instanceof Book) { @@ -259,7 +259,7 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $this->checkOwnablePermission(Permission::ChapterCreate, $newParentBook); - $newName = $request->get('name') ?: $chapter->name; + $newName = $request->input('name') ?: $chapter->name; $chapterCopy = $cloner->cloneChapter($chapter, $newParentBook, $newName); $this->showSuccessNotification(trans('entities.chapters_copy_success')); diff --git a/app/Entities/Controllers/PageApiController.php b/app/Entities/Controllers/PageApiController.php index 197018ccafe..38042e67058 100644 --- a/app/Entities/Controllers/PageApiController.php +++ b/app/Entities/Controllers/PageApiController.php @@ -74,9 +74,9 @@ public function create(Request $request) $this->validate($request, $this->rules['create']); if ($request->has('chapter_id')) { - $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id'))); + $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id'))); } else { - $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id'))); + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id'))); } $this->checkOwnablePermission(Permission::PageCreate, $parent); @@ -133,9 +133,9 @@ public function update(Request $request, string $id) $parent = null; if ($request->has('chapter_id')) { - $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->get('chapter_id'))); + $parent = $this->entityQueries->chapters->findVisibleByIdOrFail(intval($request->input('chapter_id'))); } elseif ($request->has('book_id')) { - $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->get('book_id'))); + $parent = $this->entityQueries->books->findVisibleByIdOrFail(intval($request->input('book_id'))); } if ($parent && !$parent->matches($page->getParent())) { diff --git a/app/Entities/Controllers/PageController.php b/app/Entities/Controllers/PageController.php index 8778560e275..82edfbc2763 100644 --- a/app/Entities/Controllers/PageController.php +++ b/app/Entities/Controllers/PageController.php @@ -88,7 +88,7 @@ public function createAsGuest(Request $request, string $bookSlug, ?string $chapt $page = $this->pageRepo->getNewDraftPage($parent); $this->pageRepo->publishDraft($page, [ - 'name' => $request->get('name'), + 'name' => $request->input('name'), ]); return redirect($page->getUrl('/edit')); @@ -408,7 +408,7 @@ public function move(Request $request, string $bookSlug, string $pageSlug) $this->checkOwnablePermission(Permission::PageUpdate, $page); $this->checkOwnablePermission(Permission::PageDelete, $page); - $entitySelection = $request->get('entity_selection', null); + $entitySelection = $request->input('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { return redirect($page->getUrl()); } @@ -453,7 +453,7 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $page = $this->queries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageView, $page); - $entitySelection = $request->get('entity_selection') ?: null; + $entitySelection = $request->input('entity_selection') ?: null; $newParent = $entitySelection ? $this->entityQueries->findVisibleByStringIdentifier($entitySelection) : $page->getParent(); if (!$newParent instanceof Book && !$newParent instanceof Chapter) { @@ -464,7 +464,7 @@ public function copy(Request $request, Cloner $cloner, string $bookSlug, string $this->checkOwnablePermission(Permission::PageCreate, $newParent); - $newName = $request->get('name') ?: $page->name; + $newName = $request->input('name') ?: $page->name; $pageCopy = $cloner->clonePage($page, $newParent, $newName); $this->showSuccessNotification(trans('entities.pages_copy_success')); diff --git a/app/Entities/Controllers/PageTemplateController.php b/app/Entities/Controllers/PageTemplateController.php index c0b97214856..9ff2fe0293e 100644 --- a/app/Entities/Controllers/PageTemplateController.php +++ b/app/Entities/Controllers/PageTemplateController.php @@ -21,8 +21,8 @@ public function __construct( */ public function list(Request $request) { - $page = $request->get('page', 1); - $search = $request->get('search', ''); + $page = $request->input('page', 1); + $search = $request->input('search', ''); $count = 10; $query = $this->pageQueries->visibleTemplates() diff --git a/app/Entities/Tools/PermissionsUpdater.php b/app/Entities/Tools/PermissionsUpdater.php index f3165b603e5..5770d02f186 100644 --- a/app/Entities/Tools/PermissionsUpdater.php +++ b/app/Entities/Tools/PermissionsUpdater.php @@ -20,8 +20,8 @@ class PermissionsUpdater */ public function updateFromPermissionsForm(Entity $entity, Request $request): void { - $permissions = $request->get('permissions', null); - $ownerId = $request->get('owned_by', null); + $permissions = $request->input('permissions', null); + $ownerId = $request->input('owned_by', null); $entity->permissions()->delete(); diff --git a/app/Search/SearchApiController.php b/app/Search/SearchApiController.php index 5de7a511036..3ecb955ae37 100644 --- a/app/Search/SearchApiController.php +++ b/app/Search/SearchApiController.php @@ -40,9 +40,9 @@ public function all(Request $request): JsonResponse { $this->validate($request, $this->rules['all']); - $options = SearchOptions::fromString($request->get('query') ?? ''); - $page = intval($request->get('page', '0')) ?: 1; - $count = min(intval($request->get('count', '0')) ?: 20, 100); + $options = SearchOptions::fromString($request->input('query') ?? ''); + $page = intval($request->input('page', '0')) ?: 1; + $count = min(intval($request->input('count', '0')) ?: 20, 100); $results = $this->searchRunner->searchEntities($options, 'all', $page, $count); $this->resultsFormatter->format($results['results']->all(), $options); diff --git a/app/Search/SearchController.php b/app/Search/SearchController.php index 348d44a427f..50a73910afe 100644 --- a/app/Search/SearchController.php +++ b/app/Search/SearchController.php @@ -24,7 +24,7 @@ public function search(Request $request, SearchResultsFormatter $formatter) { $searchOpts = SearchOptions::fromRequest($request); $fullSearchString = $searchOpts->toString(); - $page = intval($request->get('page', '0')) ?: 1; + $page = intval($request->input('page', '0')) ?: 1; $count = setting()->getInteger('lists-page-count-search', 18, 1, 1000); $results = $this->searchRunner->searchEntities($searchOpts, 'all', $page, $count); @@ -49,7 +49,7 @@ public function search(Request $request, SearchResultsFormatter $formatter) */ public function searchBook(Request $request, int $bookId) { - $term = $request->get('term', ''); + $term = $request->input('term', ''); $results = $this->searchRunner->searchBook($bookId, $term); return view('entities.list', ['entities' => $results]); @@ -60,7 +60,7 @@ public function searchBook(Request $request, int $bookId) */ public function searchChapter(Request $request, int $chapterId) { - $term = $request->get('term', ''); + $term = $request->input('term', ''); $results = $this->searchRunner->searchChapter($chapterId, $term); return view('entities.list', ['entities' => $results]); @@ -72,9 +72,9 @@ public function searchChapter(Request $request, int $chapterId) */ public function searchForSelector(Request $request, QueryPopular $queryPopular) { - $entityTypes = $request->filled('types') ? explode(',', $request->get('types')) : ['page', 'chapter', 'book']; - $searchTerm = $request->get('term', false); - $permission = $request->get('permission', 'view'); + $entityTypes = $request->filled('types') ? explode(',', $request->input('types')) : ['page', 'chapter', 'book']; + $searchTerm = $request->input('term', false); + $permission = $request->input('permission', 'view'); // Search for entities otherwise show most popular if ($searchTerm !== false) { @@ -93,7 +93,7 @@ public function searchForSelector(Request $request, QueryPopular $queryPopular) */ public function templatesForSelector(Request $request) { - $searchTerm = $request->get('term', false); + $searchTerm = $request->input('term', false); if ($searchTerm !== false) { $searchOptions = SearchOptions::fromString($searchTerm); @@ -119,7 +119,7 @@ public function templatesForSelector(Request $request) */ public function searchSuggestions(Request $request) { - $searchTerm = $request->get('term', ''); + $searchTerm = $request->input('term', ''); $entities = $this->searchRunner->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 5)['results']; foreach ($entities as $entity) { @@ -136,8 +136,8 @@ public function searchSuggestions(Request $request) */ public function searchSiblings(Request $request, SiblingFetcher $siblingFetcher) { - $type = $request->get('entity_type', null); - $id = $request->get('entity_id', null); + $type = $request->input('entity_type', null); + $id = $request->input('entity_id', null); $entities = $siblingFetcher->fetch($type, $id); diff --git a/app/Search/SearchOptions.php b/app/Search/SearchOptions.php index cfd068386ef..f3eb58b6b04 100644 --- a/app/Search/SearchOptions.php +++ b/app/Search/SearchOptions.php @@ -51,7 +51,7 @@ public static function fromRequest(Request $request): self } if ($request->has('term')) { - return static::fromString($request->get('term')); + return static::fromString($request->input('term')); } $instance = new SearchOptions(); diff --git a/app/Settings/AppSettingsStore.php b/app/Settings/AppSettingsStore.php index e098d87f8e4..9c370ca272a 100644 --- a/app/Settings/AppSettingsStore.php +++ b/app/Settings/AppSettingsStore.php @@ -44,7 +44,7 @@ protected function updateAppIcon(Request $request): void } // Clear icon image if requested - if ($request->get('app_icon_reset')) { + if ($request->input('app_icon_reset')) { $this->destroyExistingSettingImage('app-icon'); setting()->remove('app-icon'); foreach ($sizes as $size) { @@ -67,7 +67,7 @@ protected function updateAppLogo(Request $request): void } // Clear logo image if requested - if ($request->get('app_logo_reset')) { + if ($request->input('app_logo_reset')) { $this->destroyExistingSettingImage('app-logo'); setting()->remove('app-logo'); } diff --git a/app/Settings/MaintenanceController.php b/app/Settings/MaintenanceController.php index b2b2226bf98..64e6c011187 100644 --- a/app/Settings/MaintenanceController.php +++ b/app/Settings/MaintenanceController.php @@ -38,7 +38,7 @@ public function cleanupImages(Request $request, ImageService $imageService) $this->checkPermission(Permission::SettingsManage); $this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'cleanup-images'); - $checkRevisions = !($request->get('ignore_revisions', 'false') === 'true'); + $checkRevisions = !($request->input('ignore_revisions', 'false') === 'true'); $dryRun = !($request->has('confirm')); $imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun); diff --git a/app/Sorting/BookSortController.php b/app/Sorting/BookSortController.php index 7e2ee5465df..4ddbb14bc70 100644 --- a/app/Sorting/BookSortController.php +++ b/app/Sorting/BookSortController.php @@ -58,7 +58,7 @@ public function update(Request $request, BookSorter $sorter, string $bookSlug) // Sort via map if ($request->filled('sort-tree')) { (new DatabaseTransaction(function () use ($book, $request, $sorter, &$loggedActivityForBook) { - $sortMap = BookSortMap::fromJson($request->get('sort-tree')); + $sortMap = BookSortMap::fromJson($request->input('sort-tree')); $booksInvolved = $sorter->sortUsingMap($sortMap); // Add activity for involved books. @@ -72,7 +72,7 @@ public function update(Request $request, BookSorter $sorter, string $bookSlug) } if ($request->filled('auto-sort')) { - $sortSetId = intval($request->get('auto-sort')) ?: null; + $sortSetId = intval($request->input('auto-sort')) ?: null; if ($sortSetId && SortRule::query()->find($sortSetId) === null) { $sortSetId = null; } diff --git a/app/Uploads/Controllers/AttachmentApiController.php b/app/Uploads/Controllers/AttachmentApiController.php index ea3c4a962b3..2448b79b5d5 100644 --- a/app/Uploads/Controllers/AttachmentApiController.php +++ b/app/Uploads/Controllers/AttachmentApiController.php @@ -50,7 +50,7 @@ public function create(Request $request) $this->checkPermission(Permission::AttachmentCreateAll); $requestData = $this->validate($request, $this->rules()['create']); - $pageId = $request->get('uploaded_to'); + $pageId = $request->input('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); @@ -134,7 +134,7 @@ public function update(Request $request, string $id) $page = $attachment->page; if ($requestData['uploaded_to'] ?? false) { - $pageId = $request->get('uploaded_to'); + $pageId = $request->input('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $attachment->uploaded_to = $requestData['uploaded_to']; } diff --git a/app/Uploads/Controllers/AttachmentController.php b/app/Uploads/Controllers/AttachmentController.php index 9c60fa415f8..edcf066acd6 100644 --- a/app/Uploads/Controllers/AttachmentController.php +++ b/app/Uploads/Controllers/AttachmentController.php @@ -39,7 +39,7 @@ public function upload(Request $request) 'file' => array_merge(['required'], $this->attachmentService->getFileValidationRules()), ]); - $pageId = $request->get('uploaded_to'); + $pageId = $request->input('uploaded_to'); $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkPermission(Permission::AttachmentCreateAll); @@ -125,8 +125,8 @@ public function update(Request $request, string $attachmentId) $this->checkOwnablePermission(Permission::AttachmentUpdate, $attachment); $attachment = $this->attachmentService->updateFile($attachment, [ - 'name' => $request->get('attachment_edit_name'), - 'link' => $request->get('attachment_edit_url'), + 'name' => $request->input('attachment_edit_name'), + 'link' => $request->input('attachment_edit_url'), ]); return view('attachments.manager-edit-form', [ @@ -141,7 +141,7 @@ public function update(Request $request, string $attachmentId) */ public function attachLink(Request $request) { - $pageId = $request->get('attachment_link_uploaded_to'); + $pageId = $request->input('attachment_link_uploaded_to'); try { $this->validate($request, [ @@ -161,8 +161,8 @@ public function attachLink(Request $request) $this->checkPermission(Permission::AttachmentCreateAll); $this->checkOwnablePermission(Permission::PageUpdate, $page); - $attachmentName = $request->get('attachment_link_name'); - $link = $request->get('attachment_link_url'); + $attachmentName = $request->input('attachment_link_name'); + $link = $request->input('attachment_link_url'); $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId)); return view('attachments.manager-link-form', [ @@ -198,7 +198,7 @@ public function sortForPage(Request $request, int $pageId) $page = $this->pageQueries->findVisibleByIdOrFail($pageId); $this->checkOwnablePermission(Permission::PageUpdate, $page); - $attachmentOrder = $request->get('order'); + $attachmentOrder = $request->input('order'); $this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId); return response()->json(['message' => trans('entities.attachments_order_updated')]); @@ -231,7 +231,7 @@ public function get(Request $request, string $attachmentId) $attachmentStream = $this->attachmentService->streamAttachmentFromStorage($attachment); $attachmentSize = $this->attachmentService->getAttachmentFileSize($attachment); - if ($request->get('open') === 'true') { + if ($request->input('open') === 'true') { return $this->download()->streamedInline($attachmentStream, $fileName, $attachmentSize); } diff --git a/app/Uploads/Controllers/DrawioImageController.php b/app/Uploads/Controllers/DrawioImageController.php index f44acd997d2..8295febc1c1 100644 --- a/app/Uploads/Controllers/DrawioImageController.php +++ b/app/Uploads/Controllers/DrawioImageController.php @@ -24,10 +24,10 @@ public function __construct( */ public function list(Request $request, ImageResizer $resizer) { - $page = $request->get('page', 1); - $searchTerm = $request->get('search', null); - $uploadedToFilter = $request->get('uploaded_to', null); - $parentTypeFilter = $request->get('filter_type', null); + $page = $request->input('page', 1); + $searchTerm = $request->input('search', null); + $uploadedToFilter = $request->input('uploaded_to', null); + $parentTypeFilter = $request->input('filter_type', null); $imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm); $viewData = [ @@ -59,10 +59,10 @@ public function create(Request $request) ]); $this->checkPermission(Permission::ImageCreateAll); - $imageBase64Data = $request->get('image'); + $imageBase64Data = $request->input('image'); try { - $uploadedTo = $request->get('uploaded_to', 0); + $uploadedTo = $request->input('uploaded_to', 0); $image = $this->imageRepo->saveDrawing($imageBase64Data, $uploadedTo); } catch (ImageUploadException $e) { return response($e->getMessage(), 500); diff --git a/app/Uploads/Controllers/GalleryImageController.php b/app/Uploads/Controllers/GalleryImageController.php index 745efcde812..908322be07f 100644 --- a/app/Uploads/Controllers/GalleryImageController.php +++ b/app/Uploads/Controllers/GalleryImageController.php @@ -24,10 +24,10 @@ public function __construct( */ public function list(Request $request, ImageResizer $resizer) { - $page = $request->get('page', 1); - $searchTerm = $request->get('search', null); - $uploadedToFilter = $request->get('uploaded_to', null); - $parentTypeFilter = $request->get('filter_type', null); + $page = $request->input('page', 1); + $searchTerm = $request->input('search', null); + $uploadedToFilter = $request->input('uploaded_to', null); + $parentTypeFilter = $request->input('filter_type', null); $imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm); $viewData = [ @@ -69,7 +69,7 @@ public function create(Request $request) try { $imageUpload = $request->file('file'); - $uploadedTo = $request->get('uploaded_to', 0); + $uploadedTo = $request->input('uploaded_to', 0); $image = $this->imageRepo->saveNew($imageUpload, 'gallery', $uploadedTo); } catch (ImageUploadException $e) { return response($e->getMessage(), 500); diff --git a/app/Users/Controllers/RoleController.php b/app/Users/Controllers/RoleController.php index 549f6e0ac8f..b9f06dace84 100644 --- a/app/Users/Controllers/RoleController.php +++ b/app/Users/Controllers/RoleController.php @@ -55,7 +55,7 @@ public function create(Request $request) /** @var ?Role $role */ $role = null; if ($request->has('copy_from')) { - $role = Role::query()->find($request->get('copy_from')); + $role = Role::query()->find($request->input('copy_from')); } if ($role) { @@ -150,7 +150,7 @@ public function delete(Request $request, string $id) $this->checkPermission(Permission::UserRolesManage); try { - $migrateRoleId = intval($request->get('migrate_role_id') ?: "0"); + $migrateRoleId = intval($request->input('migrate_role_id') ?: "0"); $this->permissionsRepo->deleteRole($id, $migrateRoleId); } catch (PermissionsException $e) { $this->showErrorNotification($e->getMessage()); diff --git a/app/Users/Controllers/UserAccountController.php b/app/Users/Controllers/UserAccountController.php index a8baba5294b..21816d5b89b 100644 --- a/app/Users/Controllers/UserAccountController.php +++ b/app/Users/Controllers/UserAccountController.php @@ -106,8 +106,8 @@ public function showShortcuts() */ public function updateShortcuts(Request $request) { - $enabled = $request->get('enabled') === 'true'; - $providedShortcuts = $request->get('shortcut', []); + $enabled = $request->input('enabled') === 'true'; + $providedShortcuts = $request->input('shortcut', []); $shortcuts = new UserShortcutMap($providedShortcuts); setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson()); @@ -218,7 +218,7 @@ public function destroy(Request $request) { $this->preventAccessInDemoMode(); - $requestNewOwnerId = intval($request->get('new_owner_id')) ?: null; + $requestNewOwnerId = intval($request->input('new_owner_id')) ?: null; $newOwnerId = userCan(Permission::UsersManage) ? $requestNewOwnerId : null; $this->userRepo->destroy(user(), $newOwnerId); diff --git a/app/Users/Controllers/UserApiController.php b/app/Users/Controllers/UserApiController.php index 25753280f17..ebc17e262f3 100644 --- a/app/Users/Controllers/UserApiController.php +++ b/app/Users/Controllers/UserApiController.php @@ -141,7 +141,7 @@ public function update(Request $request, string $id) public function delete(Request $request, string $id) { $user = $this->userRepo->getById($id); - $newOwnerId = $request->get('migrate_ownership_id', null); + $newOwnerId = $request->input('migrate_ownership_id', null); $this->userRepo->destroy($user, $newOwnerId); diff --git a/app/Users/Controllers/UserController.php b/app/Users/Controllers/UserController.php index 494221b143e..f93c00a89c2 100644 --- a/app/Users/Controllers/UserController.php +++ b/app/Users/Controllers/UserController.php @@ -77,7 +77,7 @@ public function store(Request $request) $this->checkPermission(Permission::UsersManage); $authMethod = config('auth.method'); - $sendInvite = ($request->get('send_invite', 'false') === 'true'); + $sendInvite = ($request->input('send_invite', 'false') === 'true'); $externalAuth = $authMethod === 'ldap' || $authMethod === 'saml2' || $authMethod === 'oidc'; $passwordRequired = ($authMethod === 'standard' && !$sendInvite); @@ -202,7 +202,7 @@ public function destroy(Request $request, int $id) $this->checkPermission(Permission::UsersManage); $user = $this->userRepo->getById($id); - $newOwnerId = intval($request->get('new_owner_id')) ?: null; + $newOwnerId = intval($request->input('new_owner_id')) ?: null; $this->userRepo->destroy($user, $newOwnerId); diff --git a/app/Users/Controllers/UserPreferencesController.php b/app/Users/Controllers/UserPreferencesController.php index 0bed2d22a43..f4a56b7bf0e 100644 --- a/app/Users/Controllers/UserPreferencesController.php +++ b/app/Users/Controllers/UserPreferencesController.php @@ -23,7 +23,7 @@ public function changeView(Request $request, string $type) return $this->redirectToRequest($request); } - $view = $request->get('view'); + $view = $request->input('view'); if (!in_array($view, ['grid', 'list'])) { $view = 'list'; } @@ -44,8 +44,8 @@ public function changeSort(Request $request, string $type) return $this->redirectToRequest($request); } - $sort = substr($request->get('sort') ?: 'name', 0, 50); - $order = $request->get('order') === 'desc' ? 'desc' : 'asc'; + $sort = substr($request->input('sort') ?: 'name', 0, 50); + $order = $request->input('order') === 'desc' ? 'desc' : 'asc'; $sortKey = $type . '_sort'; $orderKey = $type . '_sort_order'; @@ -76,7 +76,7 @@ public function changeExpansion(Request $request, string $type) return response('Invalid key', 500); } - $newState = $request->get('expand', 'false'); + $newState = $request->input('expand', 'false'); setting()->putForCurrentUser('section_expansion#' . $type, $newState); return response('', 204); diff --git a/app/Users/Controllers/UserSearchController.php b/app/Users/Controllers/UserSearchController.php index bc0543cab16..9734255e7e0 100644 --- a/app/Users/Controllers/UserSearchController.php +++ b/app/Users/Controllers/UserSearchController.php @@ -26,7 +26,7 @@ public function forSelect(Request $request) $this->showPermissionError(); } - $search = $request->get('search', ''); + $search = $request->input('search', ''); $query = User::query() ->orderBy('name', 'asc') ->take(20); @@ -58,7 +58,7 @@ public function forMentions(Request $request) $this->showPermissionError(); } - $search = $request->get('search', ''); + $search = $request->input('search', ''); $query = User::query() ->orderBy('name', 'asc') ->take(20); diff --git a/app/Util/SimpleListOptions.php b/app/Util/SimpleListOptions.php index 81d8a587636..9fb1b98ae8f 100644 --- a/app/Util/SimpleListOptions.php +++ b/app/Util/SimpleListOptions.php @@ -30,7 +30,7 @@ public function __construct(string $typeKey, string $sort, string $order, string */ public static function fromRequest(Request $request, string $typeKey, bool $sortDescDefault = false): self { - $search = $request->get('search', ''); + $search = $request->input('search', ''); $sort = setting()->getForCurrentUser($typeKey . '_sort', ''); $order = setting()->getForCurrentUser($typeKey . '_sort_order', $sortDescDefault ? 'desc' : 'asc'); From befa3a8fbb23cb8026fc646b9c8e8617e095b91e Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 12:41:11 +0100 Subject: [PATCH 12/16] Permissions: Started addition of revision-view permission --- .../Controllers/PageRevisionController.php | 7 ++++++ app/Permissions/Permission.php | 2 ++ lang/en/settings.php | 1 + resources/views/entities/meta.blade.php | 2 +- .../show-sidebar-section-actions.blade.php | 10 +++++---- .../views/settings/roles/parts/form.blade.php | 1 + .../parts/revisions-permissions-row.blade.php | 22 +++++++++++++++++++ 7 files changed, 40 insertions(+), 5 deletions(-) create mode 100644 resources/views/settings/roles/parts/revisions-permissions-row.blade.php diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 4bc15e6e967..0d690cb2c33 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -34,6 +34,7 @@ public function __construct( */ public function index(Request $request, string $bookSlug, string $pageSlug) { + $this->checkPermission(Permission::RevisionViewAll); $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $listOptions = SimpleListOptions::fromRequest($request, 'page_revisions', true)->withSortOptions([ 'id' => trans('entities.pages_revisions_sort_number') @@ -65,6 +66,8 @@ public function index(Request $request, string $bookSlug, string $pageSlug) */ public function show(string $bookSlug, string $pageSlug, int $revisionId) { + $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); /** @var ?PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); @@ -94,6 +97,8 @@ public function show(string $bookSlug, string $pageSlug, int $revisionId) */ public function changes(string $bookSlug, string $pageSlug, int $revisionId) { + $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); /** @var ?PageRevision $revision */ $revision = $page->revisions()->where('id', '=', $revisionId)->first(); @@ -130,6 +135,7 @@ public function changes(string $bookSlug, string $pageSlug, int $revisionId) public function restore(string $bookSlug, string $pageSlug, int $revisionId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkPermission(Permission::RevisionViewAll); $this->checkOwnablePermission(Permission::PageUpdate, $page); $page = $this->pageRepo->restoreRevision($page, $revisionId); @@ -145,6 +151,7 @@ public function restore(string $bookSlug, string $pageSlug, int $revisionId) public function destroy(string $bookSlug, string $pageSlug, int $revId) { $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); + $this->checkPermission(Permission::RevisionViewAll); $this->checkOwnablePermission(Permission::PageDelete, $page); $revision = $page->revisions()->where('id', '=', $revId)->first(); diff --git a/app/Permissions/Permission.php b/app/Permissions/Permission.php index 04878ada01f..0fbe9693dcb 100644 --- a/app/Permissions/Permission.php +++ b/app/Permissions/Permission.php @@ -118,6 +118,8 @@ enum Permission: string case PageViewAll = 'page-view-all'; case PageViewOwn = 'page-view-own'; + case RevisionViewAll = 'revision-view-all'; + /** * Get the generic permissions which may be queried for entities. */ diff --git a/lang/en/settings.php b/lang/en/settings.php index c4d1eb136eb..3937c650f86 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -207,6 +207,7 @@ 'role_all' => 'All', 'role_own' => 'Own', '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', 'role_users' => 'Users in this role', 'role_users_none' => 'No users are currently assigned to this role', diff --git a/resources/views/entities/meta.blade.php b/resources/views/entities/meta.blade.php index 060c197a466..6c425a2401b 100644 --- a/resources/views/entities/meta.blade.php +++ b/resources/views/entities/meta.blade.php @@ -9,7 +9,7 @@
    @endif - @if ($entity->isA('page')) + @if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll)) @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} diff --git a/resources/views/pages/parts/show-sidebar-section-actions.blade.php b/resources/views/pages/parts/show-sidebar-section-actions.blade.php index ae115b69e23..94061ecb3a8 100644 --- a/resources/views/pages/parts/show-sidebar-section-actions.blade.php +++ b/resources/views/pages/parts/show-sidebar-section-actions.blade.php @@ -24,10 +24,12 @@ @endif @endif - - @icon('history') - {{ trans('entities.revisions') }} - + @if(userCan(\BookStack\Permissions\Permission::RevisionViewAll)) + + @icon('history') + {{ trans('entities.revisions') }} + + @endif @if(userCan(\BookStack\Permissions\Permission::RestrictionsManage, $page)) @icon('lock') diff --git a/resources/views/settings/roles/parts/form.blade.php b/resources/views/settings/roles/parts/form.blade.php index 5a9eca7d2cd..890f790574e 100644 --- a/resources/views/settings/roles/parts/form.blade.php +++ b/resources/views/settings/roles/parts/form.blade.php @@ -79,6 +79,7 @@ class="item-list toggle-switch-list"> @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.books'), 'permissionPrefix' => 'book']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.chapters'), 'permissionPrefix' => 'chapter']) @include('settings.roles.parts.asset-permissions-row', ['title' => trans('entities.pages'), 'permissionPrefix' => 'page']) + @include('settings.roles.parts.revisions-permissions-row', ['title' => trans('entities.revisions'), 'permissionPrefix' => 'revision']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.images'), 'permissionPrefix' => 'image']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.attachments'), 'permissionPrefix' => 'attachment']) @include('settings.roles.parts.related-asset-permissions-row', ['title' => trans('entities.comments'), 'permissionPrefix' => 'comment']) diff --git a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php new file mode 100644 index 00000000000..fe886a5d0e1 --- /dev/null +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -0,0 +1,22 @@ +
    + +
    + {{ trans('common.create') }}
    + - +
    +
    + {{ trans('common.view') }}
    + @include('settings.roles.parts.checkbox', ['permission' => $permissionPrefix . '-view-all', 'label' => trans('settings.role_all')]) +
    +
    + {{ trans('common.edit') }}
    + - +
    +
    + {{ trans('common.delete') }}
    + {{ trans('settings.role_controlled_by_page_delete') }} +
    +
    \ No newline at end of file From 1339f668ebfd0155c15c122f4257e435d23f9a11 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 15:32:10 +0100 Subject: [PATCH 13/16] Permissions: Added revision-view-all addition migration --- ...41616_add_revision_view_all_permission.php | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) create mode 100644 database/migrations/2026_04_19_141616_add_revision_view_all_permission.php diff --git a/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php new file mode 100644 index 00000000000..5a0b9a09b40 --- /dev/null +++ b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php @@ -0,0 +1,67 @@ +insertGetId([ + 'name' => 'revision-view-all', + 'created_at' => Carbon::now()->toDateTimeString(), + 'updated_at' => Carbon::now()->toDateTimeString(), + ]); + + // Get ids of page view permissions + $pageViewPermissions = DB::table('role_permissions') + ->whereIn('name', [ + 'page-view-own', + 'page-view-all', + ])->get(); + + if (!$pageViewPermissions->count() === 0) { + return; + } + + // Get role ids which have page view permission + $applicableRoleIds = DB::table('permission_role') + ->whereIn('permission_id', $pageViewPermissions->pluck('id')) + ->pluck('role_id') + ->unique() + ->all(); + + // Assign the new permission to relevant roles + $newPermissionRoles = array_values(array_map(function (int $roleId) use ($permissionId) { + return [ + 'role_id' => $roleId, + 'permission_id' => $permissionId, + ]; + }, $applicableRoleIds)); + + DB::table('permission_role')->insert($newPermissionRoles); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + // Get the permission to remove + $revisionViewPermission = DB::table('role_permissions') + ->where('name', '=', 'revision-view-all') + ->first(); + + if (!$revisionViewPermission) { + return; + } + + // Remove the permission, and its use on roles, from the database + DB::table('permission_role')->where('permission_id', '=', $revisionViewPermission->id)->delete(); + DB::table('role_permissions')->where('id', '=', $revisionViewPermission->id)->delete(); + } +}; From e7e019d3d44b263031d4a63f91e4c6bdf3b424eb Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 15:56:54 +0100 Subject: [PATCH 14/16] Permissions: Added testing coverage for revision-view-all --- tests/Entity/PageRevisionTest.php | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/tests/Entity/PageRevisionTest.php b/tests/Entity/PageRevisionTest.php index 132a10fa4da..8b46e84a634 100644 --- a/tests/Entity/PageRevisionTest.php +++ b/tests/Entity/PageRevisionTest.php @@ -4,6 +4,8 @@ use BookStack\Activity\ActivityType; use BookStack\Entities\Models\Page; +use BookStack\Entities\Models\PageRevision; +use BookStack\Permissions\Permission; use Tests\TestCase; class PageRevisionTest extends TestCase @@ -257,6 +259,33 @@ public function test_revision_changes_view_filters_html_content() $revisionView->assertDontSee('dontwantthishere'); } + public function test_access_to_revision_operation_requires_revision_view_all_permission() + { + $editor = $this->users->editor(); + $this->actingAs($editor); + + $page = $this->entities->page(); + $this->createRevisions($page, 3); + /** @var PageRevision $revision */ + $revision = $page->revisions()->orderBy('id', 'desc')->first(); + + $this->get($page->getUrl())->assertSee($page->getUrl('/revisions'), false); + $this->get($page->getUrl('/revisions'))->assertOk(); + $this->get($revision->getUrl())->assertOk(); + $this->get($revision->getUrl('/changes'))->assertOk(); + $this->put($revision->getUrl('/restore'))->assertRedirect($page->getUrl()); + $this->delete($revision->getUrl('/delete'))->assertRedirect($page->getUrl('/revisions')); + + $this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]); + + $this->get($page->getUrl())->assertDontSee($page->getUrl('/revisions'), false); + $this->assertPermissionError($this->get($page->getUrl('/revisions'))); + $this->assertPermissionError($this->get($revision->getUrl())); + $this->assertPermissionError($this->get($revision->getUrl('/changes'))); + $this->assertPermissionError($this->put($revision->getUrl('/restore'))); + $this->assertPermissionError($this->delete($revision->getUrl('/delete'))); + } + public function test_revision_restore_action_only_visible_with_permission() { $page = $this->entities->page(); From ec0b0384a20f10a5ec44197a3fd5ca8f9fc543aa Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 16:06:31 +0100 Subject: [PATCH 15/16] Permissions: Tweaks/fixed during review of revision-view-all changes --- app/Entities/Controllers/PageRevisionController.php | 4 ++-- .../2026_04_19_141616_add_revision_view_all_permission.php | 2 +- .../settings/roles/parts/revisions-permissions-row.blade.php | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/app/Entities/Controllers/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 0d690cb2c33..cc6b79bfe45 100644 --- a/app/Entities/Controllers/PageRevisionController.php +++ b/app/Entities/Controllers/PageRevisionController.php @@ -134,8 +134,8 @@ public function changes(string $bookSlug, string $pageSlug, int $revisionId) */ public function restore(string $bookSlug, string $pageSlug, int $revisionId) { - $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page); $page = $this->pageRepo->restoreRevision($page, $revisionId); @@ -150,8 +150,8 @@ public function restore(string $bookSlug, string $pageSlug, int $revisionId) */ public function destroy(string $bookSlug, string $pageSlug, int $revId) { - $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkPermission(Permission::RevisionViewAll); + $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageDelete, $page); $revision = $page->revisions()->where('id', '=', $revId)->first(); diff --git a/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php index 5a0b9a09b40..e4b51ff7026 100644 --- a/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php +++ b/database/migrations/2026_04_19_141616_add_revision_view_all_permission.php @@ -24,7 +24,7 @@ public function up(): void 'page-view-all', ])->get(); - if (!$pageViewPermissions->count() === 0) { + if ($pageViewPermissions->count() === 0) { return; } 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 fe886a5d0e1..326925ef93c 100644 --- a/resources/views/settings/roles/parts/revisions-permissions-row.blade.php +++ b/resources/views/settings/roles/parts/revisions-permissions-row.blade.php @@ -19,4 +19,4 @@ {{ trans('common.delete') }}
    {{ trans('settings.role_controlled_by_page_delete') }} - \ No newline at end of file + From 426f9ac4934308da9f57580bc0e2fe399346cbb1 Mon Sep 17 00:00:00 2001 From: Dan Brown Date: Sun, 19 Apr 2026 16:23:16 +0100 Subject: [PATCH 16/16] Permissions: Prevent export revision metadata view without permission --- resources/views/exports/parts/meta.blade.php | 2 +- tests/Exports/HtmlExportTest.php | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/resources/views/exports/parts/meta.blade.php b/resources/views/exports/parts/meta.blade.php index 00117f4a157..07eff14a470 100644 --- a/resources/views/exports/parts/meta.blade.php +++ b/resources/views/exports/parts/meta.blade.php @@ -1,5 +1,5 @@
    - @if ($entity->isA('page')) + @if ($entity->isA('page') && userCan(\BookStack\Permissions\Permission::RevisionViewAll)) @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }}
    @endif diff --git a/tests/Exports/HtmlExportTest.php b/tests/Exports/HtmlExportTest.php index f23352e0eb9..223a8c92285 100644 --- a/tests/Exports/HtmlExportTest.php +++ b/tests/Exports/HtmlExportTest.php @@ -5,6 +5,7 @@ use BookStack\Entities\Models\Book; use BookStack\Entities\Models\Chapter; use BookStack\Entities\Models\Page; +use BookStack\Permissions\Permission; use Illuminate\Support\Facades\Storage; use Tests\TestCase; @@ -229,6 +230,20 @@ public function test_page_export_with_deleted_creator_and_updater() $resp->assertDontSee('ExportWizardTheFifth'); } + public function test_page_export_only_includes_revision_count_if_user_has_revision_view_permissions() + { + $editor = $this->users->editor(); + $page = $this->entities->page(); + + $resp = $this->actingAs($editor)->get($page->getUrl('/export/html')); + $resp->assertSee('Revision #'); + + $this->permissions->removeUserRolePermissions($editor, [Permission::RevisionViewAll]); + + $resp = $this->actingAs($editor)->get($page->getUrl('/export/html')); + $resp->assertDontSee('Revision #'); + } + public function test_html_exports_contain_csp_meta_tag() { $entities = [