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/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/TagApiController.php b/app/Activity/Controllers/TagApiController.php new file mode 100644 index 00000000000..f5c5e95d420 --- /dev/null +++ b/app/Activity/Controllers/TagApiController.php @@ -0,0 +1,68 @@ + [ + 'name' => ['required', 'string'], + ], + ]; + } + + /** + * Get a list of tag names used in the system. + * Only the name field can be used in filters. + */ + public function listNames(): JsonResponse + { + $tagQuery = $this->tagRepo + ->queryWithTotalsForApi(''); + + return $this->apiListingResponse($tagQuery, [ + 'name', 'values', 'usages', 'page_count', 'chapter_count', 'book_count', 'shelf_count', + ], [], [ + '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(Request $request): JsonResponse + { + $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', + ], [], [ + 'value', + ]); + } +} diff --git a/app/Activity/Controllers/TagController.php b/app/Activity/Controllers/TagController.php index 0af8835ca77..b57c798254a 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, @@ -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/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/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/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/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/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/PageRevisionController.php b/app/Entities/Controllers/PageRevisionController.php index 4bc15e6e967..cc6b79bfe45 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(); @@ -129,6 +134,7 @@ public function changes(string $bookSlug, string $pageSlug, int $revisionId) */ public function restore(string $bookSlug, string $pageSlug, int $revisionId) { + $this->checkPermission(Permission::RevisionViewAll); $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageUpdate, $page); @@ -144,6 +150,7 @@ public function restore(string $bookSlug, string $pageSlug, int $revisionId) */ public function destroy(string $bookSlug, string $pageSlug, int $revId) { + $this->checkPermission(Permission::RevisionViewAll); $page = $this->pageQueries->findVisibleBySlugsOrFail($bookSlug, $pageSlug); $this->checkOwnablePermission(Permission::PageDelete, $page); 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/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 6a764990079..9fb4596f5a0 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/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/Exports/ExportFormatter.php b/app/Exports/ExportFormatter.php index 6bf0a05add9..61b7554f659 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/app/Exports/ZipExports/Models/ZipExportAttachment.php b/app/Exports/ZipExports/Models/ZipExportAttachment.php index 97995738ffe..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'], + 'link' => ['required_without:file', 'nullable', 'string', 'max:2000', 'safe_url'], 'file' => ['required_without:link', 'nullable', 'string', $context->fileReferenceRule()], ]; 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/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/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/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/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/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'); 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..e4b51ff7026 --- /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(); + } +}; 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/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/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/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 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/resources/sass/export-styles.scss b/resources/sass/export-styles.scss index 8dd7be375e8..36901c0f122 100644 --- a/resources/sass/export-styles.scss +++ b/resources/sass/export-styles.scss @@ -12,12 +12,16 @@ html, body { } body { - font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; margin: 0; padding: 0; display: block; } +// Set fonts to common system fonts, starting with DejaVu Sans due to support in DOMPDF +body, h1, h2, h3, h4, h5, h6 { + font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif; +} + table { border-spacing: 0; border-collapse: collapse; @@ -100,4 +104,4 @@ body.export-format-pdf.export-engine-dompdf { .page-content td a > img { max-width: 100%; } -} \ 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: 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/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/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..326925ef93c --- /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') }} +
+
diff --git a/routes/api.php b/routes/api.php index 308a95d8c28..5a9df3cc422 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/values-for-name', [TagApiController::class, 'listValues']); + Route::get('users', [UserApiController::class, 'list']); Route::post('users', [UserApiController::class, 'create']); Route::get('users/{id}', [UserApiController::class, 'read']); diff --git a/tests/Api/TagsApiTest.php b/tests/Api/TagsApiTest.php new file mode 100644 index 00000000000..a079fa63915 --- /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/values-for-name?name=MyValueApiTag'); + + $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/values-for-name?name=MyGreatApiTag'); + $resp->assertStatus(200); + $resp->assertJson(['total' => 8]); + $resp->assertJsonMissing(['value' => 'cat' . $pagesToTag[3]->id]); + } +} 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, 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(); 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 = [ 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); } } 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); + } + } } 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

+
    +
  • Item 1
  • +
  • Item 2
  • +
+

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); + } +}