Compare commits

...

121 Commits

Author SHA1 Message Date
Dan Brown
48f235ea5a Updated version and assets for release v24.05.2 2024-06-10 11:44:06 +01:00
Dan Brown
047771b9f4 Merge branch 'development' into release 2024-06-10 11:43:05 +01:00
Dan Brown
c096b20d9c Updated translator & dependency attribution before release v24.05.2 2024-06-10 11:42:37 +01:00
Dan Brown
11a7ccc37e SAML: Set static type to pass static checks
Not totally clear if underlying code can actually return null, but
playing it safe to remain as-is for now for patch release.
2024-06-10 10:31:35 +01:00
Dan Brown
d9b9e6c0b1 Updated translations with latest Crowdin changes (#5022) 2024-06-10 10:16:34 +01:00
Dan Brown
f18d42f08e Merge pull request #5036 from bradenterpstra01/development
Fixed incorrect code shortcut reference
2024-06-09 23:23:28 +01:00
Dan Brown
4986f008b9 Merge pull request #5052 from michaelortnerit/development
Update docker-compose.yml
2024-06-09 23:20:01 +01:00
Dan Brown
a8ce199e0d Pages: Fixed unused changelog on first page publish
Included test to cover.
For #5056
2024-06-09 17:18:23 +01:00
Dan Brown
c77e8730d6 Deps: Updated php packages via composer 2024-06-09 17:03:29 +01:00
Dan Brown
3406846c82 Images: Updated GIF handling to use native methods
Changes GIF image thumbnail handling to direcly load via gd instead of
going through interventions own handling (which supports frames) since
we don't need animation for our thumbnails, and since performance issues
could arise with GIFs that have large frame counts.

For #5029
2024-06-09 17:00:58 +01:00
Dan Brown
bddc6ae66b Roles: Added max validation for role external auth id field
For #5037
2024-06-08 20:33:34 +01:00
Michael Ortner
bd6e3c022f Update docker-compose.yml
Remove the version: because it is obsolete. See: https://docs.docker.com/compose/compose-file/04-version-and-name/#version-top-level-element-optional
2024-06-04 15:07:09 +02:00
bradenterpstra01
8759fff116 Update wysiwyg.blade.php
Remove the Shift for the numeric shortcut for incline code.

Ctrl+8 instead of Ctrl+Shift+8

I assume Mac is the same but I do not have a Mac to test with.
2024-05-29 18:01:48 -04:00
Dan Brown
6019d2ee14 MFA: Tweaked backup code wording
It was not clear before as it could be taken that the system would
securely store the codes.

Closes #5017
2024-05-23 11:30:53 +01:00
Dan Brown
b5375114d3 Updated version and assets for release v24.05.1 2024-05-21 11:07:36 +01:00
Dan Brown
fc13e56cea Merge branch 'development' into release 2024-05-21 11:07:10 +01:00
Dan Brown
f937bf3abb Updated translator & dependency attribution before release v24.05.1 2024-05-21 11:06:08 +01:00
Dan Brown
586e8963a8 Updated translations with latest Crowdin changes (#4994) 2024-05-21 11:04:27 +01:00
Dan Brown
bdfa76ed9a Deps: Updated php/composer packages 2024-05-20 17:28:53 +01:00
Dan Brown
d133f904d3 Auth: Changed email confirmations to use login attempt user
Negates the need for a public confirmation resend form
since we can instead just send direct to the last session login attempter.
2024-05-20 17:23:15 +01:00
Dan Brown
69af9e0dbd Routes: Added throttling to a range of auth-related endpoints
Some already throttled in some means, but this adds a simple ip-based
non-request-specific layer to many endpoints.
Related to #4993
2024-05-20 14:00:58 +01:00
Dan Brown
72c5141dec File Uploads: Added basic validation response formatting
Tested via app-level validation file limit, and then also with nginx
file post limit.
For #4996
2024-05-18 21:18:15 +01:00
Dan Brown
5651d2c43d Config: Reverted change to cache directory
Change made during Laravel 10 updates to align (Laravel made this change
much earlier in 5.x series) but it caused issues due to folder not
pre-existing and due to potentiall permission issues.
(CLI could create this during update, with non-compatible permissions
for webserver).

For #4999
2024-05-18 20:40:26 +01:00
Dan Brown
fc236f930b Dark Mode: Fixed setting labels missing dark mode handling
Fixes #5018
2024-05-18 20:37:49 +01:00
Dan Brown
570af500f4 WYSIWYG: Added justify cell range cleanup
To help override & gain control of setting text alignment in tables.

- Adds support of clearing "align" attributes in certain operations.
- Updates cell range action handling to dedupe execcommand handling.
- Adds clearing of additional alignment classes on direction control.

Closes #5011
2024-05-16 14:59:30 +01:00
Dan Brown
38913288d8 Devdocs: Fixed visual theme system lang folder reference
Made some other minor updates while there.
Fixes #4998
2024-05-16 14:15:26 +01:00
Dan Brown
c14d7d9509 Merge pull request #5008 from KiDxS/fix-notification-preferences-url-in-email
Fixed notification preferences URL in email
2024-05-16 14:11:15 +01:00
Angelo Geant Gaviola
79f5be4170 Fixed notification preferences URL in email 2024-05-14 17:04:23 +08:00
Dan Brown
77fc37ac25 Updated version and assets for release v24.05 2024-05-11 15:49:29 +01:00
Dan Brown
3424351e84 Merge branch 'development' into release 2024-05-11 15:48:49 +01:00
Dan Brown
a3a776d4a6 Updated translator & dependency attribution before release v24.05 2024-05-11 15:47:38 +01:00
Dan Brown
2b9b0f91cb Updated translations with latest Crowdin changes (#4890) 2024-05-11 15:15:10 +01:00
Dan Brown
424e8f503e Readme: Updated sponsor list 2024-05-10 11:02:20 +01:00
Dan Brown
d206129f3d Deps: Updated composer dependencies 2024-05-05 16:30:04 +01:00
Dan Brown
baad7fa9cb Merge pull request #4987 from BookStackApp/audit_api
Addition of Audit Log API Endpoint
2024-05-05 16:14:09 +01:00
Dan Brown
d54c7b4783 Audit Log: Fixed bad reference to linked entity item 2024-05-05 16:05:21 +01:00
Dan Brown
67df127c26 API: Added to, and updated, testing to cover audit log additions 2024-05-05 15:44:58 +01:00
Dan Brown
3946158e88 API: Added audit log list endpoint
Not yested covered with testing.
Changes database columns for more presentable names and for future use
to connect additional model types.
For #4316
2024-05-04 16:28:18 +01:00
Dan Brown
dd251d9e62 Merge branch 'nesges/development' into development 2024-05-04 14:00:40 +01:00
Dan Brown
5c28bcf865 Registration: Reviewed added simple honeypot, added testing
Also cleaned up old RegistrationController syntax.
Review of #4970
2024-05-04 13:59:41 +01:00
Dan Brown
7b3b28d3f8 Merge pull request #4972 from johnroyer/fix-typo-in-language-file
remove space at the beginning of description
2024-05-03 19:16:23 +01:00
Dan Brown
20e86bf376 Merge branch 'development' of github.com:BookStackApp/BookStack into development 2024-05-03 13:40:18 +01:00
Dan Brown
f9e087330b WYSIWYG: Added text direction support for code editor popup
Editor popup will now reflect the direction of the opened code block.
This also updates in-editor codemirror instances to correcly reflect/use
the direction if set on the inner code elem.

This also defaults new code blocks, when in RTL languages, to be started
in LTR, which can then be changed via in-editor direction controls if
needed. This is on the assumption that most code will be LTR (could not
find much examples of RTL code use).

Fixes #4943
2024-05-03 13:40:00 +01:00
Dan Brown
b0720777be Merge pull request #4985 from BookStackApp/ldap_ca_cert_control
LDAP CA TLS Cert Option, PR Review and continuation
2024-05-02 23:16:16 +01:00
Dan Brown
8087123f2e LDAP: Review, testing and update of LDAP TLS CA cert control
Review of #4913
Added testing to cover option.
Updated option so it can be used for a CA directory, or a CA file.
Updated option name to be somewhat abstracted from original underling
PHP option.

Tested against Jumpcloud.
Testing took hours due to instability which was due to these settings
sticking and being unstable on change until php process restart.
Also due to little documentation for these options.
X_TLS_CACERTDIR option needs cert files to be named via specific hashes
which can be achieved via c_rehash utility.

This also adds detail on STARTTLS failure, which took a long time to
discover due to little detail out there for deeper PHP LDAP debugging.
2024-05-02 23:11:31 +01:00
Dan Brown
4c1c315594 WYSWIYG: Fixed misaligned table cell p line height
Removes an editor-specific line-height which was overriding cell
paragraph line height, causing mis-aligned style compared to viewing.
Checked a range of styles and looked at history, could not see original
purpose of the line-height removed here.
Closes #4960
2024-05-02 15:20:51 +01:00
Dan Brown
f95fb640af WYSWIYG: Improved use of object tags to embed content
- Prevented image toolbars showing for objects embeds due to tinymce
  image placeholder, and added media toolbar.
- Fixed height of object embed placeholder being forced to auto
  when in the editor, allowing height attributed to be properly
  reflected as it would on normal page view.

Closes #4974
2024-05-01 17:22:53 +01:00
Dan Brown
493d8027cd Attachments: Fixed drag into editor in Chrome
Seemed to be chrome specific from testing.
Required editors to have preventDefault called on dragover.
Tested in Chrome, FF, & Safari.
Tested in both editors, and re-tested text/image drop to ensure still
works.

Fixed #4975
2024-04-29 19:21:13 +01:00
Dan Brown
06bb55184c WYSIWYG: Fixed unexpected clearing of table cell styles
Fixes custom table cell clear-format handling since it was being called
on many format removals, not just the clear-formatting action.
This updates the code to specifically run on the RemoveFormat action
which is triggered by the clear formatting button.
Fixes #4964
2024-04-29 17:47:06 +01:00
Dan Brown
6b681961e5 LDAP: Updated default user filter placeholder format
To not conflict with env variables, and to align with placeholders used
for PDF gen command.
Added test to cover, including old format supported for
back-compatibility.
For #4967
2024-04-28 12:29:57 +01:00
Dan Brown
e1149a27e9 Merge pull request #4969 from BookStackApp/pdf_command_option
PDF Exports: New command option and library/option cleanup
2024-04-26 17:06:38 +01:00
Dan Brown
f0dd33c1b4 PDF: Added tests for pdf command, fixed old tests for changes 2024-04-26 15:39:40 +01:00
Zero
5860e1e2ce remove space at the beginning of description 2024-04-25 13:35:36 +08:00
Dan Brown
1c7128c2cb PDF: Added implmentation of command PDF option
Tested quickly manually but not yet covered by PHPUnit tests.
2024-04-24 16:09:53 +01:00
Dan Brown
40200856af PDF: Removed barryvdh snappy to use snappy direct
Also simplifies config format, and updates snappy implmentation to use
the new config file.
Not yet tested.
2024-04-24 15:13:44 +01:00
Dan Brown
bb6670d395 PDF: Started new command option, merged options, simplified dompdf
- Updated DOMPDF to direcly use library instead of depending on barry
wrapper.
- Merged existing export options file into single exports file.
- Defined option for new command option.

Related to #4732
2024-04-22 16:40:42 +01:00
nesges
0d2a268be0 whitespace only 2024-04-21 17:44:01 +02:00
nesges
16399b63be better accessibility for honepot formfield 2024-04-21 16:08:28 +02:00
Dan Brown
d949b97cc1 Merge pull request #4955 from BookStackApp/oidc_userinfo
OIDC userinfo endpoint support
2024-04-19 16:55:29 +01:00
Dan Brown
8b14a701a4 OIDC Userinfo: Fixed issues with validation logic from changes
Also updated test to suit validation changes
2024-04-19 16:43:51 +01:00
Dan Brown
0958909cd9 OIDC Userinfo: Added additional tests to cover jwks usage 2024-04-19 15:05:00 +01:00
Dan Brown
b18cee3dc4 OIDC Userinfo: Added JWT signed response support
Not yet tested, nor checked all response validations.
2024-04-19 14:12:27 +01:00
nesges
31272e60b6 add ambrosia-container to registration form as honeypot for bots: new form field "username" must not be filled 2024-04-19 09:35:09 +02:00
nesges
1b1cb18839 fixed mislabeling of name input 2024-04-19 09:18:34 +02:00
Dan Brown
fa543bbd4d OIDC Userinfo: Started writing tests to cover userinfo calling 2024-04-17 23:26:56 +01:00
Dan Brown
7d7cd32ca7 OIDC Userinfo: Added userinfo data validation, seperated from id token
Wrapped userinfo response in its own class for additional handling and
validation.
Updated userdetails to take abstract claim data, to be populated by
either userinfo data or id token data.
2024-04-17 18:23:58 +01:00
Dan Brown
a71c8c60b7 OIDC: Extracted user detail handling to own OidcUserDetails class
Allows a proper defined object instead of an array an extracts related
logic out of OidcService.
Updated userinfo to only be called if we're missing details.
2024-04-16 18:14:22 +01:00
Dan Brown
9183e7f2fe OIDC Userinfo: Labelled changes to be made during review 2024-04-16 15:52:55 +01:00
Dan Brown
d640411adb OIDC: Cleaned up provider settings, added extra validation
- Added endpoint validation to ensure HTTPS as per spec
- Added some missing types
- Removed redirectUri from OidcProviderSettings since it's not a
  provider-based setting, but a setting for the oauth client, so
  extracted that back to service.
2024-04-16 15:19:51 +01:00
Dan Brown
dc6013fd7e Merge branch 'development' into lukeshu/oidc-development 2024-04-16 14:57:36 +01:00
Dan Brown
80ac66e0a6 Code Editor: Added scala to language list
For #4953
2024-04-16 14:44:17 +01:00
Dan Brown
f05ec4cc26 Tags: Stopped recycle bin tags being counted on index
For #4892
Added test to cover.
2024-04-15 18:44:59 +01:00
Dan Brown
d9ff001ffe Merge pull request #4904 from C0rn3j/optimize-images
15KB lossless optimization via oxipng(PNG) and svgo(SVG)
2024-04-15 18:07:29 +01:00
Dan Brown
0f6cb9ed84 Content styles: Made links underlined for visibility
Inline with A11y recommendations where color may not be reliable on its
own.
Tested various content link scenarios across chrome, safari & FF.
For #4939
2024-04-13 15:48:39 +01:00
Dan Brown
dde1f27882 Merge pull request #4930 from BookStackApp/split_md_js
JS Build: Split markdown to own file, updated packages
2024-04-08 14:46:06 +01:00
Dan Brown
f5e6f9574d JS Build: Split markdown to own file, updated packages
Markdown-related code was growing, representing half of app.js main
bundle code while only being needed in one view/scenario.
This extracts markdown related code to its own built file.
Related to #4858
2024-04-08 14:41:51 +01:00
Dan Brown
ee40adf11a Merge pull request #4921 from BookStackApp/v24-02
v23.02.3 changes
2024-04-05 15:21:05 +01:00
Dan Brown
606f9d92d0 Updated version and assets for release v24.02.3 2024-04-05 15:20:08 +01:00
Dan Brown
a5e25abb9c Merge branch 'v24-02' into release 2024-04-05 15:19:34 +01:00
Dan Brown
3e23f456fe CSS: Removed redundant calc 2024-04-05 15:18:58 +01:00
Dan Brown
b9e2d33ed4 Page Content: Aligned max-width across viewer and editors
For #4916
2024-04-05 15:06:08 +01:00
Dan Brown
19f78dbe6c WYSIWYG descriptions: Allowed anchor target attrs
Allowed since this is a control in the editor UI, but would previously
be stripped by editor config & server-side filtering.
For #4925
2024-04-03 16:46:53 +01:00
Dan Brown
a33dbcb04a References: Fixed references count/list recycle bin interaction
Count and reference list would get references then attempt to load
entities, which could fail to load if in the recycle bin.
This updates the queries to effectively ignore references for items we
can't see (in recycle bin).
Added test to cover.

For #4918
2024-04-01 17:08:53 +01:00
Dan Brown
58f6219cb3 Code: Fixed highlighting issues when no code language set
For #4917
2024-03-31 14:33:08 +01:00
Matt Moore
18269f2c60 Add LDAP_TLS_CACERTFILE to example env file 2024-03-27 13:17:25 +00:00
Matt Moore
06ef95dc5f Change to allow override of CA CERT for LDAPS
Using the env LDAP_TLS_CACERTFILE to set a file to use to override
the CA CERT used to verify LDAPS connections. This is to make this
process easier for docker use.
2024-03-26 16:30:04 +00:00
Martin Rys
76c7166268 Use zopfli for oxipng for extra 3KB~ 2024-03-26 12:31:54 +01:00
Dan Brown
6c063f424c Merge pull request #4907 from BookStackApp/licensing_update
Dependency Licensing Improvements
2024-03-24 12:01:01 +00:00
Dan Brown
3345680f7d Licensing: Added license gen as composer command 2024-03-24 11:58:31 +00:00
Dan Brown
a2fd80954b Licensing: Added links and tests for new licenses endpoint
For #4907
2024-03-23 22:04:18 +00:00
Dan Brown
0c524c7c8f Licensing: Added licenses app view
Extracted many methods to a new "MetaController" in the process.
2024-03-23 16:31:13 +00:00
Martin Rys
5f306a11e7 15KB lossless optimization via oxipng(PNG) and svgo(SVG) 2024-03-23 16:33:11 +01:00
Dan Brown
ed956a4cf0 Licensing: Updated license gen scripts to share logic 2024-03-23 15:33:05 +00:00
Dan Brown
55a2a6db88 Licensing: Added script to gen info for JS packages 2024-03-23 15:19:58 +00:00
Dan Brown
f789359886 Licensing: Added script to build PHP library licensing information 2024-03-22 14:44:23 +00:00
Dan Brown
c221a00e1e Migrations: Added prefix support to schema inspection 2024-03-19 10:30:26 +00:00
Dan Brown
83913af68b Merge branch 'development' into C0rn3j/development 2024-03-18 14:35:16 +00:00
Dan Brown
fa5395a02b Meta: Updated workflows, licence and readme
- Updated license year
- Updated some readme wording, removed lapsed sponsor, Removed twitter
  link, added link to alt github source
- Update cache action for GH workflows since GH was complaining
2024-03-18 14:26:31 +00:00
Dan Brown
85dd71507e Merge pull request #4903 from BookStackApp/laravel10
Framework: Upgrade from Laravel 9 to 10
2024-03-17 17:00:03 +00:00
Dan Brown
28d6292278 Framework: Addressed deprecations 2024-03-17 16:52:19 +00:00
Dan Brown
b4b84f81a0 Deps: Updated custom symfony/mailer package
Done during #4903 work
2024-03-17 16:32:59 +00:00
Dan Brown
2345fd4677 Deps: Updated intervention library from 2 to 3
Major version change, required some changes to API
For #4903
2024-03-17 16:03:12 +00:00
Dan Brown
3250fc732c Testing: Updated PHPUnit from 9 to 10
For #4903
2024-03-17 15:41:11 +00:00
Dan Brown
45d52f27ae Migrations: Updated with type hints instead of php doc
Also updated code to properly import used facades.
For #4903
2024-03-17 15:29:09 +00:00
Dan Brown
d6b7717985 Framework: Fixed issues breaking tests
For #4903
2024-03-16 15:26:34 +00:00
Dan Brown
794671ef32 Framework: Upgrade from Laravel 9 to 10
Following Laravel guidance and GitHub diff.
Not yet in tested state with app-specific changes made.
2024-03-16 15:12:14 +00:00
Martin Rys
70479df5dc Dockerfile: Don't cache 50MB of lists and use a single layer, make it pretty 2024-03-12 14:04:33 +01:00
Dan Brown
07761524af Dev: Fixed flaky OIDC test, updated dev version 2024-03-12 12:08:26 +00:00
Dan Brown
2ed931aeed Updated minimum PHP version from 8.0 to 8.1
For #4893
2024-03-12 11:29:51 +00:00
Dan Brown
b310e87e4c Updated version and assets for release v24.02.2 2024-03-11 14:30:48 +00:00
Dan Brown
425baf9d6e Merge branch 'development' into release 2024-03-10 18:46:05 +00:00
Dan Brown
0d3de40459 Updated translator attribution before release v24.02.1 2024-03-10 18:45:32 +00:00
Dan Brown
3619f79ca6 Updated translations with latest Crowdin changes (#4877) 2024-03-10 18:36:12 +00:00
Dan Brown
c9d9ad10f2 Merge branch 'totp-patch' into development 2024-03-10 18:32:02 +00:00
Dan Brown
d5a689366c MFA: Copied autocomplete changes from totp to backup codes
Also added tests to cover.
Related to #4849
2024-03-10 18:31:01 +00:00
Dan Brown
bc24a1360f TOTP: Added one-time-code autofill
During review of #4849
Tested on Firefox & Chromium desktop.
2024-03-10 18:24:42 +00:00
Dan Brown
77f125208e Page nav: Fixed nbsp being represented as nothing
Now represented in page nav using a normal space to avoid complete
removal of space.
Added test to cover.
For #4836
2024-03-09 15:52:09 +00:00
Dan Brown
b7d4bd5bce Breadcrumbs: Set book/shelf lists to use name ordering
Previously in database order (id) which is not predictable
nor parsable for users.
For #4876
2024-03-09 15:24:44 +00:00
Dan Brown
5a5f0b8de9 Page Display: Fixed highlighting for elements in nested details
For #4878
2024-03-09 15:07:51 +00:00
Mattic
c53c9f6866 Turned off autocomplete for TOTP codes
Small QOL change to turn off autocomplete when entering TOTP codes since they're one time use only.
2024-02-15 09:22:35 -06:00
Luke T. Shumaker
c76d12d1de Oidc: Properly query the UserInfo Endpoint
BooksStack's OIDC Client requests the 'profile' and 'email' scope values
in order to have access to the 'name', 'email', and other claims.  It
looks for these claims in the ID Token that is returned along with the
Access Token.

However, the OIDC-core specification section 5.4 [1] only requires that
the Provider include those claims in the ID Token *if* an Access Token is
not also issued.  If an Access Token is issued, the Provider can leave out
those claims from the ID Token, and the Client is supposed to obtain them
by submitting the Access Token to the UserInfo Endpoint.

So I suppose it's just good luck that the OIDC Providers that BookStack
has been tested with just so happen to also stick those claims in the ID
Token even though they don't have to.  But others (in particular:
https://login.infomaniak.com) don't do so, and require fetching the
UserInfo Endpoint.)

A workaround is currently possible by having the user write a theme with a
ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook that fetches the UserInfo
Endpoint.  This workaround isn't great, for a few reasons:
 1. Asking the user to implement core parts of the OIDC protocol is silly.
 2. The user either needs to re-fetch the .well-known/openid-configuration
    file to discover the endpoint (adding yet another round-trip to each
    login) or hard-code the endpoint, which is fragile.
 3. The hook doesn't receive the HTTP client configuration.

So, have BookStack's OidcService fetch the UserInfo Endpoint and inject
those claims into the ID Token, if a UserInfo Endpoint is defined.
Two points about this:
 - Injecting them into the ID Token's claims is the most obvious approach
   given the current code structure; though I'm not sure it is the best
   approach, perhaps it should instead fetch the user info in
   processAuthorizationResponse() and pass that as an argument to
   processAccessTokenCallback() which would then need a bit of
   restructuring.  But this made sense because it's also how the
   ThemeEvents::OIDC_ID_TOKEN_PRE_VALIDATE hook works.
 - OIDC *requires* that a UserInfo Endpoint exists, so why bother with
   that "if a UserInfo Endpoint is defined" bit?  Simply out of an
   abundance of caution that there's an existing BookStack user that is
   relying on it not fetching the UserInfo Endpoint in order to work with
   a non-compliant OIDC Provider.

[1]: https://openid.net/specs/openid-connect-core-1_0.html#ScopeClaims
2023-12-15 14:11:48 -07:00
611 changed files with 8756 additions and 4318 deletions

View File

@@ -215,10 +215,11 @@ LDAP_SERVER=false
LDAP_BASE_DN=false
LDAP_DN=false
LDAP_PASS=false
LDAP_USER_FILTER=false
LDAP_USER_FILTER="(&(uid={user}))"
LDAP_VERSION=false
LDAP_START_TLS=false
LDAP_TLS_INSECURE=false
LDAP_TLS_CA_CERT=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
@@ -267,6 +268,7 @@ OIDC_ISSUER_DISCOVER=false
OIDC_PUBLIC_KEY=null
OIDC_AUTH_ENDPOINT=null
OIDC_TOKEN_ENDPOINT=null
OIDC_USERINFO_ENDPOINT=null
OIDC_ADDITIONAL_SCOPES=null
OIDC_DUMP_USER_DETAILS=false
OIDC_USER_TO_GROUPS=false
@@ -324,6 +326,14 @@ FILE_UPLOAD_SIZE_LIMIT=50
# Can be 'a4' or 'letter'.
EXPORT_PAGE_SIZE=a4
# Export PDF Command
# Set a command which can be used to convert a HTML file into a PDF file.
# When false this will not be used.
# String values represent the command to be called for conversion.
# Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
# Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
EXPORT_PDF_COMMAND=false
# Set path to wkhtmltopdf binary for PDF generation.
# Can be 'false' or a path path like: '/home/bins/wkhtmltopdf'
# When false, BookStack will attempt to find a wkhtmltopdf in the application

View File

@@ -347,7 +347,7 @@ Taygun Yıldırım (yildirimtaygun) :: Turkish
robing29 :: German
Bruno Eduardo de Jesus Barroso (brunoejb) :: Portuguese, Brazilian
Igor V Belousov (biv) :: Russian
David Bauer (davbauer) :: German
David Bauer (davbauer) :: German; German Informal
Guttorm Hveem (guttormhveem) :: Norwegian Nynorsk; Norwegian Bokmal
Minh Giang Truong (minhgiang1204) :: Vietnamese
Ioannis Ioannides (i.ioannides) :: Greek
@@ -389,7 +389,7 @@ Marc Hagen (MarcHagen) :: Dutch
Kasper Alsøe (zeonos) :: Danish
sultani :: Persian
renge :: Korean
TheGatesDev (thegatesdev) :: Dutch
Tim (thegatesdev) :: Dutch; German Informal; French; Romanian; Catalan; Czech; Danish; German; Finnish; Hungarian; Italian; Japanese; Korean; Polish; Russian; Ukrainian; Chinese Simplified; Chinese Traditional; Portuguese, Brazilian; Persian; Spanish, Argentina; Croatian; Norwegian Nynorsk; Estonian; Uzbek; Norwegian Bokmal
Irdi (irdiOL) :: Albanian
KateBarber :: Welsh
Twister (theuncles75) :: Hebrew
@@ -406,3 +406,26 @@ Jan Picka (polipones) :: Czech
diogoalex991 :: Portuguese
Ehsan Sadeghi (ehsansadeghi) :: Persian
ka_picit :: Danish
cracrayol :: French
CapuaSC :: Dutch
Guardian75 :: German Informal
mr-kanister :: German
Michele Bastianelli (makoblaster) :: Italian
jespernissen :: Danish
Andrey (avmaksimov) :: Russian
Gonzalo Loyola (AlFcl) :: Spanish, Argentina; Spanish
grobert63 :: French
wusst. (Supporti) :: German
MaximMaximS :: Czech
damian-klima :: Slovak
crow_ :: Latvian
JocelynDelalande :: French
Jan (JW-CH) :: German Informal
Timo B (lommes) :: German Informal
Erik Lundstedt (Erik.Lundstedt) :: Swedish
yngams (younessmouhid) :: Arabic
Ohadp :: Hebrew
cbridi :: Portuguese, Brazilian
nanangsb :: Indonesian
Michal Melich (michalmelich) :: Czech
David (david-prv) :: German

View File

@@ -18,7 +18,7 @@ jobs:
- name: Setup PHP
uses: shivammathur/setup-php@v2
with:
php-version: 8.1
php-version: 8.3
extensions: gd, mbstring, json, curl, xml, mysql, ldap
- name: Get Composer Cache Directory
@@ -27,10 +27,10 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-8.1
key: ${{ runner.os }}-composer-8.3
restore-keys: ${{ runner.os }}-composer-
- name: Install composer dependencies

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2', '8.3']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1
@@ -32,7 +32,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

View File

@@ -16,7 +16,7 @@ jobs:
runs-on: ubuntu-22.04
strategy:
matrix:
php: ['8.0', '8.1', '8.2', '8.3']
php: ['8.1', '8.2', '8.3']
steps:
- uses: actions/checkout@v1
@@ -32,7 +32,7 @@ jobs:
echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
- name: Cache composer packages
uses: actions/cache@v3
uses: actions/cache@v4
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2015-2023, Dan Brown and the BookStack Project contributors.
Copyright (c) 2015-2024, Dan Brown and the BookStack Project contributors.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -32,13 +32,17 @@ class ConfirmEmailController extends Controller
/**
* Shows a notice that a user's email address has not been confirmed,
* Also has the option to re-send the confirmation email.
* along with the option to re-send the confirmation email.
*/
public function showAwaiting()
{
$user = $this->loginService->getLastLoginAttemptUser();
if ($user === null) {
$this->showErrorNotification(trans('errors.login_user_not_found'));
return redirect('/login');
}
return view('auth.user-unconfirmed', ['user' => $user]);
return view('auth.register-confirm-awaiting');
}
/**
@@ -90,19 +94,24 @@ class ConfirmEmailController extends Controller
/**
* Resend the confirmation email.
*/
public function resend(Request $request)
public function resend()
{
$this->validate($request, [
'email' => ['required', 'email', 'exists:users,email'],
]);
$user = $this->userRepo->getByEmail($request->get('email'));
$user = $this->loginService->getLastLoginAttemptUser();
if ($user === null) {
$this->showErrorNotification(trans('errors.login_user_not_found'));
return redirect('/login');
}
try {
$this->emailConfirmationService->sendConfirmation($user);
} catch (ConfirmationEmailException $e) {
$this->showErrorNotification($e->getMessage());
return redirect('/login');
} catch (Exception $e) {
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
return redirect('/register/confirm');
return redirect('/register/awaiting');
}
$this->showSuccessNotification(trans('auth.email_confirm_resent'));

View File

@@ -6,6 +6,7 @@ use BookStack\Activity\ActivityType;
use BookStack\Http\Controller;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Password;
use Illuminate\Support\Sleep;
class ForgotPasswordController extends Controller
{
@@ -32,6 +33,10 @@ class ForgotPasswordController extends Controller
'email' => ['required', 'email'],
]);
// Add random pause to the response to help avoid time-base sniffing
// of valid resets via slower email send handling.
Sleep::for(random_int(1000, 3000))->milliseconds();
// We will send the password reset link to this user. Once we have attempted
// to send the link, we will examine the response then see the message we
// need to show to the user. Finally, we'll send out a proper response.

View File

@@ -17,7 +17,7 @@ trait HandlesPartialLogins
$user = auth()->user() ?? $loginService->getLastLoginAttemptUser();
if (!$user) {
throw new NotFoundException('A user for this action could not be found');
throw new NotFoundException(trans('errors.login_user_not_found'));
}
return $user;

View File

@@ -19,20 +19,25 @@ class MfaTotpController extends Controller
protected const SETUP_SECRET_SESSION_KEY = 'mfa-setup-totp-secret';
public function __construct(
protected TotpService $totp
) {
}
/**
* Show a view that generates and displays a TOTP QR code.
*/
public function generate(TotpService $totp)
public function generate()
{
if (session()->has(static::SETUP_SECRET_SESSION_KEY)) {
$totpSecret = decrypt(session()->get(static::SETUP_SECRET_SESSION_KEY));
} else {
$totpSecret = $totp->generateSecret();
$totpSecret = $this->totp->generateSecret();
session()->put(static::SETUP_SECRET_SESSION_KEY, encrypt($totpSecret));
}
$qrCodeUrl = $totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $totp->generateQrCodeSvg($qrCodeUrl);
$qrCodeUrl = $this->totp->generateUrl($totpSecret, $this->currentOrLastAttemptedUser());
$svg = $this->totp->generateQrCodeSvg($qrCodeUrl);
$this->setPageTitle(trans('auth.mfa_gen_totp_title'));
@@ -56,7 +61,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);
@@ -87,7 +92,7 @@ class MfaTotpController extends Controller
'code' => [
'required',
'max:12', 'min:4',
new TotpValidationRule($totpSecret),
new TotpValidationRule($totpSecret, $this->totp),
],
]);

View File

@@ -15,24 +15,13 @@ use Illuminate\Validation\Rules\Password;
class RegisterController extends Controller
{
protected SocialDriverManager $socialDriverManager;
protected RegistrationService $registrationService;
protected LoginService $loginService;
/**
* Create a new controller instance.
*/
public function __construct(
SocialDriverManager $socialDriverManager,
RegistrationService $registrationService,
LoginService $loginService
protected SocialDriverManager $socialDriverManager,
protected RegistrationService $registrationService,
protected LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialDriverManager = $socialDriverManager;
$this->registrationService = $registrationService;
$this->loginService = $loginService;
}
/**
@@ -87,6 +76,8 @@ class RegisterController extends Controller
'name' => ['required', 'min:2', 'max:100'],
'email' => ['required', 'email', 'max:255', 'unique:users'],
'password' => ['required', Password::default()],
// Basic honey for bots that must not be filled in
'username' => ['prohibited'],
]);
}
}

View File

@@ -15,14 +15,11 @@ use Illuminate\Validation\Rules\Password as PasswordRule;
class ResetPasswordController extends Controller
{
protected LoginService $loginService;
public function __construct(LoginService $loginService)
{
public function __construct(
protected LoginService $loginService
) {
$this->middleware('guest');
$this->middleware('guard:standard');
$this->loginService = $loginService;
}
/**

View File

@@ -17,7 +17,7 @@ class EmailConfirmationService extends UserTokenService
*
* @throws ConfirmationEmailException
*/
public function sendConfirmation(User $user)
public function sendConfirmation(User $user): void
{
if ($user->email_confirmed) {
throw new ConfirmationEmailException(trans('errors.email_already_confirmed'), '/login');

View File

@@ -209,6 +209,12 @@ class LdapService
$this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
}
// Configure any user-provided CA cert files for LDAP.
// This option works globally and must be set before a connection is created.
if ($this->config['tls_ca_cert']) {
$this->configureTlsCaCerts($this->config['tls_ca_cert']);
}
$ldapHost = $this->parseServerString($this->config['server']);
$ldapConnection = $this->ldap->connect($ldapHost);
@@ -223,7 +229,14 @@ class LdapService
// Start and verify TLS if it's enabled
if ($this->config['start_tls']) {
$started = $this->ldap->startTls($ldapConnection);
try {
$started = $this->ldap->startTls($ldapConnection);
} catch (\Exception $exception) {
$error = $exception->getMessage() . ' :: ' . ldap_error($ldapConnection);
ldap_get_option($ldapConnection, LDAP_OPT_DIAGNOSTIC_MESSAGE, $detail);
Log::info("LDAP STARTTLS failure: {$error} {$detail}");
throw new LdapException('Could not start TLS connection. Further details in the application log.');
}
if (!$started) {
throw new LdapException('Could not start TLS connection');
}
@@ -234,6 +247,33 @@ class LdapService
return $this->ldapConnection;
}
/**
* Configure TLS CA certs globally for ldap use.
* This will detect if the given path is a directory or file, and set the relevant
* LDAP TLS options appropriately otherwise throw an exception if no file/folder found.
*
* Note: When using a folder, certificates are expected to be correctly named by hash
* which can be done via the c_rehash utility.
*
* @throws LdapException
*/
protected function configureTlsCaCerts(string $caCertPath): void
{
$errMessage = "Provided path [{$caCertPath}] for LDAP TLS CA certs could not be resolved to an existing location";
$path = realpath($caCertPath);
if ($path === false) {
throw new LdapException($errMessage);
}
if (is_dir($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTDIR, $path);
} else if (is_file($path)) {
$this->ldap->setOption(null, LDAP_OPT_X_TLS_CACERTFILE, $path);
} else {
throw new LdapException($errMessage);
}
}
/**
* Parse an LDAP server string and return the host suitable for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
@@ -249,13 +289,18 @@ class LdapService
/**
* Build a filter string by injecting common variables.
* Both "${var}" and "{var}" style placeholders are supported.
* Dollar based are old format but supported for compatibility.
*/
protected function buildFilter(string $filterString, array $attrs): string
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
$newKey = '${' . $key . '}';
$newAttrs[$newKey] = $this->ldap->escape($attrText);
$escapedText = $this->ldap->escape($attrText);
$oldVarKey = '${' . $key . '}';
$newVarKey = '{' . $key . '}';
$newAttrs[$oldVarKey] = $escapedText;
$newAttrs[$newVarKey] = $escapedText;
}
return strtr($filterString, $newAttrs);

View File

@@ -2,36 +2,26 @@
namespace BookStack\Access\Mfa;
use Illuminate\Contracts\Validation\Rule;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class TotpValidationRule implements Rule
class TotpValidationRule implements ValidationRule
{
protected $secret;
protected $totpService;
/**
* Create a new rule instance.
* Takes the TOTP secret that must be system provided, not user provided.
*/
public function __construct(string $secret)
{
$this->secret = $secret;
$this->totpService = app()->make(TotpService::class);
public function __construct(
protected string $secret,
protected TotpService $totpService,
) {
}
/**
* Determine if the validation rule passes.
*/
public function passes($attribute, $value)
public function validate(string $attribute, mixed $value, Closure $fail): void
{
return $this->totpService->verifyCode($value, $this->secret);
}
/**
* Get the validation error message.
*/
public function message()
{
return trans('validation.totp');
$passes = $this->totpService->verifyCode($value, $this->secret);
if (!$passes) {
$fail(trans('validation.totp'));
}
}
}

View File

@@ -2,58 +2,8 @@
namespace BookStack\Access\Oidc;
class OidcIdToken
class OidcIdToken extends OidcJwtWithClaims implements ProvidesClaims
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array[]|string[]
*/
protected array $keys;
public function __construct(string $token, string $issuer, array $keys)
{
$this->keys = $keys;
$this->issuer = $issuer;
$this->parse($token);
}
/**
* Parse the token content into its components.
*/
protected function parse(string $token): void
{
$this->tokenParts = explode('.', $token);
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
}
/**
* Parse a Base64-JSON encoded token part.
* Returns the data as a key-value array or empty array upon error.
*/
protected function parseEncodedTokenPart(string $part): array
{
$json = $this->base64UrlDecode($part) ?: '{}';
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Base64URL decode. Needs some character conversions to be compatible
* with PHP's default base64 handling.
*/
protected function base64UrlDecode(string $encoded): string
{
return base64_decode(strtr($encoded, '-_', '+/'));
}
/**
* Validate all possible parts of the id token.
*
@@ -61,91 +11,12 @@ class OidcIdToken
*/
public function validate(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
parent::validateCommonTokenDetails($clientId);
$this->validateTokenClaims($clientId);
return true;
}
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*
* @return mixed|null
*/
public function getClaim(string $claim)
{
return $this->payload[$claim] ?? null;
}
/**
* Get all returned claims within the token.
*/
public function getAllClaims(): array
{
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
/**
* Validate the signature of the given token and ensure it validates against the provided key.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**
* Validate the claims of the token.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation.
@@ -156,27 +27,18 @@ class OidcIdToken
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
}
// Already done in parent.
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience, or if it contains additional
// audiences not trusted by the Client.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
// Partially done in parent.
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (count($aud) !== 1) {
throw new OidcInvalidTokenException('Token audience value has ' . count($aud) . ' values, Expected 1');
}
if ($aud[0] !== $clientId) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
// 3. If the ID Token contains multiple audiences, the Client SHOULD verify that an azp Claim is present.
// NOTE: Addressed by enforcing a count of 1 above.

View File

@@ -0,0 +1,174 @@
<?php
namespace BookStack\Access\Oidc;
class OidcJwtWithClaims implements ProvidesClaims
{
protected array $header;
protected array $payload;
protected string $signature;
protected string $issuer;
protected array $tokenParts = [];
/**
* @var array[]|string[]
*/
protected array $keys;
public function __construct(string $token, string $issuer, array $keys)
{
$this->keys = $keys;
$this->issuer = $issuer;
$this->parse($token);
}
/**
* Parse the token content into its components.
*/
protected function parse(string $token): void
{
$this->tokenParts = explode('.', $token);
$this->header = $this->parseEncodedTokenPart($this->tokenParts[0]);
$this->payload = $this->parseEncodedTokenPart($this->tokenParts[1] ?? '');
$this->signature = $this->base64UrlDecode($this->tokenParts[2] ?? '') ?: '';
}
/**
* Parse a Base64-JSON encoded token part.
* Returns the data as a key-value array or empty array upon error.
*/
protected function parseEncodedTokenPart(string $part): array
{
$json = $this->base64UrlDecode($part) ?: '{}';
$decoded = json_decode($json, true);
return is_array($decoded) ? $decoded : [];
}
/**
* Base64URL decode. Needs some character conversions to be compatible
* with PHP's default base64 handling.
*/
protected function base64UrlDecode(string $encoded): string
{
return base64_decode(strtr($encoded, '-_', '+/'));
}
/**
* Validate common parts of OIDC JWT tokens.
*
* @throws OidcInvalidTokenException
*/
public function validateCommonTokenDetails(string $clientId): bool
{
$this->validateTokenStructure();
$this->validateTokenSignature();
$this->validateCommonClaims($clientId);
return true;
}
/**
* Fetch a specific claim from this token.
* Returns null if it is null or does not exist.
*/
public function getClaim(string $claim): mixed
{
return $this->payload[$claim] ?? null;
}
/**
* Get all returned claims within the token.
*/
public function getAllClaims(): array
{
return $this->payload;
}
/**
* Replace the existing claim data of this token with that provided.
*/
public function replaceClaims(array $claims): void
{
$this->payload = $claims;
}
/**
* Validate the structure of the given token and ensure we have the required pieces.
* As per https://datatracker.ietf.org/doc/html/rfc7519#section-7.2.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenStructure(): void
{
foreach (['header', 'payload'] as $prop) {
if (empty($this->$prop) || !is_array($this->$prop)) {
throw new OidcInvalidTokenException("Could not parse out a valid {$prop} within the provided token");
}
}
if (empty($this->signature) || !is_string($this->signature)) {
throw new OidcInvalidTokenException('Could not parse out a valid signature within the provided token');
}
}
/**
* Validate the signature of the given token and ensure it validates against the provided key.
*
* @throws OidcInvalidTokenException
*/
protected function validateTokenSignature(): void
{
if ($this->header['alg'] !== 'RS256') {
throw new OidcInvalidTokenException("Only RS256 signature validation is supported. Token reports using {$this->header['alg']}");
}
$parsedKeys = array_map(function ($key) {
try {
return new OidcJwtSigningKey($key);
} catch (OidcInvalidKeyException $e) {
throw new OidcInvalidTokenException('Failed to read signing key with error: ' . $e->getMessage());
}
}, $this->keys);
$parsedKeys = array_filter($parsedKeys);
$contentToSign = $this->tokenParts[0] . '.' . $this->tokenParts[1];
/** @var OidcJwtSigningKey $parsedKey */
foreach ($parsedKeys as $parsedKey) {
if ($parsedKey->verify($contentToSign, $this->signature)) {
return;
}
}
throw new OidcInvalidTokenException('Token signature could not be validated using the provided keys');
}
/**
* Validate common claims for OIDC JWT tokens.
* As per https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation
* and https://openid.net/specs/openid-connect-core-1_0.html#UserInfoResponse
*
* @throws OidcInvalidTokenException
*/
protected function validateCommonClaims(string $clientId): void
{
// 1. The Issuer Identifier for the OpenID Provider (which is typically obtained during Discovery)
// MUST exactly match the value of the iss (issuer) Claim.
if (empty($this->payload['iss']) || $this->issuer !== $this->payload['iss']) {
throw new OidcInvalidTokenException('Missing or non-matching token issuer value');
}
// 2. The Client MUST validate that the aud (audience) Claim contains its client_id value registered
// at the Issuer identified by the iss (issuer) Claim as an audience. The ID Token MUST be rejected
// if the ID Token does not list the Client as a valid audience.
if (empty($this->payload['aud'])) {
throw new OidcInvalidTokenException('Missing token audience value');
}
$aud = is_string($this->payload['aud']) ? [$this->payload['aud']] : $this->payload['aud'];
if (!in_array($clientId, $aud, true)) {
throw new OidcInvalidTokenException('Token audience value did not match the expected client_id');
}
}
}

View File

@@ -18,10 +18,10 @@ class OidcProviderSettings
public string $issuer;
public string $clientId;
public string $clientSecret;
public ?string $redirectUri;
public ?string $authorizationEndpoint;
public ?string $tokenEndpoint;
public ?string $endSessionEndpoint;
public ?string $userinfoEndpoint;
/**
* @var string[]|array[]
@@ -37,7 +37,7 @@ class OidcProviderSettings
/**
* Apply an array of settings to populate setting properties within this class.
*/
protected function applySettingsFromArray(array $settingsArray)
protected function applySettingsFromArray(array $settingsArray): void
{
foreach ($settingsArray as $key => $value) {
if (property_exists($this, $key)) {
@@ -51,9 +51,9 @@ class OidcProviderSettings
*
* @throws InvalidArgumentException
*/
protected function validateInitial()
protected function validateInitial(): void
{
$required = ['clientId', 'clientSecret', 'redirectUri', 'issuer'];
$required = ['clientId', 'clientSecret', 'issuer'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
@@ -73,12 +73,20 @@ class OidcProviderSettings
public function validate(): void
{
$this->validateInitial();
$required = ['keys', 'tokenEndpoint', 'authorizationEndpoint'];
foreach ($required as $prop) {
if (empty($this->$prop)) {
throw new InvalidArgumentException("Missing required configuration \"{$prop}\" value");
}
}
$endpointProperties = ['tokenEndpoint', 'authorizationEndpoint', 'userinfoEndpoint'];
foreach ($endpointProperties as $prop) {
if (is_string($this->$prop) && !str_starts_with($this->$prop, 'https://')) {
throw new InvalidArgumentException("Endpoint value for \"{$prop}\" must start with https://");
}
}
}
/**
@@ -86,7 +94,7 @@ class OidcProviderSettings
*
* @throws OidcIssuerDiscoveryException
*/
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes)
public function discoverFromIssuer(ClientInterface $httpClient, Repository $cache, int $cacheMinutes): void
{
try {
$cacheKey = 'oidc-discovery::' . $this->issuer;
@@ -128,6 +136,10 @@ class OidcProviderSettings
$discoveredSettings['tokenEndpoint'] = $result['token_endpoint'];
}
if (!empty($result['userinfo_endpoint'])) {
$discoveredSettings['userinfoEndpoint'] = $result['userinfo_endpoint'];
}
if (!empty($result['jwks_uri'])) {
$keys = $this->loadKeysFromUri($result['jwks_uri'], $httpClient);
$discoveredSettings['keys'] = $this->filterKeys($keys);
@@ -175,9 +187,9 @@ class OidcProviderSettings
/**
* Get the settings needed by an OAuth provider, as a key=>value array.
*/
public function arrayForProvider(): array
public function arrayForOAuthProvider(): array
{
$settingKeys = ['clientId', 'clientSecret', 'redirectUri', 'authorizationEndpoint', 'tokenEndpoint'];
$settingKeys = ['clientId', 'clientSecret', 'authorizationEndpoint', 'tokenEndpoint', 'userinfoEndpoint'];
$settings = [];
foreach ($settingKeys as $setting) {
$settings[$setting] = $this->$setting;

View File

@@ -12,7 +12,6 @@ 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;
@@ -91,10 +90,10 @@ class OidcService
'issuer' => $config['issuer'],
'clientId' => $config['client_id'],
'clientSecret' => $config['client_secret'],
'redirectUri' => url('/oidc/callback'),
'authorizationEndpoint' => $config['authorization_endpoint'],
'tokenEndpoint' => $config['token_endpoint'],
'endSessionEndpoint' => is_string($config['end_session_endpoint']) ? $config['end_session_endpoint'] : null,
'userinfoEndpoint' => $config['userinfo_endpoint'],
]);
// Use keys if configured
@@ -129,7 +128,10 @@ class OidcService
*/
protected function getProvider(OidcProviderSettings $settings): OidcOAuthProvider
{
$provider = new OidcOAuthProvider($settings->arrayForProvider(), [
$provider = new OidcOAuthProvider([
...$settings->arrayForOAuthProvider(),
'redirectUri' => url('/oidc/callback'),
], [
'httpClient' => $this->http->buildClient(5),
'optionProvider' => new HttpBasicAuthOptionProvider(),
]);
@@ -156,69 +158,6 @@ class OidcService
return array_filter($scopeArr);
}
/**
* Calculate the display name.
*/
protected function getUserDisplayName(OidcIdToken $token, string $defaultValue): string
{
$displayNameAttrString = $this->config()['display_name_claims'] ?? '';
$displayNameAttrs = explode('|', $displayNameAttrString);
$displayName = [];
foreach ($displayNameAttrs as $dnAttr) {
$dnComponent = $token->getClaim($dnAttr) ?? '';
if ($dnComponent !== '') {
$displayName[] = $dnComponent;
}
}
if (count($displayName) == 0) {
$displayName[] = $defaultValue;
}
return implode(' ', $displayName);
}
/**
* Extract the assigned groups from the id token.
*
* @return string[]
*/
protected function getUserGroups(OidcIdToken $token): array
{
$groupsAttr = $this->config()['groups_claim'];
if (empty($groupsAttr)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsAttr);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
/**
* Extract the details of a user from an ID token.
*
* @return array{name: string, email: string, external_id: string, groups: string[]}
*/
protected function getUserDetails(OidcIdToken $token): array
{
$idClaim = $this->config()['external_id_claim'];
$id = $token->getClaim($idClaim);
return [
'external_id' => $id,
'email' => $token->getClaim('email'),
'name' => $this->getUserDisplayName($token, $id),
'groups' => $this->getUserGroups($token),
];
}
/**
* Processes a received access token for a user. Login the user when
* they exist, optionally registering them automatically.
@@ -255,34 +194,35 @@ class OidcService
try {
$idToken->validate($settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("ID token validate failed with error: {$exception->getMessage()}");
throw new OidcException("ID token validation failed with error: {$exception->getMessage()}");
}
$userDetails = $this->getUserDetails($idToken);
$isLoggedIn = auth()->check();
if (empty($userDetails['email'])) {
$userDetails = $this->getUserDetailsFromToken($idToken, $accessToken, $settings);
if (empty($userDetails->email)) {
throw new OidcException(trans('errors.oidc_no_email_address'));
}
if (empty($userDetails->name)) {
$userDetails->name = $userDetails->externalId;
}
$isLoggedIn = auth()->check();
if ($isLoggedIn) {
throw new OidcException(trans('errors.oidc_already_logged_in'));
}
try {
$user = $this->registrationService->findOrRegister(
$userDetails['name'],
$userDetails['email'],
$userDetails['external_id']
$userDetails->name,
$userDetails->email,
$userDetails->externalId
);
} catch (UserRegistrationException $exception) {
throw new OidcException($exception->getMessage());
}
if ($this->shouldSyncGroups()) {
$groups = $userDetails['groups'];
$detachExisting = $this->config()['remove_from_groups'];
$this->groupService->syncUserWithFoundGroups($user, $groups, $detachExisting);
$this->groupService->syncUserWithFoundGroups($user, $userDetails->groups ?? [], $detachExisting);
}
$this->loginService->login($user, 'oidc');
@@ -290,6 +230,45 @@ class OidcService
return $user;
}
/**
* @throws OidcException
*/
protected function getUserDetailsFromToken(OidcIdToken $idToken, OidcAccessToken $accessToken, OidcProviderSettings $settings): OidcUserDetails
{
$userDetails = new OidcUserDetails();
$userDetails->populate(
$idToken,
$this->config()['external_id_claim'],
$this->config()['display_name_claims'] ?? '',
$this->config()['groups_claim'] ?? ''
);
if (!$userDetails->isFullyPopulated($this->shouldSyncGroups()) && !empty($settings->userinfoEndpoint)) {
$provider = $this->getProvider($settings);
$request = $provider->getAuthenticatedRequest('GET', $settings->userinfoEndpoint, $accessToken->getToken());
$response = new OidcUserinfoResponse(
$provider->getResponse($request),
$settings->issuer,
$settings->keys,
);
try {
$response->validate($idToken->getClaim('sub'), $settings->clientId);
} catch (OidcInvalidTokenException $exception) {
throw new OidcException("Userinfo endpoint response validation failed with error: {$exception->getMessage()}");
}
$userDetails->populate(
$response,
$this->config()['external_id_claim'],
$this->config()['display_name_claims'] ?? '',
$this->config()['groups_claim'] ?? ''
);
}
return $userDetails;
}
/**
* Get the OIDC config from the application.
*/

View File

@@ -0,0 +1,75 @@
<?php
namespace BookStack\Access\Oidc;
use Illuminate\Support\Arr;
class OidcUserDetails
{
public function __construct(
public ?string $externalId = null,
public ?string $email = null,
public ?string $name = null,
public ?array $groups = null,
) {
}
/**
* Check if the user details are fully populated for our usage.
*/
public function isFullyPopulated(bool $groupSyncActive): bool
{
$hasEmpty = empty($this->externalId)
|| empty($this->email)
|| empty($this->name)
|| ($groupSyncActive && empty($this->groups));
return !$hasEmpty;
}
/**
* Populate user details from the given claim data.
*/
public function populate(
ProvidesClaims $claims,
string $idClaim,
string $displayNameClaims,
string $groupsClaim,
): void {
$this->externalId = $claims->getClaim($idClaim) ?? $this->externalId;
$this->email = $claims->getClaim('email') ?? $this->email;
$this->name = static::getUserDisplayName($displayNameClaims, $claims) ?? $this->name;
$this->groups = static::getUserGroups($groupsClaim, $claims) ?? $this->groups;
}
protected static function getUserDisplayName(string $displayNameClaims, ProvidesClaims $token): string
{
$displayNameClaimParts = explode('|', $displayNameClaims);
$displayName = [];
foreach ($displayNameClaimParts as $claim) {
$component = $token->getClaim(trim($claim)) ?? '';
if ($component !== '') {
$displayName[] = $component;
}
}
return implode(' ', $displayName);
}
protected static function getUserGroups(string $groupsClaim, ProvidesClaims $token): array
{
if (empty($groupsClaim)) {
return [];
}
$groupsList = Arr::get($token->getAllClaims(), $groupsClaim);
if (!is_array($groupsList)) {
return [];
}
return array_values(array_filter($groupsList, function ($val) {
return is_string($val);
}));
}
}

View File

@@ -0,0 +1,67 @@
<?php
namespace BookStack\Access\Oidc;
use Psr\Http\Message\ResponseInterface;
class OidcUserinfoResponse implements ProvidesClaims
{
protected array $claims = [];
protected ?OidcJwtWithClaims $jwt = null;
public function __construct(ResponseInterface $response, string $issuer, array $keys)
{
$contentType = $response->getHeader('Content-Type')[0];
if ($contentType === 'application/json') {
$this->claims = json_decode($response->getBody()->getContents(), true);
}
if ($contentType === 'application/jwt') {
$this->jwt = new OidcJwtWithClaims($response->getBody()->getContents(), $issuer, $keys);
$this->claims = $this->jwt->getAllClaims();
}
}
/**
* @throws OidcInvalidTokenException
*/
public function validate(string $idTokenSub, string $clientId): bool
{
if (!is_null($this->jwt)) {
$this->jwt->validateCommonTokenDetails($clientId);
}
$sub = $this->getClaim('sub');
// Spec: v1.0 5.3.2: The sub (subject) Claim MUST always be returned in the UserInfo Response.
if (!is_string($sub) || empty($sub)) {
throw new OidcInvalidTokenException("No valid subject value found in userinfo data");
}
// Spec: v1.0 5.3.2: The sub Claim in the UserInfo Response MUST be verified to exactly match the sub Claim in the ID Token;
// if they do not match, the UserInfo Response values MUST NOT be used.
if ($idTokenSub !== $sub) {
throw new OidcInvalidTokenException("Subject value provided in the userinfo endpoint does not match the provided ID token value");
}
// Spec v1.0 5.3.4 Defines the following:
// Verify that the OP that responded was the intended OP through a TLS server certificate check, per RFC 6125 [RFC6125].
// This is effectively done as part of the HTTP request we're making through CURLOPT_SSL_VERIFYHOST on the request.
// If the Client has provided a userinfo_encrypted_response_alg parameter during Registration, decrypt the UserInfo Response using the keys specified during Registration.
// We don't currently support JWT encryption for OIDC
// If the response was signed, the Client SHOULD validate the signature according to JWS [JWS].
// This is done as part of the validateCommonClaims above.
return true;
}
public function getClaim(string $claim): mixed
{
return $this->claims[$claim] ?? null;
}
public function getAllClaims(): array
{
return $this->claims;
}
}

View File

@@ -0,0 +1,17 @@
<?php
namespace BookStack\Access\Oidc;
interface ProvidesClaims
{
/**
* Fetch a specific claim.
* Returns null if it is null or does not exist.
*/
public function getClaim(string $claim): mixed;
/**
* Get all contained claims.
*/
public function getAllClaims(): array;
}

View File

@@ -133,6 +133,7 @@ class Saml2Service
// value so that the exact encoding format is matched when checking the signature.
// This is primarily due to ADFS encoding query params with lowercase percent encoding while
// PHP (And most other sensible providers) standardise on uppercase.
/** @var ?string $samlRedirect */
$samlRedirect = $toolkit->processSLO(true, $requestId, true, null, true);
$errors = $toolkit->getErrors();

View File

@@ -27,14 +27,14 @@ class ActivityQueries
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->with(['user'])
->skip($count * $page)
->take($count)
->get();
$this->listLoader->loadIntoRelations($activityList->all(), 'entity', false);
$this->listLoader->loadIntoRelations($activityList->all(), 'loggable', false);
return $this->filterSimilar($activityList);
}
@@ -59,14 +59,14 @@ class ActivityQueries
$query->where(function (Builder $query) use ($queryIds) {
foreach ($queryIds as $morphClass => $idArr) {
$query->orWhere(function (Builder $innerQuery) use ($morphClass, $idArr) {
$innerQuery->where('entity_type', '=', $morphClass)
->whereIn('entity_id', $idArr);
$innerQuery->where('loggable_type', '=', $morphClass)
->whereIn('loggable_id', $idArr);
});
}
});
$activity = $query->orderBy('created_at', 'desc')
->with(['entity' => function (Relation $query) {
->with(['loggable' => function (Relation $query) {
$query->withTrashed();
}, 'user.avatar'])
->skip($count * ($page - 1))
@@ -82,7 +82,7 @@ class ActivityQueries
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissions
->restrictEntityRelationQuery(Activity::query(), 'activities', 'entity_id', 'entity_type')
->restrictEntityRelationQuery(Activity::query(), 'activities', 'loggable_id', 'loggable_type')
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)

View File

@@ -0,0 +1,28 @@
<?php
namespace BookStack\Activity\Controllers;
use BookStack\Activity\Models\Activity;
use BookStack\Http\ApiController;
class AuditLogApiController extends ApiController
{
/**
* Get a listing of audit log events in the system.
* The loggable relation fields currently only relates to core
* content types (page, book, bookshelf, chapter) but this may be
* used more in the future across other types.
* Requires permission to manage both users and system settings.
*/
public function list()
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$query = Activity::query()->with(['user']);
return $this->apiListingResponse($query, [
'id', 'type', 'detail', 'user_id', 'loggable_id', 'loggable_type', 'ip', 'created_at',
]);
}
}

View File

@@ -32,7 +32,7 @@ class AuditLogController extends Controller
$query = Activity::query()
->with([
'entity' => fn ($query) => $query->withTrashed(),
'loggable' => fn ($query) => $query->withTrashed(),
'user',
])
->orderBy($listOptions->getSort(), $listOptions->getOrder());

View File

@@ -15,26 +15,24 @@ use Illuminate\Support\Str;
/**
* @property string $type
* @property User $user
* @property Entity $entity
* @property Entity $loggable
* @property string $detail
* @property string $entity_type
* @property int $entity_id
* @property string $loggable_type
* @property int $loggable_id
* @property int $user_id
* @property Carbon $created_at
* @property Carbon $updated_at
*/
class Activity extends Model
{
/**
* Get the entity for this activity.
* Get the loggable model related to this activity.
* Currently only used for entities (previously entity_[id/type] columns).
* Could be used for others but will need an audit of uses where assumed
* to be entities.
*/
public function entity(): MorphTo
public function loggable(): MorphTo
{
if ($this->entity_type === '') {
$this->entity_type = null;
}
return $this->morphTo('entity');
return $this->morphTo('loggable');
}
/**
@@ -47,8 +45,8 @@ class Activity extends Model
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class, 'entity_id', 'entity_id')
->whereColumn('activities.entity_type', '=', 'joint_permissions.entity_type');
return $this->hasMany(JointPermission::class, 'entity_id', 'loggable_id')
->whereColumn('activities.loggable_type', '=', 'joint_permissions.entity_type');
}
/**
@@ -74,6 +72,6 @@ class Activity extends Model
*/
public function isSimilarTo(self $activityB): bool
{
return [$this->type, $this->entity_type, $this->entity_id] === [$activityB->type, $activityB->entity_type, $activityB->entity_id];
return [$this->type, $this->loggable_type, $this->loggable_id] === [$activityB->type, $activityB->loggable_type, $activityB->loggable_id];
}
}

View File

@@ -43,7 +43,7 @@ abstract class BaseActivityNotification extends MailNotification
protected function buildReasonFooterLine(LocaleDefinition $locale): LinkedMailMessageLine
{
return new LinkedMailMessageLine(
url('/preferences/notifications'),
url('/my-account/notifications'),
$locale->trans('notifications.footer_reason'),
$locale->trans('notifications.footer_reason_link'),
);

View File

@@ -38,7 +38,8 @@ class TagRepo
DB::raw('SUM(IF(entity_type = \'book\', 1, 0)) as book_count'),
DB::raw('SUM(IF(entity_type = \'bookshelf\', 1, 0)) as shelf_count'),
])
->orderBy($sort, $listOptions->getOrder());
->orderBy($sort, $listOptions->getOrder())
->whereHas('entity');
if ($nameFilter) {
$query->where('name', '=', $nameFilter);

View File

@@ -32,8 +32,8 @@ class ActivityLogger
$activity->detail = $detailToStore;
if ($detail instanceof Entity) {
$activity->entity_id = $detail->id;
$activity->entity_type = $detail->getMorphClass();
$activity->loggable_id = $detail->id;
$activity->loggable_type = $detail->getMorphClass();
}
$activity->save();
@@ -64,9 +64,9 @@ class ActivityLogger
public function removeEntity(Entity $entity): void
{
$entity->activity()->update([
'detail' => $entity->name,
'entity_id' => null,
'entity_type' => null,
'detail' => $entity->name,
'loggable_id' => null,
'loggable_type' => null,
]);
}

View File

@@ -9,7 +9,6 @@ use BookStack\Entities\Queries\QueryRecentlyViewed;
use BookStack\Entities\Queries\QueryTopFavourites;
use BookStack\Entities\Tools\PageContent;
use BookStack\Http\Controller;
use BookStack\Uploads\FaviconHandler;
use BookStack\Util\SimpleListOptions;
use Illuminate\Http\Request;
@@ -112,48 +111,4 @@ class HomeController extends Controller
return view('home.default', $commonData);
}
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$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

@@ -0,0 +1,67 @@
<?php
namespace BookStack\App;
use BookStack\Http\Controller;
use BookStack\Uploads\FaviconHandler;
class MetaController extends Controller
{
/**
* Show the view for /robots.txt.
*/
public function robots()
{
$sitePublic = setting('app-public', false);
$allowRobots = config('app.allow_robots');
if ($allowRobots === null) {
$allowRobots = $sitePublic;
}
return response()
->view('misc.robots', ['allowRobots' => $allowRobots])
->header('Content-Type', 'text/plain');
}
/**
* Show the route for 404 responses.
*/
public function notFound()
{
return response()->view('errors.404', [], 404);
}
/**
* Serve the application favicon.
* Ensures a 'favicon.ico' file exists at the web root location (if writable) to be served
* directly by the webserver in the future.
*/
public function favicon(FaviconHandler $favicons)
{
$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());
}
/**
* Show license information for the application.
*/
public function licenses()
{
$this->setPageTitle(trans('settings.licenses'));
return view('help.licenses', [
'license' => file_get_contents(base_path('LICENSE')),
'phpLibData' => file_get_contents(base_path('dev/licensing/php-library-licenses.txt')),
'jsLibData' => file_get_contents(base_path('dev/licensing/js-library-licenses.txt')),
]);
}
}

View File

@@ -25,7 +25,7 @@ class AppServiceProvider extends ServiceProvider
* Custom container bindings to register.
* @var string[]
*/
public $bindings = [
public array $bindings = [
ExceptionRenderer::class => BookStackExceptionHandlerPage::class,
];
@@ -33,7 +33,7 @@ class AppServiceProvider extends ServiceProvider
* Custom singleton bindings to register.
* @var string[]
*/
public $singletons = [
public array $singletons = [
'activity' => ActivityLogger::class,
SettingService::class => SettingService::class,
SocialDriverManager::class => SocialDriverManager::class,
@@ -42,11 +42,19 @@ class AppServiceProvider extends ServiceProvider
];
/**
* Bootstrap any application services.
*
* @return void
* Register any application services.
*/
public function boot()
public function register(): void
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
/**
* Bootstrap any application services.
*/
public function boot(): void
{
// Set root URL
$appUrl = config('app.url');
@@ -67,16 +75,4 @@ class AppServiceProvider extends ServiceProvider
'page' => Page::class,
]);
}
/**
* Register any application services.
*
* @return void
*/
public function register()
{
$this->app->singleton(PermissionApplicator::class, function ($app) {
return new PermissionApplicator(null);
});
}
}

View File

@@ -18,10 +18,8 @@ class AuthServiceProvider extends ServiceProvider
{
/**
* Bootstrap the application services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Password Configuration
// Changes here must be reflected in ApiDocsGenerate@getValidationAsString.
@@ -58,10 +56,8 @@ class AuthServiceProvider extends ServiceProvider
/**
* Register the application services.
*
* @return void
*/
public function register()
public function register(): void
{
Auth::provider('external-users', function ($app, array $config) {
return new ExternalBaseUserProvider($config['model']);

View File

@@ -29,20 +29,16 @@ class EventServiceProvider extends ServiceProvider
/**
* Register any events for your application.
*
* @return void
*/
public function boot()
public function boot(): void
{
//
}
/**
* Determine if events and listeners should be automatically discovered.
*
* @return bool
*/
public function shouldDiscoverEvents()
public function shouldDiscoverEvents(): bool
{
return false;
}

View File

@@ -24,10 +24,8 @@ class RouteServiceProvider extends ServiceProvider
/**
* Define your route model bindings, pattern filters, etc.
*
* @return void
*/
public function boot()
public function boot(): void
{
$this->configureRateLimiting();
@@ -41,10 +39,8 @@ class RouteServiceProvider extends ServiceProvider
* Define the "web" routes for the application.
*
* These routes all receive session state, CSRF protection, etc.
*
* @return void
*/
protected function mapWebRoutes()
protected function mapWebRoutes(): void
{
Route::group([
'middleware' => 'web',
@@ -65,10 +61,8 @@ class RouteServiceProvider extends ServiceProvider
* Define the "api" routes for the application.
*
* These routes are typically stateless.
*
* @return void
*/
protected function mapApiRoutes()
protected function mapApiRoutes(): void
{
Route::group([
'middleware' => 'api',
@@ -81,13 +75,15 @@ class RouteServiceProvider extends ServiceProvider
/**
* Configure the rate limiters for the application.
*
* @return void
*/
protected function configureRateLimiting()
protected function configureRateLimiting(): void
{
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('public', function (Request $request) {
return Limit::perMinute(10)->by($request->ip());
});
}
}

View File

@@ -10,10 +10,8 @@ class ThemeServiceProvider extends ServiceProvider
{
/**
* Register services.
*
* @return void
*/
public function register()
public function register(): void
{
// Register the ThemeService as a singleton
$this->app->singleton(ThemeService::class, fn ($app) => new ThemeService());
@@ -21,10 +19,8 @@ class ThemeServiceProvider extends ServiceProvider
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Boot up the theme system
$themeService = $this->app->make(ThemeService::class);

View File

@@ -11,10 +11,8 @@ class TranslationServiceProvider extends BaseProvider
{
/**
* Register the service provider.
*
* @return void
*/
public function register()
public function register(): void
{
$this->registerLoader();
@@ -41,10 +39,8 @@ class TranslationServiceProvider extends BaseProvider
/**
* Register the translation line loader.
* Overrides the default register action from Laravel so a custom loader can be used.
*
* @return void
*/
protected function registerLoader()
protected function registerLoader(): void
{
$this->app->singleton('translation.loader', function ($app) {
return new FileLoader($app['files'], $app['path.lang']);

View File

@@ -12,10 +12,8 @@ class ViewTweaksServiceProvider extends ServiceProvider
{
/**
* Bootstrap services.
*
* @return void
*/
public function boot()
public function boot(): void
{
// Set paginator to use bootstrap-style pagination
Paginator::useBootstrap();

View File

@@ -9,6 +9,7 @@
*/
use Illuminate\Support\Facades\Facade;
use Illuminate\Support\ServiceProvider;
return [
@@ -113,46 +114,20 @@ return [
],
// Application Service Providers
'providers' => [
// Laravel Framework Service Providers...
Illuminate\Auth\AuthServiceProvider::class,
Illuminate\Broadcasting\BroadcastServiceProvider::class,
Illuminate\Bus\BusServiceProvider::class,
Illuminate\Cache\CacheServiceProvider::class,
Illuminate\Foundation\Providers\ConsoleSupportServiceProvider::class,
Illuminate\Cookie\CookieServiceProvider::class,
Illuminate\Database\DatabaseServiceProvider::class,
Illuminate\Encryption\EncryptionServiceProvider::class,
Illuminate\Filesystem\FilesystemServiceProvider::class,
Illuminate\Foundation\Providers\FoundationServiceProvider::class,
Illuminate\Hashing\HashServiceProvider::class,
Illuminate\Mail\MailServiceProvider::class,
Illuminate\Notifications\NotificationServiceProvider::class,
Illuminate\Pagination\PaginationServiceProvider::class,
Illuminate\Pipeline\PipelineServiceProvider::class,
Illuminate\Queue\QueueServiceProvider::class,
Illuminate\Redis\RedisServiceProvider::class,
Illuminate\Auth\Passwords\PasswordResetServiceProvider::class,
Illuminate\Session\SessionServiceProvider::class,
Illuminate\Validation\ValidationServiceProvider::class,
Illuminate\View\ViewServiceProvider::class,
'providers' => ServiceProvider::defaultProviders()->merge([
// Third party service providers
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
SocialiteProviders\Manager\ServiceProvider::class,
// BookStack custom service providers
\BookStack\App\Providers\ThemeServiceProvider::class,
\BookStack\App\Providers\AppServiceProvider::class,
\BookStack\App\Providers\AuthServiceProvider::class,
\BookStack\App\Providers\EventServiceProvider::class,
\BookStack\App\Providers\RouteServiceProvider::class,
\BookStack\App\Providers\TranslationServiceProvider::class,
\BookStack\App\Providers\ValidationRuleServiceProvider::class,
\BookStack\App\Providers\ViewTweaksServiceProvider::class,
],
BookStack\App\Providers\ThemeServiceProvider::class,
BookStack\App\Providers\AppServiceProvider::class,
BookStack\App\Providers\AuthServiceProvider::class,
BookStack\App\Providers\EventServiceProvider::class,
BookStack\App\Providers\RouteServiceProvider::class,
BookStack\App\Providers\TranslationServiceProvider::class,
BookStack\App\Providers\ValidationRuleServiceProvider::class,
BookStack\App\Providers\ViewTweaksServiceProvider::class,
])->toArray(),
// Class Aliases
// This array of class aliases to be registered on application start.

View File

@@ -54,6 +54,7 @@ return [
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache'),
'lock_path' => storage_path('framework/cache'),
],
'memcached' => [

View File

@@ -1,23 +1,45 @@
<?php
/**
* DOMPDF configuration options.
* Export configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
$dompdfPaperSizeMap = [
'a4' => 'a4',
'letter' => 'letter',
];
$exportPageSize = env('EXPORT_PAGE_SIZE', 'a4');
return [
'show_warnings' => false, // Throw an Exception on warnings from dompdf
// Set a command which can be used to convert a HTML file into a PDF file.
// When false this will not be used.
// String values represent the command to be called for conversion.
// Supports '{input_html_path}' and '{output_pdf_path}' placeholder values.
// Example: EXPORT_PDF_COMMAND="/scripts/convert.sh {input_html_path} {output_pdf_path}"
'pdf_command' => env('EXPORT_PDF_COMMAND', false),
'options' => [
// 2024-04: Snappy/WKHTMLtoPDF now considered deprecated in regard to BookStack support.
'snappy' => [
'pdf_binary' => env('WKHTMLTOPDF', false),
'options' => [
'print-media-type' => true,
'outline' => true,
'page-size' => $snappyPaperSizeMap[$exportPageSize] ?? 'A4',
],
],
'dompdf' => [
/**
* The location of the DOMPDF font directory.
*
@@ -101,7 +123,7 @@ return [
/**
* Whether to enable font subsetting or not.
*/
'enable_fontsubsetting' => false,
'enable_font_subsetting' => false,
/**
* The PDF rendering backend to use.
@@ -165,7 +187,7 @@ return [
*
* @see CPDF_Adapter::PAPER_SIZES for valid sizes ('letter', 'legal', 'A4', etc.)
*/
'default_paper_size' => $dompdfPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'a4',
'default_paper_size' => $dompdfPaperSizeMap[$exportPageSize] ?? 'a4',
/**
* The default paper orientation.
@@ -268,15 +290,6 @@ return [
*/
'font_height_ratio' => 1.1,
/**
* Enable CSS float.
*
* Allows people to disabled CSS float support
*
* @var bool
*/
'enable_css_float' => true,
/**
* Use the HTML5 Lib parser.
*
@@ -286,5 +299,4 @@ return [
*/
'enable_html5_parser' => true,
],
];

View File

@@ -21,7 +21,8 @@ return [
// passwords are hashed using the Bcrypt algorithm. This will allow you
// to control the amount of time it takes to hash the given password.
'bcrypt' => [
'rounds' => env('BCRYPT_ROUNDS', 10),
'rounds' => env('BCRYPT_ROUNDS', 12),
'verify' => true,
],
// Argon Options

View File

@@ -4,6 +4,7 @@ use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
use Monolog\Processor\PsrLogMessageProcessor;
/**
* Logging configuration options.
@@ -49,6 +50,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
'replace_placeholders' => true,
],
'daily' => [
@@ -56,6 +58,7 @@ return [
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 7,
'replace_placeholders' => true,
],
'stderr' => [
@@ -65,16 +68,20 @@ return [
'with' => [
'stream' => 'php://stderr',
],
'processors' => [PsrLogMessageProcessor::class],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
'facility' => LOG_USER,
'replace_placeholders' => true,
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
'replace_placeholders' => true,
],
// Custom errorlog implementation that logs out a plain,
@@ -88,6 +95,7 @@ return [
'formatter_with' => [
'format' => '%message%',
],
'replace_placeholders' => true,
],
'null' => [

View File

@@ -35,6 +35,7 @@ return [
// OAuth2 endpoints.
'authorization_endpoint' => env('OIDC_AUTH_ENDPOINT', null),
'token_endpoint' => env('OIDC_TOKEN_ENDPOINT', null),
'userinfo_endpoint' => env('OIDC_USERINFO_ENDPOINT', null),
// OIDC RP-Initiated Logout endpoint URL.
// A false value force-disables RP-Initiated Logout.

View File

@@ -40,6 +40,12 @@ return [
],
// Job batching
'batching' => [
'database' => 'mysql',
'table' => 'job_batches',
],
// Failed queue job logging
'failed' => [
'driver' => 'database-uuids',

View File

@@ -123,7 +123,7 @@ return [
'dn' => env('LDAP_DN', false),
'pass' => env('LDAP_PASS', false),
'base_dn' => env('LDAP_BASE_DN', false),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'),
'user_filter' => env('LDAP_USER_FILTER', '(&(uid={user}))'),
'version' => env('LDAP_VERSION', false),
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
@@ -133,6 +133,7 @@ return [
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
'tls_ca_cert' => env('LDAP_TLS_CA_CERT', false),
'start_tls' => env('LDAP_START_TLS', false),
'thumbnail_attribute' => env('LDAP_THUMBNAIL_ATTRIBUTE', null),
],

View File

@@ -85,4 +85,11 @@ return [
// do not enable this as other CSRF protection services are in place.
// Options: lax, strict, none
'same_site' => 'lax',
// Partitioned Cookies
// Setting this value to true will tie the cookie to the top-level site for
// a cross-site context. Partitioned cookies are accepted by the browser
// when flagged "secure" and the Same-Site attribute is set to "none".
'partitioned' => false,
];

View File

@@ -1,34 +0,0 @@
<?php
/**
* SnappyPDF configuration options.
*
* Changes to these config files are not supported by BookStack and may break upon updates.
* Configuration should be altered via the `.env` file or environment variables.
* Do not edit this file unless you're happy to maintain any changes yourself.
*/
$snappyPaperSizeMap = [
'a4' => 'A4',
'letter' => 'Letter',
];
return [
'pdf' => [
'enabled' => true,
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [
'outline' => true,
'page-size' => $snappyPaperSizeMap[env('EXPORT_PAGE_SIZE', 'a4')] ?? 'A4',
],
'env' => [],
],
'image' => [
'enabled' => false,
'binary' => '/usr/local/bin/wkhtmltoimage',
'timeout' => false,
'options' => [],
'env' => [],
],
];

View File

@@ -19,7 +19,7 @@ class ClearActivityCommand extends Command
*
* @var string
*/
protected $description = 'Clear user activity from the system';
protected $description = 'Clear user (audit-log) activity from the system';
/**
* Execute the console command.

View File

@@ -137,7 +137,7 @@ abstract class Entity extends Model implements Sluggable, Favouritable, Viewable
*/
public function activity(): MorphMany
{
return $this->morphMany(Activity::class, 'entity')
return $this->morphMany(Activity::class, 'loggable')
->orderBy('created_at', 'desc');
}

View File

@@ -77,7 +77,8 @@ class PageRepo
$this->updateTemplateStatusAndContentFromInput($draft, $input);
$this->baseRepo->update($draft, $input);
$this->revisionRepo->storeNewForPage($draft, trans('entities.pages_initial_revision'));
$summary = trim($input['summary'] ?? '') ?: trans('entities.pages_initial_revision');
$this->revisionRepo->storeNewForPage($draft, $summary);
$draft->refresh();
Activity::add(ActivityType::PAGE_CREATE, $draft);

View File

@@ -379,7 +379,7 @@ class PageContent
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
$tree = collect($nodeList)->map(function (DOMElement $header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = trim(str_replace("\xc2\xa0", ' ', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [

View File

@@ -2,27 +2,28 @@
namespace BookStack\Entities\Tools;
use Barryvdh\DomPDF\Facade\Pdf as DomPDF;
use Barryvdh\Snappy\Facades\SnappyPdf;
use BookStack\Exceptions\PdfExportException;
use Knp\Snappy\Pdf as SnappyPdf;
use Dompdf\Dompdf;
use Symfony\Component\Process\Process;
class PdfGenerator
{
const ENGINE_DOMPDF = 'dompdf';
const ENGINE_WKHTML = 'wkhtml';
const ENGINE_COMMAND = 'command';
/**
* Generate PDF content from the given HTML content.
* @throws PdfExportException
*/
public function fromHtml(string $html): string
{
if ($this->getActiveEngine() === self::ENGINE_WKHTML) {
$pdf = SnappyPDF::loadHTML($html);
$pdf->setOption('print-media-type', true);
} else {
$pdf = DomPDF::loadHTML($html);
}
return $pdf->output();
return match ($this->getActiveEngine()) {
self::ENGINE_COMMAND => $this->renderUsingCommand($html),
self::ENGINE_WKHTML => $this->renderUsingWkhtml($html),
default => $this->renderUsingDomPdf($html)
};
}
/**
@@ -31,8 +32,101 @@ class PdfGenerator
*/
public function getActiveEngine(): string
{
$useWKHTML = config('snappy.pdf.binary') !== false && config('app.allow_untrusted_server_fetching') === true;
if (config('exports.pdf_command')) {
return self::ENGINE_COMMAND;
}
return $useWKHTML ? self::ENGINE_WKHTML : self::ENGINE_DOMPDF;
if ($this->getWkhtmlBinaryPath() && config('app.allow_untrusted_server_fetching') === true) {
return self::ENGINE_WKHTML;
}
return self::ENGINE_DOMPDF;
}
protected function getWkhtmlBinaryPath(): string
{
$wkhtmlBinaryPath = config('exports.snappy.pdf_binary');
if (file_exists(base_path('wkhtmltopdf'))) {
$wkhtmlBinaryPath = base_path('wkhtmltopdf');
}
return $wkhtmlBinaryPath ?: '';
}
protected function renderUsingDomPdf(string $html): string
{
$options = config('exports.dompdf');
$domPdf = new Dompdf($options);
$domPdf->setBasePath(base_path('public'));
$domPdf->loadHTML($this->convertEntities($html));
$domPdf->render();
return (string) $domPdf->output();
}
/**
* @throws PdfExportException
*/
protected function renderUsingCommand(string $html): string
{
$command = config('exports.pdf_command');
$inputHtml = tempnam(sys_get_temp_dir(), 'bs-pdfgen-html-');
$outputPdf = tempnam(sys_get_temp_dir(), 'bs-pdfgen-output-');
$replacementsByPlaceholder = [
'{input_html_path}' => $inputHtml,
'{output_pdf_path}' => $outputPdf,
];
foreach ($replacementsByPlaceholder as $placeholder => $replacement) {
$command = str_replace($placeholder, escapeshellarg($replacement), $command);
}
file_put_contents($inputHtml, $html);
$process = Process::fromShellCommandline($command);
$process->setTimeout(15);
$process->run();
if (!$process->isSuccessful()) {
throw new PdfExportException("PDF Export via command failed with exit code {$process->getExitCode()}, stdout: {$process->getOutput()}, stderr: {$process->getErrorOutput()}");
}
$pdfContents = file_get_contents($outputPdf);
unlink($outputPdf);
if ($pdfContents === false) {
throw new PdfExportException("PDF Export via command failed, unable to read PDF output file");
} else if (empty($pdfContents)) {
throw new PdfExportException("PDF Export via command failed, PDF output file is empty");
}
return $pdfContents;
}
protected function renderUsingWkhtml(string $html): string
{
$snappy = new SnappyPdf($this->getWkhtmlBinaryPath());
$options = config('exports.snappy.options');
return $snappy->getOutputFromHtml($html, $options);
}
/**
* Taken from https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/src/PDF.php
* Copyright (c) 2021 barryvdh, MIT License
* https://github.com/barryvdh/laravel-dompdf/blob/v2.1.1/LICENSE
*/
protected function convertEntities(string $subject): string
{
$entities = [
'€' => '&euro;',
'£' => '&pound;',
];
foreach ($entities as $search => $replace) {
$subject = str_replace($search, $replace, $subject);
}
return $subject;
}
}

View File

@@ -43,13 +43,13 @@ class SiblingFetcher
if ($contextShelf) {
$entities = $contextShelf->visibleBooks()->get();
} else {
$entities = $this->queries->books->visibleForList()->get();
$entities = $this->queries->books->visibleForList()->orderBy('name', 'asc')->get();
}
}
// Shelf
if ($entity instanceof Bookshelf) {
$entities = $this->queries->shelves->visibleForList()->get();
$entities = $this->queries->shelves->visibleForList()->orderBy('name', 'asc')->get();
}
return $entities;

View File

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

View File

@@ -9,16 +9,10 @@ use Illuminate\Http\Request;
class StoppedAuthenticationException extends \Exception implements Responsable
{
protected $user;
protected $loginService;
/**
* StoppedAuthenticationException constructor.
*/
public function __construct(User $user, LoginService $loginService)
{
$this->user = $user;
$this->loginService = $loginService;
public function __construct(
protected User $user,
protected LoginService $loginService
) {
parent::__construct();
}

View File

@@ -45,11 +45,11 @@ class Kernel extends HttpKernel
];
/**
* The application's route middleware.
* The application's middleware aliases.
*
* @var array
*/
protected $routeMiddleware = [
protected $middlewareAliases = [
'auth' => \BookStack\Http\Middleware\Authenticate::class,
'can' => \BookStack\Http\Middleware\CheckUserHasPermission::class,
'guest' => \BookStack\Http\Middleware\RedirectIfAuthenticated::class,

View File

@@ -6,19 +6,16 @@ use BookStack\App\Providers\RouteServiceProvider;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Symfony\Component\HttpFoundation\Response;
class RedirectIfAuthenticated
{
/**
* Handle an incoming request.
*
* @param \Illuminate\Http\Request $request
* @param \Closure $next
* @param string|null ...$guards
*
* @return mixed
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next, ...$guards)
public function handle(Request $request, Closure $next, string ...$guards): Response
{
$guards = empty($guards) ? [null] : $guards;

View File

@@ -9,7 +9,7 @@ class ThrottleApiRequests extends Middleware
/**
* Resolve the number of attempts if the user is authenticated or not.
*/
protected function resolveMaxAttempts($request, $maxAttempts)
protected function resolveMaxAttempts($request, $maxAttempts): int
{
return (int) config('api.requests_per_minute');
}

View File

@@ -9,9 +9,9 @@ class TrustHosts extends Middleware
/**
* Get the host patterns that should be trusted.
*
* @return array
* @return array<int, string|null>
*/
public function hosts()
public function hosts(): array
{
return [
$this->allSubdomainsOfApplicationUrl(),

View File

@@ -41,7 +41,8 @@ class ReferenceFetcher
{
$baseQuery = Reference::query()
->where('to_type', '=', $entity->getMorphClass())
->where('to_id', '=', $entity->id);
->where('to_id', '=', $entity->id)
->whereHas('from');
return $this->permissions->restrictEntityRelationQuery(
$baseQuery,

View File

@@ -8,16 +8,22 @@ class FileLoader extends BaseLoader
{
/**
* Load the messages for the given locale.
*
* Extends Laravel's translation FileLoader to look in multiple directories
* so that we can load in translation overrides from the theme file if wanted.
*
* Note: As of using Laravel 10, this may now be redundant since Laravel's
* file loader supports multiple paths. This needs further testing though
* to confirm if Laravel works how we expect, since we specifically need
* the theme folder to be able to partially override core lang files.
*
* @param string $locale
* @param string $group
* @param string|null $namespace
*
* @return array
*/
public function load($locale, $group, $namespace = null)
public function load($locale, $group, $namespace = null): array
{
if ($group === '*' && $namespace === '*') {
return $this->loadJsonPaths($locale);
@@ -25,8 +31,8 @@ class FileLoader extends BaseLoader
if (is_null($namespace) || $namespace === '*') {
$themePath = theme_path('lang');
$themeTranslations = $themePath ? $this->loadPath($themePath, $locale, $group) : [];
$originalTranslations = $this->loadPath($this->path, $locale, $group);
$themeTranslations = $themePath ? $this->loadPaths([$themePath], $locale, $group) : [];
$originalTranslations = $this->loadPaths($this->paths, $locale, $group);
return array_merge($originalTranslations, $themeTranslations);
}

View File

@@ -6,8 +6,14 @@ use BookStack\Exceptions\ImageUploadException;
use Exception;
use GuzzleHttp\Psr7\Utils;
use Illuminate\Support\Facades\Cache;
use Intervention\Image\Gd\Driver;
use Intervention\Image\Image as InterventionImage;
use Intervention\Image\Decoders\BinaryImageDecoder;
use Intervention\Image\Drivers\Gd\Decoders\NativeObjectDecoder;
use Intervention\Image\Drivers\Gd\Driver;
use Intervention\Image\Encoders\AutoEncoder;
use Intervention\Image\Encoders\PngEncoder;
use Intervention\Image\Interfaces\ImageInterface as InterventionImage;
use Intervention\Image\ImageManager;
use Intervention\Image\Origin;
class ImageResizer
{
@@ -95,7 +101,7 @@ class ImageResizer
}
// If not in cache and thumbnail does not exist, generate thumb and cache path
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio);
$thumbData = $this->resizeImageData($imageData, $width, $height, $keepRatio, $this->getExtension($image));
$disk->put($thumbFilePath, $thumbData, true);
Cache::put($thumbCacheKey, $thumbFilePath, static::THUMBNAIL_CACHE_TIME);
@@ -116,7 +122,7 @@ class ImageResizer
?string $format = null,
): string {
try {
$thumb = $this->interventionFromImageData($imageData);
$thumb = $this->interventionFromImageData($imageData, $format);
} catch (Exception $e) {
throw new ImageUploadException(trans('errors.cannot_create_thumbs'));
}
@@ -124,15 +130,17 @@ class ImageResizer
$this->orientImageToOriginalExif($thumb, $imageData);
if ($keepRatio) {
$thumb->resize($width, $height, function ($constraint) {
$constraint->aspectRatio();
$constraint->upsize();
});
$thumb->scaleDown($width, $height);
} else {
$thumb->fit($width, $height);
$thumb->cover($width, $height);
}
$thumbData = (string) $thumb->encode($format);
$encoder = match ($format) {
'png' => new PngEncoder(),
default => new AutoEncoder(),
};
$thumbData = (string) $thumb->encode($encoder);
// Use original image data if we're keeping the ratio
// and the resizing does not save any space.
@@ -148,10 +156,23 @@ class ImageResizer
* Performs some manual library usage to ensure image is specifically loaded
* from given binary data instead of data being misinterpreted.
*/
protected function interventionFromImageData(string $imageData): InterventionImage
protected function interventionFromImageData(string $imageData, ?string $fileType): InterventionImage
{
$driver = new Driver();
return $driver->decoder->initFromBinary($imageData);
$manager = new ImageManager(new Driver());
// Ensure gif images are decoded natively instead of deferring to intervention GIF
// handling since we don't need the added animation support.
$isGif = $fileType === 'gif';
$decoder = $isGif ? NativeObjectDecoder::class : BinaryImageDecoder::class;
$input = $isGif ? @imagecreatefromstring($imageData) : $imageData;
$image = $manager->read($input, $decoder);
if ($isGif) {
$image->setOrigin(new Origin('image/gif'));
}
return $image;
}
/**
@@ -202,7 +223,15 @@ class ImageResizer
*/
protected function isGif(Image $image): bool
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION)) === 'gif';
return $this->getExtension($image) === 'gif';
}
/**
* Get the extension for the given image, normalised to lower-case.
*/
protected function getExtension(Image $image): string
{
return strtolower(pathinfo($image->path, PATHINFO_EXTENSION));
}
/**

View File

@@ -21,7 +21,7 @@ class RoleApiController extends ApiController
'display_name' => ['required', 'string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'permissions.*' => ['string'],
],
@@ -29,7 +29,7 @@ class RoleApiController extends ApiController
'display_name' => ['string', 'min:3', 'max:180'],
'description' => ['string', 'max:180'],
'mfa_enforced' => ['boolean'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'permissions.*' => ['string'],
]

View File

@@ -75,7 +75,7 @@ class RoleController extends Controller
$data = $this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => ['max:180'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'mfa_enforced' => ['string'],
]);
@@ -109,7 +109,7 @@ class RoleController extends Controller
$data = $this->validate($request, [
'display_name' => ['required', 'min:3', 'max:180'],
'description' => ['max:180'],
'external_auth_id' => ['string'],
'external_auth_id' => ['string', 'max:180'],
'permissions' => ['array'],
'mfa_enforced' => ['string'],
]);

View File

@@ -20,7 +20,7 @@ class HtmlDescriptionFilter
*/
protected static array $allowedAttrsByElements = [
'p' => [],
'a' => ['href', 'title'],
'a' => ['href', 'title', 'target'],
'ol' => [],
'ul' => [],
'li' => [],

View File

@@ -8,7 +8,7 @@
"license": "MIT",
"type": "project",
"require": {
"php": "^8.0.2",
"php": "^8.1.0",
"ext-curl": "*",
"ext-dom": "*",
"ext-fileinfo": "*",
@@ -17,14 +17,14 @@
"ext-mbstring": "*",
"ext-xml": "*",
"bacon/bacon-qr-code": "^2.0",
"barryvdh/laravel-dompdf": "^2.0",
"barryvdh/laravel-snappy": "^1.0",
"doctrine/dbal": "^3.5",
"dompdf/dompdf": "^2.0",
"guzzlehttp/guzzle": "^7.4",
"intervention/image": "^2.7",
"laravel/framework": "^9.0",
"intervention/image": "^3.5",
"knplabs/knp-snappy": "^1.5",
"laravel/framework": "^10.10",
"laravel/socialite": "^5.10",
"laravel/tinker": "^2.6",
"laravel/tinker": "^2.8",
"league/commonmark": "^2.3",
"league/flysystem-aws-s3-v3": "^3.0",
"league/html-to-markdown": "^5.0.0",
@@ -39,17 +39,17 @@
"socialiteproviders/okta": "^4.2",
"socialiteproviders/twitch": "^5.3",
"ssddanbrown/htmldiff": "^1.0.2",
"ssddanbrown/symfony-mailer": "6.0.x-dev"
"ssddanbrown/symfony-mailer": "6.4.x-dev"
},
"require-dev": {
"fakerphp/faker": "^1.21",
"itsgoingd/clockwork": "^5.1",
"mockery/mockery": "^1.5",
"nunomaduro/collision": "^6.4",
"nunomaduro/collision": "^7.0",
"larastan/larastan": "^2.7",
"phpunit/phpunit": "^9.5",
"phpunit/phpunit": "^10.0",
"squizlabs/php_codesniffer": "^3.7",
"ssddanbrown/asserthtml": "^2.0"
"ssddanbrown/asserthtml": "^3.0"
},
"autoload": {
"psr-4": {
@@ -72,6 +72,10 @@
"lint": "phpcs",
"test": "phpunit",
"t-reset": "@php artisan test --recreate-databases",
"build-licenses": [
"@php ./dev/licensing/gen-js-licenses",
"@php ./dev/licensing/gen-php-licenses"
],
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
@@ -99,7 +103,7 @@
"preferred-install": "dist",
"sort-packages": true,
"platform": {
"php": "8.0.2"
"php": "8.1.0"
}
},
"extra": {

2415
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,17 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('users', function (Blueprint $table) {
$table->increments('id');
@@ -26,17 +27,15 @@ return new class extends Migration
'name' => 'Admin',
'email' => 'admin@admin.com',
'password' => bcrypt('password'),
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('users');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('password_resets', function (Blueprint $table) {
$table->string('email')->index();
@@ -21,10 +20,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('password_resets');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('books', function (Blueprint $table) {
$table->increments('id');
@@ -23,10 +22,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('books');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('pages', function (Blueprint $table) {
$table->increments('id');
@@ -27,10 +26,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('pages');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('images', function (Blueprint $table) {
$table->increments('id');
@@ -22,10 +21,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('images');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('chapters', function (Blueprint $table) {
$table->increments('id');
@@ -25,10 +24,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('chapters');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('pages', function (Blueprint $table) {
$table->integer('created_by');
@@ -32,10 +31,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('created_by');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('page_revisions', function (Blueprint $table) {
$table->increments('id');
@@ -25,10 +24,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('page_revisions');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('activities', function (Blueprint $table) {
$table->increments('id');
@@ -26,10 +25,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('activities');
}

View File

@@ -10,17 +10,18 @@
* @url https://github.com/Zizaco/entrust
*/
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
// Create table for storing roles
Schema::create('roles', function (Blueprint $table) {
@@ -71,22 +72,22 @@ return new class extends Migration
'name' => 'admin',
'display_name' => 'Admin',
'description' => 'Administrator of the whole application',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
$editorId = DB::table('roles')->insertGetId([
'name' => 'editor',
'display_name' => 'Editor',
'description' => 'User can edit Books, Chapters & Pages',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
$viewerId = DB::table('roles')->insertGetId([
'name' => 'viewer',
'display_name' => 'Viewer',
'description' => 'User can view books & their content behind authentication',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
// Create default CRUD permissions and allocate to admins and editors
@@ -97,8 +98,8 @@ return new class extends Migration
$newPermId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
['permission_id' => $newPermId, 'role_id' => $adminId],
@@ -115,8 +116,8 @@ return new class extends Migration
$newPermId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
'permission_id' => $newPermId,
@@ -138,10 +139,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('permission_role');
Schema::drop('permissions');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('settings', function (Blueprint $table) {
$table->string('setting_key')->primary()->indexed();
@@ -21,10 +20,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('settings');
}

View File

@@ -2,13 +2,13 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
@@ -23,15 +23,14 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
$sm = Schema::getConnection()->getDoctrineSchemaManager();
$pages = $sm->listTableDetails('pages');
$books = $sm->listTableDetails('books');
$chapters = $sm->listTableDetails('chapters');
$prefix = DB::getTablePrefix();
$pages = $sm->introspectTable($prefix . 'pages');
$books = $sm->introspectTable($prefix . 'books');
$chapters = $sm->introspectTable($prefix . 'chapters');
if ($pages->hasIndex('search')) {
Schema::table('pages', function (Blueprint $table) {

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('social_accounts', function (Blueprint $table) {
$table->increments('id');
@@ -24,10 +23,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('social_accounts');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->boolean('email_confirmed')->default(true);
@@ -26,10 +25,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('email_confirmed');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('views', function (Blueprint $table) {
$table->increments('id');
@@ -24,10 +23,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('views');
}

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('books', function (Blueprint $table) {
$table->index('slug');
@@ -48,10 +47,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('books', function (Blueprint $table) {
$table->dropIndex('books_slug_index');

View File

@@ -2,13 +2,13 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
@@ -23,15 +23,14 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
$sm = Schema::getConnection()->getDoctrineSchemaManager();
$pages = $sm->listTableDetails('pages');
$books = $sm->listTableDetails('books');
$chapters = $sm->listTableDetails('chapters');
$prefix = DB::getTablePrefix();
$pages = $sm->introspectTable($prefix . 'pages');
$books = $sm->introspectTable($prefix . 'books');
$chapters = $sm->introspectTable($prefix . 'chapters');
if ($pages->hasIndex('name_search')) {
Schema::table('pages', function (Blueprint $table) {

View File

@@ -3,15 +3,14 @@
use BookStack\Uploads\Image;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('images', function (Blueprint $table) {
$table->string('path', 400);
@@ -27,10 +26,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('images', function (Blueprint $table) {
$table->dropColumn('type');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->integer('image_id')->default(0);
@@ -19,10 +18,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('image_id');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('users', function (Blueprint $table) {
$table->string('external_auth_id')->index();
@@ -19,10 +18,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('users', function (Blueprint $table) {
$table->dropColumn('external_auth_id');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('page_revisions', function (Blueprint $table) {
$table->string('slug');
@@ -22,10 +21,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('page_revisions', function (Blueprint $table) {
$table->dropColumn('slug');

View File

@@ -1,15 +1,15 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
// Get roles with permissions we need to change
$adminRoleId = DB::table('roles')->where('name', '=', 'admin')->first()->id;
@@ -30,8 +30,8 @@ return new class extends Migration
$permissionId = DB::table('permissions')->insertGetId([
'name' => $name,
'display_name' => $displayName,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
@@ -47,8 +47,8 @@ return new class extends Migration
$permissionId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
@@ -66,10 +66,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
// Get roles with permissions we need to change
$adminRoleId = DB::table('roles')->where('name', '=', 'admin')->first()->id;
@@ -85,8 +83,8 @@ return new class extends Migration
$permissionId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,
@@ -103,8 +101,8 @@ return new class extends Migration
$permissionId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower($op),
'display_name' => $op . ' ' . $entity,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
DB::table('permission_role')->insert([
'role_id' => $adminRoleId,

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('images', function (Blueprint $table) {
$table->integer('uploaded_to')->default(0);
@@ -46,10 +45,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('images', function (Blueprint $table) {
$table->dropColumn('uploaded_to');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('page_revisions', function (Blueprint $table) {
$table->string('type')->default('version');
@@ -20,10 +19,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('page_revisions', function (Blueprint $table) {
$table->dropColumn('type');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('pages', function (Blueprint $table) {
$table->boolean('draft')->default(false);
@@ -20,10 +19,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('draft');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('pages', function (Blueprint $table) {
$table->longText('markdown')->default('');
@@ -23,10 +22,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('pages', function (Blueprint $table) {
$table->dropColumn('markdown');

View File

@@ -1,15 +1,15 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
$currentRoles = DB::table('roles')->get();
@@ -21,8 +21,8 @@ return new class extends Migration
$permId = DB::table('permissions')->insertGetId([
'name' => strtolower($entity) . '-' . strtolower(str_replace(' ', '-', $op)),
'display_name' => $op . ' ' . $entity . 's',
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
]);
// Assign view permission to all current roles
foreach ($currentRoles as $role) {
@@ -37,10 +37,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
// Delete the new view permission
$entities = ['Book', 'Page', 'Chapter'];

View File

@@ -1,17 +1,18 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Str;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('joint_permissions', function (Blueprint $table) {
$table->increments('id');
@@ -48,8 +49,8 @@ return new class extends Migration
'description' => 'The role given to public visitors if allowed',
'system_name' => 'public',
'hidden' => true,
'created_at' => \Carbon\Carbon::now()->toDateTimeString(),
'updated_at' => \Carbon\Carbon::now()->toDateTimeString(),
'created_at' => Carbon::now()->toDateTimeString(),
'updated_at' => Carbon::now()->toDateTimeString(),
];
// Ensure unique name
@@ -79,10 +80,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('joint_permissions');

View File

@@ -2,15 +2,14 @@
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::create('tags', function (Blueprint $table) {
$table->increments('id');
@@ -30,10 +29,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::drop('tags');
}

View File

@@ -1,15 +1,14 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
Schema::table('page_revisions', function ($table) {
$table->string('summary')->nullable();
@@ -18,10 +17,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('page_revisions', function ($table) {
$table->dropColumn('summary');

View File

@@ -1,17 +1,17 @@
<?php
use Carbon\Carbon;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
public function up(): void
{
// Remove the hidden property from roles
Schema::table('roles', function (Blueprint $table) {
@@ -29,8 +29,8 @@ return new class extends Migration
'name' => 'Guest',
'system_name' => 'public',
'email_confirmed' => true,
'created_at' => \Carbon\Carbon::now(),
'updated_at' => \Carbon\Carbon::now(),
'created_at' => Carbon::now(),
'updated_at' => Carbon::now(),
]);
// Get the public role
@@ -45,10 +45,8 @@ return new class extends Migration
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
public function down(): void
{
Schema::table('roles', function (Blueprint $table) {
$table->boolean('hidden')->default(false);

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