Compare commits

...

608 Commits

Author SHA1 Message Date
Dan Brown
29ddb6e1b9 Updated version and assets for release v0.29.3 2020-05-12 22:34:01 +01:00
Dan Brown
2ff90e2ff0 Merge branch 'master' into release 2020-05-12 22:33:27 +01:00
Dan Brown
9666c8c0f7 Updated shelf-list view to enforce view permissions for child books
- Aligned shelf-homepage behaviour to match
- Updated testing to cover.

For #2111
2020-05-12 22:21:45 +01:00
Dan Brown
04ecc128a2 Updated version and assets for release v0.29.2 2020-05-02 11:49:21 +01:00
Dan Brown
87d1d3423b Merge branch 'master' into release 2020-05-02 11:48:48 +01:00
Dan Brown
d3ec38bee3 Removed unused function in registration service 2020-05-02 01:07:30 +01:00
Dan Brown
413cac23ae Added command to regenerate comment content 2020-05-01 23:41:47 +01:00
Dan Brown
3c26e7b727 Updated comment md rendering to be server-side 2020-05-01 23:24:11 +01:00
Dan Brown
2a2d0aa15b Fixed incorrect color code causing yellow/orange code blocks 2020-04-29 18:28:26 +01:00
Dan Brown
4818192a2a Updated version and assets for release v0.29.1 2020-04-28 12:30:31 +01:00
Dan Brown
965dd97f54 Merge branch 'master' into release 2020-04-28 12:30:09 +01:00
Dan Brown
dfceab7cfa Updated translator attribution before release v0.29.1 2020-04-28 12:30:02 +01:00
Dan Brown
00c77e494b Updated ci with php7.4, update locale array 2020-04-28 12:28:19 +01:00
Dan Brown
ce8cea6a9f New Crowdin translations (#2071)
* New translations common.php (Korean)

* New translations settings.php (Korean)
2020-04-28 12:25:15 +01:00
Dan Brown
6f2a2c05bf New Crowdin translations (#2028)
* New translations settings.php (Chinese Simplified)

* New translations common.php (Spanish)

* New translations common.php (Spanish, Argentina)

* New translations common.php (Turkish)

* New translations common.php (French)

* New translations auth.php (Dutch)

* New translations common.php (Dutch)

* New translations entities.php (Dutch)

* New translations activities.php (Thai)

* New translations auth.php (Thai)

* New translations common.php (Thai)

* New translations components.php (Thai)

* New translations entities.php (Thai)

* New translations errors.php (Thai)

* New translations pagination.php (Thai)

* New translations passwords.php (Thai)

* New translations settings.php (Thai)

* New translations validation.php (Thai)
2020-04-28 10:19:42 +01:00
Dan Brown
898d0b5817 Added multi-select to book-sort interface
As discussed in #2064

Closes #2067
2020-04-27 16:53:27 +01:00
Dan Brown
4ef362143b Added auto-focus behaviour to page editor
- Will focus on title if the value of the field matches the default text
for the current user's language.
- Otherwise will focus on the editor body.
- Added and tested on both editors.

For #2036
2020-04-27 15:54:39 +01:00
Dan Brown
8ce38d2158 Fixed not shown existing-email warning on new ldap user
- Reduced the amount of different exceptions from LDAP attempt so they
can be handled more consistently.
- Added test to cover.
- Also cleaned up LDAP tests to reduce boilterplate mocks.

Fixes #2048
2020-04-26 12:13:00 +01:00
Dan Brown
468fec80de Updated WYSIWYG callout shortcut to handle child elems
- Will now search for a callout on/above the selected node rather than
only using the selected node.
- Issues previously where callout shortcut would not cycle if called
when child formatting was currently selected inside the callout.

For #2061
2020-04-26 09:26:41 +01:00
Dan Brown
2ec4ad1181 Tweaked ListingResponseBuilder to help avoid future issues
- Updated so none of the method mutate the query throughout the function
so that the query can be handled in a sane way, Since we were already
encountering issues due to internal method call order.
2020-04-25 22:15:59 +01:00
Dan Brown
a17b82bdde Fixed api query total not taking filters into account 2020-04-25 21:37:52 +01:00
Dan Brown
8fb1f7c361 Fixed floated content extending past page body
As shown in #2055
2020-04-25 19:59:23 +01:00
Dan Brown
c20110b6ae Fixed issue where callout and quotes overlap floated images
For #2055
2020-04-25 19:55:16 +01:00
Dan Brown
a880b1d5c5 Fixed selection not visible - dark theme codemirror
Fixes #2060
2020-04-25 19:19:41 +01:00
Dan Brown
07831df2d3 Updated user-create endpoint so saml and ldap is consistent. 2020-04-25 18:28:07 +01:00
Dan Brown
519283e643 Authenticated admins on all guards upon login
For #2031
2020-04-25 18:19:22 +01:00
Dan Brown
79a949836b Fixed incorrect API listing total when offset set
Fixes #2043
2020-04-25 16:38:11 +01:00
Dan Brown
195b74926c Updated version and assets for release v0.29.0 2020-04-13 16:10:23 +01:00
Dan Brown
2120db12b2 Merge branch 'master' into release 2020-04-13 16:10:11 +01:00
Dan Brown
0883b0533b Merge branch 'master' of github.com:BookStackApp/BookStack 2020-04-13 15:43:53 +01:00
Dan Brown
d030620846 Updated translator contributor file from crowdin 2020-04-13 15:43:20 +01:00
Dan Brown
687c4247ae New Crowdin translations (#2005)
* New translations settings.php (Portuguese)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Russian)

* New translations settings.php (Korean)

* New translations settings.php (Persian)

* New translations settings.php (Polish)

* New translations settings.php (Swedish)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Turkish)

* New translations settings.php (Slovak)

* New translations settings.php (Slovenian)

* New translations settings.php (Spanish)

* New translations settings.php (Czech)

* New translations settings.php (Danish)

* New translations settings.php (Dutch)

* New translations settings.php (Arabic)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Italian)

* New translations settings.php (Hungarian)

* New translations settings.php (Japanese)

* New translations settings.php (French)

* New translations settings.php (German)

* New translations settings.php (Hebrew)

* New translations settings.php (German Informal)

* New translations settings.php (Vietnamese)

* New translations settings.php (Ukrainian)

* New translations activities.php (Turkish)

* New translations activities.php (Turkish)

* New translations auth.php (Turkish)

* New translations common.php (Turkish)

* New translations auth.php (Turkish)

* New translations components.php (Turkish)

* New translations common.php (Turkish)

* New translations components.php (Turkish)

* New translations entities.php (Turkish)

* New translations entities.php (Turkish)

* New translations errors.php (Turkish)

* New translations entities.php (Turkish)

* New translations passwords.php (Turkish)

* New translations settings.php (Turkish)

* New translations errors.php (Turkish)

* New translations settings.php (Turkish)

* New translations settings.php (Turkish)

* New translations validation.php (Turkish)

* New translations settings.php (Turkish)

* New translations validation.php (Turkish)

* New translations common.php (Turkish)

* New translations components.php (Turkish)

* New translations validation.php (Turkish)

* New translations components.php (Turkish)

* New translations entities.php (Turkish)

* New translations errors.php (Turkish)

* New translations settings.php (Turkish)

* New translations validation.php (Turkish)

* New translations passwords.php (Arabic)

* New translations auth.php (Arabic)

* New translations auth.php (Slovak)

* New translations passwords.php (Russian)

* New translations passwords.php (Slovak)

* New translations auth.php (Slovenian)

* New translations passwords.php (Slovenian)

* New translations auth.php (Spanish)

* New translations passwords.php (Portuguese, Brazilian)

* New translations passwords.php (Polish)

* New translations auth.php (Portuguese)

* New translations auth.php (Russian)

* New translations passwords.php (Portuguese)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Ukrainian)

* New translations passwords.php (Ukrainian)

* New translations auth.php (Vietnamese)

* New translations passwords.php (Vietnamese)

* New translations auth.php (German Informal)

* New translations passwords.php (German Informal)

* New translations passwords.php (Turkish)

* New translations passwords.php (Spanish)

* New translations auth.php (Spanish, Argentina)

* New translations passwords.php (Spanish, Argentina)

* New translations auth.php (Swedish)

* New translations passwords.php (Swedish)

* New translations auth.php (Turkish)

* New translations components.php (Turkish)

* New translations entities.php (Turkish)

* New translations auth.php (Polish)

* New translations passwords.php (Danish)

* New translations auth.php (Dutch)

* New translations passwords.php (Dutch)

* New translations auth.php (Danish)

* New translations auth.php (French)

* New translations passwords.php (French)

* New translations auth.php (Chinese Simplified)

* New translations passwords.php (Chinese Simplified)

* New translations auth.php (Chinese Traditional)

* New translations passwords.php (Chinese Traditional)

* New translations auth.php (Czech)

* New translations passwords.php (Czech)

* New translations auth.php (German)

* New translations auth.php (Korean)

* New translations auth.php (Japanese)

* New translations passwords.php (Japanese)

* New translations passwords.php (Korean)

* New translations auth.php (Persian)

* New translations passwords.php (Persian)

* New translations passwords.php (Italian)

* New translations passwords.php (German)

* New translations auth.php (Hebrew)

* New translations passwords.php (Hebrew)

* New translations auth.php (Hungarian)

* New translations passwords.php (Hungarian)

* New translations auth.php (Italian)

* New translations entities.php (Turkish)

* New translations settings.php (Turkish)

* New translations validation.php (Turkish)

* New translations passwords.php (Turkish)

* New translations entities.php (Turkish)

* New translations errors.php (Turkish)

* New translations validation.php (Turkish)

* New translations auth.php (Turkish)

* New translations auth.php (Spanish)

* New translations passwords.php (Spanish)

* New translations settings.php (Spanish)

* New translations auth.php (Spanish, Argentina)

* New translations passwords.php (Spanish, Argentina)

* New translations entities.php (Turkish)

* New translations auth.php (French)

* New translations passwords.php (French)

* New translations settings.php (French)

* New translations common.php (Russian)

* New translations common.php (Slovak)

* New translations common.php (Slovenian)

* New translations common.php (Spanish)

* New translations common.php (Portuguese)

* New translations common.php (Polish)

* New translations common.php (Portuguese, Brazilian)

* New translations common.php (Ukrainian)

* New translations common.php (Vietnamese)

* New translations common.php (German Informal)

* New translations common.php (Spanish, Argentina)

* New translations common.php (Swedish)

* New translations common.php (Turkish)

* New translations common.php (Danish)

* New translations common.php (Dutch)

* New translations common.php (French)

* New translations common.php (Arabic)

* New translations common.php (Chinese Simplified)

* New translations common.php (Czech)

* New translations common.php (Chinese Traditional)

* New translations common.php (Japanese)

* New translations common.php (Italian)

* New translations common.php (Korean)

* New translations common.php (Persian)

* New translations common.php (German)

* New translations common.php (Hebrew)

* New translations common.php (Hungarian)

* New translations auth.php (Russian)

* New translations common.php (Russian)

* New translations passwords.php (Russian)

* New translations passwords.php (German)

* New translations settings.php (German)

* New translations auth.php (German)

* New translations common.php (German)

* New translations settings.php (German Informal)

* New translations passwords.php (German Informal)

* New translations common.php (German Informal)

* New translations auth.php (German Informal)
2020-04-13 15:31:35 +01:00
Dan Brown
3a70e9d49c Merge pull request #2023 from jzoy/master
fix Chinese translation error
2020-04-12 19:15:00 +01:00
Dan Brown
88dfb40c63 Some further dark-mode fixes, added toggle to homepage
- Homepage toggle especially useful for not-logged-in users since they
do not have a dropdown.
2020-04-12 19:06:34 +01:00
Dan Brown
b80b6ed942 Merge pull request #2022 from BookStackApp/dark-mode
Addition of a user-selectable dark-mode option
2020-04-11 20:52:19 +01:00
Dan Brown
50669e3f4a Added tests and translations for dark-mode components 2020-04-11 20:44:23 +01:00
Dan Brown
573c848d51 Added dark/light mode toggle to profile dropdown menu
- Also fixed some remaining areas which needed dark mode support.
2020-04-11 20:37:51 +01:00
Dan Brown
d4b0e4acad Removed throttling from web-end requests
Generally seems to cause issues when secure images are in use.
Was added during laravel upgrade but laravel does not use this directly
for its web middleware anyway.
2020-04-11 20:02:07 +01:00
Dan Brown
b0b28e7b5e Rolled dark mode out to the editors
- Updated editor, and other area, styles to look okay in dark mode.
- Used tinyMCE theme generator to create dark mode theme.
- Updated tinymce to latest 4x version.
2020-04-11 15:48:08 +01:00
jzoy
eb94500dca Update settings.php 2020-04-11 21:29:09 +08:00
jzoy
df8ea0b81d fix Chinese translation error 2020-04-11 21:26:13 +08:00
Dan Brown
067cb9c5b7 Merge branch 'master' into dark-mode 2020-04-11 14:22:41 +01:00
jzoy
7673a2bd6c Merge pull request #2 from BookStackApp/master
fetch upstream
2020-04-11 21:18:17 +08:00
Dan Brown
627720c5af Fixed incorrect []Activity -> array conversion 2020-04-10 22:49:52 +01:00
Dan Brown
1ba5a1274c Started work on supporting a dark-mode
- Most elements done, but still need to do editors, tables and final
pass.
- Toggled only by quick js check at the moment, checking via css media
query. Need to make into user-preference toggle.

For #1234
2020-04-10 22:38:29 +01:00
Dan Brown
d4df18098f Cleaned up the activity service
- Added test to ensure activity on entity delete works as expected.
2020-04-10 20:55:33 +01:00
Dan Brown
7b8fe5fbc6 Added book-export endpoints to the API 2020-04-10 16:05:17 +01:00
Dan Brown
29705a25ce Reviewed and added testing for BookShelf API implementation
- Tweaked how books are passed on update to prevent unassignment if
parameter is not provided.
- Added books to validation so they show in docs.
- Added request/response examples.
- Added tests to cover.
- Added child book info to shelf info.

Review of #1908
2020-04-10 15:19:18 +01:00
Dan Brown
da1cea06ca Merge branch 'master' of git://github.com/osmansorkar/BookStack into osmansorkar-master 2020-04-10 13:49:28 +01:00
Dan Brown
ba1be9d710 Updated password reset process not to indicate if email exists
- Intended to prevent enumeration to check if a user exists.
- Updated messages on both the reqest-reset and set-password elements.
- Also updated notification auto-hide to be dynamic based upon the
amount of words within the notification.
- Added tests to cover.

For #2016
2020-04-10 13:38:08 +01:00
Dan Brown
053cbbd5b6 Updated view-change endpoints to be clearer, separated books and shelf
- Separated books-list and shelf-show view types to be saved separately.

During review of #1755
2020-04-10 12:49:16 +01:00
Dan Brown
b8c16b15a9 Merge branch 'feature_change_view_in_shelves_show' of git://github.com/philjak/BookStack into philjak-feature_change_view_in_shelves_show 2020-04-10 12:21:56 +01:00
Dan Brown
47e645909e Reviewed #1688, Show parent shelves on books page
- Moved list to the left of the page to align with other navigational
items.
- Hid list of no shelves, to help hide shelf references if not in use.
- Tweaked test to ensure it wasn't finding shelf name in breadcrumb
rather than list being tested.
2020-04-09 17:29:22 +01:00
Dan Brown
898cedf536 Merge branch 'feature/#1598' of git://github.com/cw1998/BookStack into cw1998-feature/#1598 2020-04-09 17:18:37 +01:00
Dan Brown
e83d2eedbb Added "update-url" command to find/replace url in the database
- Also aligned format of command descriptions.

Targeted most common columns.
Have not done revisions for the sake of keeping that
content true to how it was originally stored but could
cause unexpected behaviour.

For #1225
2020-04-09 16:59:26 +01:00
Dan Brown
1962c81742 Updated WYSIWYG entity link selector to set link display text
- Sets as entity name if the input is currently empty.

For #2014
2020-04-09 15:28:44 +01:00
Dan Brown
642db1387e Updated wysiwyg code-block insert flow to be mouseless
- Can now save a code block with Ctrl+Enter.
- Codemirror will be in focus on popup show.
- TinyMCE will get back focus on code save.

For #1972
2020-04-05 21:55:31 +01:00
Dan Brown
02f7ffe53c Removed overflow hidden from all lists
- Was causing ol list numbers to be cut off.

Fixes #1978
2020-04-05 18:05:51 +01:00
Dan Brown
5f61620cc2 Added support for changing the draw.io instance URL
- Allowed DRAWIO env option to be passed as URL to point to instance.
- Updated tests to check URL gets passed to pages correctly.
- Update default URL to be the default theme.

For #826
2020-04-05 17:27:16 +01:00
Dan Brown
ea9e9565ef Removed bmp and tiff support from uploaded images.
Fixes #1990
2020-04-05 16:15:05 +01:00
Dan Brown
feab756b9f Merge pull request #2003 from BookStackApp/rtl_styles_update
Updated styles to use logical properties/values, for improved RTL support
2020-04-05 14:15:23 +01:00
Dan Brown
fb08194af1 New Crowdin translations (#2004)
* New translations errors.php (Russian)

* New translations settings.php (Portuguese, Brazilian)

* New translations auth.php (Russian)

* New translations entities.php (Russian)

* New translations settings.php (Russian)

* New translations settings.php (Slovak)

* New translations settings.php (Persian)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (Korean)

* New translations settings.php (Swedish)

* New translations validation.php (Swedish)

* New translations errors.php (Turkish)

* New translations settings.php (Turkish)

* New translations entities.php (Swedish)

* New translations settings.php (Ukrainian)

* New translations errors.php (Swedish)

* New translations entities.php (Slovenian)

* New translations errors.php (Slovenian)

* New translations pagination.php (Slovenian)

* New translations passwords.php (Slovenian)

* New translations settings.php (Slovenian)

* New translations validation.php (Slovenian)

* New translations settings.php (Spanish)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Czech)

* New translations pagination.php (Danish)

* New translations settings.php (Danish)

* New translations settings.php (Dutch)

* New translations settings.php (Arabic)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Hungarian)

* New translations errors.php (French)

* New translations errors.php (Italian)

* New translations settings.php (Italian)

* New translations settings.php (Japanese)

* New translations settings.php (French)

* New translations errors.php (German)

* New translations settings.php (German)

* New translations settings.php (Hebrew)

* New translations auth.php (Hebrew)

* New translations validation.php (Hebrew)

* New translations errors.php (Hebrew)

* New translations entities.php (Hebrew)

* New translations common.php (Hebrew)

* New translations settings.php (German Informal)

* New translations errors.php (German Informal)

* New translations settings.php (Vietnamese)
2020-04-05 14:12:23 +01:00
Dan Brown
f94fd44ff6 Updated styles to use logical properties/values
- Intended to improve RTL support in the interface.
- Also adds hebrew to language dropdown since that was missing.

Related to #1794
2020-04-05 13:07:19 +01:00
Dan Brown
f84bf8e883 Updated test files to be PSR-4 compliant
Closes #1924
2020-04-04 01:16:05 +01:00
Dan Brown
3500182c5f Updated drawing uploads to use user id in image name
- Instead of user name.
- Due to issues with advanced charts like emoji zero-width-joiners.
- Could also have security concerns on untrusted instances with certain
webserver config due to double extension possibilities.

Closes #1993
2020-04-04 00:48:32 +01:00
Dan Brown
ef416d3e86 Fixed editor JavaScript error in TemplateManager
- Caused when loading the editor with no templates in the system.
- Tried to init a search box that did not exist.
2020-04-04 00:09:58 +01:00
Dan Brown
c1fe068ffc Bumped npm packages up and ran audit-fix 2020-04-04 00:03:26 +01:00
Dan Brown
b0610d85da Updated socialite to fix deprecated GitHub auth method
- Also updated composer dependancies to cover symfony/http-foundation
security issue.

Fixes #1879
Related to #1989
2020-04-04 00:00:19 +01:00
Dan Brown
ed563fef28 Updated version and assets for release v0.28.3 2020-03-14 22:31:42 +00:00
Dan Brown
0d31a8e3f1 Merge branch 'master' into release 2020-03-14 22:31:11 +00:00
Dan Brown
64942268b8 Added Slovenian to available language options
Related to #1946
2020-03-14 22:24:27 +00:00
Dan Brown
b670ab61d6 Updated translations from crowdin
Squashed commit of the following:

commit 23861a31bb2398ca61655c584bd8c75ee9bccdad
Merge: d44acf4b 1c6287f2
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 22:18:50 2020 +0000

    Merge branch 'master' into l10n_master

commit d44acf4b64
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 19:39:46 2020 +0000

    New translations errors.php (Portuguese, Brazilian)

commit c1a4cc5d12
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 19:14:49 2020 +0000

    New translations errors.php (Spanish, Argentina)

commit fb3c5dcffc
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 19:14:48 2020 +0000

    New translations errors.php (Spanish)

commit f65c84635d
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:42:05 2020 +0000

    New translations errors.php (German Informal)

commit 99fba6932a
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:57 2020 +0000

    New translations errors.php (French)

commit bc4c9684b5
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:55 2020 +0000

    New translations errors.php (Portuguese, Brazilian)

commit 0afce79807
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:43 2020 +0000

    New translations errors.php (Russian)

commit f3daf77b95
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:41 2020 +0000

    New translations errors.php (Hungarian)

commit 63848278a5
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:40 2020 +0000

    New translations errors.php (Italian)

commit 53b5fce93c
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:38 2020 +0000

    New translations errors.php (Japanese)

commit 2638f7a663
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:37 2020 +0000

    New translations errors.php (Korean)

commit f19ffa468c
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:36 2020 +0000

    New translations errors.php (Persian)

commit 477ae0b845
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:34 2020 +0000

    New translations errors.php (Polish)

commit 56beebe12c
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:33 2020 +0000

    New translations errors.php (German)

commit 2b6540654a
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:32 2020 +0000

    New translations errors.php (Portuguese)

commit 80f7275011
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:30 2020 +0000

    New translations errors.php (Spanish)

commit 74c65f90ab
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:28 2020 +0000

    New translations errors.php (Spanish, Argentina)

commit 3bd9e4fb86
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:27 2020 +0000

    New translations errors.php (Swedish)

commit a698db00e3
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:26 2020 +0000

    New translations errors.php (Turkish)

commit f3cfc63b5c
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:25 2020 +0000

    New translations errors.php (Ukrainian)

commit 61eb76ac89
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:23 2020 +0000

    New translations errors.php (Vietnamese)

commit cfda94e2a8
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:22 2020 +0000

    New translations errors.php (Slovak)

commit f0659025a9
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:20 2020 +0000

    New translations errors.php (Dutch)

commit 27ac377ed6
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:19 2020 +0000

    New translations errors.php (Chinese Traditional)

commit 04bf21a325
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:18 2020 +0000

    New translations errors.php (Chinese Simplified)

commit 4dd5802979
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:16 2020 +0000

    New translations errors.php (Arabic)

commit 368cf2d248
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:11 2020 +0000

    New translations errors.php (Slovenian)

commit 52381df7be
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:08 2020 +0000

    New translations errors.php (Czech)

commit 7e8330510f
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 14 18:41:07 2020 +0000

    New translations errors.php (Danish)

commit 1bf07c6acf
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 22:57:41 2020 +0000

    New translations entities.php (Slovenian)

commit 4d2d120b57
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 22:27:35 2020 +0000

    New translations entities.php (Slovenian)

commit edfc88eb8e
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 21:55:51 2020 +0000

    New translations entities.php (Slovenian)

commit 840ed35d34
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 21:22:08 2020 +0000

    New translations entities.php (Slovenian)

commit aa130af285
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 14:02:50 2020 +0000

    New translations common.php (Czech)

commit 5007fee528
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 14:02:25 2020 +0000

    New translations entities.php (Slovenian)

commit 1ad8874e6b
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 13:30:21 2020 +0000

    New translations auth.php (Czech)

commit 56f17ff7d4
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 13:29:57 2020 +0000

    New translations entities.php (Slovenian)

commit 33789962cc
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 11:09:18 2020 +0000

    New translations components.php (Slovenian)

commit 1002cf9e3b
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 10:08:48 2020 +0000

    New translations components.php (Slovenian)

commit d5a6083ae8
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 09:37:23 2020 +0000

    New translations components.php (Slovenian)

commit 0d8df3a72e
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 09:07:01 2020 +0000

    New translations components.php (Slovenian)

commit 22337688f9
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 09:07:00 2020 +0000

    New translations common.php (Slovenian)

commit ff812694e2
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 08:20:44 2020 +0000

    New translations common.php (Slovenian)

commit 57b9e04927
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 06:17:11 2020 +0000

    New translations auth.php (Spanish)

commit 25343baac1
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 06:16:52 2020 +0000

    New translations activities.php (Spanish)

commit a5c72ee5f1
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Wed Mar 11 05:37:55 2020 +0000

    New translations activities.php (Spanish)

commit 6a6f28d095
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 15:49:14 2020 +0000

    New translations auth.php (Slovenian)

commit 33f30876e1
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 15:18:30 2020 +0000

    New translations auth.php (Slovenian)

commit 8bf8bc0fe5
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 14:48:46 2020 +0000

    New translations auth.php (Slovenian)

commit 303dc07704
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 14:48:44 2020 +0000

    New translations activities.php (Slovenian)

commit c219fb67aa
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 09:59:45 2020 +0000

    New translations activities.php (Slovenian)

commit f45b6c6c82
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 08:34:32 2020 +0000

    New translations activities.php (Slovenian)

commit 7d690eb13f
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 02:56:48 2020 +0000

    New translations entities.php (Russian)

commit 86aab0529e
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Tue Mar 10 02:56:43 2020 +0000

    New translations common.php (Russian)

commit 4fc1945543
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 20:37:26 2020 +0000

    New translations errors.php (Russian)

commit 09791b736d
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 20:07:52 2020 +0000

    New translations settings.php (Russian)

commit 9cb04bea4c
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:50 2020 +0000

    New translations validation.php (Slovenian)

commit 7964a5a2a0
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:34 2020 +0000

    New translations settings.php (Slovenian)

commit 361be13ff7
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:32 2020 +0000

    New translations passwords.php (Slovenian)

commit 9b6a7f0f64
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:31 2020 +0000

    New translations pagination.php (Slovenian)

commit a815adc24f
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:30 2020 +0000

    New translations errors.php (Slovenian)

commit ae6040af3a
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:28 2020 +0000

    New translations entities.php (Slovenian)

commit f1d0177dce
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:26 2020 +0000

    New translations components.php (Slovenian)

commit 16ba9f1fe1
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:24 2020 +0000

    New translations common.php (Slovenian)

commit caa47464ba
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:23 2020 +0000

    New translations auth.php (Slovenian)

commit 4b9a78aef8
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Mon Mar 9 18:49:21 2020 +0000

    New translations activities.php (Slovenian)

commit 6a55161fe9
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sun Mar 8 01:36:31 2020 +0000

    New translations common.php (French)

commit 94235393fa
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 7 18:49:01 2020 +0000

    New translations errors.php (Portuguese, Brazilian)

commit a0f75e7724
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 7 10:24:26 2020 +0000

    New translations settings.php (Russian)

commit 65437712a2
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 7 09:37:34 2020 +0000

    New translations auth.php (Russian)

commit f7f6a92dcf
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 7 00:01:07 2020 +0000

    New translations entities.php (Russian)

commit ac8819edd3
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Sat Mar 7 00:00:57 2020 +0000

    New translations settings.php (Russian)

commit f1290f2b87
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Fri Mar 6 23:23:44 2020 +0000

    New translations entities.php (Russian)

commit 8efb9bd571
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Fri Mar 6 22:45:53 2020 +0000

    New translations entities.php (Russian)

commit 4fd31df14e
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Fri Mar 6 22:45:44 2020 +0000

    New translations settings.php (Russian)

commit 262a2b3e4e
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Fri Mar 6 22:11:41 2020 +0000

    New translations entities.php (Russian)

commit 15c98a64b8
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Fri Mar 6 15:10:54 2020 +0000

    New translations errors.php (French)

commit 7a274db381
Author: Dan Brown <ssddanbrown@googlemail.com>
Date:   Fri Mar 6 15:10:43 2020 +0000

    New translations settings.php (French)
2020-03-14 22:20:01 +00:00
Dan Brown
1c6287f245 Updated translator list from Crowdin 2020-03-14 22:15:01 +00:00
Dan Brown
775ee1bde3 Merge branch 'patch-1' of git://github.com/MikeyMJCO/BookStack into MikeyMJCO-patch-1 2020-03-14 20:53:28 +00:00
Dan Brown
82278bd0f4 Updated readme
- Updated dev details to be current.
- Added warning for auto-fixing via phpcbf.
- Added warning that existing issue does not mean accepted pull request.
2020-03-14 18:56:49 +00:00
Dan Brown
8a4e75ef32 Added a "Start Database" step to github action flow
https://github.blog/changelog/2020-02-21-github-actions-breaking-change-ubuntu-virtual-environments-will-no-longer-start-the-mysql-service-automatically/
2020-03-14 18:44:02 +00:00
Dan Brown
7f6cbead33 Performed review of "public intended" functionality provided in #1817
- Updated logic to take url from referrer rather than pass as a query parameter.
- Added tests to cover functionality.
- Updated 404 page with login action button if not signed in.
- Updated 404 page with text to indicate permissions may be affecting visibility.

Related to #1817 and #1706
2020-03-14 18:29:31 +00:00
Dan Brown
a95588dc2e Merge branch 'feature/public-login-redirect' of git://github.com/Xiphoseer/BookStack into Xiphoseer-feature/public-login-redirect 2020-03-14 17:46:30 +00:00
Dan Brown
217954694c Updated npm dependancies 2020-03-14 17:38:39 +00:00
Dan Brown
17da30aa29 Updated default mail options 2020-03-14 17:32:11 +00:00
Dan Brown
aa12e48d73 Merge branch 'TBK-validation_fixes' 2020-03-14 12:46:01 +00:00
Dan Brown
200772da72 Merge branch 'validation_fixes' of git://github.com/TBK/BookStack into TBK-validation_fixes 2020-03-14 12:42:59 +00:00
dependabot[bot]
d7760b1b25 Bump acorn from 6.4.0 to 6.4.1
Bumps [acorn](https://github.com/acornjs/acorn) from 6.4.0 to 6.4.1.
- [Release notes](https://github.com/acornjs/acorn/releases)
- [Commits](https://github.com/acornjs/acorn/compare/6.4.0...6.4.1)

Signed-off-by: dependabot[bot] <support@github.com>
2020-03-14 11:31:01 +00:00
Dan Brown
a5f972043b Updated primary color action text to be consistent
- With other similar picker components on the page.

As reported in #1930
2020-03-11 21:51:43 +00:00
Dan Brown
ae1f237a1f Merge branch 'Statium-master' 2020-03-11 21:43:16 +00:00
Statium
8a681fd796 Update entities.php
More accurate translation, error correction.
2020-03-11 21:40:50 +00:00
Statium
fa9944610d Update common.php
More accurate translation, error correction.
2020-03-11 21:38:29 +00:00
Statium
bebda6ff1a Update auth.php
More accurate translation, error correction.
2020-03-11 21:38:29 +00:00
Statium
991f6da9f4 Update activities.php
More accurate translation, error correction.
2020-03-11 21:35:31 +00:00
Dan Brown
c6ff6db784 Merge branch 'Statium-patch-1' 2020-03-11 21:28:09 +00:00
Statium
b58110000d Code refactoring
Removed extra spaces displayed in the header of the login and registration link.
2020-03-11 21:27:22 +00:00
Statium
f87c3b2660 Update setting-entity-color-picker.blade.php
Reducing indentation to one look in the application settings.
2020-03-11 21:23:04 +00:00
Dan Brown
59aefe5371 Updated social auth to take name from email if empty
- Added tests to cover.

Fixes #1853
2020-03-10 19:09:22 +00:00
Dan Brown
ccf92331c2 Updated readme with extra discord and issue list links 2020-03-06 20:26:11 +00:00
Dan Brown
30db8af460 Merge branch 'master' of git://github.com/ch0wm3in/BookStack into ch0wm3in-master 2020-03-06 20:10:57 +00:00
Dan Brown
56be10f1cd Merge branch 'perl_syntax_highlight' of git://github.com/Iyeyasu/BookStack into Iyeyasu-perl_syntax_highlight 2020-03-06 19:54:15 +00:00
Dan Brown
4b2654598c Merge branch 'master' of git://github.com/JHenneberg/BookStack into JHenneberg-master 2020-03-06 19:49:16 +00:00
Dan Brown
dbf5a87adb Lang setting list changes via Crowdin
* New translations settings.php (Italian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Ukrainian)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Spanish)

* New translations settings.php (Slovak)

* New translations settings.php (Portuguese)

* New translations settings.php (Polish)

* New translations settings.php (Persian)

* New translations settings.php (Korean)

* New translations settings.php (Japanese)

* New translations settings.php (German)

* New translations settings.php (Russian)

* New translations settings.php (French)

* New translations settings.php (Dutch)

* New translations settings.php (Czech)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Arabic)

* New translations settings.php (Danish)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Hungarian)

* New translations settings.php (German Informal)
2020-03-04 22:43:30 +00:00
Dan Brown
b94b945fb0 Merge branch 'master' of git://github.com/Binternet/BookStack into Binternet-master 2020-03-04 22:22:08 +00:00
Dan Brown
34616ac195 Updated lanauge lists to match latest translations 2020-03-04 22:14:25 +00:00
Dan Brown
6303c788ed New Crowdin translations (#1868)
* New translations settings.php (Spanish)

* New translations errors.php (Japanese)

* New translations errors.php (Japanese)

* New translations components.php (Japanese)

* New translations settings.php (German)

* New translations settings.php (German)

* New translations auth.php (German Informal)

* New translations errors.php (German Informal)

* New translations settings.php (German Informal)

* New translations entities.php (German Informal)

* New translations activities.php (Persian)

* New translations auth.php (Persian)

* New translations common.php (Persian)

* New translations components.php (Persian)

* New translations entities.php (Persian)

* New translations errors.php (Persian)

* New translations pagination.php (Persian)

* New translations passwords.php (Persian)

* New translations settings.php (Persian)

* New translations validation.php (Persian)

* New translations settings.php (German Informal)

* New translations validation.php (German Informal)

* New translations settings.php (French)

* New translations errors.php (French)

* New translations settings.php (French)

* New translations settings.php (Hungarian)

* New translations activities.php (Portuguese)

* New translations auth.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations passwords.php (Vietnamese)

* New translations pagination.php (Vietnamese)

* New translations errors.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations components.php (Vietnamese)

* New translations common.php (Vietnamese)

* New translations activities.php (Vietnamese)

* New translations auth.php (Portuguese)

* New translations validation.php (Portuguese)

* New translations settings.php (Portuguese)

* New translations passwords.php (Portuguese)

* New translations pagination.php (Portuguese)

* New translations errors.php (Portuguese)

* New translations entities.php (Portuguese)

* New translations components.php (Portuguese)

* New translations common.php (Portuguese)

* New translations validation.php (Vietnamese)

* New translations components.php (Vietnamese)

* New translations auth.php (Vietnamese)

* New translations components.php (Vietnamese)

* New translations auth.php (Vietnamese)

* New translations activities.php (Vietnamese)

* New translations auth.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations pagination.php (Vietnamese)

* New translations passwords.php (Vietnamese)

* New translations common.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations common.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations validation.php (Vietnamese)

* New translations validation.php (Vietnamese)

* New translations validation.php (Vietnamese)

* New translations errors.php (Vietnamese)

* New translations errors.php (Vietnamese)

* New translations errors.php (Vietnamese)

* New translations errors.php (Vietnamese)

* New translations errors.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations settings.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations errors.php (Chinese Simplified)

* New translations errors.php (French)

* New translations errors.php (German)

* New translations errors.php (Arabic)

* New translations errors.php (Czech)

* New translations errors.php (Danish)

* New translations errors.php (Dutch)

* New translations errors.php (Hungarian)

* New translations errors.php (Italian)

* New translations errors.php (Chinese Traditional)

* New translations errors.php (Swedish)

* New translations errors.php (Portuguese)

* New translations errors.php (Persian)

* New translations errors.php (German Informal)

* New translations errors.php (Ukrainian)

* New translations errors.php (Turkish)

* New translations errors.php (Korean)

* New translations errors.php (Spanish, Argentina)

* New translations errors.php (Spanish)

* New translations errors.php (Slovak)

* New translations errors.php (Russian)

* New translations errors.php (Polish)

* New translations errors.php (Japanese)

* New translations errors.php (Portuguese, Brazilian)

* New translations errors.php (Vietnamese)

* New translations errors.php (Spanish)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations entities.php (Vietnamese)

* New translations errors.php (Vietnamese)

* New translations auth.php (Swedish)

* New translations common.php (Swedish)

* New translations entities.php (Swedish)

* New translations settings.php (Swedish)

* New translations errors.php (Chinese Simplified)

* New translations errors.php (Russian)

* New translations errors.php (Russian)

* New translations common.php (Russian)

* New translations settings.php (Russian)

* New translations settings.php (Russian)

* New translations errors.php (Hungarian)

* New translations settings.php (Hungarian)

* New translations settings.php (Russian)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations activities.php (Russian)

* New translations auth.php (Russian)

* New translations components.php (Russian)

* New translations entities.php (Russian)

* New translations validation.php (Russian)

* New translations errors.php (Russian)

* New translations common.php (Russian)

* New translations entities.php (Russian)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese, Brazilian)

* New translations auth.php (Russian)

* New translations components.php (Russian)

* New translations entities.php (Russian)

* New translations errors.php (Russian)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations passwords.php (Russian)

* New translations auth.php (Danish)

* New translations auth.php (Danish)

* New translations common.php (Danish)

* New translations components.php (Danish)

* New translations entities.php (Danish)

* New translations entities.php (Danish)

* New translations entities.php (Danish)

* New translations errors.php (Danish)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations settings.php (Russian)

* New translations validation.php (Russian)

* New translations errors.php (Danish)

* New translations errors.php (Danish)

* New translations settings.php (Danish)

* New translations settings.php (Danish)

* New translations settings.php (Danish)

* New translations validation.php (Danish)

* New translations validation.php (Danish)

* New translations settings.php (Danish)

* New translations settings.php (Danish)

* New translations auth.php (Russian)

* New translations settings.php (Russian)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations validation.php (Russian)

* New translations settings.php (Russian)
2020-03-04 21:58:04 +00:00
TBK
57f587a78b Allow book, shelf, settings & profile form input validation to skip image 2020-03-04 00:17:53 +01:00
TBK
d3737d5a87 Remove redundant getImageValidationRules method 2020-03-04 00:17:49 +01:00
TBK
5cd56f63ff Change check to verify that request is present and contains a file 2020-03-04 00:17:45 +01:00
osmansorkar
1859c7917f added api functionality to handle book Shelves 2020-02-23 11:41:49 +06:00
Mikey O'Toole
65c4985710 Resolve issue #1911 (Additional margin/padding present in nested lists)
Optionally we could consider removing the rule at line 274. This doesn't handle multiple layers of nested lists and would be better covered by the generic rule which checks for an `ol` or `ul` nested inside a `li` which is how MarkDown renders nested lists as HTML.
2020-02-20 14:25:23 +00:00
osmansorkar
01adf39be8 added Handle Authorization Header on .htaccess to solve Authorization problem get from laravel orginal github ripo 2020-02-19 12:44:23 +06:00
jzoy
27191d1a2c Merge pull request #1 from BookStackApp/master
同步作者更新内容
2020-02-18 22:30:53 +08:00
Dan Brown
b8354b974b Updated version and assets for release v0.28.2 2020-02-15 22:36:08 +00:00
Dan Brown
034c1e289d Merge branch 'master' into release 2020-02-15 22:35:46 +00:00
Dan Brown
01b95d91ba Fixed side-effect in binary LDAP handling
- Was not stripping prefix when sending value to LDAP server in search.
- Updated test to cover.
2020-02-15 22:35:15 +00:00
Dan Brown
f31605a3de Updated version and assets for release v0.28.1 2020-02-15 22:08:06 +00:00
Dan Brown
e7cc75c74d Merge branch 'master' into release 2020-02-15 22:07:17 +00:00
Dan Brown
54a4c6e678 Fixed code-block drag+drop handling
- Added custom handling, Tracks if contenteditable blocks are being dragged. On drop the selection location will be roughly checked to put the block above or below the cursor block root element.
2020-02-15 21:37:41 +00:00
Dan Brown
29cc35a304 Added dump_user_details option to LDAP and added binary attribute decode option
Related to #1872
2020-02-15 20:31:23 +00:00
Dan Brown
6caedc7a37 Fixed issues preventing breadcrumb navigation menus from opening
- Added tests to cover endpoint

Fixes #1884
2020-02-15 19:09:33 +00:00
Dan Brown
5978d9a0d3 Updated cover image methods so image parameter is not optional but still nullable 2020-02-15 18:38:36 +00:00
Dan Brown
98ab3c1ffb Merge branch 'new_bookshelf_cover_fix' of git://github.com/TBK/BookStack into TBK-new_bookshelf_cover_fix 2020-02-15 18:34:45 +00:00
Dan Brown
ea3c3cde5a Added test to ensure shelf cover image gets set on create
Related to #1897
2020-02-15 18:34:02 +00:00
Dan Brown
e9d879bcc5 Made some updates to project readme and license 2020-02-15 15:47:17 +00:00
Dan Brown
ccd50fe918 Aligned export styles a little better and fixed potential DOMPDF css error
- Removed different PDF template used on pages.
- Updated export view files to have the intended format passed.
- Shared the export CSS amoung the export templates.

Should hopefully address #1886
2020-02-15 15:34:06 +00:00
Dan Brown
14363edb73 Fixed LDAP error thrown by not found user details
- Added testing to cover.

Related to #1876
2020-02-15 14:44:36 +00:00
Dan Brown
e8cfb4f2be Removed unintended extra lines in code blocks
Fixes #1877
2020-02-15 14:24:55 +00:00
Dan Brown
49386b42da Updated email test send to show error on failure
- Added test to cover
- Closes #1874
2020-02-15 14:13:15 +00:00
TBK
9533e0646e Fix for missing cover on create new shelf 2020-02-14 20:33:07 +01:00
ch0wm3in
c1fe81466f Fixed 'interaction_required' response for azure
Azure Conditional Access policy 2FA returns 'interaction_required' 400 response https://github.com/SocialiteProviders/Providers/issues/208
2020-02-12 15:03:55 +01:00
JHenneberg
0df0227ad4 Added support for Fortran language
sorted import alphabetically
2020-02-07 13:45:19 +01:00
Dan Brown
4b79d5e4e8 Updated version and assets for release v0.28.0 2020-02-03 22:44:45 +00:00
Dan Brown
34854915b3 Merge branch 'master' into release 2020-02-03 22:43:58 +00:00
Dan Brown
33ef1cd4fa Updated translators file 2020-02-03 22:25:17 +00:00
Dan Brown
d8072cbef2 New Crowdin translations (#1850)
* New translations settings.php (Korean)

* New translations settings.php (Polish)

* New translations settings.php (Ukrainian)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Spanish)

* New translations errors.php (Spanish)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Japanese)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Italian)

* New translations settings.php (Hungarian)

* New translations settings.php (German)

* New translations settings.php (French)

* New translations settings.php (Dutch)

* New translations settings.php (Danish)

* New translations settings.php (Czech)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Arabic)

* New translations settings.php (German Informal)

* New translations common.php (Dutch)

* New translations settings.php (Spanish)

* New translations errors.php (Hungarian)

* New translations settings.php (Hungarian)

* New translations common.php (Hungarian)

* New translations errors.php (Hungarian)

* New translations settings.php (Hungarian)

* New translations validation.php (Hungarian)

* New translations errors.php (Portuguese, Brazilian)

* New translations errors.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations auth.php (Chinese Traditional)

* New translations common.php (Chinese Traditional)

* New translations entities.php (Chinese Traditional)

* New translations errors.php (Chinese Traditional)

* New translations entities.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Traditional)

* New translations validation.php (Chinese Traditional)

* New translations validation.php (Chinese Traditional)

* New translations errors.php (German)

* New translations errors.php (German)

* New translations settings.php (German)

* New translations settings.php (German Informal)

* New translations errors.php (French)

* New translations settings.php (French)

* New translations errors.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Hungarian)

* New translations settings.php (Hungarian)

* New translations validation.php (Hungarian)

* New translations errors.php (Chinese Simplified)

* New translations errors.php (Spanish)

* New translations errors.php (Korean)

* New translations settings.php (Korean)

* New translations errors.php (Polish)

* New translations settings.php (Polish)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations errors.php (Slovak)

* New translations settings.php (Slovak)

* New translations settings.php (Spanish)

* New translations errors.php (Japanese)

* New translations errors.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)

* New translations errors.php (Swedish)

* New translations settings.php (Swedish)

* New translations errors.php (Turkish)

* New translations settings.php (Turkish)

* New translations errors.php (Ukrainian)

* New translations settings.php (Ukrainian)

* New translations settings.php (Japanese)

* New translations settings.php (Italian)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Portuguese, Brazilian)

* New translations errors.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations errors.php (French)

* New translations settings.php (French)

* New translations errors.php (German)

* New translations settings.php (German)

* New translations settings.php (Hungarian)

* New translations errors.php (Portuguese, Brazilian)

* New translations settings.php (German Informal)

* New translations errors.php (Italian)

* New translations errors.php (Arabic)

* New translations settings.php (Arabic)

* New translations errors.php (Czech)

* New translations settings.php (Czech)

* New translations errors.php (Danish)

* New translations settings.php (Danish)

* New translations errors.php (Dutch)

* New translations settings.php (Dutch)

* New translations errors.php (Hungarian)

* New translations errors.php (German Informal)

* New translations settings.php (Spanish)

* New translations settings.php (French)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Japanese)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Spanish)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Polish)

* New translations settings.php (Korean)

* New translations settings.php (Italian)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Dutch)

* New translations settings.php (Danish)

* New translations settings.php (Czech)

* New translations settings.php (Arabic)

* New translations settings.php (German Informal)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Hungarian)

* New translations settings.php (German)

* New translations settings.php (French)

* New translations settings.php (Ukrainian)
2020-02-03 21:00:17 +00:00
Dan Brown
718a97537e Added app theme setting to complete env and fixed text error 2020-02-03 20:33:10 +00:00
Dan Brown
dea8343bc8 Made docs sidebar sticky, changed theme to default
- MDN theme appeared fairly bad for markdown use, and the geometric
background was a bit much. Swapped out to default theme.
- Rough-added stickiness to docs sidebar, will need more work once it
starts to expand possible screen height.
2020-02-02 21:59:51 +00:00
Dan Brown
5ce3b861a9 Improved styling of the 500 error page 2020-02-02 21:04:43 +00:00
Dan Brown
e15fcf5b50 Merge pull request #1866 from BookStackApp/auth_alignment
Auth service alignment
2020-02-02 18:06:15 +00:00
Dan Brown
9d77cca734 Cleaned setting section redirect path 2020-02-02 17:57:21 +00:00
Dan Brown
b4f2b73590 Updated settings-save action to return to the same section 2020-02-02 17:35:16 +00:00
Dan Brown
3991fbe726 Checked over and aligned registration option behavior across all auth options
- Added tests to cover
2020-02-02 17:31:00 +00:00
Dan Brown
e6c6de0848 Simplified guard names and rolled out guard route checks
- Included tests to cover for LDAP and SAML
- Updated wording for external auth id option.
- Updated 'assertPermissionError' test case to be usable in BrowserKitTests
2020-02-02 13:10:21 +00:00
Dan Brown
5d08ec3cef Fixed failing tests caused by auth changes 2020-02-02 12:00:41 +00:00
Dan Brown
e743cd3f60 Added files missed in previous commit 2020-02-02 10:59:03 +00:00
Dan Brown
3470a6a140 Aligned SAML2 system with LDAP implementation in terms of guards and UI 2020-02-01 16:11:56 +00:00
Dan Brown
7728931f15 Set more appropriate login validation and broken up LDAP guide a bit 2020-02-01 14:30:23 +00:00
Dan Brown
575b85021d Started alignment of auth services
- Removed LDAP specific logic from login controller, placed in Guard.
- Created safer base user provider for ldap login, to be used for SAML
soon.
- Moved LDAP auth work from user provider to guard.
2020-02-01 11:42:22 +00:00
Dan Brown
92690d1ae9 Moved socal auth routes to their own controller
Also cleaned some phpdocs and extracted register actions to their own
service.
2020-01-26 14:42:50 +00:00
Dan Brown
fb5df49fd4 Updated laravel version and moved flare to non-dev 2020-01-26 13:27:28 +00:00
Dan Brown
82a8db3739 Merge pull request #1845 from SoarinFerret/add-close-icon-to-notifications
Add close icon to notifications
2020-01-19 16:07:08 +00:00
D4rt
b059744fb5 Add Perl syntax higlighting to code editor 2020-01-19 07:41:18 +02:00
Dan Brown
5ff89a1abb Added danish to language arrays 2020-01-18 16:10:16 +00:00
Dan Brown
7a2404d5e0 New Crowdin translations (#1825)
* New translations common.php (Turkish)

* New translations errors.php (Turkish)

* New translations settings.php (Turkish)

* New translations common.php (Italian)

* New translations settings.php (Italian)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations common.php (Portuguese, Brazilian)

* New translations validation.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations common.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)

* New translations components.php (Portuguese, Brazilian)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)

* New translations activities.php (Portuguese, Brazilian)

* New translations common.php (Portuguese, Brazilian)

* New translations components.php (Portuguese, Brazilian)

* New translations passwords.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations auth.php (Dutch)

* New translations auth.php (Dutch)

* New translations common.php (Dutch)

* New translations settings.php (Dutch)

* New translations common.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations validation.php (Dutch)

* New translations settings.php (Portuguese, Brazilian)

* New translations components.php (Dutch)

* New translations errors.php (Dutch)

* New translations settings.php (Dutch)

* New translations validation.php (Dutch)

* New translations settings.php (Dutch)

* New translations validation.php (Dutch)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Portuguese, Brazilian)

* New translations errors.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations components.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations errors.php (Portuguese, Brazilian)

* New translations entities.php (Portuguese, Brazilian)

* New translations errors.php (Portuguese, Brazilian)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Portuguese, Brazilian)

* New translations validation.php (Portuguese, Brazilian)

* New translations validation.php (Portuguese, Brazilian)

* New translations validation.php (Portuguese, Brazilian)

* New translations errors.php (Danish)

* New translations errors.php (Danish)

* New translations activities.php (Danish)

* New translations common.php (Danish)

* New translations auth.php (Danish)

* New translations auth.php (Danish)

* New translations passwords.php (Danish)

* New translations common.php (Korean)

* New translations settings.php (Korean)

* New translations settings.php (Korean)

* New translations errors.php (Korean)

* New translations common.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations errors.php (Chinese Simplified)

* New translations errors.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Korean)

* New translations settings.php (Spanish)

* New translations settings.php (Polish)

* New translations errors.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations errors.php (Russian)

* New translations settings.php (Russian)

* New translations errors.php (Slovak)

* New translations settings.php (Slovak)

* New translations errors.php (Spanish)

* New translations errors.php (Spanish, Argentina)

* New translations settings.php (Japanese)

* New translations settings.php (Spanish, Argentina)

* New translations errors.php (Swedish)

* New translations settings.php (Swedish)

* New translations errors.php (Turkish)

* New translations settings.php (Turkish)

* New translations errors.php (Ukrainian)

* New translations settings.php (Ukrainian)

* New translations errors.php (German Informal)

* New translations errors.php (Polish)

* New translations errors.php (Japanese)

* New translations errors.php (Korean)

* New translations errors.php (Danish)

* New translations errors.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations errors.php (Arabic)

* New translations settings.php (Arabic)

* New translations errors.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations errors.php (Czech)

* New translations settings.php (Czech)

* New translations settings.php (Danish)

* New translations settings.php (Italian)

* New translations errors.php (Dutch)

* New translations settings.php (Dutch)

* New translations errors.php (French)

* New translations settings.php (French)

* New translations errors.php (German)

* New translations settings.php (German)

* New translations errors.php (Hungarian)

* New translations settings.php (Hungarian)

* New translations errors.php (Italian)

* New translations settings.php (German Informal)
2020-01-18 16:03:27 +00:00
Dan Brown
0ba75713e1 Fixed github action workflow 2020-01-18 15:30:54 +00:00
Dan Brown
281200e212 Further updated github actions config
- Added composer caching based off github docs.
- Focused when actions run so they're not running unneccessarily.
2020-01-18 15:27:57 +00:00
Dan Brown
4ed23b0187 Added caching to github action workflow 2020-01-18 15:17:21 +00:00
Dan Brown
517687669c Merge pull request #1826 from BookStackApp/api_origins
Baseline API Implementation
2020-01-18 15:10:35 +00:00
Dan Brown
be554b9c79 Added configurable API throttling, Handled API errors standardly 2020-01-18 15:03:28 +00:00
Dan Brown
1350136ca3 Fixed bad test class name 2020-01-18 14:07:43 +00:00
Dan Brown
b9fb655b60 Added "Getting Started" API docs 2020-01-18 14:03:11 +00:00
Dan Brown
64455307b1 Added a few test to cover api docs pages 2020-01-18 10:04:13 +00:00
Dan Brown
8ead596067 Updated default codemirror theme
- To mdn-like theme, to have better default legibility and contrast
2020-01-18 09:55:02 +00:00
Dan Brown
8016f1121e Refined docs view, Added example requests 2020-01-18 09:48:30 +00:00
Dan Brown
45b5e631e2 Added a view for the API docs 2020-01-15 20:18:02 +00:00
SoarinFerret
4297d64e29 Add close icon to notifications 2020-01-14 13:50:29 -06:00
Dan Brown
bed2498667 Started work on generating API docs 2020-01-12 16:25:14 +00:00
Dan Brown
04a8614136 Filled out base Book API endpoints, added example responses 2020-01-12 14:45:54 +00:00
Dan Brown
a8595d8aaf Fixed test class names + add perm. check to api session auth 2020-01-01 17:01:36 +00:00
Dan Brown
a7a97a53f1 Added API listing filtering & cleaned ApiAuthenticate returns
API listing endpoint filter can be found via &filter[name]=my+book query
parameters. There are a range of operators that can be used such as
&filter[id:gte]=4
2020-01-01 16:33:47 +00:00
Dan Brown
55abf7be24 Added tests to cover API config and listing code 2019-12-30 20:48:23 +00:00
Dan Brown
3cacda6762 Added expiry checking to API token auth
- Added test to cover to ensure its checked going forward
2019-12-30 19:51:41 +00:00
Dan Brown
3d11cba223 Added testing coverage to API token auth 2019-12-30 19:42:46 +00:00
Dan Brown
6f1b88a6a6 Change email confirmation from own middle to trait
Email confirmation middleware caused more mess than good, As caused
priority issues and it depended on auth actions. Instead its now a trai
used on auth middlewares.

Also used 'EncryptCookies' middleware on API instead of custom
decryption in custom middleware since we'd need to do replicate all the
same actions anyway. Shouldn't have too much effect since it only
actions over cookies that exist, of which none should be there for most
API requests.

Also split out some large guard functions to be a little more readable
and appease codeclimate.
2019-12-30 15:49:20 +00:00
Dan Brown
349b4629be Extracted API auth into guard
Also implemented more elegant solution to allowing session auth for API
routes; A new 'StartSessionIfCookieExists' middleware, which wraps the
default 'StartSession' middleware will run for API routes which only
sets up the session if a session cookie is found on the request. Also
decrypts only the session cookie.

Also cleaned some TokenController codeclimate warnings.
2019-12-30 14:51:28 +00:00
Dan Brown
3de55ee645 Linked new API token system into middleware
Base logic in place but needs review and refactor to see if can better
fit into Laravel using 'Guard' system. Currently has issues due to
cookies in use from active session on API.
2019-12-30 02:16:07 +00:00
Lior Broshi
80a50f1ecb added rtl support for hebrew + added to localMap 2019-12-29 23:06:54 +02:00
Lior Broshi
23ad8024ec resolved conflict 2019-12-29 23:03:10 +02:00
Lior Broshi
da03e34c67 added he locale to configuration 2019-12-29 23:01:45 +02:00
Lior Broshi
5f333eebf0 validation 2019-12-29 22:53:42 +02:00
Dan Brown
2cfa37399c Fixed some empty-expiry conditions of token ui flows 2019-12-29 20:18:37 +00:00
Dan Brown
692fc46c7d Removed token 'client' text, avoid confusion w/ oAuth
- Instead have a token_id and a secret.
   - Displayed a 'Token ID' and 'Token Secret'.
2019-12-29 20:07:28 +00:00
Dan Brown
832fbd65af Added testing coverage to user API token interfaces 2019-12-29 19:46:46 +00:00
Dan Brown
dccb279c84 Built out interfaces & endpoints for API token managment 2019-12-29 17:03:52 +00:00
Dan Brown
d336ba6874 Started work on API token controls
- Added access-api permission.
- Started user profile UI work.
- Created database table and model for tokens.
- Fixed incorrect templates down migration :(
2019-12-29 13:02:26 +00:00
Dan Brown
04137e7c98 Started core API route work 2019-12-28 14:58:07 +00:00
Dan Brown
c055310507 Updated to latest laravel 6 version 2019-12-28 13:01:42 +00:00
Dan Brown
5c040bf2b7 Merge branch 'albergoniSivaf-master' 2019-12-27 17:15:45 +00:00
Dan Brown
cf743370a8 Updated code block lang order and added extra pascal option
- Fixed modal window sizing/positioning to be properly center and
responsive.

Related to #1730
2019-12-27 17:14:34 +00:00
Dan Brown
891dbfe085 Merge branch 'master' of git://github.com/albergoniSivaf/BookStack into albergoniSivaf-master 2019-12-27 17:03:10 +00:00
Dan Brown
6f9cad2106 Merge pull request #1793 from abublihi/master
Fix An Exception
2019-12-27 16:52:07 +00:00
Dan Brown
a433cde3b7 Merge pull request #1824 from BookStackApp/l10n_master
New Crowdin translations
2019-12-27 16:46:02 +00:00
Dan Brown
91081a0d72 New translations validation.php (German Informal) 2019-12-27 16:40:44 +00:00
Dan Brown
77c2b2ef64 New translations common.php (Slovak) 2019-12-27 16:40:43 +00:00
Dan Brown
426440d552 New translations errors.php (Slovak) 2019-12-27 16:40:40 +00:00
Dan Brown
a8ebcfaef6 New translations settings.php (Slovak) 2019-12-27 16:40:38 +00:00
Dan Brown
4db8c1279a New translations entities.php (Spanish) 2019-12-27 16:40:35 +00:00
Dan Brown
0ec4ca033c New translations errors.php (Spanish) 2019-12-27 16:40:33 +00:00
Dan Brown
0c446800a6 New translations settings.php (Spanish) 2019-12-27 16:40:31 +00:00
Dan Brown
2246b468bc New translations validation.php (Spanish) 2019-12-27 16:40:30 +00:00
Dan Brown
d7673c6a7b New translations common.php (Spanish) 2019-12-27 16:40:28 +00:00
Dan Brown
8269f99114 New translations common.php (Spanish, Argentina) 2019-12-27 16:40:27 +00:00
Dan Brown
9798fa8ba7 New translations settings.php (Russian) 2019-12-27 16:40:25 +00:00
Dan Brown
420baa2f65 New translations settings.php (Polish) 2019-12-27 16:40:24 +00:00
Dan Brown
f259bc2f15 New translations validation.php (Polish) 2019-12-27 16:40:22 +00:00
Dan Brown
312153befc New translations common.php (Portuguese, Brazilian) 2019-12-27 16:40:20 +00:00
Dan Brown
f85bf94c80 New translations errors.php (Portuguese, Brazilian) 2019-12-27 16:40:17 +00:00
Dan Brown
971c0b1b88 New translations activities.php (Russian) 2019-12-27 16:40:15 +00:00
Dan Brown
eff87696bc New translations auth.php (Russian) 2019-12-27 16:40:14 +00:00
Dan Brown
b44c27ba81 New translations common.php (Russian) 2019-12-27 16:40:13 +00:00
Dan Brown
f978ba3033 New translations entities.php (Russian) 2019-12-27 16:40:11 +00:00
Dan Brown
c163863b03 New translations passwords.php (Russian) 2019-12-27 16:40:08 +00:00
Dan Brown
ce70738c93 New translations settings.php (Portuguese, Brazilian) 2019-12-27 16:40:07 +00:00
Dan Brown
96f1fd534a New translations errors.php (Russian) 2019-12-27 16:40:05 +00:00
Dan Brown
5800bd82fb New translations errors.php (Spanish, Argentina) 2019-12-27 16:40:04 +00:00
Dan Brown
20ba63ce0e New translations validation.php (Turkish) 2019-12-27 16:40:03 +00:00
Dan Brown
026247be99 New translations activities.php (Ukrainian) 2019-12-27 16:40:01 +00:00
Dan Brown
303f4164f0 New translations auth.php (Ukrainian) 2019-12-27 16:40:00 +00:00
Dan Brown
3cb0742918 New translations common.php (Ukrainian) 2019-12-27 16:39:59 +00:00
Dan Brown
d7d634a3d7 New translations entities.php (Ukrainian) 2019-12-27 16:39:58 +00:00
Dan Brown
d957e72047 New translations errors.php (Ukrainian) 2019-12-27 16:39:56 +00:00
Dan Brown
6a0627e1d1 New translations passwords.php (Ukrainian) 2019-12-27 16:39:54 +00:00
Dan Brown
063a7af846 New translations settings.php (Turkish) 2019-12-27 16:39:53 +00:00
Dan Brown
dc5cc8c3ec New translations settings.php (Ukrainian) 2019-12-27 16:39:52 +00:00
Dan Brown
f39f2ca487 New translations auth.php (German Informal) 2019-12-27 16:39:50 +00:00
Dan Brown
b5d1b611b1 New translations common.php (German Informal) 2019-12-27 16:39:49 +00:00
Dan Brown
e9e6f11c2f New translations entities.php (German Informal) 2019-12-27 16:39:48 +00:00
Dan Brown
e634391fd7 New translations errors.php (German Informal) 2019-12-27 16:39:46 +00:00
Dan Brown
6ffb283014 New translations settings.php (German Informal) 2019-12-27 16:39:45 +00:00
Dan Brown
0105d4d5dd New translations validation.php (Ukrainian) 2019-12-27 16:39:42 +00:00
Dan Brown
63f9391f53 New translations errors.php (Turkish) 2019-12-27 16:39:40 +00:00
Dan Brown
51dd11410f New translations settings.php (Spanish, Argentina) 2019-12-27 16:39:38 +00:00
Dan Brown
475a1f4dff New translations common.php (Swedish) 2019-12-27 16:39:37 +00:00
Dan Brown
ed38be9672 New translations errors.php (Swedish) 2019-12-27 16:39:35 +00:00
Dan Brown
f55acc7cf1 New translations settings.php (Swedish) 2019-12-27 16:39:33 +00:00
Dan Brown
e6a1b2ea97 New translations auth.php (Turkish) 2019-12-27 16:39:30 +00:00
Dan Brown
8e4f331674 New translations common.php (Turkish) 2019-12-27 16:39:29 +00:00
Dan Brown
5ab6ad0526 New translations entities.php (Turkish) 2019-12-27 16:39:27 +00:00
Dan Brown
1568f953b6 New translations common.php (Dutch) 2019-12-27 16:39:25 +00:00
Dan Brown
68750b5da4 New translations settings.php (Czech) 2019-12-27 16:39:23 +00:00
Dan Brown
7cf2fd72ab New translations errors.php (Czech) 2019-12-27 16:39:21 +00:00
Dan Brown
c24905f606 New translations common.php (Czech) 2019-12-27 16:39:20 +00:00
Dan Brown
740543d32d New translations settings.php (Dutch) 2019-12-27 16:39:16 +00:00
Dan Brown
dd4feb4f3d New translations common.php (French) 2019-12-27 16:39:14 +00:00
Dan Brown
c58214c6fd New translations entities.php (French) 2019-12-27 16:39:12 +00:00
Dan Brown
3d187627d0 New translations errors.php (French) 2019-12-27 16:39:10 +00:00
Dan Brown
601126ba2e New translations errors.php (Dutch) 2019-12-27 16:39:07 +00:00
Dan Brown
003eb6ea26 New translations settings.php (Arabic) 2019-12-27 16:39:06 +00:00
Dan Brown
e4d847ab51 New translations settings.php (Chinese Traditional) 2019-12-27 16:39:05 +00:00
Dan Brown
82e344166e New translations settings.php (French) 2019-12-27 16:39:03 +00:00
Dan Brown
9e6f208dec New translations common.php (Arabic) 2019-12-27 16:39:02 +00:00
Dan Brown
5856998c60 New translations errors.php (Arabic) 2019-12-27 16:38:59 +00:00
Dan Brown
0d9be20af8 New translations auth.php (Chinese Simplified) 2019-12-27 16:38:57 +00:00
Dan Brown
f6816db615 New translations common.php (Chinese Simplified) 2019-12-27 16:38:56 +00:00
Dan Brown
faf455e78c New translations entities.php (Chinese Simplified) 2019-12-27 16:38:54 +00:00
Dan Brown
3534082c8c New translations settings.php (Chinese Simplified) 2019-12-27 16:38:52 +00:00
Dan Brown
8240295c86 New translations common.php (Chinese Traditional) 2019-12-27 16:38:50 +00:00
Dan Brown
bbb379919f New translations entities.php (Chinese Traditional) 2019-12-27 16:38:48 +00:00
Dan Brown
d1a9ddf3db New translations errors.php (Chinese Traditional) 2019-12-27 16:38:47 +00:00
Dan Brown
ee9c53325d New translations errors.php (Chinese Simplified) 2019-12-27 16:38:45 +00:00
Dan Brown
7c5488f318 New translations errors.php (Korean) 2019-12-27 16:38:44 +00:00
Dan Brown
f9969978a5 New translations auth.php (German) 2019-12-27 16:38:43 +00:00
Dan Brown
85c2e3bc8e New translations common.php (Japanese) 2019-12-27 16:38:41 +00:00
Dan Brown
f10808f6a0 New translations errors.php (Japanese) 2019-12-27 16:38:39 +00:00
Dan Brown
5d214b184d New translations settings.php (Japanese) 2019-12-27 16:38:38 +00:00
Dan Brown
f5a83eccb7 New translations auth.php (Korean) 2019-12-27 16:38:35 +00:00
Dan Brown
cf473edcbd New translations common.php (Korean) 2019-12-27 16:38:34 +00:00
Dan Brown
58b14d9e89 New translations entities.php (Korean) 2019-12-27 16:38:32 +00:00
Dan Brown
7cf60afe14 New translations passwords.php (Korean) 2019-12-27 16:38:30 +00:00
Dan Brown
78bd482931 New translations settings.php (Korean) 2019-12-27 16:38:29 +00:00
Dan Brown
83e946f146 New translations common.php (Polish) 2019-12-27 16:38:26 +00:00
Dan Brown
8d3220c98a New translations entities.php (Polish) 2019-12-27 16:38:25 +00:00
Dan Brown
9b0d1f1328 New translations errors.php (Polish) 2019-12-27 16:38:23 +00:00
Dan Brown
433d66e406 New translations activities.php (German) 2019-12-27 16:38:21 +00:00
Dan Brown
e2fec5cb4f New translations settings.php (Italian) 2019-12-27 16:38:20 +00:00
Dan Brown
702ad28a9a New translations common.php (German) 2019-12-27 16:38:18 +00:00
Dan Brown
8c224a57f0 New translations entities.php (German) 2019-12-27 16:38:17 +00:00
Dan Brown
a260279463 New translations errors.php (German) 2019-12-27 16:38:15 +00:00
Dan Brown
76f43b95d4 New translations settings.php (German) 2019-12-27 16:38:13 +00:00
Dan Brown
4b19ba9ed1 New translations validation.php (German) 2019-12-27 16:38:12 +00:00
Dan Brown
f1777280c3 New translations auth.php (Hungarian) 2019-12-27 16:38:10 +00:00
Dan Brown
4df112661f New translations common.php (Hungarian) 2019-12-27 16:38:09 +00:00
Dan Brown
33e17aed6e New translations entities.php (Hungarian) 2019-12-27 16:38:07 +00:00
Dan Brown
047b711fc4 New translations settings.php (Hungarian) 2019-12-27 16:38:04 +00:00
Dan Brown
15df902a32 New translations validation.php (Hungarian) 2019-12-27 16:38:02 +00:00
Dan Brown
5e2908c996 New translations common.php (Italian) 2019-12-27 16:38:01 +00:00
Dan Brown
6c9e162821 New translations entities.php (Italian) 2019-12-27 16:37:59 +00:00
Dan Brown
8235677b31 New translations errors.php (Italian) 2019-12-27 16:37:58 +00:00
Dan Brown
a4eb5c3268 New translations errors.php (Hungarian) 2019-12-27 16:37:56 +00:00
Dan Brown
4613dbfb8f Merge branch 'qianmengnet-master' 2019-12-27 16:29:06 +00:00
Dan Brown
8144887e4c Merge branch 'master' of git://github.com/qianmengnet/BookStack into qianmengnet-master 2019-12-27 16:28:56 +00:00
Dan Brown
d9f0d46d20 Merge pull request #1819 from johnroyer/translate
update translation
2019-12-27 16:22:59 +00:00
Dan Brown
611cb29073 Merge pull request #1804 from artskoczylas/master
Update of polish language
2019-12-27 16:21:23 +00:00
Dan Brown
84af59bbe6 Merge pull request #1791 from jzoy/master
Simplified Chinese Translation Update
2019-12-27 16:19:00 +00:00
Dan Brown
a3cf45cfb6 Merge pull request #1762 from dellamina/updateItalianTranslation
Update Italian translation
2019-12-27 16:18:04 +00:00
Dan Brown
dabe79a438 Merge pull request #1734 from ististudio/master
Update Korean translation
2019-12-27 16:16:55 +00:00
ezzra
a82d9fdba5 fix translate for "actions" 2019-12-27 15:47:03 +00:00
Lior Broshi
8794f62ad8 settings 2019-12-24 23:36:35 +02:00
Lior Broshi
290610b77e more heb 2019-12-23 22:04:15 +02:00
Dan Brown
865e8d4ec5 Improved markdown mobile editor experience
- Updated styles of codemirror area to be a bit more forefull in taking
up space.
- Added a fullscreen toggle as a backup option.

For #1675
2019-12-22 14:22:38 +00:00
Dan Brown
e06f9f7fe3 Removed setting override system due to confusing behaviour
- Was only used to disable registration when LDAP was enabled.
- Caused saved option not to show on settings page causing confusion.
- Extended setting logic where used to take ldap into account instead of
global override.
- Added warning on setting page to show registration enable setting is
not used while ldap is active.

For #1541
2019-12-22 13:19:17 +00:00
Dan Brown
32e7f0a2e6 Made display thumbnail generation use original data if smaller
Thumbnail generation would sometimes create a file larger than the
original, if the original was already well optimized, therefore making
the thumbnail counter-productive. This change compares the sizes of the
original and the generated thumbnail, and uses the smaller of the two if
the thumbnail does not change the aspect ratio of the image.

Fixes #1751
2019-12-22 12:44:49 +00:00
Lior Broshi
b65d4cda0e pagination, passwords and part of settings 2019-12-21 22:34:50 +02:00
Dan Brown
a83a7f34f4 Better standardised and fixes areas of image pasting
- Extracted logic to get images from paste/drop event into own file to
align usage in both events for both editors.
- Fixed non-ability to drag+drop into WYSIWYG editor.
- Updated check for table data to look for table specific rich-text
instead of just any text since some the old check was too general and
was preventing some legitimate image paste events.

Tested on Chrome and FireFox on Ubuntu.
Attempted to test on Safari via browserstack but environment was
unreliable and could not access folders to test drag/drop of files.

Relates to #1651 and #1697
2019-12-21 15:48:03 +00:00
Dan Brown
5491bd62a2 Fixed test failing due to redirect changes
- Also set APP_THEME param during testing to avoid local conflicts
2019-12-21 13:48:44 +00:00
Dan Brown
ceeb9d35a0 Updated JS dependancies 2019-12-21 13:30:41 +00:00
Zero
50ed56f65d update translation 2019-12-17 16:52:39 +08:00
Dan Brown
b93f8a4d46 Auto-expand collapsible sections if containing error
For #1693
2019-12-16 13:27:17 +00:00
Dan Brown
a9634b6b66 Set a default outline color and width
- Applied since the browser defaults caused outlines to appear very
large in some cases.
- Set default color to use app primary color, to help them blend into
the design a little.

For #1738
2019-12-16 13:15:11 +00:00
Dan Brown
f9fa6904b9 Made LDAP auth ID attribute configurable
- Allows the field that gets stored as the "External Authentication ID"
to be configurable. Defined as LDAP_ID_ATTRIBUTE=uid in .env.
- Added test to cover usage.
- Also now auto-lowercases when searching for attributes in LDAP
response since PHP always provides them as lower case.

Closes #592.
2019-12-16 12:40:21 +00:00
Dan Brown
017703ff1a Updated page delete to return to chapter if within one
- Added test to cover

Closes #1715
2019-12-16 11:54:53 +00:00
Dan Brown
f122bebae7 Prevented whitespace in codeblocks being trimmed
For #1771
2019-12-16 11:44:28 +00:00
Daniel Seiler
afa501e75b Recall previous route when manually clicking login 2019-12-14 08:41:22 +01:00
Dan Brown
02af69ddf2 Added command to copy shelf permissions
Has options to run for all or to specify a slug for a specific shelf.

Closes #1091
2019-12-11 21:22:03 +00:00
jzoy
7f0dbf350e Update entities.php 2019-12-11 20:03:32 +08:00
jzoy
b3889c380a Update entities.php 2019-12-11 19:24:18 +08:00
Dan Brown
cee4dccc55 Compacted entity color options in settings view
- Also extracted the view code into it's own blade template
- Made smaller color input styles
2019-12-07 21:23:15 +00:00
Dan Brown
615a050856 Merge branch 'settings-color-selector' of git://github.com/james-geiger/BookStack into james-geiger-settings-color-selector 2019-12-07 20:36:39 +00:00
Dan Brown
5a533fff8b Updated codeblock editor to work with fade animation
- Added fadeIn to animation JS service.
- Updated overlay to use anim service and to recieve a callback for
after-anim actions.
- Updated code editor popup to refresh Codemirror instance layout after
animation has completed.

Fixes #1672
2019-12-07 16:54:34 +00:00
Dan Brown
a6bbe46987 Updated code system to dynamically set php codemirror mode
- Codemirror mode mapping value can now be a function to dynamically set
mode depending on actual code content.
- Used above system to set php mode type, depending on if '<?php' tags
exist in content.

Closes #1557
2019-12-07 16:23:44 +00:00
Dan Brown
7af6fe4917 Standardised issue template name casing 2019-12-07 15:57:58 +00:00
Dan Brown
1746f2c249 Fixed issue template wording 2019-12-07 15:57:07 +00:00
Dan Brown
701d105b9e Updated details and attribution for translators 2019-12-07 15:54:25 +00:00
Artur Skoczylas
6bc6543402 consistent of book translation 2019-11-28 15:55:18 +01:00
Artur Skoczylas
000059f6cf Update polish language 2019-11-28 15:39:54 +01:00
Dan Brown
1bcb24a921 Added in the github translators that didn't save in the last comit 2019-11-22 22:41:00 +00:00
Dan Brown
abb5cc1852 Added a translator attriubtion file
Added list of users that have provided translations along with the
languages they have provided.

Github users, starting with an '@', were added via manually going
through past pull requests and inspecting the user and language.
2019-11-22 22:23:22 +00:00
qianmengnet
9bed57e40e Modify Chinese language pack
Modify Chinese language pack
2019-11-22 16:35:47 +08:00
abublihi
23a716a3ac Fix "Declaration of Middleware\TrustProxies::handle should be compatible with Fideloper\Proxy\TrustProxies::handle" 2019-11-20 14:00:20 +03:00
jzoy
f8279384a6 Simplified Chinese Translation Update 2019-11-19 18:22:23 +08:00
Dan Brown
4e09656c78 Merge pull request #1787 from BookStackApp/saml2_auth
SAML2 Authentication
2019-11-17 19:20:37 +00:00
Dan Brown
c33ef4b9b2 Added tests to cover saml and added controller middleware 2019-11-17 19:15:37 +00:00
Dan Brown
ebb3724892 Added onelogin attribution and tweaks after testing saml with onelogin 2019-11-17 17:00:42 +00:00
Dan Brown
6d899f3b17 Added icon for saml, added saml to register page, updated complete env 2019-11-17 16:07:06 +00:00
Dan Brown
aef6eb81e4 Added SAML singleLogoutService capabilities 2019-11-17 15:40:36 +00:00
Dan Brown
488325f459 Added the ability to auto-load config from metadata url 2019-11-17 14:44:26 +00:00
Dan Brown
3a17ba2cb9 Started using OneLogin SAML lib directly
- Aligned and formatted config options.
- Provided way to override onelogin lib options if required.
- Added endpoints in core bookstack routes.
- Provided way to debug details provided by idp and formatted by
bookstack.
- Started on test work
- Handled case of email address already in use.
2019-11-17 13:26:43 +00:00
Dan Brown
9bba84684f Appeased codeclimate by extracting out external_auth_id group matching 2019-11-16 15:24:09 +00:00
Dan Brown
8169c725d5 Started review of SAML implementation
- Updated PHPdoc of SAML service to use type hinting instead.
- Updated groups to only sync if enabled.
- Updated names of some config props.
- Removed a couple of unused config props.
- Added exception to handle no email on SAML response.
2019-11-16 14:42:51 +00:00
Dan Brown
bb1f43cbd8 Merge branch 'feature/saml' of git://github.com/Xiphoseer/BookStack into Xiphoseer-feature/saml 2019-11-16 12:42:45 +00:00
User
7c7098b601 Update some postpositional particles 2019-11-07 16:54:38 +09:00
User
ffd05f1584 Update some terms 2019-11-07 15:19:15 +09:00
Mattia Della Mina
b8a7ef15bd improve italian translation 2019-10-31 22:04:04 +01:00
jakob
6cd26e23a8 Allow toggling between grid and list view in shelf view (shelves.show) 2019-10-30 11:23:42 +01:00
Dan Brown
189a598d56 Merge branch 'master' of github.com:BookStackApp/BookStack 2019-10-29 22:34:12 +00:00
Dan Brown
90f9240be4 Merge branch 'philjak-feature_move_page_into_chapter' 2019-10-29 22:33:53 +00:00
Dan Brown
d64c358c4f Updated sort logic to handle chapter to book scenario
- Extended tests out to cover
2019-10-29 22:33:09 +00:00
Dan Brown
e108808a32 Merge branch 'feature_move_page_into_chapter' of git://github.com/philjak/BookStack into philjak-feature_move_page_into_chapter 2019-10-29 22:26:11 +00:00
Dan Brown
6a1b6a97f9 Added test for page move into chapter 2019-10-29 22:25:53 +00:00
jakob
bea983ab85 Download and assign avatar when creating LDAP user in database. Fixes issue #1161 2019-10-29 22:18:02 +00:00
jakob
7368ff3e6a No need to save page 2019-10-28 16:53:48 +01:00
jakob
4daeb9daa6 Check if parent is a chapter. If so, move into Book and assing page to chapter. 2019-10-28 15:33:28 +01:00
Dan Brown
b80aa2350f Merge branch 'philjak-feature_bugfix_save_book_cover' 2019-10-27 17:05:34 +00:00
Dan Brown
e26474f233 Merge branch 'feature_bugfix_save_book_cover' of git://github.com/philjak/BookStack into philjak-feature_bugfix_save_book_cover 2019-10-27 17:03:02 +00:00
Dan Brown
1b350d622d Merge branch 'cw1998-fix/#1662' 2019-10-27 16:57:00 +00:00
Dan Brown
4b9618cd21 Update book form so cancel URL is explicitly passed in
- Added to prevent future possibility of 'shelf' var being introduced in
scope and therefore causing a side-effect of redirect logic.
2019-10-27 16:55:05 +00:00
Dan Brown
28184c6bfc Merge branch 'fix/#1662' of git://github.com/cw1998/BookStack into cw1998-fix/#1662 2019-10-27 16:44:41 +00:00
Dan Brown
99ce3067c7 Added test to check custom theme lang items 2019-10-26 18:07:14 +01:00
Dan Brown
4763b899b6 Made it possible to override translations via theme system 2019-10-26 18:07:14 +01:00
Dan Brown
1366fc45ce Added tests to cover test email sends
- Also tweaked wording of 'E-mail' to 'Email' to remain consistent with
the rest of the app.

Related to #1696 and #1719
2019-10-23 20:25:51 +01:00
Dan Brown
a2370f7c9d Merge branch 'feature-send-test-email' of git://github.com/timoschwarzer/BookStack into timoschwarzer-feature-send-test-email 2019-10-23 19:53:51 +01:00
jakob
bc38fd3ac4 entity needs to be saved after image upload and associate 2019-10-22 11:18:08 +02:00
istist
5ae0e127df Update auth.php
Edit for  'Must be over 7 characters'.
2019-10-21 04:17:52 +09:00
User
2eb6f178c2 Banish .DS_Store 2019-10-21 03:51:55 +09:00
User
19ce6da0d3 Update Korean translation 2019-10-21 03:44:38 +09:00
Dan Brown
99ae592ae9 New Crowdin translations (#1732)
* New translations auth.php (Ukrainian)

* New translations common.php (Ukrainian)

* New translations entities.php (Ukrainian)

* New translations entities.php (Ukrainian)

* New translations settings.php (Ukrainian)

* New translations validation.php (Ukrainian)

* New translations settings.php (French)

* New translations settings.php (Russian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Ukrainian)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovak)

* New translations settings.php (Polish)

* New translations settings.php (Spanish)

* New translations settings.php (Dutch)

* New translations settings.php (Korean)

* New translations settings.php (Japanese)

* New translations settings.php (Italian)

* New translations settings.php (Hungarian)

* New translations settings.php (German)

* New translations settings.php (Czech)

* New translations settings.php (Arabic)

* New translations settings.php (German Informal)
2019-10-19 00:13:00 +01:00
Dan Brown
f37131a5bf Removed old Translation Service + Provider
Was no longer needed due to only being there to perform
language extension for de_informal but now this is done by crowdin
instead so it's redundant. Same goes for checking and formatting
scripts.

Also removed comment advising deletion form settings.php language list
since this is now auto-copied to languages anyway.

Related to #1261
2019-10-19 00:04:49 +01:00
Dan Brown
f3a7d58816 Initial Crowdin Translation Integration Merge (#1731)
* New translations activities.php (French)

* New translations entities.php (Turkish)

* New translations entities.php (Swedish)

* New translations errors.php (Swedish)

* New translations pagination.php (Swedish)

* New translations passwords.php (Swedish)

* New translations settings.php (Swedish)

* New translations validation.php (Swedish)

* New translations auth.php (Turkish)

* New translations common.php (Turkish)

* New translations errors.php (Turkish)

* New translations common.php (Swedish)

* New translations settings.php (Turkish)

* New translations validation.php (Turkish)

* New translations activities.php (Ukrainian)

* New translations auth.php (Ukrainian)

* New translations common.php (Ukrainian)

* New translations components.php (Ukrainian)

* New translations entities.php (Ukrainian)

* New translations errors.php (Ukrainian)

* New translations components.php (Swedish)

* New translations auth.php (Swedish)

* New translations passwords.php (Ukrainian)

* New translations settings.php (Russian)

* New translations settings.php (Polish)

* New translations validation.php (Polish)

* New translations activities.php (Russian)

* New translations auth.php (Russian)

* New translations common.php (Russian)

* New translations components.php (Russian)

* New translations entities.php (Russian)

* New translations errors.php (Russian)

* New translations passwords.php (Russian)

* New translations validation.php (Russian)

* New translations activities.php (Swedish)

* New translations activities.php (Slovak)

* New translations auth.php (Slovak)

* New translations common.php (Slovak)

* New translations components.php (Slovak)

* New translations entities.php (Slovak)

* New translations errors.php (Slovak)

* New translations pagination.php (Slovak)

* New translations passwords.php (Slovak)

* New translations settings.php (Slovak)

* New translations validation.php (Slovak)

* New translations pagination.php (Ukrainian)

* New translations settings.php (Ukrainian)

* New translations pagination.php (Polish)

* New translations passwords.php (Spanish, Argentina)

* New translations settings.php (Portuguese, Brazilian)

* New translations validation.php (Portuguese, Brazilian)

* New translations activities.php (Spanish, Argentina)

* New translations auth.php (Spanish, Argentina)

* New translations common.php (Spanish, Argentina)

* New translations components.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations errors.php (Spanish, Argentina)

* New translations pagination.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)

* New translations errors.php (Portuguese, Brazilian)

* New translations validation.php (Spanish, Argentina)

* New translations activities.php (German Informal)

* New translations auth.php (German Informal)

* New translations common.php (German Informal)

* New translations components.php (German Informal)

* New translations entities.php (German Informal)

* New translations errors.php (German Informal)

* New translations pagination.php (German Informal)

* New translations passwords.php (German Informal)

* New translations settings.php (German Informal)

* New translations entities.php (Portuguese, Brazilian)

* New translations validation.php (Ukrainian)

* New translations activities.php (Chinese Traditional)

* New translations activities.php (Chinese Simplified)

* New translations auth.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations components.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations errors.php (Chinese Simplified)

* New translations pagination.php (Chinese Simplified)

* New translations passwords.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations validation.php (Chinese Simplified)

* New translations auth.php (Chinese Traditional)

* New translations components.php (Portuguese, Brazilian)

* New translations common.php (Chinese Traditional)

* New translations components.php (Chinese Traditional)

* New translations entities.php (Chinese Traditional)

* New translations errors.php (Chinese Traditional)

* New translations pagination.php (Chinese Traditional)

* New translations passwords.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations validation.php (Chinese Traditional)

* New translations activities.php (Portuguese, Brazilian)

* New translations auth.php (Portuguese, Brazilian)

* New translations common.php (Portuguese, Brazilian)

* New translations passwords.php (Polish)

* New translations errors.php (Polish)

* New translations auth.php (French)

* New translations validation.php (Czech)

* New translations validation.php (Arabic)

* New translations auth.php (Czech)

* New translations common.php (Czech)

* New translations entities.php (Czech)

* New translations errors.php (Czech)

* New translations pagination.php (Czech)

* New translations settings.php (Czech)

* New translations activities.php (German)

* New translations passwords.php (Arabic)

* New translations auth.php (German)

* New translations common.php (German)

* New translations components.php (German)

* New translations entities.php (German)

* New translations errors.php (German)

* New translations pagination.php (German)

* New translations passwords.php (German)

* New translations settings.php (German)

* New translations validation.php (German)

* New translations settings.php (Arabic)

* New translations pagination.php (Arabic)

* New translations common.php (Hungarian)

* New translations common.php (Spanish)

* New translations common.php (French)

* New translations components.php (French)

* New translations entities.php (French)

* New translations errors.php (French)

* New translations pagination.php (French)

* New translations passwords.php (French)

* New translations settings.php (French)

* New translations validation.php (French)

* New translations errors.php (Arabic)

* New translations settings.php (Spanish)

* New translations validation.php (Spanish)

* New translations activities.php (Arabic)

* New translations auth.php (Arabic)

* New translations common.php (Arabic)

* New translations components.php (Arabic)

* New translations entities.php (Arabic)

* New translations auth.php (Hungarian)

* New translations entities.php (Polish)

* New translations common.php (Dutch)

* New translations common.php (Korean)

* New translations components.php (Korean)

* New translations entities.php (Korean)

* New translations errors.php (Korean)

* New translations pagination.php (Korean)

* New translations passwords.php (Korean)

* New translations settings.php (Korean)

* New translations validation.php (Korean)

* New translations activities.php (Dutch)

* New translations auth.php (Dutch)

* New translations components.php (Dutch)

* New translations activities.php (Korean)

* New translations entities.php (Dutch)

* New translations errors.php (Dutch)

* New translations pagination.php (Dutch)

* New translations passwords.php (Dutch)

* New translations settings.php (Dutch)

* New translations validation.php (Dutch)

* New translations activities.php (Polish)

* New translations auth.php (Polish)

* New translations common.php (Polish)

* New translations components.php (Polish)

* New translations auth.php (Korean)

* New translations validation.php (Japanese)

* New translations entities.php (Hungarian)

* New translations errors.php (Italian)

* New translations errors.php (Hungarian)

* New translations settings.php (Hungarian)

* New translations validation.php (Hungarian)

* New translations activities.php (Italian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations components.php (Italian)

* New translations entities.php (Italian)

* New translations pagination.php (Italian)

* New translations settings.php (Japanese)

* New translations passwords.php (Italian)

* New translations settings.php (Italian)

* New translations validation.php (Italian)

* New translations activities.php (Japanese)

* New translations auth.php (Japanese)

* New translations common.php (Japanese)

* New translations components.php (Japanese)

* New translations entities.php (Japanese)

* New translations errors.php (Japanese)

* New translations pagination.php (Japanese)

* New translations passwords.php (Japanese)

* New translations validation.php (German Informal)
2019-10-18 17:08:22 +01:00
Albergoni Andrea
48c44958f5 Added support for Pascal language 2019-10-18 16:34:38 +02:00
Dan Brown
f1d7699df5 Updated Korean to be correct country code 2019-10-18 14:27:41 +01:00
Dan Brown
37560a3f8c Fixed crowdin translation path 2019-10-18 02:09:12 +01:00
Dan Brown
5f3caf3643 Update Crowdin configuration file 2019-10-18 01:46:30 +01:00
Dan Brown
c8d90791ff Moved crownin config file 2019-10-18 01:45:25 +01:00
Dan Brown
c108c16419 Added crowdin config file 2019-10-18 01:40:50 +01:00
Dan Brown
b09ea76b8d Renamed properties input option as INI
- Also made INI be recognised as the codemirror "Properties" format.
2019-10-17 21:16:55 +01:00
Dan Brown
8b4bfa4d78 Merge branch 'master' of git://github.com/c0shea/BookStack into c0shea-master 2019-10-17 21:09:05 +01:00
James Geiger
e6fe299c4f added additional color settings into UI
Adds new options in the customization section of the settings to change the shelf, book, chapter, page, and draft colors.
2019-10-17 13:46:18 -05:00
James Geiger
ae19658b50 Placeholder for allowing colors to be changed in settings UI. 2019-10-17 09:44:20 -05:00
Dan Brown
d7557befe2 Copied release page link to normal settings page
- Also updated link to not leak referrer info
2019-10-17 15:06:55 +01:00
Dan Brown
5c7262673a Merge branch 'patch-1' of git://github.com/DeftNerd/BookStack into DeftNerd-patch-1 2019-10-17 14:58:20 +01:00
Dan Brown
ef762e851a Reverted changes to codemirror line wrapping 2019-10-17 14:41:39 +01:00
Dan Brown
e91ef54cc9 Merge branch 'fix-1575' of git://github.com/james-geiger/BookStack into james-geiger-fix-1575 2019-10-17 14:32:39 +01:00
Dan Brown
3959841dbc Added back in some tabindex that shouldn't have been removed 2019-10-17 14:21:13 +01:00
Dan Brown
e48d7d59cc Removed tabindexes where found to be not required 2019-10-17 14:19:35 +01:00
Dan Brown
5a887e31da Merge branch 'master' of git://github.com/almandin/BookStack into almandin-master 2019-10-17 14:09:07 +01:00
Dan Brown
df98deb59d Added Turkish to locale system 2019-10-17 14:01:19 +01:00
Dan Brown
ddb7f33868 Merge branch 'master' of git://github.com/oykenfurkan/BookStack into oykenfurkan-master 2019-10-17 13:56:53 +01:00
Dan Brown
076cf59a41 Merge pull request #1695 from qligier/master
French translation update
2019-10-17 13:46:33 +01:00
Dan Brown
4ab6a25893 Merge pull request #1681 from leomartinez/master
Updated 'Spanish Argentina' translation.
2019-10-17 13:44:28 +01:00
Dan Brown
f413fc528a Merge pull request #1646 from kostefun/patch-15
Update settings.php
2019-10-17 13:43:27 +01:00
Dan Brown
76bd0fdfa6 Added editor instance event hooks
As per #1721
2019-10-16 18:01:35 +01:00
Dan Brown
b24279cc12 Merge branch 'patching-v0.27' 2019-10-16 16:37:29 +01:00
Dan Brown
af6f34b529 Updated version and assets for release v0.27.5 2019-10-16 16:35:50 +01:00
Dan Brown
fb82a2b896 Merge branch 'patching-v0.27' into release 2019-10-16 16:35:10 +01:00
Timo Schwarzer
61a9139bf0 Add feature to send test e-mails 2019-10-16 08:24:33 +02:00
Dan Brown
d6456e961a Fixed issue causing text overlap in sort select box
Updated grid columns to be more adaptable to content, with a min-width
of the old value.
Fixes #1654
2019-10-07 21:06:15 +01:00
Dan Brown
d4c62265ca Made JS animation cleanup process more reliable
Fixes #1643
2019-10-07 20:57:25 +01:00
Dan Brown
b6c0baf44d Updated comment delete action to be a button
Fixes issue that causes code error when an anchor tag.

Closes #1650
2019-10-07 20:21:04 +01:00
Dan Brown
ccec991f6c Added zip/unzip to docker dev setup for composer to use 2019-10-05 13:21:38 +01:00
Dan Brown
6736779eff Merge pull request #1698 from 3mmarg97/master
Fixing composer install inside docker `app` container
2019-10-05 13:13:29 +01:00
Dan Brown
31f5786e01 Entity Repo & Controller Refactor (#1690)
* Started mass-refactoring of the current entity repos

* Rewrote book tree logic

- Now does two simple queries instead of one really complex one.
- Extracted logic into its own class.
- Remove model-level akward union field listing.
- Logic now more readable than being large separate query and
compilation functions.

* Extracted and split book sort logic

* Finished up Book controller/repo organisation

* Refactored bookshelves controllers and repo parts

* Fixed issues found via phpunit

* Refactored Chapter controller

* Updated Chapter export controller

* Started Page controller/repo refactor

* Refactored another chunk of PageController

* Completed initial pagecontroller refactor pass

* Fixed tests and continued reduction of old repos

* Removed old page remove and further reduced entity repo

* Removed old entity repo, split out page controller

* Ran phpcbf and split out some page content methods

* Tidied up some EntityProvider elements

* Fixed issued caused by viewservice change
2019-10-05 12:55:01 +01:00
Ammar Al-Khawaldeh
3f025f69cf Add git to the apt-get install packages. 2019-10-03 20:46:49 +03:00
Quentin Ligier
593933c84e French translation update 2019-10-01 20:34:54 +02:00
Christopher Wilkinson
4ad4dfa55a Show bookshelves that a book belongs to on a book view
Closes #1598
2019-09-27 00:45:22 +01:00
Christopher Wilkinson
2f94f078e3 Fix Book form (create) returning to the full books list on cancel
Fixes #1662
Added a small block of logic to determine the correct URL to attribute to the cancel button on a given page create form.
If adding a book from a bookshelf, return to the bookshelf. If editing a book, return to the book. In all other cases, return to the full books list.
2019-09-26 22:51:24 +01:00
Leonardo
0fa4ef072f Updated 'Spanish Argentina' translation. 2019-09-24 22:36:08 -03:00
Dan Brown
7cd956b24b Removed some unused parameters and fixed env test logic 2019-09-20 01:18:59 +01:00
Dan Brown
8b550991a4 Refactored some core entity actions
- Created BookChild class to share some page/chapter logic.
- Gave entities the power to generate their own permissions and slugs.
- Moved bits out of BaseController constructor since it was overly
sticky.
- Moved slug generation logic into its own class.
- Created a facade for permissions due to high use.
- Fixed failing test issues from last commits
2019-09-20 00:18:28 +01:00
Dan Brown
f7a5a0705b Moved shelf book append logic 2019-09-19 18:20:09 +01:00
Dan Brown
615b2de433 Simplified activity facade interface
Also cleaned up any other bits along the way.
2019-09-19 18:03:17 +01:00
Dan Brown
2a2cc858f0 Refactored notification showing and global view data 2019-09-19 15:12:10 +01:00
Connor O'Shea
fca69643db Normalize ini and properties values 2019-09-15 20:24:47 -04:00
Connor O'Shea
4ad43b1a1f Add support for properties 2019-09-15 20:22:26 -04:00
Connor O'Shea
228aa4740b Add support for properties (INI) 2019-09-15 20:20:11 -04:00
Dan Brown
60d0f96cd7 Extracted some methods into a BookRepo 2019-09-15 23:28:23 +01:00
Dan Brown
d28abf24d4 Split out export actions into own controllers 2019-09-15 22:33:27 +01:00
Dan Brown
3281925375 Standardised how request is injected into controller methods
Puts it in-line with how Laravel recommend.
2019-09-15 18:53:30 +01:00
Dan Brown
be08dc1588 Ran phpcbf and updated helpers typehinting 2019-09-15 18:29:51 +01:00
Dan Brown
b1566099a3 Added laravel stats package and enabled debugbar models 2019-09-15 18:07:00 +01:00
Dan Brown
e81f90d9bd Updated twitch provider 2019-09-15 17:50:08 +01:00
Dan Brown
8e212bdeab Ran NPM audit fix 2019-09-15 17:39:07 +01:00
Dan Brown
2ab5df75dd Updated version and removed travis 2019-09-14 14:23:16 +01:00
Dan Brown
ab91e245fb Merge branch 'master' of github.com:BookStackApp/BookStack 2019-09-14 14:21:35 +01:00
Dan Brown
1ee3e779e4 Merge branch 'laravel-upgrade' 2019-09-14 14:21:24 +01:00
Dan Brown
58a79fcb19 Removed old str_random functions from seeders 2019-09-14 14:17:55 +01:00
Dan Brown
cbf9d701af Updated to laravel 6 2019-09-14 14:12:39 +01:00
Dan Brown
140298bd96 Updated to Laravel 5.8 2019-09-13 23:58:40 +01:00
Furkan
84c36f6032 Added Turkish translations. 2019-09-13 15:45:38 +03:00
oykenfurkan
ec29c08beb Delete tr 2019-09-13 15:32:54 +03:00
oykenfurkan
9e8e5b2ca0 Create tr
Adding Turkish translations
2019-09-13 15:32:40 +03:00
Dan Brown
f311635de6 Add testing via GitHub actions (#1657)
To replace Travis CI. Travis to be removed after migration to Laravel 6.
2019-09-12 21:19:05 +01:00
kostefun
339d3849b8 Update settings.php
fix rus
2019-09-09 10:07:16 +07:00
Lior Broshi
974e52be5c errors.php 2019-09-08 23:08:49 +03:00
Lior Broshi
d84de86b40 entities.php 2019-09-07 22:46:30 +03:00
Dan Brown
5b464938b6 Updated version and assets for release v0.27.4 2019-09-07 13:30:08 +01:00
Dan Brown
81f954890d Merge branch 'patching-v0.27' into release 2019-09-07 13:29:53 +01:00
Dan Brown
58f5508b05 Added mysql service for travis 2019-09-07 00:14:19 +01:00
Dan Brown
de8a1a372d Updated travis-ci php versions 2019-09-07 00:10:03 +01:00
Dan Brown
6917ea088f Upgraded app to Laravel 5.7 2019-09-06 23:36:16 +01:00
Dan Brown
213e9d2941 Upgraded to Laravel 5.6 2019-09-06 22:14:39 +01:00
Dan Brown
0e2bbcec62 Updated version and assets for release v0.27.3 2019-09-03 21:50:12 +01:00
Dan Brown
fdd339f525 Merge branch 'master' into release 2019-09-03 21:49:46 +01:00
Dan Brown
8cf7d6a83d Updated version and assets for release v0.27.2 2019-09-01 12:12:23 +01:00
Dan Brown
58a5008718 Merge branch 'master' into release 2019-09-01 12:12:10 +01:00
Dan Brown
c44a8df55d Updated version and assets for release v0.27.1 2019-09-01 11:13:50 +01:00
Dan Brown
ff1494c519 Merge branch 'master' into release 2019-09-01 11:13:18 +01:00
Dan Brown
b8ce8fd852 Updated assets for release v0.27 2019-08-31 14:16:14 +01:00
Dan Brown
75e7454a5f Merge branch 'master' into release and set version 2019-08-31 14:15:18 +01:00
Lior Broshi
019085071a entities, not done yet 2019-08-15 23:24:45 +03:00
James Geiger
c14611d14b Fixed inline code overflowing off of page in issue #1575. 2019-08-14 23:45:48 -05:00
James Geiger
fccc112cc1 Merge pull request #1 from BookStackApp/master
merge aug. 11 commits
2019-08-14 06:10:43 +00:00
Virgile
3bcfe2a460 Adds autofocus on the email field of the standard login page. 2019-08-13 17:30:29 +02:00
Daniel Seiler
8e723f10dc Add error messages, fix LDAP error 2019-08-07 15:31:10 +02:00
Daniel Seiler
03dbe32f99 Refactor for codestyle 2019-08-07 12:07:21 +02:00
Daniel Seiler
bda0082461 Add login and automatic registration; Prepare Group sync 2019-08-06 23:42:46 +02:00
Dan Brown
2558ea8931 Updated version for release v0.26.4 2019-08-06 21:42:09 +01:00
Dan Brown
ac0f47a4b2 Merge branch 'v0.26' into release 2019-08-06 21:41:06 +01:00
Daniel Seiler
3c41b15be6 Initial work on SAML integration 2019-08-05 20:06:39 +02:00
Lior Broshi
6c7e74f475 components 2019-07-22 22:57:00 +03:00
Lior Broshi
b153ffd019 common 2019-07-22 22:35:29 +03:00
Lior Broshi
9cfa391ab6 auth 2019-07-22 22:31:01 +03:00
Dan Brown
4f16129869 Updated version for release v0.26.3 2019-07-10 20:21:22 +01:00
Dan Brown
64a8037fdd Merge branch 'v0.26' into release 2019-07-10 20:19:54 +01:00
Lior Broshi
de140ab5a5 started Hebrew translation, not done yet 2019-07-09 22:40:35 +03:00
Dan Brown
7502ba1bc8 Updated version and assets for release v0.26.2 2019-05-27 13:48:20 +01:00
Dan Brown
33a04697ef Merge branch 'master' into release 2019-05-27 13:47:47 +01:00
Adam Brown
47a107ac5b Update maintenance.php
* Added a link to the Github releases page when someone clicks the current release version (to look for changelog information, or to see if there are new updates)
* Removed unnecessary BR tag by fixing the CSS class for the version display so it is properly aligned with the rest of the menu
2019-05-23 10:45:15 -04:00
Dan Brown
b70a5c0cdb Updated version and assets for release v0.26.1 2019-05-07 23:05:47 +01:00
Dan Brown
9443ae9f40 Merge branch 'master' into release 2019-05-07 23:05:10 +01:00
Dan Brown
220c2a4102 Updated version and assets for release v0.26.0 2019-05-06 18:58:56 +01:00
Dan Brown
e9914eb301 Merge branch 'master' into release 2019-05-06 18:57:58 +01:00
Dan Brown
934512d09c Updated version and assets for release v0.25.5 2019-03-24 19:45:17 +00:00
Dan Brown
9102c90986 Merge branch 'master' into release 2019-03-24 19:45:00 +00:00
Dan Brown
c3e74219c4 Updated version and assets for release v0.25.4 2019-03-21 19:46:19 +00:00
Dan Brown
13c9d7bc2d Merge branch 'master' into release 2019-03-21 19:43:48 +00:00
Dan Brown
119b539586 Updated version and assets for release v0.25.3 2019-03-21 00:03:26 +00:00
Dan Brown
29a5c180f0 Merge branch 'master' into release 2019-03-21 00:02:33 +00:00
Dan Brown
7906602291 Updated version and assets for release v0.25.2 2019-03-10 13:45:21 +00:00
Dan Brown
6dafe773ff Merge branch 'master' into release 2019-03-10 13:44:29 +00:00
Dan Brown
25bc28a1be Updated version and assets for release v0.25.1 2019-01-20 15:42:32 +00:00
Dan Brown
4c561c7fa0 Merge branch 'master' into release 2019-01-20 15:41:24 +00:00
Dan Brown
95b3e78573 Updated version and assets for release v0.25.0 2019-01-12 22:48:53 +00:00
Dan Brown
63a345bc93 Merge branch 'master' into release 2019-01-12 22:47:07 +00:00
Dan Brown
e093a172cb Updated assets and version for release v0.24.3 2018-11-27 21:52:20 +00:00
Dan Brown
4b01f8934b Merge branch 'master' into release 2018-11-27 21:51:32 +00:00
Dan Brown
bc116b45b5 Re-updated assets for release v0.24.2 2018-11-10 16:10:22 +00:00
Dan Brown
a059960b9e Merge branch 'master' into release 2018-11-10 16:09:14 +00:00
Dan Brown
7770966fed Updated assets for release v0.24.2 2018-11-10 16:01:55 +00:00
Dan Brown
d7adcf6c69 Merge branch 'master' into release 2018-11-10 16:01:01 +00:00
Dan Brown
04a364dcc3 Incremented version for v0.24.1 2018-09-24 16:34:16 +01:00
Dan Brown
db83ac7eaa Merge branch 'master' into release 2018-09-24 16:32:30 +01:00
Dan Brown
3ca9dddf61 Merge branch 'master' into release 2018-09-24 15:59:39 +01:00
Dan Brown
bf74f53ca7 Updated assets for release and incremented version 2018-09-24 12:18:27 +01:00
Dan Brown
9d67efb4a4 Merge branch 'master' into release 2018-09-24 12:08:21 +01:00
Dan Brown
3a39b9f440 Merge pull request #1022 from BookStackApp/revert-983-master
Revert "Update german translation"
2018-09-22 18:33:29 +01:00
Dan Brown
27f7aab375 Revert "Update german translation" 2018-09-22 18:33:15 +01:00
Dan Brown
337da0c467 Merge pull request #983 from vriic/master
Update german translation
2018-09-22 18:27:04 +01:00
Nikolai Nikolajevic
f56b3560c4 Update german translation 2018-08-23 16:17:46 +02:00
Dan Brown
02dfe11ce6 Increment version for release v0.23.2 2018-08-19 15:33:23 +01:00
Dan Brown
83d06beb70 Merge branch 'master' into release 2018-08-19 15:33:10 +01:00
Dan Brown
a8cfc059c8 Updated version for release v0.23.1 2018-08-12 14:22:53 +01:00
Dan Brown
1614b2bab0 Merge branch 'master' into release 2018-08-12 14:22:17 +01:00
Dan Brown
4bdec0d214 Updated version and assets for release v0.23 2018-07-29 20:28:49 +01:00
Dan Brown
6a7d7e7c2b Merge branch 'master' into release 2018-07-29 20:26:00 +01:00
Dan Brown
30d4674657 Updated assets for release v0.22 2018-05-28 14:19:14 +01:00
Dan Brown
9f961f95f8 Merge branch 'master' into release 2018-05-28 14:19:04 +01:00
Dan Brown
bab99a26ec Updated assets and version for v0.21 release 2018-04-22 20:21:22 +01:00
Dan Brown
9a7fecd269 Merge branch 'master' into release 2018-04-22 20:19:02 +01:00
Dan Brown
a8dc0d449b Updated the version because i'm such a plonker
And forgot to do this last release.
I wonder if there's a simple commit hook that could prevent the same two
versions twice in a row?
2018-03-30 15:41:46 +01:00
Dan Brown
a0381f76bf Merge branch 'v0.20' into release 2018-03-30 15:33:23 +01:00
Dan Brown
6102f66daa Updated assets for release v0.20.1 2018-03-25 16:58:14 +01:00
Dan Brown
c6134d162d Merge branch 'master' into release 2018-03-25 16:54:48 +01:00
Dan Brown
2046f9b9de Updated assets for release v0.20.0 2018-02-11 18:20:17 +00:00
Dan Brown
ac3ba594a4 Merge branch 'master' into release and updated version 2018-02-11 18:19:38 +00:00
Dan Brown
22df25a480 Updated assets and version for v0.19.0 2017-12-10 18:21:07 +00:00
Dan Brown
8b30c7f02e Merge branch 'master' into release 2017-12-10 18:19:20 +00:00
Dan Brown
757cdddc7c Updated version and JS for release v0.18.5 2017-11-11 18:33:04 +00:00
Dan Brown
df95e99680 Updated assets and version for release v0.18.4 2017-10-15 19:28:29 +01:00
Dan Brown
5a6d544db7 Merge branch 'master' into release 2017-10-15 19:27:50 +01:00
Dan Brown
16117d329c Merge branch 'master' into release, Updated version 2017-10-06 21:05:45 +01:00
Dan Brown
e90da18ada Updated assets and version for v0.18.2 release 2017-10-01 18:12:59 +01:00
Dan Brown
a08d80e1cc Merge branch 'master' into release 2017-10-01 18:12:07 +01:00
Dan Brown
6258175922 Updated assets and version for v0.18.1 release 2017-09-20 21:36:17 +01:00
Dan Brown
15736777a0 Merge branch 'master' into release 2017-09-20 21:35:33 +01:00
Dan Brown
75915e8a94 Updated assets for release v0.18 2017-09-10 17:07:57 +01:00
Dan Brown
9bde0ae4ea Merge branch 'master' into release 2017-09-10 17:05:05 +01:00
Dan Brown
0c802d1f86 Updated assets and version for release v0.17.4 2017-07-28 13:04:21 +01:00
Dan Brown
b7a96c6466 Merge branch 'master' into release 2017-07-28 13:03:36 +01:00
Dan Brown
4b645a82c7 Updated version for release 2017-07-22 17:27:01 +01:00
Dan Brown
d599b77b6f Merge branch 'master' into release 2017-07-22 17:26:44 +01:00
Dan Brown
26e93dc8c1 Updated assets and version for release v0.17.2 2017-07-22 16:49:07 +01:00
Dan Brown
a4c9a8491b Merge branch 'master' into release 2017-07-22 16:46:57 +01:00
Dan Brown
70ee636d87 Updated css and version for release 2017-07-10 20:52:32 +01:00
Dan Brown
b35f6dbb03 Merge branch 'master' into release 2017-07-10 20:51:25 +01:00
Dan Brown
67d9e24d8f Merge branch 'master' into release
Also updated assets, Version number
2017-07-02 22:52:26 +01:00
Dan Brown
3903fda6ca Incremented version 2017-06-04 15:38:49 +01:00
Dan Brown
441e46ebaa Merge branch 'v0.16' into release 2017-06-04 15:38:29 +01:00
Dan Brown
1f4260f359 Updated version for release v0.16.2 2017-05-07 19:35:51 +01:00
Dan Brown
dc0bf8ad4e Merge branch 'master' into release 2017-05-07 19:35:34 +01:00
Dan Brown
102e326e6a Updated JS and version for release v0.16.1 2017-04-30 19:51:23 +01:00
Dan Brown
2b25bf6f3b Merge branch 'master' into release 2017-04-30 19:50:29 +01:00
Dan Brown
f93280696d Updated assets for release v0.16 2017-04-23 20:42:28 +01:00
Dan Brown
1787391b07 Merge branch 'master' into release 2017-04-23 20:41:45 +01:00
Dan Brown
a74a8ee483 Updated version for v0.15.3 2017-03-23 22:22:16 +00:00
Dan Brown
7fa5405cb7 Merge branch 'master' into release 2017-03-23 22:21:04 +00:00
Dan Brown
6725ddcc41 Updated version for release v0.15.2 2017-03-05 15:50:52 +00:00
Dan Brown
bce941db3f Merge branch 'master' into release 2017-03-05 15:49:47 +00:00
Dan Brown
6d926048ec Updated to version v0.15.1 2017-02-27 16:59:10 +00:00
Dan Brown
5335c973b4 Merge branch 'master' into release 2017-02-27 16:58:20 +00:00
Dan Brown
15c3e5c96e Updated assets for release v0.15 2017-02-27 14:58:02 +00:00
Dan Brown
a5d5904969 Merge branch 'master' into release 2017-02-27 14:57:38 +00:00
Dan Brown
598758b991 Updated version for v0.14.3 2017-02-05 21:23:27 +00:00
Dan Brown
9926e23bc8 Merge branch 'v0.14' into release 2017-02-05 21:21:54 +00:00
Dan Brown
5d3264bc63 Updated assets for release v0.14.2 2017-02-01 22:27:04 +00:00
Dan Brown
d71f819f95 Merge branch 'v0.14' into release 2017-02-01 22:22:38 +00:00
Dan Brown
ee13509760 Updated version number 2017-01-23 22:28:31 +00:00
Dan Brown
82d7bb1f32 Merge branch 'master' into release 2017-01-23 22:28:02 +00:00
Dan Brown
cdfda508d8 Updated assets for release v0.14 2017-01-22 12:36:10 +00:00
Dan Brown
da941e584f Merge branch 'master' into release ready for v0.14 2017-01-22 12:31:27 +00:00
Dan Brown
65874d7b96 Updated assets for release v0.13.1 2016-11-27 19:42:33 +00:00
Dan Brown
ac9b8f405c Merge fixes from master for release v0.13.1 2016-11-27 19:41:12 +00:00
Dan Brown
8d1419a12e Update assets and version for release v0.13 2016-11-13 12:29:52 +00:00
Dan Brown
04f7a7d301 Merge branch 'master' into release 2016-11-13 12:26:56 +00:00
Dan Brown
c10d2a1493 Updated assets for release v0.12.2 2016-10-30 13:19:19 +00:00
Dan Brown
97bbf79ffd Merge branch 'v0.12' into release 2016-10-30 13:18:23 +00:00
Dan Brown
f7b01ae53d Updated assets for release v0.12.1 2016-09-06 20:50:15 +01:00
Dan Brown
d704e1dbba Merge branch 'master' into release 2016-09-06 20:49:15 +01:00
Dan Brown
ef2ff5e093 Updated assets for release v0.12 2016-09-05 19:49:42 +01:00
Dan Brown
7caed3b0db Merge branch 'master' into release 2016-09-05 19:35:21 +01:00
Dan Brown
45641d0754 Updated assets for release v0.11.2 2016-08-21 14:56:29 +01:00
Dan Brown
4b1d08ba99 Merge branch 'v0.11' into release 2016-08-21 14:55:11 +01:00
Dan Brown
160fa99ba4 Updated assets for release v0.11.1 2016-08-14 12:40:55 +01:00
Dan Brown
d2a5ab49ed Merge branch 'v0.11' into release 2016-08-14 12:37:48 +01:00
Dan Brown
c6404d8917 Updated assets for release v0.11 2016-07-03 10:56:16 +01:00
Dan Brown
7113807f12 Merge branch 'master' into release 2016-07-03 10:52:04 +01:00
Dan Brown
be711215e8 Updated assets for release v0.10 2016-05-22 15:12:47 +01:00
Dan Brown
7e3b404240 Merge branch 'master' into release for version v0.10 2016-05-22 15:11:50 +01:00
Dan Brown
e86901ca20 Updated assets for release v0.9.3 2016-05-03 21:13:02 +01:00
Dan Brown
bdfa61c8b2 Merge branch 'v0.9' into release 2016-05-03 21:11:01 +01:00
Dan Brown
2cc36787f5 Updated assets for release 0.9.2 2016-04-15 19:57:02 +01:00
Dan Brown
448ac61b48 Merge branch 'master' into release 2016-04-15 19:52:59 +01:00
Dan Brown
753f6394f7 Merge branch 'master' into release 2016-04-12 20:09:14 +01:00
Dan Brown
b1faf65934 Updated assets for release 0.9.0 2016-04-09 15:49:02 +01:00
Dan Brown
09f478bd74 Merge branch 'master' into release 2016-04-09 15:47:14 +01:00
Dan Brown
a0497feddd Updated assets for release 0.8.2 2016-03-30 21:44:30 +01:00
Dan Brown
789693bde9 Merge branch 'v0.8' into release 2016-03-30 21:32:46 +01:00
Dan Brown
1fe933e4ea Merge branch 'master' into release 2016-03-13 15:38:06 +00:00
Dan Brown
724b4b5a70 Updated assets for release 0.8.0 2016-03-13 15:15:14 +00:00
Dan Brown
1778a56146 Merge branch 'master' into release 2016-03-13 15:13:23 +00:00
Dan Brown
744865fcb2 Updated assets for release 0.7.6 2016-03-06 13:28:44 +00:00
Dan Brown
7f8c8b448d Merged branch master into release 2016-03-06 13:26:29 +00:00
Dan Brown
a67c53826d Updated assets for release 0.7.5 2016-02-25 21:24:09 +00:00
Dan Brown
14b131e850 Merge branch 'master' into release 2016-02-25 21:23:06 +00:00
Dan Brown
9b55a52b85 Updated assets for release 0.7.4 2016-02-11 22:35:01 +00:00
Dan Brown
db1d10e80f Merge branch 'master' into release 2016-02-11 22:29:29 +00:00
Dan Brown
1be576966f Updated assets for release 0.7.3 2016-02-08 20:47:33 +00:00
Dan Brown
b97e792c5f Merge branch 'master' into release 2016-02-08 20:45:48 +00:00
Dan Brown
8dec674cc3 Merge branch 'master' into release 2016-02-02 07:35:20 +00:00
Dan Brown
f784c03746 Merge branch 'master' into release 2016-02-01 18:31:04 +00:00
Dan Brown
148e172fe8 Updated assets for release 0.7 2016-01-31 18:03:55 +00:00
Dan Brown
56ae86646f Merge branch 'master' into release 2016-01-31 18:01:25 +00:00
Dan Brown
1d2b6fdfa2 Add updated assets 2016-01-02 14:50:59 +00:00
Dan Brown
4fc75beed4 Merge branch 'master' into release 2016-01-02 14:49:05 +00:00
Dan Brown
3b3bc0c4bf Updated compiled assets 2015-12-31 17:26:22 +00:00
Dan Brown
910faab88e Merge branch 'master' into release 2015-12-31 17:22:03 +00:00
Dan Brown
f184d763ad Added build folder to release 2015-12-16 17:53:53 +00:00
Dan Brown
a91d42634d Merge branch 'master' into release 2015-12-16 17:29:34 +00:00
Dan Brown
f517ef3616 Added new asset structure 2015-12-16 17:27:53 +00:00
Dan Brown
e99507ddcf Merge branch 'master' into release 2015-12-16 17:21:21 +00:00
Dan Brown
d2cacf1945 Release update 2015-12-01 21:30:21 +00:00
Dan Brown
448ac1405b Merge branch 'master' into release 2015-12-01 21:15:08 +00:00
Dan Brown
6ad21ce885 Added built assets for release 2015-11-30 21:59:34 +00:00
790 changed files with 37529 additions and 13036 deletions

View File

@@ -17,9 +17,13 @@ DB_USERNAME=database_username
DB_PASSWORD=database_user_password
# Mail system to use
# Can be 'smtp', 'mail' or 'sendmail'
# Can be 'smtp' or 'sendmail'
MAIL_DRIVER=smtp
# Mail sender options
MAIL_FROM_NAME=BookStack
MAIL_FROM=bookstack@example.com
# SMTP mail options
MAIL_HOST=localhost
MAIL_PORT=1025

View File

@@ -37,6 +37,11 @@ APP_AUTO_LANG_PUBLIC=true
# Valid timezone values can be found here: https://www.php.net/manual/en/timezones.php
APP_TIMEZONE=UTC
# Application theme
# Used to specific a themes/<APP_THEME> folder where BookStack UI
# overrides can be made. Defaults to disabled.
APP_THEME=false
# Database details
# Host can contain a port (localhost:3306) or a separate DB_PORT option can be used.
DB_HOST=localhost
@@ -89,7 +94,7 @@ REDIS_SERVERS=127.0.0.1:6379:0
# Queue driver to use
# Queue not really currently used but may be configurable in the future.
# Would advise not to change this for now.
QUEUE_DRIVER=sync
QUEUE_CONNECTION=sync
# Storage system to use
# Can be 'local', 'local_secure' or 's3'
@@ -121,7 +126,7 @@ STORAGE_S3_ENDPOINT=https://my-custom-s3-compatible.service.com:8001
STORAGE_URL=false
# Authentication method to use
# Can be 'standard' or 'ldap'
# Can be 'standard', 'ldap' or 'saml2'
AUTH_METHOD=standard
# Social authentication configuration
@@ -191,9 +196,11 @@ LDAP_PASS=false
LDAP_USER_FILTER=false
LDAP_VERSION=false
LDAP_TLS_INSECURE=false
LDAP_ID_ATTRIBUTE=uid
LDAP_EMAIL_ATTRIBUTE=mail
LDAP_DISPLAY_NAME_ATTRIBUTE=cn
LDAP_FOLLOW_REFERRALS=true
LDAP_DUMP_USER_DETAILS=false
# LDAP group sync configuration
# Refer to https://www.bookstackapp.com/docs/admin/ldap-auth/
@@ -201,6 +208,26 @@ LDAP_USER_TO_GROUPS=false
LDAP_GROUP_ATTRIBUTE="memberOf"
LDAP_REMOVE_FROM_GROUPS=false
# SAML authentication configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
SAML2_NAME=SSO
SAML2_EMAIL_ATTRIBUTE=email
SAML2_DISPLAY_NAME_ATTRIBUTES=username
SAML2_EXTERNAL_ID_ATTRIBUTE=null
SAML2_IDP_ENTITYID=null
SAML2_IDP_SSO=null
SAML2_IDP_SLO=null
SAML2_IDP_x509=null
SAML2_ONELOGIN_OVERRIDES=null
SAML2_DUMP_USER_DETAILS=false
SAML2_AUTOLOAD_METADATA=false
# SAML group sync configuration
# Refer to https://www.bookstackapp.com/docs/admin/saml2-auth/
SAML2_USER_TO_GROUPS=false
SAML2_GROUP_ATTRIBUTE=group
SAML2_REMOVE_FROM_GROUPS=false
# Disable default third-party services such as Gravatar and Draw.IO
# Service-specific options will override this option
DISABLE_EXTERNAL_SERVICES=false
@@ -211,7 +238,10 @@ DISABLE_EXTERNAL_SERVICES=false
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
AVATAR_URL=
# Enable Draw.io integration
# Enable draw.io integration
# Can simply be true/false to enable/disable the integration.
# Alternatively, It can be URL to the draw.io instance you want to use.
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
DRAWIO=true
# Default item listing view
@@ -235,3 +265,9 @@ ALLOW_CONTENT_SCRIPTS=false
# Contents of the robots.txt file can be overridden, making this option obsolete.
ALLOW_ROBOTS=null
# The default and maximum item-counts for listing API requests.
API_DEFAULT_ITEM_COUNT=100
API_MAX_ITEM_COUNT=500
# The number of API requests that can be made per minute by a single user.
API_REQUESTS_PER_MIN=180

View File

@@ -1,5 +1,5 @@
---
name: Bug report
name: Bug Report
about: Create a report to help us improve
---

View File

@@ -1,5 +1,5 @@
---
name: Feature request
name: Feature Request
about: Suggest an idea for this project
---

View File

@@ -0,0 +1,13 @@
---
name: Language Request
about: Request a new language to be added to Crowdin for you to translate
---
### Language To Add
_Specify here the language you want to add._
----
_This issue template is to request a new language be added to our [Crowdin translation management project](https://crowdin.com/project/bookstack). Please don't use this template to request a new language that you are not prepared to provide translations for._

100
.github/translators.txt vendored Normal file
View File

@@ -0,0 +1,100 @@
Name :: Languages
@robertlandes :: German
@SergioMendolia :: French
@NakaharaL :: Portuguese, Brazilian
@ReeseSebastian :: German
@arietimmerman :: Dutch
@diegoseso :: Spanish
@S64 :: Japanese
@JachuPL :: Polish
@Joorem :: French
@timoschwarzer :: German
@sanderdw :: Dutch
@lbguilherme :: Portuguese, Brazilian
@marcusforsberg :: Swedish
@artur-trzesiok :: Polish
@Alwaysin :: French
@msaus :: Japanese
@moucho :: Spanish
@vriic :: German
@DeehSlash :: Portuguese, Brazilian
@alex2702 :: German
@nicobubulle :: French
@kmoj86 :: Arabic
@houbaron :: Chinese Traditional; Chinese Simplified
@mullinsmikey :: Russian
@limkukhyun :: Korean
@CliffyPrime :: German
@kejjang :: Chinese Traditional
@TheLastOperator :: French
@qianmengnet :: Simplified Chinese
@ezzra :: German; German Informal
@vasiliev123 :: Polish
@Mant1kor :: Ukrainian
@Xiphoseer :: German; German Informal
@maantje :: Dutch
@cima :: Czech
@agvol :: Russian
@Hambern :: Swedish
@NootoNooto :: Dutch
@kostefun :: Russian
@lucaguindani :: French
@miles75 :: Hungarian
@danielroehrig-mm :: German
@oykenfurkan :: Turkish
@qligier :: French
@johnroyer :: Traditional Chinese
@artskoczylas :: Polish
@dellamina :: Italian
@jzoy :: Simplified Chinese
@ististudio :: Korean
@leomartinez :: Spanish Argentina
cipi1965 :: Italian
Mykola Ronik (Mantikor) :: Ukrainian
furkanoyk :: Turkish
m0uch0 :: Spanish
Maxim Zalata (zlatin) :: Russian; Ukrainian
nutsflag :: French
Leonardo Mario Martinez (leonardo.m.martinez) :: Spanish, Argentina
Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
叫钦叔就好 (254351722) :: Chinese Traditional; Chinese Simplified
aekramer :: Dutch
JachuPL :: Polish
milesteg :: Hungarian
Beenbag :: German
Lett3rs :: Danish
Julian (julian.henneberg) :: German; German Informal
3GNWn :: Danish
dbguichu :: Chinese Simplified
Randy Kim (hyunjun) :: Korean
Francesco M. Taurino (ftaurino) :: Italian
DanielFrederiksen :: Danish
Finn Wessel (19finnwessel6) :: German Informal; German
Gustav Kånåhols (Kurbitz) :: Swedish
Vuong Trung Hieu (fpooon) :: Vietnamese
Emil Petersen (emoyly) :: Danish
mrjaboozy :: Slovenian
Statium :: Russian
Mikkel Struntze (MStruntze) :: Danish
kostefun :: Russian
Tuyen.NG (tuyendev) :: Vietnamese
Ghost_chu (dbguichu) :: Chinese Simplified
Ziipen :: Danish
Samuel Schwarz (Guiph7quan) :: Czech
Aleph (toishoki) :: Turkish
Julio Alberto García (Yllelder) :: Spanish
Rafael (raribeir) :: Portuguese, Brazilian
Hiroyuki Odake (dakesan) :: Japanese
Alex Lee (qianmengnet) :: Chinese Simplified
swinn37 :: French
Hasan Özbey (the-turk) :: Turkish
rcy :: Swedish
Ali Yasir Yılmaz (ayyilmaz) :: Turkish
scureza :: Italian
Biepa :: German Informal; German
syecu :: Chinese Simplified
Lap1t0r :: French
Thinkverse (thinkverse) :: Swedish
alef (toishoki) :: Turkish
Robbert Feunekes (Muukuro) :: Dutch
seohyeon.joo :: Korean

54
.github/workflows/phpunit.yml vendored Normal file
View File

@@ -0,0 +1,54 @@
name: phpunit
on:
push:
branches:
- master
- release
pull_request:
branches:
- '*'
- '*/*'
- '!l10n_master'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
php: [7.2, 7.4]
steps:
- uses: actions/checkout@v1
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
- name: Start Database
run: |
sudo /etc/init.d/mysql start
- name: Setup Database
run: |
mysql -uroot -proot -e 'CREATE DATABASE IF NOT EXISTS `bookstack-test`;'
mysql -uroot -proot -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';"
mysql -uroot -proot -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
mysql -uroot -proot -e 'FLUSH PRIVILEGES;'
- name: Install composer dependencies & Test
run: composer install --prefer-dist --no-interaction --ansi
- name: Migrate and seed the database
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
php${{ matrix.php }} artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
- name: phpunit
run: php${{ matrix.php }} ./vendor/bin/phpunit

10
.gitignore vendored
View File

@@ -5,10 +5,10 @@ Homestead.yaml
.idea
npm-debug.log
yarn-error.log
/public/dist
/public/dist/*.map
/public/plugins
/public/css
/public/js
/public/css/*.map
/public/js/*.map
/public/bower
/public/build/
/storage/images
@@ -21,4 +21,6 @@ nbproject
.buildpath
.project
.settings/
webpack-stats.json
webpack-stats.json
.phpunit.result.cache
.DS_Store

View File

@@ -1,28 +0,0 @@
dist: trusty
sudo: false
language: php
php:
- 7.0.20
- 7.1.9
cache:
directories:
- $HOME/.composer/cache
before_script:
- mysql -u root -e 'create database `bookstack-test`;'
- mysql -u root -e "CREATE USER 'bookstack-test'@'localhost' IDENTIFIED BY 'bookstack-test';"
- mysql -u root -e "GRANT ALL ON \`bookstack-test\`.* TO 'bookstack-test'@'localhost';"
- mysql -u root -e "FLUSH PRIVILEGES;"
- phpenv config-rm xdebug.ini
- composer install --prefer-dist --no-interaction
- php artisan clear-compiled -n
- php artisan optimize -n
- php artisan migrate --force -n --database=mysql_testing
- php artisan db:seed --force -n --class=DummyContentSeeder --database=mysql_testing
after_failure:
- cat storage/logs/laravel.log
script:
- phpunit

View File

@@ -1,6 +1,6 @@
The MIT License (MIT)
Copyright (c) 2018 Dan Brown and the BookStack Project contributors
Copyright (c) 2020 Dan Brown and the BookStack Project contributors
https://github.com/BookStackApp/BookStack/graphs/contributors
Permission is hereby granted, free of charge, to any person obtaining a copy

View File

@@ -3,13 +3,18 @@
namespace BookStack\Actions;
use BookStack\Auth\User;
use BookStack\Entities\Entity;
use BookStack\Model;
/**
* @property string key
* @property \User user
* @property \Entity entity
* @property string extra
* @property string $key
* @property User $user
* @property Entity $entity
* @property string $extra
* @property string $entity_type
* @property int $entity_id
* @property int $user_id
* @property int $book_id
*/
class Activity extends Model
{
@@ -45,10 +50,8 @@ class Activity extends Model
/**
* Checks if another Activity matches the general information of another.
* @param $activityB
* @return bool
*/
public function isSimilarTo($activityB)
public function isSimilarTo(Activity $activityB): bool
{
return [$this->key, $this->entity_type, $this->entity_id] === [$activityB->key, $activityB->entity_type, $activityB->entity_id];
}

View File

@@ -1,8 +1,9 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Entity;
use Session;
use Illuminate\Support\Collection;
class ActivityService
{
@@ -12,8 +13,6 @@ class ActivityService
/**
* ActivityService constructor.
* @param \BookStack\Actions\Activity $activity
* @param PermissionService $permissionService
*/
public function __construct(Activity $activity, PermissionService $permissionService)
{
@@ -24,73 +23,66 @@ class ActivityService
/**
* Add activity data to database.
* @param Entity $entity
* @param $activityKey
* @param int $bookId
* @param bool $extra
*/
public function add(Entity $entity, $activityKey, $bookId = 0, $extra = false)
public function add(Entity $entity, string $activityKey, ?int $bookId = null)
{
$activity = $this->activity->newInstance();
$activity->user_id = $this->user->id;
$activity->book_id = $bookId;
$activity->key = strtolower($activityKey);
if ($extra !== false) {
$activity->extra = $extra;
}
$activity = $this->newActivityForUser($activityKey, $bookId);
$entity->activity()->save($activity);
$this->setNotification($activityKey);
}
/**
* Adds a activity history with a message & without binding to a entity.
* @param $activityKey
* @param int $bookId
* @param bool|false $extra
* Adds a activity history with a message, without binding to a entity.
*/
public function addMessage($activityKey, $bookId = 0, $extra = false)
public function addMessage(string $activityKey, string $message, ?int $bookId = null)
{
$this->activity->user_id = $this->user->id;
$this->activity->book_id = $bookId;
$this->activity->key = strtolower($activityKey);
if ($extra !== false) {
$this->activity->extra = $extra;
}
$this->activity->save();
$this->newActivityForUser($activityKey, $bookId)->forceFill([
'extra' => $message
])->save();
$this->setNotification($activityKey);
}
/**
* Get a new activity instance for the current user.
*/
protected function newActivityForUser(string $key, ?int $bookId = null): Activity
{
return $this->activity->newInstance()->forceFill([
'key' => strtolower($key),
'user_id' => $this->user->id,
'book_id' => $bookId ?? 0,
]);
}
/**
* Removes the entity attachment from each of its activities
* and instead uses the 'extra' field with the entities name.
* Used when an entity is deleted.
* @param Entity $entity
* @return mixed
*/
public function removeEntity(Entity $entity)
public function removeEntity(Entity $entity): Collection
{
$activities = $entity->activity;
foreach ($activities as $activity) {
$activity->extra = $entity->name;
$activity->entity_id = 0;
$activity->entity_type = null;
$activity->save();
}
$activities = $entity->activity()->get();
$entity->activity()->update([
'extra' => $entity->name,
'entity_id' => 0,
'entity_type' => '',
]);
return $activities;
}
/**
* Gets the latest activity.
* @param int $count
* @param int $page
* @return array
*/
public function latest($count = 20, $page = 0)
public function latest(int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->with('user', 'entity')->skip($count * $page)->take($count)->get();
->orderBy('created_at', 'desc')
->with(['user', 'entity'])
->skip($count * $page)
->take($count)
->get();
return $this->filterSimilar($activityList);
}
@@ -98,17 +90,13 @@ class ActivityService
/**
* Gets the latest activity for an entity, Filtering out similar
* items to prevent a message activity list.
* @param Entity $entity
* @param int $count
* @param int $page
* @return array
*/
public function entityActivity($entity, $count = 20, $page = 1)
public function entityActivity(Entity $entity, int $count = 20, int $page = 1): array
{
if ($entity->isA('book')) {
$query = $this->activity->where('book_id', '=', $entity->id);
$query = $this->activity->newQuery()->where('book_id', '=', $entity->id);
} else {
$query = $this->activity->where('entity_type', '=', $entity->getMorphClass())
$query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
->where('entity_id', '=', $entity->id);
}
@@ -124,18 +112,18 @@ class ActivityService
}
/**
* Get latest activity for a user, Filtering out similar
* items.
* @param $user
* @param int $count
* @param int $page
* @return array
* Get latest activity for a user, Filtering out similar items.
*/
public function userActivity($user, $count = 20, $page = 0)
public function userActivity(User $user, int $count = 20, int $page = 0): array
{
$activityList = $this->permissionService
->filterRestrictedEntityRelations($this->activity, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')->where('user_id', '=', $user->id)->skip($count * $page)->take($count)->get();
->orderBy('created_at', 'desc')
->where('user_id', '=', $user->id)
->skip($count * $page)
->take($count)
->get();
return $this->filterSimilar($activityList);
}
@@ -144,34 +132,31 @@ class ActivityService
* @param Activity[] $activities
* @return array
*/
protected function filterSimilar($activities)
protected function filterSimilar(iterable $activities): array
{
$newActivity = [];
$previousItem = false;
$previousItem = null;
foreach ($activities as $activityItem) {
if ($previousItem === false) {
$previousItem = $activityItem;
$newActivity[] = $activityItem;
continue;
}
if (!$activityItem->isSimilarTo($previousItem)) {
if (!$previousItem || !$activityItem->isSimilarTo($previousItem)) {
$newActivity[] = $activityItem;
}
$previousItem = $activityItem;
}
return $newActivity;
}
/**
* Flashes a notification message to the session if an appropriate message is available.
* @param $activityKey
*/
protected function setNotification($activityKey)
protected function setNotification(string $activityKey)
{
$notificationTextKey = 'activities.' . $activityKey . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
Session::flash('success', $message);
session()->flash('success', $message);
}
}
}

View File

@@ -2,9 +2,15 @@
use BookStack\Ownable;
/**
* @property string text
* @property string html
* @property int|null parent_id
* @property int local_id
*/
class Comment extends Ownable
{
protected $fillable = ['text', 'html', 'parent_id'];
protected $fillable = ['text', 'parent_id'];
protected $appends = ['created', 'updated'];
/**

View File

@@ -1,23 +1,20 @@
<?php namespace BookStack\Actions;
use BookStack\Entities\Entity;
use League\CommonMark\CommonMarkConverter;
/**
* Class CommentRepo
* @package BookStack\Repos
*/
class CommentRepo
{
/**
* @var \BookStack\Actions\Comment $comment
* @var Comment $comment
*/
protected $comment;
/**
* CommentRepo constructor.
* @param \BookStack\Actions\Comment $comment
*/
public function __construct(Comment $comment)
{
$this->comment = $comment;
@@ -25,65 +22,71 @@ class CommentRepo
/**
* Get a comment by ID.
* @param $id
* @return \BookStack\Actions\Comment|\Illuminate\Database\Eloquent\Model
*/
public function getById($id)
public function getById(int $id): Comment
{
return $this->comment->newQuery()->findOrFail($id);
}
/**
* Create a new comment on an entity.
* @param \BookStack\Entities\Entity $entity
* @param array $data
* @return \BookStack\Actions\Comment
*/
public function create(Entity $entity, $data = [])
public function create(Entity $entity, string $text, ?int $parent_id): Comment
{
$userId = user()->id;
$comment = $this->comment->newInstance($data);
$comment = $this->comment->newInstance();
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->created_by = $userId;
$comment->updated_by = $userId;
$comment->local_id = $this->getNextLocalId($entity);
$comment->parent_id = $parent_id;
$entity->comments()->save($comment);
return $comment;
}
/**
* Update an existing comment.
* @param \BookStack\Actions\Comment $comment
* @param array $input
* @return mixed
*/
public function update($comment, $input)
public function update(Comment $comment, string $text): Comment
{
$comment->updated_by = user()->id;
$comment->update($input);
$comment->text = $text;
$comment->html = $this->commentToHtml($text);
$comment->save();
return $comment;
}
/**
* Delete a comment from the system.
* @param \BookStack\Actions\Comment $comment
* @return mixed
*/
public function delete($comment)
public function delete(Comment $comment)
{
return $comment->delete();
$comment->delete();
}
/**
* Convert the given comment markdown text to HTML.
*/
public function commentToHtml(string $commentText): string
{
$converter = new CommonMarkConverter([
'html_input' => 'strip',
'max_nesting_level' => 10,
'allow_unsafe_links' => false,
]);
return $converter->convertToHtml($commentText);
}
/**
* Get the next local ID relative to the linked entity.
* @param \BookStack\Entities\Entity $entity
* @return int
*/
protected function getNextLocalId(Entity $entity)
protected function getNextLocalId(Entity $entity): int
{
$comments = $entity->comments(false)->orderBy('local_id', 'desc')->first();
if ($comments === null) {
return 1;
}
return $comments->local_id + 1;
return ($comments->local_id ?? 0) + 1;
}
}

View File

@@ -1,8 +1,10 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Book;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use DB;
use Illuminate\Support\Collection;
class ViewService
@@ -13,8 +15,8 @@ class ViewService
/**
* ViewService constructor.
* @param \BookStack\Actions\View $view
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
* @param View $view
* @param PermissionService $permissionService
* @param EntityProvider $entityProvider
*/
public function __construct(View $view, PermissionService $permissionService, EntityProvider $entityProvider)
@@ -26,7 +28,7 @@ class ViewService
/**
* Add a view to the given entity.
* @param Entity $entity
* @param \BookStack\Entities\Entity $entity
* @return int
*/
public function add(Entity $entity)
@@ -43,7 +45,7 @@ class ViewService
}
// Otherwise create new view count
$entity->views()->save($this->view->create([
$entity->views()->save($this->view->newInstance([
'user_id' => $user->id,
'views' => 1
]));
@@ -59,12 +61,12 @@ class ViewService
* @param string $action - used for permission checking
* @return Collection
*/
public function getPopular(int $count = 10, int $page = 0, $filterModels = null, string $action = 'view')
public function getPopular(int $count = 10, int $page = 0, array $filterModels = null, string $action = 'view')
{
$skipCount = $count * $page;
$query = $this->permissionService
->filterRestrictedEntityRelations($this->view, 'views', 'viewable_id', 'viewable_type', $action)
->select('*', 'viewable_id', 'viewable_type', \DB::raw('SUM(views) as view_count'))
->select('*', 'viewable_id', 'viewable_type', DB::raw('SUM(views) as view_count'))
->groupBy('viewable_id', 'viewable_type')
->orderBy('view_count', 'desc');

View File

@@ -0,0 +1,127 @@
<?php namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use ReflectionClass;
use ReflectionException;
use ReflectionMethod;
class ApiDocsGenerator
{
protected $reflectionClasses = [];
protected $controllerClasses = [];
/**
* Generate API documentation.
*/
public function generate(): Collection
{
$apiRoutes = $this->getFlatApiRoutes();
$apiRoutes = $this->loadDetailsFromControllers($apiRoutes);
$apiRoutes = $this->loadDetailsFromFiles($apiRoutes);
$apiRoutes = $apiRoutes->groupBy('base_model');
return $apiRoutes;
}
/**
* Load any API details stored in static files.
*/
protected function loadDetailsFromFiles(Collection $routes): Collection
{
return $routes->map(function (array $route) {
$exampleTypes = ['request', 'response'];
foreach ($exampleTypes as $exampleType) {
$exampleFile = base_path("dev/api/{$exampleType}s/{$route['name']}.json");
$exampleContent = file_exists($exampleFile) ? file_get_contents($exampleFile) : null;
$route["example_{$exampleType}"] = $exampleContent;
}
return $route;
});
}
/**
* Load any details we can fetch from the controller and its methods.
*/
protected function loadDetailsFromControllers(Collection $routes): Collection
{
return $routes->map(function (array $route) {
$method = $this->getReflectionMethod($route['controller'], $route['controller_method']);
$comment = $method->getDocComment();
$route['description'] = $comment ? $this->parseDescriptionFromMethodComment($comment) : null;
$route['body_params'] = $this->getBodyParamsFromClass($route['controller'], $route['controller_method']);
return $route;
});
}
/**
* Load body params and their rules by inspecting the given class and method name.
* @throws \Illuminate\Contracts\Container\BindingResolutionException
*/
protected function getBodyParamsFromClass(string $className, string $methodName): ?array
{
/** @var ApiController $class */
$class = $this->controllerClasses[$className] ?? null;
if ($class === null) {
$class = app()->make($className);
$this->controllerClasses[$className] = $class;
}
$rules = $class->getValdationRules()[$methodName] ?? [];
foreach ($rules as $param => $ruleString) {
$rules[$param] = explode('|', $ruleString);
}
return count($rules) > 0 ? $rules : null;
}
/**
* Parse out the description text from a class method comment.
*/
protected function parseDescriptionFromMethodComment(string $comment)
{
$matches = [];
preg_match_all('/^\s*?\*\s((?![@\s]).*?)$/m', $comment, $matches);
return implode(' ', $matches[1] ?? []);
}
/**
* Get a reflection method from the given class name and method name.
* @throws ReflectionException
*/
protected function getReflectionMethod(string $className, string $methodName): ReflectionMethod
{
$class = $this->reflectionClasses[$className] ?? null;
if ($class === null) {
$class = new ReflectionClass($className);
$this->reflectionClasses[$className] = $class;
}
return $class->getMethod($methodName);
}
/**
* Get the system API routes, formatted into a flat collection.
*/
protected function getFlatApiRoutes(): Collection
{
return collect(Route::getRoutes()->getRoutes())->filter(function ($route) {
return strpos($route->uri, 'api/') === 0;
})->map(function ($route) {
[$controller, $controllerMethod] = explode('@', $route->action['uses']);
$baseModelName = explode('.', explode('/', $route->uri)[1])[0];
$shortName = $baseModelName . '-' . $controllerMethod;
return [
'name' => $shortName,
'uri' => $route->uri,
'method' => $route->methods[0],
'controller' => $controller,
'controller_method' => $controllerMethod,
'controller_method_kebab' => Str::kebab($controllerMethod),
'base_model' => $baseModelName,
];
});
}
}

31
app/Api/ApiToken.php Normal file
View File

@@ -0,0 +1,31 @@
<?php namespace BookStack\Api;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Support\Carbon;
class ApiToken extends Model
{
protected $fillable = ['name', 'expires_at'];
protected $casts = [
'expires_at' => 'date:Y-m-d'
];
/**
* Get the user that this token belongs to.
*/
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
/**
* Get the default expiry value for an API token.
* Set to 100 years from now.
*/
public static function defaultExpiry(): string
{
return Carbon::now()->addYears(100)->format('Y-m-d');
}
}

166
app/Api/ApiTokenGuard.php Normal file
View File

@@ -0,0 +1,166 @@
<?php
namespace BookStack\Api;
use BookStack\Exceptions\ApiAuthException;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\Guard;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Hash;
use Symfony\Component\HttpFoundation\Request;
class ApiTokenGuard implements Guard
{
use GuardHelpers;
/**
* The request instance.
*/
protected $request;
/**
* The last auth exception thrown in this request.
* @var ApiAuthException
*/
protected $lastAuthException;
/**
* ApiTokenGuard constructor.
*/
public function __construct(Request $request)
{
$this->request = $request;
}
/**
* @inheritDoc
*/
public function user()
{
// Return the user if we've already retrieved them.
// Effectively a request-instance cache for this method.
if (!is_null($this->user)) {
return $this->user;
}
$user = null;
try {
$user = $this->getAuthorisedUserFromRequest();
} catch (ApiAuthException $exception) {
$this->lastAuthException = $exception;
}
$this->user = $user;
return $user;
}
/**
* Determine if current user is authenticated. If not, throw an exception.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*
* @throws ApiAuthException
*/
public function authenticate()
{
if (! is_null($user = $this->user())) {
return $user;
}
if ($this->lastAuthException) {
throw $this->lastAuthException;
}
throw new ApiAuthException('Unauthorized');
}
/**
* Check the API token in the request and fetch a valid authorised user.
* @throws ApiAuthException
*/
protected function getAuthorisedUserFromRequest(): Authenticatable
{
$authToken = trim($this->request->headers->get('Authorization', ''));
$this->validateTokenHeaderValue($authToken);
[$id, $secret] = explode(':', str_replace('Token ', '', $authToken));
$token = ApiToken::query()
->where('token_id', '=', $id)
->with(['user'])->first();
$this->validateToken($token, $secret);
return $token->user;
}
/**
* Validate the format of the token header value string.
* @throws ApiAuthException
*/
protected function validateTokenHeaderValue(string $authToken): void
{
if (empty($authToken)) {
throw new ApiAuthException(trans('errors.api_no_authorization_found'));
}
if (strpos($authToken, ':') === false || strpos($authToken, 'Token ') !== 0) {
throw new ApiAuthException(trans('errors.api_bad_authorization_format'));
}
}
/**
* Validate the given secret against the given token and ensure the token
* currently has access to the instance API.
* @throws ApiAuthException
*/
protected function validateToken(?ApiToken $token, string $secret): void
{
if ($token === null) {
throw new ApiAuthException(trans('errors.api_user_token_not_found'));
}
if (!Hash::check($secret, $token->secret)) {
throw new ApiAuthException(trans('errors.api_incorrect_token_secret'));
}
$now = Carbon::now();
if ($token->expires_at <= $now) {
throw new ApiAuthException(trans('errors.api_user_token_expired'), 403);
}
if (!$token->user->can('access-api')) {
throw new ApiAuthException(trans('errors.api_user_no_api_permission'), 403);
}
}
/**
* @inheritDoc
*/
public function validate(array $credentials = [])
{
if (empty($credentials['id']) || empty($credentials['secret'])) {
return false;
}
$token = ApiToken::query()
->where('token_id', '=', $credentials['id'])
->with(['user'])->first();
if ($token === null) {
return false;
}
return Hash::check($credentials['secret'], $token->secret);
}
/**
* "Log out" the currently authenticated user.
*/
public function logout()
{
$this->user = null;
}
}

View File

@@ -0,0 +1,138 @@
<?php namespace BookStack\Api;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Http\Request;
class ListingResponseBuilder
{
protected $query;
protected $request;
protected $fields;
protected $filterOperators = [
'eq' => '=',
'ne' => '!=',
'gt' => '>',
'lt' => '<',
'gte' => '>=',
'lte' => '<=',
'like' => 'like'
];
/**
* ListingResponseBuilder constructor.
*/
public function __construct(Builder $query, Request $request, array $fields)
{
$this->query = $query;
$this->request = $request;
$this->fields = $fields;
}
/**
* Get the response from this builder.
*/
public function toResponse()
{
$filteredQuery = $this->filterQuery($this->query);
$total = $filteredQuery->count();
$data = $this->fetchData($filteredQuery);
return response()->json([
'data' => $data,
'total' => $total,
]);
}
/**
* Fetch the data to return in the response.
*/
protected function fetchData(Builder $query): Collection
{
$query = $this->countAndOffsetQuery($query);
$query = $this->sortQuery($query);
return $query->get($this->fields);
}
/**
* Apply any filtering operations found in the request.
*/
protected function filterQuery(Builder $query): Builder
{
$query = clone $query;
$requestFilters = $this->request->get('filter', []);
if (!is_array($requestFilters)) {
return $query;
}
$queryFilters = collect($requestFilters)->map(function ($value, $key) {
return $this->requestFilterToQueryFilter($key, $value);
})->filter(function ($value) {
return !is_null($value);
})->values()->toArray();
return $query->where($queryFilters);
}
/**
* Convert a request filter query key/value pair into a [field, op, value] where condition.
*/
protected function requestFilterToQueryFilter($fieldKey, $value): ?array
{
$splitKey = explode(':', $fieldKey);
$field = $splitKey[0];
$filterOperator = $splitKey[1] ?? 'eq';
if (!in_array($field, $this->fields)) {
return null;
}
if (!in_array($filterOperator, array_keys($this->filterOperators))) {
$filterOperator = 'eq';
}
$queryOperator = $this->filterOperators[$filterOperator];
return [$field, $queryOperator, $value];
}
/**
* Apply sorting operations to the query from given parameters
* otherwise falling back to the first given field, ascending.
*/
protected function sortQuery(Builder $query): Builder
{
$query = clone $query;
$defaultSortName = $this->fields[0];
$direction = 'asc';
$sort = $this->request->get('sort', '');
if (strpos($sort, '-') === 0) {
$direction = 'desc';
}
$sortName = ltrim($sort, '+- ');
if (!in_array($sortName, $this->fields)) {
$sortName = $defaultSortName;
}
return $query->orderBy($sortName, $direction);
}
/**
* Apply count and offset for paging, based on params from the request while falling
* back to system defined default, taking the max limit into account.
*/
protected function countAndOffsetQuery(Builder $query): Builder
{
$query = clone $query;
$offset = max(0, $this->request->get('offset', 0));
$maxCount = config('api.max_item_count');
$count = $this->request->get('count', config('api.default_item_count'));
$count = max(min($maxCount, $count), 1);
return $query->skip($offset)->take($count);
}
}

View File

@@ -13,7 +13,11 @@ class Application extends \Illuminate\Foundation\Application
*/
public function configPath($path = '')
{
return $this->basePath.DIRECTORY_SEPARATOR.'app'.DIRECTORY_SEPARATOR.'Config'.($path ? DIRECTORY_SEPARATOR.$path : $path);
return $this->basePath
. DIRECTORY_SEPARATOR
. 'app'
. DIRECTORY_SEPARATOR
. 'Config'
. ($path ? DIRECTORY_SEPARATOR.$path : $path);
}
}
}

View File

@@ -36,5 +36,4 @@ class EmailConfirmationService extends UserTokenService
return setting('registration-confirmation')
|| setting('registration-restrict');
}
}

View File

@@ -0,0 +1,81 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;
class ExternalAuthService
{
/**
* Check a role against an array of group names to see if it matches.
* Checked against role 'external_auth_id' if set otherwise the name of the role.
*/
protected function roleMatchesGroupNames(Role $role, array $groupNames): bool
{
if ($role->external_auth_id) {
return $this->externalIdMatchesGroupNames($role->external_auth_id, $groupNames);
}
$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
return in_array($roleName, $groupNames);
}
/**
* Check if the given external auth ID string matches one of the given group names.
*/
protected function externalIdMatchesGroupNames(string $externalId, array $groupNames): bool
{
$externalAuthIds = explode(',', strtolower($externalId));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true;
}
}
return false;
}
/**
* Match an array of group names to BookStack system roles.
* Formats group names to be lower-case and hyphenated.
* @param array $groupNames
* @return \Illuminate\Support\Collection
*/
protected function matchGroupsToSystemsRoles(array $groupNames)
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}
$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
$query->whereIn('name', $groupNames);
foreach ($groupNames as $groupName) {
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
}
})->get();
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
return $this->roleMatchesGroupNames($role, $groupNames);
});
return $matchedRoles->pluck('id');
}
/**
* Sync the groups to the user roles for the current user
*/
public function syncWithGroups(User $user, array $userGroups): void
{
// Get the ids for the roles from the names
$groupsAsRoles = $this->matchGroupsToSystemsRoles($userGroups);
// Sync groups
if ($this->config['remove_from_groups']) {
$user->roles()->sync($groupsAsRoles);
$user->attachDefaultRole();
} else {
$user->roles()->syncWithoutDetaching($groupsAsRoles);
}
}
}

View File

@@ -1,12 +1,11 @@
<?php
namespace BookStack\Providers;
namespace BookStack\Auth\Access;
use BookStack\Auth\Access\LdapService;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Contracts\Auth\UserProvider;
class LdapUserProvider implements UserProvider
class ExternalBaseUserProvider implements UserProvider
{
/**
@@ -16,21 +15,13 @@ class LdapUserProvider implements UserProvider
*/
protected $model;
/**
* @var \BookStack\Auth\LdapService
*/
protected $ldapService;
/**
* LdapUserProvider constructor.
* @param $model
* @param \BookStack\Auth\LdapService $ldapService
*/
public function __construct($model, LdapService $ldapService)
public function __construct(string $model)
{
$this->model = $model;
$this->ldapService = $ldapService;
}
/**
@@ -44,7 +35,6 @@ class LdapUserProvider implements UserProvider
return new $class;
}
/**
* Retrieve a user by their unique identifier.
*
@@ -65,12 +55,7 @@ class LdapUserProvider implements UserProvider
*/
public function retrieveByToken($identifier, $token)
{
$model = $this->createModel();
return $model->newQuery()
->where($model->getAuthIdentifierName(), $identifier)
->where($model->getRememberTokenName(), $token)
->first();
return null;
}
@@ -83,10 +68,7 @@ class LdapUserProvider implements UserProvider
*/
public function updateRememberToken(Authenticatable $user, $token)
{
if ($user->exists) {
$user->setRememberToken($token);
$user->save();
}
//
}
/**
@@ -97,27 +79,11 @@ class LdapUserProvider implements UserProvider
*/
public function retrieveByCredentials(array $credentials)
{
// Get user via LDAP
$userDetails = $this->ldapService->getUserDetails($credentials['username']);
if ($userDetails === null) {
return null;
}
// Search current user base by looking up a uid
$model = $this->createModel();
$currentUser = $model->newQuery()
->where('external_auth_id', $userDetails['uid'])
return $model->newQuery()
->where('external_auth_id', $credentials['external_auth_id'])
->first();
if ($currentUser !== null) {
return $currentUser;
}
$model->name = $userDetails['name'];
$model->external_auth_id = $userDetails['uid'];
$model->email = $userDetails['email'];
$model->email_confirmed = false;
return $model;
}
/**
@@ -129,6 +95,7 @@ class LdapUserProvider implements UserProvider
*/
public function validateCredentials(Authenticatable $user, array $credentials)
{
return $this->ldapService->validateUserCredentials($user, $credentials['username'], $credentials['password']);
// Should be done in the guard.
return false;
}
}

View File

@@ -0,0 +1,305 @@
<?php
namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\RegistrationService;
use Illuminate\Auth\GuardHelpers;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\StatefulGuard;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
/**
* Class BaseSessionGuard
* A base implementation of a session guard. Is a copy of the default Laravel
* guard with 'remember' functionality removed. Basic auth and event emission
* has also been removed to keep this simple. Designed to be extended by external
* Auth Guards.
*
* @package Illuminate\Auth
*/
class ExternalBaseSessionGuard implements StatefulGuard
{
use GuardHelpers;
/**
* The name of the Guard. Typically "session".
*
* Corresponds to guard name in authentication configuration.
*
* @var string
*/
protected $name;
/**
* The user we last attempted to retrieve.
*
* @var \Illuminate\Contracts\Auth\Authenticatable
*/
protected $lastAttempted;
/**
* The session used by the guard.
*
* @var \Illuminate\Contracts\Session\Session
*/
protected $session;
/**
* Indicates if the logout method has been called.
*
* @var bool
*/
protected $loggedOut = false;
/**
* Service to handle common registration actions.
*
* @var RegistrationService
*/
protected $registrationService;
/**
* Create a new authentication guard.
*
* @return void
*/
public function __construct(string $name, UserProvider $provider, Session $session, RegistrationService $registrationService)
{
$this->name = $name;
$this->session = $session;
$this->provider = $provider;
$this->registrationService = $registrationService;
}
/**
* Get the currently authenticated user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function user()
{
if ($this->loggedOut) {
return;
}
// If we've already retrieved the user for the current request we can just
// return it back immediately. We do not want to fetch the user data on
// every call to this method because that would be tremendously slow.
if (! is_null($this->user)) {
return $this->user;
}
$id = $this->session->get($this->getName());
// First we will try to load the user using the
// identifier in the session if one exists.
if (! is_null($id)) {
$this->user = $this->provider->retrieveById($id);
}
return $this->user;
}
/**
* Get the ID for the currently authenticated user.
*
* @return int|null
*/
public function id()
{
if ($this->loggedOut) {
return;
}
return $this->user()
? $this->user()->getAuthIdentifier()
: $this->session->get($this->getName());
}
/**
* Log a user into the application without sessions or cookies.
*
* @param array $credentials
* @return bool
*/
public function once(array $credentials = [])
{
if ($this->validate($credentials)) {
$this->setUser($this->lastAttempted);
return true;
}
return false;
}
/**
* Log the given user ID into the application without sessions or cookies.
*
* @param mixed $id
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function onceUsingId($id)
{
if (! is_null($user = $this->provider->retrieveById($id))) {
$this->setUser($user);
return $user;
}
return false;
}
/**
* Validate a user's credentials.
*
* @param array $credentials
* @return bool
*/
public function validate(array $credentials = [])
{
return false;
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
{
return false;
}
/**
* Log the given user ID into the application.
*
* @param mixed $id
* @param bool $remember
* @return \Illuminate\Contracts\Auth\Authenticatable|false
*/
public function loginUsingId($id, $remember = false)
{
if (! is_null($user = $this->provider->retrieveById($id))) {
$this->login($user, $remember);
return $user;
}
return false;
}
/**
* Log a user into the application.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @param bool $remember
* @return void
*/
public function login(AuthenticatableContract $user, $remember = false)
{
$this->updateSession($user->getAuthIdentifier());
$this->setUser($user);
}
/**
* Update the session with the given ID.
*
* @param string $id
* @return void
*/
protected function updateSession($id)
{
$this->session->put($this->getName(), $id);
$this->session->migrate(true);
}
/**
* Log the user out of the application.
*
* @return void
*/
public function logout()
{
$this->clearUserDataFromStorage();
// Now we will clear the users out of memory so they are no longer available
// as the user is no longer considered as being signed into this
// application and should not be available here.
$this->user = null;
$this->loggedOut = true;
}
/**
* Remove the user data from the session and cookies.
*
* @return void
*/
protected function clearUserDataFromStorage()
{
$this->session->remove($this->getName());
}
/**
* Get the last user we attempted to authenticate.
*
* @return \Illuminate\Contracts\Auth\Authenticatable
*/
public function getLastAttempted()
{
return $this->lastAttempted;
}
/**
* Get a unique identifier for the auth session value.
*
* @return string
*/
public function getName()
{
return 'login_'.$this->name.'_'.sha1(static::class);
}
/**
* Determine if the user was authenticated via "remember me" cookie.
*
* @return bool
*/
public function viaRemember()
{
return false;
}
/**
* Return the currently cached user.
*
* @return \Illuminate\Contracts\Auth\Authenticatable|null
*/
public function getUser()
{
return $this->user;
}
/**
* Set the current user.
*
* @param \Illuminate\Contracts\Auth\Authenticatable $user
* @return $this
*/
public function setUser(AuthenticatableContract $user)
{
$this->user = $user;
$this->loggedOut = false;
return $this;
}
}

View File

@@ -0,0 +1,123 @@
<?php
namespace BookStack\Auth\Access\Guards;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\LdapException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\UserRegistrationException;
use Illuminate\Contracts\Auth\UserProvider;
use Illuminate\Contracts\Session\Session;
use Illuminate\Support\Facades\Hash;
use Illuminate\Support\Str;
class LdapSessionGuard extends ExternalBaseSessionGuard
{
protected $ldapService;
/**
* LdapSessionGuard constructor.
*/
public function __construct($name,
UserProvider $provider,
Session $session,
LdapService $ldapService,
RegistrationService $registrationService
)
{
$this->ldapService = $ldapService;
parent::__construct($name, $provider, $session, $registrationService);
}
/**
* Validate a user's credentials.
*
* @param array $credentials
* @return bool
* @throws LdapException
*/
public function validate(array $credentials = [])
{
$userDetails = $this->ldapService->getUserDetails($credentials['username']);
if (isset($userDetails['uid'])) {
$this->lastAttempted = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
]);
}
return $this->ldapService->validateUserCredentials($userDetails, $credentials['password']);
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
* @return bool
* @throws LoginAttemptException
* @throws LdapException
*/
public function attempt(array $credentials = [], $remember = false)
{
$username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username);
$user = null;
if (isset($userDetails['uid'])) {
$this->lastAttempted = $user = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
]);
}
if (!$this->ldapService->validateUserCredentials($userDetails, $credentials['password'])) {
return false;
}
if (is_null($user)) {
try {
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
} catch (UserRegistrationException $exception) {
throw new LoginAttemptException($exception->message);
}
}
// Sync LDAP groups if required
if ($this->ldapService->shouldSyncGroups()) {
$this->ldapService->syncGroups($user, $username);
}
$this->login($user, $remember);
return true;
}
/**
* Create a new user from the given ldap credentials and login credentials
* @throws LoginAttemptEmailNeededException
* @throws LoginAttemptException
* @throws UserRegistrationException
*/
protected function createNewFromLdapAndCreds(array $ldapUserDetails, array $credentials): User
{
$email = trim($ldapUserDetails['email'] ?: ($credentials['email'] ?? ''));
if (empty($email)) {
throw new LoginAttemptEmailNeededException();
}
$details = [
'name' => $ldapUserDetails['name'],
'email' => $ldapUserDetails['email'] ?: $credentials['email'],
'external_auth_id' => $ldapUserDetails['uid'],
'password' => Str::random(32),
];
return $this->registrationService->registerUser($details, null, false);
}
}

View File

@@ -0,0 +1,40 @@
<?php
namespace BookStack\Auth\Access\Guards;
/**
* Saml2 Session Guard
*
* The saml2 login process is async in nature meaning it does not fit very well
* into the default laravel 'Guard' auth flow. Instead most of the logic is done
* via the Saml2 controller & Saml2Service. This class provides a safer, thin
* version of SessionGuard.
*
* @package BookStack\Auth\Access\Guards
*/
class Saml2SessionGuard extends ExternalBaseSessionGuard
{
/**
* Validate a user's credentials.
*
* @param array $credentials
* @return bool
*/
public function validate(array $credentials = [])
{
return false;
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
* @return bool
*/
public function attempt(array $credentials = [], $remember = false)
{
return false;
}
}

View File

@@ -1,37 +1,29 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\LdapException;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Database\Eloquent\Builder;
use ErrorException;
/**
* Class LdapService
* Handles any app-specific LDAP tasks.
* @package BookStack\Services
*/
class LdapService
class LdapService extends ExternalAuthService
{
protected $ldap;
protected $ldapConnection;
protected $config;
protected $userRepo;
protected $enabled;
/**
* LdapService constructor.
* @param Ldap $ldap
* @param \BookStack\Auth\UserRepo $userRepo
*/
public function __construct(Access\Ldap $ldap, UserRepo $userRepo)
public function __construct(Ldap $ldap)
{
$this->ldap = $ldap;
$this->config = config('services.ldap');
$this->userRepo = $userRepo;
$this->enabled = config('auth.method') === 'ldap';
}
@@ -45,17 +37,21 @@ class LdapService
}
/**
* Search for attributes for a specific user on the ldap
* @param string $userName
* @param array $attributes
* @return null|array
* Search for attributes for a specific user on the ldap.
* @throws LdapException
*/
private function getUserWithAttributes($userName, $attributes)
private function getUserWithAttributes(string $userName, array $attributes): ?array
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
// Clean attributes
foreach ($attributes as $index => $attribute) {
if (strpos($attribute, 'BIN;') === 0) {
$attributes[$index] = substr($attribute, strlen('BIN;'));
}
}
// Find user
$userFilter = $this->buildFilter($this->config['user_filter'], ['user' => $userName]);
$baseDn = $this->config['base_dn'];
@@ -73,69 +69,78 @@ class LdapService
/**
* Get the details of a user from LDAP using the given username.
* User found via configurable user filter.
* @param $userName
* @return array|null
* @throws LdapException
*/
public function getUserDetails($userName)
public function getUserDetails(string $userName): ?array
{
$idAttr = $this->config['id_attribute'];
$emailAttr = $this->config['email_attribute'];
$displayNameAttr = $this->config['display_name_attribute'];
$user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr, $displayNameAttr]);
$user = $this->getUserWithAttributes($userName, ['cn', 'dn', $idAttr, $emailAttr, $displayNameAttr]);
if ($user === null) {
return null;
}
$userCn = $this->getUserResponseProperty($user, 'cn', null);
return [
'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']),
$formatted = [
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
];
if ($this->config['dump_user_details']) {
throw new JsonDebugException([
'details_from_ldap' => $user,
'details_bookstack_parsed' => $formatted,
]);
}
return $formatted;
}
/**
* Get a property from an LDAP user response fetch.
* Handles properties potentially being part of an array.
* @param array $userDetails
* @param string $propertyKey
* @param $defaultValue
* @return mixed
* If the given key is prefixed with 'BIN;', that indicator will be stripped
* from the key and any fetched values will be converted from binary to hex.
*/
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{
if (isset($userDetails[$propertyKey])) {
return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
$isBinary = strpos($propertyKey, 'BIN;') === 0;
$propertyKey = strtolower($propertyKey);
$value = $defaultValue;
if ($isBinary) {
$propertyKey = substr($propertyKey, strlen('BIN;'));
}
return $defaultValue;
if (isset($userDetails[$propertyKey])) {
$value = (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
if ($isBinary) {
$value = bin2hex($value);
}
}
return $value;
}
/**
* @param Authenticatable $user
* @param string $username
* @param string $password
* @return bool
* Check if the given credentials are valid for the given user.
* @throws LdapException
*/
public function validateUserCredentials(Authenticatable $user, $username, $password)
public function validateUserCredentials(?array $ldapUserDetails, string $password): bool
{
$ldapUser = $this->getUserDetails($username);
if ($ldapUser === null) {
return false;
}
if ($ldapUser['uid'] !== $user->external_auth_id) {
if (is_null($ldapUserDetails)) {
return false;
}
$ldapConnection = $this->getConnection();
try {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUser['dn'], $password);
} catch (\ErrorException $e) {
$ldapBind = $this->ldap->bind($ldapConnection, $ldapUserDetails['dn'], $password);
} catch (ErrorException $e) {
$ldapBind = false;
}
@@ -205,12 +210,10 @@ class LdapService
}
/**
* Parse a LDAP server string and return the host and port for
* a connection. Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'
* @param $serverString
* @return array
* Parse a LDAP server string and return the host and port for a connection.
* Is flexible to formats such as 'ldap.example.com:8069' or 'ldaps://ldap.example.com'.
*/
protected function parseServerString($serverString)
protected function parseServerString(string $serverString): array
{
$serverNameParts = explode(':', $serverString);
@@ -227,11 +230,8 @@ class LdapService
/**
* Build a filter string by injecting common variables.
* @param string $filterString
* @param array $attrs
* @return string
*/
protected function buildFilter($filterString, array $attrs)
protected function buildFilter(string $filterString, array $attrs): string
{
$newAttrs = [];
foreach ($attrs as $key => $attrText) {
@@ -242,12 +242,10 @@ class LdapService
}
/**
* Get the groups a user is a part of on ldap
* @param string $userName
* @return array
* Get the groups a user is a part of on ldap.
* @throws LdapException
*/
public function getUserGroups($userName)
public function getUserGroups(string $userName): array
{
$groupsAttr = $this->config['group_attribute'];
$user = $this->getUserWithAttributes($userName, [$groupsAttr]);
@@ -262,40 +260,36 @@ class LdapService
}
/**
* Get the parent groups of an array of groups
* @param array $groupsArray
* @param array $checked
* @return array
* Get the parent groups of an array of groups.
* @throws LdapException
*/
private function getGroupsRecursive($groupsArray, $checked)
private function getGroupsRecursive(array $groupsArray, array $checked): array
{
$groups_to_add = [];
$groupsToAdd = [];
foreach ($groupsArray as $groupName) {
if (in_array($groupName, $checked)) {
continue;
}
$groupsToAdd = $this->getGroupGroups($groupName);
$groups_to_add = array_merge($groups_to_add, $groupsToAdd);
$parentGroups = $this->getGroupGroups($groupName);
$groupsToAdd = array_merge($groupsToAdd, $parentGroups);
$checked[] = $groupName;
}
$groupsArray = array_unique(array_merge($groupsArray, $groups_to_add), SORT_REGULAR);
if (!empty($groups_to_add)) {
return $this->getGroupsRecursive($groupsArray, $checked);
} else {
$groupsArray = array_unique(array_merge($groupsArray, $groupsToAdd), SORT_REGULAR);
if (empty($groupsToAdd)) {
return $groupsArray;
}
return $this->getGroupsRecursive($groupsArray, $checked);
}
/**
* Get the parent groups of a single group
* @param string $groupName
* @return array
* Get the parent groups of a single group.
* @throws LdapException
*/
private function getGroupGroups($groupName)
private function getGroupGroups(string $groupName): array
{
$ldapConnection = $this->getConnection();
$this->bindSystemUser($ldapConnection);
@@ -312,17 +306,14 @@ class LdapService
return [];
}
$groupGroups = $this->groupFilter($groups[0]);
return $groupGroups;
return $this->groupFilter($groups[0]);
}
/**
* Filter out LDAP CN and DN language in a ldap search return
* Gets the base CN (common name) of the string
* @param array $userGroupSearchResponse
* @return array
* Filter out LDAP CN and DN language in a ldap search return.
* Gets the base CN (common name) of the string.
*/
protected function groupFilter(array $userGroupSearchResponse)
protected function groupFilter(array $userGroupSearchResponse): array
{
$groupsAttr = strtolower($this->config['group_attribute']);
$ldapGroups = [];
@@ -343,73 +334,12 @@ class LdapService
}
/**
* Sync the LDAP groups to the user roles for the current user
* @param \BookStack\Auth\User $user
* @param string $username
* Sync the LDAP groups to the user roles for the current user.
* @throws LdapException
*/
public function syncGroups(User $user, string $username)
{
$userLdapGroups = $this->getUserGroups($username);
// Get the ids for the roles from the names
$ldapGroupsAsRoles = $this->matchLdapGroupsToSystemsRoles($userLdapGroups);
// Sync groups
if ($this->config['remove_from_groups']) {
$user->roles()->sync($ldapGroupsAsRoles);
$this->userRepo->attachDefaultRole($user);
} else {
$user->roles()->syncWithoutDetaching($ldapGroupsAsRoles);
}
}
/**
* Match an array of group names from LDAP to BookStack system roles.
* Formats LDAP group names to be lower-case and hyphenated.
* @param array $groupNames
* @return \Illuminate\Support\Collection
*/
protected function matchLdapGroupsToSystemsRoles(array $groupNames)
{
foreach ($groupNames as $i => $groupName) {
$groupNames[$i] = str_replace(' ', '-', trim(strtolower($groupName)));
}
$roles = Role::query()->where(function (Builder $query) use ($groupNames) {
$query->whereIn('name', $groupNames);
foreach ($groupNames as $groupName) {
$query->orWhere('external_auth_id', 'LIKE', '%' . $groupName . '%');
}
})->get();
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
return $this->roleMatchesGroupNames($role, $groupNames);
});
return $matchedRoles->pluck('id');
}
/**
* Check a role against an array of group names to see if it matches.
* Checked against role 'external_auth_id' if set otherwise the name of the role.
* @param \BookStack\Auth\Role $role
* @param array $groupNames
* @return bool
*/
protected function roleMatchesGroupNames(Role $role, array $groupNames)
{
if ($role->external_auth_id) {
$externalAuthIds = explode(',', strtolower($role->external_auth_id));
foreach ($externalAuthIds as $externalAuthId) {
if (in_array(trim($externalAuthId), $groupNames)) {
return true;
}
}
return false;
}
$roleName = str_replace(' ', '-', trim(strtolower($role->display_name)));
return in_array($roleName, $groupNames);
$this->syncWithGroups($user, $userLdapGroups);
}
}

View File

@@ -0,0 +1,109 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\UserRegistrationException;
use Exception;
class RegistrationService
{
protected $userRepo;
protected $emailConfirmationService;
/**
* RegistrationService constructor.
*/
public function __construct(UserRepo $userRepo, EmailConfirmationService $emailConfirmationService)
{
$this->userRepo = $userRepo;
$this->emailConfirmationService = $emailConfirmationService;
}
/**
* Check whether or not registrations are allowed in the app settings.
* @throws UserRegistrationException
*/
public function ensureRegistrationAllowed()
{
if (!$this->registrationAllowed()) {
throw new UserRegistrationException(trans('auth.registrations_disabled'), '/login');
}
}
/**
* Check if standard BookStack User registrations are currently allowed.
* Does not prevent external-auth based registration.
*/
protected function registrationAllowed(): bool
{
$authMethod = config('auth.method');
$authMethodsWithRegistration = ['standard'];
return in_array($authMethod, $authMethodsWithRegistration) && setting('registration-enabled');
}
/**
* The registrations flow for all users.
* @throws UserRegistrationException
*/
public function registerUser(array $userData, ?SocialAccount $socialAccount = null, bool $emailConfirmed = false): User
{
$userEmail = $userData['email'];
// Email restriction
$this->ensureEmailDomainAllowed($userEmail);
// Ensure user does not already exist
$alreadyUser = !is_null($this->userRepo->getByEmail($userEmail));
if ($alreadyUser) {
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]));
}
// Create the user
$newUser = $this->userRepo->registerNew($userData, $emailConfirmed);
// Assign social account if given
if ($socialAccount) {
$newUser->socialAccounts()->save($socialAccount);
}
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
$newUser->save();
$message = '';
try {
$this->emailConfirmationService->sendConfirmation($newUser);
} catch (Exception $e) {
$message = trans('auth.email_confirm_send_error');
}
throw new UserRegistrationException($message, '/register/confirm');
}
return $newUser;
}
/**
* Ensure that the given email meets any active email domain registration restrictions.
* Throws if restrictions are active and the email does not match an allowed domain.
* @throws UserRegistrationException
*/
protected function ensureEmailDomainAllowed(string $userEmail): void
{
$registrationRestrict = setting('registration-restrict');
if (!$registrationRestrict) {
return;
}
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
$userEmailDomain = $domain = mb_substr(mb_strrchr($userEmail, "@"), 1);
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
$redirect = $this->registrationAllowed() ? '/register' : '/login';
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), $redirect);
}
}
}

View File

@@ -0,0 +1,378 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\User;
use BookStack\Exceptions\JsonDebugException;
use BookStack\Exceptions\SamlException;
use BookStack\Exceptions\UserRegistrationException;
use Exception;
use Illuminate\Support\Str;
use OneLogin\Saml2\Auth;
use OneLogin\Saml2\Error;
use OneLogin\Saml2\IdPMetadataParser;
use OneLogin\Saml2\ValidationError;
/**
* Class Saml2Service
* Handles any app-specific SAML tasks.
*/
class Saml2Service extends ExternalAuthService
{
protected $config;
protected $registrationService;
protected $user;
/**
* Saml2Service constructor.
*/
public function __construct(RegistrationService $registrationService, User $user)
{
$this->config = config('saml2');
$this->registrationService = $registrationService;
$this->user = $user;
}
/**
* Initiate a login flow.
* @throws Error
*/
public function login(): array
{
$toolKit = $this->getToolkit();
$returnRoute = url('/saml2/acs');
return [
'url' => $toolKit->login($returnRoute, [], false, false, true),
'id' => $toolKit->getLastRequestID(),
];
}
/**
* Initiate a logout flow.
* @throws Error
*/
public function logout(): array
{
$toolKit = $this->getToolkit();
$returnRoute = url('/');
try {
$url = $toolKit->logout($returnRoute, [], null, null, true);
$id = $toolKit->getLastRequestID();
} catch (Error $error) {
if ($error->getCode() !== Error::SAML_SINGLE_LOGOUT_NOT_SUPPORTED) {
throw $error;
}
$this->actionLogout();
$url = '/';
$id = null;
}
return ['url' => $url, 'id' => $id];
}
/**
* Process the ACS response from the idp and return the
* matching, or new if registration active, user matched to the idp.
* Returns null if not authenticated.
* @throws Error
* @throws SamlException
* @throws ValidationError
* @throws JsonDebugException
* @throws UserRegistrationException
*/
public function processAcsResponse(?string $requestId): ?User
{
$toolkit = $this->getToolkit();
$toolkit->processResponse($requestId);
$errors = $toolkit->getErrors();
if (!empty($errors)) {
throw new Error(
'Invalid ACS Response: '.implode(', ', $errors)
);
}
if (!$toolkit->isAuthenticated()) {
return null;
}
$attrs = $toolkit->getAttributes();
$id = $toolkit->getNameId();
return $this->processLoginCallback($id, $attrs);
}
/**
* Process a response for the single logout service.
* @throws Error
*/
public function processSlsResponse(?string $requestId): ?string
{
$toolkit = $this->getToolkit();
$redirect = $toolkit->processSLO(true, $requestId, false, null, true);
$errors = $toolkit->getErrors();
if (!empty($errors)) {
throw new Error(
'Invalid SLS Response: '.implode(', ', $errors)
);
}
$this->actionLogout();
return $redirect;
}
/**
* Do the required actions to log a user out.
*/
protected function actionLogout()
{
auth()->logout();
session()->invalidate();
}
/**
* Get the metadata for this service provider.
* @throws Error
*/
public function metadata(): string
{
$toolKit = $this->getToolkit();
$settings = $toolKit->getSettings();
$metadata = $settings->getSPMetadata();
$errors = $settings->validateMetadata($metadata);
if (!empty($errors)) {
throw new Error(
'Invalid SP metadata: '.implode(', ', $errors),
Error::METADATA_SP_INVALID
);
}
return $metadata;
}
/**
* Load the underlying Onelogin SAML2 toolkit.
* @throws Error
* @throws Exception
*/
protected function getToolkit(): Auth
{
$settings = $this->config['onelogin'];
$overrides = $this->config['onelogin_overrides'] ?? [];
if ($overrides && is_string($overrides)) {
$overrides = json_decode($overrides, true);
}
$metaDataSettings = [];
if ($this->config['autoload_from_metadata']) {
$metaDataSettings = IdPMetadataParser::parseRemoteXML($settings['idp']['entityId']);
}
$spSettings = $this->loadOneloginServiceProviderDetails();
$settings = array_replace_recursive($settings, $spSettings, $metaDataSettings, $overrides);
return new Auth($settings);
}
/**
* Load dynamic service provider options required by the onelogin toolkit.
*/
protected function loadOneloginServiceProviderDetails(): array
{
$spDetails = [
'entityId' => url('/saml2/metadata'),
'assertionConsumerService' => [
'url' => url('/saml2/acs'),
],
'singleLogoutService' => [
'url' => url('/saml2/sls')
],
];
return [
'baseurl' => url('/saml2'),
'sp' => $spDetails
];
}
/**
* Check if groups should be synced.
*/
protected function shouldSyncGroups(): bool
{
return $this->config['user_to_groups'] !== false;
}
/**
* Calculate the display name
*/
protected function getUserDisplayName(array $samlAttributes, string $defaultValue): string
{
$displayNameAttr = $this->config['display_name_attributes'];
$displayName = [];
foreach ($displayNameAttr as $dnAttr) {
$dnComponent = $this->getSamlResponseAttribute($samlAttributes, $dnAttr, null);
if ($dnComponent !== null) {
$displayName[] = $dnComponent;
}
}
if (count($displayName) == 0) {
$displayName = $defaultValue;
} else {
$displayName = implode(' ', $displayName);
}
return $displayName;
}
/**
* Get the value to use as the external id saved in BookStack
* used to link the user to an existing BookStack DB user.
*/
protected function getExternalId(array $samlAttributes, string $defaultValue)
{
$userNameAttr = $this->config['external_id_attribute'];
if ($userNameAttr === null) {
return $defaultValue;
}
return $this->getSamlResponseAttribute($samlAttributes, $userNameAttr, $defaultValue);
}
/**
* Extract the details of a user from a SAML response.
*/
protected function getUserDetails(string $samlID, $samlAttributes): array
{
$emailAttr = $this->config['email_attribute'];
$externalId = $this->getExternalId($samlAttributes, $samlID);
$defaultEmail = filter_var($samlID, FILTER_VALIDATE_EMAIL) ? $samlID : null;
$email = $this->getSamlResponseAttribute($samlAttributes, $emailAttr, $defaultEmail);
return [
'external_id' => $externalId,
'name' => $this->getUserDisplayName($samlAttributes, $externalId),
'email' => $email,
'saml_id' => $samlID,
];
}
/**
* Get the groups a user is a part of from the SAML response.
*/
public function getUserGroups(array $samlAttributes): array
{
$groupsAttr = $this->config['group_attribute'];
$userGroups = $samlAttributes[$groupsAttr] ?? null;
if (!is_array($userGroups)) {
$userGroups = [];
}
return $userGroups;
}
/**
* For an array of strings, return a default for an empty array,
* a string for an array with one element and the full array for
* more than one element.
*/
protected function simplifyValue(array $data, $defaultValue)
{
switch (count($data)) {
case 0:
$data = $defaultValue;
break;
case 1:
$data = $data[0];
break;
}
return $data;
}
/**
* Get a property from an SAML response.
* Handles properties potentially being an array.
*/
protected function getSamlResponseAttribute(array $samlAttributes, string $propertyKey, $defaultValue)
{
if (isset($samlAttributes[$propertyKey])) {
return $this->simplifyValue($samlAttributes[$propertyKey], $defaultValue);
}
return $defaultValue;
}
/**
* Get the user from the database for the specified details.
* @throws SamlException
* @throws UserRegistrationException
*/
protected function getOrRegisterUser(array $userDetails): ?User
{
$user = $this->user->newQuery()
->where('external_auth_id', '=', $userDetails['external_id'])
->first();
if (is_null($user)) {
$userData = [
'name' => $userDetails['name'],
'email' => $userDetails['email'],
'password' => Str::random(32),
'external_auth_id' => $userDetails['external_id'],
];
$user = $this->registrationService->registerUser($userData, null, false);
}
return $user;
}
/**
* Process the SAML response for a user. Login the user when
* they exist, optionally registering them automatically.
* @throws SamlException
* @throws JsonDebugException
* @throws UserRegistrationException
*/
public function processLoginCallback(string $samlID, array $samlAttributes): User
{
$userDetails = $this->getUserDetails($samlID, $samlAttributes);
$isLoggedIn = auth()->check();
if ($this->config['dump_user_details']) {
throw new JsonDebugException([
'id_from_idp' => $samlID,
'attrs_from_idp' => $samlAttributes,
'attrs_after_parsing' => $userDetails,
]);
}
if ($userDetails['email'] === null) {
throw new SamlException(trans('errors.saml_no_email_address'));
}
if ($isLoggedIn) {
throw new SamlException(trans('errors.saml_already_logged_in'), '/login');
}
$user = $this->getOrRegisterUser($userDetails);
if ($user === null) {
throw new SamlException(trans('errors.saml_user_not_registered', ['name' => $userDetails['external_id']]), '/login');
}
if ($this->shouldSyncGroups()) {
$groups = $this->getUserGroups($samlAttributes);
$this->syncWithGroups($user, $groups);
}
auth()->login($user);
return $user;
}
}

View File

@@ -5,8 +5,11 @@ use BookStack\Auth\UserRepo;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\UserRegistrationException;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\Factory as Socialite;
use Laravel\Socialite\Contracts\Provider;
use Laravel\Socialite\Contracts\User as SocialUser;
use Symfony\Component\HttpFoundation\RedirectResponse;
class SocialAuthService
{
@@ -19,9 +22,6 @@ class SocialAuthService
/**
* SocialAuthService constructor.
* @param \BookStack\Auth\UserRepo $userRepo
* @param Socialite $socialite
* @param SocialAccount $socialAccount
*/
public function __construct(UserRepo $userRepo, Socialite $socialite, SocialAccount $socialAccount)
{
@@ -33,11 +33,9 @@ class SocialAuthService
/**
* Start the social login path.
* @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured
*/
public function startLogIn($socialDriver)
public function startLogIn(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
return $this->getSocialDriver($driver)->redirect();
@@ -45,11 +43,9 @@ class SocialAuthService
/**
* Start the social registration process
* @param string $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws SocialDriverNotConfigured
*/
public function startRegister($socialDriver)
public function startRegister(string $socialDriver): RedirectResponse
{
$driver = $this->validateDriver($socialDriver);
return $this->getSocialDriver($driver)->redirect();
@@ -57,12 +53,9 @@ class SocialAuthService
/**
* Handle the social registration process on callback.
* @param string $socialDriver
* @param SocialUser $socialUser
* @return SocialUser
* @throws UserRegistrationException
*/
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser)
public function handleRegistrationCallback(string $socialDriver, SocialUser $socialUser): SocialUser
{
// Check social account has not already been used
if ($this->socialAccount->where('driver_id', '=', $socialUser->getId())->exists()) {
@@ -71,7 +64,7 @@ class SocialAuthService
if ($this->userRepo->getByEmail($socialUser->getEmail())) {
$email = $socialUser->getEmail();
throw new UserRegistrationException(trans('errors.social_account_in_use', ['socialAccount'=>$socialDriver, 'email' => $email]), '/login');
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $email]), '/login');
}
return $socialUser;
@@ -79,11 +72,9 @@ class SocialAuthService
/**
* Get the social user details via the social driver.
* @param string $socialDriver
* @return SocialUser
* @throws SocialDriverNotConfigured
*/
public function getSocialUser(string $socialDriver)
public function getSocialUser(string $socialDriver): SocialUser
{
$driver = $this->validateDriver($socialDriver);
return $this->socialite->driver($driver)->user();
@@ -91,12 +82,9 @@ class SocialAuthService
/**
* Handle the login process on a oAuth callback.
* @param $socialDriver
* @param SocialUser $socialUser
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws SocialSignInAccountNotUsed
*/
public function handleLoginCallback($socialDriver, SocialUser $socialUser)
public function handleLoginCallback(string $socialDriver, SocialUser $socialUser)
{
$socialId = $socialUser->getId();
@@ -104,6 +92,7 @@ class SocialAuthService
$socialAccount = $this->socialAccount->where('driver_id', '=', $socialId)->first();
$isLoggedIn = auth()->check();
$currentUser = user();
$titleCaseDriver = Str::title($socialDriver);
// When a user is not logged in and a matching SocialAccount exists,
// Simply log the user into the application.
@@ -117,26 +106,26 @@ class SocialAuthService
if ($isLoggedIn && $socialAccount === null) {
$this->fillSocialAccount($socialDriver, $socialUser);
$currentUser->socialAccounts()->save($this->socialAccount);
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => title_case($socialDriver)]));
session()->flash('success', trans('settings.users_social_connected', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in and the social account exists and is already linked to the current user.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id === $currentUser->id) {
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => title_case($socialDriver)]));
session()->flash('error', trans('errors.social_account_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
}
// When a user is logged in, A social account exists but the users do not match.
if ($isLoggedIn && $socialAccount !== null && $socialAccount->user->id != $currentUser->id) {
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => title_case($socialDriver)]));
session()->flash('error', trans('errors.social_account_already_used_existing', ['socialAccount' => $titleCaseDriver]));
return redirect($currentUser->getEditUrl());
}
// Otherwise let the user know this social account is not used by anyone.
$message = trans('errors.social_account_not_used', ['socialAccount' => title_case($socialDriver)]);
if (setting('registration-enabled')) {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => title_case($socialDriver)]);
$message = trans('errors.social_account_not_used', ['socialAccount' => $titleCaseDriver]);
if (setting('registration-enabled') && config('auth.method') !== 'ldap' && config('auth.method') !== 'saml2') {
$message .= trans('errors.social_account_register_instructions', ['socialAccount' => $titleCaseDriver]);
}
throw new SocialSignInAccountNotUsed($message, '/login');
@@ -144,20 +133,18 @@ class SocialAuthService
/**
* Ensure the social driver is correct and supported.
*
* @param $socialDriver
* @return string
* @throws SocialDriverNotConfigured
*/
private function validateDriver($socialDriver)
protected function validateDriver(string $socialDriver): string
{
$driver = trim(strtolower($socialDriver));
if (!in_array($driver, $this->validSocialDrivers)) {
abort(404, trans('errors.social_driver_not_found'));
}
if (!$this->checkDriverConfigured($driver)) {
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => title_case($socialDriver)]));
throw new SocialDriverNotConfigured(trans('errors.social_driver_not_configured', ['socialAccount' => Str::title($socialDriver)]));
}
return $driver;
@@ -165,10 +152,8 @@ class SocialAuthService
/**
* Check a social driver has been configured correctly.
* @param $driver
* @return bool
*/
private function checkDriverConfigured($driver)
protected function checkDriverConfigured(string $driver): bool
{
$lowerName = strtolower($driver);
$configPrefix = 'services.' . $lowerName . '.';
@@ -178,55 +163,48 @@ class SocialAuthService
/**
* Gets the names of the active social drivers.
* @return array
*/
public function getActiveDrivers()
public function getActiveDrivers(): array
{
$activeDrivers = [];
foreach ($this->validSocialDrivers as $driverKey) {
if ($this->checkDriverConfigured($driverKey)) {
$activeDrivers[$driverKey] = $this->getDriverName($driverKey);
}
}
return $activeDrivers;
}
/**
* Get the presentational name for a driver.
* @param $driver
* @return mixed
*/
public function getDriverName($driver)
public function getDriverName(string $driver): string
{
return config('services.' . strtolower($driver) . '.name');
}
/**
* Check if the current config for the given driver allows auto-registration.
* @param string $driver
* @return bool
*/
public function driverAutoRegisterEnabled(string $driver)
public function driverAutoRegisterEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_register') === true;
}
/**
* Check if the current config for the given driver allow email address auto-confirmation.
* @param string $driver
* @return bool
*/
public function driverAutoConfirmEmailEnabled(string $driver)
public function driverAutoConfirmEmailEnabled(string $driver): bool
{
return config('services.' . strtolower($driver) . '.auto_confirm') === true;
}
/**
* @param string $socialDriver
* @param SocialUser $socialUser
* @return SocialAccount
* Fill and return a SocialAccount from the given driver name and SocialUser.
*/
public function fillSocialAccount($socialDriver, $socialUser)
public function fillSocialAccount(string $socialDriver, SocialUser $socialUser): SocialAccount
{
$this->socialAccount->fill([
'driver' => $socialDriver,
@@ -238,28 +216,26 @@ class SocialAuthService
/**
* Detach a social account from a user.
* @param $socialDriver
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function detachSocialAccount($socialDriver)
public function detachSocialAccount(string $socialDriver)
{
user()->socialAccounts()->where('driver', '=', $socialDriver)->delete();
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => title_case($socialDriver)]));
return redirect(user()->getEditUrl());
}
/**
* Provide redirect options per service for the Laravel Socialite driver
* @param $driverName
* @return \Laravel\Socialite\Contracts\Provider
*/
public function getSocialDriver(string $driverName)
public function getSocialDriver(string $driverName): Provider
{
$driver = $this->socialite->driver($driverName);
if ($driverName === 'google' && config('services.google.select_account')) {
$driver->with(['prompt' => 'select_account']);
}
if ($driverName === 'azure') {
$driver->with(['resource' => 'https://graph.windows.net']);
}
return $driver;
}

View File

@@ -19,5 +19,4 @@ class UserInviteService extends UserTokenService
$token = $this->createTokenForUser($user);
$user->notify(new UserInvite($token));
}
}

View File

@@ -5,6 +5,7 @@ use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use Carbon\Carbon;
use Illuminate\Database\Connection as Database;
use Illuminate\Support\Str;
use stdClass;
class UserTokenService
@@ -73,9 +74,9 @@ class UserTokenService
*/
protected function generateToken() : string
{
$token = str_random(24);
$token = Str::random(24);
while ($this->tokenExists($token)) {
$token = str_random(25);
$token = Str::random(25);
}
return $token;
}
@@ -130,5 +131,4 @@ class UserTokenService
return Carbon::now()->subHours($this->expiryTime)
->gt(new Carbon($tokenEntry->created_at));
}
}
}

View File

@@ -215,7 +215,6 @@ class PermissionService
* @param Collection $books
* @param array $roles
* @param bool $deleteOld
* @throws \Throwable
*/
protected function buildJointPermissionsForBooks($books, $roles, $deleteOld = false)
{
@@ -634,42 +633,40 @@ class PermissionService
}
/**
* Get the children of a book in an efficient single query, Filtered by the permission system.
* @param integer $book_id
* @param bool $filterDrafts
* @param bool $fetchPageContent
* @return QueryBuilder
* Limited the given entity query so that the query will only
* return items that the user has permission for the given ability.
*/
public function bookChildrenQuery($book_id, $filterDrafts = false, $fetchPageContent = false)
public function restrictEntityQuery(Builder $query, string $ability = 'view'): Builder
{
$entities = $this->entityProvider;
$pageSelect = $this->db->table('pages')->selectRaw($entities->page->entityRawQuery($fetchPageContent))
->where('book_id', '=', $book_id)->where(function ($query) use ($filterDrafts) {
$query->where('draft', '=', 0);
if (!$filterDrafts) {
$query->orWhere(function ($query) {
$query->where('draft', '=', 1)->where('created_by', '=', $this->currentUser()->id);
});
}
});
$chapterSelect = $this->db->table('chapters')->selectRaw($entities->chapter->entityRawQuery())->where('book_id', '=', $book_id);
$query = $this->db->query()->select('*')->from($this->db->raw("({$pageSelect->toSql()} UNION {$chapterSelect->toSql()}) AS U"))
->mergeBindings($pageSelect)->mergeBindings($chapterSelect);
// Add joint permission filter
$whereQuery = $this->db->table('joint_permissions as jp')->selectRaw('COUNT(*)')
->whereRaw('jp.entity_id=U.id')->whereRaw('jp.entity_type=U.entity_type')
->where('jp.action', '=', 'view')->whereIn('jp.role_id', $this->getRoles())
->where(function ($query) {
$query->where('jp.has_permission', '=', 1)->orWhere(function ($query) {
$query->where('jp.has_permission_own', '=', 1)->where('jp.created_by', '=', $this->currentUser()->id);
});
});
$query->whereRaw("({$whereQuery->toSql()}) > 0")->mergeBindings($whereQuery);
$query->orderBy('draft', 'desc')->orderBy('priority', 'asc');
$this->clean();
return $query;
return $query->where(function (Builder $parentQuery) use ($ability) {
$parentQuery->whereHas('jointPermissions', function (Builder $permissionQuery) use ($ability) {
$permissionQuery->whereIn('role_id', $this->getRoles())
->where('action', '=', $ability)
->where(function (Builder $query) {
$query->where('has_permission', '=', true)
->orWhere(function (Builder $query) {
$query->where('has_permission_own', '=', true)
->where('created_by', '=', $this->currentUser()->id);
});
});
});
});
}
/**
* Extend the given page query to ensure draft items are not visible
* unless created by the given user.
*/
public function enforceDraftVisiblityOnQuery(Builder $query): Builder
{
return $query->where(function (Builder $query) {
$query->where('draft', '=', false)
->orWhere(function (Builder $query) {
$query->where('draft', '=', true)
->where('created_by', '=', $this->currentUser()->id);
});
});
}
/**
@@ -684,12 +681,11 @@ class PermissionService
if (strtolower($entityType) === 'page') {
// Prevent drafts being visible to others.
$query = $query->where(function ($query) {
$query->where('draft', '=', false);
if ($this->currentUser()) {
$query->orWhere(function ($query) {
$query->where('draft', '=', true)->where('created_by', '=', $this->currentUser()->id);
$query->where('draft', '=', false)
->orWhere(function ($query) {
$query->where('draft', '=', true)
->where('created_by', '=', $this->currentUser()->id);
});
}
});
}

View File

@@ -3,6 +3,7 @@
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use Illuminate\Support\Str;
class PermissionsRepo
{
@@ -66,7 +67,7 @@ class PermissionsRepo
$role->name = str_replace(' ', '-', strtolower($roleData['display_name']));
// Prevent duplicate names
while ($this->role->where('name', '=', $role->name)->count() > 0) {
$role->name .= strtolower(str_random(2));
$role->name .= strtolower(Str::random(2));
}
$role->save();
@@ -136,7 +137,7 @@ class PermissionsRepo
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
throw new PermissionsException(trans('errors.role_system_cannot_be_deleted'));
} else if ($role->id == setting('registration-role')) {
} else if ($role->id === intval(setting('registration-role'))) {
throw new PermissionsException(trans('errors.role_registration_default_cannot_delete'));
}

View File

@@ -4,6 +4,13 @@ use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Model;
/**
* Class Role
* @property string $display_name
* @property string $description
* @property string $external_auth_id
* @package BookStack\Auth
*/
class Role extends Model
{
@@ -65,7 +72,7 @@ class Role extends Model
*/
public function detachPermission(RolePermission $permission)
{
$this->permissions()->detach($permission->id);
$this->permissions()->detach([$permission->id]);
}
/**
@@ -75,7 +82,7 @@ class Role extends Model
*/
public static function getRole($roleName)
{
return static::where('name', '=', $roleName)->first();
return static::query()->where('name', '=', $roleName)->first();
}
/**
@@ -85,7 +92,7 @@ class Role extends Model
*/
public static function getSystemRole($roleName)
{
return static::where('system_name', '=', $roleName)->first();
return static::query()->where('system_name', '=', $roleName)->first();
}
/**
@@ -94,6 +101,15 @@ class Role extends Model
*/
public static function visible()
{
return static::where('hidden', '=', false)->orderBy('name')->get();
return static::query()->where('hidden', '=', false)->orderBy('name')->get();
}
/**
* Get the roles that can be restricted.
* @return \Illuminate\Database\Eloquent\Builder[]|\Illuminate\Database\Eloquent\Collection
*/
public static function restrictable()
{
return static::query()->where('system_name', '!=', 'admin')->get();
}
}

View File

@@ -1,5 +1,6 @@
<?php namespace BookStack\Auth;
use BookStack\Api\ApiToken;
use BookStack\Model;
use BookStack\Notifications\ResetPassword;
use BookStack\Uploads\Image;
@@ -9,6 +10,7 @@ use Illuminate\Auth\Passwords\CanResetPassword;
use Illuminate\Contracts\Auth\Authenticatable as AuthenticatableContract;
use Illuminate\Contracts\Auth\CanResetPassword as CanResetPasswordContract;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Notifications\Notifiable;
/**
@@ -45,7 +47,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
* The attributes excluded from the model's JSON form.
* @var array
*/
protected $hidden = ['password', 'remember_token'];
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at',
];
/**
* This holds the user's permissions when loaded.
@@ -53,13 +58,24 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $permissions;
/**
* This holds the default user when loaded.
* @var null|User
*/
protected static $defaultUser = null;
/**
* Returns the default public user.
* @return User
*/
public static function getDefault()
{
return static::where('system_name', '=', 'public')->first();
if (!is_null(static::$defaultUser)) {
return static::$defaultUser;
}
static::$defaultUser = static::where('system_name', '=', 'public')->first();
return static::$defaultUser;
}
/**
@@ -103,6 +119,17 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
return $this->roles->pluck('system_name')->contains($role);
}
/**
* Attach the default system role to this user.
*/
public function attachDefaultRole(): void
{
$roleId = setting('registration-role');
if ($roleId && $this->roles()->where('id', '=', $roleId)->count() === 0) {
$this->roles()->attach($roleId);
}
}
/**
* Get all permissions belonging to a the current user.
* @param bool $cache
@@ -140,16 +167,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
public function attachRole(Role $role)
{
$this->attachRoleId($role->id);
}
/**
* Attach a role id to this user.
* @param $id
*/
public function attachRoleId($id)
{
$this->roles()->attach($id);
$this->roles()->attach($role->id);
}
/**
@@ -207,19 +225,26 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
}
/**
* Get the url for editing this user.
* @return string
* Get the API tokens assigned to this user.
*/
public function getEditUrl()
public function apiTokens(): HasMany
{
return url('/settings/users/' . $this->id);
return $this->hasMany(ApiToken::class);
}
/**
* Get the url for editing this user.
*/
public function getEditUrl(string $path = ''): string
{
$uri = '/settings/users/' . $this->id . '/' . trim($path, '/');
return url(rtrim($uri, '/'));
}
/**
* Get the url that links to this user's profile.
* @return mixed
*/
public function getProfileUrl()
public function getProfileUrl(): string
{
return url('/user/' . $this->id);
}

View File

@@ -1,39 +1,37 @@
<?php namespace BookStack\Auth;
use Activity;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Page;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\UserUpdateException;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Builder;
use Images;
use Log;
class UserRepo
{
protected $user;
protected $role;
protected $entityRepo;
/**
* UserRepo constructor.
* @param User $user
* @param Role $role
* @param EntityRepo $entityRepo
*/
public function __construct(User $user, Role $role, EntityRepo $entityRepo)
public function __construct(User $user, Role $role)
{
$this->user = $user;
$this->role = $role;
$this->entityRepo = $entityRepo;
}
/**
* @param string $email
* @return User|null
* Get a user by their email address.
*/
public function getByEmail($email)
public function getByEmail(string $email): ?User
{
return $this->user->where('email', '=', $email)->first();
}
@@ -79,31 +77,16 @@ class UserRepo
/**
* Creates a new user and attaches a role to them.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
*/
public function registerNew(array $data, $verifyEmail = false)
public function registerNew(array $data, bool $emailConfirmed = false): User
{
$user = $this->create($data, $verifyEmail);
$this->attachDefaultRole($user);
$user = $this->create($data, $emailConfirmed);
$user->attachDefaultRole();
$this->downloadAndAssignUserAvatar($user);
return $user;
}
/**
* Give a user the default role. Used when creating a new user.
* @param User $user
*/
public function attachDefaultRole(User $user)
{
$roleId = setting('registration-role');
if ($roleId !== false && $user->roles()->where('id', '=', $roleId)->count() === 0) {
$user->attachRoleId($roleId);
}
}
/**
* Assign a user to a system-level role.
* @param User $user
@@ -121,7 +104,7 @@ class UserRepo
/**
* Checks if the give user is the only admin.
* @param \BookStack\Auth\User $user
* @param User $user
* @return bool
*/
public function isOnlyAdmin(User $user)
@@ -173,28 +156,27 @@ class UserRepo
/**
* Create a new basic instance of user.
* @param array $data
* @param boolean $verifyEmail
* @return \BookStack\Auth\User
*/
public function create(array $data, $verifyEmail = false)
public function create(array $data, bool $emailConfirmed = false): User
{
return $this->user->forceCreate([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'email_confirmed' => $verifyEmail
'email_confirmed' => $emailConfirmed,
'external_auth_id' => $data['external_auth_id'] ?? '',
]);
}
/**
* Remove the given user from storage, Delete all related content.
* @param \BookStack\Auth\User $user
* @param User $user
* @throws Exception
*/
public function destroy(User $user)
{
$user->socialAccounts()->delete();
$user->apiTokens()->delete();
$user->delete();
// Delete user profile images
@@ -206,7 +188,7 @@ class UserRepo
/**
* Get the latest activity for a user.
* @param \BookStack\Auth\User $user
* @param User $user
* @param int $count
* @param int $page
* @return array
@@ -218,36 +200,35 @@ class UserRepo
/**
* Get the recently created content for this given user.
* @param \BookStack\Auth\User $user
* @param int $count
* @return mixed
*/
public function getRecentlyCreated(User $user, $count = 20)
public function getRecentlyCreated(User $user, int $count = 20): array
{
$createdByUserQuery = function (Builder $query) use ($user) {
$query->where('created_by', '=', $user->id);
$query = function (Builder $query) use ($user, $count) {
return $query->orderBy('created_at', 'desc')
->where('created_by', '=', $user->id)
->take($count)
->get();
};
return [
'pages' => $this->entityRepo->getRecentlyCreated('page', $count, 0, $createdByUserQuery),
'chapters' => $this->entityRepo->getRecentlyCreated('chapter', $count, 0, $createdByUserQuery),
'books' => $this->entityRepo->getRecentlyCreated('book', $count, 0, $createdByUserQuery),
'shelves' => $this->entityRepo->getRecentlyCreated('bookshelf', $count, 0, $createdByUserQuery)
'pages' => $query(Page::visible()->where('draft', '=', false)),
'chapters' => $query(Chapter::visible()),
'books' => $query(Book::visible()),
'shelves' => $query(Bookshelf::visible()),
];
}
/**
* Get asset created counts for the give user.
* @param \BookStack\Auth\User $user
* @return array
*/
public function getAssetCounts(User $user)
public function getAssetCounts(User $user): array
{
$createdBy = ['created_by' => $user->id];
return [
'pages' => $this->entityRepo->getUserTotalCreated('page', $user),
'chapters' => $this->entityRepo->getUserTotalCreated('chapter', $user),
'books' => $this->entityRepo->getUserTotalCreated('book', $user),
'shelves' => $this->entityRepo->getUserTotalCreated('bookshelf', $user),
'pages' => Page::visible()->where($createdBy)->count(),
'chapters' => Chapter::visible()->where($createdBy)->count(),
'books' => Book::visible()->where($createdBy)->count(),
'shelves' => Bookshelf::visible()->where($createdBy)->count(),
];
}
@@ -260,16 +241,6 @@ class UserRepo
return $this->role->newQuery()->orderBy('name', 'asc')->get();
}
/**
* Get all the roles which can be given restricted access to
* other entities in the system.
* @return mixed
*/
public function getRestrictableRoles()
{
return $this->role->where('system_name', '!=', 'admin')->get();
}
/**
* Get an avatar image for a user and set it as their avatar.
* Returns early if avatars disabled or not set in config.
@@ -288,7 +259,7 @@ class UserRepo
$user->save();
return true;
} catch (Exception $e) {
\Log::error('Failed to save user avatar image');
Log::error('Failed to save user avatar image');
return false;
}
}

23
app/Config/api.php Normal file
View File

@@ -0,0 +1,23 @@
<?php
/**
* API 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.
*/
return [
// The default number of items that are returned in listing API requests.
// This count can often be overridden, up the the max option, per-request via request options.
'default_item_count' => env('API_DEFAULT_ITEM_COUNT', 100),
// The maximum number of items that can be returned in a listing API request.
'max_item_count' => env('API_MAX_ITEM_COUNT', 500),
// The number of API requests that can be made per minute by a single user.
'requests_per_minute' => env('API_REQUESTS_PER_MIN', 180)
];

View File

@@ -52,11 +52,14 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'kr', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW'],
'locales' => ['en', 'ar', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
// Application Fallback Locale
'fallback_locale' => 'en',
// Faker Locale
'faker_locale' => 'en_GB',
// Enable right-to-left text control.
'rtl' => false,
@@ -72,10 +75,6 @@ return [
// Encryption cipher
'cipher' => 'AES-256-CBC',
// Logging configuration
// Options: single, daily, syslog, errorlog
'log' => env('APP_LOGGING', 'single'),
// Application Services Provides
'providers' => [
@@ -107,7 +106,6 @@ return [
Barryvdh\DomPDF\ServiceProvider::class,
Barryvdh\Snappy\ServiceProvider::class,
// BookStack replacement service providers (Extends Laravel)
BookStack\Providers\PaginationServiceProvider::class,
BookStack\Providers\TranslationServiceProvider::class,
@@ -137,6 +135,7 @@ return [
// Laravel
'App' => Illuminate\Support\Facades\App::class,
'Arr' => Illuminate\Support\Arr::class,
'Artisan' => Illuminate\Support\Facades\Artisan::class,
'Auth' => Illuminate\Support\Facades\Auth::class,
'Blade' => Illuminate\Support\Facades\Blade::class,
@@ -166,6 +165,7 @@ return [
'Schema' => Illuminate\Support\Facades\Schema::class,
'Session' => Illuminate\Support\Facades\Session::class,
'Storage' => Illuminate\Support\Facades\Storage::class,
'Str' => Illuminate\Support\Str::class,
'URL' => Illuminate\Support\Facades\URL::class,
'Validator' => Illuminate\Support\Facades\Validator::class,
'View' => Illuminate\Support\Facades\View::class,
@@ -181,6 +181,7 @@ return [
'Setting' => BookStack\Facades\Setting::class,
'Views' => BookStack\Facades\Views::class,
'Images' => BookStack\Facades\Images::class,
'Permissions' => BookStack\Facades\Permissions::class,
],

View File

@@ -11,14 +11,14 @@
return [
// Method of authentication to use
// Options: standard, ldap
// Options: standard, ldap, saml2
'method' => env('AUTH_METHOD', 'standard'),
// Authentication Defaults
// This option controls the default authentication "guard" and password
// reset options for your application.
'defaults' => [
'guard' => 'web',
'guard' => env('AUTH_METHOD', 'standard'),
'passwords' => 'users',
],
@@ -26,16 +26,22 @@ return [
// All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data.
// Supported: "session", "token"
// Supported drivers: "session", "api-token", "ldap-session"
'guards' => [
'web' => [
'standard' => [
'driver' => 'session',
'provider' => 'users',
],
'ldap' => [
'driver' => 'ldap-session',
'provider' => 'external',
],
'saml2' => [
'driver' => 'saml2-session',
'provider' => 'external',
],
'api' => [
'driver' => 'token',
'provider' => 'users',
'driver' => 'api-token',
],
],
@@ -43,17 +49,15 @@ return [
// All authentication drivers have a user provider. This defines how the
// users are actually retrieved out of your database or other storage
// mechanisms used by this application to persist your user's data.
// Supported: database, eloquent, ldap
'providers' => [
'users' => [
'driver' => env('AUTH_METHOD', 'standard') === 'standard' ? 'eloquent' : env('AUTH_METHOD'),
'driver' => 'eloquent',
'model' => \BookStack\Auth\User::class,
],
'external' => [
'driver' => 'external-users',
'model' => \BookStack\Auth\User::class,
],
// 'users' => [
// 'driver' => 'database',
// 'table' => 'users',
// ],
],
// Resetting Passwords
@@ -69,4 +73,4 @@ return [
],
],
];
];

View File

@@ -24,9 +24,13 @@ return [
'pusher' => [
'driver' => 'pusher',
'key' => env('PUSHER_KEY'),
'secret' => env('PUSHER_SECRET'),
'key' => env('PUSHER_APP_KEY'),
'secret' => env('PUSHER_APP_SECRET'),
'app_id' => env('PUSHER_APP_ID'),
'options' => [
'cluster' => env('PUSHER_APP_CLUSTER'),
'useTLS' => true,
],
],
'redis' => [
@@ -38,6 +42,11 @@ return [
'driver' => 'log',
],
'null' => [
'driver' => 'null',
],
],
];

View File

@@ -14,8 +14,12 @@ if (env('CACHE_DRIVER') === 'memcached') {
$memcachedServers = explode(',', trim(env('MEMCACHED_SERVERS', '127.0.0.1:11211:100'), ','));
foreach ($memcachedServers as $index => $memcachedServer) {
$memcachedServerDetails = explode(':', $memcachedServer);
if (count($memcachedServerDetails) < 2) $memcachedServerDetails[] = '11211';
if (count($memcachedServerDetails) < 3) $memcachedServerDetails[] = '100';
if (count($memcachedServerDetails) < 2) {
$memcachedServerDetails[] = '11211';
}
if (count($memcachedServerDetails) < 3) {
$memcachedServerDetails[] = '100';
}
$memcachedServers[$index] = array_combine($memcachedServerKeys, $memcachedServerDetails);
}
}
@@ -62,6 +66,6 @@ return [
// Cache key prefix
// Used to prevent collisions in shared cache systems.
'prefix' => env('CACHE_PREFIX', 'bookstack'),
'prefix' => env('CACHE_PREFIX', 'bookstack_cache'),
];

View File

@@ -11,10 +11,9 @@
// REDIS
// Split out configuration into an array
if (env('REDIS_SERVERS', false)) {
$redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null];
$redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ','));
$redisConfig = [];
$redisConfig = ['client' => 'predis'];
$cluster = count($redisServers) > 1;
if ($cluster) {
@@ -59,14 +58,9 @@ return [
// Many of those shown here are unsupported by BookStack.
'connections' => [
'sqlite' => [
'driver' => 'sqlite',
'database' => storage_path('database.sqlite'),
'prefix' => '',
],
'mysql' => [
'driver' => 'mysql',
'url' => env('DATABASE_URL'),
'host' => $mysql_host,
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
@@ -76,43 +70,28 @@ return [
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => false,
'engine' => null,
'options' => extension_loaded('pdo_mysql') ? array_filter([
PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'),
]) : [],
],
'mysql_testing' => [
'driver' => 'mysql',
'url' => env('TEST_DATABASE_URL'),
'host' => '127.0.0.1',
'database' => 'bookstack-test',
'username' => env('MYSQL_USER', 'bookstack-test'),
'password' => env('MYSQL_PASSWORD', 'bookstack-test'),
'charset' => 'utf8',
'collation' => 'utf8_unicode_ci',
'charset' => 'utf8mb4',
'collation' => 'utf8mb4_unicode_ci',
'prefix' => '',
'prefix_indexes' => true,
'strict' => false,
],
'pgsql' => [
'driver' => 'pgsql',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
'schema' => 'public',
],
'sqlsrv' => [
'driver' => 'sqlsrv',
'host' => env('DB_HOST', 'localhost'),
'database' => env('DB_DATABASE', 'forge'),
'username' => env('DB_USERNAME', 'forge'),
'password' => env('DB_PASSWORD', ''),
'charset' => 'utf8',
'prefix' => '',
],
],
// Migration Repository Table

View File

@@ -79,6 +79,7 @@ return [
'files' => false, // Show the included files
'config' => false, // Display config settings
'cache' => false, // Display cache events
'models' => true, // Display models
],
// Configure some DataCollectors

View File

@@ -69,7 +69,7 @@ return [
* should be an absolute path.
* This is only checked on command line call by dompdf.php, but not by
* direct class use like:
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
* $dompdf = new DOMPDF(); $dompdf->load_html($htmldata); $dompdf->render(); $pdfdata = $dompdf->output();
*/
"DOMPDF_CHROOT" => realpath(base_path()),

37
app/Config/hashing.php Normal file
View File

@@ -0,0 +1,37 @@
<?php
/**
* Hashing 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.
*/
return [
// Default Hash Driver
// This option controls the default hash driver that will be used to hash
// passwords for your application. By default, the bcrypt algorithm is used.
// Supported: "bcrypt", "argon", "argon2id"
'driver' => 'bcrypt',
// Bcrypt Options
// Here you may specify the configuration options that should be used when
// 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),
],
// Argon Options
// Here you may specify the configuration options that should be used when
// passwords are hashed using the Argon algorithm. These will allow you
// to control the amount of time it takes to hash the given password.
'argon' => [
'memory' => 1024,
'threads' => 2,
'time' => 2,
],
];

82
app/Config/logging.php Normal file
View File

@@ -0,0 +1,82 @@
<?php
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
/**
* Logging 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.
*/
return [
// Default Log Channel
// This option defines the default log channel that gets used when writing
// messages to the logs. The name specified in this option should match
// one of the channels defined in the "channels" configuration array.
'default' => env('LOG_CHANNEL', 'single'),
// Log Channels
// Here you may configure the log channels for your application. Out of
// the box, Laravel uses the Monolog PHP logging library. This gives
// you a variety of powerful log handlers / formatters to utilize.
// Available Drivers: "single", "daily", "slack", "syslog",
// "errorlog", "monolog",
// "custom", "stack"
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => ['daily'],
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 14,
],
'daily' => [
'driver' => 'daily',
'path' => storage_path('logs/laravel.log'),
'level' => 'debug',
'days' => 7,
],
'slack' => [
'driver' => 'slack',
'url' => env('LOG_SLACK_WEBHOOK_URL'),
'username' => 'Laravel Log',
'emoji' => ':boom:',
'level' => 'critical',
],
'stderr' => [
'driver' => 'monolog',
'handler' => StreamHandler::class,
'with' => [
'stream' => 'php://stderr',
],
],
'syslog' => [
'driver' => 'syslog',
'level' => 'debug',
],
'errorlog' => [
'driver' => 'errorlog',
'level' => 'debug',
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
],
];

View File

@@ -23,7 +23,7 @@ return [
// Global "From" address & name
'from' => [
'address' => env('MAIL_FROM', 'mail@bookstackapp.com'),
'name' => env('MAIL_FROM_NAME','BookStack')
'name' => env('MAIL_FROM_NAME', 'BookStack')
],
// Email encryption protocol
@@ -46,4 +46,10 @@ return [
],
],
// Log Channel
// If you are using the "log" driver, you may specify the logging channel
// if you prefer to keep mail messages separate from other log entries
// for simpler reading. Otherwise, the default channel will be used.
'log_channel' => env('MAIL_LOG_CHANNEL'),
];

View File

@@ -12,11 +12,12 @@ return [
// Default driver to use for the queue
// Options: null, sync, redis
'default' => env('QUEUE_DRIVER', 'sync'),
'default' => env('QUEUE_CONNECTION', 'sync'),
// Queue connection configuration
'connections' => [
'sync' => [
'driver' => 'sync',
],
@@ -25,38 +26,15 @@ return [
'driver' => 'database',
'table' => 'jobs',
'queue' => 'default',
'expire' => 60,
],
'beanstalkd' => [
'driver' => 'beanstalkd',
'host' => 'localhost',
'queue' => 'default',
'ttr' => 60,
],
'sqs' => [
'driver' => 'sqs',
'key' => 'your-public-key',
'secret' => 'your-secret-key',
'queue' => 'your-queue-url',
'region' => 'us-east-1',
],
'iron' => [
'driver' => 'iron',
'host' => 'mq-aws-us-east-1.iron.io',
'token' => 'your-token',
'project' => 'your-project-id',
'queue' => 'your-queue-name',
'encrypt' => true,
'retry_after' => 90,
],
'redis' => [
'driver' => 'redis',
'connection' => 'default',
'queue' => 'default',
'expire' => 60,
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
],
],

144
app/Config/saml2.php Normal file
View File

@@ -0,0 +1,144 @@
<?php
return [
// Display name, shown to users, for SAML2 option
'name' => env('SAML2_NAME', 'SSO'),
// Dump user details after a login request for debugging purposes
'dump_user_details' => env('SAML2_DUMP_USER_DETAILS', false),
// Attribute, within a SAML response, to find the user's email address
'email_attribute' => env('SAML2_EMAIL_ATTRIBUTE', 'email'),
// Attribute, within a SAML response, to find the user's display name
'display_name_attributes' => explode('|', env('SAML2_DISPLAY_NAME_ATTRIBUTES', 'username')),
// Attribute, within a SAML response, to use to connect a BookStack user to the SAML user.
'external_id_attribute' => env('SAML2_EXTERNAL_ID_ATTRIBUTE', null),
// Group sync options
// Enable syncing, upon login, of SAML2 groups to BookStack groups
'user_to_groups' => env('SAML2_USER_TO_GROUPS', false),
// Attribute, within a SAML response, to find group names on
'group_attribute' => env('SAML2_GROUP_ATTRIBUTE', 'group'),
// When syncing groups, remove any groups that no longer match. Otherwise sync only adds new groups.
'remove_from_groups' => env('SAML2_REMOVE_FROM_GROUPS', false),
// Autoload IDP details from the metadata endpoint
'autoload_from_metadata' => env('SAML2_AUTOLOAD_METADATA', false),
// Overrides, in JSON format, to the configuration passed to underlying onelogin library.
'onelogin_overrides' => env('SAML2_ONELOGIN_OVERRIDES', null),
'onelogin' => [
// If 'strict' is True, then the PHP Toolkit will reject unsigned
// or unencrypted messages if it expects them signed or encrypted
// Also will reject the messages if not strictly follow the SAML
// standard: Destination, NameId, Conditions ... are validated too.
'strict' => true,
// Enable debug mode (to print errors)
'debug' => env('APP_DEBUG', false),
// Set a BaseURL to be used instead of try to guess
// the BaseURL of the view that process the SAML Message.
// Ex. http://sp.example.com/
// http://example.com/sp/
'baseurl' => null,
// Service Provider Data that we are deploying
'sp' => [
// Identifier of the SP entity (must be a URI)
'entityId' => '',
// Specifies info about where and how the <AuthnResponse> message MUST be
// returned to the requester, in this case our SP.
'assertionConsumerService' => [
// URL Location where the <Response> from the IdP will be returned
'url' => '',
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-POST binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST',
],
// Specifies info about where and how the <Logout Response> message MUST be
// returned to the requester, in this case our SP.
'singleLogoutService' => [
// URL Location where the <Response> from the IdP will be returned
'url' => '',
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
// Specifies constraints on the name identifier to be used to
// represent the requested subject.
// Take a look on lib/Saml2/Constants.php to see the NameIdFormat supported
'NameIDFormat' => 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress',
// Usually x509cert and privateKey of the SP are provided by files placed at
// the certs folder. But we can also provide them with the following parameters
'x509cert' => '',
'privateKey' => '',
],
// Identity Provider Data that we want connect with our SP
'idp' => [
// Identifier of the IdP entity (must be a URI)
'entityId' => env('SAML2_IDP_ENTITYID', null),
// SSO endpoint info of the IdP. (Authentication Request protocol)
'singleSignOnService' => [
// URL Target of the IdP where the SP will send the Authentication Request Message
'url' => env('SAML2_IDP_SSO', null),
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
// SLO endpoint info of the IdP.
'singleLogoutService' => [
// URL Location of the IdP where the SP will send the SLO Request
'url' => env('SAML2_IDP_SLO', null),
// URL location of the IdP where the SP will send the SLO Response (ResponseLocation)
// if not set, url for the SLO Request will be used
'responseUrl' => '',
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only
'binding' => 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect',
],
// Public x509 certificate of the IdP
'x509cert' => env('SAML2_IDP_x509', null),
/*
* Instead of use the whole x509cert you can use a fingerprint in
* order to validate the SAMLResponse, but we don't recommend to use
* that method on production since is exploitable by a collision
* attack.
* (openssl x509 -noout -fingerprint -in "idp.crt" to generate it,
* or add for example the -sha256 , -sha384 or -sha512 parameter)
*
* If a fingerprint is provided, then the certFingerprintAlgorithm is required in order to
* let the toolkit know which Algorithm was used. Possible values: sha1, sha256, sha384 or sha512
* 'sha1' is the default value.
*/
// 'certFingerprint' => '',
// 'certFingerprintAlgorithm' => 'sha1',
/* In some scenarios the IdP uses different certificates for
* signing/encryption, or is under key rollover phase and more
* than one certificate is published on IdP metadata.
* In order to handle that the toolkit offers that parameter.
* (when used, 'x509cert' and 'certFingerprint' values are
* ignored).
*/
// 'x509certMulti' => array(
// 'signing' => array(
// 0 => '<cert1-string>',
// ),
// 'encryption' => array(
// 0 => '<cert2-string>',
// )
// ),
],
],
];

View File

@@ -22,23 +22,6 @@ return [
// Callback URL for social authentication methods
'callback_url' => env('APP_URL', false),
'mailgun' => [
'domain' => '',
'secret' => '',
],
'ses' => [
'key' => '',
'secret' => '',
'region' => 'us-east-1',
],
'stripe' => [
'model' => \BookStack\Auth\User::class,
'key' => '',
'secret' => '',
],
'github' => [
'client_id' => env('GITHUB_APP_ID', false),
'client_secret' => env('GITHUB_APP_SECRET', false),
@@ -98,8 +81,8 @@ return [
'okta' => [
'client_id' => env('OKTA_APP_ID'),
'client_secret' => env('OKTA_APP_SECRET'),
'redirect' => env('APP_URL') . '/login/service/okta/callback',
'base_url' => env('OKTA_BASE_URL'),
'redirect' => env('APP_URL') . '/login/service/okta/callback',
'base_url' => env('OKTA_BASE_URL'),
'name' => 'Okta',
'auto_register' => env('OKTA_AUTO_REGISTER', false),
'auto_confirm' => env('OKTA_AUTO_CONFIRM_EMAIL', false),
@@ -135,18 +118,20 @@ return [
'ldap' => [
'server' => env('LDAP_SERVER', false),
'dump_user_details' => env('LDAP_DUMP_USER_DETAILS', false),
'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}))'),
'version' => env('LDAP_VERSION', false),
'id_attribute' => env('LDAP_ID_ATTRIBUTE', 'uid'),
'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'),
'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'),
'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false),
'user_to_groups' => env('LDAP_USER_TO_GROUPS',false),
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS',false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
]
'user_to_groups' => env('LDAP_USER_TO_GROUPS', false),
'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
'tls_insecure' => env('LDAP_TLS_INSECURE', false),
],
];

View File

@@ -35,13 +35,18 @@ return [
// Session database table, if database driver is in use
'table' => 'sessions',
// Session Cache Store
// When using the "apc" or "memcached" session drivers, you may specify a
// cache store that should be used for these sessions. This value must
// correspond with one of the application's configured cache stores.
'store' => null,
// Session Sweeping Lottery
// Some session drivers must manually sweep their storage location to get
// rid of old sessions from storage. Here are the chances that it will
// happen on a given request. By default, the odds are 2 out of 100.
'lottery' => [2, 100],
// Session Cookie Name
// Here you may change the name of the cookie used to identify a session
// instance by ID. The name specified here will get used every time a

View File

@@ -16,7 +16,12 @@ return [
'app-editor' => 'wysiwyg',
'app-color' => '#206ea7',
'app-color-light' => 'rgba(32,110,167,0.15)',
'bookshelf-color' => '#a94747',
'book-color' => '#077b70',
'chapter-color' => '#af4d0d',
'page-color' => '#206ea7',
'page-draft-color' => '#7e50b1',
'app-custom-head' => false,
'registration-enabled' => false,
];
];

View File

@@ -13,7 +13,9 @@ return [
'enabled' => true,
'binary' => file_exists(base_path('wkhtmltopdf')) ? base_path('wkhtmltopdf') : env('WKHTMLTOPDF', false),
'timeout' => false,
'options' => [],
'options' => [
'outline' => true
],
'env' => [],
],
'image' => [

View File

@@ -18,7 +18,7 @@ class ClearViews extends Command
*
* @var string
*/
protected $description = 'Clear all view-counts for all entities.';
protected $description = 'Clear all view-counts for all entities';
/**
* Create a new command instance.

View File

@@ -0,0 +1,88 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Repos\BookshelfRepo;
use Illuminate\Console\Command;
class CopyShelfPermissions extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:copy-shelf-permissions
{--a|all : Perform for all shelves in the system}
{--s|slug= : The slug for a shelf to target}
';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Copy shelf permissions to all child books';
/**
* @var BookshelfRepo
*/
protected $bookshelfRepo;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(BookshelfRepo $repo)
{
$this->bookshelfRepo = $repo;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$shelfSlug = $this->option('slug');
$cascadeAll = $this->option('all');
$shelves = null;
if (!$cascadeAll && !$shelfSlug) {
$this->error('Either a --slug or --all option must be provided.');
return;
}
if ($cascadeAll) {
$continue = $this->confirm(
'Permission settings for all shelves will be cascaded. '.
'Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. '.
'Are you sure you want to proceed?'
);
if (!$continue && !$this->hasOption('no-interaction')) {
return;
}
$shelves = Bookshelf::query()->get(['id', 'restricted']);
}
if ($shelfSlug) {
$shelves = Bookshelf::query()->where('slug', '=', $shelfSlug)->get(['id', 'restricted']);
if ($shelves->count() === 0) {
$this->info('No shelves found with the given slug.');
}
}
foreach ($shelves as $shelf) {
$this->bookshelfRepo->copyDownPermissions($shelf, false);
$this->info('Copied permissions for shelf [' . $shelf->id . ']');
}
$this->info('Permissions copied for ' . $shelves->count() . ' shelves.');
}
}

View File

@@ -25,7 +25,7 @@ class DeleteUsers extends Command
*
* @var string
*/
protected $description = 'Delete users that are not "admin" or system users.';
protected $description = 'Delete users that are not "admin" or system users';
public function __construct(User $user, UserRepo $userRepo)
{

View File

@@ -0,0 +1,61 @@
<?php
namespace BookStack\Console\Commands;
use BookStack\Actions\Comment;
use BookStack\Actions\CommentRepo;
use Illuminate\Console\Command;
class RegenerateCommentContent extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:regenerate-comment-content {--database= : The database connection to use.}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Regenerate the stored HTML of all comments';
/**
* @var CommentRepo
*/
protected $commentRepo;
/**
* Create a new command instance.
*/
public function __construct(CommentRepo $commentRepo)
{
$this->commentRepo = $commentRepo;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$connection = \DB::getDefaultConnection();
if ($this->option('database') !== null) {
\DB::setDefaultConnection($this->option('database'));
}
Comment::query()->chunk(100, function ($comments) {
foreach ($comments as $comment) {
$comment->html = $this->commentRepo->commentToHtml($comment->text);
$comment->save();
}
});
\DB::setDefaultConnection($connection);
$this->comment('Comment HTML content has been regenerated');
}
}

View File

@@ -30,8 +30,6 @@ class RegeneratePermissions extends Command
/**
* Create a new command instance.
*
* @param \BookStack\Auth\\BookStack\Auth\Permissions\PermissionService $permissionService
*/
public function __construct(PermissionService $permissionService)
{

View File

@@ -3,6 +3,7 @@
namespace BookStack\Console\Commands;
use BookStack\Entities\SearchService;
use DB;
use Illuminate\Console\Command;
class RegenerateSearch extends Command
@@ -26,7 +27,7 @@ class RegenerateSearch extends Command
/**
* Create a new command instance.
*
* @param \BookStack\Entities\SearchService $searchService
* @param SearchService $searchService
*/
public function __construct(SearchService $searchService)
{
@@ -41,14 +42,14 @@ class RegenerateSearch extends Command
*/
public function handle()
{
$connection = \DB::getDefaultConnection();
$connection = DB::getDefaultConnection();
if ($this->option('database') !== null) {
\DB::setDefaultConnection($this->option('database'));
$this->searchService->setConnection(\DB::connection($this->option('database')));
DB::setDefaultConnection($this->option('database'));
$this->searchService->setConnection(DB::connection($this->option('database')));
}
$this->searchService->indexAllEntities();
\DB::setDefaultConnection($connection);
DB::setDefaultConnection($connection);
$this->comment('Search index regenerated');
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace BookStack\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
class UpdateUrl extends Command
{
/**
* The name and signature of the console command.
*
* @var string
*/
protected $signature = 'bookstack:update-url
{oldUrl : URL to replace}
{newUrl : URL to use as the replacement}';
/**
* The console command description.
*
* @var string
*/
protected $description = 'Find and replace the given URLs in your BookStack database';
protected $db;
/**
* Create a new command instance.
*
* @return void
*/
public function __construct(Connection $db)
{
$this->db = $db;
parent::__construct();
}
/**
* Execute the console command.
*
* @return mixed
*/
public function handle()
{
$oldUrl = str_replace("'", '', $this->argument('oldUrl'));
$newUrl = str_replace("'", '', $this->argument('newUrl'));
$urlPattern = '/https?:\/\/(.+)/';
if (!preg_match($urlPattern, $oldUrl) || !preg_match($urlPattern, $newUrl)) {
$this->error("The given urls are expected to be full urls starting with http:// or https://");
return 1;
}
if (!$this->checkUserOkayToProceed($oldUrl, $newUrl)) {
return 1;
}
$columnsToUpdateByTable = [
"attachments" => ["path"],
"pages" => ["html", "text", "markdown"],
"images" => ["url"],
"comments" => ["html", "text"],
];
foreach ($columnsToUpdateByTable as $table => $columns) {
foreach ($columns as $column) {
$changeCount = $this->db->table($table)->update([
$column => $this->db->raw("REPLACE({$column}, '{$oldUrl}', '{$newUrl}')")
]);
$this->info("Updated {$changeCount} rows in {$table}->{$column}");
}
}
$this->info("URL update procedure complete.");
return 0;
}
/**
* Warn the user of the dangers of this operation.
* Returns a boolean indicating if they've accepted the warnings.
*/
protected function checkUserOkayToProceed(string $oldUrl, string $newUrl): bool
{
$dangerWarning = "This will search for \"{$oldUrl}\" in your database and replace it with \"{$newUrl}\".\n";
$dangerWarning .= "Are you sure you want to proceed?";
$backupConfirmation = "This operation could cause issues if used incorrectly. Have you made a backup of your existing database?";
return $this->confirm($dangerWarning) && $this->confirm($backupConfirmation);
}
}

View File

@@ -1,21 +1,25 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Image;
use Exception;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Support\Collection;
class Book extends Entity
/**
* Class Book
* @property string $description
* @property int $image_id
* @property Image|null $cover
* @package BookStack\Entities
*/
class Book extends Entity implements HasCoverImage
{
public $searchFactor = 2;
protected $fillable = ['name', 'description', 'image_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Book';
}
protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot'];
/**
* Get the url for this book.
@@ -45,7 +49,7 @@ class Book extends Entity
try {
$cover = $this->cover ? url($this->cover->getThumb($width, $height, false)) : $default;
} catch (\Exception $err) {
} catch (Exception $err) {
$cover = $default;
}
return $cover;
@@ -53,16 +57,23 @@ class Book extends Entity
/**
* Get the cover image of the book
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function cover()
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_book';
}
/**
* Get all pages within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function pages()
{
@@ -71,7 +82,7 @@ class Book extends Entity
/**
* Get the direct child pages of this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function directPages()
{
@@ -80,7 +91,7 @@ class Book extends Entity
/**
* Get all chapters within this book.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function chapters()
{
@@ -89,13 +100,24 @@ class Book extends Entity
/**
* Get the shelves this book is contained within.
* @return \Illuminate\Database\Eloquent\Relations\BelongsToMany
* @return BelongsToMany
*/
public function shelves()
{
return $this->belongsToMany(Bookshelf::class, 'bookshelves_books', 'book_id', 'bookshelf_id');
}
/**
* Get the direct child items within this book.
* @return Collection
*/
public function getDirectChildren(): Collection
{
$pages = $this->directPages()->visible()->get();
$chapters = $this->chapters()->visible()->get();
return $pages->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
/**
* Get an excerpt of this book's description to the specified length or less.
* @param int $length
@@ -106,13 +128,4 @@ class Book extends Entity
$description = $this->description;
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\Book' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
}

View File

@@ -0,0 +1,60 @@
<?php namespace BookStack\Entities;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Class BookChild
* @property int $book_id
* @property int $priority
* @property Book $book
* @method Builder whereSlugs(string $bookSlug, string $childSlug)
*/
class BookChild extends Entity
{
/**
* Scope a query to find items where the the child has the given childSlug
* where its parent has the bookSlug.
*/
public function scopeWhereSlugs(Builder $query, string $bookSlug, string $childSlug)
{
return $query->with('book')
->whereHas('book', function (Builder $query) use ($bookSlug) {
$query->where('slug', '=', $bookSlug);
})
->where('slug', '=', $childSlug);
}
/**
* Get the book this page sits in.
* @return BelongsTo
*/
public function book(): BelongsTo
{
return $this->belongsTo(Book::class);
}
/**
* Change the book that this entity belongs to.
*/
public function changeBook(int $newBookId): Entity
{
$this->book_id = $newBookId;
$this->refreshSlug();
$this->save();
$this->refresh();
// Update related activity
$this->activity()->update(['book_id' => $newBookId]);
// Update all child pages if a chapter
if ($this instanceof Chapter) {
foreach ($this->pages as $page) {
$page->changeBook($newBookId);
}
}
return $this;
}
}

View File

@@ -1,8 +1,10 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Image;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Bookshelf extends Entity
class Bookshelf extends Entity implements HasCoverImage
{
protected $table = 'bookshelves';
@@ -10,14 +12,7 @@ class Bookshelf extends Entity
protected $fillable = ['name', 'description', 'image_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Bookshelf';
}
protected $hidden = ['restricted'];
/**
* Get the books in this shelf.
@@ -31,6 +26,14 @@ class Bookshelf extends Entity
->orderBy('order', 'asc');
}
/**
* Related books that are visible to the current user.
*/
public function visibleBooks(): BelongsToMany
{
return $this->books()->visible();
}
/**
* Get the url for this bookshelf.
* @param string|bool $path
@@ -68,13 +71,20 @@ class Bookshelf extends Entity
/**
* Get the cover image of the shelf
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function cover()
public function cover(): BelongsTo
{
return $this->belongsTo(Image::class, 'image_id');
}
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string
{
return 'cover_shelf';
}
/**
* Get an excerpt of this book's description to the specified length or less.
* @param int $length
@@ -86,22 +96,27 @@ class Bookshelf extends Entity
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\BookShelf' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text,'' as html, '0' as book_id, '0' as priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
/**
* Check if this shelf contains the given book.
* @param Book $book
* @return bool
*/
public function contains(Book $book)
public function contains(Book $book): bool
{
return $this->books()->where('id', '=', $book->id)->count() > 0;
}
/**
* Add a book to the end of this shelf.
* @param Book $book
*/
public function appendBook(Book $book)
{
if ($this->contains($book)) {
return;
}
$maxOrder = $this->books()->max('order');
$this->books()->attach($book->id, ['order' => $maxOrder + 1]);
}
}

View File

@@ -1,5 +1,6 @@
<?php namespace BookStack\Entities;
use BookStack\Entities\Managers\EntityContext;
use Illuminate\View\View;
class BreadcrumbsViewComposer
@@ -9,9 +10,9 @@ class BreadcrumbsViewComposer
/**
* BreadcrumbsViewComposer constructor.
* @param EntityContextManager $entityContextManager
* @param EntityContext $entityContextManager
*/
public function __construct(EntityContextManager $entityContextManager)
public function __construct(EntityContext $entityContextManager)
{
$this->entityContextManager = $entityContextManager;
}
@@ -23,8 +24,9 @@ class BreadcrumbsViewComposer
public function compose(View $view)
{
$crumbs = $view->getData()['crumbs'];
if (array_first($crumbs) instanceof Book) {
$shelf = $this->entityContextManager->getContextualShelfForBook(array_first($crumbs));
$firstCrumb = $crumbs[0] ?? null;
if ($firstCrumb instanceof Book) {
$shelf = $this->entityContextManager->getContextualShelfForBook($firstCrumb);
if ($shelf) {
array_unshift($crumbs, $shelf);
$view->with('crumbs', $crumbs);

View File

@@ -1,29 +1,18 @@
<?php namespace BookStack\Entities;
class Chapter extends Entity
use Illuminate\Support\Collection;
/**
* Class Chapter
* @property Collection<Page> $pages
* @package BookStack\Entities
*/
class Chapter extends BookChild
{
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
/**
* Get the morph class for this model.
* @return string
*/
public function getMorphClass()
{
return 'BookStack\\Chapter';
}
/**
* Get the book this chapter is within.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function book()
{
return $this->belongsTo(Book::class);
}
/**
* Get the pages that this chapter contains.
* @param string $dir
@@ -62,15 +51,6 @@ class Chapter extends Entity
return mb_strlen($description) > $length ? mb_substr($description, 0, $length-3) . '...' : $description;
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return "'BookStack\\\\Chapter' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, '' as html, book_id, priority, '0' as chapter_id, '0' as draft, created_by, updated_by, updated_at, created_at";
}
/**
* Check if this chapter has any child pages.
* @return bool
@@ -79,4 +59,15 @@ class Chapter extends Entity
{
return count($this->pages) > 0;
}
/**
* Get the visible pages in this chapter.
*/
public function getVisiblePages(): Collection
{
return $this->pages()->visible()
->orderBy('draft', 'desc')
->orderBy('priority', 'asc')
->get();
}
}

View File

@@ -6,8 +6,11 @@ use BookStack\Actions\Tag;
use BookStack\Actions\View;
use BookStack\Auth\Permissions\EntityPermission;
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Facades\Permissions;
use BookStack\Ownable;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\MorphMany;
/**
@@ -15,7 +18,7 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
* The base class for book-like items such as pages, chapters & books.
* This is not a database model in itself but extended.
*
* @property integer $id
* @property int $id
* @property string $name
* @property string $slug
* @property Carbon $created_at
@@ -23,6 +26,11 @@ use Illuminate\Database\Eloquent\Relations\MorphMany;
* @property int $created_by
* @property int $updated_by
* @property boolean $restricted
* @property Collection $tags
* @method static Entity|Builder visible()
* @method static Entity|Builder hasPermission(string $permission)
* @method static Builder withLastView()
* @method static Builder withViewCount()
*
* @package BookStack\Entities
*/
@@ -40,14 +48,45 @@ class Entity extends Ownable
public $searchFactor = 1.0;
/**
* Get the morph class for this model.
* Set here since, due to folder changes, the namespace used
* in the database no longer matches the class namespace.
* @return string
* Get the entities that are visible to the current user.
*/
public function getMorphClass()
public function scopeVisible(Builder $query)
{
return 'BookStack\\Entity';
return $this->scopeHasPermission($query, 'view');
}
/**
* Scope the query to those entities that the current user has the given permission for.
*/
public function scopeHasPermission(Builder $query, string $permission)
{
return Permissions::restrictEntityQuery($query, $permission);
}
/**
* Query scope to get the last view from the current user.
*/
public function scopeWithLastView(Builder $query)
{
$viewedAtQuery = View::query()->select('updated_at')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())
->where('user_id', '=', user()->id)
->take(1);
return $query->addSelect(['last_viewed_at' => $viewedAtQuery]);
}
/**
* Query scope to get the total view count of the entities.
*/
public function scopeWithViewCount(Builder $query)
{
$viewCountQuery = View::query()->selectRaw('SUM(views) as view_count')
->whereColumn('viewable_id', '=', $this->getTable() . '.id')
->where('viewable_type', '=', $this->getMorphClass())->take(1);
$query->addSelect(['view_count' => $viewCountQuery]);
}
/**
@@ -87,11 +126,12 @@ class Entity extends Ownable
/**
* Gets the activity objects for this entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function activity()
{
return $this->morphMany(Activity::class, 'entity')->orderBy('created_at', 'desc');
return $this->morphMany(Activity::class, 'entity')
->orderBy('created_at', 'desc');
}
/**
@@ -102,14 +142,9 @@ class Entity extends Ownable
return $this->morphMany(View::class, 'viewable');
}
public function viewCountQuery()
{
return $this->views()->selectRaw('viewable_id, sum(views) as view_count')->groupBy('viewable_id');
}
/**
* Get the Tag models that have been user assigned to this entity.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function tags()
{
@@ -129,7 +164,7 @@ class Entity extends Ownable
/**
* Get the related search terms.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function searchTerms()
{
@@ -158,7 +193,7 @@ class Entity extends Ownable
/**
* Get the entity jointPermissions this is connected to.
* @return \Illuminate\Database\Eloquent\Relations\MorphMany
* @return MorphMany
*/
public function jointPermissions()
{
@@ -237,15 +272,6 @@ class Entity extends Ownable
return trim($text);
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @return string
*/
public function entityRawQuery()
{
return '';
}
/**
* Get the url of this entity
* @param $path
@@ -255,4 +281,32 @@ class Entity extends Ownable
{
return $path;
}
/**
* Rebuild the permissions for this entity.
*/
public function rebuildPermissions()
{
/** @noinspection PhpUnhandledExceptionInspection */
Permissions::buildJointPermissionsForEntity($this);
}
/**
* Index the current entity for search
*/
public function indexForSearch()
{
$searchService = app()->make(SearchService::class);
$searchService->indexEntity($this);
}
/**
* Generate and set a new URL slug for this model.
*/
public function refreshSlug(): string
{
$generator = new SlugGenerator($this);
$this->slug = $generator->generate();
return $this->slug;
}
}

View File

@@ -39,11 +39,6 @@ class EntityProvider
/**
* EntityProvider constructor.
* @param Bookshelf $bookshelf
* @param Book $book
* @param Chapter $chapter
* @param Page $page
* @param PageRevision $pageRevision
*/
public function __construct(
Bookshelf $bookshelf,
@@ -62,9 +57,8 @@ class EntityProvider
/**
* Fetch all core entity types as an associated array
* with their basic names as the keys.
* @return Entity[]
*/
public function all()
public function all(): array
{
return [
'bookshelf' => $this->bookshelf,
@@ -76,10 +70,8 @@ class EntityProvider
/**
* Get an entity instance by it's basic name.
* @param string $type
* @return Entity
*/
public function get(string $type)
public function get(string $type): Entity
{
$type = strtolower($type);
return $this->all()[$type];
@@ -87,15 +79,9 @@ class EntityProvider
/**
* Get the morph classes, as an array, for a single or multiple types.
* @param string|array $types
* @return array<string>
*/
public function getMorphClasses($types)
public function getMorphClasses(array $types): array
{
if (is_string($types)) {
$types = [$types];
}
$morphClasses = [];
foreach ($types as $type) {
$model = $this->get($type);

View File

@@ -1,152 +1,145 @@
<?php namespace BookStack\Entities;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Managers\PageContent;
use BookStack\Uploads\ImageService;
use DomPDF;
use Exception;
use SnappyPDF;
use Throwable;
class ExportService
{
protected $entityRepo;
protected $imageService;
/**
* ExportService constructor.
* @param EntityRepo $entityRepo
* @param ImageService $imageService
*/
public function __construct(EntityRepo $entityRepo, ImageService $imageService)
public function __construct(ImageService $imageService)
{
$this->entityRepo = $entityRepo;
$this->imageService = $imageService;
}
/**
* Convert a page to a self-contained HTML file.
* Includes required CSS & image content. Images are base64 encoded into the HTML.
* @param \BookStack\Entities\Page $page
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function pageToContainedHtml(Page $page)
{
$this->entityRepo->renderPage($page);
$pageHtml = view('pages/export', [
'page' => $page
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages.export', [
'page' => $page,
'format' => 'html',
])->render();
return $this->containHtml($pageHtml);
}
/**
* Convert a chapter to a self-contained HTML file.
* @param \BookStack\Entities\Chapter $chapter
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function chapterToContainedHtml(Chapter $chapter)
{
$pages = $this->entityRepo->getChapterChildren($chapter);
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
$page->html = $this->entityRepo->renderPage($page);
$page->html = (new PageContent($page))->render();
});
$html = view('chapters/export', [
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages
'pages' => $pages,
'format' => 'html',
])->render();
return $this->containHtml($html);
}
/**
* Convert a book to a self-contained HTML file.
* @param Book $book
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function bookToContainedHtml(Book $book)
{
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$html = view('books/export', [
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books.export', [
'book' => $book,
'bookChildren' => $bookTree
'bookChildren' => $bookTree,
'format' => 'html',
])->render();
return $this->containHtml($html);
}
/**
* Convert a page to a PDF file.
* @param Page $page
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function pageToPdf(Page $page)
{
$this->entityRepo->renderPage($page);
$html = view('pages/pdf', [
'page' => $page
$page->html = (new PageContent($page))->render();
$html = view('pages.export', [
'page' => $page,
'format' => 'pdf',
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert a chapter to a PDF file.
* @param \BookStack\Entities\Chapter $chapter
* @return mixed|string
* @throws \Throwable
* @throws Throwable
*/
public function chapterToPdf(Chapter $chapter)
{
$pages = $this->entityRepo->getChapterChildren($chapter);
$pages = $chapter->getVisiblePages();
$pages->each(function ($page) {
$page->html = $this->entityRepo->renderPage($page);
$page->html = (new PageContent($page))->render();
});
$html = view('chapters/export', [
$html = view('chapters.export', [
'chapter' => $chapter,
'pages' => $pages
'pages' => $pages,
'format' => 'pdf',
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert a book to a PDF file
* @param \BookStack\Entities\Book $book
* @return string
* @throws \Throwable
* Convert a book to a PDF file.
* @throws Throwable
*/
public function bookToPdf(Book $book)
{
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$html = view('books/export', [
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books.export', [
'book' => $book,
'bookChildren' => $bookTree
'bookChildren' => $bookTree,
'format' => 'pdf',
])->render();
return $this->htmlToPdf($html);
}
/**
* Convert normal webpage HTML to a PDF.
* @param $html
* @return string
* @throws \Exception
* Convert normal web-page HTML to a PDF.
* @throws Exception
*/
protected function htmlToPdf($html)
protected function htmlToPdf(string $html): string
{
$containedHtml = $this->containHtml($html);
$useWKHTML = config('snappy.pdf.binary') !== false;
if ($useWKHTML) {
$pdf = \SnappyPDF::loadHTML($containedHtml);
$pdf = SnappyPDF::loadHTML($containedHtml);
$pdf->setOption('print-media-type', true);
} else {
$pdf = \DomPDF::loadHTML($containedHtml);
$pdf = DomPDF::loadHTML($containedHtml);
}
return $pdf->output();
}
/**
* Bundle of the contents of a html file to be self-contained.
* @param $htmlContent
* @return mixed|string
* @throws \Exception
* @throws Exception
*/
protected function containHtml($htmlContent)
protected function containHtml(string $htmlContent): string
{
$imageTagsOutput = [];
preg_match_all("/\<img.*src\=(\'|\")(.*?)(\'|\").*?\>/i", $htmlContent, $imageTagsOutput);
@@ -188,12 +181,10 @@ class ExportService
/**
* Converts the page contents into simple plain text.
* This method filters any bad looking content to provide a nice final output.
* @param Page $page
* @return mixed
*/
public function pageToPlainText(Page $page)
public function pageToPlainText(Page $page): string
{
$html = $this->entityRepo->renderPage($page);
$html = (new PageContent($page))->render();
$text = strip_tags($html);
// Replace multiple spaces with single spaces
$text = preg_replace('/\ {2,}/', ' ', $text);
@@ -207,10 +198,8 @@ class ExportService
/**
* Convert a chapter into a plain text string.
* @param \BookStack\Entities\Chapter $chapter
* @return string
*/
public function chapterToPlainText(Chapter $chapter)
public function chapterToPlainText(Chapter $chapter): string
{
$text = $chapter->name . "\n\n";
$text .= $chapter->description . "\n\n";
@@ -222,12 +211,10 @@ class ExportService
/**
* Convert a book into a plain text string.
* @param Book $book
* @return string
*/
public function bookToPlainText(Book $book)
public function bookToPlainText(Book $book): string
{
$bookTree = $this->entityRepo->getBookChildren($book, true, true);
$bookTree = (new BookContents($book))->getTree(false, true);
$text = $book->name . "\n\n";
foreach ($bookTree as $bookChild) {
if ($bookChild->isA('chapter')) {

View File

@@ -0,0 +1,20 @@
<?php
namespace BookStack\Entities;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
interface HasCoverImage
{
/**
* Get the cover image for this item.
*/
public function cover(): BelongsTo;
/**
* Get the type of the image model that is used when storing a cover image.
*/
public function coverImageTypeKey(): string;
}

View File

@@ -0,0 +1,204 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Book;
use BookStack\Entities\BookChild;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\Page;
use BookStack\Exceptions\SortOperationException;
use Illuminate\Support\Collection;
class BookContents
{
/**
* @var Book
*/
protected $book;
/**
* BookContents constructor.
* @param $book
*/
public function __construct(Book $book)
{
$this->book = $book;
}
/**
* Get the current priority of the last item
* at the top-level of the book.
*/
public function getLastPriority(): int
{
$maxPage = Page::visible()->where('book_id', '=', $this->book->id)
->where('draft', '=', false)
->where('chapter_id', '=', 0)->max('priority');
$maxChapter = Chapter::visible()->where('book_id', '=', $this->book->id)
->max('priority');
return max($maxChapter, $maxPage, 1);
}
/**
* Get the contents as a sorted collection tree.
* TODO - Support $renderPages option
*/
public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
{
$pages = $this->getPages($showDrafts);
$chapters = Chapter::visible()->where('book_id', '=', $this->book->id)->get();
$all = collect()->concat($pages)->concat($chapters);
$chapterMap = $chapters->keyBy('id');
$lonePages = collect();
$pages->groupBy('chapter_id')->each(function ($pages, $chapter_id) use ($chapterMap, &$lonePages) {
$chapter = $chapterMap->get($chapter_id);
if ($chapter) {
$chapter->setAttribute('pages', collect($pages)->sortBy($this->bookChildSortFunc()));
} else {
$lonePages = $lonePages->concat($pages);
}
});
$all->each(function (Entity $entity) {
$entity->setRelation('book', $this->book);
});
return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());
}
/**
* Function for providing a sorting score for an entity in relation to the
* other items within the book.
*/
protected function bookChildSortFunc(): callable
{
return function (Entity $entity) {
if (isset($entity['draft']) && $entity['draft']) {
return -100;
}
return $entity['priority'] ?? 0;
};
}
/**
* Get the visible pages within this book.
*/
protected function getPages(bool $showDrafts = false): Collection
{
$query = Page::visible()->where('book_id', '=', $this->book->id);
if (!$showDrafts) {
$query->where('draft', '=', false);
}
return $query->get();
}
/**
* Sort the books content using the given map.
* The map is a single-dimension collection of objects in the following format:
* {
* +"id": "294" (ID of item)
* +"sort": 1 (Sort order index)
* +"parentChapter": false (ID of parent chapter, as string, or false)
* +"type": "page" (Entity type of item)
* +"book": "1" (Id of book to place item in)
* }
*
* Returns a list of books that were involved in the operation.
* @throws SortOperationException
*/
public function sortUsingMap(Collection $sortMap): Collection
{
// Load models into map
$this->loadModelsIntoSortMap($sortMap);
$booksInvolved = $this->getBooksInvolvedInSort($sortMap);
// Perform the sort
$sortMap->each(function ($mapItem) {
$this->applySortUpdates($mapItem);
});
// Update permissions and activity.
$booksInvolved->each(function (Book $book) {
$book->rebuildPermissions();
});
return $booksInvolved;
}
/**
* Using the given sort map item, detect changes for the related model
* and update it if required.
*/
protected function applySortUpdates(\stdClass $sortMapItem)
{
/** @var BookChild $model */
$model = $sortMapItem->model;
$priorityChanged = intval($model->priority) !== intval($sortMapItem->sort);
$bookChanged = intval($model->book_id) !== intval($sortMapItem->book);
$chapterChanged = ($sortMapItem->type === 'page') && intval($model->chapter_id) !== $sortMapItem->parentChapter;
if ($bookChanged) {
$model->changeBook($sortMapItem->book);
}
if ($chapterChanged) {
$model->chapter_id = intval($sortMapItem->parentChapter);
$model->save();
}
if ($priorityChanged) {
$model->priority = intval($sortMapItem->sort);
$model->save();
}
}
/**
* Load models from the database into the given sort map.
*/
protected function loadModelsIntoSortMap(Collection $sortMap): void
{
$keyMap = $sortMap->keyBy(function (\stdClass $sortMapItem) {
return $sortMapItem->type . ':' . $sortMapItem->id;
});
$pageIds = $sortMap->where('type', '=', 'page')->pluck('id');
$chapterIds = $sortMap->where('type', '=', 'chapter')->pluck('id');
$pages = Page::visible()->whereIn('id', $pageIds)->get();
$chapters = Chapter::visible()->whereIn('id', $chapterIds)->get();
foreach ($pages as $page) {
$sortItem = $keyMap->get('page:' . $page->id);
$sortItem->model = $page;
}
foreach ($chapters as $chapter) {
$sortItem = $keyMap->get('chapter:' . $chapter->id);
$sortItem->model = $chapter;
}
}
/**
* Get the books involved in a sort.
* The given sort map should have its models loaded first.
* @throws SortOperationException
*/
protected function getBooksInvolvedInSort(Collection $sortMap): Collection
{
$bookIdsInvolved = collect([$this->book->id]);
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('book'));
$bookIdsInvolved = $bookIdsInvolved->concat($sortMap->pluck('model.book_id'));
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
$books = Book::hasPermission('update')->whereIn('id', $bookIdsInvolved)->get();
if (count($books) !== count($bookIdsInvolved)) {
throw new SortOperationException("Could not find all books requested in sort operation");
}
return $books;
}
}

View File

@@ -1,44 +1,38 @@
<?php namespace BookStack\Entities;
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use Illuminate\Session\Store;
class EntityContextManager
class EntityContext
{
protected $session;
protected $entityRepo;
protected $KEY_SHELF_CONTEXT_ID = 'context_bookshelf_id';
/**
* EntityContextManager constructor.
* @param Store $session
* @param EntityRepo $entityRepo
*/
public function __construct(Store $session, EntityRepo $entityRepo)
public function __construct(Store $session)
{
$this->session = $session;
$this->entityRepo = $entityRepo;
}
/**
* Get the current bookshelf context for the given book.
* @param Book $book
* @return Bookshelf|null
*/
public function getContextualShelfForBook(Book $book)
public function getContextualShelfForBook(Book $book): ?Bookshelf
{
$contextBookshelfId = $this->session->get($this->KEY_SHELF_CONTEXT_ID, null);
if (is_int($contextBookshelfId)) {
/** @var Bookshelf $shelf */
$shelf = $this->entityRepo->getById('bookshelf', $contextBookshelfId);
if ($shelf && $shelf->contains($book)) {
return $shelf;
}
if (!is_int($contextBookshelfId)) {
return null;
}
return null;
$shelf = Bookshelf::visible()->find($contextBookshelfId);
$shelfContainsBook = $shelf && $shelf->contains($book);
return $shelfContainsBook ? $shelf : null;
}
/**

View File

@@ -0,0 +1,304 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Page;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use DOMXPath;
class PageContent
{
protected $page;
/**
* PageContent constructor.
*/
public function __construct(Page $page)
{
$this->page = $page;
}
/**
* Update the content of the page with new provided HTML.
*/
public function setNewHTML(string $html)
{
$this->page->html = $this->formatHtml($html);
$this->page->text = $this->toPlainText();
}
/**
* Formats a page's html to be tagged correctly within the system.
*/
protected function formatHtml(string $htmlText): string
{
if ($htmlText == '') {
return $htmlText;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
// Set ids on top-level nodes
$idMap = [];
foreach ($childNodes as $index => $childNode) {
$this->setUniqueId($childNode, $idMap);
}
// Ensure no duplicate ids within child items
$xPath = new DOMXPath($doc);
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
$this->setUniqueId($domElem, $idMap);
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
}
/**
* Set a unique id on the given DOMElement.
* A map for existing ID's should be passed in to check for current existence.
* @param DOMElement $element
* @param array $idMap
*/
protected function setUniqueId($element, array &$idMap)
{
if (get_class($element) !== 'DOMElement') {
return;
}
// Overwrite id if not a BookStack custom id
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return;
}
// Create an unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
$loopIndex++;
}
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
}
/**
* Get a plain-text visualisation of this page.
*/
protected function toPlainText(): string
{
$html = $this->render(true);
return strip_tags($html);
}
/**
* Render the page for viewing
*/
public function render(bool $blankIncludes = false) : string
{
$content = $this->page->html;
if (!config('app.allow_content_scripts')) {
$content = $this->escapeScripts($content);
}
if ($blankIncludes) {
$content = $this->blankPageIncludes($content);
} else {
$content = $this->parsePageIncludes($content);
}
return $content;
}
/**
* Parse the headers on the page to get a navigation menu
*/
public function getNavigation(string $htmlContent): array
{
if (empty($htmlContent)) {
return [];
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlContent, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
return $headers ? $this->headerNodesToLevelList($headers) : [];
}
/**
* Convert a DOMNodeList into an array of readable header attributes
* with levels normalised to the lower header level.
*/
protected function headerNodesToLevelList(DOMNodeList $nodeList): array
{
$tree = collect($nodeList)->map(function ($header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [
'nodeName' => strtolower($header->nodeName),
'level' => intval(str_replace('h', '', $header->nodeName)),
'link' => '#' . $header->getAttribute('id'),
'text' => $text,
];
})->filter(function ($header) {
return mb_strlen($header['text']) > 0;
});
// Shift headers if only smaller headers have been used
$levelChange = ($tree->pluck('level')->min() - 1);
$tree = $tree->map(function ($header) use ($levelChange) {
$header['level'] -= ($levelChange);
return $header;
});
return $tree->toArray();
}
/**
* Remove any page include tags within the given HTML.
*/
protected function blankPageIncludes(string $html) : string
{
return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
}
/**
* Parse any include tags "{{@<page_id>#section}}" to be part of the page.
*/
protected function parsePageIncludes(string $html) : string
{
$matches = [];
preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
foreach ($matches[1] as $index => $includeId) {
$fullMatch = $matches[0][$index];
$splitInclude = explode('#', $includeId, 2);
// Get page id from reference
$pageId = intval($splitInclude[0]);
if (is_nan($pageId)) {
continue;
}
// Find page and skip this if page not found
$matchedPage = Page::visible()->find($pageId);
if ($matchedPage === null) {
$html = str_replace($fullMatch, '', $html);
continue;
}
// If we only have page id, just insert all page html and continue.
if (count($splitInclude) === 1) {
$html = str_replace($fullMatch, $matchedPage->html, $html);
continue;
}
// Create and load HTML into a document
$innerContent = $this->fetchSectionOfPage($matchedPage, $splitInclude[1]);
$html = str_replace($fullMatch, trim($innerContent), $html);
}
return $html;
}
/**
* Fetch the content from a specific section of the given page.
*/
protected function fetchSectionOfPage(Page $page, string $sectionId): string
{
$topLevelTags = ['table', 'ul', 'ol'];
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML(mb_convert_encoding('<body>'.$page->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
// Search included content for the id given and blank out if not exists.
$matchingElem = $doc->getElementById($sectionId);
if ($matchingElem === null) {
return '';
}
// Otherwise replace the content with the found content
// Checks if the top-level wrapper should be included by matching on tag types
$innerContent = '';
$isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
if ($isTopLevel) {
$innerContent .= $doc->saveHTML($matchingElem);
} else {
foreach ($matchingElem->childNodes as $childNode) {
$innerContent .= $doc->saveHTML($childNode);
}
}
libxml_clear_errors();
return $innerContent;
}
/**
* Escape script tags within HTML content.
*/
protected function escapeScripts(string $html) : string
{
if (empty($html)) {
return $html;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
// Remove standard script tags
$scriptElems = $xPath->query('//script');
foreach ($scriptElems as $scriptElem) {
$scriptElem->parentNode->removeChild($scriptElem);
}
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
foreach ($badIframes as $badIframe) {
$badIframe->parentNode->removeChild($badIframe);
}
// Remove 'on*' attributes
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
foreach ($onAttributes as $attr) {
/** @var \DOMAttr $attr*/
$attrName = $attr->nodeName;
$attr->parentNode->removeAttribute($attrName);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
}
return $html;
}
}

View File

@@ -0,0 +1,74 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Page;
use BookStack\Entities\PageRevision;
use Carbon\Carbon;
use Illuminate\Database\Eloquent\Builder;
class PageEditActivity
{
protected $page;
/**
* PageEditActivity constructor.
*/
public function __construct(Page $page)
{
$this->page = $page;
}
/**
* Check if there's active editing being performed on this page.
* @return bool
*/
public function hasActiveEditing(): bool
{
return $this->activePageEditingQuery(60)->count() > 0;
}
/**
* Get a notification message concerning the editing activity on the page.
*/
public function activeEditingMessage(): string
{
$pageDraftEdits = $this->activePageEditingQuery(60)->get();
$count = $pageDraftEdits->count();
$userMessage = $count > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $count]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
$timeMessage = trans('entities.pages_draft_edit_active.time_b', ['minCount'=> 60]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}
/**
* Get the message to show when the user will be editing one of their drafts.
* @param PageRevision $draft
* @return string
*/
public function getEditingActiveDraftMessage(PageRevision $draft): string
{
$message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
return $message;
}
return $message . "\n" . trans('entities.pages_draft_edited_notification');
}
/**
* A query to check for active update drafts on a particular page
* within the last given many minutes.
*/
protected function activePageEditingQuery(int $withinMinutes): Builder
{
$checkTime = Carbon::now()->subMinutes($withinMinutes);
$query = PageRevision::query()
->where('type', '=', 'update_draft')
->where('page_id', '=', $this->page->id)
->where('updated_at', '>', $this->page->updated_at)
->where('created_by', '!=', user()->id)
->where('updated_at', '>=', $checkTime)
->with('createdBy');
return $query;
}
}

View File

@@ -0,0 +1,109 @@
<?php namespace BookStack\Entities\Managers;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\HasCoverImage;
use BookStack\Entities\Page;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use BookStack\Uploads\AttachmentService;
use BookStack\Uploads\ImageService;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
class TrashCan
{
/**
* Remove a bookshelf from the system.
* @throws Exception
*/
public function destroyShelf(Bookshelf $shelf)
{
$this->destroyCommonRelations($shelf);
$shelf->delete();
}
/**
* Remove a book from the system.
* @throws NotifyException
* @throws BindingResolutionException
*/
public function destroyBook(Book $book)
{
foreach ($book->pages as $page) {
$this->destroyPage($page);
}
foreach ($book->chapters as $chapter) {
$this->destroyChapter($chapter);
}
$this->destroyCommonRelations($book);
$book->delete();
}
/**
* Remove a page from the system.
* @throws NotifyException
*/
public function destroyPage(Page $page)
{
// Check if set as custom homepage & remove setting if not used or throw error if active
$customHome = setting('app-homepage', '0:');
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
if (setting('app-homepage-type') === 'page') {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
}
setting()->remove('app-homepage');
}
$this->destroyCommonRelations($page);
// Delete Attached Files
$attachmentService = app(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}
$page->delete();
}
/**
* Remove a chapter from the system.
* @throws Exception
*/
public function destroyChapter(Chapter $chapter)
{
if (count($chapter->pages) > 0) {
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
}
}
$this->destroyCommonRelations($chapter);
$chapter->delete();
}
/**
* Update entity relations to remove or update outstanding connections.
*/
protected function destroyCommonRelations(Entity $entity)
{
Activity::removeEntity($entity);
$entity->views()->delete();
$entity->permissions()->delete();
$entity->tags()->delete();
$entity->comments()->delete();
$entity->jointPermissions()->delete();
$entity->searchTerms()->delete();
if ($entity instanceof HasCoverImage && $entity->cover) {
$imageService = app()->make(ImageService::class);
$imageService->destroy($entity->cover);
}
}
}

View File

@@ -1,8 +1,25 @@
<?php namespace BookStack\Entities;
use BookStack\Uploads\Attachment;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Permissions;
class Page extends Entity
/**
* Class Page
* @property int $chapter_id
* @property string $html
* @property string $markdown
* @property string $text
* @property bool $template
* @property bool $draft
* @property int $revision_count
* @property Chapter $chapter
* @property Collection $attachments
*/
class Page extends BookChild
{
protected $fillable = ['name', 'html', 'priority', 'markdown'];
@@ -11,12 +28,12 @@ class Page extends Entity
public $textField = 'text';
/**
* Get the morph class for this model.
* @return string
* Get the entities that are visible to the current user.
*/
public function getMorphClass()
public function scopeVisible(Builder $query)
{
return 'BookStack\\Page';
$query = Permissions::enforceDraftVisiblityOnQuery($query);
return parent::scopeVisible($query);
}
/**
@@ -30,27 +47,17 @@ class Page extends Entity
return $array;
}
/**
* Get the book this page sits in.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function book()
{
return $this->belongsTo(Book::class);
}
/**
* Get the parent item
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function parent()
public function parent(): Entity
{
return $this->chapter_id ? $this->chapter() : $this->book();
return $this->chapter_id ? $this->chapter : $this->book;
}
/**
* Get the chapter that this page is in, If applicable.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
* @return BelongsTo
*/
public function chapter()
{
@@ -72,12 +79,12 @@ class Page extends Entity
*/
public function revisions()
{
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc');
return $this->hasMany(PageRevision::class)->where('type', '=', 'version')->orderBy('created_at', 'desc')->orderBy('id', 'desc');
}
/**
* Get the attachments assigned to this page.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
* @return HasMany
*/
public function attachments()
{
@@ -95,27 +102,17 @@ class Page extends Entity
$midText = $this->draft ? '/draft/' : '/page/';
$idComponent = $this->draft ? $this->id : urlencode($this->slug);
$url = '/books/' . urlencode($bookSlug) . $midText . $idComponent;
if ($path !== false) {
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent . '/' . trim($path, '/'));
$url .= '/' . trim($path, '/');
}
return url('/books/' . urlencode($bookSlug) . $midText . $idComponent);
}
/**
* Return a generalised, common raw query that can be 'unioned' across entities.
* @param bool $withContent
* @return string
*/
public function entityRawQuery($withContent = false)
{
$htmlQuery = $withContent ? 'html' : "'' as html";
return "'BookStack\\\\Page' as entity_type, id, id as entity_id, slug, name, {$this->textField} as text, {$htmlQuery}, book_id, priority, chapter_id, draft, created_by, updated_by, updated_at, created_at";
return url($url);
}
/**
* Get the current revision for the page if existing
* @return \BookStack\Entities\PageRevision|null
* @return PageRevision|null
*/
public function getCurrentRevision()
{

View File

@@ -2,7 +2,21 @@
use BookStack\Auth\User;
use BookStack\Model;
use Carbon\Carbon;
/**
* Class PageRevision
* @property int $page_id
* @property string $slug
* @property string $book_slug
* @property int $created_by
* @property Carbon $created_at
* @property string $type
* @property string $summary
* @property string $markdown
* @property string $html
* @property int $revision_number
*/
class PageRevision extends Model
{
protected $fillable = ['name', 'html', 'text', 'markdown', 'summary'];
@@ -41,13 +55,18 @@ class PageRevision extends Model
/**
* Get the previous revision for the same page if existing
* @return \BookStack\PageRevision|null
* @return \BookStack\Entities\PageRevision|null
*/
public function getPrevious()
{
if ($id = static::where('page_id', '=', $this->page_id)->where('id', '<', $this->id)->max('id')) {
return static::find($id);
$id = static::newQuery()->where('page_id', '=', $this->page_id)
->where('id', '<', $this->id)
->max('id');
if ($id) {
return static::query()->find($id);
}
return null;
}

View File

@@ -0,0 +1,119 @@
<?php
namespace BookStack\Entities\Repos;
use BookStack\Actions\TagRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Entity;
use BookStack\Entities\HasCoverImage;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BaseRepo
{
protected $tagRepo;
protected $imageRepo;
/**
* BaseRepo constructor.
* @param $tagRepo
*/
public function __construct(TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
}
/**
* Create a new entity in the system
*/
public function create(Entity $entity, array $input)
{
$entity->fill($input);
$entity->forceFill([
'created_by' => user()->id,
'updated_by' => user()->id,
]);
$entity->refreshSlug();
$entity->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
}
$entity->rebuildPermissions();
$entity->indexForSearch();
}
/**
* Update the given entity.
*/
public function update(Entity $entity, array $input)
{
$entity->fill($input);
$entity->updated_by = user()->id;
if ($entity->isDirty('name')) {
$entity->refreshSlug();
}
$entity->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entity, $input['tags']);
}
$entity->rebuildPermissions();
$entity->indexForSearch();
}
/**
* Update the given items' cover image, or clear it.
* @throws ImageUploadException
* @throws \Exception
*/
public function updateCoverImage(HasCoverImage $entity, ?UploadedFile $coverImage, bool $removeImage = false)
{
if ($coverImage) {
$this->imageRepo->destroyImage($entity->cover);
$image = $this->imageRepo->saveNew($coverImage, 'cover_book', $entity->id, 512, 512, true);
$entity->cover()->associate($image);
$entity->save();
}
if ($removeImage) {
$this->imageRepo->destroyImage($entity->cover);
$entity->image_id = 0;
$entity->save();
}
}
/**
* Update the permissions of an entity.
*/
public function updatePermissions(Entity $entity, bool $restricted, Collection $permissions = null)
{
$entity->restricted = $restricted;
$entity->permissions()->delete();
if (!is_null($permissions)) {
$entityPermissionData = $permissions->flatMap(function ($restrictions, $roleId) {
return collect($restrictions)->keys()->map(function ($action) use ($roleId) {
return [
'role_id' => $roleId,
'action' => strtolower($action),
] ;
});
});
$entity->permissions()->createMany($entityPermissionData);
}
$entity->save();
$entity->rebuildPermissions();
}
}

View File

@@ -0,0 +1,134 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Actions\TagRepo;
use BookStack\Entities\Book;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookRepo
{
protected $baseRepo;
protected $tagRepo;
protected $imageRepo;
/**
* BookRepo constructor.
* @param $tagRepo
*/
public function __construct(BaseRepo $baseRepo, TagRepo $tagRepo, ImageRepo $imageRepo)
{
$this->baseRepo = $baseRepo;
$this->tagRepo = $tagRepo;
$this->imageRepo = $imageRepo;
}
/**
* Get all books in a paginated format.
*/
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
{
return Book::visible()->orderBy($sort, $order)->paginate($count);
}
/**
* Get the books that were most recently viewed by this user.
*/
public function getRecentlyViewed(int $count = 20): Collection
{
return Book::visible()->withLastView()
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc')
->take($count)->get();
}
/**
* Get the most popular books in the system.
*/
public function getPopular(int $count = 20): Collection
{
return Book::visible()->withViewCount()
->having('view_count', '>', 0)
->orderBy('view_count', 'desc')
->take($count)->get();
}
/**
* Get the most recently created books from the system.
*/
public function getRecentlyCreated(int $count = 20): Collection
{
return Book::visible()->orderBy('created_at', 'desc')
->take($count)->get();
}
/**
* Get a book by its slug.
*/
public function getBySlug(string $slug): Book
{
$book = Book::visible()->where('slug', '=', $slug)->first();
if ($book === null) {
throw new NotFoundException(trans('errors.book_not_found'));
}
return $book;
}
/**
* Create a new book in the system
*/
public function create(array $input): Book
{
$book = new Book();
$this->baseRepo->create($book, $input);
return $book;
}
/**
* Update the given book.
*/
public function update(Book $book, array $input): Book
{
$this->baseRepo->update($book, $input);
return $book;
}
/**
* Update the given book's cover image, or clear it.
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Book $book, ?UploadedFile $coverImage, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($book, $coverImage, $removeImage);
}
/**
* Update the permissions of a book.
*/
public function updatePermissions(Book $book, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($book, $restricted, $permissions);
}
/**
* Remove a book from the system.
* @throws NotifyException
* @throws BindingResolutionException
*/
public function destroy(Book $book)
{
$trashCan = new TrashCan();
$trashCan->destroyBook($book);
}
}

View File

@@ -0,0 +1,179 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotFoundException;
use Exception;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Collection;
class BookshelfRepo
{
protected $baseRepo;
/**
* BookshelfRepo constructor.
* @param $baseRepo
*/
public function __construct(BaseRepo $baseRepo)
{
$this->baseRepo = $baseRepo;
}
/**
* Get all bookshelves in a paginated format.
*/
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
{
return Bookshelf::visible()
->with('visibleBooks')
->orderBy($sort, $order)
->paginate($count);
}
/**
* Get the bookshelves that were most recently viewed by this user.
*/
public function getRecentlyViewed(int $count = 20): Collection
{
return Bookshelf::visible()->withLastView()
->having('last_viewed_at', '>', 0)
->orderBy('last_viewed_at', 'desc')
->take($count)->get();
}
/**
* Get the most popular bookshelves in the system.
*/
public function getPopular(int $count = 20): Collection
{
return Bookshelf::visible()->withViewCount()
->having('view_count', '>', 0)
->orderBy('view_count', 'desc')
->take($count)->get();
}
/**
* Get the most recently created bookshelves from the system.
*/
public function getRecentlyCreated(int $count = 20): Collection
{
return Bookshelf::visible()->orderBy('created_at', 'desc')
->take($count)->get();
}
/**
* Get a shelf by its slug.
*/
public function getBySlug(string $slug): Bookshelf
{
$shelf = Bookshelf::visible()->where('slug', '=', $slug)->first();
if ($shelf === null) {
throw new NotFoundException(trans('errors.bookshelf_not_found'));
}
return $shelf;
}
/**
* Create a new shelf in the system.
*/
public function create(array $input, array $bookIds): Bookshelf
{
$shelf = new Bookshelf();
$this->baseRepo->create($shelf, $input);
$this->updateBooks($shelf, $bookIds);
return $shelf;
}
/**
* Create a new shelf in the system.
*/
public function update(Bookshelf $shelf, array $input, ?array $bookIds): Bookshelf
{
$this->baseRepo->update($shelf, $input);
if (!is_null($bookIds)) {
$this->updateBooks($shelf, $bookIds);
}
return $shelf;
}
/**
* Update which books are assigned to this shelf by
* syncing the given book ids.
* Function ensures the books are visible to the current user and existing.
*/
protected function updateBooks(Bookshelf $shelf, array $bookIds)
{
$numericIDs = collect($bookIds)->map(function ($id) {
return intval($id);
});
$syncData = Book::visible()
->whereIn('id', $bookIds)
->get(['id'])->pluck('id')->mapWithKeys(function ($bookId) use ($numericIDs) {
return [$bookId => ['order' => $numericIDs->search($bookId)]];
});
$shelf->books()->sync($syncData);
}
/**
* Update the given shelf cover image, or clear it.
* @throws ImageUploadException
* @throws Exception
*/
public function updateCoverImage(Bookshelf $shelf, ?UploadedFile $coverImage, bool $removeImage = false)
{
$this->baseRepo->updateCoverImage($shelf, $coverImage, $removeImage);
}
/**
* Update the permissions of a bookshelf.
*/
public function updatePermissions(Bookshelf $shelf, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($shelf, $restricted, $permissions);
}
/**
* Copy down the permissions of the given shelf to all child books.
*/
public function copyDownPermissions(Bookshelf $shelf, $checkUserPermissions = true): int
{
$shelfPermissions = $shelf->permissions()->get(['role_id', 'action'])->toArray();
$shelfBooks = $shelf->books()->get(['id', 'restricted']);
$updatedBookCount = 0;
/** @var Book $book */
foreach ($shelfBooks as $book) {
if ($checkUserPermissions && !userCan('restrictions-manage', $book)) {
continue;
}
$book->permissions()->delete();
$book->restricted = $shelf->restricted;
$book->permissions()->createMany($shelfPermissions);
$book->save();
$book->rebuildPermissions();
$updatedBookCount++;
}
return $updatedBookCount;
}
/**
* Remove a bookshelf from the system.
* @throws Exception
*/
public function destroy(Bookshelf $shelf)
{
$trashCan = new TrashCan();
$trashCan->destroyShelf($shelf);
}
}

View File

@@ -0,0 +1,108 @@
<?php namespace BookStack\Entities\Repos;
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use Exception;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
class ChapterRepo
{
protected $baseRepo;
/**
* ChapterRepo constructor.
* @param $baseRepo
*/
public function __construct(BaseRepo $baseRepo)
{
$this->baseRepo = $baseRepo;
}
/**
* Get a chapter via the slug.
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $chapterSlug): Chapter
{
$chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->first();
if ($chapter === null) {
throw new NotFoundException(trans('errors.chapter_not_found'));
}
return $chapter;
}
/**
* Create a new chapter in the system.
*/
public function create(array $input, Book $parentBook): Chapter
{
$chapter = new Chapter();
$chapter->book_id = $parentBook->id;
$chapter->priority = (new BookContents($parentBook))->getLastPriority() + 1;
$this->baseRepo->create($chapter, $input);
return $chapter;
}
/**
* Update the given chapter.
*/
public function update(Chapter $chapter, array $input): Chapter
{
$this->baseRepo->update($chapter, $input);
return $chapter;
}
/**
* Update the permissions of a chapter.
*/
public function updatePermissions(Chapter $chapter, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($chapter, $restricted, $permissions);
}
/**
* Remove a chapter from the system.
* @throws Exception
*/
public function destroy(Chapter $chapter)
{
$trashCan = new TrashCan();
$trashCan->destroyChapter($chapter);
}
/**
* Move the given chapter into a new parent book.
* The $parentIdentifier must be a string of the following format:
* 'book:<id>' (book:5)
* @throws MoveOperationException
*/
public function move(Chapter $chapter, string $parentIdentifier): Book
{
$stringExploded = explode(':', $parentIdentifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($entityType !== 'book') {
throw new MoveOperationException('Chapters can only be moved into books');
}
$parent = Book::visible()->where('id', '=', $entityId)->first();
if ($parent === null) {
throw new MoveOperationException('Book to move chapter into not found');
}
$chapter->changeBook($parent->id);
$chapter->rebuildPermissions();
return $parent;
}
}

View File

@@ -1,924 +0,0 @@
<?php namespace BookStack\Entities\Repos;
use Activity;
use BookStack\Actions\TagRepo;
use BookStack\Actions\ViewService;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Book;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\EntityProvider;
use BookStack\Entities\Page;
use BookStack\Entities\SearchService;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Uploads\AttachmentService;
use DOMDocument;
use DOMNode;
use DOMXPath;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\Request;
use Illuminate\Support\Collection;
use Throwable;
class EntityRepo
{
/**
* @var EntityProvider
*/
protected $entityProvider;
/**
* @var PermissionService
*/
protected $permissionService;
/**
* @var ViewService
*/
protected $viewService;
/**
* @var TagRepo
*/
protected $tagRepo;
/**
* @var SearchService
*/
protected $searchService;
/**
* EntityRepo constructor.
* @param EntityProvider $entityProvider
* @param ViewService $viewService
* @param PermissionService $permissionService
* @param TagRepo $tagRepo
* @param SearchService $searchService
*/
public function __construct(
EntityProvider $entityProvider,
ViewService $viewService,
PermissionService $permissionService,
TagRepo $tagRepo,
SearchService $searchService
) {
$this->entityProvider = $entityProvider;
$this->viewService = $viewService;
$this->permissionService = $permissionService;
$this->tagRepo = $tagRepo;
$this->searchService = $searchService;
}
/**
* Base query for searching entities via permission system
* @param string $type
* @param bool $allowDrafts
* @param string $permission
* @return \Illuminate\Database\Query\Builder
*/
protected function entityQuery($type, $allowDrafts = false, $permission = 'view')
{
$q = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type), $permission);
if (strtolower($type) === 'page' && !$allowDrafts) {
$q = $q->where('draft', '=', false);
}
return $q;
}
/**
* Check if an entity with the given id exists.
* @param $type
* @param $id
* @return bool
*/
public function exists($type, $id)
{
return $this->entityQuery($type)->where('id', '=', $id)->exists();
}
/**
* Get an entity by ID
* @param string $type
* @param integer $id
* @param bool $allowDrafts
* @param bool $ignorePermissions
* @return Entity
*/
public function getById($type, $id, $allowDrafts = false, $ignorePermissions = false)
{
$query = $this->entityQuery($type, $allowDrafts);
if ($ignorePermissions) {
$query = $this->entityProvider->get($type)->newQuery();
}
return $query->find($id);
}
/**
* @param string $type
* @param []int $ids
* @param bool $allowDrafts
* @param bool $ignorePermissions
* @return Builder[]|\Illuminate\Database\Eloquent\Collection|Collection
*/
public function getManyById($type, $ids, $allowDrafts = false, $ignorePermissions = false)
{
$query = $this->entityQuery($type, $allowDrafts);
if ($ignorePermissions) {
$query = $this->entityProvider->get($type)->newQuery();
}
return $query->whereIn('id', $ids)->get();
}
/**
* Get an entity by its url slug.
* @param string $type
* @param string $slug
* @param string|bool $bookSlug
* @return Entity
* @throws NotFoundException
*/
public function getBySlug($type, $slug, $bookSlug = false)
{
$q = $this->entityQuery($type)->where('slug', '=', $slug);
if (strtolower($type) === 'chapter' || strtolower($type) === 'page') {
$q = $q->where('book_id', '=', function ($query) use ($bookSlug) {
$query->select('id')
->from($this->entityProvider->book->getTable())
->where('slug', '=', $bookSlug)->limit(1);
});
}
$entity = $q->first();
if ($entity === null) {
throw new NotFoundException(trans('errors.' . strtolower($type) . '_not_found'));
}
return $entity;
}
/**
* Get all entities of a type with the given permission, limited by count unless count is false.
* @param string $type
* @param integer|bool $count
* @param string $permission
* @return Collection
*/
public function getAll($type, $count = 20, $permission = 'view')
{
$q = $this->entityQuery($type, false, $permission)->orderBy('name', 'asc');
if ($count !== false) {
$q = $q->take($count);
}
return $q->get();
}
/**
* Get all entities in a paginated format
* @param $type
* @param int $count
* @param string $sort
* @param string $order
* @param null|callable $queryAddition
* @return LengthAwarePaginator
*/
public function getAllPaginated($type, int $count = 10, string $sort = 'name', string $order = 'asc', $queryAddition = null)
{
$query = $this->entityQuery($type);
$query = $this->addSortToQuery($query, $sort, $order);
if ($queryAddition) {
$queryAddition($query);
}
return $query->paginate($count);
}
/**
* Add sorting operations to an entity query.
* @param Builder $query
* @param string $sort
* @param string $order
* @return Builder
*/
protected function addSortToQuery(Builder $query, string $sort = 'name', string $order = 'asc')
{
$order = ($order === 'asc') ? 'asc' : 'desc';
$propertySorts = ['name', 'created_at', 'updated_at'];
if (in_array($sort, $propertySorts)) {
return $query->orderBy($sort, $order);
}
return $query;
}
/**
* Get the most recently created entities of the given type.
* @param string $type
* @param int $count
* @param int $page
* @param bool|callable $additionalQuery
* @return Collection
*/
public function getRecentlyCreated($type, $count = 20, $page = 0, $additionalQuery = false)
{
$query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
->orderBy('created_at', 'desc');
if (strtolower($type) === 'page') {
$query = $query->where('draft', '=', false);
}
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
}
return $query->skip($page * $count)->take($count)->get();
}
/**
* Get the most recently updated entities of the given type.
* @param string $type
* @param int $count
* @param int $page
* @param bool|callable $additionalQuery
* @return Collection
*/
public function getRecentlyUpdated($type, $count = 20, $page = 0, $additionalQuery = false)
{
$query = $this->permissionService->enforceEntityRestrictions($type, $this->entityProvider->get($type))
->orderBy('updated_at', 'desc');
if (strtolower($type) === 'page') {
$query = $query->where('draft', '=', false);
}
if ($additionalQuery !== false && is_callable($additionalQuery)) {
$additionalQuery($query);
}
return $query->skip($page * $count)->take($count)->get();
}
/**
* Get the most recently viewed entities.
* @param string|bool $type
* @param int $count
* @param int $page
* @return mixed
*/
public function getRecentlyViewed($type, $count = 10, $page = 0)
{
$filter = is_bool($type) ? false : $this->entityProvider->get($type);
return $this->viewService->getUserRecentlyViewed($count, $page, $filter);
}
/**
* Get the latest pages added to the system with pagination.
* @param string $type
* @param int $count
* @return mixed
*/
public function getRecentlyCreatedPaginated($type, $count = 20)
{
return $this->entityQuery($type)->orderBy('created_at', 'desc')->paginate($count);
}
/**
* Get the latest pages added to the system with pagination.
* @param string $type
* @param int $count
* @return mixed
*/
public function getRecentlyUpdatedPaginated($type, $count = 20)
{
return $this->entityQuery($type)->orderBy('updated_at', 'desc')->paginate($count);
}
/**
* Get the most popular entities base on all views.
* @param string $type
* @param int $count
* @param int $page
* @return mixed
*/
public function getPopular(string $type, int $count = 10, int $page = 0)
{
return $this->viewService->getPopular($count, $page, $type);
}
/**
* Get draft pages owned by the current user.
* @param int $count
* @param int $page
* @return Collection
*/
public function getUserDraftPages($count = 20, $page = 0)
{
return $this->entityProvider->page->where('draft', '=', true)
->where('created_by', '=', user()->id)
->orderBy('updated_at', 'desc')
->skip($count * $page)->take($count)->get();
}
/**
* Get the number of entities the given user has created.
* @param string $type
* @param User $user
* @return int
*/
public function getUserTotalCreated(string $type, User $user)
{
return $this->entityProvider->get($type)
->where('created_by', '=', $user->id)->count();
}
/**
* Get the child items for a chapter sorted by priority but
* with draft items floated to the top.
* @param Bookshelf $bookshelf
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getBookshelfChildren(Bookshelf $bookshelf)
{
return $this->permissionService->enforceEntityRestrictions('book', $bookshelf->books())->get();
}
/**
* Get the direct children of a book.
* @param Book $book
* @return \Illuminate\Database\Eloquent\Collection
*/
public function getBookDirectChildren(Book $book)
{
$pages = $this->permissionService->enforceEntityRestrictions('page', $book->directPages())->get();
$chapters = $this->permissionService->enforceEntityRestrictions('chapters', $book->chapters())->get();
return collect()->concat($pages)->concat($chapters)->sortBy('priority')->sortByDesc('draft');
}
/**
* Get all child objects of a book.
* Returns a sorted collection of Pages and Chapters.
* Loads the book slug onto child elements to prevent access database access for getting the slug.
* @param Book $book
* @param bool $filterDrafts
* @param bool $renderPages
* @return mixed
*/
public function getBookChildren(Book $book, $filterDrafts = false, $renderPages = false)
{
$q = $this->permissionService->bookChildrenQuery($book->id, $filterDrafts, $renderPages)->get();
$entities = [];
$parents = [];
$tree = [];
foreach ($q as $index => $rawEntity) {
if ($rawEntity->entity_type === $this->entityProvider->page->getMorphClass()) {
$entities[$index] = $this->entityProvider->page->newFromBuilder($rawEntity);
if ($renderPages) {
$entities[$index]->html = $rawEntity->html;
$entities[$index]->html = $this->renderPage($entities[$index]);
};
} else if ($rawEntity->entity_type === $this->entityProvider->chapter->getMorphClass()) {
$entities[$index] = $this->entityProvider->chapter->newFromBuilder($rawEntity);
$key = $entities[$index]->entity_type . ':' . $entities[$index]->id;
$parents[$key] = $entities[$index];
$parents[$key]->setAttribute('pages', collect());
}
if ($entities[$index]->chapter_id === 0 || $entities[$index]->chapter_id === '0') {
$tree[] = $entities[$index];
}
$entities[$index]->book = $book;
}
foreach ($entities as $entity) {
if ($entity->chapter_id === 0 || $entity->chapter_id === '0') {
continue;
}
$parentKey = $this->entityProvider->chapter->getMorphClass() . ':' . $entity->chapter_id;
if (!isset($parents[$parentKey])) {
$tree[] = $entity;
continue;
}
$chapter = $parents[$parentKey];
$chapter->pages->push($entity);
}
return collect($tree);
}
/**
* Get the child items for a chapter sorted by priority but
* with draft items floated to the top.
* @param Chapter $chapter
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getChapterChildren(Chapter $chapter)
{
return $this->permissionService->enforceEntityRestrictions('page', $chapter->pages())
->orderBy('draft', 'DESC')->orderBy('priority', 'ASC')->get();
}
/**
* Get the next sequential priority for a new child element in the given book.
* @param Book $book
* @return int
*/
public function getNewBookPriority(Book $book)
{
$lastElem = $this->getBookChildren($book)->pop();
return $lastElem ? $lastElem->priority + 1 : 0;
}
/**
* Get a new priority for a new page to be added to the given chapter.
* @param Chapter $chapter
* @return int
*/
public function getNewChapterPriority(Chapter $chapter)
{
$lastPage = $chapter->pages('DESC')->first();
return $lastPage !== null ? $lastPage->priority + 1 : 0;
}
/**
* Find a suitable slug for an entity.
* @param string $type
* @param string $name
* @param bool|integer $currentId
* @param bool|integer $bookId Only pass if type is not a book
* @return string
*/
public function findSuitableSlug($type, $name, $currentId = false, $bookId = false)
{
$slug = $this->nameToSlug($name);
while ($this->slugExists($type, $slug, $currentId, $bookId)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
return $slug;
}
/**
* Check if a slug already exists in the database.
* @param string $type
* @param string $slug
* @param bool|integer $currentId
* @param bool|integer $bookId
* @return bool
*/
protected function slugExists($type, $slug, $currentId = false, $bookId = false)
{
$query = $this->entityProvider->get($type)->where('slug', '=', $slug);
if (strtolower($type) === 'page' || strtolower($type) === 'chapter') {
$query = $query->where('book_id', '=', $bookId);
}
if ($currentId) {
$query = $query->where('id', '!=', $currentId);
}
return $query->count() > 0;
}
/**
* Updates entity restrictions from a request
* @param Request $request
* @param Entity $entity
* @throws Throwable
*/
public function updateEntityPermissionsFromRequest(Request $request, Entity $entity)
{
$entity->restricted = $request->get('restricted', '') === 'true';
$entity->permissions()->delete();
if ($request->filled('restrictions')) {
foreach ($request->get('restrictions') as $roleId => $restrictions) {
foreach ($restrictions as $action => $value) {
$entity->permissions()->create([
'role_id' => $roleId,
'action' => strtolower($action)
]);
}
}
}
$entity->save();
$this->permissionService->buildJointPermissionsForEntity($entity);
}
/**
* Create a new entity from request input.
* Used for books and chapters.
* @param string $type
* @param array $input
* @param bool|Book $book
* @return Entity
*/
public function createFromInput($type, $input = [], $book = false)
{
$isChapter = strtolower($type) === 'chapter';
$entityModel = $this->entityProvider->get($type)->newInstance($input);
$entityModel->slug = $this->findSuitableSlug($type, $entityModel->name, false, $isChapter ? $book->id : false);
$entityModel->created_by = user()->id;
$entityModel->updated_by = user()->id;
$isChapter ? $book->chapters()->save($entityModel) : $entityModel->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
}
$this->permissionService->buildJointPermissionsForEntity($entityModel);
$this->searchService->indexEntity($entityModel);
return $entityModel;
}
/**
* Update entity details from request input.
* Used for books and chapters
* @param string $type
* @param Entity $entityModel
* @param array $input
* @return Entity
*/
public function updateFromInput($type, Entity $entityModel, $input = [])
{
if ($entityModel->name !== $input['name']) {
$entityModel->slug = $this->findSuitableSlug($type, $input['name'], $entityModel->id);
}
$entityModel->fill($input);
$entityModel->updated_by = user()->id;
$entityModel->save();
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($entityModel, $input['tags']);
}
$this->permissionService->buildJointPermissionsForEntity($entityModel);
$this->searchService->indexEntity($entityModel);
return $entityModel;
}
/**
* Sync the books assigned to a shelf from a comma-separated list
* of book IDs.
* @param Bookshelf $shelf
* @param string $books
*/
public function updateShelfBooks(Bookshelf $shelf, string $books)
{
$ids = explode(',', $books);
// Check books exist and match ordering
$bookIds = $this->entityQuery('book')->whereIn('id', $ids)->get(['id'])->pluck('id');
$syncData = [];
foreach ($ids as $index => $id) {
if ($bookIds->contains($id)) {
$syncData[$id] = ['order' => $index];
}
}
$shelf->books()->sync($syncData);
}
/**
* Append a Book to a BookShelf.
* @param Bookshelf $shelf
* @param Book $book
*/
public function appendBookToShelf(Bookshelf $shelf, Book $book)
{
if ($shelf->contains($book)) {
return;
}
$maxOrder = $shelf->books()->max('order');
$shelf->books()->attach($book->id, ['order' => $maxOrder + 1]);
}
/**
* Change the book that an entity belongs to.
* @param string $type
* @param integer $newBookId
* @param Entity $entity
* @param bool $rebuildPermissions
* @return Entity
*/
public function changeBook($type, $newBookId, Entity $entity, $rebuildPermissions = false)
{
$entity->book_id = $newBookId;
// Update related activity
foreach ($entity->activity as $activity) {
$activity->book_id = $newBookId;
$activity->save();
}
$entity->slug = $this->findSuitableSlug($type, $entity->name, $entity->id, $newBookId);
$entity->save();
// Update all child pages if a chapter
if (strtolower($type) === 'chapter') {
foreach ($entity->pages as $page) {
$this->changeBook('page', $newBookId, $page, false);
}
}
// Update permissions if applicable
if ($rebuildPermissions) {
$entity->load('book');
$this->permissionService->buildJointPermissionsForEntity($entity->book);
}
return $entity;
}
/**
* Alias method to update the book jointPermissions in the PermissionService.
* @param Book $book
*/
public function buildJointPermissionsForBook(Book $book)
{
$this->permissionService->buildJointPermissionsForEntity($book);
}
/**
* Format a name as a url slug.
* @param $name
* @return string
*/
protected function nameToSlug($name)
{
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
$slug = preg_replace('/\s{2,}/', ' ', $slug);
$slug = str_replace(' ', '-', $slug);
if ($slug === "") {
$slug = substr(md5(rand(1, 500)), 0, 5);
}
return $slug;
}
/**
* Render the page for viewing
* @param Page $page
* @param bool $blankIncludes
* @return string
*/
public function renderPage(Page $page, bool $blankIncludes = false) : string
{
$content = $page->html;
if (!config('app.allow_content_scripts')) {
$content = $this->escapeScripts($content);
}
if ($blankIncludes) {
$content = $this->blankPageIncludes($content);
} else {
$content = $this->parsePageIncludes($content);
}
return $content;
}
/**
* Remove any page include tags within the given HTML.
* @param string $html
* @return string
*/
protected function blankPageIncludes(string $html) : string
{
return preg_replace("/{{@\s?([0-9].*?)}}/", '', $html);
}
/**
* Parse any include tags "{{@<page_id>#section}}" to be part of the page.
* @param string $html
* @return mixed|string
*/
protected function parsePageIncludes(string $html) : string
{
$matches = [];
preg_match_all("/{{@\s?([0-9].*?)}}/", $html, $matches);
$topLevelTags = ['table', 'ul', 'ol'];
foreach ($matches[1] as $index => $includeId) {
$splitInclude = explode('#', $includeId, 2);
$pageId = intval($splitInclude[0]);
if (is_nan($pageId)) {
continue;
}
$matchedPage = $this->getById('page', $pageId);
if ($matchedPage === null) {
$html = str_replace($matches[0][$index], '', $html);
continue;
}
if (count($splitInclude) === 1) {
$html = str_replace($matches[0][$index], $matchedPage->html, $html);
continue;
}
$doc = new DOMDocument();
libxml_use_internal_errors(true);
$doc->loadHTML(mb_convert_encoding('<body>'.$matchedPage->html.'</body>', 'HTML-ENTITIES', 'UTF-8'));
$matchingElem = $doc->getElementById($splitInclude[1]);
if ($matchingElem === null) {
$html = str_replace($matches[0][$index], '', $html);
continue;
}
$innerContent = '';
$isTopLevel = in_array(strtolower($matchingElem->nodeName), $topLevelTags);
if ($isTopLevel) {
$innerContent .= $doc->saveHTML($matchingElem);
} else {
foreach ($matchingElem->childNodes as $childNode) {
$innerContent .= $doc->saveHTML($childNode);
}
}
libxml_clear_errors();
$html = str_replace($matches[0][$index], trim($innerContent), $html);
}
return $html;
}
/**
* Escape script tags within HTML content.
* @param string $html
* @return string
*/
protected function escapeScripts(string $html) : string
{
if ($html == '') {
return $html;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($html, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
// Remove standard script tags
$scriptElems = $xPath->query('//script');
foreach ($scriptElems as $scriptElem) {
$scriptElem->parentNode->removeChild($scriptElem);
}
// Remove data or JavaScript iFrames
$badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
foreach ($badIframes as $badIframe) {
$badIframe->parentNode->removeChild($badIframe);
}
// Remove 'on*' attributes
$onAttributes = $xPath->query('//@*[starts-with(name(), \'on\')]');
foreach ($onAttributes as $attr) {
/** @var \DOMAttr $attr*/
$attrName = $attr->nodeName;
$attr->parentNode->removeAttribute($attrName);
}
$html = '';
$topElems = $doc->documentElement->childNodes->item(0)->childNodes;
foreach ($topElems as $child) {
$html .= $doc->saveHTML($child);
}
return $html;
}
/**
* Search for image usage within page content.
* @param $imageString
* @return mixed
*/
public function searchForImage($imageString)
{
$pages = $this->entityQuery('page')->where('html', 'like', '%' . $imageString . '%')->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
$page->html = '';
$page->text = '';
}
return count($pages) > 0 ? $pages : false;
}
/**
* Destroy a bookshelf instance
* @param Bookshelf $shelf
* @throws Throwable
*/
public function destroyBookshelf(Bookshelf $shelf)
{
$this->destroyEntityCommonRelations($shelf);
$shelf->delete();
}
/**
* Destroy the provided book and all its child entities.
* @param Book $book
* @throws NotifyException
* @throws Throwable
*/
public function destroyBook(Book $book)
{
foreach ($book->pages as $page) {
$this->destroyPage($page);
}
foreach ($book->chapters as $chapter) {
$this->destroyChapter($chapter);
}
$this->destroyEntityCommonRelations($book);
$book->delete();
}
/**
* Destroy a chapter and its relations.
* @param Chapter $chapter
* @throws Throwable
*/
public function destroyChapter(Chapter $chapter)
{
if (count($chapter->pages) > 0) {
foreach ($chapter->pages as $page) {
$page->chapter_id = 0;
$page->save();
}
}
$this->destroyEntityCommonRelations($chapter);
$chapter->delete();
}
/**
* Destroy a given page along with its dependencies.
* @param Page $page
* @throws NotifyException
* @throws Throwable
*/
public function destroyPage(Page $page)
{
// Check if set as custom homepage & remove setting if not used or throw error if active
$customHome = setting('app-homepage', '0:');
if (intval($page->id) === intval(explode(':', $customHome)[0])) {
if (setting('app-homepage-type') === 'page') {
throw new NotifyException(trans('errors.page_custom_home_deletion'), $page->getUrl());
}
setting()->remove('app-homepage');
}
$this->destroyEntityCommonRelations($page);
// Delete Attached Files
$attachmentService = app(AttachmentService::class);
foreach ($page->attachments as $attachment) {
$attachmentService->deleteFile($attachment);
}
$page->delete();
}
/**
* Destroy or handle the common relations connected to an entity.
* @param Entity $entity
* @throws Throwable
*/
protected function destroyEntityCommonRelations(Entity $entity)
{
Activity::removeEntity($entity);
$entity->views()->delete();
$entity->permissions()->delete();
$entity->tags()->delete();
$entity->comments()->delete();
$this->permissionService->deleteJointPermissionsForEntity($entity);
$this->searchService->deleteEntityTerms($entity);
}
/**
* Copy the permissions of a bookshelf to all child books.
* Returns the number of books that had permissions updated.
* @param Bookshelf $bookshelf
* @return int
* @throws Throwable
*/
public function copyBookshelfPermissions(Bookshelf $bookshelf)
{
$shelfPermissions = $bookshelf->permissions()->get(['role_id', 'action'])->toArray();
$shelfBooks = $bookshelf->books()->get();
$updatedBookCount = 0;
foreach ($shelfBooks as $book) {
if (!userCan('restrictions-manage', $book)) {
continue;
}
$book->permissions()->delete();
$book->restricted = $bookshelf->restricted;
$book->permissions()->createMany($shelfPermissions);
$book->save();
$this->permissionService->buildJointPermissionsForEntity($book);
$updatedBookCount++;
}
return $updatedBookCount;
}
}

View File

@@ -3,91 +3,199 @@
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Entity;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Managers\TrashCan;
use BookStack\Entities\Page;
use BookStack\Entities\PageRevision;
use Carbon\Carbon;
use DOMDocument;
use DOMElement;
use DOMXPath;
use BookStack\Exceptions\MoveOperationException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Exceptions\NotifyException;
use BookStack\Exceptions\PermissionsException;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Pagination\LengthAwarePaginator;
use Illuminate\Support\Collection;
class PageRepo extends EntityRepo
class PageRepo
{
protected $baseRepo;
/**
* Get page by slug.
* @param string $pageSlug
* @param string $bookSlug
* @return Page
* @throws \BookStack\Exceptions\NotFoundException
* PageRepo constructor.
*/
public function getPageBySlug(string $pageSlug, string $bookSlug)
public function __construct(BaseRepo $baseRepo)
{
return $this->getBySlug('page', $pageSlug, $bookSlug);
$this->baseRepo = $baseRepo;
}
/**
* Search through page revisions and retrieve the last page in the
* current book that has a slug equal to the one given.
* @param string $pageSlug
* @param string $bookSlug
* @return null|Page
* Get a page by ID.
* @throws NotFoundException
*/
public function getPageByOldSlug(string $pageSlug, string $bookSlug)
public function getById(int $id): Page
{
$revision = $this->entityProvider->pageRevision->where('slug', '=', $pageSlug)
->whereHas('page', function ($query) {
$this->permissionService->enforceEntityRestrictions('page', $query);
$page = Page::visible()->with(['book'])->find($id);
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page its book and own slug.
* @throws NotFoundException
*/
public function getBySlug(string $bookSlug, string $pageSlug): Page
{
$page = Page::visible()->whereSlugs($bookSlug, $pageSlug)->first();
if (!$page) {
throw new NotFoundException(trans('errors.page_not_found'));
}
return $page;
}
/**
* Get a page by its old slug but checking the revisions table
* for the last revision that matched the given page and book slug.
*/
public function getByOldSlug(string $bookSlug, string $pageSlug): ?Page
{
$revision = PageRevision::query()
->whereHas('page', function (Builder $query) {
$query->visible();
})
->where('slug', '=', $pageSlug)
->where('type', '=', 'version')
->where('book_slug', '=', $bookSlug)
->orderBy('created_at', 'desc')
->with('page')->first();
return $revision !== null ? $revision->page : null;
->with('page')
->first();
return $revision ? $revision->page : null;
}
/**
* Updates a page with any fillable data and saves it into the database.
* @param Page $page
* @param int $book_id
* @param array $input
* @return Page
* @throws \Exception
* Get pages that have been marked as a template.
*/
public function updatePage(Page $page, int $book_id, array $input)
public function getTemplates(int $count = 10, int $page = 1, string $search = ''): LengthAwarePaginator
{
$query = Page::visible()
->where('template', '=', true)
->orderBy('name', 'asc')
->skip(($page - 1) * $count)
->take($count);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
}
$paginator = $query->paginate($count, ['*'], 'page', $page);
$paginator->withPath('/templates');
return $paginator;
}
/**
* Get a parent item via slugs.
*/
public function getParentFromSlugs(string $bookSlug, string $chapterSlug = null): Entity
{
if ($chapterSlug !== null) {
return $chapter = Chapter::visible()->whereSlugs($bookSlug, $chapterSlug)->firstOrFail();
}
return Book::visible()->where('slug', '=', $bookSlug)->firstOrFail();
}
/**
* Get the draft copy of the given page for the current user.
*/
public function getUserDraft(Page $page): ?PageRevision
{
$revision = $this->getUserDraftQuery($page)->first();
return $revision;
}
/**
* Get a new draft page belonging to the given parent entity.
*/
public function getNewDraftPage(Entity $parent)
{
$page = (new Page())->forceFill([
'name' => trans('entities.pages_initial_name'),
'created_by' => user()->id,
'updated_by' => user()->id,
'draft' => true,
]);
if ($parent instanceof Chapter) {
$page->chapter_id = $parent->id;
$page->book_id = $parent->book_id;
} else {
$page->book_id = $parent->id;
}
$page->save();
$page->refresh()->rebuildPermissions();
return $page;
}
/**
* Publish a draft page to make it a live, non-draft page.
*/
public function publishDraft(Page $draft, array $input): Page
{
$this->baseRepo->update($draft, $input);
if (isset($input['template']) && userCan('templates-manage')) {
$draft->template = ($input['template'] === 'true');
}
$pageContent = new PageContent($draft);
$pageContent->setNewHTML($input['html']);
$draft->draft = false;
$draft->revision_count = 1;
$draft->priority = $this->getNewPriority($draft);
$draft->refreshSlug();
$draft->save();
$this->savePageRevision($draft, trans('entities.pages_initial_revision'));
$draft->indexForSearch();
return $draft->refresh();
}
/**
* Update a page in the system.
*/
public function update(Page $page, array $input): Page
{
// Hold the old details to compare later
$oldHtml = $page->html;
$oldName = $page->name;
// Prevent slug being updated if no name change
if ($page->name !== $input['name']) {
$page->slug = $this->findSuitableSlug('page', $input['name'], $page->id, $book_id);
}
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($page, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$page->template = ($input['template'] === 'true');
}
$this->baseRepo->update($page, $input);
// Update with new details
$userId = user()->id;
$page->fill($input);
$page->html = $this->formatHtml($input['html']);
$page->text = $this->pageToPlainText($page);
$pageContent = new PageContent($page);
$pageContent->setNewHTML($input['html']);
$page->revision_count++;
if (setting('app-editor') !== 'markdown') {
$page->markdown = '';
}
$page->updated_by = $userId;
$page->revision_count++;
$page->save();
// Remove all update drafts for this user & page.
$this->userUpdatePageDraftsQuery($page, $userId)->delete();
$this->getUserDraftQuery($page)->delete();
// Save a revision after updating
$summary = $input['summary'] ?? null;
@@ -95,24 +203,20 @@ class PageRepo extends EntityRepo
$this->savePageRevision($page, $summary);
}
$this->searchService->indexEntity($page);
return $page;
}
/**
* Saves a page revision into the system.
* @param Page $page
* @param null|string $summary
* @return PageRevision
* @throws \Exception
*/
public function savePageRevision(Page $page, string $summary = null)
protected function savePageRevision(Page $page, string $summary = null)
{
$revision = $this->entityProvider->pageRevision->newInstance($page->toArray());
$revision = new PageRevision($page->toArray());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';
}
$revision->page_id = $page->id;
$revision->slug = $page->slug;
$revision->book_slug = $page->book->slug;
@@ -123,164 +227,29 @@ class PageRepo extends EntityRepo
$revision->revision_number = $page->revision_count;
$revision->save();
$revisionLimit = config('app.revision_limit');
if ($revisionLimit !== false) {
$revisionsToDelete = $this->entityProvider->pageRevision->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')->skip(intval($revisionLimit))->take(10)->get(['id']);
if ($revisionsToDelete->count() > 0) {
$this->entityProvider->pageRevision->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
$this->deleteOldRevisions($page);
return $revision;
}
/**
* Formats a page's html to be tagged correctly within the system.
* @param string $htmlText
* @return string
*/
protected function formatHtml(string $htmlText)
{
if ($htmlText == '') {
return $htmlText;
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($htmlText, 'HTML-ENTITIES', 'UTF-8'));
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
// Set ids on top-level nodes
$idMap = [];
foreach ($childNodes as $index => $childNode) {
$this->setUniqueId($childNode, $idMap);
}
// Ensure no duplicate ids within child items
$xPath = new DOMXPath($doc);
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
$this->setUniqueId($domElem, $idMap);
}
// Generate inner html as a string
$html = '';
foreach ($childNodes as $childNode) {
$html .= $doc->saveHTML($childNode);
}
return $html;
}
/**
* Set a unique id on the given DOMElement.
* A map for existing ID's should be passed in to check for current existence.
* @param DOMElement $element
* @param array $idMap
*/
protected function setUniqueId($element, array &$idMap)
{
if (get_class($element) !== 'DOMElement') {
return;
}
// Overwrite id if not a BookStack custom id
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return;
}
// Create an unique id for the element
// Uses the content as a basis to ensure output is the same every time
// the same content is passed through.
$contentId = 'bkmrk-' . mb_substr(strtolower(preg_replace('/\s+/', '-', trim($element->nodeValue))), 0, 20);
$newId = urlencode($contentId);
$loopIndex = 0;
while (isset($idMap[$newId])) {
$newId = urlencode($contentId . '-' . $loopIndex);
$loopIndex++;
}
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
}
/**
* Get the plain text version of a page's content.
* @param \BookStack\Entities\Page $page
* @return string
*/
protected function pageToPlainText(Page $page) : string
{
$html = $this->renderPage($page, true);
return strip_tags($html);
}
/**
* Get a new draft page instance.
* @param Book $book
* @param Chapter|null $chapter
* @return \BookStack\Entities\Page
* @throws \Throwable
*/
public function getDraftPage(Book $book, Chapter $chapter = null)
{
$page = $this->entityProvider->page->newInstance();
$page->name = trans('entities.pages_initial_name');
$page->created_by = user()->id;
$page->updated_by = user()->id;
$page->draft = true;
if ($chapter) {
$page->chapter_id = $chapter->id;
}
$book->pages()->save($page);
$page = $this->entityProvider->page->find($page->id);
$this->permissionService->buildJointPermissionsForEntity($page);
return $page;
}
/**
* Save a page update draft.
* @param Page $page
* @param array $data
* @return PageRevision|Page
*/
public function updatePageDraft(Page $page, array $data = [])
public function updatePageDraft(Page $page, array $input)
{
// If the page itself is a draft simply update that
if ($page->draft) {
$page->fill($data);
if (isset($data['html'])) {
$page->text = $this->pageToPlainText($page);
$page->fill($input);
if (isset($input['html'])) {
$content = new PageContent($page);
$content->setNewHTML($input['html']);
}
$page->save();
return $page;
}
// Otherwise save the data to a revision
$userId = user()->id;
$drafts = $this->userUpdatePageDraftsQuery($page, $userId)->get();
if ($drafts->count() > 0) {
$draft = $drafts->first();
} else {
$draft = $this->entityProvider->pageRevision->newInstance();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = $userId;
$draft->type = 'update_draft';
}
$draft->fill($data);
$draft = $this->getPageRevisionToUpdate($page);
$draft->fill($input);
if (setting('app-editor') !== 'markdown') {
$draft->markdown = '';
}
@@ -290,225 +259,78 @@ class PageRepo extends EntityRepo
}
/**
* Publish a draft page to make it a normal page.
* Sets the slug and updates the content.
* @param Page $draftPage
* @param array $input
* @return Page
* @throws \Exception
* Destroy a page from the system.
* @throws NotifyException
*/
public function publishPageDraft(Page $draftPage, array $input)
public function destroy(Page $page)
{
$draftPage->fill($input);
// Save page tags if present
if (isset($input['tags'])) {
$this->tagRepo->saveTagsToEntity($draftPage, $input['tags']);
}
if (isset($input['template']) && userCan('templates-manage')) {
$draftPage->template = ($input['template'] === 'true');
}
$draftPage->slug = $this->findSuitableSlug('page', $draftPage->name, false, $draftPage->book->id);
$draftPage->html = $this->formatHtml($input['html']);
$draftPage->text = $this->pageToPlainText($draftPage);
$draftPage->draft = false;
$draftPage->revision_count = 1;
$draftPage->save();
$this->savePageRevision($draftPage, trans('entities.pages_initial_revision'));
$this->searchService->indexEntity($draftPage);
return $draftPage;
}
/**
* The base query for getting user update drafts.
* @param Page $page
* @param $userId
* @return mixed
*/
protected function userUpdatePageDraftsQuery(Page $page, int $userId)
{
return $this->entityProvider->pageRevision->where('created_by', '=', $userId)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
/**
* Get the latest updated draft revision for a particular page and user.
* @param Page $page
* @param $userId
* @return PageRevision|null
*/
public function getUserPageDraft(Page $page, int $userId)
{
return $this->userUpdatePageDraftsQuery($page, $userId)->first();
}
/**
* Get the notification message that informs the user that they are editing a draft page.
* @param PageRevision $draft
* @return string
*/
public function getUserPageDraftMessage(PageRevision $draft)
{
$message = trans('entities.pages_editing_draft_notification', ['timeDiff' => $draft->updated_at->diffForHumans()]);
if ($draft->page->updated_at->timestamp <= $draft->updated_at->timestamp) {
return $message;
}
return $message . "\n" . trans('entities.pages_draft_edited_notification');
}
/**
* A query to check for active update drafts on a particular page.
* @param Page $page
* @param int $minRange
* @return mixed
*/
protected function activePageEditingQuery(Page $page, int $minRange = null)
{
$query = $this->entityProvider->pageRevision->where('type', '=', 'update_draft')
->where('page_id', '=', $page->id)
->where('updated_at', '>', $page->updated_at)
->where('created_by', '!=', user()->id)
->with('createdBy');
if ($minRange !== null) {
$query = $query->where('updated_at', '>=', Carbon::now()->subMinutes($minRange));
}
return $query;
}
/**
* Check if a page is being actively editing.
* Checks for edits since last page updated.
* Passing in a minuted range will check for edits
* within the last x minutes.
* @param Page $page
* @param int $minRange
* @return bool
*/
public function isPageEditingActive(Page $page, int $minRange = null)
{
$draftSearch = $this->activePageEditingQuery($page, $minRange);
return $draftSearch->count() > 0;
}
/**
* Get a notification message concerning the editing activity on a particular page.
* @param Page $page
* @param int $minRange
* @return string
*/
public function getPageEditingActiveMessage(Page $page, int $minRange = null)
{
$pageDraftEdits = $this->activePageEditingQuery($page, $minRange)->get();
$userMessage = $pageDraftEdits->count() > 1 ? trans('entities.pages_draft_edit_active.start_a', ['count' => $pageDraftEdits->count()]): trans('entities.pages_draft_edit_active.start_b', ['userName' => $pageDraftEdits->first()->createdBy->name]);
$timeMessage = $minRange === null ? trans('entities.pages_draft_edit_active.time_a') : trans('entities.pages_draft_edit_active.time_b', ['minCount'=>$minRange]);
return trans('entities.pages_draft_edit_active.message', ['start' => $userMessage, 'time' => $timeMessage]);
}
/**
* Parse the headers on the page to get a navigation menu
* @param string $pageContent
* @return array
*/
public function getPageNav(string $pageContent)
{
if ($pageContent == '') {
return [];
}
libxml_use_internal_errors(true);
$doc = new DOMDocument();
$doc->loadHTML(mb_convert_encoding($pageContent, 'HTML-ENTITIES', 'UTF-8'));
$xPath = new DOMXPath($doc);
$headers = $xPath->query("//h1|//h2|//h3|//h4|//h5|//h6");
if (is_null($headers)) {
return [];
}
$tree = collect($headers)->map(function($header) {
$text = trim(str_replace("\xc2\xa0", '', $header->nodeValue));
$text = mb_substr($text, 0, 100);
return [
'nodeName' => strtolower($header->nodeName),
'level' => intval(str_replace('h', '', $header->nodeName)),
'link' => '#' . $header->getAttribute('id'),
'text' => $text,
];
})->filter(function($header) {
return mb_strlen($header['text']) > 0;
});
// Shift headers if only smaller headers have been used
$levelChange = ($tree->pluck('level')->min() - 1);
$tree = $tree->map(function ($header) use ($levelChange) {
$header['level'] -= ($levelChange);
return $header;
});
return $tree->toArray();
$trashCan = new TrashCan();
$trashCan->destroyPage($page);
}
/**
* Restores a revision's content back into a page.
* @param Page $page
* @param Book $book
* @param int $revisionId
* @return Page
* @throws \Exception
*/
public function restorePageRevision(Page $page, Book $book, int $revisionId)
public function restoreRevision(Page $page, int $revisionId): Page
{
$page->revision_count++;
$this->savePageRevision($page);
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
$page->fill($revision->toArray());
$page->slug = $this->findSuitableSlug('page', $page->name, $page->id, $book->id);
$page->text = $this->pageToPlainText($page);
$content = new PageContent($page);
$content->setNewHTML($page->html);
$page->updated_by = user()->id;
$page->refreshSlug();
$page->save();
$this->searchService->indexEntity($page);
$page->indexForSearch();
return $page;
}
/**
* Change the page's parent to the given entity.
* @param Page $page
* @param Entity $parent
* @throws \Throwable
* Move the given page into a new parent book or chapter.
* The $parentIdentifier must be a string of the following format:
* 'book:<id>' (book:5)
* @throws MoveOperationException
* @throws PermissionsException
*/
public function changePageParent(Page $page, Entity $parent)
public function move(Page $page, string $parentIdentifier): Book
{
$book = $parent->isA('book') ? $parent : $parent->book;
$page->chapter_id = $parent->isA('chapter') ? $parent->id : 0;
$page->save();
if ($page->book->id !== $book->id) {
$page = $this->changeBook('page', $book->id, $page);
$parent = $this->findParentByIdentifier($parentIdentifier);
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
$page->load('book');
$this->permissionService->buildJointPermissionsForEntity($book);
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : null;
$page->changeBook($parent instanceof Book ? $parent->id : $parent->book->id);
$page->rebuildPermissions();
return ($parent instanceof Book ? $parent : $parent->book);
}
/**
* Create a copy of a page in a new location with a new name.
* @param \BookStack\Entities\Page $page
* @param \BookStack\Entities\Entity $newParent
* @param string $newName
* @return \BookStack\Entities\Page
* @throws \Throwable
* Copy an existing page in the system.
* Optionally providing a new parent via string identifier and a new name.
* @throws MoveOperationException
* @throws PermissionsException
*/
public function copyPage(Page $page, Entity $newParent, string $newName = '')
public function copy(Page $page, string $parentIdentifier = null, string $newName = null): Page
{
$newBook = $newParent->isA('book') ? $newParent : $newParent->book;
$newChapter = $newParent->isA('chapter') ? $newParent : null;
$copyPage = $this->getDraftPage($newBook, $newChapter);
$parent = $parentIdentifier ? $this->findParentByIdentifier($parentIdentifier) : $page->parent();
if ($parent === null) {
throw new MoveOperationException('Book or chapter to move page into not found');
}
if (!userCan('page-create', $parent)) {
throw new PermissionsException('User does not have permission to create a page within the new parent');
}
$copyPage = $this->getNewDraftPage($parent);
$pageData = $page->getAttributes();
// Update name
@@ -524,38 +346,116 @@ class PageRepo extends EntityRepo
}
}
// Set priority
if ($newParent->isA('chapter')) {
$pageData['priority'] = $this->getNewChapterPriority($newParent);
} else {
$pageData['priority'] = $this->getNewBookPriority($newParent);
}
return $this->publishPageDraft($copyPage, $pageData);
return $this->publishDraft($copyPage, $pageData);
}
/**
* Get pages that have been marked as templates.
* @param int $count
* @param int $page
* @param string $search
* @return \Illuminate\Contracts\Pagination\LengthAwarePaginator
* Find a page parent entity via a identifier string in the format:
* {type}:{id}
* Example: (book:5)
* @throws MoveOperationException
*/
public function getPageTemplates(int $count = 10, int $page = 1, string $search = '')
protected function findParentByIdentifier(string $identifier): ?Entity
{
$query = $this->entityQuery('page')
->where('template', '=', true)
->orderBy('name', 'asc')
->skip( ($page - 1) * $count)
->take($count);
$stringExploded = explode(':', $identifier);
$entityType = $stringExploded[0];
$entityId = intval($stringExploded[1]);
if ($search) {
$query->where('name', 'like', '%' . $search . '%');
if ($entityType !== 'book' && $entityType !== 'chapter') {
throw new MoveOperationException('Pages can only be in books or chapters');
}
$paginator = $query->paginate($count, ['*'], 'page', $page);
$paginator->withPath('/templates');
$parentClass = $entityType === 'book' ? Book::class : Chapter::class;
return $parentClass::visible()->where('id', '=', $entityId)->first();
}
return $paginator;
/**
* Update the permissions of a page.
*/
public function updatePermissions(Page $page, bool $restricted, Collection $permissions = null)
{
$this->baseRepo->updatePermissions($page, $restricted, $permissions);
}
/**
* Change the page's parent to the given entity.
*/
protected function changeParent(Page $page, Entity $parent)
{
$book = ($parent instanceof Book) ? $parent : $parent->book;
$page->chapter_id = ($parent instanceof Chapter) ? $parent->id : 0;
$page->save();
if ($page->book->id !== $book->id) {
$page->changeBook($book->id);
}
$page->load('book');
$book->rebuildPermissions();
}
/**
* Get a page revision to update for the given page.
* Checks for an existing revisions before providing a fresh one.
*/
protected function getPageRevisionToUpdate(Page $page): PageRevision
{
$drafts = $this->getUserDraftQuery($page)->get();
if ($drafts->count() > 0) {
return $drafts->first();
}
$draft = new PageRevision();
$draft->page_id = $page->id;
$draft->slug = $page->slug;
$draft->book_slug = $page->book->slug;
$draft->created_by = user()->id;
$draft->type = 'update_draft';
return $draft;
}
/**
* Delete old revisions, for the given page, from the system.
*/
protected function deleteOldRevisions(Page $page)
{
$revisionLimit = config('app.revision_limit');
if ($revisionLimit === false) {
return;
}
$revisionsToDelete = PageRevision::query()
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc')
->skip(intval($revisionLimit))
->take(10)
->get(['id']);
if ($revisionsToDelete->count() > 0) {
PageRevision::query()->whereIn('id', $revisionsToDelete->pluck('id'))->delete();
}
}
/**
* Get a new priority for a page
*/
protected function getNewPriority(Page $page): int
{
if ($page->parent() instanceof Chapter) {
$lastPage = $page->parent()->pages('desc')->first();
return $lastPage ? $lastPage->priority + 1 : 0;
}
return (new BookContents($page->book))->getLastPriority() + 1;
}
/**
* Get the query to find the user's draft copies of the given page.
*/
protected function getUserDraftQuery(Page $page)
{
return PageRevision::query()->where('created_by', '=', user()->id)
->where('type', 'update_draft')
->where('page_id', '=', $page->id)
->orderBy('created_at', 'desc');
}
}

View File

@@ -6,6 +6,7 @@ use Illuminate\Database\Eloquent\Builder as EloquentBuilder;
use Illuminate\Database\Query\Builder;
use Illuminate\Database\Query\JoinClause;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
class SearchService
{
@@ -210,7 +211,7 @@ class SearchService
// Handle filters
foreach ($terms['filters'] as $filterTerm => $filterValue) {
$functionName = camel_case('filter_' . $filterTerm);
$functionName = Str::camel('filter_' . $filterTerm);
if (method_exists($this, $functionName)) {
$this->$functionName($entitySelect, $entity, $filterValue);
}
@@ -514,7 +515,7 @@ class SearchService
protected function filterSortBy(EloquentBuilder $query, Entity $model, $input)
{
$functionName = camel_case('sort_by_' . $input);
$functionName = Str::camel('sort_by_' . $input);
if (method_exists($this, $functionName)) {
$this->$functionName($query, $model);
}

View File

@@ -0,0 +1,62 @@
<?php namespace BookStack\Entities;
class SlugGenerator
{
protected $entity;
/**
* SlugGenerator constructor.
* @param $entity
*/
public function __construct(Entity $entity)
{
$this->entity = $entity;
}
/**
* Generate a fresh slug for the given entity.
* The slug will generated so it does not conflict within the same parent item.
*/
public function generate(): string
{
$slug = $this->formatNameAsSlug($this->entity->name);
while ($this->slugInUse($slug)) {
$slug .= '-' . substr(md5(rand(1, 500)), 0, 3);
}
return $slug;
}
/**
* Format a name as a url slug.
*/
protected function formatNameAsSlug(string $name): string
{
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
$slug = preg_replace('/\s{2,}/', ' ', $slug);
$slug = str_replace(' ', '-', $slug);
if ($slug === "") {
$slug = substr(md5(rand(1, 500)), 0, 5);
}
return $slug;
}
/**
* Check if a slug is already in-use for this
* type of model within the same parent.
*/
protected function slugInUse(string $slug): bool
{
$query = $this->entity->newQuery()->where('slug', '=', $slug);
if ($this->entity instanceof BookChild) {
$query->where('book_id', '=', $this->entity->book_id);
}
if ($this->entity->id) {
$query->where('id', '!=', $this->entity->id);
}
return $query->count() > 0;
}
}

View File

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

View File

@@ -7,6 +7,9 @@ use Illuminate\Auth\Access\AuthorizationException;
use Illuminate\Auth\AuthenticationException;
use Illuminate\Database\Eloquent\ModelNotFoundException;
use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Symfony\Component\HttpKernel\Exception\HttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
@@ -47,10 +50,17 @@ class Handler extends ExceptionHandler
*/
public function render($request, Exception $e)
{
if ($this->isApiRequest($request)) {
return $this->renderApiException($e);
}
// Handle notify exceptions which will redirect to the
// specified location then show a notification message.
if ($this->isExceptionType($e, NotifyException::class)) {
session()->flash('error', $this->getOriginalMessage($e));
$message = $this->getOriginalMessage($e);
if (!empty($message)) {
session()->flash('error', $message);
}
return redirect($e->redirectLocation);
}
@@ -70,6 +80,41 @@ class Handler extends ExceptionHandler
return parent::render($request, $e);
}
/**
* Check if the given request is an API request.
*/
protected function isApiRequest(Request $request): bool
{
return strpos($request->path(), 'api/') === 0;
}
/**
* Render an exception when the API is in use.
*/
protected function renderApiException(Exception $e): JsonResponse
{
$code = $e->getCode() === 0 ? 500 : $e->getCode();
$headers = [];
if ($e instanceof HttpException) {
$code = $e->getStatusCode();
$headers = $e->getHeaders();
}
$responseData = [
'error' => [
'message' => $e->getMessage(),
]
];
if ($e instanceof ValidationException) {
$responseData['error']['validation'] = $e->errors();
$code = $e->status;
}
$responseData['error']['code'] = $code;
return new JsonResponse($responseData, $code, $headers);
}
/**
* Check the exception chain to compare against the original exception type.
* @param Exception $e

View File

@@ -0,0 +1,25 @@
<?php namespace BookStack\Exceptions;
use Exception;
class JsonDebugException extends Exception
{
protected $data;
/**
* JsonDebugException constructor.
*/
public function __construct($data)
{
$this->data = $data;
}
/**
* Covert this exception into a response.
*/
public function render()
{
return response()->json($this->data);
}
}

View File

@@ -0,0 +1,6 @@
<?php namespace BookStack\Exceptions;
class LoginAttemptEmailNeededException extends LoginAttemptException
{
}

View File

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

View File

@@ -0,0 +1,8 @@
<?php namespace BookStack\Exceptions;
use Exception;
class MoveOperationException extends Exception
{
}

View File

@@ -8,8 +8,6 @@ class NotifyException extends \Exception
/**
* NotifyException constructor.
* @param string $message
* @param string $redirectLocation
*/
public function __construct(string $message, string $redirectLocation = "/")
{

View File

@@ -1,6 +1,6 @@
<?php namespace BookStack\Exceptions;
class AuthException extends PrettyException
class SamlException extends NotifyException
{
}

View File

@@ -0,0 +1,8 @@
<?php namespace BookStack\Exceptions;
use Exception;
class SortOperationException extends Exception
{
}

View File

@@ -0,0 +1,17 @@
<?php
namespace BookStack\Exceptions;
use Exception;
class UnauthorizedException extends Exception
{
/**
* ApiAuthException constructor.
*/
public function __construct($message, $code = 401)
{
parent::__construct($message, $code);
}
}

View File

@@ -1,6 +1,7 @@
<?php namespace BookStack\Exceptions;
class UserTokenExpiredException extends \Exception {
class UserTokenExpiredException extends \Exception
{
public $userId;
@@ -14,6 +15,4 @@ class UserTokenExpiredException extends \Exception {
$this->userId = $userId;
parent::__construct($message);
}
}
}

View File

@@ -1,3 +1,6 @@
<?php namespace BookStack\Exceptions;
class UserTokenNotFoundException extends \Exception {}
class UserTokenNotFoundException extends \Exception
{
}

View File

@@ -0,0 +1,16 @@
<?php namespace BookStack\Facades;
use Illuminate\Support\Facades\Facade;
class Permissions extends Facade
{
/**
* Get the registered name of the component.
*
* @return string
*/
protected static function getFacadeAccessor()
{
return 'permissions';
}
}

View File

@@ -0,0 +1,30 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ListingResponseBuilder;
use BookStack\Http\Controllers\Controller;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Http\JsonResponse;
class ApiController extends Controller
{
protected $rules = [];
/**
* Provide a paginated listing JSON response in a standard format
* taking into account any pagination parameters passed by the user.
*/
protected function apiListingResponse(Builder $query, array $fields): JsonResponse
{
$listing = new ListingResponseBuilder($query, request(), $fields);
return $listing->toResponse();
}
/**
* Get the validation rules for this controller.
*/
public function getValdationRules(): array
{
return $this->rules;
}
}

View File

@@ -0,0 +1,47 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Api\ApiDocsGenerator;
use Cache;
use Illuminate\Support\Collection;
class ApiDocsController extends ApiController
{
/**
* Load the docs page for the API.
*/
public function display()
{
$docs = $this->getDocs();
return view('api-docs.index', [
'docs' => $docs,
]);
}
/**
* Show a JSON view of the API docs data.
*/
public function json() {
$docs = $this->getDocs();
return response()->json($docs);
}
/**
* Get the base docs data.
* Checks and uses the system cache for quick re-fetching.
*/
protected function getDocs(): Collection
{
$appVersion = trim(file_get_contents(base_path('version')));
$cacheKey = 'api-docs::' . $appVersion;
if (Cache::has($cacheKey) && config('app.env') === 'production') {
$docs = Cache::get($cacheKey);
} else {
$docs = (new ApiDocsGenerator())->generate();
Cache::put($cacheKey, $docs, 60*24);
}
return $docs;
}
}

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