Compare commits

...

125 Commits

Author SHA1 Message Date
Dan Brown
7e44b195c5 Updated version and assets for release v23.10 2023-10-30 12:15:59 +00:00
Dan Brown
5b45eac5e1 Merge branch 'development' into release 2023-10-30 12:14:23 +00:00
Dan Brown
49b286cd34 Demo mode: Updated my account access to be more selective 2023-10-30 12:07:18 +00:00
Dan Brown
e006f9674f Langs: Updated translators and locale list pre v23.10 2023-10-30 11:41:36 +00:00
Dan Brown
8bffcebd64 Updated translations with latest Crowdin changes (#4523) 2023-10-30 11:16:19 +00:00
Dan Brown
7c4dc981cd Middlware: Prevented caching of all app requests
Previously we'd prevent caching of authed responses for security
(prevent back cache or proxy caching) but caching could still be an
issue in non-auth scenarios due to CSRF (eg. returning to login screen after
session expiry).

For #4600
2023-10-23 13:32:15 +01:00
Dan Brown
9b4f1fb981 Styles: Aligned empty state alignment & consistency
- Fixed inital empty state margins/paddings to be aligned and not differ
when lists are empty.
- Aligned button/action display when viewing empty entities.
- Fixed use of non-existing permission in books for book empty state
  button.

Fixes #4563
2023-10-23 11:53:19 +01:00
Dan Brown
d42af4affc Shortcuts: Prevented help shown when in inputs
For #4606
2023-10-23 11:04:09 +01:00
Dan Brown
8375d341ea Deps: Updated npm and composer packages 2023-10-20 16:39:40 +01:00
Dan Brown
f5756ff28a Security: Swapped twitter for mastodon link 2023-10-19 16:53:02 +01:00
Dan Brown
c513cdaebe Merge pull request #4618 from radiantwave/patch-1
Remove huntr from SECURITY.md
2023-10-19 16:50:30 +01:00
Dan Brown
995b7d61e9 Merge pull request #4615 from BookStackApp/user_account
User preferences/options cleanup
2023-10-19 16:49:06 +01:00
Dan Brown
02bfaffeb4 My Acount: Updated old preference url reference for watches 2023-10-19 16:37:55 +01:00
Daniel
38fe40809b Update SECURITY.md
Remove huntr
2023-10-19 17:12:18 +02:00
Dan Brown
ce53f641ad My Account: Covered profile and auth pages with tests 2023-10-19 16:06:59 +01:00
Dan Brown
f55e7ca3c9 User Account: Ensured page titles for pages and api tokens 2023-10-19 15:24:48 +01:00
Dan Brown
fabc854390 My Account: Updated and started adding to tests
- Updated existing tests now affected by my-account changes.
- Updated some existing tests to more accuractly check the scenario.
- Updated some code styling in SocialController.
- Fixed redirects for social account flows to fit my-account.
- Added test for social account attaching.
- Added test for api token redirect handling.
2023-10-19 14:18:42 +01:00
Dan Brown
12946414b0 API Tokens: Updated interfaces to return to correct location
Since management of API tokens can be accessed via two routes, this adds
tracking and handling to reutrn the user to the correct place.
2023-10-19 11:31:45 +01:00
Dan Brown
f9422dff18 My Account: Added self-delete flow 2023-10-19 10:48:27 +01:00
Dan Brown
cf72e48d2a User form: Always show external auth field, update access control
Updated old user management routes to only be accessible with permission
to manage users, so also removed old content controls checking for that
permission.
2023-10-19 10:20:04 +01:00
Dan Brown
e4ea73ee25 My Account: Cleaned-up/reorganised user header dropdown 2023-10-18 17:57:14 +01:00
Dan Brown
03c44b3992 My Account: Extracted/tweaked profile text, removed old index 2023-10-18 17:53:58 +01:00
Dan Brown
c1b01639c1 My Account: Built out profile page & endpoints
Text currently hard-coded, needs finalising and extracting.
2023-10-18 12:39:57 +01:00
Dan Brown
a868012048 Users: Built out auth page for my-account section 2023-10-17 17:38:07 +01:00
Dan Brown
a9d0f36766 User: Started cleanup of user self-management
- Moved preference views to more general "my-account" area.
- Started new layout for my-account with sidebar.
- Added MFA to prefeences view (to be moved).
2023-10-17 13:11:10 +01:00
Dan Brown
3274181e14 Merge pull request #4604 from BookStackApp/editor_trim_enhancement
Editor design update
2023-10-14 17:30:04 +01:00
Dan Brown
8166e27f2b Editors: Properly aligned edit area border radius 2023-10-14 17:18:09 +01:00
Dan Brown
8ffa436f3d Editors: Adjusted new design for mobile and dark mode
Tested new design across FF, Chrome, and Gnome web (webkit)
2023-10-14 17:10:29 +01:00
Dan Brown
8c10959339 Editors: Tightened up new design, adjusted for MD editor 2023-10-14 16:33:48 +01:00
Dan Brown
45c7409092 Editor: Started toying with more singificant design update 2023-10-13 17:33:11 +01:00
Dan Brown
a12b60e1ad Editor: Started attempts to improve design elements 2023-10-08 15:04:07 +01:00
Dan Brown
ccb3c2516a Homepage: Made much nicer at ipad-like widths
Updated default homepage layout to be much nicer at ipad-like widths by
switching to css-column approach at those breakpoints.
Also neated top actions by switching to simpler flexbox layout.

Fixes #4596
2023-10-07 12:38:54 +01:00
Dan Brown
2e2272343b Merge branch 'LawssssCat/development' into development 2023-10-06 12:03:38 +01:00
Dan Brown
031067745b Layout: Restructured tri-layout for sidebar control
Restructured tri-layout grid system, so the sidebars are contained in
their own child grid system, mimicking the parent grid, so we can treat
them as part of the same parent scroll container at smaller screen
sizes for consistent scroll/sticky behavior.

Tested on Firefox, Chromium, Gnome Web and Safari (MacOS).

For #4394
Changes made during review of #4562
2023-10-06 12:03:38 +01:00
Dan Brown
1267068d9c CI: Added path filtering to actions
In the hope we can make the CI runs a bit more efficient and energy
conscious, by only running when relevant files have changed.
2023-10-04 09:18:24 +01:00
Dan Brown
0241032f06 Tags: Fixed enter press clearing field
For #4570
2023-10-04 09:08:10 +01:00
Dan Brown
bd7c7eb8d6 Print Styles: Removed use of seperate style sheet
Seemed a bit redundant and complicated, since we're only adding a few
extra styles. Just merged into main styles instead.
2023-10-03 15:14:21 +01:00
Dan Brown
c5d5b6e3c1 Print Styles: Fixed header/footer content showing
Extra bits were showing due to recent changes.
Done a quick pass through major display views.

Fixes #4594
2023-10-03 14:59:35 +01:00
Dan Brown
1005f4bd7a Testing: Added favicon cleanup
Was leaving a changed favicon leaving other test to fail.
2023-10-03 14:50:54 +01:00
Dan Brown
b24296e0c9 Added NETWAYS to readme sponsor list
Related to:
edceda5342
2023-10-03 12:10:45 +01:00
Dan Brown
d1f28ed245 Merge branch 'basic-pwa-support' into development 2023-10-02 15:58:07 +01:00
Dan Brown
1d91b4d8a6 PWA Manifest: Tweaks during review of PR #4430
- Updated to go through HomeController with the builder as a helper
  class.
- Extracted some reapeated items into variables in manifest.
- Updated background color to match those used by BookStack.
- Removed reference of icon.ico since its not intended to be used.
- Added tests to cover functionality.

Review of #4430
2023-10-02 15:54:39 +01:00
Dan Brown
8bba5dd5a0 Merge pull request #4578 from BookStackApp/upload_handling
Improvements to file/image upload handling UX
2023-10-01 17:20:10 +00:00
Dan Brown
ffb04a8be6 JS: Fixed ESLint issues 2023-10-01 18:13:54 +01:00
Dan Brown
b2d48d9a7f Images: Rolled out image memory handling to image actions
- Moved thumnbail loading out of repo into ImageResizer.
- Updated gallery and editor image handling to show errors where
  possible to indicate memory issues for resizing/thumbs.
- Updated gallery to load image data in a per-image basis via edit form
  for more resiliant thumb/data fetching. Data was previously provided
  via gallery listing, which could be affected by failing generation
  of other images.
- Updated image manager double click handling to be more pleasant and
  not flash away the edit form.
- Updated editor handlers to use main URL when thumbs fail to load.
2023-10-01 13:05:18 +01:00
Dan Brown
20bcbd76ef Images: Extracted out image resizing to its own class 2023-09-30 20:00:48 +01:00
Dan Brown
e703009d7f Images: Added thin wrapper around image filesystem instances
Extracts duplicated required handling (Like path adjustment) out to
simpler storage disk instance which can be passed around.
2023-09-30 19:12:22 +01:00
Dan Brown
7247e31936 Images: Started refactor of image service
To break it up.
Also added better memory handling to other parts of the app.
2023-09-30 18:28:42 +01:00
Dan Brown
40721433f7 Image manager: Tweaked grid sizing to prevent massive items 2023-09-30 12:43:51 +01:00
Dan Brown
97274a8140 Images: Added test to cover thubmnail regen endpoint 2023-09-30 12:29:49 +01:00
Dan Brown
5c318a45b8 Images: Reverted some thumbnails to be on-demand generated
Added since we can't always be sure of future image usage, and in many
cases we don't generate ahead-of-time.
Also:
- Simplified image handling on certain models.
- Updated various string handling operations to use newer functions.
2023-09-30 12:09:29 +01:00
Dan Brown
5af3041b9b Thumbnails: Added OOM handling and regen endpoint
- Added some level of app out-of-memory handling so we can show a proper
  error message upon OOM events.
- Added endpoint and image-manager button/action for regenerating
  thumbnails for an image so they can be re-created upon failure.
2023-09-29 13:54:08 +01:00
Dan Brown
cc0827ff28 Images: Updated to create thumbnails at specific events 2023-09-29 11:46:32 +01:00
Dan Brown
59da7666b5 Uploads: Added user-facing message for Laravel post limit handling
Uploads over the post max size Would previously error without a
clean user facing message. This catches that error to provide a
user friendly message, compatible with our common error handling.

Tested on image manager handling.
Added test to cover.
2023-09-25 13:48:23 +01:00
JonatanRek
287ed4ff3b Remove Dumps 2023-09-24 20:19:53 +02:00
Dan Brown
21badde4ef Editors: Updated entity link select to pre-fill with selection
Updated all uses across both editors, so the entity link selector popup
now initates a search with the selection text if existing.

For #4571
2023-09-24 18:33:33 +01:00
Dan Brown
e9664dc678 Exports: Fixed issues with book text export format
- Fixed missing page content for direct page children
- Fixed lack of book description.
- Fixed inconsistent spacing between items.
- Fixed lack of spacing between HTML items when HTML on same line.

For #4557
2023-09-24 18:03:37 +01:00
Dan Brown
d5a3bdb7aa Header: Simplified, split and re-orgranised view file(s)
- Moved "common" template partials, that are only used in layouts, to
  layouts/parts folder.
- Simplified HTML structure of header template.
- Extracted logo and links from header template to simplify.
- Added header-links-start template for easier extension/customization
  without needing to override full list of links.
  - Added test to cover usage of this.

For #4564
2023-09-24 10:29:51 +01:00
Dan Brown
c3b4128a38 Homepage: Added tags button to non-default home views
For #4558
2023-09-24 09:31:44 +01:00
Dan Brown
f77bb01b51 Search: Added further backslash handling
Added due to now not being able to perform an exact search where
contains a trailing backslash.
Now all backslashes in exact terms are consided escape chars
and require escaping themselves.
Potential breaking change due to search syntax handling change.

Related to #4535.
2023-09-23 13:41:10 +01:00
Dan Brown
fb417828a4 Readme: Updated badges, sponsors and top links 2023-09-23 12:47:24 +01:00
JonatanRek
57791c1466 Fix Reloading changes on dark mode switch 2023-09-22 11:31:24 +02:00
JonatanRek
46e3b2ceb3 Merge branch 'basic-pwa-support' of https://github.com/GamerClassN7/BookStack into basic-pwa-support 2023-09-22 11:19:38 +02:00
JonatanRek
10e8e1a88d New line fix 2023-09-22 11:19:34 +02:00
JonatanRek
7e09c9a147 Update HomeController.php 2023-09-22 11:19:17 +02:00
JonatanRek
2a2f893fcc Formating Fixes 2023-09-22 11:18:10 +02:00
JonatanRek
9b99664bff Additional Tweaks and FIxes 2023-09-22 11:15:13 +02:00
JonatanRek
f910424fa3 Implementation of required changes 2023-09-22 11:00:41 +02:00
JonatanRek
6e19a8a4bb Merge branch 'basic-pwa-support' of https://github.com/GamerClassN7/BookStack into basic-pwa-support 2023-09-22 10:49:53 +02:00
JonatanRek
cb9c3fc9f5 Fix Dark theme 2023-09-22 10:49:37 +02:00
JonatanRek
effc03e99e Merge branch 'BookStackApp:development' into basic-pwa-support 2023-09-22 10:48:48 +02:00
Dan Brown
8964575973 Search: Added support for escaped exact terms
Also prevented use of empty exact matches.
Prevents issues when attempting to use exact search terms in inputs for
just search terms, and use of single " chars within search terms since
these would get auto-promoted to exacts.

For #4535
2023-09-19 20:09:33 +01:00
Dan Brown
4b4d8ba2a1 Avatar Commend: Simplified and updated during review
During review of #4560.

- Simplified command to share as much log as possible across different
  run options.
- Extracted out user handling to share with MFA command.
- Added specific handling for disabled avatar fetching.
- Added mention of avatar endpoint, to make it clear where these avatars
  are coming from (Protect against user expectation of LDAP avatar sync).
- Simplified a range of the testing.
- Tweaked wording and code formatting.
2023-09-19 15:53:01 +01:00
lawsssscat
588ed785d2 fix Sidebar scrolling at mid-range sceen 2023-09-19 22:12:33 +08:00
Marc Hagen
ca98155373 fix: Actually check if we have correct data 2023-09-18 20:04:59 +02:00
Marc Hagen
ea7592509f feat: Artisan command for updating avatars for existing users 2023-09-18 20:04:28 +02:00
Dan Brown
95b9ea1a21 Dev: Reviewed and expanded on PHP testing docs 2023-09-17 23:41:02 +01:00
Dan Brown
684a9dee8e Merge branch 'tusharnain4578/development' into development 2023-09-17 22:29:06 +01:00
Dan Brown
c42cd29ed3 Notifications: Updated comment notif. prefs. test
Combined testcases, updated to use actual text strings, and set comments
setting via correct method.

Made during review of #4552
2023-09-17 22:26:51 +01:00
Dan Brown
35813e818d Merge pull request #4555 from BookStackApp/language_cleanup
Language cleanup
2023-09-17 22:15:38 +01:00
Dan Brown
78bf11cf65 Locales: Removed a lot of existing locale handling
There was a lot of locale handling to get correct/expected date
formatting within the app.
Carbon now has built-in locale content rather than us needing to target
specific system locales.

This also removes setting locale via Carbon directly.
Carbon registers its own Laravel service provider which seems to
accurately pull the correct locale from the app.

For #4555
2023-09-17 22:02:12 +01:00
Tushar Nain
baa957d980 Update UserPreferencesTest.php
Added Testcases for preferences menu of Comment Notifications visibility when comments are enabled/disabled.
2023-09-17 23:31:01 +05:30
Dan Brown
b42e8cdb63 Locales: Fixed errors occuring for PHP < 8.2 2023-09-17 17:35:00 +01:00
Dan Brown
8994c1b9d9 Locales: More use of locale objects, Addressed failing tests 2023-09-17 16:20:21 +01:00
Dan Brown
ac9a65945f Locales: Performed cleanup and alignment of locale handling
- Reduced app settings down to what's required.
- Used new view-shared $locale object instead of using globals via
  config.
- Aligned language used to default on "locale" instead of mixing
  locale/language.

For #4501
2023-09-17 13:31:38 +01:00
Dan Brown
b292cf7090 Extracted icon helper, aligned container resolution
Also updated breadcrumb view composer to current standards.
Closes #4553
2023-09-16 18:26:28 +01:00
Dan Brown
54791c8627 Merge pull request #4554 from BookStackApp/guest_user_cleanup
Guest control: Cleaned methods involved in fetching/handling
2023-09-16 13:59:16 +01:00
Dan Brown
e16bdf443c Removed redundant null check 2023-09-16 13:49:03 +01:00
Dan Brown
b90033a730 Guest control: Cleaned methods involved in fetching/handling
- Moves guest user caching from User class to app container for
  simplicity.
- Updates test to use simpler $this->users->guest() method for
  consistency.
- Streamlined helpers to avoid function overlap for simplicity.
- Extracted user profile dropdown while doing changes.
2023-09-16 13:18:35 +01:00
Dan Brown
9ac932fc28 Merge branch 'v23-08' into development 2023-09-16 11:55:57 +01:00
Tushar Nain
6a5361d853 Fixed : Comment notification settings are visible even if comments are disabled
Added a UX condition to display comment notification settings, only if the user has enabled the comment notifications.
2023-09-16 14:00:08 +05:30
Dan Brown
c1d30341e7 Updated version and assets for release v23.08.3 2023-09-15 13:49:40 +01:00
Dan Brown
80d2b4913b Merge branch 'v23-08' into release 2023-09-15 13:49:12 +01:00
Dan Brown
45b8d6cd0c Comments: Fixed wrong identification of parent comment
Would cause comment reply notifications to not be sent to expected user.
Updated test to cover problem case.

For #4548
2023-09-15 13:38:02 +01:00
Dan Brown
dfaf6f7c13 Cleanup Command: Allowed running non-interactively
For #4541
2023-09-14 14:17:20 +01:00
Dan Brown
417705651c Dark Mode: Fixed not toggle action when dark by default
Added test to cover.
For #4543.
2023-09-14 13:53:24 +01:00
Dan Brown
4ec600adfa Langs: Added Uzbek, Updated translator attribution
For #4527
2023-09-13 10:52:12 +01:00
Dan Brown
709c182bda Merge branch 'Bajszi97/development' into development 2023-09-13 10:12:49 +01:00
Dan Brown
a452092e40 Reviewed #4533, formatting and tweaks
- Updating formatting.
- Tweaked truncation to roughly match elipsis char to width used.
- Updated testing to use existing helpers, and ran check as admin user
  to avoid name conflicts.
2023-09-13 10:09:33 +01:00
Bajszi
83028f3fbe Test comment creator name truncation 2023-09-12 21:10:25 +02:00
Bajszi
f4deb13301 Truncate with three dots 2023-09-12 21:10:25 +02:00
Dan Brown
6e098905d4 Theme: Added handling for functions.php file load error
This adds specific handling for functions.php error loading to re-throw
errors wrapped in a more descriptive message, to make it clear the error
is due to an issue in their functions.php file.

Decided to throw and stop, rather than ignore & continue, to be on the
safe side in the event auth-level (or other security level) customizations
have been made via functions.php.

Adds test to cover.
Closes #4504
2023-09-12 12:34:02 +01:00
Bajszi
f997d3e0bb Trimmed name last resort 2023-09-11 21:27:15 +02:00
Dan Brown
8e3f8de627 Notifications: Reorgranised classes into domain specific folders
Closes #4500
2023-09-11 19:27:36 +01:00
Dan Brown
18f396c21b Views: Rolled out ID to similar recent activity lists
To allow for easier customization.
Related to #4530
2023-09-11 18:50:39 +01:00
Dan Brown
ec86576e1e Merge branch 'v23-08' into development 2023-09-11 18:43:23 +01:00
Dan Brown
99eb3e5f71 Comments: Fixed JS error when lacking commenting permissions
The page comments component would throw an error due to references to
form elements/content, when form elements may not exist due to
permisisons.

For #4531
2023-09-11 18:40:40 +01:00
Dan Brown
4985e39db4 Slack auth: Switched from community to laravel library
Tested locally before & after change, and looked at code to compare.
Nothing seen or experienced that should affect things, from testing all
is working as expected with no difference from before.
- Update composer requirement of socialite to that which included slack.
- Updated PHP depds while there.
- Updated format of socialite events to align with current documentation
  and to use class references instead of strings.
2023-09-11 15:26:04 +01:00
Dan Brown
05f2ec40cc OIDC: Moved name claim option handling from config to service
Closes #4494
2023-09-11 11:50:58 +01:00
Dan Brown
564dc70ac4 Fixed php8 compat issue, updated readme & templates
- Changed use of array spread since it was not supported in PHP8.0.
- Updated issue templates based to reduce less valueable fields, update
  some details, and try to help bug reports be more focused on bugs.
- Updated readme with peertube link and attribution advistory for
  translations PRs.
2023-09-11 11:37:07 +01:00
Dan Brown
2fbf5527c7 Simplified and aligned handling of mixed entity endpoints
Fixes #4444
2023-09-10 15:19:23 +01:00
Dan Brown
3928cbac18 Mail: changed default "MAIL_FROM" address
Used an "example.com" address so we're using a propoer reserved domain,
and to avoid these trying to be delivered to the main bookstackapp
domain.

Closes #4518
2023-09-09 12:41:37 +01:00
Dan Brown
8659ee0936 Merge pull request #4525 from BookStackApp/http_alignment
HTTP calling logic alignment
2023-09-08 17:30:44 +01:00
Dan Brown
06490f624c Removed use of HttpFetcher
- Fixed some existing issues in new aligned process.
- Manually tested each external call scenario.
2023-09-08 17:16:57 +01:00
Dan Brown
a8b5652210 Started aligning app-wide outbound http calling behaviour 2023-09-08 14:16:09 +01:00
Dan Brown
15da4b98ef Updated translations with latest Crowdin changes (#4512)
Last translation merge for possible continued v23.08 branch
2023-09-07 15:57:59 +01:00
Dan Brown
21cd2d17f6 Updated sponsors and dev version 2023-09-07 14:43:29 +01:00
Dan Brown
3f473528b1 Updated version and assets for release v23.08.2 2023-09-04 12:06:50 +01:00
Dan Brown
d0dcd4f61b Merge branch 'development' into release 2023-09-04 12:06:15 +01:00
Dan Brown
ad60517536 Updated translations with latest Crowdin changes (#4506) 2023-09-04 11:48:25 +01:00
Dan Brown
2c20abc872 WYSIWYG: Fixed filtering issue causing broken page edits
Could error upon div elements without classes, including drawings.

Related to #4510 and #4509
2023-09-04 11:25:05 +01:00
JonatanRek
2b604b5af9 Move Manifest Definition to Separate Config File 2023-08-10 17:02:31 +02:00
JonatanRek
08ea97fd83 Manifest Tweaks 2023-08-10 16:43:14 +02:00
JonatanRek
601491b275 Add Color 2023-08-10 15:51:09 +02:00
JonatanRek
88e148ba00 Initial Draft 2023-08-10 15:44:27 +02:00
554 changed files with 13912 additions and 5361 deletions

View File

@@ -72,7 +72,7 @@ MYSQL_ATTR_SSL_CA="/path/to/ca.pem"
# Mail configuration
# Refer to https://www.bookstackapp.com/docs/admin/email-webhooks/#email-configuration
MAIL_DRIVER=smtp
MAIL_FROM=mail@bookstackapp.com
MAIL_FROM=bookstack@example.com
MAIL_FROM_NAME=BookStack
MAIL_HOST=localhost

View File

@@ -1,7 +1,14 @@
name: Bug Report
description: Create a report to help us improve or fix things
description: Create a report to help us fix bugs & issues in existing supported functionality
labels: [":bug: Bug"]
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out a bug report!
Please note that this form is for reporting bugs in existing supported functionality.
If you are reporting something that's not an issue in functionality we've previously supported and/or is simply something different to your expectations, then it may be more appropriate to raise via a feature or support request instead.
- type: textarea
id: description
attributes:
@@ -13,7 +20,7 @@ body:
id: reproduction
attributes:
label: Steps to Reproduce
description: Detail the steps that would replicate this issue
description: Detail the steps that would replicate this issue.
placeholder: |
1. Go to '...'
2. Click on '....'
@@ -32,7 +39,7 @@ body:
id: context
attributes:
label: Screenshots or Additional Context
description: Provide any additional context and screenshots here to help us solve this issue
description: Provide any additional context and screenshots here to help us solve this issue.
validations:
required: false
- type: input
@@ -48,23 +55,7 @@ body:
id: bsversion
attributes:
label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5)
validations:
required: true
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
description: This can be found in the settings view of BookStack. Please provide an exact version(s) you've tested on.
placeholder: (eg. v23.06.7)
validations:
required: true

View File

@@ -33,9 +33,9 @@ body:
attributes:
label: Have you searched for an existing open/closed issue?
description: |
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundemental benefit/goal of your request.
To help us keep these issues under control, please ensure you have first [searched our issue list](https://github.com/BookStackApp/BookStack/issues?q=is%3Aissue) for any existing issues that cover the fundamental benefit/goal of your request.
options:
- label: I have searched for existing issues and none cover my fundemental request
- label: I have searched for existing issues and none cover my fundamental request
required: true
- type: dropdown
id: existing_usage
@@ -43,8 +43,8 @@ body:
label: How long have you been using BookStack?
options:
- Not using yet, just scoping
- 0 to 6 months
- 6 months to 1 year
- Under 3 months
- 3 months to 1 year
- 1 to 5 years
- Over 5 years
validations:

View File

@@ -33,7 +33,7 @@ body:
attributes:
label: Exact BookStack Version
description: This can be found in the settings view of BookStack. Please provide an exact version.
placeholder: (eg. v21.08.5)
placeholder: (eg. v23.06.7)
validations:
required: true
- type: textarea
@@ -44,19 +44,11 @@ body:
placeholder: Be sure to remove any confidential details in your logs
validations:
required: false
- type: input
id: phpversion
attributes:
label: PHP Version
description: Keep in mind your command-line PHP version may differ to that of your webserver. Provide that most relevant to the issue.
placeholder: (eg. 7.4)
validations:
required: false
- type: textarea
id: hosting
attributes:
label: Hosting Environment
description: Describe your hosting environment as much as possible including any proxies used (If applicable).
placeholder: (eg. Ubuntu 20.04 VPS, installed using official installation script)
placeholder: (eg. PHP8.1 on Ubuntu 22.04 VPS, installed using official installation script)
validations:
required: true

15
.github/SECURITY.md vendored
View File

@@ -15,18 +15,13 @@ If you'd like to be notified of new potential security concerns you can [sign-up
If you've found an issue that likely has no impact to existing users (For example, in a development-only branch)
feel free to raise it via a standard GitHub bug report issue.
If the issue could have a security impact to BookStack instances, please use one of the below
methods to report the vulnerability:
- Directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
- You will need to login to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
- Alternatively you can send a DM via Twitter to [@ssddanbrown](https://twitter.com/ssddanbrown).
- [Disclose via huntr.dev](https://huntr.dev/bounties/disclose)
- Bounties may be available to you through this platform.
- Be sure to use `https://github.com/BookStackApp/BookStack` as the repository URL.
If the issue could have a security impact to BookStack instances,
please directly contact the lead maintainer [@ssddanbrown](https://github.com/ssddanbrown).
You will need to log in to be able to see the email address on the [GitHub profile page](https://github.com/ssddanbrown).
Alternatively you can send a DM via Mastodon to [@danb@fosstodon.org](https://fosstodon.org/@danb).
Please be patient while the vulnerability is being reviewed. Deploying the fix to address the vulnerability
can often take a little time due to the amount of preparation required, to ensure the vulnerability has
been covered, and to create the content required to adequately notify the user-base.
Thank you for keeping BookStack instances safe!
Thank you for keeping BookStack instances safe!

View File

@@ -57,6 +57,7 @@ Name :: Languages
@Jokuna :: Korean
@smartshogu :: German; German Informal
@samadha56 :: Persian
@mrmuminov :: Uzbek
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
@@ -176,7 +177,7 @@ Alexander Predl (Harveyhase68) :: German
Rem (Rem9000) :: Dutch
Michał Stelmach (stelmach-web) :: Polish
arniom :: French
REMOVED_USER :: ; French; Dutch; Turkish
REMOVED_USER :: French; Dutch; Turkish;
林祖年 (contagion) :: Chinese Traditional
Siamak Guodarzi (siamakgoudarzi88) :: Persian
Lis Maestrelo (lismtrl) :: Portuguese, Brazilian
@@ -269,7 +270,7 @@ mcgong (GongMingCai) :: Chinese Simplified; Chinese Traditional
Nanang Setia Budi (sefidananang) :: Indonesian
Андрей Павлов (andrei.pavlov) :: Russian
Alex Navarro (alex.n.navarro) :: Portuguese, Brazilian
Ji-Hyeon Gim (PotatoGim) :: Korean
Jihyeon Gim (PotatoGim) :: Korean
Mihai Ochian (soulstorm19) :: Romanian
HeartCore :: German Informal; German
simon.pct :: French
@@ -289,7 +290,7 @@ Ismael Mesquita (mesquitoliveira) :: Portuguese, Brazilian
LiZerui (CNLiZerui) :: Chinese Traditional
Fabrice Boyer (FabriceBoyer) :: French
mikael (bitcanon) :: Swedish
Matthias Mai (schnapsidee) :: German; German Informal
Matthias Mai (schnapsidee) :: German Informal; German
Ufuk Ayyıldız (ufukayyildiz) :: Turkish
Jan Mitrof (jan.kachlik) :: Czech
edwardsmirnov :: Russian
@@ -347,7 +348,7 @@ robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal
Guttorm Hveem (guttormhveem) :: Norwegian Bokmal; Norwegian Nynorsk
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
Vadim (vadrozh) :: Russian
@@ -357,3 +358,12 @@ Dženan (Dzenan) :: Swedish
Péter Péli (peter.peli) :: Hungarian
TWME :: Chinese Traditional
Sascha (Man-in-Black) :: German
Mohammadreza Madadi (madadi.efl) :: Persian
Konstantin Kovacheli (kkovacheli) :: Ukrainian
link1183 :: French
Renan (rfpe) :: Portuguese, Brazilian
Lowkey (bbsweb) :: Chinese Simplified
ZZnOB (zznobzz) :: Russian
rupus :: Swedish
developernecsys :: Norwegian Nynorsk
xuan LI (xuanli233) :: Chinese Simplified

View File

@@ -1,6 +1,12 @@
name: analyse-php
on: [push, pull_request]
on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
jobs:
build:

View File

@@ -1,6 +1,14 @@
name: lint-js
on: [push, pull_request]
on:
push:
paths:
- '**.js'
- '**.json'
pull_request:
paths:
- '**.js'
- '**.json'
jobs:
build:

View File

@@ -1,6 +1,12 @@
name: lint-php
on: [push, pull_request]
on:
push:
paths:
- '**.php'
pull_request:
paths:
- '**.php'
jobs:
build:

View File

@@ -1,6 +1,14 @@
name: test-migrations
on: [push, pull_request]
on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
jobs:
build:

View File

@@ -1,6 +1,14 @@
name: test-php
on: [push, pull_request]
on:
push:
paths:
- '**.php'
- 'composer.*'
pull_request:
paths:
- '**.php'
- 'composer.*'
jobs:
build:

View File

@@ -16,22 +16,12 @@ use Laravel\Socialite\Contracts\User as SocialUser;
class SocialController extends Controller
{
protected SocialAuthService $socialAuthService;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* SocialController constructor.
*/
public function __construct(
SocialAuthService $socialAuthService,
RegistrationService $registrationService,
LoginService $loginService
protected SocialAuthService $socialAuthService,
protected RegistrationService $registrationService,
protected LoginService $loginService,
) {
$this->middleware('guest')->only(['register']);
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
}
/**
@@ -112,7 +102,7 @@ class SocialController extends Controller
$this->socialAuthService->detachSocialAccount($socialDriver);
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
return redirect(user()->getEditUrl());
return redirect('/my-account/auth#social-accounts');
}
/**

View File

@@ -71,7 +71,7 @@ trait ThrottlesLogins
*/
protected function limiter(): RateLimiter
{
return app(RateLimiter::class);
return app()->make(RateLimiter::class);
}
/**

View File

@@ -2,8 +2,8 @@
namespace BookStack\Access;
use BookStack\Access\Notifications\ConfirmEmailNotification;
use BookStack\Exceptions\ConfirmationEmailException;
use BookStack\Notifications\ConfirmEmail;
use BookStack\Users\Models\User;
class EmailConfirmationService extends UserTokenService
@@ -26,7 +26,7 @@ class EmailConfirmationService extends UserTokenService
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new ConfirmEmail($token));
$user->notify(new ConfirmEmailNotification($token));
}
/**

View File

@@ -1,11 +1,12 @@
<?php
namespace BookStack\Notifications;
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ConfirmEmail extends MailNotification
class ConfirmEmailNotification extends MailNotification
{
public function __construct(
public string $token

View File

@@ -1,11 +1,12 @@
<?php
namespace BookStack\Notifications;
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class ResetPassword extends MailNotification
class ResetPasswordNotification extends MailNotification
{
public function __construct(
public string $token

View File

@@ -0,0 +1,27 @@
<?php
namespace BookStack\Access\Notifications;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInviteNotification extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$locale = $notifiable->getLocale();
return $this->newMailMessage($locale)
->subject($locale->trans('auth.user_invite_email_subject', $appName))
->greeting($locale->trans('auth.user_invite_email_greeting', $appName))
->line($locale->trans('auth.user_invite_email_text'))
->action($locale->trans('auth.user_invite_email_action'), url('/register/invite/' . $this->token));
}
}

View File

@@ -20,15 +20,8 @@ class OidcOAuthProvider extends AbstractProvider
{
use BearerAuthorizationTrait;
/**
* @var string
*/
protected $authorizationEndpoint;
/**
* @var string
*/
protected $tokenEndpoint;
protected string $authorizationEndpoint;
protected string $tokenEndpoint;
/**
* Scopes to use for the OIDC authorization call.
@@ -60,7 +53,7 @@ class OidcOAuthProvider extends AbstractProvider
}
/**
* Add an additional scope to this provider upon the default.
* Add another scope to this provider upon the default.
*/
public function addScope(string $scope): void
{

View File

@@ -59,7 +59,7 @@ class OidcProviderSettings
}
}
if (strpos($this->issuer, 'https://') !== 0) {
if (!str_starts_with($this->issuer, 'https://')) {
throw new InvalidArgumentException('Issuer value must start with https://');
}
}

View File

@@ -9,13 +9,13 @@ use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\StoppedAuthenticationException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Cache;
use League\OAuth2\Client\OptionProvider\HttpBasicAuthOptionProvider;
use League\OAuth2\Client\Provider\Exception\IdentityProviderException;
use Psr\Http\Client\ClientInterface as HttpClient;
/**
* Class OpenIdConnectService
@@ -26,7 +26,7 @@ class OidcService
public function __construct(
protected RegistrationService $registrationService,
protected LoginService $loginService,
protected HttpClient $httpClient,
protected HttpRequestService $http,
protected GroupSyncService $groupService
) {
}
@@ -94,7 +94,7 @@ class OidcService
// Run discovery
if ($config['discover'] ?? false) {
try {
$settings->discoverFromIssuer($this->httpClient, Cache::store(null), 15);
$settings->discoverFromIssuer($this->http->buildClient(5), Cache::store(null), 15);
} catch (OidcIssuerDiscoveryException $exception) {
throw new OidcException('OIDC Discovery Error: ' . $exception->getMessage());
}
@@ -111,7 +111,7 @@ class OidcService
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
'httpClient' => $this->httpClient,
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
@@ -142,10 +142,11 @@ class OidcService
*/
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{
$displayNameAttr = $this->config()['display_name_claims'];
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
$displayNameAttrs = explode('|', $displayNameAttrString);
$displayName = [];
foreach ($displayNameAttr as $dnAttr) {
foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') {
$displayName[] = $dnComponent;

View File

@@ -154,21 +154,21 @@ class SocialAuthService
$currentUser->socialAccounts()->save($account);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
return redirect('/my-account/auth#social_accounts');
}
// When a user is logged in and the social account exists and is already linked to the current user.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
return redirect('/my-account/auth#social_accounts');
}
// When a user is logged in, A social account exists but the users do not match.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
return redirect('/my-account/auth#social_accounts');
}
// Otherwise let the user know this social account is not used by anyone.
@@ -214,6 +214,7 @@ class SocialAuthService
/**
* Gets the names of the active social drivers.
* @returns array<string, string>
*/
public function getActiveDrivers(): array
{

View File

@@ -2,7 +2,7 @@
namespace BookStack\Access;
use BookStack\Notifications\UserInvite;
use BookStack\Access\Notifications\UserInviteNotification;
use BookStack\Users\Models\User;
class UserInviteService extends UserTokenService
@@ -18,6 +18,6 @@ class UserInviteService extends UserTokenService
{
$this->deleteByUser($user);
$token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token));
$user->notify(new UserInviteNotification($token));
}
}

View File

@@ -6,11 +6,17 @@ use BookStack\Activity\Models\Favouritable;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Queries\TopFavourites;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
class FavouriteController extends Controller
{
public function __construct(
protected MixedEntityRequestHelper $entityHelper,
) {
}
/**
* Show a listing of all favourite items for the current user.
*/
@@ -36,13 +42,14 @@ class FavouriteController extends Controller
*/
public function add(Request $request)
{
$favouritable = $this->getValidatedModelFromRequest($request);
$favouritable->favourites()->firstOrCreate([
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->firstOrCreate([
'user_id' => user()->id,
]);
$this->showSuccessNotification(trans('activities.favourite_add_notification', [
'name' => $favouritable->name,
'name' => $entity->name,
]));
return redirect()->back();
@@ -53,48 +60,16 @@ class FavouriteController extends Controller
*/
public function remove(Request $request)
{
$favouritable = $this->getValidatedModelFromRequest($request);
$favouritable->favourites()->where([
$modelInfo = $this->validate($request, $this->entityHelper->validationRules());
$entity = $this->entityHelper->getVisibleEntityFromRequestData($modelInfo);
$entity->favourites()->where([
'user_id' => user()->id,
])->delete();
$this->showSuccessNotification(trans('activities.favourite_remove_notification', [
'name' => $favouritable->name,
'name' => $entity->name,
]));
return redirect()->back();
}
/**
* @throws \Illuminate\Validation\ValidationException
* @throws \Exception
*/
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);
if (!class_exists($modelInfo['type'])) {
throw new \Exception('Model not found');
}
/** @var Model $model */
$model = new $modelInfo['type']();
if (!$model instanceof Favouritable) {
throw new \Exception('Model not favouritable');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new \Exception('Model instance not found');
}
return $modelInstance;
}
}

View File

@@ -3,25 +3,22 @@
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Tools\UserEntityWatchOptions;
use BookStack\App\Model;
use BookStack\Entities\Models\Entity;
use BookStack\Entities\Tools\MixedEntityRequestHelper;
use BookStack\Http\Controller;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class WatchController extends Controller
{
public function update(Request $request)
public function update(Request $request, MixedEntityRequestHelper $entityHelper)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$requestData = $this->validate($request, [
$requestData = $this->validate($request, array_merge([
'level' => ['required', 'string'],
]);
], $entityHelper->validationRules()));
$watchable = $this->getValidatedModelFromRequest($request);
$watchable = $entityHelper->getVisibleEntityFromRequestData($requestData);
$watchOptions = new UserEntityWatchOptions(user(), $watchable);
$watchOptions->updateLevelByName($requestData['level']);
@@ -29,37 +26,4 @@ class WatchController extends Controller
return redirect()->back();
}
/**
* @throws ValidationException
* @throws Exception
*/
protected function getValidatedModelFromRequest(Request $request): Entity
{
$modelInfo = $this->validate($request, [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
]);
if (!class_exists($modelInfo['type'])) {
throw new Exception('Model not found');
}
/** @var Model $model */
$model = new $modelInfo['type']();
if (!$model instanceof Entity) {
throw new Exception('Model not an entity');
}
$modelInstance = $model->newQuery()
->where('id', '=', $modelInfo['id'])
->first(['id', 'name', 'owned_by']);
$inaccessibleEntity = ($modelInstance instanceof Entity && !userCan('view', $modelInstance));
if (is_null($modelInstance) || $inaccessibleEntity) {
throw new Exception('Model instance not found');
}
return $modelInstance;
}
}

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Models\Webhook;
use BookStack\Activity\Tools\WebhookFormatter;
use BookStack\Facades\Theme;
use BookStack\Http\HttpRequestService;
use BookStack\Theming\ThemeEvents;
use BookStack\Users\Models\User;
use BookStack\Util\SsrUrlValidator;
@@ -14,7 +15,6 @@ use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class DispatchWebhookJob implements ShouldQueue
@@ -49,25 +49,28 @@ class DispatchWebhookJob implements ShouldQueue
*
* @return void
*/
public function handle()
public function handle(HttpRequestService $http)
{
$lastError = null;
try {
(new SsrUrlValidator())->ensureAllowed($this->webhook->endpoint);
$response = Http::asJson()
->withOptions(['allow_redirects' => ['strict' => true]])
->timeout($this->webhook->timeout)
->post($this->webhook->endpoint, $this->webhookData);
} catch (\Exception $exception) {
$lastError = $exception->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
$client = $http->buildClient($this->webhook->timeout, [
'connect_timeout' => 10,
'allow_redirects' => ['strict' => true],
]);
if (isset($response) && $response->failed()) {
$lastError = "Response status from endpoint was {$response->status()}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$response->status()}");
$response = $client->sendRequest($http->jsonRequest('POST', $this->webhook->endpoint, $this->webhookData));
$statusCode = $response->getStatusCode();
if ($statusCode >= 400) {
$lastError = "Response status from endpoint was {$statusCode}";
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with status {$statusCode}");
}
} catch (\Exception $error) {
$lastError = $error->getMessage();
Log::error("Webhook call to endpoint {$this->webhook->endpoint} failed with error \"{$lastError}\"");
}
$this->webhook->last_called_at = now();

View File

@@ -12,10 +12,12 @@ use Illuminate\Database\Eloquent\Relations\MorphTo;
* @property int $id
* @property string $text
* @property string $html
* @property int|null $parent_id
* @property int|null $parent_id - Relates to local_id, not id
* @property int $local_id
* @property string $entity_type
* @property int $entity_id
* @property int $created_by
* @property int $updated_by
*/
class Comment extends Model implements Loggable
{
@@ -38,7 +40,9 @@ class Comment extends Model implements Loggable
*/
public function parent(): BelongsTo
{
return $this->belongsTo(Comment::class);
return $this->belongsTo(Comment::class, 'parent_id', 'local_id', 'parent')
->where('entity_type', '=', $this->entity_type)
->where('entity_id', '=', $this->entity_id);
}
/**

View File

@@ -41,7 +41,7 @@ class View extends Model
public static function incrementFor(Viewable $viewable): int
{
$user = user();
if (is_null($user) || $user->isDefault()) {
if ($user->isGuest()) {
return 0;
}

View File

@@ -4,7 +4,8 @@ namespace BookStack\Activity\Notifications\Messages;
use BookStack\Activity\Models\Loggable;
use BookStack\Activity\Notifications\MessageParts\LinkedMailMessageLine;
use BookStack\Notifications\MailNotification;
use BookStack\App\MailNotification;
use BookStack\Translation\LocaleDefinition;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
@@ -35,12 +36,12 @@ abstract class BaseActivityNotification extends MailNotification
/**
* Build the common reason footer line used in mail messages.
*/
protected function buildReasonFooterLine(string $language): LinkedMailMessageLine
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
{
return new LinkedMailMessageLine(
url('/preferences/notifications'),
trans('notifications.footer_reason', [], $language),
trans('notifications.footer_reason_link', [], $language),
$locale->trans('notifications.footer_reason'),
$locale->trans('notifications.footer_reason_link'),
);
}
}

View File

@@ -17,17 +17,17 @@ class CommentCreationNotification extends BaseActivityNotification
/** @var Page $page */
$page = $comment->entity;
$language = $notifiable->getLanguage();
$locale = $notifiable->getLocale();
return $this->newMailMessage($language)
->subject(trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()], $language))
->line(trans('notifications.new_comment_intro', ['appName' => setting('app-name')], $language))
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.new_comment_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.new_comment_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
trans('notifications.detail_page_name', [], $language) => $page->name,
trans('notifications.detail_commenter', [], $language) => $this->user->name,
trans('notifications.detail_comment', [], $language) => strip_tags($comment->html),
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_commenter') => $this->user->name,
$locale->trans('notifications.detail_comment') => strip_tags($comment->html),
]))
->action(trans('notifications.action_view_comment', [], $language), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($language));
->action($locale->trans('notifications.action_view_comment'), $page->getUrl('#comment' . $comment->local_id))
->line($this->buildReasonFooterLine($locale));
}
}

View File

@@ -14,16 +14,16 @@ class PageCreationNotification extends BaseActivityNotification
/** @var Page $page */
$page = $this->detail;
$language = $notifiable->getLanguage();
$locale = $notifiable->getLocale();
return $this->newMailMessage($language)
->subject(trans('notifications.new_page_subject', ['pageName' => $page->getShortName()], $language))
->line(trans('notifications.new_page_intro', ['appName' => setting('app-name')], $language))
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.new_page_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.new_page_intro', ['appName' => setting('app-name')], $locale))
->line(new ListMessageLine([
trans('notifications.detail_page_name', [], $language) => $page->name,
trans('notifications.detail_created_by', [], $language) => $this->user->name,
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_created_by') => $this->user->name,
]))
->action(trans('notifications.action_view_page', [], $language), $page->getUrl())
->line($this->buildReasonFooterLine($language));
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($locale));
}
}

View File

@@ -14,17 +14,17 @@ class PageUpdateNotification extends BaseActivityNotification
/** @var Page $page */
$page = $this->detail;
$language = $notifiable->getLanguage();
$locale = $notifiable->getLocale();
return $this->newMailMessage($language)
->subject(trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()], $language))
->line(trans('notifications.updated_page_intro', ['appName' => setting('app-name')], $language))
return $this->newMailMessage($locale)
->subject($locale->trans('notifications.updated_page_subject', ['pageName' => $page->getShortName()]))
->line($locale->trans('notifications.updated_page_intro', ['appName' => setting('app-name')]))
->line(new ListMessageLine([
trans('notifications.detail_page_name', [], $language) => $page->name,
trans('notifications.detail_updated_by', [], $language) => $this->user->name,
$locale->trans('notifications.detail_page_name') => $page->name,
$locale->trans('notifications.detail_updated_by') => $this->user->name,
]))
->line(trans('notifications.updated_page_debounce', [], $language))
->action(trans('notifications.action_view_page', [], $language), $page->getUrl())
->line($this->buildReasonFooterLine($language));
->line($locale->trans('notifications.updated_page_debounce'))
->action($locale->trans('notifications.action_view_page'), $page->getUrl())
->line($this->buildReasonFooterLine($locale));
}
}

View File

@@ -22,7 +22,7 @@ class UserEntityWatchOptions
public function canWatch(): bool
{
return $this->user->can('receive-notifications') && !$this->user->isDefault();
return $this->user->can('receive-notifications') && !$this->user->isGuest();
}
public function getWatchLevel(): string

View File

@@ -31,6 +31,8 @@ class ApiDocsController extends ApiController
/**
* Redirect to the API docs page.
* Required as a controller method, instead of the Route::redirect helper,
* to ensure the URL is generated correctly.
*/
public function redirect()
{

View File

@@ -52,4 +52,12 @@ class ApiToken extends Model implements Loggable
{
return "({$this->id}) {$this->name}; User: {$this->user->logDescriptor()}";
}
/**
* Get the URL for managing this token.
*/
public function getUrl(string $path = ''): string
{
return url("/api-tokens/{$this->user_id}/{$this->id}/" . trim($path, '/'));
}
}

View File

@@ -14,16 +14,19 @@ class UserApiTokenController extends Controller
/**
* Show the form to create a new API token.
*/
public function create(int $userId)
public function create(Request $request, int $userId)
{
// Ensure user is has access-api permission and is the current user or has permission to manage the current user.
$this->checkPermission('access-api');
$this->checkPermissionOrCurrentUser('users-manage', $userId);
$this->updateContext($request);
$user = User::query()->findOrFail($userId);
$this->setPageTitle(trans('settings.user_api_token_create'));
return view('users.api-tokens.create', [
'user' => $user,
'back' => $this->getRedirectPath($user),
]);
}
@@ -60,22 +63,27 @@ class UserApiTokenController extends Controller
session()->flash('api-token-secret:' . $token->id, $secret);
$this->logActivity(ActivityType::API_TOKEN_CREATE, $token);
return redirect($user->getEditUrl('/api-tokens/' . $token->id));
return redirect($token->getUrl());
}
/**
* Show the details for a user API token, with access to edit.
*/
public function edit(int $userId, int $tokenId)
public function edit(Request $request, int $userId, int $tokenId)
{
$this->updateContext($request);
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
$secret = session()->pull('api-token-secret:' . $token->id, null);
$this->setPageTitle(trans('settings.user_api_token'));
return view('users.api-tokens.edit', [
'user' => $user,
'token' => $token,
'model' => $token,
'secret' => $secret,
'back' => $this->getRedirectPath($user),
]);
}
@@ -97,7 +105,7 @@ class UserApiTokenController extends Controller
$this->logActivity(ActivityType::API_TOKEN_UPDATE, $token);
return redirect($user->getEditUrl('/api-tokens/' . $token->id));
return redirect($token->getUrl());
}
/**
@@ -107,6 +115,8 @@ class UserApiTokenController extends Controller
{
[$user, $token] = $this->checkPermissionAndFetchUserToken($userId, $tokenId);
$this->setPageTitle(trans('settings.user_api_token_delete'));
return view('users.api-tokens.delete', [
'user' => $user,
'token' => $token,
@@ -123,7 +133,7 @@ class UserApiTokenController extends Controller
$this->logActivity(ActivityType::API_TOKEN_DELETE, $token);
return redirect($user->getEditUrl('#api_tokens'));
return redirect($this->getRedirectPath($user));
}
/**
@@ -142,4 +152,30 @@ class UserApiTokenController extends Controller
return [$user, $token];
}
/**
* Update the context for where the user is coming from to manage API tokens.
* (Track of location for correct return redirects)
*/
protected function updateContext(Request $request): void
{
$context = $request->query('context');
if ($context) {
session()->put('api-token-context', $context);
}
}
/**
* Get the redirect path for the current api token editing session.
* Attempts to recall the context of where the user is editing from.
*/
protected function getRedirectPath(User $relatedUser): string
{
$context = session()->get('api-token-context');
if ($context === 'settings' || user()->id !== $relatedUser->id) {
return $relatedUser->getEditUrl('#api_tokens');
}
return url('/my-account/auth#api_tokens');
}
}

View File

@@ -78,14 +78,14 @@ class HomeController extends Controller
}
if ($homepageOption === 'bookshelves') {
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$shelves = app()->make(BookshelfRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('home.shelves', $data);
}
if ($homepageOption === 'books') {
$books = app(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$books = app()->make(BookRepo::class)->getAllPaginated(18, $commonData['listOptions']->getSort(), $commonData['listOptions']->getOrder());
$data = array_merge($commonData, ['books' => $books]);
return view('home.books', $data);
@@ -140,4 +140,12 @@ class HomeController extends Controller
$exists = $favicons->restoreOriginalIfNotExists();
return response()->file($exists ? $favicons->getPath() : $favicons->getOriginalPath());
}
/**
* Serve a PWA application manifest.
*/
public function pwaManifest(PwaManifestBuilder $manifestBuilder)
{
return response()->json($manifestBuilder->build());
}
}

View File

@@ -1,7 +1,8 @@
<?php
namespace BookStack\Notifications;
namespace BookStack\App;
use BookStack\Translation\LocaleDefinition;
use BookStack\Users\Models\User;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -32,9 +33,9 @@ abstract class MailNotification extends Notification implements ShouldQueue
/**
* Create a new mail message.
*/
protected function newMailMessage(string $language = ''): MailMessage
protected function newMailMessage(?LocaleDefinition $locale = null): MailMessage
{
$data = ['language' => $language ?: null];
$data = ['locale' => $locale ?? user()->getLocale()];
return (new MailMessage())->view([
'html' => 'vendor.notifications.email',

View File

@@ -9,16 +9,15 @@ use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\BookStackExceptionHandlerPage;
use BookStack\Http\HttpRequestService;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\SettingService;
use BookStack\Util\CspService;
use GuzzleHttp\Client;
use Illuminate\Contracts\Foundation\ExceptionRenderer;
use Illuminate\Database\Eloquent\Relations\Relation;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\URL;
use Illuminate\Support\ServiceProvider;
use Psr\Http\Client\ClientInterface as HttpClientInterface;
class AppServiceProvider extends ServiceProvider
{
@@ -39,6 +38,7 @@ class AppServiceProvider extends ServiceProvider
SettingService::class => SettingService::class,
SocialAuthService::class => SocialAuthService::class,
CspService::class => CspService::class,
HttpRequestService::class => HttpRequestService::class,
];
/**
@@ -51,7 +51,7 @@ class AppServiceProvider extends ServiceProvider
// Set root URL
$appUrl = config('app.url');
if ($appUrl) {
$isHttps = (strpos($appUrl, 'https://') === 0);
$isHttps = str_starts_with($appUrl, 'https://');
URL::forceRootUrl($appUrl);
URL::forceScheme($isHttps ? 'https' : 'http');
}
@@ -75,12 +75,6 @@ class AppServiceProvider extends ServiceProvider
*/
public function register()
{
$this->app->bind(HttpClientInterface::class, function ($app) {
return new Client([
'timeout' => 3,
]);
});
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});

View File

@@ -9,6 +9,7 @@ use BookStack\Access\LdapService;
use BookStack\Access\LoginService;
use BookStack\Access\RegistrationService;
use BookStack\Api\ApiTokenGuard;
use BookStack\Users\Models\User;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\ServiceProvider;
use Illuminate\Validation\Rules\Password;
@@ -65,5 +66,11 @@ class AuthServiceProvider extends ServiceProvider
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);
});
// Bind and provide the default system user as a singleton to the app instance when needed.
// This effectively "caches" fetching the user at an app-instance level.
$this->app->singleton('users.default', function () {
return User::query()->where('system_name', '=', 'public')->first();
});
}
}

View File

@@ -3,7 +3,12 @@
namespace BookStack\App\Providers;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
use SocialiteProviders\Azure\AzureExtendSocialite;
use SocialiteProviders\Discord\DiscordExtendSocialite;
use SocialiteProviders\GitLab\GitLabExtendSocialite;
use SocialiteProviders\Manager\SocialiteWasCalled;
use SocialiteProviders\Okta\OktaExtendSocialite;
use SocialiteProviders\Twitch\TwitchExtendSocialite;
class EventServiceProvider extends ServiceProvider
{
@@ -14,12 +19,11 @@ class EventServiceProvider extends ServiceProvider
*/
protected $listen = [
SocialiteWasCalled::class => [
'SocialiteProviders\Slack\SlackExtendSocialite@handle',
'SocialiteProviders\Azure\AzureExtendSocialite@handle',
'SocialiteProviders\Okta\OktaExtendSocialite@handle',
'SocialiteProviders\GitLab\GitLabExtendSocialite@handle',
'SocialiteProviders\Twitch\TwitchExtendSocialite@handle',
'SocialiteProviders\Discord\DiscordExtendSocialite@handle',
AzureExtendSocialite::class . '@handle',
OktaExtendSocialite::class . '@handle',
GitLabExtendSocialite::class . '@handle',
TwitchExtendSocialite::class . '@handle',
DiscordExtendSocialite::class . '@handle',
],
];

View File

@@ -25,7 +25,7 @@ class ViewTweaksServiceProvider extends ServiceProvider
// Custom blade view directives
Blade::directive('icon', function ($expression) {
return "<?php echo icon($expression); ?>";
return "<?php echo (new \BookStack\Util\SvgIcon($expression))->toHtml(); ?>";
});
}
}

View File

@@ -0,0 +1,59 @@
<?php
namespace BookStack\App;
class PwaManifestBuilder
{
public function build(): array
{
$darkMode = (bool) setting()->getForCurrentUser('dark-mode-enabled');
$appName = setting('app-name');
return [
"name" => $appName,
"short_name" => $appName,
"start_url" => "./",
"scope" => "/",
"display" => "standalone",
"background_color" => $darkMode ? '#111111' : '#F2F2F2',
"description" => $appName,
"theme_color" => ($darkMode ? setting('app-color-dark') : setting('app-color')),
"launch_handler" => [
"client_mode" => "focus-existing"
],
"orientation" => "portrait",
"icons" => [
[
"src" => setting('app-icon-32') ?: url('/icon-32.png'),
"sizes" => "32x32",
"type" => "image/png"
],
[
"src" => setting('app-icon-64') ?: url('/icon-64.png'),
"sizes" => "64x64",
"type" => "image/png"
],
[
"src" => setting('app-icon-128') ?: url('/icon-128.png'),
"sizes" => "128x128",
"type" => "image/png"
],
[
"src" => setting('app-icon-180') ?: url('/icon-180.png'),
"sizes" => "180x180",
"type" => "image/png"
],
[
"src" => setting('app-icon') ?: url('/icon.png'),
"sizes" => "256x256",
"type" => "image/png"
],
[
"src" => url('favicon.ico'),
"sizes" => "48x48",
"type" => "image/vnd.microsoft.icon"
],
],
];
}
}

View File

@@ -35,23 +35,7 @@ function versioned_asset(string $file = ''): string
*/
function user(): User
{
return auth()->user() ?: User::getDefault();
}
/**
* Check if current user is a signed in user.
*/
function signedInUser(): bool
{
return auth()->user() && !auth()->user()->isDefault();
}
/**
* Check if the current user has general access.
*/
function hasAppAccess(): bool
{
return !auth()->guest() || setting('app-public');
return auth()->user() ?: User::getGuest();
}
/**
@@ -61,11 +45,11 @@ function hasAppAccess(): bool
function userCan(string $permission, Model $ownable = null): bool
{
if ($ownable === null) {
return user() && user()->can($permission);
return user()->can($permission);
}
// Check permission on ownable item
$permissions = app(PermissionApplicator::class);
$permissions = app()->make(PermissionApplicator::class);
return $permissions->checkOwnableUserAccess($ownable, $permission);
}
@@ -76,7 +60,7 @@ function userCan(string $permission, Model $ownable = null): bool
*/
function userCanOnAny(string $action, string $entityClass = ''): bool
{
$permissions = app(PermissionApplicator::class);
$permissions = app()->make(PermissionApplicator::class);
return $permissions->checkUserHasEntityPermissionOnAny($action, $entityClass);
}
@@ -88,7 +72,7 @@ function userCanOnAny(string $action, string $entityClass = ''): bool
*/
function setting(string $key = null, $default = null)
{
$settingService = resolve(SettingService::class);
$settingService = app()->make(SettingService::class);
if (is_null($key)) {
return $settingService;
@@ -113,39 +97,6 @@ function theme_path(string $path = ''): ?string
return base_path('themes/' . $theme . ($path ? DIRECTORY_SEPARATOR . $path : $path));
}
/**
* Get fetch an SVG icon as a string.
* Checks for icons defined within a custom theme before defaulting back
* to the 'resources/assets/icons' folder.
*
* Returns an empty string if icon file not found.
*/
function icon(string $name, array $attrs = []): string
{
$attrs = array_merge([
'class' => 'svg-icon',
'data-icon' => $name,
'role' => 'presentation',
], $attrs);
$attrString = ' ';
foreach ($attrs as $attrName => $attr) {
$attrString .= $attrName . '="' . $attr . '" ';
}
$iconPath = resource_path('icons/' . $name . '.svg');
$themeIconPath = theme_path('icons/' . $name . '.svg');
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} elseif (!file_exists($iconPath)) {
return '';
}
$fileContents = file_get_contents($iconPath);
return str_replace('<svg', '<svg' . $attrString, $fileContents);
}
/**
* Generate a URL with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction

View File

@@ -83,10 +83,10 @@ return [
'timezone' => env('APP_TIMEZONE', 'UTC'),
// Default locale to use
// A default variant is also stored since Laravel can overwrite
// app.locale when dynamically setting the locale in-app.
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'bg', 'bs', 'ca', 'cs', 'cy', 'da', 'de', 'de_informal', 'el', 'es', 'es_AR', 'et', 'eu', 'fa', 'fr', 'he', 'hr', 'hu', 'id', 'it', 'ja', 'ka', 'ko', 'lt', 'lv', 'nl', 'nb', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ro', 'ru', 'tr', 'uk', 'uz', 'vi', 'zh_CN', 'zh_TW'],
'default_locale' => env('APP_LANG', 'en'),
// Application Fallback Locale
'fallback_locale' => 'en',
@@ -94,9 +94,6 @@ return [
// Faker Locale
'faker_locale' => 'en_GB',
// Enable right-to-left text control.
'rtl' => false,
// Auto-detect the locale for public users
// For public users their locale can be guessed by headers sent by their
// browser. This is usually set by users in their browser settings.

View File

@@ -22,7 +22,7 @@ return [
// Global "From" address & name
'from' => [
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
'address' => env('MAIL_FROM', 'bookstack@example.com'),
'name' => env('MAIL_FROM_NAME', 'BookStack'),
],

View File

@@ -9,7 +9,7 @@ return [
'dump_user_details' => env('OIDC_DUMP_USER_DETAILS', false),
// Claim, within an OpenId token, to find the user's display name
'display_name_claims' => explode('|', env('OIDC_DISPLAY_NAME_CLAIMS', 'name')),
'display_name_claims' => env('OIDC_DISPLAY_NAME_CLAIMS', 'name'),
// Claim, within an OpenID token, to use to connect a BookStack user to the OIDC user.
'external_id_claim' => env('OIDC_EXTERNAL_ID_CLAIM', 'sub'),

View File

@@ -35,7 +35,7 @@ class CleanupImagesCommand extends Command
if (!$dryRun) {
$this->warn("This operation is destructive and is not guaranteed to be fully accurate.\nEnsure you have a backup of your images.\n");
$proceed = $this->confirm("Are you sure you want to proceed?");
$proceed = !$this->input->isInteractive() || $this->confirm("Are you sure you want to proceed?");
if (!$proceed) {
return 0;
}
@@ -46,7 +46,7 @@ class CleanupImagesCommand extends Command
if ($dryRun) {
$this->comment('Dry run, no images have been deleted');
$this->comment($deleteCount . ' images found that would have been deleted');
$this->comment($deleteCount . ' image(s) found that would have been deleted');
$this->showDeletedImages($deleted);
$this->comment('Run with -f or --force to perform deletions');
@@ -54,7 +54,8 @@ class CleanupImagesCommand extends Command
}
$this->showDeletedImages($deleted);
$this->comment($deleteCount . ' images deleted');
$this->comment("{$deleteCount} image(s) deleted");
return 0;
}
@@ -65,7 +66,7 @@ class CleanupImagesCommand extends Command
}
if (count($paths) > 0) {
$this->line('Images to delete:');
$this->line('Image(s) to delete:');
}
foreach ($paths as $path) {

View File

@@ -0,0 +1,40 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
/**
* @mixin Command
*/
trait HandlesSingleUser
{
/**
* Fetch a user provided to this command.
* Expects the command to accept 'id' and 'email' options.
* @throws Exception
*/
private function fetchProvidedUser(): User
{
$id = $this->option('id');
$email = $this->option('email');
if (!$id && !$email) {
throw new Exception("Either a --id=<number> or --email=<email> option must be provided.\nRun this command with `--help` to show more options.");
}
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
$user = User::query()
->where($field, '=', $value)
->first();
if (!$user) {
throw new Exception("A user where {$field}={$value} could not be found.");
}
return $user;
}
}

View File

@@ -0,0 +1,116 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
use BookStack\Uploads\UserAvatars;
class RefreshAvatarCommand extends Command
{
use HandlesSingleUser;
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:refresh-avatar
{--id= : Numeric ID of the user to refresh avatar for}
{--email= : Email address of the user to refresh avatar for}
{--users-without-avatars : Refresh avatars for users that currently have no avatar}
{--a|all : Refresh avatars for all users}
{--f|force : Actually run the update, Defaults to a dry-run}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Refresh avatar for the given user(s)';
public function handle(UserAvatars $userAvatar): int
{
if (!$userAvatar->avatarFetchEnabled()) {
$this->error("Avatar fetching is disabled on this instance.");
return self::FAILURE;
}
if ($this->option('users-without-avatars')) {
return $this->processUsers(User::query()->whereDoesntHave('avatar')->get()->all(), $userAvatar);
}
if ($this->option('all')) {
return $this->processUsers(User::query()->get()->all(), $userAvatar);
}
try {
$user = $this->fetchProvidedUser();
return $this->processUsers([$user], $userAvatar);
} catch (Exception $exception) {
$this->error($exception->getMessage());
return self::FAILURE;
}
}
/**
* @param User[] $users
*/
private function processUsers(array $users, UserAvatars $userAvatar): int
{
$dryRun = !$this->option('force');
$this->info(count($users) . " user(s) found to update avatars for.");
if (count($users) === 0) {
return self::SUCCESS;
}
if (!$dryRun) {
$fetchHost = parse_url($userAvatar->getAvatarUrl(), PHP_URL_HOST);
$this->warn("This will destroy any existing avatar images these users have, and attempt to fetch new avatar images from {$fetchHost}.");
$proceed = !$this->input->isInteractive() || $this->confirm('Are you sure you want to proceed?');
if (!$proceed) {
return self::SUCCESS;
}
}
$this->info("");
$exitCode = self::SUCCESS;
foreach ($users as $user) {
$linePrefix = "[ID: {$user->id}] $user->email -";
if ($dryRun) {
$this->warn("{$linePrefix} Not updated");
continue;
}
if ($this->fetchAvatar($userAvatar, $user)) {
$this->info("{$linePrefix} Updated");
} else {
$this->error("{$linePrefix} Not updated");
$exitCode = self::FAILURE;
}
}
if ($dryRun) {
$this->comment("");
$this->comment("Dry run, no avatars were updated.");
$this->comment('Run with -f or --force to perform the update.');
}
return $exitCode;
}
private function fetchAvatar(UserAvatars $userAvatar, User $user): bool
{
$oldId = $user->avatar->id ?? 0;
$userAvatar->fetchAndAssignToUser($user);
$user->refresh();
$newId = $user->avatar->id ?? $oldId;
return $oldId !== $newId;
}
}

View File

@@ -2,11 +2,13 @@
namespace BookStack\Console\Commands;
use BookStack\Users\Models\User;
use Exception;
use Illuminate\Console\Command;
class ResetMfaCommand extends Command
{
use HandlesSingleUser;
/**
* The name and signature of the console command.
*
@@ -29,25 +31,10 @@ class ResetMfaCommand extends Command
*/
public function handle(): int
{
$id = $this->option('id');
$email = $this->option('email');
if (!$id && !$email) {
$this->error('Either a --id=<number> or --email=<email> option must be provided.');
return 1;
}
$field = $id ? 'id' : 'email';
$value = $id ?: $email;
/** @var User $user */
$user = User::query()
->where($field, '=', $value)
->first();
if (!$user) {
$this->error("A user where {$field}={$value} could not be found.");
try {
$user = $this->fetchProvidedUser();
} catch (Exception $exception) {
$this->error($exception->getMessage());
return 1;
}

View File

@@ -8,29 +8,21 @@ use Illuminate\View\View;
class BreadcrumbsViewComposer
{
protected $entityContextManager;
/**
* BreadcrumbsViewComposer constructor.
*
* @param ShelfContext $entityContextManager
*/
public function __construct(ShelfContext $entityContextManager)
{
$this->entityContextManager = $entityContextManager;
public function __construct(
protected ShelfContext $shelfContext
) {
}
/**
* Modify data when the view is composed.
*
* @param View $view
*/
public function compose(View $view)
public function compose(View $view): void
{
$crumbs = $view->getData()['crumbs'];
$firstCrumb = $crumbs[0] ?? null;
if ($firstCrumb instanceof Book) {
$shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb);
$shelf = $this->shelfContext->getContextualShelfForBook($firstCrumb);
if ($shelf) {
array_unshift($crumbs, $shelf);
$view->with('crumbs', $crumbs);

View File

@@ -40,26 +40,19 @@ class Book extends Entity implements HasCoverImage
/**
* Returns book cover image, if book cover not exists return default cover image.
*
* @param int $width - Width of the image
* @param int $height - Height of the image
*
* @return string
*/
public function getBookCover($width = 440, $height = 250)
public function getBookCover(int $width = 440, int $height = 250): string
{
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id) {
if (!$this->image_id || !$this->cover) {
return $default;
}
try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
$cover = $default;
return $default;
}
return $cover;
}
/**

View File

@@ -3,6 +3,7 @@
namespace BookStack\Entities\Models;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
@@ -49,28 +50,21 @@ class Bookshelf extends Entity implements HasCoverImage
}
/**
* Returns BookShelf cover image, if cover does not exists return default cover image.
*
* @param int $width - Width of the image
* @param int $height - Height of the image
*
* @return string
* Returns shelf cover image, if cover not exists return default cover image.
*/
public function getBookCover($width = 440, $height = 250)
public function getBookCover(int $width = 440, int $height = 250): string
{
// TODO - Make generic, focused on books right now, Perhaps set-up a better image
$default = 'data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==';
if (!$this->image_id) {
if (!$this->image_id || !$this->cover) {
return $default;
}
try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
} catch (\Exception $err) {
$cover = $default;
return $this->cover->getThumb($width, $height, false) ?? $default;
} catch (Exception $err) {
return $default;
}
return $cover;
}
/**

View File

@@ -10,7 +10,7 @@ class RecentlyViewed extends EntityQuery
public function run(int $count, int $page): Collection
{
$user = user();
if ($user === null || $user->isDefault()) {
if ($user === null || $user->isGuest()) {
return collect();
}

View File

@@ -10,7 +10,7 @@ class TopFavourites extends EntityQuery
public function run(int $count, int $skip = 0)
{
$user = user();
if ($user->isDefault()) {
if ($user->isGuest()) {
return collect();
}

View File

@@ -16,18 +16,11 @@ use Throwable;
class ExportFormatter
{
protected ImageService $imageService;
protected PdfGenerator $pdfGenerator;
protected CspService $cspService;
/**
* ExportService constructor.
*/
public function __construct(ImageService $imageService, PdfGenerator $pdfGenerator, CspService $cspService)
{
$this->imageService = $imageService;
$this->pdfGenerator = $pdfGenerator;
$this->cspService = $cspService;
public function __construct(
protected ImageService $imageService,
protected PdfGenerator $pdfGenerator,
protected CspService $cspService
) {
}
/**
@@ -36,13 +29,14 @@ class ExportFormatter
*
* @throws Throwable
*/
public function pageToContainedHtml(Page $page)
public function pageToContainedHtml(Page $page): string
{
$page->html = (new PageContent($page))->render();
$pageHtml = view('exports.page', [
'page' => $page,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render();
return $this->containHtml($pageHtml);
@@ -53,7 +47,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function chapterToContainedHtml(Chapter $chapter)
public function chapterToContainedHtml(Chapter $chapter): string
{
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
@@ -64,6 +58,7 @@ class ExportFormatter
'pages' => $pages,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render();
return $this->containHtml($html);
@@ -74,7 +69,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function bookToContainedHtml(Book $book)
public function bookToContainedHtml(Book $book): string
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [
@@ -82,6 +77,7 @@ class ExportFormatter
'bookChildren' => $bookTree,
'format' => 'html',
'cspContent' => $this->cspService->getCspMetaTagValue(),
'locale' => user()->getLocale(),
])->render();
return $this->containHtml($html);
@@ -92,13 +88,14 @@ class ExportFormatter
*
* @throws Throwable
*/
public function pageToPdf(Page $page)
public function pageToPdf(Page $page): string
{
$page->html = (new PageContent($page))->render();
$html = view('exports.page', [
'page' => $page,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render();
return $this->htmlToPdf($html);
@@ -109,7 +106,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function chapterToPdf(Chapter $chapter)
public function chapterToPdf(Chapter $chapter): string
{
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
@@ -121,6 +118,7 @@ class ExportFormatter
'pages' => $pages,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render();
return $this->htmlToPdf($html);
@@ -131,7 +129,7 @@ class ExportFormatter
*
* @throws Throwable
*/
public function bookToPdf(Book $book)
public function bookToPdf(Book $book): string
{
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('exports.book', [
@@ -139,6 +137,7 @@ class ExportFormatter
'bookChildren' => $bookTree,
'format' => 'pdf',
'engine' => $this->pdfGenerator->getActiveEngine(),
'locale' => user()->getLocale(),
])->render();
return $this->htmlToPdf($html);
@@ -194,7 +193,7 @@ class ExportFormatter
/** @var DOMElement $iframe */
foreach ($iframes as $iframe) {
$link = $iframe->getAttribute('src');
if (strpos($link, '//') === 0) {
if (str_starts_with($link, '//')) {
$link = 'https:' . $link;
}
@@ -223,7 +222,7 @@ class ExportFormatter
foreach ($imageTagsOutput[0] as $index => $imgMatch) {
$oldImgTagString = $imgMatch;
$srcString = $imageTagsOutput[2][$index];
$imageEncoded = $this->imageService->imageUriToBase64($srcString);
$imageEncoded = $this->imageService->imageUrlToBase64($srcString);
if ($imageEncoded === null) {
$imageEncoded = $srcString;
}
@@ -240,7 +239,7 @@ class ExportFormatter
foreach ($linksOutput[0] as $index => $linkMatch) {
$oldLinkString = $linkMatch;
$srcString = $linksOutput[2][$index];
if (strpos(trim($srcString), 'http') !== 0) {
if (!str_starts_with(trim($srcString), 'http')) {
$newSrcString = url($srcString);
$newLinkString = str_replace($srcString, $newSrcString, $oldLinkString);
$htmlContent = str_replace($oldLinkString, $newLinkString, $htmlContent);
@@ -255,17 +254,20 @@ class ExportFormatter
* Converts the page contents into simple plain text.
* This method filters any bad looking content to provide a nice final output.
*/
public function pageToPlainText(Page $page): string
public function pageToPlainText(Page $page, bool $pageRendered = false, bool $fromParent = false): string
{
$html = (new PageContent($page))->render();
$text = strip_tags($html);
$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);
$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 . "\n\n" . $text;
$text = $page->name . ($fromParent ? "\n" : "\n\n") . $text;
return $text;
}
@@ -275,13 +277,15 @@ class ExportFormatter
*/
public function chapterToPlainText(Chapter $chapter): string
{
$text = $chapter->name . "\n\n";
$text .= $chapter->description . "\n\n";
$text = $chapter->name . "\n" . $chapter->description;
$text = trim($text) . "\n\n";
$parts = [];
foreach ($chapter->getVisiblePages() as $page) {
$text .= $this->pageToPlainText($page);
$parts[] = $this->pageToPlainText($page, false, true);
}
return $text;
return $text . implode("\n\n", $parts);
}
/**
@@ -289,17 +293,20 @@ class ExportFormatter
*/
public function bookToPlainText(Book $book): string
{
$bookTree = (new BookContents($book))->getTree(false, false);
$text = $book->name . "\n\n";
$bookTree = (new BookContents($book))->getTree(false, true);
$text = $book->name . "\n" . $book->description;
$text = rtrim($text) . "\n\n";
$parts = [];
foreach ($bookTree as $bookChild) {
if ($bookChild->isA('chapter')) {
$text .= $this->chapterToPlainText($bookChild);
$parts[] = $this->chapterToPlainText($bookChild);
} else {
$text .= $this->pageToPlainText($bookChild);
$parts[] = $this->pageToPlainText($bookChild, true, true);
}
}
return $text;
return $text . implode("\n\n", $parts);
}
/**

View File

@@ -0,0 +1,39 @@
<?php
namespace BookStack\Entities\Tools;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Models\Entity;
class MixedEntityRequestHelper
{
public function __construct(
protected EntityProvider $entities,
) {
}
/**
* Query out an entity, visible to the current user, for the given
* entity request details (this provided in a request validated by
* this classes' validationRules method).
* @param array{type: string, id: string} $requestData
*/
public function getVisibleEntityFromRequestData(array $requestData): Entity
{
$entityType = $this->entities->get($requestData['type']);
return $entityType->newQuery()->scopes(['visible'])->findOrFail($requestData['id']);
}
/**
* Get the validation rules for an abstract entity request.
* @return array{type: string[], id: string[]}
*/
public function validationRules(): array
{
return [
'type' => ['required', 'string'],
'id' => ['required', 'integer'],
];
}
}

View File

@@ -197,7 +197,7 @@ class TrashCan
$page->allRevisions()->delete();
// Delete Attached Files
$attachmentService = app(AttachmentService::class);
$attachmentService = app()->make(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}

View File

@@ -6,9 +6,11 @@ use Exception;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\Exceptions\PostTooLargeException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
use Symfony\Component\ErrorHandler\Error\FatalError;
use Symfony\Component\HttpKernel\Exception\HttpExceptionInterface;
use Throwable;
@@ -35,6 +37,15 @@ class Handler extends ExceptionHandler
'password_confirmation',
];
/**
* A function to run upon out of memory.
* If it returns a response, that will be provided back to the request
* upon an out of memory event.
*
* @var ?callable<?\Illuminate\Http\Response>
*/
protected $onOutOfMemory = null;
/**
* Report or log an exception.
*
@@ -59,6 +70,17 @@ class Handler extends ExceptionHandler
*/
public function render($request, Throwable $e)
{
if ($e instanceof FatalError && str_contains($e->getMessage(), 'bytes exhausted (tried to allocate') && $this->onOutOfMemory) {
$response = call_user_func($this->onOutOfMemory);
if ($response) {
return $response;
}
}
if ($e instanceof PostTooLargeException) {
$e = new NotifyException(trans('errors.server_post_limit'), '/', 413);
}
if ($this->isApiRequest($request)) {
return $this->renderApiException($e);
}
@@ -66,12 +88,30 @@ class Handler extends ExceptionHandler
return parent::render($request, $e);
}
/**
* Provide a function to be called when an out of memory event occurs.
* If the callable returns a response, this response will be returned
* to the request upon error.
*/
public function prepareForOutOfMemory(callable $onOutOfMemory)
{
$this->onOutOfMemory = $onOutOfMemory;
}
/**
* Forget the current out of memory handler, if existing.
*/
public function forgetOutOfMemoryHandler()
{
$this->onOutOfMemory = null;
}
/**
* Check if the given request is an API request.
*/
protected function isApiRequest(Request $request): bool
{
return strpos($request->path(), 'api/') === 0;
return str_starts_with($request->path(), 'api/');
}
/**

View File

@@ -0,0 +1,7 @@
<?php
namespace BookStack\Exceptions;
class ThemeException extends \Exception
{
}

View File

@@ -71,7 +71,7 @@ abstract class Controller extends BaseController
*/
protected function preventGuestAccess(): void
{
if (!signedInUser()) {
if (user()->isGuest()) {
$this->showPermissionError();
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
class HttpClientHistory
{
public function __construct(
protected &$container
) {
}
public function requestCount(): int
{
return count($this->container);
}
public function requestAt(int $index): ?GuzzleRequest
{
return $this->container[$index]['request'] ?? null;
}
public function latestRequest(): ?GuzzleRequest
{
return $this->requestAt($this->requestCount() - 1);
}
public function all(): array
{
return $this->container;
}
}

View File

@@ -0,0 +1,70 @@
<?php
namespace BookStack\Http;
use GuzzleHttp\Client;
use GuzzleHttp\Handler\MockHandler;
use GuzzleHttp\HandlerStack;
use GuzzleHttp\Middleware;
use GuzzleHttp\Psr7\Request as GuzzleRequest;
use GuzzleHttp\Psr7\Response;
use Psr\Http\Client\ClientInterface;
class HttpRequestService
{
protected ?HandlerStack $handler = null;
/**
* Build a new http client for sending requests on.
*/
public function buildClient(int $timeout, array $options = []): ClientInterface
{
$defaultOptions = [
'timeout' => $timeout,
'handler' => $this->handler,
];
return new Client(array_merge($options, $defaultOptions));
}
/**
* Create a new JSON http request for use with a client.
*/
public function jsonRequest(string $method, string $uri, array $data): GuzzleRequest
{
$headers = ['Content-Type' => 'application/json'];
return new GuzzleRequest($method, $uri, $headers, json_encode($data));
}
/**
* Mock any http clients built from this service, and response with the given responses.
* Returns history which can then be queried.
* @link https://docs.guzzlephp.org/en/stable/testing.html#history-middleware
*/
public function mockClient(array $responses = [], bool $pad = true): HttpClientHistory
{
// By default, we pad out the responses with 10 successful values so that requests will be
// properly recorded for inspection. Otherwise, we can't later check if we're received
// too many requests.
if ($pad) {
$response = new Response(200, [], 'success');
$responses = array_merge($responses, array_fill(0, 10, $response));
}
$container = [];
$history = Middleware::history($container);
$mock = new MockHandler($responses);
$this->handler = HandlerStack::create($mock);
$this->handler->push($history, 'history');
return new HttpClientHistory($container);
}
/**
* Clear mocking that has been set up for clients.
*/
public function clearMocking(): void
{
$this->handler = null;
}
}

View File

@@ -15,6 +15,7 @@ class Kernel extends HttpKernel
\Illuminate\Foundation\Http\Middleware\ValidatePostSize::class,
\BookStack\Http\Middleware\TrimStrings::class,
\BookStack\Http\Middleware\TrustProxies::class,
\BookStack\Http\Middleware\PreventResponseCaching::class,
];
/**
@@ -30,7 +31,6 @@ class Kernel extends HttpKernel
\Illuminate\Session\Middleware\StartSession::class,
\Illuminate\View\Middleware\ShareErrorsFromSession::class,
\BookStack\Http\Middleware\VerifyCsrfToken::class,
\BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
\BookStack\Http\Middleware\RunThemeActions::class,
\BookStack\Http\Middleware\Localization::class,
@@ -40,7 +40,6 @@ class Kernel extends HttpKernel
\BookStack\Http\Middleware\EncryptCookies::class,
\BookStack\Http\Middleware\StartSessionIfCookieExists::class,
\BookStack\Http\Middleware\ApiAuthenticate::class,
\BookStack\Http\Middleware\PreventAuthenticatedResponseCaching::class,
\BookStack\Http\Middleware\CheckEmailConfirmed::class,
],
];

View File

@@ -31,7 +31,7 @@ class ApiAuthenticate
{
// Return if the user is already found to be signed in via session-based auth.
// This is to make it easy to browser the API via browser after just logging into the system.
if (signedInUser() || session()->isStarted()) {
if (!user()->isGuest() || session()->isStarted()) {
if (!$this->sessionUserHasApiAccess()) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
@@ -53,6 +53,6 @@ class ApiAuthenticate
{
$hasApiPermission = user()->can('access-api');
return $hasApiPermission && hasAppAccess();
return $hasApiPermission && user()->hasAppAccess();
}
}

View File

@@ -12,7 +12,7 @@ class Authenticate
*/
public function handle(Request $request, Closure $next)
{
if (!hasAppAccess()) {
if (!user()->hasAppAccess()) {
if ($request->ajax()) {
return response('Unauthorized.', 401);
}

View File

@@ -2,17 +2,14 @@
namespace BookStack\Http\Middleware;
use BookStack\Translation\LanguageManager;
use Carbon\Carbon;
use BookStack\Translation\LocaleManager;
use Closure;
class Localization
{
protected LanguageManager $languageManager;
public function __construct(LanguageManager $languageManager)
{
$this->languageManager = $languageManager;
public function __construct(
protected LocaleManager $localeManager
) {
}
/**
@@ -25,22 +22,12 @@ class Localization
*/
public function handle($request, Closure $next)
{
// Get and record the default language in the config
$defaultLang = config('app.locale');
config()->set('app.default_locale', $defaultLang);
// Share details of the user's locale for use in views
$userLocale = $this->localeManager->getForUser(user());
view()->share('locale', $userLocale);
// Get the user's language and record that in the config for use in views
$userLang = $this->languageManager->getUserLanguage($request, $defaultLang);
config()->set('app.lang', str_replace('_', '-', $this->languageManager->getIsoName($userLang)));
// Set text direction
if ($this->languageManager->isRTL($userLang)) {
config()->set('app.rtl', true);
}
app()->setLocale($userLang);
Carbon::setLocale($userLang);
$this->languageManager->setPhpDateTimeLocale($userLang);
// Set locale for system components
app()->setLocale($userLocale->appLocale());
return $next($request);
}

View File

@@ -5,7 +5,7 @@ namespace BookStack\Http\Middleware;
use Closure;
use Symfony\Component\HttpFoundation\Response;
class PreventAuthenticatedResponseCaching
class PreventResponseCaching
{
/**
* Handle an incoming request.
@@ -20,11 +20,8 @@ class PreventAuthenticatedResponseCaching
/** @var Response $response */
$response = $next($request);
if (signedInUser()) {
$response->headers->set('Cache-Control', 'max-age=0, no-store, private');
$response->headers->set('Pragma', 'no-cache');
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
}
$response->headers->set('Cache-Control', 'no-cache, no-store, private');
$response->headers->set('Expires', 'Sun, 12 Jul 2015 19:01:00 GMT');
return $response;
}

View File

@@ -1,26 +0,0 @@
<?php
namespace BookStack\Notifications;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class UserInvite extends MailNotification
{
public function __construct(
public string $token
) {
}
public function toMail(User $notifiable): MailMessage
{
$appName = ['appName' => setting('app-name')];
$language = $notifiable->getLanguage();
return $this->newMailMessage($language)
->subject(trans('auth.user_invite_email_subject', $appName, $language))
->greeting(trans('auth.user_invite_email_greeting', $appName, $language))
->line(trans('auth.user_invite_email_text', [], $language))
->action(trans('auth.user_invite_email_action', [], $language), url('/register/invite/' . $this->token));
}
}

View File

@@ -44,8 +44,8 @@ class SearchOptions
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$parsedStandardTerms = static::parseStandardTermString($inputs['search'] ?? '');
$instance->searches = $parsedStandardTerms['terms'];
$instance->exacts = $parsedStandardTerms['exacts'];
$instance->searches = array_filter($parsedStandardTerms['terms']);
$instance->exacts = array_filter($parsedStandardTerms['exacts']);
array_push($instance->exacts, ...array_filter($inputs['exact'] ?? []));
@@ -78,7 +78,7 @@ class SearchOptions
];
$patterns = [
'exacts' => '/"(.*?)"/',
'exacts' => '/"((?:\\\\.|[^"\\\\])*)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/',
];
@@ -93,6 +93,11 @@ class SearchOptions
}
}
// Unescape exacts and backslash escapes
foreach ($terms['exacts'] as $index => $exact) {
$terms['exacts'][$index] = static::decodeEscapes($exact);
}
// Parse standard terms
$parsedStandardTerms = static::parseStandardTermString($searchString);
array_push($terms['searches'], ...$parsedStandardTerms['terms']);
@@ -106,12 +111,41 @@ class SearchOptions
}
$terms['filters'] = $splitFilters;
// Filter down terms where required
$terms['exacts'] = array_filter($terms['exacts']);
$terms['searches'] = array_filter($terms['searches']);
return $terms;
}
/**
* Decode backslash escaping within the input string.
*/
protected static function decodeEscapes(string $input): string
{
$decoded = "";
$escaping = false;
foreach (str_split($input) as $char) {
if ($escaping) {
$decoded .= $char;
$escaping = false;
} else if ($char === '\\') {
$escaping = true;
} else {
$decoded .= $char;
}
}
return $decoded;
}
/**
* Parse a standard search term string into individual search terms and
* extract any exact terms searches to be made.
* convert any required terms to exact matches. This is done since some
* characters will never be in the standard index, since we use them as
* delimiters, and therefore we convert a term to be exact if it
* contains one of those delimiter characters.
*
* @return array{terms: array<string>, exacts: array<string>}
*/
@@ -129,8 +163,8 @@ class SearchOptions
continue;
}
$parsedList = (strpbrk($searchTerm, $indexDelimiters) === false) ? 'terms' : 'exacts';
$parsed[$parsedList][] = $searchTerm;
$becomeExact = (strpbrk($searchTerm, $indexDelimiters) !== false);
$parsed[$becomeExact ? 'exacts' : 'terms'][] = $searchTerm;
}
return $parsed;
@@ -141,20 +175,22 @@ class SearchOptions
*/
public function toString(): string
{
$string = implode(' ', $this->searches ?? []);
$parts = $this->searches;
foreach ($this->exacts as $term) {
$string .= ' "' . $term . '"';
$escaped = str_replace('\\', '\\\\', $term);
$escaped = str_replace('"', '\"', $escaped);
$parts[] = '"' . $escaped . '"';
}
foreach ($this->tags as $term) {
$string .= " [{$term}]";
$parts[] = "[{$term}]";
}
foreach ($this->filters as $filterName => $filterVal) {
$string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
$parts[] = '{' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
}
return $string;
return implode(' ', $parts);
}
}

View File

@@ -5,7 +5,6 @@ namespace BookStack\Settings;
use BookStack\Activity\ActivityType;
use BookStack\Entities\Tools\TrashCan;
use BookStack\Http\Controller;
use BookStack\Notifications\TestEmail;
use BookStack\References\ReferenceStore;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
@@ -69,7 +68,7 @@ class MaintenanceController extends Controller
$this->logActivity(ActivityType::MAINTENANCE_ACTION_RUN, 'send-test-email');
try {
user()->notifyNow(new TestEmail());
user()->notifyNow(new TestEmailNotification());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();

View File

@@ -34,7 +34,7 @@ class SettingController extends Controller
return view('settings.' . $category, [
'category' => $category,
'version' => $version,
'guestUser' => User::getDefault(),
'guestUser' => User::getGuest(),
]);
}

View File

@@ -47,7 +47,7 @@ class SettingService
$default = config('setting-defaults.user.' . $key, false);
}
if ($user->isDefault()) {
if ($user->isGuest()) {
return $this->getFromSession($key, $default);
}
@@ -206,7 +206,7 @@ class SettingService
*/
public function putUser(User $user, string $key, string $value): bool
{
if ($user->isDefault()) {
if ($user->isGuest()) {
session()->put($key, $value);
return true;

View File

@@ -1,11 +1,12 @@
<?php
namespace BookStack\Notifications;
namespace BookStack\Settings;
use BookStack\App\MailNotification;
use BookStack\Users\Models\User;
use Illuminate\Notifications\Messages\MailMessage;
class TestEmail extends MailNotification
class TestEmailNotification extends MailNotification
{
public function toMail(User $notifiable): MailMessage
{

View File

@@ -3,19 +3,23 @@
namespace BookStack\Theming;
use BookStack\Access\SocialAuthService;
use BookStack\Exceptions\ThemeException;
use Illuminate\Console\Application;
use Illuminate\Console\Application as Artisan;
use Symfony\Component\Console\Command\Command;
class ThemeService
{
protected $listeners = [];
/**
* @var array<string, callable[]>
*/
protected array $listeners = [];
/**
* Listen to a given custom theme event,
* setting up the action to be ran when the event occurs.
*/
public function listen(string $event, callable $action)
public function listen(string $event, callable $action): void
{
if (!isset($this->listeners[$event])) {
$this->listeners[$event] = [];
@@ -31,10 +35,8 @@ class ThemeService
*
* If a callback returns a non-null value, this method will
* stop and return that value itself.
*
* @return mixed
*/
public function dispatch(string $event, ...$args)
public function dispatch(string $event, ...$args): mixed
{
foreach ($this->listeners[$event] ?? [] as $action) {
$result = call_user_func_array($action, $args);
@@ -49,7 +51,7 @@ class ThemeService
/**
* Register a new custom artisan command to be available.
*/
public function registerCommand(Command $command)
public function registerCommand(Command $command): void
{
Artisan::starting(function (Application $application) use ($command) {
$application->addCommands([$command]);
@@ -59,18 +61,22 @@ class ThemeService
/**
* Read any actions from the set theme path if the 'functions.php' file exists.
*/
public function readThemeActions()
public function readThemeActions(): void
{
$themeActionsFile = theme_path('functions.php');
if ($themeActionsFile && file_exists($themeActionsFile)) {
require $themeActionsFile;
try {
require $themeActionsFile;
} catch (\Error $exception) {
throw new ThemeException("Failed loading theme functions file at \"{$themeActionsFile}\" with error: {$exception->getMessage()}");
}
}
}
/**
* @see SocialAuthService::addSocialDriver
*/
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null)
public function addSocialDriver(string $driverName, array $config, string $socialiteHandler, callable $configureForRedirect = null): void
{
$socialAuthService = app()->make(SocialAuthService::class);
$socialAuthService->addSocialDriver($driverName, $config, $socialiteHandler, $configureForRedirect);

View File

@@ -1,147 +0,0 @@
<?php
namespace BookStack\Translation;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
class LanguageManager
{
/**
* Array of right-to-left language options.
*/
protected array $rtlLanguages = ['ar', 'fa', 'he'];
/**
* Map of BookStack language names to best-estimate ISO and windows locale names.
* Locales can often be found by running `locale -a` on a linux system.
* Windows locales can be found at:
* https://docs.microsoft.com/en-us/cpp/c-runtime-library/language-strings?view=msvc-170.
*
* @var array<string, array{iso: string, windows: string}>
*/
protected array $localeMap = [
'ar' => ['iso' => 'ar', 'windows' => 'Arabic'],
'bg' => ['iso' => 'bg_BG', 'windows' => 'Bulgarian'],
'bs' => ['iso' => 'bs_BA', 'windows' => 'Bosnian (Latin)'],
'ca' => ['iso' => 'ca', 'windows' => 'Catalan'],
'cs' => ['iso' => 'cs_CZ', 'windows' => 'Czech'],
'da' => ['iso' => 'da_DK', 'windows' => 'Danish'],
'de' => ['iso' => 'de_DE', 'windows' => 'German'],
'de_informal' => ['iso' => 'de_DE', 'windows' => 'German'],
'en' => ['iso' => 'en_GB', 'windows' => 'English'],
'el' => ['iso' => 'el_GR', 'windows' => 'Greek'],
'es' => ['iso' => 'es_ES', 'windows' => 'Spanish'],
'es_AR' => ['iso' => 'es_AR', 'windows' => 'Spanish'],
'et' => ['iso' => 'et_EE', 'windows' => 'Estonian'],
'eu' => ['iso' => 'eu_ES', 'windows' => 'Basque'],
'fa' => ['iso' => 'fa_IR', 'windows' => 'Persian'],
'fr' => ['iso' => 'fr_FR', 'windows' => 'French'],
'he' => ['iso' => 'he_IL', 'windows' => 'Hebrew'],
'hr' => ['iso' => 'hr_HR', 'windows' => 'Croatian'],
'hu' => ['iso' => 'hu_HU', 'windows' => 'Hungarian'],
'id' => ['iso' => 'id_ID', 'windows' => 'Indonesian'],
'it' => ['iso' => 'it_IT', 'windows' => 'Italian'],
'ja' => ['iso' => 'ja', 'windows' => 'Japanese'],
'ko' => ['iso' => 'ko_KR', 'windows' => 'Korean'],
'lt' => ['iso' => 'lt_LT', 'windows' => 'Lithuanian'],
'lv' => ['iso' => 'lv_LV', 'windows' => 'Latvian'],
'nl' => ['iso' => 'nl_NL', 'windows' => 'Dutch'],
'nb' => ['iso' => 'nb_NO', 'windows' => 'Norwegian (Bokmal)'],
'pl' => ['iso' => 'pl_PL', 'windows' => 'Polish'],
'pt' => ['iso' => 'pt_PT', 'windows' => 'Portuguese'],
'pt_BR' => ['iso' => 'pt_BR', 'windows' => 'Portuguese'],
'ro' => ['iso' => 'ro_RO', 'windows' => 'Romanian'],
'ru' => ['iso' => 'ru', 'windows' => 'Russian'],
'sk' => ['iso' => 'sk_SK', 'windows' => 'Slovak'],
'sl' => ['iso' => 'sl_SI', 'windows' => 'Slovenian'],
'sv' => ['iso' => 'sv_SE', 'windows' => 'Swedish'],
'uk' => ['iso' => 'uk_UA', 'windows' => 'Ukrainian'],
'vi' => ['iso' => 'vi_VN', 'windows' => 'Vietnamese'],
'zh_CN' => ['iso' => 'zh_CN', 'windows' => 'Chinese (Simplified)'],
'zh_TW' => ['iso' => 'zh_TW', 'windows' => 'Chinese (Traditional)'],
'tr' => ['iso' => 'tr_TR', 'windows' => 'Turkish'],
];
/**
* Get the language specifically for the currently logged-in user if available.
*/
public function getUserLanguage(Request $request, string $default): string
{
try {
$user = user();
} catch (\Exception $exception) {
return $default;
}
if ($user->isDefault() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale($request, $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Get the language for the given user.
*/
public function getLanguageForUser(User $user): string
{
$default = config('app.locale');
return setting()->getUser($user, 'language', $default);
}
/**
* Check if the given BookStack language value is a right-to-left language.
*/
public function isRTL(string $language): bool
{
return in_array($language, $this->rtlLanguages);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = config('app.locales');
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get the ISO version of a BookStack language name.
*/
public function getIsoName(string $language): string
{
return $this->localeMap[$language]['iso'] ?? $language;
}
/**
* Set the system date locale for localized date formatting.
* Will try both the standard locale name and the UTF8 variant.
*/
public function setPhpDateTimeLocale(string $language): void
{
$isoLang = $this->localeMap[$language]['iso'] ?? '';
$isoLangPrefix = explode('_', $isoLang)[0];
$locales = array_values(array_filter([
$isoLang ? $isoLang . '.utf8' : false,
$isoLang ?: false,
$isoLang ? str_replace('_', '-', $isoLang) : false,
$isoLang ? $isoLangPrefix . '.UTF-8' : false,
$this->localeMap[$language]['windows'] ?? false,
$language,
]));
if (!empty($locales)) {
setlocale(LC_TIME, $locales[0], ...array_slice($locales, 1));
}
}
}

View File

@@ -0,0 +1,53 @@
<?php
namespace BookStack\Translation;
class LocaleDefinition
{
public function __construct(
protected string $appName,
protected string $isoName,
protected bool $isRtl
) {
}
/**
* Provide the BookStack-specific locale name.
*/
public function appLocale(): string
{
return $this->appName;
}
/**
* Provide the ISO-aligned locale name.
*/
public function isoLocale(): string
{
return $this->isoName;
}
/**
* Returns a string suitable for the HTML "lang" attribute.
*/
public function htmlLang(): string
{
return str_replace('_', '-', $this->isoName);
}
/**
* Returns a string suitable for the HTML "dir" attribute.
*/
public function htmlDirection(): string
{
return $this->isRtl ? 'rtl' : 'ltr';
}
/**
* Translate using this locate.
*/
public function trans(string $key, array $replace = []): string
{
return trans($key, $replace, $this->appLocale());
}
}

View File

@@ -0,0 +1,122 @@
<?php
namespace BookStack\Translation;
use BookStack\Users\Models\User;
use Illuminate\Http\Request;
class LocaleManager
{
/**
* Array of right-to-left locale options.
*/
protected array $rtlLocales = ['ar', 'fa', 'he'];
/**
* Map of BookStack locale names to best-estimate ISO locale names.
* Locales can often be found by running `locale -a` on a linux system.
*
* @var array<string, string>
*/
protected array $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'bs' => 'bs_BA',
'ca' => 'ca',
'cs' => 'cs_CZ',
'cy' => 'cy_GB',
'da' => 'da_DK',
'de' => 'de_DE',
'de_informal' => 'de_DE',
'el' => 'el_GR',
'en' => 'en_GB',
'es' => 'es_ES',
'es_AR' => 'es_AR',
'et' => 'et_EE',
'eu' => 'eu_ES',
'fa' => 'fa_IR',
'fi' => 'fi_FI',
'fr' => 'fr_FR',
'he' => 'he_IL',
'hr' => 'hr_HR',
'hu' => 'hu_HU',
'id' => 'id_ID',
'it' => 'it_IT',
'ja' => 'ja',
'ka' => 'ka_GE',
'ko' => 'ko_KR',
'lt' => 'lt_LT',
'lv' => 'lv_LV',
'nb' => 'nb_NO',
'nl' => 'nl_NL',
'nn' => 'nn_NO',
'pl' => 'pl_PL',
'pt' => 'pt_PT',
'pt_BR' => 'pt_BR',
'ro' => 'ro_RO',
'ru' => 'ru',
'sk' => 'sk_SK',
'sl' => 'sl_SI',
'sq' => 'sq_AL',
'sv' => 'sv_SE',
'tr' => 'tr_TR',
'uk' => 'uk_UA',
'uz' => 'uz_UZ',
'vi' => 'vi_VN',
'zh_CN' => 'zh_CN',
'zh_TW' => 'zh_TW',
];
/**
* Get the BookStack locale string for the given user.
*/
protected function getLocaleForUser(User $user): string
{
$default = config('app.default_locale');
if ($user->isGuest() && config('app.auto_detect_locale')) {
return $this->autoDetectLocale(request(), $default);
}
return setting()->getUser($user, 'language', $default);
}
/**
* Get a locale definition for the current user.
*/
public function getForUser(User $user): LocaleDefinition
{
$localeString = $this->getLocaleForUser($user);
return new LocaleDefinition(
$localeString,
$this->localeMap[$localeString] ?? $localeString,
in_array($localeString, $this->rtlLocales),
);
}
/**
* Autodetect the visitors locale by matching locales in their headers
* against the locales supported by BookStack.
*/
protected function autoDetectLocale(Request $request, string $default): string
{
$availableLocales = $this->getAllAppLocales();
foreach ($request->getLanguages() as $lang) {
if (in_array($lang, $availableLocales)) {
return $lang;
}
}
return $default;
}
/**
* Get all the available app-specific level locale strings.
*/
public function getAllAppLocales(): array
{
return array_keys($this->localeMap);
}
}

View File

@@ -5,23 +5,23 @@ namespace BookStack\Uploads\Controllers;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controller;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Util\OutOfMemoryHandler;
use Exception;
use Illuminate\Http\Request;
class DrawioImageController extends Controller
{
protected $imageRepo;
public function __construct(ImageRepo $imageRepo)
{
$this->imageRepo = $imageRepo;
public function __construct(
protected ImageRepo $imageRepo
) {
}
/**
* Get a list of gallery images, in a list.
* Can be paged and filtered by entity.
*/
public function list(Request $request)
public function list(Request $request, ImageResizer $resizer)
{
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
@@ -29,11 +29,20 @@ class DrawioImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
return view('pages.parts.image-manager-list', [
$viewData = [
'warning' => '',
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
];
new OutOfMemoryHandler(function () use ($viewData) {
$viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
return response()->view('pages.parts.image-manager-list', $viewData, 200);
});
$resizer->loadGalleryThumbnailsForMany($imgData['images']);
return view('pages.parts.image-manager-list', $viewData);
}
/**

View File

@@ -5,7 +5,11 @@ namespace BookStack\Uploads\Controllers;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Http\Controller;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Util\OutOfMemoryHandler;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\App;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
class GalleryImageController extends Controller
@@ -19,7 +23,7 @@ class GalleryImageController extends Controller
* Get a list of gallery images, in a list.
* Can be paged and filtered by entity.
*/
public function list(Request $request)
public function list(Request $request, ImageResizer $resizer)
{
$page = $request->get('page', 1);
$searchTerm = $request->get('search', null);
@@ -27,11 +31,20 @@ class GalleryImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 30, $uploadedToFilter, $searchTerm);
return view('pages.parts.image-manager-list', [
$viewData = [
'warning' => '',
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
];
new OutOfMemoryHandler(function () use ($viewData) {
$viewData['warning'] = trans('errors.image_gallery_thumbnail_memory_limit');
return response()->view('pages.parts.image-manager-list', $viewData, 200);
});
$resizer->loadGalleryThumbnailsForMany($imgData['images']);
return view('pages.parts.image-manager-list', $viewData);
}
/**
@@ -51,6 +64,10 @@ class GalleryImageController extends Controller
return $this->jsonError(implode("\n", $exception->errors()['file']));
}
new OutOfMemoryHandler(function () {
return $this->jsonError(trans('errors.image_upload_memory_limit'));
});
try {
$imageUpload = $request->file('file');
$uploadedTo = $request->get('uploaded_to', 0);

View File

@@ -4,19 +4,22 @@ namespace BookStack\Uploads\Controllers;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Http\Controller;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use BookStack\Uploads\ImageService;
use BookStack\Util\OutOfMemoryHandler;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ImageController extends Controller
{
public function __construct(
protected ImageRepo $imageRepo,
protected ImageService $imageService
protected ImageService $imageService,
protected ImageResizer $imageResizer,
) {
}
@@ -38,13 +41,10 @@ class ImageController extends Controller
/**
* Update image details.
*
* @throws ImageUploadException
* @throws ValidationException
*/
public function update(Request $request, string $id)
{
$this->validate($request, [
$data = $this->validate($request, [
'name' => ['required', 'min:2', 'string'],
]);
@@ -52,9 +52,7 @@ class ImageController extends Controller
$this->checkImagePermission($image);
$this->checkOwnablePermission('image-update', $image);
$image = $this->imageRepo->updateImageDetails($image, $request->all());
$this->imageRepo->loadThumbs($image);
$image = $this->imageRepo->updateImageDetails($image, $data);
return view('pages.parts.image-manager-form', [
'image' => $image,
@@ -76,6 +74,10 @@ class ImageController extends Controller
$this->checkOwnablePermission('image-update', $image);
$file = $request->file('file');
new OutOfMemoryHandler(function () {
return $this->jsonError(trans('errors.image_upload_memory_limit'));
});
try {
$this->imageRepo->updateImageFile($image, $file);
} catch (ImageUploadException $exception) {
@@ -99,12 +101,20 @@ class ImageController extends Controller
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
}
$this->imageRepo->loadThumbs($image);
return view('pages.parts.image-manager-form', [
$viewData = [
'image' => $image,
'dependantPages' => $dependantPages ?? null,
]);
'warning' => '',
];
new OutOfMemoryHandler(function () use ($viewData) {
$viewData['warning'] = trans('errors.image_thumbnail_memory_limit');
return response()->view('pages.parts.image-manager-form', $viewData);
});
$this->imageResizer->loadGalleryThumbnailsForImage($image, false);
return view('pages.parts.image-manager-form', $viewData);
}
/**
@@ -124,9 +134,28 @@ class ImageController extends Controller
}
/**
* Check related page permission and ensure type is drawio or gallery.
* Rebuild the thumbnails for the given image.
*/
protected function checkImagePermission(Image $image)
public function rebuildThumbnails(string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkImagePermission($image);
$this->checkOwnablePermission('image-update', $image);
new OutOfMemoryHandler(function () {
return $this->jsonError(trans('errors.image_thumbnail_memory_limit'));
});
$this->imageResizer->loadGalleryThumbnailsForImage($image, true);
return response(trans('components.image_rebuild_thumbs_success'));
}
/**
* Check related page permission and ensure type is drawio or gallery.
* @throws NotifyException
*/
protected function checkImagePermission(Image $image): void
{
if ($image->type !== 'drawio' && $image->type !== 'gallery') {
$this->showPermissionError();

View File

@@ -6,6 +6,7 @@ use BookStack\Entities\Models\Page;
use BookStack\Http\ApiController;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageResizer;
use Illuminate\Http\Request;
class ImageGalleryApiController extends ApiController
@@ -15,7 +16,8 @@ class ImageGalleryApiController extends ApiController
];
public function __construct(
protected ImageRepo $imageRepo
protected ImageRepo $imageRepo,
protected ImageResizer $imageResizer,
) {
}
@@ -130,7 +132,7 @@ class ImageGalleryApiController extends ApiController
*/
protected function formatForSingleResponse(Image $image): array
{
$this->imageRepo->loadThumbs($image);
$this->imageResizer->loadGalleryThumbnailsForImage($image, false);
$data = $image->toArray();
$data['created_by'] = $image->createdBy;
$data['updated_by'] = $image->updatedBy;
@@ -138,6 +140,7 @@ class ImageGalleryApiController extends ApiController
$escapedUrl = htmlentities($image->url);
$escapedName = htmlentities($image->name);
if ($image->type === 'drawio') {
$data['content']['html'] = "<div drawio-diagram=\"{$image->id}\"><img src=\"{$escapedUrl}\"></div>";
$data['content']['markdown'] = $data['content']['html'];

View File

@@ -1,38 +0,0 @@
<?php
namespace BookStack\Uploads;
use BookStack\Exceptions\HttpFetchException;
class HttpFetcher
{
/**
* Fetch content from an external URI.
*
* @param string $uri
*
* @throws HttpFetchException
*
* @return bool|string
*/
public function fetch(string $uri)
{
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $uri,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_CONNECTTIMEOUT => 5,
]);
$data = curl_exec($ch);
$err = curl_error($ch);
curl_close($ch);
if ($err) {
$errno = curl_errno($ch);
throw new HttpFetchException($err, $errno);
}
return $data;
}
}

View File

@@ -45,13 +45,14 @@ class Image extends Model
}
/**
* Get a thumbnail for this image.
* Get a thumbnail URL for this image.
* Attempts to generate the thumbnail if not already existing.
*
* @throws \Exception
*/
public function getThumb(?int $width, ?int $height, bool $keepRatio = false): string
public function getThumb(?int $width, ?int $height, bool $keepRatio = false): ?string
{
return app()->make(ImageService::class)->getThumbnail($this, $width, $height, $keepRatio);
return app()->make(ImageResizer::class)->resizeToThumbnailUrl($this, $width, $height, $keepRatio, false);
}
/**

View File

@@ -13,7 +13,8 @@ class ImageRepo
{
public function __construct(
protected ImageService $imageService,
protected PermissionApplicator $permissions
protected PermissionApplicator $permissions,
protected ImageResizer $imageResizer,
) {
}
@@ -29,19 +30,13 @@ class ImageRepo
* Execute a paginated query, returning in a standard format.
* Also runs the query through the restriction system.
*/
private function returnPaginated($query, $page = 1, $pageSize = 24): array
protected function returnPaginated(Builder $query, int $page = 1, int $pageSize = 24): array
{
$images = $query->orderBy('created_at', 'desc')->skip($pageSize * ($page - 1))->take($pageSize + 1)->get();
$hasMore = count($images) > $pageSize;
$returnImages = $images->take($pageSize);
$returnImages->each(function (Image $image) {
$this->loadThumbs($image);
});
return [
'images' => $returnImages,
'has_more' => $hasMore,
'images' => $images->take($pageSize),
'has_more' => count($images) > $pageSize,
];
}
@@ -119,7 +114,7 @@ class ImageRepo
$image = $this->imageService->saveNewFromUpload($uploadFile, $type, $uploadedTo, $resizeWidth, $resizeHeight, $keepRatio);
if ($type !== 'system') {
$this->loadThumbs($image);
$this->imageResizer->loadGalleryThumbnailsForImage($image, true);
}
return $image;
@@ -133,7 +128,7 @@ class ImageRepo
public function saveNewFromData(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{
$image = $this->imageService->saveNew($imageName, $imageData, $type, $uploadedTo);
$this->loadThumbs($image);
$this->imageResizer->loadGalleryThumbnailsForImage($image, true);
return $image;
}
@@ -160,7 +155,7 @@ class ImageRepo
$image->fill($updateDetails);
$image->updated_by = user()->id;
$image->save();
$this->loadThumbs($image);
$this->imageResizer->loadGalleryThumbnailsForImage($image, false);
return $image;
}
@@ -179,8 +174,9 @@ class ImageRepo
$image->updated_by = user()->id;
$image->touch();
$image->save();
$this->imageService->replaceExistingFromUpload($image->path, $image->type, $file);
$this->loadThumbs($image, true);
$this->imageResizer->loadGalleryThumbnailsForImage($image, true);
}
/**
@@ -212,31 +208,6 @@ class ImageRepo
}
}
/**
* Load thumbnails onto an image object.
*/
public function loadThumbs(Image $image, bool $forceCreate = false): void
{
$image->setAttribute('thumbs', [
'gallery' => $this->getThumbnail($image, 150, 150, false, $forceCreate),
'display' => $this->getThumbnail($image, 1680, null, true, $forceCreate),
]);
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
*/
protected function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio, bool $forceCreate): ?string
{
try {
return $this->imageService->getThumbnail($image, $width, $height, $keepRatio, $forceCreate);
} catch (Exception $exception) {
return null;
}
}
/**
* Get the raw image data from an Image.
*/

View File

@@ -0,0 +1,206 @@
<?php
namespace BookStack\Uploads;
use BookStack\Exceptions\ImageUploadException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Cache;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
class ImageResizer
{
protected const THUMBNAIL_CACHE_TIME = 604_800; // 1 week
public function __construct(
protected ImageManager $intervention,
protected ImageStorage $storage,
) {
}
/**
* Load gallery thumbnails for a set of images.
* @param iterable<Image> $images
*/
public function loadGalleryThumbnailsForMany(iterable $images, bool $shouldCreate = false): void
{
foreach ($images as $image) {
$this->loadGalleryThumbnailsForImage($image, $shouldCreate);
}
}
/**
* Load gallery thumbnails into the given image instance.
*/
public function loadGalleryThumbnailsForImage(Image $image, bool $shouldCreate): void
{
$thumbs = ['gallery' => null, 'display' => null];
try {
$thumbs['gallery'] = $this->resizeToThumbnailUrl($image, 150, 150, false, $shouldCreate);
$thumbs['display'] = $this->resizeToThumbnailUrl($image, 1680, null, true, $shouldCreate);
} catch (Exception $exception) {
// Prevent thumbnail errors from stopping execution
}
$image->setAttribute('thumbs', $thumbs);
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
*
* @throws Exception
*/
public function resizeToThumbnailUrl(
Image $image,
?int $width,
?int $height,
bool $keepRatio = false,
bool $shouldCreate = false
): ?string {
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->storage->getPublicUrl($image->path);
}
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
// Return path if in cache
$cachedThumbPath = Cache::get($thumbCacheKey);
if ($cachedThumbPath && !$shouldCreate) {
return $this->storage->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$disk = $this->storage->getDisk($image->type);
if (!$shouldCreate && $disk->exists($thumbFilePath)) {
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($thumbFilePath);
}
$imageData = $disk->get($imagePath);
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
Cache::put($thumbCacheKey, $image->path, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($image->path);
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
$disk->put($thumbFilePath, $thumbData, true);
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
return $this->storage->getPublicUrl($thumbFilePath);
}
/**
* Resize the image of given data to the specified size, and return the new image data.
*
* @throws ImageUploadException
*/
public function resizeImageData(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
{
try {
$thumb = $this->intervention->make($imageData);
} catch (Exception $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
} else {
$thumb->fit($width, $height);
}
$thumbData = (string) $thumb->encode();
// Use original image data if we're keeping the ratio
// and the resizing does not save any space.
if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
return $imageData;
}
return $thumbData;
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it
* can be used (At least when created using binary string data) so we need to do some
* implementation on our side to use the original image data.
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
* Copyright (c) Oliver Vogel, MIT License.
*/
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
{
if (!extension_loaded('exif')) {
return;
}
$stream = Utils::streamFor($originalData)->detach();
$exif = @exif_read_data($stream);
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
switch ($orientation) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;
}
}
/**
* Checks if the image is a gif. Returns true if it is, else false.
*/
protected function isGif(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image and image data is apng.
*/
protected function isApngData(Image $image, string &$imageData): bool
{
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
if (!$isPng) {
return false;
}
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
return str_contains($initialHeader, 'acTL');
}
}

View File

@@ -6,109 +6,27 @@ use BookStack\Entities\Models\Book;
use BookStack\Entities\Models\Bookshelf;
use BookStack\Entities\Models\Page;
use BookStack\Exceptions\ImageUploadException;
use ErrorException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Contracts\Cache\Repository as Cache;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Contracts\Filesystem\Filesystem as Storage;
use Illuminate\Filesystem\FilesystemAdapter;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Intervention\Image\Exception\NotSupportedException;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\ImageManager;
use League\Flysystem\WhitespacePathNormalizer;
use Psr\SimpleCache\InvalidArgumentException;
use Symfony\Component\HttpFoundation\File\UploadedFile;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ImageService
{
protected ImageManager $imageTool;
protected Cache $cache;
protected FilesystemManager $fileSystem;
protected static array $supportedExtensions = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
public function __construct(ImageManager $imageTool, FilesystemManager $fileSystem, Cache $cache)
{
$this->imageTool = $imageTool;
$this->fileSystem = $fileSystem;
$this->cache = $cache;
}
/**
* Get the storage that will be used for storing images.
*/
protected function getStorageDisk(string $imageType = ''): Storage
{
return $this->fileSystem->disk($this->getStorageDiskName($imageType));
}
/**
* Check if local secure image storage (Fetched behind authentication)
* is currently active in the instance.
*/
protected function usingSecureImages(string $imageType = 'gallery'): bool
{
return $this->getStorageDiskName($imageType) === 'local_secure_images';
}
/**
* Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
* is currently active in the instance.
*/
protected function usingSecureRestrictedImages()
{
return config('filesystems.images') === 'local_secure_restricted';
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForStorageDisk(string $path, string $imageType = ''): string
{
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
if ($this->usingSecureImages($imageType)) {
return $path;
}
return 'uploads/images/' . $path;
}
/**
* Get the name of the storage disk to use.
*/
protected function getStorageDiskName(string $imageType): string
{
$storageType = config('filesystems.images');
$localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
// Ensure system images (App logo) are uploaded to a public space
if ($imageType === 'system' && $localSecureInUse) {
return 'local';
}
// Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories.
if ($localSecureInUse) {
return 'local_secure_images';
}
return $storageType;
public function __construct(
protected ImageStorage $storage,
protected ImageResizer $resizer,
) {
}
/**
* Saves a new image from an upload.
*
* @throws ImageUploadException
*
* @return mixed
*/
public function saveNewFromUpload(
UploadedFile $uploadedFile,
@@ -117,12 +35,12 @@ class ImageService
int $resizeWidth = null,
int $resizeHeight = null,
bool $keepRatio = true
) {
): Image {
$imageName = $uploadedFile->getClientOriginalName();
$imageData = file_get_contents($uploadedFile->getRealPath());
if ($resizeWidth !== null || $resizeHeight !== null) {
$imageData = $this->resizeImage($imageData, $resizeWidth, $resizeHeight, $keepRatio);
$imageData = $this->resizer->resizeImageData($imageData, $resizeWidth, $resizeHeight, $keepRatio);
}
return $this->saveNew($imageName, $imageData, $type, $uploadedTo);
@@ -151,13 +69,13 @@ class ImageService
*/
public function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{
$storage = $this->getStorageDisk($type);
$disk = $this->storage->getDisk($type);
$secureUploads = setting('app-secure-images');
$fileName = $this->cleanImageFileName($imageName);
$fileName = $this->storage->cleanImageFileName($imageName);
$imagePath = '/uploads/images/' . $type . '/' . date('Y-m') . '/';
while ($storage->exists($this->adjustPathForStorageDisk($imagePath . $fileName, $type))) {
while ($disk->exists($imagePath . $fileName)) {
$fileName = Str::random(3) . $fileName;
}
@@ -167,7 +85,7 @@ class ImageService
}
try {
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($fullPath, $type), $imageData);
$disk->put($fullPath, $imageData, true);
} catch (Exception $e) {
Log::error('Error when attempting image upload:' . $e->getMessage());
@@ -177,7 +95,7 @@ class ImageService
$imageDetails = [
'name' => $imageName,
'path' => $fullPath,
'url' => $this->getPublicUrl($fullPath),
'url' => $this->storage->getPublicUrl($fullPath),
'type' => $type,
'uploaded_to' => $uploadedTo,
];
@@ -194,214 +112,26 @@ class ImageService
return $image;
}
/**
* Replace an existing image file in the system using the given file.
*/
public function replaceExistingFromUpload(string $path, string $type, UploadedFile $file): void
{
$imageData = file_get_contents($file->getRealPath());
$storage = $this->getStorageDisk($type);
$adjustedPath = $this->adjustPathForStorageDisk($path, $type);
$storage->put($adjustedPath, $imageData);
}
/**
* Save image data for the given path in the public space, if possible,
* for the provided storage mechanism.
*/
protected function saveImageDataInPublicSpace(Storage $storage, string $path, string $data)
{
$storage->put($path, $data);
// Set visibility when a non-AWS-s3, s3-like storage option is in use.
// Done since this call can break s3-like services but desired for other image stores.
// Attempting to set ACL during above put request requires different permissions
// hence would technically be a breaking change for actual s3 usage.
$usingS3 = strtolower(config('filesystems.images')) === 's3';
$usingS3Like = $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
if (!$usingS3Like) {
$storage->setVisibility($path, 'public');
}
}
/**
* Clean up an image file name to be both URL and storage safe.
*/
protected function cleanImageFileName(string $name): string
{
$name = str_replace(' ', '-', $name);
$nameParts = explode('.', $name);
$extension = array_pop($nameParts);
$name = implode('-', $nameParts);
$name = Str::slug($name);
if (strlen($name) === 0) {
$name = Str::random(10);
}
return $name . '.' . $extension;
}
/**
* Checks if the image is a gif. Returns true if it is, else false.
*/
protected function isGif(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
}
/**
* Check if the given image and image data is apng.
*/
protected function isApngData(Image $image, string &$imageData): bool
{
$isPng = strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'png';
if (!$isPng) {
return false;
}
$initialHeader = substr($imageData, 0, strpos($imageData, 'IDAT'));
return strpos($initialHeader, 'acTL') !== false;
}
/**
* Get the thumbnail for an image.
* If $keepRatio is true only the width will be used.
* Checks the cache then storage to avoid creating / accessing the filesystem on every check.
*
* @throws Exception
* @throws InvalidArgumentException
*/
public function getThumbnail(Image $image, ?int $width, ?int $height, bool $keepRatio = false, bool $forceCreate = false): string
{
// Do not resize GIF images where we're not cropping
if ($keepRatio && $this->isGif($image)) {
return $this->getPublicUrl($image->path);
}
$thumbDirName = '/' . ($keepRatio ? 'scaled-' : 'thumbs-') . $width . '-' . $height . '/';
$imagePath = $image->path;
$thumbFilePath = dirname($imagePath) . $thumbDirName . basename($imagePath);
$thumbCacheKey = 'images::' . $image->id . '::' . $thumbFilePath;
// Return path if in cache
$cachedThumbPath = $this->cache->get($thumbCacheKey);
if ($cachedThumbPath && !$forceCreate) {
return $this->getPublicUrl($cachedThumbPath);
}
// If thumbnail has already been generated, serve that and cache path
$storage = $this->getStorageDisk($image->type);
if (!$forceCreate && $storage->exists($this->adjustPathForStorageDisk($thumbFilePath, $image->type))) {
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
$imageData = $storage->get($this->adjustPathForStorageDisk($imagePath, $image->type));
// Do not resize apng images where we're not cropping
if ($keepRatio && $this->isApngData($image, $imageData)) {
$this->cache->put($thumbCacheKey, $image->path, 60 * 60 * 72);
return $this->getPublicUrl($image->path);
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImage($imageData, $width, $height, $keepRatio);
$this->saveImageDataInPublicSpace($storage, $this->adjustPathForStorageDisk($thumbFilePath, $image->type), $thumbData);
$this->cache->put($thumbCacheKey, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
/**
* Resize the image of given data to the specified size, and return the new image data.
*
* @throws ImageUploadException
*/
protected function resizeImage(string $imageData, ?int $width, ?int $height, bool $keepRatio): string
{
try {
$thumb = $this->imageTool->make($imageData);
} catch (ErrorException | NotSupportedException $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
} else {
$thumb->fit($width, $height);
}
$thumbData = (string) $thumb->encode();
// Use original image data if we're keeping the ratio
// and the resizing does not save any space.
if ($keepRatio && strlen($thumbData) > strlen($imageData)) {
return $imageData;
}
return $thumbData;
}
/**
* Orientate the given intervention image based upon the given original image data.
* Intervention does have an `orientate` method but the exif data it needs is lost before it
* can be used (At least when created using binary string data) so we need to do some
* implementation on our side to use the original image data.
* Bulk of logic taken from: https://github.com/Intervention/image/blob/b734a4988b2148e7d10364b0609978a88d277536/src/Intervention/Image/Commands/OrientateCommand.php
* Copyright (c) Oliver Vogel, MIT License.
*/
protected function orientImageToOriginalExif(InterventionImage $image, string $originalData): void
{
if (!extension_loaded('exif')) {
return;
}
$stream = Utils::streamFor($originalData)->detach();
$exif = @exif_read_data($stream);
$orientation = $exif ? ($exif['Orientation'] ?? null) : null;
switch ($orientation) {
case 2:
$image->flip();
break;
case 3:
$image->rotate(180);
break;
case 4:
$image->rotate(180)->flip();
break;
case 5:
$image->rotate(270)->flip();
break;
case 6:
$image->rotate(270);
break;
case 7:
$image->rotate(90)->flip();
break;
case 8:
$image->rotate(90);
break;
}
$disk = $this->storage->getDisk($type);
$disk->put($path, $imageData);
}
/**
* Get the raw data content from an image.
*
* @throws FileNotFoundException
* @throws Exception
*/
public function getImageData(Image $image): string
{
$storage = $this->getStorageDisk();
$disk = $this->storage->getDisk();
return $storage->get($this->adjustPathForStorageDisk($image->path, $image->type));
return $disk->get($image->path);
}
/**
@@ -409,53 +139,13 @@ class ImageService
*
* @throws Exception
*/
public function destroy(Image $image)
public function destroy(Image $image): void
{
$this->destroyImagesFromPath($image->path, $image->type);
$disk = $this->storage->getDisk($image->type);
$disk->destroyAllMatchingNameFromPath($image->path);
$image->delete();
}
/**
* Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided path.
*/
protected function destroyImagesFromPath(string $path, string $imageType): bool
{
$path = $this->adjustPathForStorageDisk($path, $imageType);
$storage = $this->getStorageDisk($imageType);
$imageFolder = dirname($path);
$imageFileName = basename($path);
$allImages = collect($storage->allFiles($imageFolder));
// Delete image files
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
return basename($imagePath) === $imageFileName;
});
$storage->delete($imagesToDelete->all());
// Cleanup of empty folders
$foldersInvolved = array_merge([$imageFolder], $storage->directories($imageFolder));
foreach ($foldersInvolved as $directory) {
if ($this->isFolderEmpty($storage, $directory)) {
$storage->deleteDirectory($directory);
}
}
return true;
}
/**
* Check whether a folder is empty.
*/
protected function isFolderEmpty(Storage $storage, string $path): bool
{
$files = $storage->files($path);
$folders = $storage->directories($path);
return count($files) === 0 && count($folders) === 0;
}
/**
* Delete gallery and drawings that are not within HTML content of pages or page revisions.
* Checks based off of only the image name.
@@ -463,7 +153,7 @@ class ImageService
*
* Returns the path of the images that would be/have been deleted.
*/
public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true)
public function deleteUnusedImages(bool $checkRevisions = true, bool $dryRun = true): array
{
$types = ['gallery', 'drawio'];
$deletedPaths = [];
@@ -499,36 +189,32 @@ class ImageService
* Attempts to convert the URL to a system storage url then
* fetch the data from the disk or storage location.
* Returns null if the image data cannot be fetched from storage.
*
* @throws FileNotFoundException
*/
public function imageUriToBase64(string $uri): ?string
public function imageUrlToBase64(string $url): ?string
{
$storagePath = $this->imageUrlToStoragePath($uri);
if (empty($uri) || is_null($storagePath)) {
$storagePath = $this->storage->urlToPath($url);
if (empty($url) || is_null($storagePath)) {
return null;
}
$storagePath = $this->adjustPathForStorageDisk($storagePath);
// Apply access control when local_secure_restricted images are active
if ($this->usingSecureRestrictedImages()) {
if ($this->storage->usingSecureRestrictedImages()) {
if (!$this->checkUserHasAccessToRelationOfImageAtPath($storagePath)) {
return null;
}
}
$storage = $this->getStorageDisk();
$disk = $this->storage->getDisk();
$imageData = null;
if ($storage->exists($storagePath)) {
$imageData = $storage->get($storagePath);
if ($disk->exists($storagePath)) {
$imageData = $disk->get($storagePath);
}
if (is_null($imageData)) {
return null;
}
$extension = pathinfo($uri, PATHINFO_EXTENSION);
$extension = pathinfo($url, PATHINFO_EXTENSION);
if ($extension === 'svg') {
$extension = 'svg+xml';
}
@@ -543,20 +229,18 @@ class ImageService
*/
public function pathAccessibleInLocalSecure(string $imagePath): bool
{
/** @var FilesystemAdapter $disk */
$disk = $this->getStorageDisk('gallery');
$disk = $this->storage->getDisk('gallery');
if ($this->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
if ($this->storage->usingSecureRestrictedImages() && !$this->checkUserHasAccessToRelationOfImageAtPath($imagePath)) {
return false;
}
// Check local_secure is active
return $this->usingSecureImages()
&& $disk instanceof FilesystemAdapter
return $disk->usingSecureImages()
// Check the image file exists
&& $disk->exists($imagePath)
// Check the file is likely an image file
&& strpos($disk->mimeType($imagePath), 'image/') === 0;
&& str_starts_with($disk->mimeType($imagePath), 'image/');
}
/**
@@ -565,14 +249,14 @@ class ImageService
*/
protected function checkUserHasAccessToRelationOfImageAtPath(string $path): bool
{
if (strpos($path, '/uploads/images/') === 0) {
if (str_starts_with($path, 'uploads/images/')) {
$path = substr($path, 15);
}
// Strip thumbnail element from path if existing
$originalPathSplit = array_filter(explode('/', $path), function (string $part) {
$resizedDir = (strpos($part, 'thumbs-') === 0 || strpos($part, 'scaled-') === 0);
$missingExtension = strpos($part, '.') === false;
$resizedDir = (str_starts_with($part, 'thumbs-') || str_starts_with($part, 'scaled-'));
$missingExtension = !str_contains($part, '.');
return !($resizedDir && $missingExtension);
});
@@ -613,7 +297,7 @@ class ImageService
*/
public function streamImageFromStorageResponse(string $imageType, string $path): StreamedResponse
{
$disk = $this->getStorageDisk($imageType);
$disk = $this->storage->getDisk($imageType);
return $disk->response($path);
}
@@ -627,64 +311,4 @@ class ImageService
{
return in_array($extension, static::$supportedExtensions);
}
/**
* Get a storage path for the given image URL.
* Ensures the path will start with "uploads/images".
* Returns null if the url cannot be resolved to a local URL.
*/
private function imageUrlToStoragePath(string $url): ?string
{
$url = ltrim(trim($url), '/');
// Handle potential relative paths
$isRelative = strpos($url, 'http') !== 0;
if ($isRelative) {
if (strpos(strtolower($url), 'uploads/images') === 0) {
return trim($url, '/');
}
return null;
}
// Handle local images based on paths on the same domain
$potentialHostPaths = [
url('uploads/images/'),
$this->getPublicUrl('/uploads/images/'),
];
foreach ($potentialHostPaths as $potentialBasePath) {
$potentialBasePath = strtolower($potentialBasePath);
if (strpos(strtolower($url), $potentialBasePath) === 0) {
return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
}
}
return null;
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* If s3-style store is in use it will default to guessing a public bucket URL.
*/
private function getPublicUrl(string $filePath): string
{
$storageUrl = config('filesystems.url');
// Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if (!$storageUrl && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (strpos($storageDetails['bucket'], '.') === false) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
} else {
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
}
}
$basePath = $storageUrl ?: url('/');
return rtrim($basePath, '/') . $filePath;
}
}

View File

@@ -0,0 +1,136 @@
<?php
namespace BookStack\Uploads;
use Illuminate\Filesystem\FilesystemManager;
use Illuminate\Support\Str;
class ImageStorage
{
public function __construct(
protected FilesystemManager $fileSystem,
) {
}
/**
* Get the storage disk for the given image type.
*/
public function getDisk(string $imageType = ''): ImageStorageDisk
{
$diskName = $this->getDiskName($imageType);
return new ImageStorageDisk(
$diskName,
$this->fileSystem->disk($diskName),
);
}
/**
* Check if "local secure restricted" (Fetched behind auth, with permissions enforced)
* is currently active in the instance.
*/
public function usingSecureRestrictedImages(): bool
{
return config('filesystems.images') === 'local_secure_restricted';
}
/**
* Clean up an image file name to be both URL and storage safe.
*/
public function cleanImageFileName(string $name): string
{
$name = str_replace(' ', '-', $name);
$nameParts = explode('.', $name);
$extension = array_pop($nameParts);
$name = implode('-', $nameParts);
$name = Str::slug($name);
if (strlen($name) === 0) {
$name = Str::random(10);
}
return $name . '.' . $extension;
}
/**
* Get the name of the storage disk to use.
*/
protected function getDiskName(string $imageType): string
{
$storageType = strtolower(config('filesystems.images'));
$localSecureInUse = ($storageType === 'local_secure' || $storageType === 'local_secure_restricted');
// Ensure system images (App logo) are uploaded to a public space
if ($imageType === 'system' && $localSecureInUse) {
return 'local';
}
// Rename local_secure options to get our image specific storage driver which
// is scoped to the relevant image directories.
if ($localSecureInUse) {
return 'local_secure_images';
}
return $storageType;
}
/**
* Get a storage path for the given image URL.
* Ensures the path will start with "uploads/images".
* Returns null if the url cannot be resolved to a local URL.
*/
public function urlToPath(string $url): ?string
{
$url = ltrim(trim($url), '/');
// Handle potential relative paths
$isRelative = !str_starts_with($url, 'http');
if ($isRelative) {
if (str_starts_with(strtolower($url), 'uploads/images')) {
return trim($url, '/');
}
return null;
}
// Handle local images based on paths on the same domain
$potentialHostPaths = [
url('uploads/images/'),
$this->getPublicUrl('/uploads/images/'),
];
foreach ($potentialHostPaths as $potentialBasePath) {
$potentialBasePath = strtolower($potentialBasePath);
if (str_starts_with(strtolower($url), $potentialBasePath)) {
return 'uploads/images/' . trim(substr($url, strlen($potentialBasePath)), '/');
}
}
return null;
}
/**
* Gets a public facing url for an image by checking relevant environment variables.
* If s3-style store is in use it will default to guessing a public bucket URL.
*/
public function getPublicUrl(string $filePath): string
{
$storageUrl = config('filesystems.url');
// Get the standard public s3 url if s3 is set as storage type
// Uses the nice, short URL if bucket name has no periods in otherwise the longer
// region-based url will be used to prevent http issues.
if (!$storageUrl && config('filesystems.images') === 's3') {
$storageDetails = config('filesystems.disks.s3');
if (!str_contains($storageDetails['bucket'], '.')) {
$storageUrl = 'https://' . $storageDetails['bucket'] . '.s3.amazonaws.com';
} else {
$storageUrl = 'https://s3-' . $storageDetails['region'] . '.amazonaws.com/' . $storageDetails['bucket'];
}
}
$basePath = $storageUrl ?: url('/');
return rtrim($basePath, '/') . $filePath;
}
}

View File

@@ -0,0 +1,140 @@
<?php
namespace BookStack\Uploads;
use Illuminate\Contracts\Filesystem\Filesystem;
use Illuminate\Filesystem\FilesystemAdapter;
use League\Flysystem\WhitespacePathNormalizer;
use Symfony\Component\HttpFoundation\StreamedResponse;
class ImageStorageDisk
{
public function __construct(
protected string $diskName,
protected Filesystem $filesystem,
) {
}
/**
* Check if local secure image storage (Fetched behind authentication)
* is currently active in the instance.
*/
public function usingSecureImages(): bool
{
return $this->diskName === 'local_secure_images';
}
/**
* Change the originally provided path to fit any disk-specific requirements.
* This also ensures the path is kept to the expected root folders.
*/
protected function adjustPathForDisk(string $path): string
{
$path = (new WhitespacePathNormalizer())->normalizePath(str_replace('uploads/images/', '', $path));
if ($this->usingSecureImages()) {
return $path;
}
return 'uploads/images/' . $path;
}
/**
* Check if a file at the given path exists.
*/
public function exists(string $path): bool
{
return $this->filesystem->exists($this->adjustPathForDisk($path));
}
/**
* Get the file at the given path.
*/
public function get(string $path): ?string
{
return $this->filesystem->get($this->adjustPathForDisk($path));
}
/**
* Save the given image data at the given path. Can choose to set
* the image as public which will update its visibility after saving.
*/
public function put(string $path, string $data, bool $makePublic = false): void
{
$path = $this->adjustPathForDisk($path);
$this->filesystem->put($path, $data);
// Set visibility when a non-AWS-s3, s3-like storage option is in use.
// Done since this call can break s3-like services but desired for other image stores.
// Attempting to set ACL during above put request requires different permissions
// hence would technically be a breaking change for actual s3 usage.
if ($makePublic && !$this->isS3Like()) {
$this->filesystem->setVisibility($path, 'public');
}
}
/**
* Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided path.
*/
public function destroyAllMatchingNameFromPath(string $path): void
{
$path = $this->adjustPathForDisk($path);
$imageFolder = dirname($path);
$imageFileName = basename($path);
$allImages = collect($this->filesystem->allFiles($imageFolder));
// Delete image files
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
return basename($imagePath) === $imageFileName;
});
$this->filesystem->delete($imagesToDelete->all());
// Cleanup of empty folders
$foldersInvolved = array_merge([$imageFolder], $this->filesystem->directories($imageFolder));
foreach ($foldersInvolved as $directory) {
if ($this->isFolderEmpty($directory)) {
$this->filesystem->deleteDirectory($directory);
}
}
}
/**
* Get the mime type of the file at the given path.
* Only works for local filesystem adapters.
*/
public function mimeType(string $path): string
{
$path = $this->adjustPathForDisk($path);
return $this->filesystem instanceof FilesystemAdapter ? $this->filesystem->mimeType($path) : '';
}
/**
* Get a stream response for the image at the given path.
*/
public function response(string $path): StreamedResponse
{
return $this->filesystem->response($this->adjustPathForDisk($path));
}
/**
* Check if the image storage in use is an S3-like (but not likely S3) external system.
*/
protected function isS3Like(): bool
{
$usingS3 = $this->diskName === 's3';
return $usingS3 && !is_null(config('filesystems.disks.s3.endpoint'));
}
/**
* Check whether a folder is empty.
*/
protected function isFolderEmpty(string $path): bool
{
$files = $this->filesystem->files($path);
$folders = $this->filesystem->directories($path);
return count($files) === 0 && count($folders) === 0;
}
}

View File

@@ -3,20 +3,20 @@
namespace BookStack\Uploads;
use BookStack\Exceptions\HttpFetchException;
use BookStack\Http\HttpRequestService;
use BookStack\Users\Models\User;
use Exception;
use GuzzleHttp\Psr7\Request;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use Psr\Http\Client\ClientExceptionInterface;
class UserAvatars
{
protected $imageService;
protected $http;
public function __construct(ImageService $imageService, HttpFetcher $http)
{
$this->imageService = $imageService;
$this->http = $http;
public function __construct(
protected ImageService $imageService,
protected HttpRequestService $http
) {
}
/**
@@ -56,7 +56,7 @@ class UserAvatars
/**
* Destroy all user avatars uploaded to the given user.
*/
public function destroyAllForUser(User $user)
public function destroyAllForUser(User $user): void
{
$profileImages = Image::query()->where('type', '=', 'user')
->where('uploaded_to', '=', $user->id)
@@ -70,7 +70,7 @@ class UserAvatars
/**
* Save an avatar image from an external service.
*
* @throws Exception
* @throws HttpFetchException
*/
protected function saveAvatarImage(User $user, int $size = 500): Image
{
@@ -112,28 +112,32 @@ class UserAvatars
protected function getAvatarImageData(string $url): string
{
try {
$imageData = $this->http->fetch($url);
} catch (HttpFetchException $exception) {
$client = $this->http->buildClient(5);
$response = $client->sendRequest(new Request('GET', $url));
if ($response->getStatusCode() !== 200) {
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]));
}
return (string) $response->getBody();
} catch (ClientExceptionInterface $exception) {
throw new HttpFetchException(trans('errors.cannot_get_image_from_url', ['url' => $url]), $exception->getCode(), $exception);
}
return $imageData;
}
/**
* Check if fetching external avatars is enabled.
*/
protected function avatarFetchEnabled(): bool
public function avatarFetchEnabled(): bool
{
$fetchUrl = $this->getAvatarUrl();
return is_string($fetchUrl) && strpos($fetchUrl, 'http') === 0;
return str_starts_with($fetchUrl, 'http');
}
/**
* Get the URL to fetch avatars from.
*/
protected function getAvatarUrl(): string
public function getAvatarUrl(): string
{
$configOption = config('services.avatar_url');
if ($configOption === false) {

View File

@@ -0,0 +1,227 @@
<?php
namespace BookStack\Users\Controllers;
use BookStack\Access\SocialAuthService;
use BookStack\Http\Controller;
use BookStack\Permissions\PermissionApplicator;
use BookStack\Settings\UserNotificationPreferences;
use BookStack\Settings\UserShortcutMap;
use BookStack\Uploads\ImageRepo;
use BookStack\Users\UserRepo;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Validation\Rules\Password;
class UserAccountController extends Controller
{
public function __construct(
protected UserRepo $userRepo,
) {
$this->middleware(function (Request $request, Closure $next) {
$this->preventGuestAccess();
return $next($request);
});
}
/**
* Redirect the root my-account path to the main/first category.
* Required as a controller method, instead of the Route::redirect helper,
* to ensure the URL is generated correctly.
*/
public function redirect()
{
return redirect('/my-account/profile');
}
/**
* Show the profile form interface.
*/
public function showProfile()
{
$this->setPageTitle(trans('preferences.profile'));
return view('users.account.profile', [
'model' => user(),
'category' => 'profile',
]);
}
/**
* Handle the submission of the user profile form.
*/
public function updateProfile(Request $request, ImageRepo $imageRepo)
{
$this->preventAccessInDemoMode();
$user = user();
$validated = $this->validate($request, [
'name' => ['min:2', 'max:100'],
'email' => ['min:2', 'email', 'unique:users,email,' . $user->id],
'language' => ['string', 'max:15', 'alpha_dash'],
'profile_image' => array_merge(['nullable'], $this->getImageValidationRules()),
]);
$this->userRepo->update($user, $validated, userCan('users-manage'));
// Save profile image if in request
if ($request->hasFile('profile_image')) {
$imageUpload = $request->file('profile_image');
$imageRepo->destroyImage($user->avatar);
$image = $imageRepo->saveNew($imageUpload, 'user', $user->id);
$user->image_id = $image->id;
$user->save();
}
// Delete the profile image if reset option is in request
if ($request->has('profile_image_reset')) {
$imageRepo->destroyImage($user->avatar);
$user->image_id = 0;
$user->save();
}
return redirect('/my-account/profile');
}
/**
* Show the user-specific interface shortcuts.
*/
public function showShortcuts()
{
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
$this->setPageTitle(trans('preferences.shortcuts_interface'));
return view('users.account.shortcuts', [
'category' => 'shortcuts',
'shortcuts' => $shortcuts,
'enabled' => $enabled,
]);
}
/**
* Update the user-specific interface shortcuts.
*/
public function updateShortcuts(Request $request)
{
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
return redirect('/my-account/shortcuts');
}
/**
* Show the notification preferences for the current user.
*/
public function showNotifications(PermissionApplicator $permissions)
{
$this->checkPermission('receive-notifications');
$preferences = (new UserNotificationPreferences(user()));
$query = user()->watches()->getQuery();
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$watches = $query->with('watchable')->paginate(20);
$this->setPageTitle(trans('preferences.notifications'));
return view('users.account.notifications', [
'category' => 'notifications',
'preferences' => $preferences,
'watches' => $watches,
]);
}
/**
* Update the notification preferences for the current user.
*/
public function updateNotifications(Request $request)
{
$this->preventAccessInDemoMode();
$this->checkPermission('receive-notifications');
$data = $this->validate($request, [
'preferences' => ['required', 'array'],
'preferences.*' => ['required', 'string'],
]);
$preferences = (new UserNotificationPreferences(user()));
$preferences->updateFromSettingsArray($data['preferences']);
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
return redirect('/my-account/notifications');
}
/**
* Show the view for the "Access & Security" account options.
*/
public function showAuth(SocialAuthService $socialAuthService)
{
$mfaMethods = user()->mfaValues()->get()->groupBy('method');
$this->setPageTitle(trans('preferences.auth'));
return view('users.account.auth', [
'category' => 'auth',
'mfaMethods' => $mfaMethods,
'authMethod' => config('auth.method'),
'activeSocialDrivers' => $socialAuthService->getActiveDrivers(),
]);
}
/**
* Handle the submission for the auth change password form.
*/
public function updatePassword(Request $request)
{
$this->preventAccessInDemoMode();
if (config('auth.method') !== 'standard') {
$this->showPermissionError();
}
$validated = $this->validate($request, [
'password' => ['required_with:password_confirm', Password::default()],
'password-confirm' => ['same:password', 'required_with:password'],
]);
$this->userRepo->update(user(), $validated, false);
$this->showSuccessNotification(trans('preferences.auth_change_password_success'));
return redirect('/my-account/auth');
}
/**
* Show the user self-delete page.
*/
public function delete()
{
$this->setPageTitle(trans('preferences.delete_my_account'));
return view('users.account.delete', [
'category' => 'profile',
]);
}
/**
* Remove the current user from the system.
*/
public function destroy(Request $request)
{
$this->preventAccessInDemoMode();
$requestNewOwnerId = intval($request->get('new_owner_id')) ?: null;
$newOwnerId = userCan('users-manage') ? $requestNewOwnerId : null;
$this->userRepo->destroy(user(), $newOwnerId);
return redirect('/');
}
}

View File

@@ -103,8 +103,7 @@ class UserController extends Controller
*/
public function edit(int $id, SocialAuthService $socialAuthService)
{
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->checkPermission('users-manage');
$user = $this->userRepo->getById($id);
$user->load(['apiTokens', 'mfaValues']);
@@ -134,8 +133,7 @@ class UserController extends Controller
public function update(Request $request, int $id)
{
$this->preventAccessInDemoMode();
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->checkPermission('users-manage');
$validated = $this->validate($request, [
'name' => ['min:2', 'max:100'],
@@ -150,7 +148,7 @@ class UserController extends Controller
]);
$user = $this->userRepo->getById($id);
$this->userRepo->update($user, $validated, userCan('users-manage'));
$this->userRepo->update($user, $validated, true);
// Save profile image if in request
if ($request->hasFile('profile_image')) {
@@ -168,9 +166,7 @@ class UserController extends Controller
$user->save();
}
$redirectUrl = userCan('users-manage') ? '/settings/users' : "/settings/users/{$user->id}";
return redirect($redirectUrl);
return redirect('/settings/users');
}
/**
@@ -178,8 +174,7 @@ class UserController extends Controller
*/
public function delete(int $id)
{
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->checkPermission('users-manage');
$user = $this->userRepo->getById($id);
$this->setPageTitle(trans('settings.users_delete_named', ['userName' => $user->name]));
@@ -195,8 +190,7 @@ class UserController extends Controller
public function destroy(Request $request, int $id)
{
$this->preventAccessInDemoMode();
$this->preventGuestAccess();
$this->checkPermissionOrCurrentUser('users-manage', $id);
$this->checkPermission('users-manage');
$user = $this->userRepo->getById($id);
$newOwnerId = intval($request->get('new_owner_id')) ?: null;

View File

@@ -16,88 +16,6 @@ class UserPreferencesController extends Controller
) {
}
/**
* Show the overview for user preferences.
*/
public function index()
{
return view('users.preferences.index');
}
/**
* Show the user-specific interface shortcuts.
*/
public function showShortcuts()
{
$shortcuts = UserShortcutMap::fromUserPreferences();
$enabled = setting()->getForCurrentUser('ui-shortcuts-enabled', false);
$this->setPageTitle(trans('preferences.shortcuts_interface'));
return view('users.preferences.shortcuts', [
'shortcuts' => $shortcuts,
'enabled' => $enabled,
]);
}
/**
* Update the user-specific interface shortcuts.
*/
public function updateShortcuts(Request $request)
{
$enabled = $request->get('enabled') === 'true';
$providedShortcuts = $request->get('shortcut', []);
$shortcuts = new UserShortcutMap($providedShortcuts);
setting()->putForCurrentUser('ui-shortcuts', $shortcuts->toJson());
setting()->putForCurrentUser('ui-shortcuts-enabled', $enabled);
$this->showSuccessNotification(trans('preferences.shortcuts_update_success'));
return redirect('/preferences/shortcuts');
}
/**
* Show the notification preferences for the current user.
*/
public function showNotifications(PermissionApplicator $permissions)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$preferences = (new UserNotificationPreferences(user()));
$query = user()->watches()->getQuery();
$query = $permissions->restrictEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$query = $permissions->filterDeletedFromEntityRelationQuery($query, 'watches', 'watchable_id', 'watchable_type');
$watches = $query->with('watchable')->paginate(20);
$this->setPageTitle(trans('preferences.notifications'));
return view('users.preferences.notifications', [
'preferences' => $preferences,
'watches' => $watches,
]);
}
/**
* Update the notification preferences for the current user.
*/
public function updateNotifications(Request $request)
{
$this->checkPermission('receive-notifications');
$this->preventGuestAccess();
$data = $this->validate($request, [
'preferences' => ['required', 'array'],
'preferences.*' => ['required', 'string'],
]);
$preferences = (new UserNotificationPreferences(user()));
$preferences->updateFromSettingsArray($data['preferences']);
$this->showSuccessNotification(trans('preferences.notifications_update_success'));
return redirect('/preferences/notifications');
}
/**
* Update the preferred view format for a list view of the given type.
*/
@@ -145,7 +63,7 @@ class UserPreferencesController extends Controller
*/
public function toggleDarkMode()
{
$enabled = setting()->getForCurrentUser('dark-mode-enabled', false);
$enabled = setting()->getForCurrentUser('dark-mode-enabled');
setting()->putForCurrentUser('dark-mode-enabled', $enabled ? 'false' : 'true');
return redirect()->back();

View File

@@ -14,7 +14,7 @@ class UserSearchController extends Controller
*/
public function forSelect(Request $request)
{
$hasPermission = signedInUser() && (
$hasPermission = !user()->isGuest() && (
userCan('users-manage')
|| userCan('restrictions-manage-own')
|| userCan('restrictions-manage-all')

View File

@@ -3,6 +3,7 @@
namespace BookStack\Users\Models;
use BookStack\Access\Mfa\MfaValue;
use BookStack\Access\Notifications\ResetPasswordNotification;
use BookStack\Access\SocialAccount;
use BookStack\Activity\Models\Favourite;
use BookStack\Activity\Models\Loggable;
@@ -11,8 +12,8 @@ use BookStack\Api\ApiToken;
use BookStack\App\Model;
use BookStack\App\Sluggable;
use BookStack\Entities\Tools\SlugGenerator;
use BookStack\Notifications\ResetPassword;
use BookStack\Translation\LanguageManager;
use BookStack\Translation\LocaleDefinition;
use BookStack\Translation\LocaleManager;
use BookStack\Uploads\Image;
use Carbon\Carbon;
use Exception;
@@ -88,38 +89,31 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected string $avatarUrl = '';
/**
* This holds the default user when loaded.
*/
protected static ?User $defaultUser = null;
/**
* Returns the default public user.
* Fetches from the container as a singleton to effectively cache at an app level.
*/
public static function getDefault(): self
public static function getGuest(): self
{
if (!is_null(static::$defaultUser)) {
return static::$defaultUser;
}
static::$defaultUser = static::query()->where('system_name', '=', 'public')->first();
return static::$defaultUser;
}
public static function clearDefault(): void
{
static::$defaultUser = null;
return app()->make('users.default');
}
/**
* Check if the user is the default public user.
*/
public function isDefault(): bool
public function isGuest(): bool
{
return $this->system_name === 'public';
}
/**
* Check if the user has general access to the application.
*/
public function hasAppAccess(): bool
{
return !$this->isGuest() || setting('app-public');
}
/**
* The roles that belong to the user.
*
@@ -250,7 +244,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
try {
$avatar = $this->avatar ? url($this->avatar->getThumb($size, $size, false)) : $default;
$avatar = $this->avatar?->getThumb($size, $size, false) ?? $default;
} catch (Exception $err) {
$avatar = $default;
}
@@ -345,15 +339,15 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $splitName[0];
}
return '';
return mb_substr($this->name, 0, max($chars - 2, 0)) . '';
}
/**
* Get the system language for this user.
* Get the locale for this user.
*/
public function getLanguage(): string
public function getLocale(): LocaleDefinition
{
return app()->make(LanguageManager::class)->getLanguageForUser($this);
return app()->make(LocaleManager::class)->getForUser($this);
}
/**
@@ -365,7 +359,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function sendPasswordResetNotification($token)
{
$this->notify(new ResetPassword($token));
$this->notify(new ResetPasswordNotification($token));
}
/**
@@ -381,7 +375,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function refreshSlug(): string
{
$this->slug = app(SlugGenerator::class)->generate($this);
$this->slug = app()->make(SlugGenerator::class)->generate($this);
return $this->slug;
}

View File

@@ -0,0 +1,58 @@
<?php
namespace BookStack\Util;
use BookStack\Exceptions\Handler;
use Illuminate\Contracts\Debug\ExceptionHandler;
/**
* Create a handler which runs the provided actions upon an
* out-of-memory event. This allows reserving of memory to allow
* the desired action to run as needed.
*
* Essentially provides a wrapper and memory reserving around the
* memory handling added to the default app error handler.
*/
class OutOfMemoryHandler
{
protected $onOutOfMemory;
protected string $memoryReserve = '';
public function __construct(callable $onOutOfMemory, int $memoryReserveMB = 4)
{
$this->onOutOfMemory = $onOutOfMemory;
$this->memoryReserve = str_repeat('x', $memoryReserveMB * 1_000_000);
$this->getHandler()->prepareForOutOfMemory(function () {
return $this->handle();
});
}
protected function handle(): mixed
{
$result = null;
$this->memoryReserve = '';
if ($this->onOutOfMemory) {
$result = call_user_func($this->onOutOfMemory);
$this->forget();
}
return $result;
}
/**
* Forget the handler so no action is taken place on out of memory.
*/
public function forget(): void
{
$this->memoryReserve = '';
$this->onOutOfMemory = null;
$this->getHandler()->forgetOutOfMemoryHandler();
}
protected function getHandler(): Handler
{
return app()->make(ExceptionHandler::class);
}
}

39
app/Util/SvgIcon.php Normal file
View File

@@ -0,0 +1,39 @@
<?php
namespace BookStack\Util;
class SvgIcon
{
public function __construct(
protected string $name,
protected array $attrs = []
) {
}
public function toHtml(): string
{
$attrs = array_merge([
'class' => 'svg-icon',
'data-icon' => $this->name,
'role' => 'presentation',
], $this->attrs);
$attrString = ' ';
foreach ($attrs as $attrName => $attr) {
$attrString .= $attrName . '="' . $attr . '" ';
}
$iconPath = resource_path('icons/' . $this->name . '.svg');
$themeIconPath = theme_path('icons/' . $this->name . '.svg');
if ($themeIconPath && file_exists($themeIconPath)) {
$iconPath = $themeIconPath;
} elseif (!file_exists($iconPath)) {
return '';
}
$fileContents = file_get_contents($iconPath);
return str_replace('<svg', '<svg' . $attrString, $fileContents);
}
}

View File

@@ -23,7 +23,7 @@
"guzzlehttp/guzzle": "^7.4",
"intervention/image": "^2.7",
"laravel/framework": "^9.0",
"laravel/socialite": "^5.2",
"laravel/socialite": "^5.8",
"laravel/tinker": "^2.6",
"league/commonmark": "^2.3",
"league/flysystem-aws-s3-v3": "^3.0",
@@ -37,7 +37,6 @@
"socialiteproviders/gitlab": "^4.1",
"socialiteproviders/microsoft-azure": "^5.1",
"socialiteproviders/okta": "^4.2",
"socialiteproviders/slack": "^4.1",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2"
},

575
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -23,13 +23,13 @@ npm run production
npm run dev
```
BookStack has many integration tests that use Laravel's built-in testing capabilities which makes use of PHPUnit. There is a `mysql_testing` database defined within the app config which is what is used by PHPUnit. This database is set with the database name, username and password all defined as `bookstack-test`. You will have to create that database and that set of credentials before testing.
Further details about the BookStack JavaScript codebase can be found in the [javascript-code.md document](javascript-code.md).
The testing database will also need migrating and seeding beforehand. This can be done by running `composer refresh-test-database`.
## Automated App Testing
Once done you can run `composer test` in the application root directory to run all tests. Tests can be ran in parallel by running them via `composer t`. This will use Laravel's built-in parallel testing functionality, and attempt to create and seed a database instance for each testing thread. If required these parallel testing instances can be reset, before testing again, by running `composer t-reset`.
BookStack has a large suite of PHP tests to cover application functionality. We try to ensure that all additions and changes to the platform are covered with testing.
If the codebase needs to be tested with deprecations, this can be done via uncommenting the relevant line within the TestCase@setUp function.
For details about setting-up, running and writing tests please see the [php-testing.md document](php-testing.md).
## Code Standards

Some files were not shown because too many files have changed in this diff Show More