Compare commits

...

463 Commits

Author SHA1 Message Date
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
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
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
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
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
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
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
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
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
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
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
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
639 changed files with 21988 additions and 11223 deletions

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,6 +196,7 @@ 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
@@ -201,6 +207,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
@@ -235,3 +261,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._

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

@@ -0,0 +1,73 @@
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
Gustav Kånåhols (Kurbitz) :: Swedish

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

@@ -0,0 +1,50 @@
name: phpunit
on:
push:
branches:
- master
- release
pull_request:
branches:
- '*'
- '*/*'
- '!l10n_master'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
php: [7.2, 7.3]
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: 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

@@ -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
{

View File

@@ -1,8 +1,8 @@
<?php namespace BookStack\Actions;
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Book;
use BookStack\Entities\Entity;
use Session;
class ActivityService
{
@@ -12,7 +12,7 @@ class ActivityService
/**
* ActivityService constructor.
* @param \BookStack\Actions\Activity $activity
* @param Activity $activity
* @param PermissionService $permissionService
*/
public function __construct(Activity $activity, PermissionService $permissionService)
@@ -24,52 +24,57 @@ class ActivityService
/**
* Add activity data to database.
* @param Entity $entity
* @param $activityKey
* @param \BookStack\Entities\Entity $entity
* @param string $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
* Adds a activity history with a message, without binding to a entity.
* @param string $activityKey
* @param string $message
* @param int $bookId
* @param bool|false $extra
*/
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.
* @param string $key
* @param int|null $bookId
* @return Activity
*/
protected function newActivityForUser(string $key, int $bookId = null)
{
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
* @param \BookStack\Entities\Entity $entity
* @return mixed
*/
public function removeEntity(Entity $entity)
{
// TODO - Rewrite to db query.
$activities = $entity->activity;
foreach ($activities as $activity) {
$activity->extra = $entity->name;
@@ -90,7 +95,11 @@ class ActivityService
{
$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,7 +107,7 @@ class ActivityService
/**
* Gets the latest activity for an entity, Filtering out similar
* items to prevent a message activity list.
* @param Entity $entity
* @param \BookStack\Entities\Entity $entity
* @param int $count
* @param int $page
* @return array
@@ -171,7 +180,7 @@ class ActivityService
$notificationTextKey = 'activities.' . $activityKey . '_notification';
if (trans()->has($notificationTextKey)) {
$message = trans($notificationTextKey);
Session::flash('success', $message);
session()->flash('success', $message);
}
}
}

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,125 @@
<?php namespace BookStack\Api;
use BookStack\Http\Controllers\Api\ApiController;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Route;
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,
'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,135 @@
<?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()
{
$data = $this->fetchData();
$total = $this->query->count();
return response()->json([
'data' => $data,
'total' => $total,
]);
}
/**
* Fetch the data to return in the response.
*/
protected function fetchData(): Collection
{
$this->applyCountAndOffset($this->query);
$this->applySorting($this->query);
$this->applyFiltering($this->query);
return $this->query->get($this->fields);
}
/**
* Apply any filtering operations found in the request.
*/
protected function applyFiltering(Builder $query)
{
$requestFilters = $this->request->get('filter', []);
if (!is_array($requestFilters)) {
return;
}
$queryFilters = collect($requestFilters)->map(function ($value, $key) {
return $this->requestFilterToQueryFilter($key, $value);
})->filter(function ($value) {
return !is_null($value);
})->values()->toArray();
$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 applySorting(Builder $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;
}
$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 applyCountAndOffset(Builder $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);
$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,114 @@
<?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']);
$this->lastAttempted = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
]);
return $this->ldapService->validateUserCredentials($userDetails, $credentials['username'], $credentials['password']);
}
/**
* Attempt to authenticate a user using the given credentials.
*
* @param array $credentials
* @param bool $remember
* @return bool
* @throws LoginAttemptEmailNeededException
* @throws LoginAttemptException
* @throws LdapException
* @throws UserRegistrationException
*/
public function attempt(array $credentials = [], $remember = false)
{
$username = $credentials['username'];
$userDetails = $this->ldapService->getUserDetails($username);
$this->lastAttempted = $user = $this->provider->retrieveByCredentials([
'external_auth_id' => $userDetails['uid']
]);
if (!$this->ldapService->validateUserCredentials($userDetails, $username, $credentials['password'])) {
return false;
}
if (is_null($user)) {
$user = $this->createNewFromLdapAndCreds($userDetails, $credentials);
}
// 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,28 @@
<?php namespace BookStack\Auth\Access;
use BookStack\Auth\Access;
use BookStack\Auth\Role;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
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,13 +36,10 @@ 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);
@@ -73,16 +61,15 @@ 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;
@@ -90,7 +77,7 @@ class LdapService
$userCn = $this->getUserResponseProperty($user, 'cn', null);
return [
'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']),
'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
'dn' => $user['dn'],
'email' => $this->getUserResponseProperty($user, $emailAttr, null),
@@ -100,13 +87,10 @@ class LdapService
/**
* 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
*/
protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue)
{
$propertyKey = strtolower($propertyKey);
if (isset($userDetails[$propertyKey])) {
return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]);
}
@@ -115,27 +99,19 @@ class LdapService
}
/**
* @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 $username, string $password): bool
{
$ldapUser = $this->getUserDetails($username);
if ($ldapUser === null) {
return false;
}
if ($ldapUser['uid'] !== $user->external_auth_id) {
if ($ldapUserDetails === null) {
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 +181,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 +201,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 +213,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 +231,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 +277,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 +305,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,118 @@
<?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);
}
}
/**
* Alias to the UserRepo method of the same name.
* Attaches the default system role, if configured, to the given user.
*/
public function attachDefaultRole(User $user): void
{
$this->userRepo->attachDefaultRole($user);
}
}

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,22 +216,17 @@ 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);

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,7 @@ 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'];
/**
* This holds the user's permissions when loaded.
@@ -53,13 +55,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 +116,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 +164,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 +222,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', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fr', 'hu', 'nl', 'pt_BR', 'sk', 'cs', 'sv', 'ko', 'ja', 'pl', 'it', 'ru', 'uk', 'zh_CN', 'zh_TW', 'tr'],
// 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),
@@ -140,13 +123,14 @@ return [
'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

@@ -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

@@ -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'];
/**
* 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->contact($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,15 +12,6 @@ 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';
}
/**
* Get the books in this shelf.
* Should not be used directly since does not take into account permissions.
@@ -31,6 +24,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 +69,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 +94,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,35 +1,34 @@
<?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);
$page->html = (new PageContent($page))->render();
$pageHtml = view('pages/export', [
'page' => $page
])->render();
@@ -38,15 +37,13 @@ class ExportService
/**
* 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', [
'chapter' => $chapter,
@@ -57,13 +54,11 @@ class ExportService
/**
* 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);
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books/export', [
'book' => $book,
'bookChildren' => $bookTree
@@ -73,13 +68,11 @@ class ExportService
/**
* 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);
$page->html = (new PageContent($page))->render();
$html = view('pages/pdf', [
'page' => $page
])->render();
@@ -88,32 +81,30 @@ class ExportService
/**
* 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', [
'chapter' => $chapter,
'pages' => $pages
])->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);
$bookTree = (new BookContents($book))->getTree(false, true);
$html = view('books/export', [
'book' => $book,
'bookChildren' => $bookTree
@@ -122,31 +113,27 @@ class ExportService
}
/**
* 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 +175,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 +192,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 +205,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 = null, 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 = null, 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,173 @@
<?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);
$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 = null, 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

@@ -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;
}
}

View File

@@ -0,0 +1,101 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Book;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\NotifyException;
use BookStack\Facades\Activity;
use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class BooksApiController extends ApiController
{
protected $bookRepo;
protected $rules = [
'create' => [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
],
'update' => [
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
],
];
/**
* BooksApiController constructor.
*/
public function __construct(BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
}
/**
* Get a listing of books visible to the user.
*/
public function list()
{
$books = Book::visible();
return $this->apiListingResponse($books, [
'id', 'name', 'slug', 'description', 'created_at', 'updated_at', 'created_by', 'updated_by', 'image_id',
]);
}
/**
* Create a new book in the system.
* @throws ValidationException
*/
public function create(Request $request)
{
$this->checkPermission('book-create-all');
$requestData = $this->validate($request, $this->rules['create']);
$book = $this->bookRepo->create($requestData);
Activity::add($book, 'book_create', $book->id);
return response()->json($book);
}
/**
* View the details of a single book.
*/
public function read(string $id)
{
$book = Book::visible()->with(['tags', 'cover', 'createdBy', 'updatedBy'])->findOrFail($id);
return response()->json($book);
}
/**
* Update the details of a single book.
* @throws ValidationException
*/
public function update(Request $request, string $id)
{
$book = Book::visible()->findOrFail($id);
$this->checkOwnablePermission('book-update', $book);
$requestData = $this->validate($request, $this->rules['update']);
$book = $this->bookRepo->update($book, $requestData);
Activity::add($book, 'book_update', $book->id);
return response()->json($book);
}
/**
* Delete a single book from the system.
* @throws NotifyException
* @throws BindingResolutionException
*/
public function delete(string $id)
{
$book = Book::visible()->findOrFail($id);
$this->checkOwnablePermission('book-delete', $book);
$this->bookRepo->destroy($book);
Activity::addMessage('book_delete', $book->name);
return response('', 204);
}
}

View File

@@ -1,37 +1,37 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\FileUploadException;
use BookStack\Exceptions\NotFoundException;
use BookStack\Uploads\Attachment;
use BookStack\Uploads\AttachmentService;
use Exception;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
{
protected $attachmentService;
protected $attachment;
protected $entityRepo;
protected $pageRepo;
/**
* AttachmentController constructor.
* @param \BookStack\Uploads\AttachmentService $attachmentService
* @param Attachment $attachment
* @param EntityRepo $entityRepo
*/
public function __construct(AttachmentService $attachmentService, Attachment $attachment, EntityRepo $entityRepo)
public function __construct(AttachmentService $attachmentService, Attachment $attachment, PageRepo $pageRepo)
{
$this->attachmentService = $attachmentService;
$this->attachment = $attachment;
$this->entityRepo = $entityRepo;
$this->pageRepo = $pageRepo;
parent::__construct();
}
/**
* Endpoint at which attachments are uploaded to.
* @param Request $request
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\JsonResponse|\Symfony\Component\HttpFoundation\Response
* @throws ValidationException
* @throws NotFoundException
*/
public function upload(Request $request)
{
@@ -41,7 +41,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
@@ -59,11 +59,10 @@ class AttachmentController extends Controller
/**
* Update an uploaded attachment.
* @param int $attachmentId
* @param Request $request
* @return mixed
* @throws ValidationException
* @throws NotFoundException
*/
public function uploadUpdate($attachmentId, Request $request)
public function uploadUpdate(Request $request, $attachmentId)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
@@ -71,7 +70,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
@@ -94,11 +93,10 @@ class AttachmentController extends Controller
/**
* Update the details of an existing file.
* @param $attachmentId
* @param Request $request
* @return Attachment|mixed
* @throws ValidationException
* @throws NotFoundException
*/
public function update($attachmentId, Request $request)
public function update(Request $request, $attachmentId)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
@@ -107,7 +105,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
@@ -123,8 +121,8 @@ class AttachmentController extends Controller
/**
* Attach a link to a page.
* @param Request $request
* @return mixed
* @throws ValidationException
* @throws NotFoundException
*/
public function attachLink(Request $request)
{
@@ -135,7 +133,7 @@ class AttachmentController extends Controller
]);
$pageId = $request->get('uploaded_to');
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
@@ -149,29 +147,26 @@ class AttachmentController extends Controller
/**
* Get the attachments for a specific page.
* @param $pageId
* @return mixed
*/
public function listForPage($pageId)
public function listForPage(int $pageId)
{
$page = $this->entityRepo->getById('page', $pageId, true);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-view', $page);
return response()->json($page->attachments);
}
/**
* Update the attachment sorting.
* @param $pageId
* @param Request $request
* @return mixed
* @throws ValidationException
* @throws NotFoundException
*/
public function sortForPage($pageId, Request $request)
public function sortForPage(Request $request, int $pageId)
{
$this->validate($request, [
'files' => 'required|array',
'files.*.id' => 'required|integer',
]);
$page = $this->entityRepo->getById('page', $pageId);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$attachments = $request->get('files');
@@ -181,16 +176,15 @@ class AttachmentController extends Controller
/**
* Get an attachment from storage.
* @param $attachmentId
* @return \Illuminate\Contracts\Routing\ResponseFactory|\Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector|\Symfony\Component\HttpFoundation\Response
* @throws \Illuminate\Contracts\Filesystem\FileNotFoundException
* @throws FileNotFoundException
* @throws NotFoundException
*/
public function get($attachmentId)
public function get(int $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$page = $this->entityRepo->getById('page', $attachment->uploaded_to);
if ($page === null) {
try {
$page = $this->pageRepo->getById($attachment->uploaded_to);
} catch (NotFoundException $exception) {
throw new NotFoundException(trans('errors.attachment_not_found'));
}
@@ -208,9 +202,9 @@ class AttachmentController extends Controller
* Delete a specific attachment in the system.
* @param $attachmentId
* @return mixed
* @throws \Exception
* @throws Exception
*/
public function delete($attachmentId)
public function delete(int $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('attachment-delete', $attachment);

View File

@@ -64,16 +64,15 @@ class ConfirmEmailController extends Controller
try {
$userId = $this->emailConfirmationService->checkTokenAndGetUserId($token);
} catch (Exception $exception) {
if ($exception instanceof UserTokenNotFoundException) {
session()->flash('error', trans('errors.email_confirmation_invalid'));
$this->showErrorNotification(trans('errors.email_confirmation_invalid'));
return redirect('/register');
}
if ($exception instanceof UserTokenExpiredException) {
$user = $this->userRepo->getById($exception->userId);
$this->emailConfirmationService->sendConfirmation($user);
session()->flash('error', trans('errors.email_confirmation_expired'));
$this->showErrorNotification(trans('errors.email_confirmation_expired'));
return redirect('/register/confirm');
}
@@ -85,7 +84,7 @@ class ConfirmEmailController extends Controller
$user->save();
auth()->login($user);
session()->flash('success', trans('auth.email_confirm_success'));
$this->showSuccessNotification(trans('auth.email_confirm_success'));
$this->emailConfirmationService->deleteByUser($user);
return redirect('/');
@@ -107,12 +106,11 @@ class ConfirmEmailController extends Controller
try {
$this->emailConfirmationService->sendConfirmation($user);
} catch (Exception $e) {
session()->flash('error', trans('auth.email_confirm_send_error'));
$this->showErrorNotification(trans('auth.email_confirm_send_error'));
return redirect('/register/confirm');
}
session()->flash('success', trans('auth.email_confirm_resent'));
$this->showSuccessNotification(trans('auth.email_confirm_resent'));
return redirect('/register/confirm');
}
}

View File

@@ -30,6 +30,7 @@ class ForgotPasswordController extends Controller
public function __construct()
{
$this->middleware('guest');
$this->middleware('guard:standard');
parent::__construct();
}
@@ -53,7 +54,7 @@ class ForgotPasswordController extends Controller
if ($response === Password::RESET_LINK_SENT) {
$message = trans('auth.reset_password_sent_success', ['email' => $request->get('email')]);
session()->flash('success', $message);
$this->showSuccessNotification($message);
return back()->with('status', trans($response));
}

View File

@@ -2,12 +2,11 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\LdapService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\AuthException;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller;
use Illuminate\Contracts\Auth\Authenticatable;
use Illuminate\Foundation\Auth\AuthenticatesUsers;
use Illuminate\Http\Request;
@@ -27,32 +26,23 @@ class LoginController extends Controller
use AuthenticatesUsers;
/**
* Where to redirect users after login.
*
* @var string
* Redirection paths
*/
protected $redirectTo = '/';
protected $redirectPath = '/';
protected $redirectAfterLogout = '/login';
protected $socialAuthService;
protected $ldapService;
protected $userRepo;
/**
* Create a new controller instance.
*
* @param \BookStack\Auth\\BookStack\Auth\Access\SocialAuthService $socialAuthService
* @param LdapService $ldapService
* @param \BookStack\Auth\UserRepo $userRepo
*/
public function __construct(SocialAuthService $socialAuthService, LdapService $ldapService, UserRepo $userRepo)
public function __construct(SocialAuthService $socialAuthService)
{
$this->middleware('guest', ['only' => ['getLogin', 'postLogin']]);
$this->middleware('guest', ['only' => ['getLogin', 'login']]);
$this->middleware('guard:standard,ldap', ['only' => ['login', 'logout']]);
$this->socialAuthService = $socialAuthService;
$this->ldapService = $ldapService;
$this->userRepo = $userRepo;
$this->redirectPath = url('/');
$this->redirectAfterLogout = url('/login');
parent::__construct();
@@ -64,55 +54,15 @@ class LoginController extends Controller
}
/**
* Overrides the action when a user is authenticated.
* If the user authenticated but does not exist in the user table we create them.
* @param Request $request
* @param Authenticatable $user
* @return \Illuminate\Http\RedirectResponse
* @throws AuthException
* @throws \BookStack\Exceptions\LdapException
* Get the needed authorization credentials from the request.
*/
protected function authenticated(Request $request, Authenticatable $user)
protected function credentials(Request $request)
{
// Explicitly log them out for now if they do no exist.
if (!$user->exists) {
auth()->logout($user);
}
if (!$user->exists && $user->email === null && !$request->filled('email')) {
$request->flash();
session()->flash('request-email', true);
return redirect('/login');
}
if (!$user->exists && $user->email === null && $request->filled('email')) {
$user->email = $request->get('email');
}
if (!$user->exists) {
// Check for users with same email already
$alreadyUser = $user->newQuery()->where('email', '=', $user->email)->count() > 0;
if ($alreadyUser) {
throw new AuthException(trans('errors.error_user_exists_different_creds', ['email' => $user->email]));
}
$user->save();
$this->userRepo->attachDefaultRole($user);
auth()->login($user);
}
// Sync LDAP groups if required
if ($this->ldapService->shouldSyncGroups()) {
$this->ldapService->syncGroups($user, $request->get($this->username()));
}
return redirect()->intended('/');
return $request->only('username', 'email', 'password');
}
/**
* Show the application login form.
* @param Request $request
* @return \Illuminate\Http\Response
*/
public function getLogin(Request $request)
{
@@ -126,18 +76,90 @@ class LoginController extends Controller
]);
}
return view('auth.login', ['socialDrivers' => $socialDrivers, 'authMethod' => $authMethod]);
return view('auth.login', [
'socialDrivers' => $socialDrivers,
'authMethod' => $authMethod,
]);
}
/**
* Redirect to the relevant social site.
* @param $socialDriver
* @return \Symfony\Component\HttpFoundation\RedirectResponse
* @throws \BookStack\Exceptions\SocialDriverNotConfigured
* Handle a login request to the application.
*
* @param \Illuminate\Http\Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Http\Response|\Illuminate\Http\JsonResponse
*
* @throws \Illuminate\Validation\ValidationException
*/
public function getSocialLogin($socialDriver)
public function login(Request $request)
{
session()->put('social-callback', 'login');
return $this->socialAuthService->startLogIn($socialDriver);
$this->validateLogin($request);
// If the class is using the ThrottlesLogins trait, we can automatically throttle
// the login attempts for this application. We'll key this by the username and
// the IP address of the client making these requests into this application.
if (method_exists($this, 'hasTooManyLoginAttempts') &&
$this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
return $this->sendLockoutResponse($request);
}
try {
if ($this->attemptLogin($request)) {
return $this->sendLoginResponse($request);
}
} catch (LoginAttemptException $exception) {
return $this->sendLoginAttemptExceptionResponse($exception, $request);
}
// If the login attempt was unsuccessful we will increment the number of attempts
// to login and redirect the user back to the login form. Of course, when this
// user surpasses their maximum number of attempts they will get locked out.
$this->incrementLoginAttempts($request);
return $this->sendFailedLoginResponse($request);
}
/**
* Validate the user login request.
*
* @param \Illuminate\Http\Request $request
* @return void
*
* @throws \Illuminate\Validation\ValidationException
*/
protected function validateLogin(Request $request)
{
$rules = ['password' => 'required|string'];
$authMethod = config('auth.method');
if ($authMethod === 'standard') {
$rules['email'] = 'required|email';
}
if ($authMethod === 'ldap') {
$rules['username'] = 'required|string';
$rules['email'] = 'email';
}
$request->validate($rules);
}
/**
* Send a response when a login attempt exception occurs.
*/
protected function sendLoginAttemptExceptionResponse(LoginAttemptException $exception, Request $request)
{
if ($exception instanceof LoginAttemptEmailNeededException) {
$request->flash();
session()->flash('request-email', true);
}
if ($message = $exception->getMessage()) {
$this->showWarningNotification($message);
}
return redirect('/login');
}
}

View File

@@ -2,23 +2,14 @@
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\EmailConfirmationService;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Auth\SocialAccount;
use BookStack\Auth\User;
use BookStack\Auth\UserRepo;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller;
use Exception;
use Illuminate\Foundation\Auth\RegistersUsers;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Routing\Redirector;
use Laravel\Socialite\Contracts\User as SocialUser;
use Illuminate\Support\Facades\Hash;
use Validator;
class RegisterController extends Controller
@@ -37,8 +28,7 @@ class RegisterController extends Controller
use RegistersUsers;
protected $socialAuthService;
protected $emailConfirmationService;
protected $userRepo;
protected $registrationService;
/**
* Where to redirect users after login / registration.
@@ -50,17 +40,15 @@ class RegisterController extends Controller
/**
* Create a new controller instance.
*
* @param SocialAuthService $socialAuthService
* @param EmailConfirmationService $emailConfirmationService
* @param UserRepo $userRepo
*/
public function __construct(SocialAuthService $socialAuthService, EmailConfirmationService $emailConfirmationService, UserRepo $userRepo)
public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
{
$this->middleware('guest')->only(['getRegister', 'postRegister', 'socialRegister']);
$this->middleware('guest');
$this->middleware('guard:standard');
$this->socialAuthService = $socialAuthService;
$this->emailConfirmationService = $emailConfirmationService;
$this->userRepo = $userRepo;
$this->registrationService = $registrationService;
$this->redirectTo = url('/');
$this->redirectPath = url('/');
parent::__construct();
@@ -69,7 +57,6 @@ class RegisterController extends Controller
/**
* Get a validator for an incoming registration request.
*
* @param array $data
* @return \Illuminate\Contracts\Validation\Validator
*/
protected function validator(array $data)
@@ -77,46 +64,45 @@ class RegisterController extends Controller
return Validator::make($data, [
'name' => 'required|min:2|max:255',
'email' => 'required|email|max:255|unique:users',
'password' => 'required|min:6',
'password' => 'required|min:8',
]);
}
/**
* Check whether or not registrations are allowed in the app settings.
* @throws UserRegistrationException
*/
protected function checkRegistrationAllowed()
{
if (!setting('registration-enabled')) {
throw new UserRegistrationException(trans('auth.registrations_disabled'), '/login');
}
}
/**
* Show the application registration form.
* @return Response
* @throws UserRegistrationException
*/
public function getRegister()
{
$this->checkRegistrationAllowed();
$this->registrationService->ensureRegistrationAllowed();
$socialDrivers = $this->socialAuthService->getActiveDrivers();
return view('auth.register', ['socialDrivers' => $socialDrivers]);
return view('auth.register', [
'socialDrivers' => $socialDrivers,
]);
}
/**
* Handle a registration request for the application.
* @param Request|Request $request
* @return RedirectResponse|Redirector
* @throws UserRegistrationException
*/
public function postRegister(Request $request)
{
$this->checkRegistrationAllowed();
$this->registrationService->ensureRegistrationAllowed();
$this->validator($request->all())->validate();
$userData = $request->all();
return $this->registerUser($userData);
try {
$user = $this->registrationService->registerUser($userData);
auth()->login($user);
} catch (UserRegistrationException $exception) {
if ($exception->getMessage()) {
$this->showErrorNotification($exception->getMessage());
}
return redirect($exception->redirectLocation);
}
$this->showSuccessNotification(trans('auth.register_success'));
return redirect($this->redirectPath());
}
/**
@@ -129,140 +115,8 @@ class RegisterController extends Controller
return User::create([
'name' => $data['name'],
'email' => $data['email'],
'password' => bcrypt($data['password']),
'password' => Hash::make($data['password']),
]);
}
/**
* The registrations flow for all users.
* @param array $userData
* @param bool|false|SocialAccount $socialAccount
* @param bool $emailVerified
* @return RedirectResponse|Redirector
* @throws UserRegistrationException
*/
protected function registerUser(array $userData, $socialAccount = false, $emailVerified = false)
{
$registrationRestrict = setting('registration-restrict');
if ($registrationRestrict) {
$restrictedEmailDomains = explode(',', str_replace(' ', '', $registrationRestrict));
$userEmailDomain = $domain = mb_substr(mb_strrchr($userData['email'], "@"), 1);
if (!in_array($userEmailDomain, $restrictedEmailDomains)) {
throw new UserRegistrationException(trans('auth.registration_email_domain_invalid'), '/register');
}
}
$newUser = $this->userRepo->registerNew($userData, $emailVerified);
if ($socialAccount) {
$newUser->socialAccounts()->save($socialAccount);
}
if ($this->emailConfirmationService->confirmationRequired() && !$emailVerified) {
$newUser->save();
try {
$this->emailConfirmationService->sendConfirmation($newUser);
} catch (Exception $e) {
session()->flash('error', trans('auth.email_confirm_send_error'));
}
return redirect('/register/confirm');
}
auth()->login($newUser);
session()->flash('success', trans('auth.register_success'));
return redirect($this->redirectPath());
}
/**
* Redirect to the social site for authentication intended to register.
* @param $socialDriver
* @return mixed
* @throws UserRegistrationException
* @throws SocialDriverNotConfigured
*/
public function socialRegister($socialDriver)
{
$this->checkRegistrationAllowed();
session()->put('social-callback', 'register');
return $this->socialAuthService->startRegister($socialDriver);
}
/**
* The callback for social login services.
* @param $socialDriver
* @param Request $request
* @return RedirectResponse|Redirector
* @throws SocialSignInException
* @throws UserRegistrationException
* @throws SocialDriverNotConfigured
*/
public function socialCallback($socialDriver, Request $request)
{
if (!session()->has('social-callback')) {
throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
}
// Check request for error information
if ($request->has('error') && $request->has('error_description')) {
throw new SocialSignInException(trans('errors.social_login_bad_response', [
'socialAccount' => $socialDriver,
'error' => $request->get('error_description'),
]), '/login');
}
$action = session()->pull('social-callback');
// Attempt login or fall-back to register if allowed.
$socialUser = $this->socialAuthService->getSocialUser($socialDriver);
if ($action == 'login') {
try {
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
} catch (SocialSignInAccountNotUsed $exception) {
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
throw $exception;
}
}
if ($action == 'register') {
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
return redirect()->back();
}
/**
* Detach a social account from a user.
* @param $socialDriver
* @return RedirectResponse|Redirector
*/
public function detachSocialAccount($socialDriver)
{
return $this->socialAuthService->detachSocialAccount($socialDriver);
}
/**
* Register a new user after a registration callback.
* @param string $socialDriver
* @param SocialUser $socialUser
* @return RedirectResponse|Redirector
* @throws UserRegistrationException
*/
protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
{
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
// Create an array of the user data to create a new user instance
$userData = [
'name' => $socialUser->getName(),
'email' => $socialUser->getEmail(),
'password' => str_random(30)
];
return $this->registerUser($userData, $socialAccount, $emailVerified);
}
}

View File

@@ -4,6 +4,7 @@ namespace BookStack\Http\Controllers\Auth;
use BookStack\Http\Controllers\Controller;
use Illuminate\Foundation\Auth\ResetsPasswords;
use Illuminate\Http\Request;
class ResetPasswordController extends Controller
{
@@ -30,19 +31,21 @@ class ResetPasswordController extends Controller
public function __construct()
{
$this->middleware('guest');
$this->middleware('guard:standard');
parent::__construct();
}
/**
* Get the response for a successful password reset.
*
* @param string $response
* @param Request $request
* @param string $response
* @return \Illuminate\Http\Response
*/
protected function sendResetResponse($response)
protected function sendResetResponse(Request $request, $response)
{
$message = trans('auth.reset_password_success');
session()->flash('success', $message);
$this->showSuccessNotification($message);
return redirect($this->redirectPath())
->with('status', trans($response));
}

View File

@@ -0,0 +1,87 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\Saml2Service;
use BookStack\Http\Controllers\Controller;
class Saml2Controller extends Controller
{
protected $samlService;
/**
* Saml2Controller constructor.
*/
public function __construct(Saml2Service $samlService)
{
parent::__construct();
$this->samlService = $samlService;
$this->middleware('guard:saml2');
}
/**
* Start the login flow via SAML2.
*/
public function login()
{
$loginDetails = $this->samlService->login();
session()->flash('saml2_request_id', $loginDetails['id']);
return redirect($loginDetails['url']);
}
/**
* Start the logout flow via SAML2.
*/
public function logout()
{
$logoutDetails = $this->samlService->logout();
if ($logoutDetails['id']) {
session()->flash('saml2_logout_request_id', $logoutDetails['id']);
}
return redirect($logoutDetails['url']);
}
/*
* Get the metadata for this SAML2 service provider.
*/
public function metadata()
{
$metaData = $this->samlService->metadata();
return response()->make($metaData, 200, [
'Content-Type' => 'text/xml'
]);
}
/**
* Single logout service.
* Handle logout requests and responses.
*/
public function sls()
{
$requestId = session()->pull('saml2_logout_request_id', null);
$redirect = $this->samlService->processSlsResponse($requestId) ?? '/';
return redirect($redirect);
}
/**
* Assertion Consumer Service.
* Processes the SAML response from the IDP.
*/
public function acs()
{
$requestId = session()->pull('saml2_request_id', null);
$user = $this->samlService->processAcsResponse($requestId);
if ($user === null) {
$this->showErrorNotification(trans('errors.saml_fail_authed', ['system' => config('saml2.name')]));
return redirect('/login');
}
return redirect()->intended();
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace BookStack\Http\Controllers\Auth;
use BookStack\Auth\Access\RegistrationService;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\SocialDriverNotConfigured;
use BookStack\Exceptions\SocialSignInAccountNotUsed;
use BookStack\Exceptions\SocialSignInException;
use BookStack\Exceptions\UserRegistrationException;
use BookStack\Http\Controllers\Controller;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\Support\Str;
use Laravel\Socialite\Contracts\User as SocialUser;
class SocialController extends Controller
{
protected $socialAuthService;
protected $registrationService;
/**
* SocialController constructor.
*/
public function __construct(SocialAuthService $socialAuthService, RegistrationService $registrationService)
{
$this->middleware('guest')->only(['getRegister', 'postRegister']);
$this->socialAuthService = $socialAuthService;
$this->registrationService = $registrationService;
}
/**
* Redirect to the relevant social site.
* @throws \BookStack\Exceptions\SocialDriverNotConfigured
*/
public function getSocialLogin(string $socialDriver)
{
session()->put('social-callback', 'login');
return $this->socialAuthService->startLogIn($socialDriver);
}
/**
* Redirect to the social site for authentication intended to register.
* @throws SocialDriverNotConfigured
* @throws UserRegistrationException
*/
public function socialRegister(string $socialDriver)
{
$this->registrationService->ensureRegistrationAllowed();
session()->put('social-callback', 'register');
return $this->socialAuthService->startRegister($socialDriver);
}
/**
* The callback for social login services.
* @throws SocialSignInException
* @throws SocialDriverNotConfigured
* @throws UserRegistrationException
*/
public function socialCallback(Request $request, string $socialDriver)
{
if (!session()->has('social-callback')) {
throw new SocialSignInException(trans('errors.social_no_action_defined'), '/login');
}
// Check request for error information
if ($request->has('error') && $request->has('error_description')) {
throw new SocialSignInException(trans('errors.social_login_bad_response', [
'socialAccount' => $socialDriver,
'error' => $request->get('error_description'),
]), '/login');
}
$action = session()->pull('social-callback');
// Attempt login or fall-back to register if allowed.
$socialUser = $this->socialAuthService->getSocialUser($socialDriver);
if ($action === 'login') {
try {
return $this->socialAuthService->handleLoginCallback($socialDriver, $socialUser);
} catch (SocialSignInAccountNotUsed $exception) {
if ($this->socialAuthService->driverAutoRegisterEnabled($socialDriver)) {
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
throw $exception;
}
}
if ($action === 'register') {
return $this->socialRegisterCallback($socialDriver, $socialUser);
}
return redirect()->back();
}
/**
* Detach a social account from a user.
*/
public function detachSocialAccount(string $socialDriver)
{
$this->socialAuthService->detachSocialAccount($socialDriver);
session()->flash('success', trans('settings.users_social_disconnected', ['socialAccount' => Str::title($socialDriver)]));
return redirect(user()->getEditUrl());
}
/**
* Register a new user after a registration callback.
* @throws UserRegistrationException
*/
protected function socialRegisterCallback(string $socialDriver, SocialUser $socialUser)
{
$socialUser = $this->socialAuthService->handleRegistrationCallback($socialDriver, $socialUser);
$socialAccount = $this->socialAuthService->fillSocialAccount($socialDriver, $socialUser);
$emailVerified = $this->socialAuthService->driverAutoConfirmEmailEnabled($socialDriver);
// Create an array of the user data to create a new user instance
$userData = [
'name' => $socialUser->getName(),
'email' => $socialUser->getEmail(),
'password' => Str::random(32)
];
$user = $this->registrationService->registerUser($userData, $socialAccount, $emailVerified);
auth()->login($user);
$this->showSuccessNotification(trans('auth.register_success'));
return redirect('/');
}
}

View File

@@ -8,11 +8,9 @@ use BookStack\Exceptions\UserTokenExpiredException;
use BookStack\Exceptions\UserTokenNotFoundException;
use BookStack\Http\Controllers\Controller;
use Exception;
use Illuminate\Contracts\View\Factory;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Redirector;
use Illuminate\View\View;
class UserInviteController extends Controller
{
@@ -21,22 +19,20 @@ class UserInviteController extends Controller
/**
* Create a new controller instance.
*
* @param UserInviteService $inviteService
* @param UserRepo $userRepo
*/
public function __construct(UserInviteService $inviteService, UserRepo $userRepo)
{
$this->middleware('guest');
$this->middleware('guard:standard');
$this->inviteService = $inviteService;
$this->userRepo = $userRepo;
$this->middleware('guest');
parent::__construct();
}
/**
* Show the page for the user to set the password for their account.
* @param string $token
* @return Factory|View|RedirectResponse
* @throws Exception
*/
public function showSetPassword(string $token)
@@ -54,15 +50,12 @@ class UserInviteController extends Controller
/**
* Sets the password for an invited user and then grants them access.
* @param string $token
* @param Request $request
* @return RedirectResponse|Redirector
* @throws Exception
*/
public function setPassword(string $token, Request $request)
public function setPassword(Request $request, string $token)
{
$this->validate($request, [
'password' => 'required|min:6'
'password' => 'required|min:8'
]);
try {
@@ -77,7 +70,7 @@ class UserInviteController extends Controller
$user->save();
auth()->login($user);
session()->flash('success', trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->showSuccessNotification(trans('auth.user_invite_success', ['appName' => setting('app-name')]));
$this->inviteService->deleteByUser($user);
return redirect('/');
@@ -85,7 +78,6 @@ class UserInviteController extends Controller
/**
* Check and validate the exception thrown when checking an invite token.
* @param Exception $exception
* @return RedirectResponse|Redirector
* @throws Exception
*/
@@ -96,11 +88,10 @@ class UserInviteController extends Controller
}
if ($exception instanceof UserTokenExpiredException) {
session()->flash('error', trans('errors.invite_token_expired'));
$this->showErrorNotification(trans('errors.invite_token_expired'));
return redirect('/password/email');
}
throw $exception;
}
}

View File

@@ -1,67 +1,46 @@
<?php namespace BookStack\Http\Controllers;
use Activity;
use BookStack\Auth\UserRepo;
use BookStack\Entities\Book;
use BookStack\Entities\EntityContextManager;
use BookStack\Entities\Repos\EntityRepo;
use BookStack\Entities\ExportService;
use BookStack\Uploads\ImageRepo;
use BookStack\Entities\Managers\BookContents;
use BookStack\Entities\Bookshelf;
use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\Repos\BookRepo;
use BookStack\Exceptions\ImageUploadException;
use BookStack\Exceptions\NotifyException;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Validation\ValidationException;
use Throwable;
use Views;
class BookController extends Controller
{
protected $entityRepo;
protected $userRepo;
protected $exportService;
protected $bookRepo;
protected $entityContextManager;
protected $imageRepo;
/**
* BookController constructor.
* @param EntityRepo $entityRepo
* @param UserRepo $userRepo
* @param ExportService $exportService
* @param EntityContextManager $entityContextManager
* @param ImageRepo $imageRepo
*/
public function __construct(
EntityRepo $entityRepo,
UserRepo $userRepo,
ExportService $exportService,
EntityContextManager $entityContextManager,
ImageRepo $imageRepo
) {
$this->entityRepo = $entityRepo;
$this->userRepo = $userRepo;
$this->exportService = $exportService;
public function __construct(EntityContext $entityContextManager, BookRepo $bookRepo)
{
$this->bookRepo = $bookRepo;
$this->entityContextManager = $entityContextManager;
$this->imageRepo = $imageRepo;
parent::__construct();
}
/**
* Display a listing of the book.
* @return Response
*/
public function index()
{
$view = setting()->getUser($this->currentUser, 'books_view_type', config('app.views.books'));
$sort = setting()->getUser($this->currentUser, 'books_sort', 'name');
$order = setting()->getUser($this->currentUser, 'books_sort_order', 'asc');
$sortOptions = [
'name' => trans('common.sort_name'),
'created_at' => trans('common.sort_created_at'),
'updated_at' => trans('common.sort_updated_at'),
];
$view = setting()->getForCurrentUser('books_view_type', config('app.views.books'));
$sort = setting()->getForCurrentUser('books_sort', 'name');
$order = setting()->getForCurrentUser('books_sort_order', 'asc');
$books = $this->entityRepo->getAllPaginated('book', 18, $sort, $order);
$recents = $this->signedIn ? $this->entityRepo->getRecentlyViewed('book', 4, 0) : false;
$popular = $this->entityRepo->getPopular('book', 4, 0);
$new = $this->entityRepo->getRecentlyCreated('book', 4, 0);
$books = $this->bookRepo->getAllPaginated(18, $sort, $order);
$recents = $this->isSignedIn() ? $this->bookRepo->getRecentlyViewed(4) : false;
$popular = $this->bookRepo->getPopular(4);
$new = $this->bookRepo->getRecentlyCreated(4);
$this->entityContextManager->clearShelfContext();
@@ -74,25 +53,22 @@ class BookController extends Controller
'view' => $view,
'sort' => $sort,
'order' => $order,
'sortOptions' => $sortOptions,
]);
}
/**
* Show the form for creating a new book.
* @param string $shelfSlug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
*/
public function create(string $shelfSlug = null)
{
$this->checkPermission('book-create-all');
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = $this->entityRepo->getBySlug('bookshelf', $shelfSlug);
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
$this->checkPermission('book-create-all');
$this->setPageTitle(trans('entities.books_create'));
return view('books.create', [
'bookshelf' => $bookshelf
@@ -101,12 +77,8 @@ class BookController extends Controller
/**
* Store a newly created book in storage.
*
* @param Request $request
* @param string $shelfSlug
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
* @throws \BookStack\Exceptions\ImageUploadException
* @throws ImageUploadException
* @throws ValidationException
*/
public function store(Request $request, string $shelfSlug = null)
{
@@ -114,21 +86,21 @@ class BookController extends Controller
$this->validate($request, [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'image' => $this->imageRepo->getImageValidationRules(),
'image' => $this->getImageValidationRules(),
]);
$bookshelf = null;
if ($shelfSlug !== null) {
$bookshelf = $this->entityRepo->getBySlug('bookshelf', $shelfSlug);
$bookshelf = Bookshelf::visible()->where('slug', '=', $shelfSlug)->firstOrFail();
$this->checkOwnablePermission('bookshelf-update', $bookshelf);
}
$book = $this->entityRepo->createFromInput('book', $request->all());
$this->bookUpdateActions($book, $request);
$book = $this->bookRepo->create($request->all());
$this->bookRepo->updateCoverImage($book, $request->file('image', null));
Activity::add($book, 'book_create', $book->id);
if ($bookshelf) {
$this->entityRepo->appendBookToShelf($bookshelf, $book);
$bookshelf->appendBook($book);
Activity::add($bookshelf, 'bookshelf_update');
}
@@ -137,17 +109,11 @@ class BookController extends Controller
/**
* Display the specified book.
* @param $slug
* @param Request $request
* @return Response
* @throws \BookStack\Exceptions\NotFoundException
*/
public function show($slug, Request $request)
public function show(Request $request, string $slug)
{
$book = $this->entityRepo->getBySlug('book', $slug);
$this->checkOwnablePermission('book-view', $book);
$bookChildren = $this->entityRepo->getBookChildren($book);
$book = $this->bookRepo->getBySlug($slug);
$bookChildren = (new BookContents($book))->getTree(true);
Views::add($book);
if ($request->has('shelf')) {
@@ -165,12 +131,10 @@ class BookController extends Controller
/**
* Show the form for editing the specified book.
* @param $slug
* @return Response
*/
public function edit($slug)
public function edit(string $slug)
{
$book = $this->entityRepo->getBySlug('book', $slug);
$book = $this->bookRepo->getBySlug($slug);
$this->checkOwnablePermission('book-update', $book);
$this->setPageTitle(trans('entities.books_edit_named', ['bookName'=>$book->getShortName()]));
return view('books.edit', ['book' => $book, 'current' => $book]);
@@ -178,254 +142,83 @@ class BookController extends Controller
/**
* Update the specified book in storage.
* @param Request $request
* @param $slug
* @return Response
* @throws \BookStack\Exceptions\ImageUploadException
* @throws \BookStack\Exceptions\NotFoundException
* @throws ImageUploadException
* @throws ValidationException
* @throws Throwable
*/
public function update(Request $request, string $slug)
{
$book = $this->entityRepo->getBySlug('book', $slug);
$book = $this->bookRepo->getBySlug($slug);
$this->checkOwnablePermission('book-update', $book);
$this->validate($request, [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'image' => $this->imageRepo->getImageValidationRules(),
'image' => $this->getImageValidationRules(),
]);
$book = $this->entityRepo->updateFromInput('book', $book, $request->all());
$this->bookUpdateActions($book, $request);
$book = $this->bookRepo->update($book, $request->all());
$resetCover = $request->has('image_reset');
$this->bookRepo->updateCoverImage($book, $request->file('image', null), $resetCover);
Activity::add($book, 'book_update', $book->id);
return redirect($book->getUrl());
}
/**
* Shows the page to confirm deletion
* @param $bookSlug
* @return \Illuminate\View\View
*/
public function showDelete($bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('book-delete', $book);
$this->setPageTitle(trans('entities.books_delete_named', ['bookName'=>$book->getShortName()]));
return view('books.delete', ['book' => $book, 'current' => $book]);
}
/**
* Shows the view which allows pages to be re-ordered and sorted.
* @param string $bookSlug
* @return \Illuminate\View\View
* @throws \BookStack\Exceptions\NotFoundException
*/
public function sort($bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('book-update', $book);
$bookChildren = $this->entityRepo->getBookChildren($book, true);
$this->setPageTitle(trans('entities.books_sort_named', ['bookName'=>$book->getShortName()]));
return view('books.sort', ['book' => $book, 'current' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Shows the sort box for a single book.
* Used via AJAX when loading in extra books to a sort.
* @param $bookSlug
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function getSortItem($bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$bookChildren = $this->entityRepo->getBookChildren($book);
return view('books.sort-box', ['book' => $book, 'bookChildren' => $bookChildren]);
}
/**
* Saves an array of sort mapping to pages and chapters.
* @param string $bookSlug
* @param Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function saveSort($bookSlug, Request $request)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$this->checkOwnablePermission('book-update', $book);
// Return if no map sent
if (!$request->filled('sort-tree')) {
return redirect($book->getUrl());
}
// Sort pages and chapters
$sortMap = collect(json_decode($request->get('sort-tree')));
$bookIdsInvolved = collect([$book->id]);
// Load models into map
$sortMap->each(function ($mapItem) use ($bookIdsInvolved) {
$mapItem->type = ($mapItem->type === 'page' ? 'page' : 'chapter');
$mapItem->model = $this->entityRepo->getById($mapItem->type, $mapItem->id);
// Store source and target books
$bookIdsInvolved->push(intval($mapItem->model->book_id));
$bookIdsInvolved->push(intval($mapItem->book));
});
// Get the books involved in the sort
$bookIdsInvolved = $bookIdsInvolved->unique()->toArray();
$booksInvolved = $this->entityRepo->getManyById('book', $bookIdsInvolved, false, true);
// Throw permission error if invalid ids or inaccessible books given.
if (count($bookIdsInvolved) !== count($booksInvolved)) {
$this->showPermissionError();
}
// Check permissions of involved books
$booksInvolved->each(function (Book $book) {
$this->checkOwnablePermission('book-update', $book);
});
// Perform the sort
$sortMap->each(function ($mapItem) {
$model = $mapItem->model;
$priorityChanged = intval($model->priority) !== intval($mapItem->sort);
$bookChanged = intval($model->book_id) !== intval($mapItem->book);
$chapterChanged = ($mapItem->type === 'page') && intval($model->chapter_id) !== $mapItem->parentChapter;
if ($bookChanged) {
$this->entityRepo->changeBook($mapItem->type, $mapItem->book, $model);
}
if ($chapterChanged) {
$model->chapter_id = intval($mapItem->parentChapter);
$model->save();
}
if ($priorityChanged) {
$model->priority = intval($mapItem->sort);
$model->save();
}
});
// Rebuild permissions and add activity for involved books.
$booksInvolved->each(function (Book $book) {
$this->entityRepo->buildJointPermissionsForBook($book);
Activity::add($book, 'book_sort', $book->id);
});
Activity::add($book, 'book_update', $book->id);
return redirect($book->getUrl());
}
/**
* Remove the specified book from storage.
* @param $bookSlug
* @return Response
* Shows the page to confirm deletion.
*/
public function destroy($bookSlug)
public function showDelete(string $bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
Activity::addMessage('book_delete', 0, $book->name);
$this->setPageTitle(trans('entities.books_delete_named', ['bookName' => $book->getShortName()]));
return view('books.delete', ['book' => $book, 'current' => $book]);
}
if ($book->cover) {
$this->imageRepo->destroyImage($book->cover);
}
$this->entityRepo->destroyBook($book);
/**
* Remove the specified book from the system.
* @throws Throwable
* @throws NotifyException
*/
public function destroy(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('book-delete', $book);
Activity::addMessage('book_delete', $book->name);
$this->bookRepo->destroy($book);
return redirect('/books');
}
/**
* Show the Restrictions view.
* @param $bookSlug
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* Show the permissions view.
*/
public function showPermissions($bookSlug)
public function showPermissions(string $bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$roles = $this->userRepo->getRestrictableRoles();
return view('books.permissions', [
'book' => $book,
'roles' => $roles
]);
}
/**
* Set the restrictions for this book.
* @param $bookSlug
* @param Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws \BookStack\Exceptions\NotFoundException
* @throws \Throwable
* @throws Throwable
*/
public function permissions($bookSlug, Request $request)
public function permissions(Request $request, string $bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$book = $this->bookRepo->getBySlug($bookSlug);
$this->checkOwnablePermission('restrictions-manage', $book);
$this->entityRepo->updateEntityPermissionsFromRequest($request, $book);
session()->flash('success', trans('entities.books_permissions_updated'));
$restricted = $request->get('restricted') === 'true';
$permissions = $request->filled('restrictions') ? collect($request->get('restrictions')) : null;
$this->bookRepo->updatePermissions($book, $restricted, $permissions);
$this->showSuccessNotification(trans('entities.books_permissions_updated'));
return redirect($book->getUrl());
}
/**
* Export a book as a PDF file.
* @param string $bookSlug
* @return mixed
*/
public function exportPdf($bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$pdfContent = $this->exportService->bookToPdf($book);
return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
}
/**
* Export a book as a contained HTML file.
* @param string $bookSlug
* @return mixed
*/
public function exportHtml($bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$htmlContent = $this->exportService->bookToContainedHtml($book);
return $this->downloadResponse($htmlContent, $bookSlug . '.html');
}
/**
* Export a book as a plain text file.
* @param $bookSlug
* @return mixed
*/
public function exportPlainText($bookSlug)
{
$book = $this->entityRepo->getBySlug('book', $bookSlug);
$textContent = $this->exportService->bookToPlainText($book);
return $this->downloadResponse($textContent, $bookSlug . '.txt');
}
/**
* Common actions to run on book update.
* Handles updating the cover image.
* @param Book $book
* @param Request $request
* @throws \BookStack\Exceptions\ImageUploadException
*/
protected function bookUpdateActions(Book $book, Request $request)
{
// Update the cover image if in request
if ($request->has('image')) {
$this->imageRepo->destroyImage($book->cover);
$newImage = $request->file('image');
$image = $this->imageRepo->saveNew($newImage, 'cover_book', $book->id, 512, 512, true);
$book->image_id = $image->id;
$book->save();
}
if ($request->has('image_reset')) {
$this->imageRepo->destroyImage($book->cover);
$book->image_id = 0;
$book->save();
}
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
class BookExportController extends Controller
{
protected $bookRepo;
protected $exportService;
/**
* BookExportController constructor.
*/
public function __construct(BookRepo $bookRepo, ExportService $exportService)
{
$this->bookRepo = $bookRepo;
$this->exportService = $exportService;
parent::__construct();
}
/**
* Export a book as a PDF file.
* @throws Throwable
*/
public function pdf(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$pdfContent = $this->exportService->bookToPdf($book);
return $this->downloadResponse($pdfContent, $bookSlug . '.pdf');
}
/**
* Export a book as a contained HTML file.
* @throws Throwable
*/
public function html(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$htmlContent = $this->exportService->bookToContainedHtml($book);
return $this->downloadResponse($htmlContent, $bookSlug . '.html');
}
/**
* Export a book as a plain text file.
*/
public function plainText(string $bookSlug)
{
$book = $this->bookRepo->getBySlug($bookSlug);
$textContent = $this->exportService->bookToPlainText($book);
return $this->downloadResponse($textContent, $bookSlug . '.txt');
}
}

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