Compare commits

...

126 Commits

Author SHA1 Message Date
Dan Brown
751772b87a Updated version and assets for release v0.30.2 2020-09-30 22:44:58 +01:00
Dan Brown
76e30869e1 Merge branch 'master' into release 2020-09-30 22:44:17 +01:00
Dan Brown
f3ee8f2d4c Updated http service to not read 204 response data 2020-09-30 22:32:03 +01:00
Dan Brown
ea406690f5 Updated esbuild options and version & updated npm deps
Had to change way sortable is imported due to changes, Still
seemed to have functioning multi-select.
2020-09-30 22:28:53 +01:00
Dan Brown
465d405926 Updated page content related links on content id changes
For #2278
2020-09-28 22:26:50 +01:00
Dan Brown
1097c61d6d Fixed duplicate requests in attachment manager issue
Closes #2286
2020-09-28 21:55:24 +01:00
Dan Brown
def2d61ad8 Merge pull request #2272 from jakubboucek/feature/fix-invalid-canonical-redirect
Fixed canonical redirects on non-root url app instances
2020-09-28 21:15:23 +01:00
Dan Brown
8b0f5e7000 Updated draw.io references to diagrams.net
Related to #2044
2020-09-28 20:45:38 +01:00
Jakub Bouček
1e88e8086f Fixed canonical redirects on non-root url app instances
If BookStack instance is deployed to any non-root path, e.g. http://example.com/wiki/,
requests for http://example.com/wiki/shelves/
was redirected to http://example.com/shelves
instead of http://example.com/wiki/shelves

Synced with: https://github.com/laravel/laravel/blob/master/public/.htaccess
2020-09-27 02:50:37 +02:00
Dan Brown
d48ac0a37d Removed redundant test
Now replaced in recent commit by one that checks actual message gets
displayed on the redirect page.
Redirect page changed to login page.
2020-09-26 18:24:05 +01:00
Dan Brown
3edc9fe9eb Updated version and assets for release v0.30.1 2020-09-26 17:51:37 +01:00
Dan Brown
616c62703e Merge branch 'master' into release 2020-09-26 17:50:25 +01:00
Dan Brown
3eeb1e7d08 Updated translators fiel with latest 2020-09-26 17:48:02 +01:00
Dan Brown
0d43b50f9d New Crowdin updates (#2262)
* New translations entities.php (Russian)

* New translations settings.php (Russian)

* New translations entities.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Chinese Simplified)

* New translations entities.php (Czech)

* New translations common.php (Czech)

* New translations components.php (Czech)

* New translations settings.php (Czech)

* New translations errors.php (Czech)

* New translations settings.php (Czech)

* New translations settings.php (Czech)

* New translations settings.php (Czech)

* New translations settings.php (German)

* New translations settings.php (German)

* New translations entities.php (German)

* New translations validation.php (Czech)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (German Informal)

* New translations settings.php (German Informal)

* New translations auth.php (Czech)
2020-09-26 17:46:32 +01:00
Dan Brown
6bcfac6751 Updated codemirror and updated codemirror base styles
Aligns styles with current release, since was causing overflow
with scrollbars.

Fixes #2267
2020-09-26 17:33:43 +01:00
Dan Brown
68489e5b44 Updated PR code to use isA and updated that function definition
Related to #2227
2020-09-26 17:00:17 +01:00
Dan Brown
fe0e307313 Merge branch 'renderpages' of git://github.com/mr-vinn/BookStack into mr-vinn-renderpages 2020-09-26 16:55:05 +01:00
Dan Brown
9985046685 Added test for includes on book export
Related to #2227
2020-09-26 16:54:24 +01:00
Dan Brown
53ec794e53 Fixed issue where SAML login not notifiy on existing user
Added testing to cover

Fixes #2263
2020-09-26 16:43:06 +01:00
Dan Brown
328d2514c4 Updated settings nav to be more flexible
Uses flexbox layout, flexed to content instead of rigid thirds like
before. Also extracted row into own file
2020-09-26 16:26:30 +01:00
Dan Brown
de2756dd95 Updated callout links to be correct colors
- Also updated to be underlined instead of bold
2020-09-26 15:40:51 +01:00
Dan Brown
1f97047799 Merge branch 'master' of git://github.com/alexmannuk/BookStack into alexmannuk-master 2020-09-26 15:35:13 +01:00
Dan Brown
c870c10e38 Merge pull request #2270 from gertjankrol/feature/test-migrations-workflow
Add `test-migrations` workflow
2020-09-26 15:25:17 +01:00
Dan Brown
49fa21c1e2 Merge pull request #2268 from gertjankrol/master
Fix the `AddActivityIndexes` migration's `down()` method
2020-09-26 15:21:21 +01:00
Dan Brown
9f87423584 Merge pull request #2274 from abulgatz/patch-1
Fixed "Ubunto Mono" $mono type misspelling
2020-09-26 12:11:53 +01:00
Dan Brown
08fbd39fcb Fixed markdown iframe loading and content alignment
Fixes #2280
2020-09-26 12:01:01 +01:00
Adam
5f75a9f32c Fix "Ubunto Mono" $mono type misspelling 2020-09-23 16:19:30 -05:00
Gertjan Krol
3750922c3e Added the test-migrations workflow 2020-09-22 19:53:45 +02:00
Gertjan Krol
4b0d1ddf39 Fixed the AddActivityIndexes migration's down() method 2020-09-22 19:22:27 +02:00
Dan Brown
ecd56917e7 Updated version and assets for release v0.30.0 2020-09-20 10:33:18 +01:00
Dan Brown
e22c9cae91 Merge branch 'master' into release 2020-09-20 10:30:10 +01:00
Dan Brown
a6c20c321f Merged latest translation changes 2020-09-20 10:28:01 +01:00
Dan Brown
e12012a6fc Updated translation contributors 2020-09-20 09:15:02 +01:00
Dan Brown
73b4c6d947 Fixed some wording in example env 2020-09-19 23:09:08 +01:00
Dan Brown
9e11fc33fa Updated example env with helpful info
- Added comments to explain the use of the file.
- Added comments to advise that space/hash containing values would need
to be quoted.

Related to #2258
2020-09-19 16:09:43 +01:00
Dan Brown
ff46d81681 Merge branch 'jb-l10n-fix-czech' of git://github.com/jakubboucek/BookStack into jakubboucek-jb-l10n-fix-czech 2020-09-19 15:44:18 +01:00
Dan Brown
1f202f6dbc Updated locale lists for Bulgarian 2020-09-19 15:36:17 +01:00
Dan Brown
bccf4653cb New Crowdin translations (#2077)
* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Persian)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (Thai)

* New translations errors.php (German Informal)

* New translations entities.php (Spanish)

* New translations entities.php (French)

* New translations entities.php (Arabic)

* New translations entities.php (Arabic)

* New translations components.php (Portuguese, Brazilian)

* New translations entities.php (Portuguese, Brazilian)

* New translations auth.php (Italian)

* New translations common.php (Italian)

* New translations components.php (Italian)

* New translations entities.php (Italian)

* New translations settings.php (Italian)

* New translations components.php (Chinese Simplified)

* New translations entities.php (Chinese Simplified)

* New translations settings.php (Spanish)

* New translations components.php (German)

* New translations components.php (Japanese)

* New translations components.php (Dutch)

* New translations components.php (German Informal)

* New translations components.php (Portuguese, Brazilian)

* New translations common.php (Ukrainian)

* New translations components.php (Portuguese)

* New translations common.php (Russian)

* New translations components.php (Russian)

* New translations common.php (Slovak)

* New translations components.php (Slovak)

* New translations common.php (Slovenian)

* New translations components.php (Slovenian)

* New translations common.php (Swedish)

* New translations components.php (Swedish)

* New translations common.php (Turkish)

* New translations components.php (Turkish)

* New translations components.php (Ukrainian)

* New translations components.php (Polish)

* New translations common.php (Chinese Simplified)

* New translations common.php (Chinese Traditional)

* New translations components.php (Chinese Traditional)

* New translations common.php (Vietnamese)

* New translations components.php (Vietnamese)

* New translations common.php (Portuguese, Brazilian)

* New translations common.php (Persian)

* New translations components.php (Persian)

* New translations common.php (Spanish, Argentina)

* New translations components.php (Spanish, Argentina)

* New translations common.php (Thai)

* New translations components.php (Thai)

* New translations common.php (Portuguese)

* New translations common.php (Polish)

* New translations common.php (Italian)

* New translations common.php (Bulgarian)

* New translations components.php (Italian)

* New translations components.php (Chinese Simplified)

* New translations components.php (German)

* New translations components.php (Japanese)

* New translations components.php (Dutch)

* New translations components.php (German Informal)

* New translations common.php (French)

* New translations components.php (French)

* New translations common.php (Spanish)

* New translations components.php (Spanish)

* New translations common.php (Arabic)

* New translations components.php (Arabic)

* New translations components.php (Bulgarian)

* New translations common.php (Dutch)

* New translations common.php (Czech)

* New translations components.php (Czech)

* New translations common.php (Danish)

* New translations components.php (Danish)

* New translations common.php (German)

* New translations common.php (Hebrew)

* New translations components.php (Hebrew)

* New translations common.php (Hungarian)

* New translations components.php (Hungarian)

* New translations common.php (Japanese)

* New translations common.php (Korean)

* New translations components.php (Korean)

* New translations common.php (German Informal)

* New translations components.php (German)

* New translations common.php (German)

* New translations entities.php (German)

* New translations common.php (French)

* New translations components.php (French)

* New translations common.php (Spanish)

* New translations components.php (Spanish)

* New translations components.php (Chinese Simplified)

* New translations common.php (Chinese Simplified)

* New translations common.php (Polish)

* New translations components.php (Polish)

* New translations auth.php (Polish)

* New translations entities.php (Polish)

* New translations errors.php (Polish)

* New translations passwords.php (Polish)

* New translations settings.php (Polish)

* New translations settings.php (Polish)

* New translations common.php (Spanish, Argentina)

* New translations components.php (Spanish, Argentina)

* New translations auth.php (Spanish, Argentina)

* New translations entities.php (Spanish, Argentina)

* New translations passwords.php (Spanish, Argentina)

* New translations settings.php (Spanish, Argentina)

* New translations entities.php (German)

* New translations components.php (German Informal)

* New translations common.php (German Informal)

* New translations entities.php (German Informal)

* New translations settings.php (Italian)

* New translations settings.php (Dutch)

* New translations settings.php (Thai)

* New translations settings.php (Persian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Vietnamese)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Ukrainian)

* New translations settings.php (Turkish)

* New translations settings.php (Swedish)

* New translations settings.php (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Korean)

* New translations settings.php (Chinese Simplified)

* New translations settings.php (Japanese)

* New translations settings.php (Hungarian)

* New translations settings.php (Hebrew)

* New translations settings.php (German)

* New translations settings.php (Danish)

* New translations settings.php (Czech)

* New translations settings.php (Bulgarian)

* New translations settings.php (Arabic)

* New translations settings.php (French)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Polish)

* New translations settings.php (Spanish)

* New translations settings.php (German Informal)

* New translations settings.php (Spanish)

* New translations settings.php (French)

* New translations components.php (Turkish)

* New translations settings.php (Turkish)

* New translations entities.php (Turkish)

* New translations common.php (Turkish)

* New translations components.php (Portuguese, Brazilian)

* New translations common.php (Portuguese, Brazilian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Chinese Simplified)

* New translations activities.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations activities.php (Chinese Traditional)

* New translations auth.php (Chinese Traditional)

* New translations common.php (Chinese Traditional)

* New translations components.php (Chinese Traditional)

* New translations errors.php (Chinese Traditional)

* New translations passwords.php (Chinese Traditional)

* New translations settings.php (German)

* New translations settings.php (German Informal)

* New translations activities.php (Slovak)

* New translations auth.php (Slovak)

* New translations auth.php (Slovak)

* New translations common.php (Slovak)

* New translations components.php (Slovak)

* New translations components.php (Slovak)

* New translations entities.php (Slovak)

* New translations common.php (Slovak)

* New translations entities.php (Slovak)

* New translations passwords.php (Slovak)

* New translations settings.php (Dutch)

* New translations components.php (Dutch)

* New translations entities.php (Dutch)

* New translations passwords.php (Dutch)

* New translations activities.php (Arabic)

* New translations entities.php (French)

* New translations settings.php (Chinese Traditional)

* New translations settings.php (Chinese Traditional)

* New translations auth.php (Chinese Traditional)

* New translations errors.php (Chinese Traditional)

* New translations activities.php (Japanese)

* New translations auth.php (Japanese)

* New translations entities.php (Chinese Traditional)

* New translations validation.php (Chinese Traditional)

* New translations common.php (Russian)

* New translations components.php (Russian)

* New translations entities.php (Russian)

* New translations settings.php (Russian)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Polish)

* New translations settings.php (Polish)

* New translations settings.php (Polish)

* New translations entities.php (Russian)

* New translations entities.php (Portuguese)

* New translations entities.php (Thai)

* New translations entities.php (Spanish, Argentina)

* New translations entities.php (Persian)

* New translations entities.php (Portuguese, Brazilian)

* New translations entities.php (Vietnamese)

* New translations entities.php (Chinese Traditional)

* New translations entities.php (Chinese Simplified)

* New translations entities.php (Ukrainian)

* New translations entities.php (Turkish)

* New translations entities.php (Swedish)

* New translations entities.php (Slovenian)

* New translations entities.php (Slovak)

* New translations entities.php (Polish)

* New translations entities.php (French)

* New translations entities.php (Dutch)

* New translations entities.php (Korean)

* New translations entities.php (Japanese)

* New translations entities.php (Italian)

* New translations entities.php (Hungarian)

* New translations entities.php (Hebrew)

* New translations entities.php (German)

* New translations entities.php (Danish)

* New translations entities.php (Czech)

* New translations entities.php (Bulgarian)

* New translations entities.php (Arabic)

* New translations entities.php (Spanish)

* New translations entities.php (German Informal)

* New translations entities.php (French)

* New translations settings.php (Russian)

* New translations settings.php (Portuguese)

* New translations settings.php (Thai)

* New translations settings.php (Spanish, Argentina)

* New translations settings.php (Persian)

* New translations settings.php (Portuguese, Brazilian)

* New translations settings.php (Vietnamese)

* 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 (Slovenian)

* New translations settings.php (Slovak)

* New translations settings.php (Polish)

* New translations settings.php (French)

* 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 (Hebrew)

* New translations settings.php (German)

* New translations settings.php (Danish)

* New translations settings.php (Czech)

* New translations settings.php (Bulgarian)

* New translations settings.php (Arabic)

* New translations settings.php (Spanish)

* New translations settings.php (German Informal)

* New translations entities.php (Spanish)

* New translations settings.php (Spanish)
2020-09-19 15:22:32 +01:00
Dan Brown
31eec34b5d Moved decode and updated page plaintext decode test 2020-09-19 15:13:18 +01:00
Dan Brown
44f3508171 Merge branch 'preview-entities' of git://github.com/mr-vinn/BookStack into mr-vinn-preview-entities 2020-09-19 14:58:56 +01:00
Dan Brown
2e39e45886 Added test to check text gen decodes HTML entities 2020-09-19 14:58:18 +01:00
Dan Brown
458aa72c2f Updated composer deps 2020-09-19 12:12:48 +01:00
Dan Brown
78bf044a7a Added audit log interface
- Displays the currently tracked activities in the system.

Related to #2173 and #1167
2020-09-19 12:06:45 +01:00
Dan Brown
e5f0b4dd85 Split out Maintenance to separate controller 2020-09-19 09:24:58 +01:00
Vinnie Okada
311a12b7ef Decode HTML entities
Decode HTML entities in page text before saving it to the database.
2020-09-18 06:54:30 -06:00
Dan Brown
d9e2bddee4 Added some robustness to page draft saving
- Updated so that a warning is always shown on error, Not just on first
in chain.
- Added last-resort localStorage content saving.
2020-09-13 19:32:45 +01:00
Dan Brown
6578ac0b4a Fixed visible revision delete menu 2020-09-13 19:12:15 +01:00
Dan Brown
09c6d6c722 Added button for inserting attachment link to a page
For #1460
2020-09-13 18:58:05 +01:00
Dan Brown
ad48cd3e48 Continued implementation of attachment drag+drop
Cannot get working in chrome reliably due to conflicting handling of
events and drag+drop API. Getting attachment drop working breaks other
parts of TinyMCE.
Implementing current work as should still work for MD editor and within
FireFox.

Related to #1460
2020-09-13 18:31:14 +01:00
Dan Brown
e305ba14d9 Merge branch 'master' into attachment_drag_drop 2020-09-13 16:33:31 +01:00
Vinnie Okada
2c3f453c1f Implement the renderPages parameter
Render page content when getTree() is called with a true $renderPages
argument.
2020-09-07 09:05:51 -06:00
Dan Brown
b87e97f99e Added punnycode since its reuquired by markdownit
Is a native, although depricated, nodejs module. Have installed manually
since esbuild could not resolve the nodejs module
2020-09-05 20:37:23 +01:00
Dan Brown
e5377d5f46 Updated saml2 slo config so url is used if no repsonse url
Updated config to change empty string to null since the empty string was
hitting an isset check which caused an empty string to be used instead
of the slo url as a backup option.

Closes #2002
2020-09-05 19:26:47 +01:00
Dan Brown
ff1ee2d71f Updated flow to ensure /register/confirm route is used where needed
Was accidentally skipped during previous updates. Will now be used on
saml, ldap & standard registration where required.
Uses session to know if the email was just sent and, if so, show the
confirmation route.
2020-09-05 17:26:48 +01:00
Dan Brown
c029741a17 Updated npm deps 2020-09-05 16:54:25 +01:00
Dan Brown
ac83c349da Migrated from webpack to esbuild 2020-09-05 16:50:20 +01:00
Jakub Bouček
fefcaa21e7 Fix English translations
- Fix obvious bug
- Reunite capitalisation
2020-08-31 20:45:09 +02:00
Jakub Bouček
6a36db3cde Czech translations: Fix broken labels 2020-08-31 20:45:09 +02:00
Jakub Bouček
c9352bfd42 Czech translations: Add new translations to cs, improve existing 2020-08-31 20:45:09 +02:00
Jakub Bouček
9c457d9ffe Fix Czech translations (email -> e-mail)
In Czech language "email" does not means "email" but "enamel paint", correct is "e-mail".
See in Wikipedia:
- https://cs.wikipedia.org/wiki/E-mail
- https://cs.wikipedia.org/wiki/Email_(barva)
2020-08-31 17:34:46 +02:00
alexmannuk
7837b8c4ee Updated callout link formatting
Updated callout links to use font colouring based on type, with bold text to denote link, instead of using the theme link colour per issue #303.
2020-08-24 20:03:08 +01:00
Dan Brown
87a5340a05 Prevented email confirmation exception throw on registration
Was preventing any other registration actions from taking place such as
LDAP/SAML group sync. Email confirmation should be actioned by
middleware on post-registration redirect.

Added testing to cover.
Tested for LDAP, SAML and normal registration with email confirmation
required to ensure flows work as expected.

Fixes #2082
2020-08-04 17:54:50 +01:00
Dan Brown
c076ca408c Fixed non-visible horizontal rules in dark mode
Fixes #2209
2020-08-04 15:39:07 +01:00
Dan Brown
1ac11c1852 Added warning to role screen for important permissions
Warning related to permissions that could allow a person to promote
their own permissions to gain more privileges than expected.

For #2105.
2020-08-04 15:26:13 +01:00
Dan Brown
5f1ee5fb0e Removed role 'name' field from database
The 'name' field was really redundant and caused confusion in the
codebase, since the 'Display' name is often used and we have a
'system_name' for the admin and public role.

This fixes #2032, Where external auth group matching has confusing
behaviour as matching was done against the display_name, if no
external_auth field is set, but only roles with a match 'name' field
would be considered.

This also fixes and error where the role users migration, on role
delete, would not actually fire due to mis-matching http body keys.
Looks like this has been an issue from the start. Added some testing to
cover. Fixes #2211.

Also converted phpdoc to typehints in many areas of the reviewed code
during the above.
2020-08-04 14:55:01 +01:00
Dan Brown
a9f02550f0 Removed joint_permissions auto_increment id
Removed auto_incrementing id and set a primary key of the [role_id,
entity_type, entity_id, action] instead since this table could recieve a
lot of activity, especially when permission regeneration was automated,
leading to very high auto_increment counts which could max out the
integer limit.

Also updated some RolesTest comment endpoints to align with
recent route changes.

Should fix #2091
2020-08-04 13:02:31 +01:00
Dan Brown
7590ecd37c Updated some comment elements and standardised more JS
- Updated comment routes to be simpler.
- Updated comments JS to align better with updated component system.
- Documented available global JS functions/services.
- Removed redundant controller method.
- Added window.$events helpers for validation messages and
success/error.
- Updated JS events system to not be class based for simplicity.
- Added window.trans_plural method to handle pluralisation/replacements
where you already have the translation string itself.

Fixes #1836
2020-07-28 18:19:18 +01:00
Dan Brown
2c0fdf83c1 Updated public-login redirect to check url
Direct links to the login pages for public instances could lead to a
redirect back to an external page upon login.
This adds a check to ensure the URL is a URL expected from the current
bookstack instance, or at least under the same domain.

Fixes #2073
2020-07-28 16:29:06 +01:00
Dan Brown
2ed0317129 Updated functionality for logging failed access
- Added testing to cover.
- Linked logging into Laravel's monolog logging system and made log
channel configurable.
- Updated env var names to be specific to login access.
- Added extra locations as to where failed logins would be captured.

Related to #1881 and #728
2020-07-28 12:59:43 +01:00
Dan Brown
2f6ff07347 Merge branch 'auth' of git://github.com/benrubson/BookStack into benrubson-auth 2020-07-28 10:46:40 +01:00
Dan Brown
18f406d97b Started attachment drag/drop
Currently fighting between sortable and tinymce mechanisms which prevent
this working due to the different events stopping the drop event while
needing the dragover for cursor placement.
2020-07-28 10:45:28 +01:00
Dan Brown
76fcbd3752 Removed default anchor CSS filtering in dark mode
Due to causing content images to be rendered in unexpected ways.

- Also removed CSS filters from other image usage.
- Tweaked header CSS filtering to not be so aggressive.
- Forced WYSIWYG editor to be on its own layer since that would allow
massive larger performance increases in Safari, especially when using
dark mode.

Closes #2045.
Closes #2154.
2020-07-26 16:36:15 +01:00
Dan Brown
6e4132121c Updated pagination colors for visibility
Fixes #1839
2020-07-26 15:07:47 +01:00
Dan Brown
f5fefbdb06 Removed a few remaining vue references 2020-07-26 14:49:05 +01:00
Dan Brown
a46b248cf4 Fixed some image manager behaviour
fixed:
- Double click not working after tab usage.
- Synced edit form with select button.
2020-07-25 11:47:12 +01:00
Dan Brown
8213ea9a71 Fixed issue where URL params in image names would cause loading failure
Updated file name handling to route through str:slug to be cleaned up
a little.
Added testing to cover.

Fixes #2161
2020-07-25 11:18:40 +01:00
Dan Brown
03211ebea6 Removed unused tinymce imagetools plugin 2020-07-25 01:09:35 +01:00
Dan Brown
2bacc3c967 Removed vuejs from the project 2020-07-25 00:25:30 +01:00
Dan Brown
02dc3154e3 Converted image-manager to be component/HTML based
Instead of vue based.
2020-07-25 00:20:58 +01:00
Dan Brown
b6aa232205 Fixed issue where more images than expected could be deleted
When deleting images, images within the same directory, that have
a suffix of the delete image name, would also be deleted.

Added test to cover.
2020-07-24 23:41:59 +01:00
Dan Brown
b383f5776d Tweaked dropdown shadows a tad 2020-07-05 21:23:57 +01:00
Dan Brown
3bfd26bf86 Converted the page editor from vue to component 2020-07-05 21:18:17 +01:00
Dan Brown
9d6f574494 Updated attachment tests to align with front-end changes 2020-07-04 17:04:26 +01:00
Dan Brown
d41452f39c Finished breakdown of attachment vue into components 2020-07-04 16:53:02 +01:00
Dan Brown
14b6cd1091 Started migration of attachment manager from vue
- Created new dropzone component.
- Added standard component event system using custom DOM events.
- Added tabs component.
- Added ajax-delete-row component.
2020-06-30 22:12:45 +01:00
Dan Brown
8dc9689c6d Removed tests for removed ajax tag route 2020-06-29 23:46:08 +01:00
Dan Brown
181ae6d055 Fixed tag-manager loading on entity-creation 2020-06-29 23:40:34 +01:00
Dan Brown
573c4e26d5 Finished moving tag-manager from a vue to a component
Now tags load with the page, not via AJAX.
2020-06-29 22:11:03 +01:00
Dan Brown
4e107b9160 Started migrating tag manager JS to HTML-first component 2020-06-28 23:15:05 +01:00
Dan Brown
10305a4446 Converted entity-dash from vue to a component 2020-06-28 21:15:00 +01:00
Dan Brown
a5fa745749 Moved overlay component, migrated code-editor & added features
- Moved Code-editor from vue to component.
- Updated popup code so it background click only hides if the click
originated on the same background. Clicks within the popup will no
longer cause it to hide.
- Added session-level history tracking to code editor.
2020-06-28 00:06:47 +01:00
Dan Brown
9023f78cdc Merge branch 'master' of github.com:BookStackApp/BookStack 2020-06-27 17:19:05 +01:00
Dan Brown
8bc3e0f31a Merge branch 'master' of git://github.com/drzippie/BookStack into drzippie-master 2020-06-27 17:11:11 +01:00
Dan Brown
afed379c5c Merge pull request #2157 from Honvid/fix/lang_error
fix the translate error
2020-06-27 17:06:38 +01:00
Dan Brown
540119f133 Moved sass build out of webpack, updated npm deps
Moving sass out of webpack cleans the setup quite considerably and
brings a good speed improvement.
Made use of npm-run-all so the previous commands still run like before.
2020-06-27 16:52:26 +01:00
Dan Brown
d5de28c444 Merge branch 'use-dart-sass' of git://github.com/timoschwarzer/BookStack into timoschwarzer-use-dart-sass 2020-06-27 15:59:38 +01:00
Dan Brown
7a2e39212e Fixed empty search scenario 2020-06-27 13:37:18 +01:00
Dan Brown
715dee2d0e Converted search filters to not be vue based 2020-06-27 13:29:00 +01:00
Timo Schwarzer
0f55d776a6 Replace node-sass with dart-sass 2020-06-26 12:44:41 +02:00
Antonio Cortés (DrZippie)
d617dba61c removed test_slug_multi_byte_lower_casing and added new test test_slug_multi_byte_url_safe 2020-06-25 18:42:28 +02:00
Antonio Cortés (DrZippie)
ca202c1819 Added Illuminate\Support\Str::slug to generate slug from text to improve the creation of slugs with non-English characters 2020-06-25 18:08:13 +02:00
Dan Brown
76d02cd472 Started attempt at formalising component system used in BookStack
Added a document to try to define things.
Updated the loading so components are registed dynamically.
Added some standardised ways to reference other elems & define options
2020-06-24 20:38:08 +01:00
Honvid
118e31608a fix the bug for lang's extra letter. 2020-06-16 11:44:08 +08:00
Honvid
418fd9037f Merge pull request #1 from BookStackApp/master
sync the remote master
2020-06-10 07:46:06 +08:00
benrubson
9d7ce59b18 Move logFailedAccess into Activity 2020-05-23 15:37:38 +02:00
Dan Brown
71e7dd5894 Removed failing URL test
- Was found that the test was not testing the actual situation anyway.
- A work-around in the request creation, within testing, just happened
 to result in the desired outcome.

For reference: https://github.com/laravel/framework/pull/32345
2020-05-23 12:56:31 +01:00
Dan Brown
3502abdd49 Fixed revision issues caused by page fillable changes 2020-05-23 12:28:14 +01:00
Dan Brown
31514bae06 Updated framework and other deps 2020-05-23 11:50:44 +01:00
Dan Brown
19bfc8ad37 Prevented entity "Not Found" events from being logged
- Added testing to cover, which was more hassle than thought
  since Laravel did not have built in log test helpers, so:
- Added Log testing helper.

Related to #2110
2020-05-23 11:28:59 +01:00
benrubson
8f1f73defa Properly use env/config functions 2020-05-23 12:06:37 +02:00
Dan Brown
bf4a3b73f8 Updated listing endpoints to be clickable in api docs 2020-05-23 00:53:13 +01:00
Dan Brown
00c0815808 Fixed issue where updated page content would not be indexed
- Also updated html field of pages to not be fillable.
   (Since HTML should always go through app id parsing)

Related to #2042
2020-05-23 00:46:13 +01:00
Dan Brown
b61f950560 Incremented version number 2020-05-23 00:29:09 +01:00
Dan Brown
8a6cf0cdec Added chapters to the API 2020-05-23 00:28:41 +01:00
Dan Brown
24bad5034a Updated API auth to allow public user if given permission 2020-05-22 22:34:18 +01:00
Dan Brown
29ddb6e1b9 Updated version and assets for release v0.29.3 2020-05-12 22:34:01 +01:00
Dan Brown
2ff90e2ff0 Merge branch 'master' into release 2020-05-12 22:33:27 +01:00
Dan Brown
9666c8c0f7 Updated shelf-list view to enforce view permissions for child books
- Aligned shelf-homepage behaviour to match
- Updated testing to cover.

For #2111
2020-05-12 22:21:45 +01:00
benrubson
58df3ad956 Log failed accesses option 2020-05-03 16:20:02 +02:00
Dan Brown
04ecc128a2 Updated version and assets for release v0.29.2 2020-05-02 11:49:21 +01:00
Dan Brown
87d1d3423b Merge branch 'master' into release 2020-05-02 11:48:48 +01:00
Dan Brown
d3ec38bee3 Removed unused function in registration service 2020-05-02 01:07:30 +01:00
Dan Brown
413cac23ae Added command to regenerate comment content 2020-05-01 23:41:47 +01:00
Dan Brown
3c26e7b727 Updated comment md rendering to be server-side 2020-05-01 23:24:11 +01:00
Dan Brown
2a2d0aa15b Fixed incorrect color code causing yellow/orange code blocks 2020-04-29 18:28:26 +01:00
benrubson
12a9a45747 Log failed accesses 2020-02-09 10:01:33 +01:00
421 changed files with 9702 additions and 10048 deletions

View File

@@ -1,3 +1,11 @@
# This file, when named as ".env" in the root of your BookStack install
# folder, is used for the core configuration of the application.
# By default this file contains the most common required options but
# a full list of options can be found in the '.env.example.complete' file.
# NOTE: If any of your values contain a space or a hash you will need to
# wrap the entire value in quotes. (eg. MAIL_FROM_NAME="BookStack Mailer")
# Application key
# Used for encryption where needed.
# Run `php artisan key:generate` to generate a valid key.
@@ -5,7 +13,7 @@ APP_KEY=SomeRandomString
# Application URL
# Remove the hash below and set a URL if using BookStack behind
# a proxy, if using a third-party authentication option.
# a proxy or if using a third-party authentication option.
# This must be the root URL that you want to host BookStack on.
# All URL's in BookStack will be generated using this value.
#APP_URL=https://example.com
@@ -25,11 +33,10 @@ MAIL_FROM_NAME=BookStack
MAIL_FROM=bookstack@example.com
# SMTP mail options
# These settings can be checked using the "Send a Test Email"
# feature found in the "Settings > Maintenance" area of the system.
MAIL_HOST=localhost
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
# A full list of options can be found in the '.env.example.complete' file.
MAIL_ENCRYPTION=null

View File

@@ -238,9 +238,9 @@ DISABLE_EXTERNAL_SERVICES=false
# Example: AVATAR_URL=https://seccdn.libravatar.org/avatar/${hash}?s=${size}&d=identicon
AVATAR_URL=
# Enable draw.io integration
# Enable diagrams.net integration
# Can simply be true/false to enable/disable the integration.
# Alternatively, It can be URL to the draw.io instance you want to use.
# Alternatively, It can be URL to the diagrams.net instance you want to use.
# For URLs, The following URL parameters should be included: embed=1&proto=json&spin=1
DRAWIO=true
@@ -270,4 +270,12 @@ 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
API_REQUESTS_PER_MIN=180
# Enable the logging of failed email+password logins with the given message.
# The default log channel below uses the php 'error_log' function which commonly
# results in messages being output to the webserver error logs.
# The message can contain a %u parameter which will be replaced with the login
# user identifier (Username or email).
LOG_FAILED_LOGIN_MESSAGE=false
LOG_FAILED_LOGIN_CHANNEL=errorlog_plain_webserver

View File

@@ -61,7 +61,7 @@ Rodrigo Saczuk Niz (rodrigoniz) :: Portuguese, Brazilian
aekramer :: Dutch
JachuPL :: Polish
milesteg :: Hungarian
Beenbag :: German
Beenbag :: German; German Informal
Lett3rs :: Danish
Julian (julian.henneberg) :: German; German Informal
3GNWn :: Danish
@@ -98,3 +98,27 @@ Thinkverse (thinkverse) :: Swedish
alef (toishoki) :: Turkish
Robbert Feunekes (Muukuro) :: Dutch
seohyeon.joo :: Korean
Orenda (OREDNA) :: Bulgarian
Marek Pavelka (marapavelka) :: Czech
Venkinovec :: Czech
Tommy Ku (tommyku) :: Chinese Traditional; Japanese
Michał Bielejewski (bielej) :: Polish
jozefrebjak :: Slovak
Ikhwan Koo (Ikhwan.Koo) :: Korean
Whay (remkovdhoef) :: Dutch
jc7115 :: Chinese Traditional
주서현 (seohyeon.joo) :: Korean
ReadySystems :: Arabic
HFinch :: German; German Informal
brechtgijsens :: Dutch
Lowkey (v587ygq) :: Chinese Simplified
sdl-blue :: German Informal
sqlik :: Polish
Roy van Schaijk (royvanschaijk) :: Dutch
Simsimpicpic :: French
Zenahr Barzani (Zenahr) :: German; Japanese; Dutch; German Informal
tatsuya.info :: Japanese
fadiapp :: Arabic
Jakub Bouček (jakubboucek) :: Czech
Marco (cdrfun) :: German
10935336 :: Chinese Simplified

58
.github/workflows/test-migrations.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: test-migrations
on:
push:
branches:
- master
- release
pull_request:
branches:
- '*'
- '*/*'
- '!l10n_master'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
php: [7.2, 7.4]
steps:
- uses: actions/checkout@v1
- name: Get Composer Cache Directory
id: composer-cache
run: |
echo "::set-output name=dir::$(composer config cache-files-dir)"
- name: Cache composer packages
uses: actions/cache@v1
with:
path: ${{ steps.composer-cache.outputs.dir }}
key: ${{ runner.os }}-composer-${{ matrix.php }}
- name: Start MySQL
run: |
sudo /etc/init.d/mysql start
- name: Create database & user
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
run: composer install --prefer-dist --no-interaction --ansi
- name: Start migration test
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing
- name: Start migration:rollback test
run: |
php${{ matrix.php }} artisan migrate:rollback --force -n --database=mysql_testing
- name: Start migration rerun test
run: |
php${{ matrix.php }} artisan migrate --force -n --database=mysql_testing

View File

@@ -4,6 +4,7 @@ use BookStack\Auth\Permissions\PermissionService;
use BookStack\Auth\User;
use BookStack\Entities\Entity;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Log;
class ActivityService
{
@@ -49,7 +50,7 @@ class ActivityService
protected function newActivityForUser(string $key, ?int $bookId = null): Activity
{
return $this->activity->newInstance()->forceFill([
'key' => strtolower($key),
'key' => strtolower($key),
'user_id' => $this->user->id,
'book_id' => $bookId ?? 0,
]);
@@ -64,8 +65,8 @@ class ActivityService
{
$activities = $entity->activity()->get();
$entity->activity()->update([
'extra' => $entity->name,
'entity_id' => 0,
'extra' => $entity->name,
'entity_id' => 0,
'entity_type' => '',
]);
return $activities;
@@ -99,7 +100,7 @@ class ActivityService
$query = $this->activity->newQuery()->where('entity_type', '=', $entity->getMorphClass())
->where('entity_id', '=', $entity->id);
}
$activity = $this->permissionService
->filterRestrictedEntityRelations($query, 'activities', 'entity_id', 'entity_type')
->orderBy('created_at', 'desc')
@@ -159,4 +160,20 @@ class ActivityService
session()->flash('success', $message);
}
}
/**
* Log out a failed login attempt, Providing the given username
* as part of the message if the '%u' string is used.
*/
public function logFailedLogin(string $username)
{
$message = config('logging.failed_login.message');
if (!$message) {
return;
}
$message = str_replace("%u", $username, $message);
$channel = config('logging.failed_login.channel');
Log::channel($channel)->warning($message);
}
}

View File

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

View File

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

View File

@@ -9,6 +9,7 @@ use BookStack\Model;
class Tag extends Model
{
protected $fillable = ['name', 'value', 'order'];
protected $hidden = ['id', 'entity_id', 'entity_type'];
/**
* Get the entity that this tag belongs to

View File

@@ -2,71 +2,31 @@
use BookStack\Auth\Permissions\PermissionService;
use BookStack\Entities\Entity;
use DB;
use Illuminate\Support\Collection;
/**
* Class TagRepo
* @package BookStack\Repos
*/
class TagRepo
{
protected $tag;
protected $entity;
protected $permissionService;
/**
* TagRepo constructor.
* @param \BookStack\Actions\Tag $attr
* @param \BookStack\Entities\Entity $ent
* @param \BookStack\Auth\Permissions\PermissionService $ps
*/
public function __construct(Tag $attr, Entity $ent, PermissionService $ps)
public function __construct(Tag $tag, PermissionService $ps)
{
$this->tag = $attr;
$this->entity = $ent;
$this->tag = $tag;
$this->permissionService = $ps;
}
/**
* Get an entity instance of its particular type.
* @param $entityType
* @param $entityId
* @param string $action
* @return \Illuminate\Database\Eloquent\Model|null|static
*/
public function getEntity($entityType, $entityId, $action = 'view')
{
$entityInstance = $this->entity->getEntityInstance($entityType);
$searchQuery = $entityInstance->where('id', '=', $entityId)->with('tags');
$searchQuery = $this->permissionService->enforceEntityRestrictions($entityType, $searchQuery, $action);
return $searchQuery->first();
}
/**
* Get all tags for a particular entity.
* @param string $entityType
* @param int $entityId
* @return mixed
*/
public function getForEntity($entityType, $entityId)
{
$entity = $this->getEntity($entityType, $entityId);
if ($entity === null) {
return collect();
}
return $entity->tags;
}
/**
* Get tag name suggestions from scanning existing tag names.
* If no search term is given the 50 most popular tag names are provided.
* @param $searchTerm
* @return array
*/
public function getNameSuggestions($searchTerm = false)
public function getNameSuggestions(?string $searchTerm): Collection
{
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('name');
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('name');
if ($searchTerm) {
$query = $query->where('name', 'LIKE', $searchTerm . '%')->orderBy('name', 'desc');
@@ -82,13 +42,10 @@ class TagRepo
* Get tag value suggestions from scanning existing tag values.
* If no search is given the 50 most popular values are provided.
* Passing a tagName will only find values for a tags with a particular name.
* @param $searchTerm
* @param $tagName
* @return array
*/
public function getValueSuggestions($searchTerm = false, $tagName = false)
public function getValueSuggestions(?string $searchTerm, ?string $tagName): Collection
{
$query = $this->tag->select('*', \DB::raw('count(*) as count'))->groupBy('value');
$query = $this->tag->select('*', DB::raw('count(*) as count'))->groupBy('value');
if ($searchTerm) {
$query = $query->where('value', 'LIKE', $searchTerm . '%')->orderBy('value', 'desc');
@@ -96,7 +53,7 @@ class TagRepo
$query = $query->orderBy('count', 'desc')->take(50);
}
if ($tagName !== false) {
if ($tagName) {
$query = $query->where('name', '=', $tagName);
}
@@ -106,35 +63,28 @@ class TagRepo
/**
* Save an array of tags to an entity
* @param \BookStack\Entities\Entity $entity
* @param array $tags
* @return array|\Illuminate\Database\Eloquent\Collection
*/
public function saveTagsToEntity(Entity $entity, $tags = [])
public function saveTagsToEntity(Entity $entity, array $tags = []): iterable
{
$entity->tags()->delete();
$newTags = [];
foreach ($tags as $tag) {
if (trim($tag['name']) === '') {
continue;
}
$newTags[] = $this->newInstanceFromInput($tag);
}
$newTags = collect($tags)->filter(function ($tag) {
return boolval(trim($tag['name']));
})->map(function ($tag) {
return $this->newInstanceFromInput($tag);
})->all();
return $entity->tags()->saveMany($newTags);
}
/**
* Create a new Tag instance from user input.
* @param $input
* @return \BookStack\Actions\Tag
* Input must be an array with a 'name' and an optional 'value' key.
*/
protected function newInstanceFromInput($input)
protected function newInstanceFromInput(array $input): Tag
{
$name = trim($input['name']);
$value = isset($input['value']) ? trim($input['value']) : '';
// Any other modification or cleanup required can go here
$values = ['name' => $name, 'value' => $value];
return $this->tag->newInstance($values);
return $this->tag->newInstance(['name' => $name, 'value' => $value]);
}
}

View File

@@ -3,6 +3,8 @@
use BookStack\Auth\Role;
use BookStack\Auth\User;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class ExternalAuthService
{
@@ -39,22 +41,14 @@ class ExternalAuthService
/**
* 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)
protected function matchGroupsToSystemsRoles(array $groupNames): Collection
{
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();
$roles = Role::query()->get(['id', 'external_auth_id', 'display_name']);
$matchedRoles = $roles->filter(function (Role $role) use ($groupNames) {
return $this->roleMatchesGroupNames($role, $groupNames);
});

View File

@@ -57,7 +57,7 @@ class RegistrationService
// 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]));
throw new UserRegistrationException(trans('errors.error_user_exists_different_creds', ['email' => $userEmail]), '/login');
}
// Create the user
@@ -71,15 +71,15 @@ class RegistrationService
// Start email confirmation flow if required
if ($this->emailConfirmationService->confirmationRequired() && !$emailConfirmed) {
$newUser->save();
$message = '';
try {
$this->emailConfirmationService->sendConfirmation($newUser);
session()->flash('sent-email-confirmation', true);
} catch (Exception $e) {
$message = trans('auth.email_confirm_send_error');
throw new UserRegistrationException($message, '/register/confirm');
}
throw new UserRegistrationException($message, '/register/confirm');
}
return $newUser;
@@ -106,13 +106,4 @@ class RegistrationService
}
}
/**
* 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

@@ -311,7 +311,6 @@ class Saml2Service extends ExternalAuthService
/**
* Get the user from the database for the specified details.
* @throws SamlException
* @throws UserRegistrationException
*/
protected function getOrRegisterUser(array $userDetails): ?User

View File

@@ -3,25 +3,26 @@
use BookStack\Auth\Role;
use BookStack\Entities\Entity;
use BookStack\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\MorphOne;
class JointPermission extends Model
{
protected $primaryKey = null;
public $timestamps = false;
/**
* Get the role that this points to.
* @return \Illuminate\Database\Eloquent\Relations\BelongsTo
*/
public function role()
public function role(): BelongsTo
{
return $this->belongsTo(Role::class);
}
/**
* Get the entity this points to.
* @return \Illuminate\Database\Eloquent\Relations\MorphOne
*/
public function entity()
public function entity(): MorphOne
{
return $this->morphOne(Entity::class, 'entity');
}

View File

@@ -1,8 +1,9 @@
<?php namespace BookStack\Auth\Permissions;
use BookStack\Auth\Permissions;
use BookStack\Auth\Role;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Str;
class PermissionsRepo
@@ -16,11 +17,8 @@ class PermissionsRepo
/**
* PermissionsRepo constructor.
* @param RolePermission $permission
* @param Role $role
* @param \BookStack\Auth\Permissions\PermissionService $permissionService
*/
public function __construct(RolePermission $permission, Role $role, Permissions\PermissionService $permissionService)
public function __construct(RolePermission $permission, Role $role, PermissionService $permissionService)
{
$this->permission = $permission;
$this->role = $role;
@@ -29,46 +27,34 @@ class PermissionsRepo
/**
* Get all the user roles from the system.
* @return \Illuminate\Database\Eloquent\Collection|static[]
*/
public function getAllRoles()
public function getAllRoles(): Collection
{
return $this->role->all();
}
/**
* Get all the roles except for the provided one.
* @param Role $role
* @return mixed
*/
public function getAllRolesExcept(Role $role)
public function getAllRolesExcept(Role $role): Collection
{
return $this->role->where('id', '!=', $role->id)->get();
}
/**
* Get a role via its ID.
* @param $id
* @return mixed
*/
public function getRoleById($id)
public function getRoleById($id): Role
{
return $this->role->findOrFail($id);
return $this->role->newQuery()->findOrFail($id);
}
/**
* Save a new role into the system.
* @param array $roleData
* @return Role
*/
public function saveNewRole($roleData)
public function saveNewRole(array $roleData): Role
{
$role = $this->role->newInstance($roleData);
$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->save();
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
@@ -80,13 +66,11 @@ class PermissionsRepo
/**
* Updates an existing role.
* Ensure Admin role always have core permissions.
* @param $roleId
* @param $roleData
* @throws PermissionsException
*/
public function updateRole($roleId, $roleData)
public function updateRole($roleId, array $roleData)
{
$role = $this->role->findOrFail($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
$permissions = isset($roleData['permissions']) ? array_keys($roleData['permissions']) : [];
if ($role->system_name === 'admin') {
@@ -108,16 +92,19 @@ class PermissionsRepo
/**
* Assign an list of permission names to an role.
* @param Role $role
* @param array $permissionNameArray
*/
public function assignRolePermissions(Role $role, $permissionNameArray = [])
public function assignRolePermissions(Role $role, array $permissionNameArray = [])
{
$permissions = [];
$permissionNameArray = array_values($permissionNameArray);
if ($permissionNameArray && count($permissionNameArray) > 0) {
$permissions = $this->permission->whereIn('name', $permissionNameArray)->pluck('id')->toArray();
if ($permissionNameArray) {
$permissions = $this->permission->newQuery()
->whereIn('name', $permissionNameArray)
->pluck('id')
->toArray();
}
$role->permissions()->sync($permissions);
}
@@ -126,13 +113,13 @@ class PermissionsRepo
* Check it's not an admin role or set as default before deleting.
* If an migration Role ID is specified the users assign to the current role
* will be added to the role of the specified id.
* @param $roleId
* @param $migrateRoleId
* @throws PermissionsException
* @throws Exception
*/
public function deleteRole($roleId, $migrateRoleId)
{
$role = $this->role->findOrFail($roleId);
/** @var Role $role */
$role = $this->role->newQuery()->findOrFail($roleId);
// Prevent deleting admin role or default registration role.
if ($role->system_name && in_array($role->system_name, $this->systemRoles)) {
@@ -142,9 +129,9 @@ class PermissionsRepo
}
if ($migrateRoleId) {
$newRole = $this->role->find($migrateRoleId);
$newRole = $this->role->newQuery()->find($migrateRoleId);
if ($newRole) {
$users = $role->users->pluck('id')->toArray();
$users = $role->users()->pluck('id')->toArray();
$newRole->users()->sync($users);
}
}

View File

@@ -3,6 +3,9 @@
use BookStack\Auth\Role;
use BookStack\Model;
/**
* @property int $id
*/
class RolePermission extends Model
{
/**

View File

@@ -3,13 +3,16 @@
use BookStack\Auth\Permissions\JointPermission;
use BookStack\Auth\Permissions\RolePermission;
use BookStack\Model;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Database\Eloquent\Relations\HasMany;
/**
* Class Role
* @property int $id
* @property string $display_name
* @property string $description
* @property string $external_auth_id
* @package BookStack\Auth
* @property string $system_name
*/
class Role extends Model
{
@@ -26,9 +29,8 @@ class Role extends Model
/**
* Get all related JointPermissions.
* @return \Illuminate\Database\Eloquent\Relations\HasMany
*/
public function jointPermissions()
public function jointPermissions(): HasMany
{
return $this->hasMany(JointPermission::class);
}
@@ -43,10 +45,8 @@ class Role extends Model
/**
* Check if this role has a permission.
* @param $permissionName
* @return bool
*/
public function hasPermission($permissionName)
public function hasPermission(string $permissionName): bool
{
$permissions = $this->getRelationValue('permissions');
foreach ($permissions as $permission) {
@@ -59,7 +59,6 @@ class Role extends Model
/**
* Add a permission to this role.
* @param RolePermission $permission
*/
public function attachPermission(RolePermission $permission)
{
@@ -68,7 +67,6 @@ class Role extends Model
/**
* Detach a single permission from this role.
* @param RolePermission $permission
*/
public function detachPermission(RolePermission $permission)
{
@@ -76,39 +74,33 @@ class Role extends Model
}
/**
* Get the role object for the specified role.
* @param $roleName
* @return Role
* Get the role of the specified display name.
*/
public static function getRole($roleName)
public static function getRole(string $displayName): ?Role
{
return static::query()->where('name', '=', $roleName)->first();
return static::query()->where('display_name', '=', $displayName)->first();
}
/**
* Get the role object for the specified system role.
* @param $roleName
* @return Role
*/
public static function getSystemRole($roleName)
public static function getSystemRole(string $systemName): ?Role
{
return static::query()->where('system_name', '=', $roleName)->first();
return static::query()->where('system_name', '=', $systemName)->first();
}
/**
* Get all visible roles
* @return mixed
*/
public static function visible()
public static function visible(): Collection
{
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()
public static function restrictable(): Collection
{
return static::query()->where('system_name', '!=', 'admin')->get();
}

View File

@@ -49,7 +49,7 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
*/
protected $hidden = [
'password', 'remember_token', 'system_name', 'email_confirmed', 'external_auth_id', 'email',
'created_at', 'updated_at',
'created_at', 'updated_at', 'image_id',
];
/**
@@ -101,12 +101,10 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Check if the user has a role.
* @param $role
* @return mixed
*/
public function hasRole($role)
public function hasRole($roleId): bool
{
return $this->roles->pluck('name')->contains($role);
return $this->roles->pluck('id')->contains($roleId);
}
/**
@@ -163,7 +161,6 @@ class User extends Model implements AuthenticatableContract, CanResetPasswordCon
/**
* Attach a role to this user.
* @param Role $role
*/
public function attachRole(Role $role)
{

View File

@@ -238,7 +238,7 @@ class UserRepo
*/
public function getAllRoles()
{
return $this->role->newQuery()->orderBy('name', 'asc')->get();
return $this->role->newQuery()->orderBy('display_name', 'asc')->get();
}
/**

View File

@@ -52,7 +52,7 @@ return [
'locale' => env('APP_LANG', 'en'),
// Locales available
'locales' => ['en', 'ar', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
'locales' => ['en', 'ar', 'bg', 'cs', 'da', 'de', 'de_informal', 'es', 'es_AR', 'fa', 'fr', 'he', 'hu', 'it', 'ja', 'ko', 'nl', 'pt', 'pt_BR', 'sk', 'sl', 'sv', 'pl', 'ru', 'th', 'tr', 'uk', 'vi', 'zh_CN', 'zh_TW',],
// Application Fallback Locale
'fallback_locale' => 'en',

View File

@@ -1,5 +1,7 @@
<?php
use Monolog\Formatter\LineFormatter;
use Monolog\Handler\ErrorLogHandler;
use Monolog\Handler\NullHandler;
use Monolog\Handler\StreamHandler;
@@ -73,10 +75,38 @@ return [
'level' => 'debug',
],
// Custom errorlog implementation that logs out a plain,
// non-formatted message intended for the webserver log.
'errorlog_plain_webserver' => [
'driver' => 'monolog',
'level' => 'debug',
'handler' => ErrorLogHandler::class,
'handler_with' => [4],
'formatter' => LineFormatter::class,
'formatter_with' => [
'format' => "%message%",
],
],
'null' => [
'driver' => 'monolog',
'handler' => NullHandler::class,
],
// Testing channel
// Uses a shared testing instance during tests
// so that logs can be checked against.
'testing' => [
'driver' => 'testing',
],
],
// Failed Login Message
// Allows a configurable message to be logged when a login request fails.
'failed_login' => [
'message' => env('LOG_FAILED_LOGIN_MESSAGE', null),
'channel' => env('LOG_FAILED_LOGIN_CHANNEL', 'errorlog_plain_webserver'),
],
];

View File

@@ -101,7 +101,7 @@ return [
'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' => '',
'responseUrl' => null,
// SAML protocol binding to be used when returning the <Response>
// message. Onelogin Toolkit supports for this endpoint the
// HTTP-Redirect binding only

View File

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

View File

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

View File

@@ -19,7 +19,7 @@ class Book extends Entity implements HasCoverImage
public $searchFactor = 2;
protected $fillable = ['name', 'description'];
protected $hidden = ['restricted', 'pivot'];
protected $hidden = ['restricted', 'pivot', 'image_id'];
/**
* Get the url for this book.

View File

@@ -12,7 +12,7 @@ class Bookshelf extends Entity implements HasCoverImage
protected $fillable = ['name', 'description', 'image_id'];
protected $hidden = ['restricted'];
protected $hidden = ['restricted', 'image_id'];
/**
* Get the books in this shelf.

View File

@@ -12,6 +12,7 @@ class Chapter extends BookChild
public $searchFactor = 1.3;
protected $fillable = ['name', 'description', 'priority', 'book_id'];
protected $hidden = ['restricted', 'pivot'];
/**
* Get the pages that this chapter contains.

View File

@@ -201,12 +201,10 @@ class Entity extends Ownable
}
/**
* Allows checking of the exact class, Used to check entity type.
* Cleaner method for is_a.
* @param $type
* @return bool
* Check if this instance or class is a certain type of entity.
* Examples of $type are 'page', 'book', 'chapter'
*/
public static function isA($type)
public static function isA(string $type): bool
{
return static::getType() === strtolower($type);
}
@@ -238,10 +236,8 @@ class Entity extends Ownable
/**
* Gets a limited-length version of the entities name.
* @param int $length
* @return string
*/
public function getShortName($length = 25)
public function getShortName(int $length = 25): string
{
if (mb_strlen($this->name) <= $length) {
return $this->name;
@@ -288,7 +284,7 @@ class Entity extends Ownable
public function rebuildPermissions()
{
/** @noinspection PhpUnhandledExceptionInspection */
Permissions::buildJointPermissionsForEntity($this);
Permissions::buildJointPermissionsForEntity(clone $this);
}
/**
@@ -297,7 +293,7 @@ class Entity extends Ownable
public function indexForSearch()
{
$searchService = app()->make(SearchService::class);
$searchService->indexEntity($this);
$searchService->indexEntity(clone $this);
}
/**

View File

@@ -41,7 +41,6 @@ class BookContents
/**
* Get the contents as a sorted collection tree.
* TODO - Support $renderPages option
*/
public function getTree(bool $showDrafts = false, bool $renderPages = false): Collection
{
@@ -60,8 +59,12 @@ class BookContents
}
});
$all->each(function (Entity $entity) {
$all->each(function (Entity $entity) use ($renderPages) {
$entity->setRelation('book', $this->book);
if ($renderPages && $entity->isA('page')) {
$entity->html = (new PageContent($entity))->render();
}
});
return collect($chapters)->concat($lonePages)->sortBy($this->bookChildSortFunc());

View File

@@ -2,7 +2,6 @@
use BookStack\Entities\Page;
use DOMDocument;
use DOMElement;
use DOMNodeList;
use DOMXPath;
@@ -44,18 +43,24 @@ class PageContent
$container = $doc->documentElement;
$body = $container->childNodes->item(0);
$childNodes = $body->childNodes;
$xPath = new DOMXPath($doc);
// Set ids on top-level nodes
$idMap = [];
foreach ($childNodes as $index => $childNode) {
$this->setUniqueId($childNode, $idMap);
[$oldId, $newId] = $this->setUniqueId($childNode, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Ensure no duplicate ids within child items
$xPath = new DOMXPath($doc);
$idElems = $xPath->query('//body//*//*[@id]');
foreach ($idElems as $domElem) {
$this->setUniqueId($domElem, $idMap);
[$oldId, $newId] = $this->setUniqueId($domElem, $idMap);
if ($newId && $newId !== $oldId) {
$this->updateLinks($xPath, '#' . $oldId, '#' . $newId);
}
}
// Generate inner html as a string
@@ -67,23 +72,34 @@ class PageContent
return $html;
}
/**
* Update the all links to the $old location to instead point to $new.
*/
protected function updateLinks(DOMXPath $xpath, string $old, string $new)
{
$old = str_replace('"', '', $old);
$matchingLinks = $xpath->query('//body//*//*[@href="'.$old.'"]');
foreach ($matchingLinks as $domElem) {
$domElem->setAttribute('href', $new);
}
}
/**
* 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
* Returns a pair of strings in the format [old_id, new_id]
*/
protected function setUniqueId($element, array &$idMap)
protected function setUniqueId(\DOMNode $element, array &$idMap): array
{
if (get_class($element) !== 'DOMElement') {
return;
return ['', ''];
}
// Overwrite id if not a BookStack custom id
// Stop if there's an existing valid id that has not already been used.
$existingId = $element->getAttribute('id');
if (strpos($existingId, 'bkmrk') === 0 && !isset($idMap[$existingId])) {
$idMap[$existingId] = true;
return;
return [$existingId, $existingId];
}
// Create an unique id for the element
@@ -100,6 +116,7 @@ class PageContent
$element->setAttribute('id', $newId);
$idMap[$newId] = true;
return [$existingId, $newId];
}
/**
@@ -108,7 +125,7 @@ class PageContent
protected function toPlainText(): string
{
$html = $this->render(true);
return strip_tags($html);
return html_entity_decode(strip_tags($html));
}
/**

View File

@@ -21,12 +21,14 @@ use Permissions;
*/
class Page extends BookChild
{
protected $fillable = ['name', 'html', 'priority', 'markdown'];
protected $fillable = ['name', 'priority', 'markdown'];
protected $simpleAttributes = ['name', 'id', 'slug'];
public $textField = 'text';
protected $hidden = ['html', 'markdown', 'text', 'restricted', 'pivot'];
/**
* Get the entities that are visible to the current user.
*/

View File

@@ -28,8 +28,10 @@ class BookshelfRepo
*/
public function getAllPaginated(int $count = 20, string $sort = 'name', string $order = 'asc'): LengthAwarePaginator
{
return Bookshelf::visible()->with('visibleBooks')
->orderBy($sort, $order)->paginate($count);
return Bookshelf::visible()
->with('visibleBooks')
->orderBy($sort, $order)
->paginate($count);
}
/**

View File

@@ -180,12 +180,11 @@ class PageRepo
$page->template = ($input['template'] === 'true');
}
$pageContent = new PageContent($page);
$pageContent->setNewHTML($input['html']);
$this->baseRepo->update($page, $input);
// Update with new details
$page->fill($input);
$pageContent = new PageContent($page);
$pageContent->setNewHTML($input['html']);
$page->revision_count++;
if (setting('app-editor') !== 'markdown') {
@@ -211,7 +210,7 @@ class PageRepo
*/
protected function savePageRevision(Page $page, string $summary = null)
{
$revision = new PageRevision($page->toArray());
$revision = new PageRevision($page->getAttributes());
if (setting('app-editor') !== 'markdown') {
$revision->markdown = '';
@@ -279,7 +278,7 @@ class PageRepo
$revision = $page->revisions()->where('id', '=', $revisionId)->first();
$page->fill($revision->toArray());
$content = new PageContent($page);
$content->setNewHTML($page->html);
$content->setNewHTML($revision->html);
$page->updated_by = user()->id;
$page->refreshSlug();
$page->save();

View File

@@ -0,0 +1,141 @@
<?php namespace BookStack\Entities;
use Illuminate\Http\Request;
class SearchOptions
{
/**
* @var array
*/
public $searches = [];
/**
* @var array
*/
public $exacts = [];
/**
* @var array
*/
public $tags = [];
/**
* @var array
*/
public $filters = [];
/**
* Create a new instance from a search string.
*/
public static function fromString(string $search): SearchOptions
{
$decoded = static::decode($search);
$instance = new static();
foreach ($decoded as $type => $value) {
$instance->$type = $value;
}
return $instance;
}
/**
* Create a new instance from a request.
* Will look for a classic string term and use that
* Otherwise we'll use the details from an advanced search form.
*/
public static function fromRequest(Request $request): SearchOptions
{
if (!$request->has('search') && !$request->has('term')) {
return static::fromString('');
}
if ($request->has('term')) {
return static::fromString($request->get('term'));
}
$instance = new static();
$inputs = $request->only(['search', 'types', 'filters', 'exact', 'tags']);
$instance->searches = explode(' ', $inputs['search'] ?? []);
$instance->exacts = array_filter($inputs['exact'] ?? []);
$instance->tags = array_filter($inputs['tags'] ?? []);
foreach (($inputs['filters'] ?? []) as $filterKey => $filterVal) {
if (empty($filterVal)) {
continue;
}
$instance->filters[$filterKey] = $filterVal === 'true' ? '' : $filterVal;
}
if (isset($inputs['types']) && count($inputs['types']) < 4) {
$instance->filters['type'] = implode('|', $inputs['types']);
}
return $instance;
}
/**
* Decode a search string into an array of terms.
*/
protected static function decode(string $searchString): array
{
$terms = [
'searches' => [],
'exacts' => [],
'tags' => [],
'filters' => []
];
$patterns = [
'exacts' => '/"(.*?)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/'
];
// Parse special terms
foreach ($patterns as $termType => $pattern) {
$matches = [];
preg_match_all($pattern, $searchString, $matches);
if (count($matches) > 0) {
$terms[$termType] = $matches[1];
$searchString = preg_replace($pattern, '', $searchString);
}
}
// Parse standard terms
foreach (explode(' ', trim($searchString)) as $searchTerm) {
if ($searchTerm !== '') {
$terms['searches'][] = $searchTerm;
}
}
// Split filter values out
$splitFilters = [];
foreach ($terms['filters'] as $filter) {
$explodedFilter = explode(':', $filter, 2);
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
}
$terms['filters'] = $splitFilters;
return $terms;
}
/**
* Encode this instance to a search string.
*/
public function toString(): string
{
$string = implode(' ', $this->searches ?? []);
foreach ($this->exacts as $term) {
$string .= ' "' . $term . '"';
}
foreach ($this->tags as $term) {
$string .= " [{$term}]";
}
foreach ($this->filters as $filterName => $filterVal) {
$string .= ' {' . $filterName . ($filterVal ? ':' . $filterVal : '') . '}';
}
return $string;
}
}

View File

@@ -39,10 +39,6 @@ class SearchService
/**
* SearchService constructor.
* @param SearchTerm $searchTerm
* @param EntityProvider $entityProvider
* @param Connection $db
* @param PermissionService $permissionService
*/
public function __construct(SearchTerm $searchTerm, EntityProvider $entityProvider, Connection $db, PermissionService $permissionService)
{
@@ -54,7 +50,6 @@ class SearchService
/**
* Set the database connection
* @param Connection $connection
*/
public function setConnection(Connection $connection)
{
@@ -63,23 +58,18 @@ class SearchService
/**
* Search all entities in the system.
* @param string $searchString
* @param string $entityType
* @param int $page
* @param int $count - Count of each entity to search, Total returned could can be larger and not guaranteed.
* @param string $action
* @return array[int, Collection];
* The provided count is for each entity to search,
* Total returned could can be larger and not guaranteed.
*/
public function searchEntities($searchString, $entityType = 'all', $page = 1, $count = 20, $action = 'view')
public function searchEntities(SearchOptions $searchOpts, string $entityType = 'all', int $page = 1, int $count = 20, string $action = 'view'): array
{
$terms = $this->parseSearchString($searchString);
$entityTypes = array_keys($this->entityProvider->all());
$entityTypesToSearch = $entityTypes;
if ($entityType !== 'all') {
$entityTypesToSearch = $entityType;
} else if (isset($terms['filters']['type'])) {
$entityTypesToSearch = explode('|', $terms['filters']['type']);
} else if (isset($searchOpts->filters['type'])) {
$entityTypesToSearch = explode('|', $searchOpts->filters['type']);
}
$results = collect();
@@ -90,8 +80,8 @@ class SearchService
if (!in_array($entityType, $entityTypes)) {
continue;
}
$search = $this->searchEntityTable($terms, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($terms, $entityType, $page, $count, $action, true);
$search = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action);
$entityTotal = $this->searchEntityTable($searchOpts, $entityType, $page, $count, $action, true);
if ($entityTotal > $page * $count) {
$hasMore = true;
}
@@ -103,29 +93,26 @@ class SearchService
'total' => $total,
'count' => count($results),
'has_more' => $hasMore,
'results' => $results->sortByDesc('score')->values()
'results' => $results->sortByDesc('score')->values(),
];
}
/**
* Search a book for entities
* @param integer $bookId
* @param string $searchString
* @return Collection
*/
public function searchBook($bookId, $searchString)
public function searchBook(int $bookId, string $searchString): Collection
{
$terms = $this->parseSearchString($searchString);
$opts = SearchOptions::fromString($searchString);
$entityTypes = ['page', 'chapter'];
$entityTypesToSearch = isset($terms['filters']['type']) ? explode('|', $terms['filters']['type']) : $entityTypes;
$entityTypesToSearch = isset($opts->filters['type']) ? explode('|', $opts->filters['type']) : $entityTypes;
$results = collect();
foreach ($entityTypesToSearch as $entityType) {
if (!in_array($entityType, $entityTypes)) {
continue;
}
$search = $this->buildEntitySearchQuery($terms, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$search = $this->buildEntitySearchQuery($opts, $entityType)->where('book_id', '=', $bookId)->take(20)->get();
$results = $results->merge($search);
}
return $results->sortByDesc('score')->take(20);
@@ -133,30 +120,23 @@ class SearchService
/**
* Search a book for entities
* @param integer $chapterId
* @param string $searchString
* @return Collection
*/
public function searchChapter($chapterId, $searchString)
public function searchChapter(int $chapterId, string $searchString): Collection
{
$terms = $this->parseSearchString($searchString);
$pages = $this->buildEntitySearchQuery($terms, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
$opts = SearchOptions::fromString($searchString);
$pages = $this->buildEntitySearchQuery($opts, 'page')->where('chapter_id', '=', $chapterId)->take(20)->get();
return $pages->sortByDesc('score');
}
/**
* Search across a particular entity type.
* @param array $terms
* @param string $entityType
* @param int $page
* @param int $count
* @param string $action
* @param bool $getCount Return the total count of the search
* Setting getCount = true will return the total
* matching instead of the items themselves.
* @return \Illuminate\Database\Eloquent\Collection|int|static[]
*/
public function searchEntityTable($terms, $entityType = 'page', $page = 1, $count = 20, $action = 'view', $getCount = false)
public function searchEntityTable(SearchOptions $searchOpts, string $entityType = 'page', int $page = 1, int $count = 20, string $action = 'view', bool $getCount = false)
{
$query = $this->buildEntitySearchQuery($terms, $entityType, $action);
$query = $this->buildEntitySearchQuery($searchOpts, $entityType, $action);
if ($getCount) {
return $query->count();
}
@@ -167,22 +147,18 @@ class SearchService
/**
* Create a search query for an entity
* @param array $terms
* @param string $entityType
* @param string $action
* @return EloquentBuilder
*/
protected function buildEntitySearchQuery($terms, $entityType = 'page', $action = 'view')
protected function buildEntitySearchQuery(SearchOptions $searchOpts, string $entityType = 'page', string $action = 'view'): EloquentBuilder
{
$entity = $this->entityProvider->get($entityType);
$entitySelect = $entity->newQuery();
// Handle normal search terms
if (count($terms['search']) > 0) {
if (count($searchOpts->searches) > 0) {
$subQuery = $this->db->table('search_terms')->select('entity_id', 'entity_type', \DB::raw('SUM(score) as score'));
$subQuery->where('entity_type', '=', $entity->getMorphClass());
$subQuery->where(function (Builder $query) use ($terms) {
foreach ($terms['search'] as $inputTerm) {
$subQuery->where(function (Builder $query) use ($searchOpts) {
foreach ($searchOpts->searches as $inputTerm) {
$query->orWhere('term', 'like', $inputTerm .'%');
}
})->groupBy('entity_type', 'entity_id');
@@ -193,9 +169,9 @@ class SearchService
}
// Handle exact term matching
if (count($terms['exact']) > 0) {
$entitySelect->where(function (EloquentBuilder $query) use ($terms, $entity) {
foreach ($terms['exact'] as $inputTerm) {
if (count($searchOpts->exacts) > 0) {
$entitySelect->where(function (EloquentBuilder $query) use ($searchOpts, $entity) {
foreach ($searchOpts->exacts as $inputTerm) {
$query->where(function (EloquentBuilder $query) use ($inputTerm, $entity) {
$query->where('name', 'like', '%'.$inputTerm .'%')
->orWhere($entity->textField, 'like', '%'.$inputTerm .'%');
@@ -205,12 +181,12 @@ class SearchService
}
// Handle tag searches
foreach ($terms['tags'] as $inputTerm) {
foreach ($searchOpts->tags as $inputTerm) {
$this->applyTagSearch($entitySelect, $inputTerm);
}
// Handle filters
foreach ($terms['filters'] as $filterTerm => $filterValue) {
foreach ($searchOpts->filters as $filterTerm => $filterValue) {
$functionName = Str::camel('filter_' . $filterTerm);
if (method_exists($this, $functionName)) {
$this->$functionName($entitySelect, $entity, $filterValue);
@@ -220,60 +196,10 @@ class SearchService
return $this->permissionService->enforceEntityRestrictions($entityType, $entitySelect, $action);
}
/**
* Parse a search string into components.
* @param $searchString
* @return array
*/
protected function parseSearchString($searchString)
{
$terms = [
'search' => [],
'exact' => [],
'tags' => [],
'filters' => []
];
$patterns = [
'exact' => '/"(.*?)"/',
'tags' => '/\[(.*?)\]/',
'filters' => '/\{(.*?)\}/'
];
// Parse special terms
foreach ($patterns as $termType => $pattern) {
$matches = [];
preg_match_all($pattern, $searchString, $matches);
if (count($matches) > 0) {
$terms[$termType] = $matches[1];
$searchString = preg_replace($pattern, '', $searchString);
}
}
// Parse standard terms
foreach (explode(' ', trim($searchString)) as $searchTerm) {
if ($searchTerm !== '') {
$terms['search'][] = $searchTerm;
}
}
// Split filter values out
$splitFilters = [];
foreach ($terms['filters'] as $filter) {
$explodedFilter = explode(':', $filter, 2);
$splitFilters[$explodedFilter[0]] = (count($explodedFilter) > 1) ? $explodedFilter[1] : '';
}
$terms['filters'] = $splitFilters;
return $terms;
}
/**
* Get the available query operators as a regex escaped list.
* @return mixed
*/
protected function getRegexEscapedOperators()
protected function getRegexEscapedOperators(): string
{
$escapedOperators = [];
foreach ($this->queryOperators as $operator) {
@@ -284,11 +210,8 @@ class SearchService
/**
* Apply a tag search term onto a entity query.
* @param EloquentBuilder $query
* @param string $tagTerm
* @return mixed
*/
protected function applyTagSearch(EloquentBuilder $query, $tagTerm)
protected function applyTagSearch(EloquentBuilder $query, string $tagTerm): EloquentBuilder
{
preg_match("/^(.*?)((".$this->getRegexEscapedOperators().")(.*?))?$/", $tagTerm, $tagSplit);
$query->whereHas('tags', function (EloquentBuilder $query) use ($tagSplit) {
@@ -318,7 +241,6 @@ class SearchService
/**
* Index the given entity.
* @param Entity $entity
*/
public function indexEntity(Entity $entity)
{

View File

@@ -1,5 +1,7 @@
<?php namespace BookStack\Entities;
use Illuminate\Support\Str;
class SlugGenerator
{
@@ -32,9 +34,7 @@ class SlugGenerator
*/
protected function formatNameAsSlug(string $name): string
{
$slug = preg_replace('/[\+\/\\\?\@\}\{\.\,\=\[\]\#\&\!\*\'\;\:\$\%]/', '', mb_strtolower($name));
$slug = preg_replace('/\s{2,}/', ' ', $slug);
$slug = str_replace(' ', '-', $slug);
$slug = Str::slug($name);
if ($slug === "") {
$slug = substr(md5(rand(1, 500)), 0, 5);
}

View File

@@ -9,7 +9,6 @@ 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;
@@ -26,6 +25,7 @@ class Handler extends ExceptionHandler
HttpException::class,
ModelNotFoundException::class,
ValidationException::class,
NotFoundException::class,
];
/**

View File

@@ -8,7 +8,7 @@ use Illuminate\Contracts\Container\BindingResolutionException;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class BooksApiController extends ApiController
class BookApiController extends ApiController
{
protected $bookRepo;
@@ -17,10 +17,12 @@ class BooksApiController extends ApiController
'create' => [
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
'update' => [
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
];

View File

@@ -5,9 +5,8 @@ use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
class BooksExportApiController extends ApiController
class BookExportApiController extends ApiController
{
protected $bookRepo;
protected $exportService;

View File

@@ -0,0 +1,104 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Book;
use BookStack\Entities\Chapter;
use BookStack\Entities\Repos\ChapterRepo;
use BookStack\Facades\Activity;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Http\Request;
class ChapterApiController extends ApiController
{
protected $chapterRepo;
protected $rules = [
'create' => [
'book_id' => 'required|integer',
'name' => 'required|string|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
'update' => [
'book_id' => 'integer',
'name' => 'string|min:1|max:255',
'description' => 'string|max:1000',
'tags' => 'array',
],
];
/**
* ChapterController constructor.
*/
public function __construct(ChapterRepo $chapterRepo)
{
$this->chapterRepo = $chapterRepo;
}
/**
* Get a listing of chapters visible to the user.
*/
public function list()
{
$chapters = Chapter::visible();
return $this->apiListingResponse($chapters, [
'id', 'book_id', 'name', 'slug', 'description', 'priority',
'created_at', 'updated_at', 'created_by', 'updated_by',
]);
}
/**
* Create a new chapter in the system.
*/
public function create(Request $request)
{
$this->validate($request, $this->rules['create']);
$bookId = $request->get('book_id');
$book = Book::visible()->findOrFail($bookId);
$this->checkOwnablePermission('chapter-create', $book);
$chapter = $this->chapterRepo->create($request->all(), $book);
Activity::add($chapter, 'chapter_create', $book->id);
return response()->json($chapter->load(['tags']));
}
/**
* View the details of a single chapter.
*/
public function read(string $id)
{
$chapter = Chapter::visible()->with(['tags', 'createdBy', 'updatedBy', 'pages' => function (HasMany $query) {
$query->visible()->get(['id', 'name', 'slug']);
}])->findOrFail($id);
return response()->json($chapter);
}
/**
* Update the details of a single chapter.
*/
public function update(Request $request, string $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$this->checkOwnablePermission('chapter-update', $chapter);
$updatedChapter = $this->chapterRepo->update($chapter, $request->all());
Activity::add($chapter, 'chapter_update', $chapter->book->id);
return response()->json($updatedChapter->load(['tags']));
}
/**
* Delete a chapter from the system.
*/
public function delete(string $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$this->checkOwnablePermission('chapter-delete', $chapter);
$this->chapterRepo->destroy($chapter);
Activity::addMessage('chapter_delete', $chapter->name, $chapter->book->id);
return response('', 204);
}
}

View File

@@ -0,0 +1,54 @@
<?php namespace BookStack\Http\Controllers\Api;
use BookStack\Entities\Chapter;
use BookStack\Entities\ExportService;
use BookStack\Entities\Repos\BookRepo;
use Throwable;
class ChapterExportApiController extends ApiController
{
protected $chapterRepo;
protected $exportService;
/**
* ChapterExportController constructor.
*/
public function __construct(BookRepo $chapterRepo, ExportService $exportService)
{
$this->chapterRepo = $chapterRepo;
$this->exportService = $exportService;
parent::__construct();
}
/**
* Export a chapter as a PDF file.
* @throws Throwable
*/
public function exportPdf(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$pdfContent = $this->exportService->chapterToPdf($chapter);
return $this->downloadResponse($pdfContent, $chapter->slug . '.pdf');
}
/**
* Export a chapter as a contained HTML file.
* @throws Throwable
*/
public function exportHtml(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$htmlContent = $this->exportService->chapterToContainedHtml($chapter);
return $this->downloadResponse($htmlContent, $chapter->slug . '.html');
}
/**
* Export a chapter as a plain text file.
*/
public function exportPlainText(int $id)
{
$chapter = Chapter::visible()->findOrFail($id);
$textContent = $this->exportService->chapterToPlainText($chapter);
return $this->downloadResponse($textContent, $chapter->slug . '.txt');
}
}

View File

@@ -8,6 +8,7 @@ use BookStack\Uploads\AttachmentService;
use Exception;
use Illuminate\Contracts\Filesystem\FileNotFoundException;
use Illuminate\Http\Request;
use Illuminate\Support\MessageBag;
use Illuminate\Validation\ValidationException;
class AttachmentController extends Controller
@@ -60,25 +61,17 @@ class AttachmentController extends Controller
/**
* Update an uploaded attachment.
* @throws ValidationException
* @throws NotFoundException
*/
public function uploadUpdate(Request $request, $attachmentId)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'file' => 'required|file'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
$this->checkOwnablePermission('view', $attachment->page);
$this->checkOwnablePermission('page-update', $attachment->page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError(trans('errors.attachment_page_mismatch'));
}
$uploadedFile = $request->file('file');
@@ -92,57 +85,87 @@ class AttachmentController extends Controller
}
/**
* Update the details of an existing file.
* @throws ValidationException
* @throws NotFoundException
* Get the update form for an attachment.
* @return \Illuminate\Contracts\Foundation\Application|\Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function update(Request $request, $attachmentId)
public function getUpdateForm(string $attachmentId)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'string|min:1|max:255'
]);
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId);
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('page-update', $page);
$this->checkOwnablePermission('page-update', $attachment->page);
$this->checkOwnablePermission('attachment-create', $attachment);
if (intval($pageId) !== intval($attachment->uploaded_to)) {
return $this->jsonError(trans('errors.attachment_page_mismatch'));
return view('attachments.manager-edit-form', [
'attachment' => $attachment,
]);
}
/**
* Update the details of an existing file.
*/
public function update(Request $request, string $attachmentId)
{
$attachment = $this->attachment->newQuery()->findOrFail($attachmentId);
try {
$this->validate($request, [
'attachment_edit_name' => 'required|string|min:1|max:255',
'attachment_edit_url' => 'string|min:1|max:255'
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
'attachment' => $attachment,
'errors' => new MessageBag($exception->errors()),
]), 422);
}
$attachment = $this->attachmentService->updateFile($attachment, $request->all());
return response()->json($attachment);
$this->checkOwnablePermission('view', $attachment->page);
$this->checkOwnablePermission('page-update', $attachment->page);
$this->checkOwnablePermission('attachment-create', $attachment);
$attachment = $this->attachmentService->updateFile($attachment, [
'name' => $request->get('attachment_edit_name'),
'link' => $request->get('attachment_edit_url'),
]);
return view('attachments.manager-edit-form', [
'attachment' => $attachment,
]);
}
/**
* Attach a link to a page.
* @throws ValidationException
* @throws NotFoundException
*/
public function attachLink(Request $request)
{
$this->validate($request, [
'uploaded_to' => 'required|integer|exists:pages,id',
'name' => 'required|string|min:1|max:255',
'link' => 'required|string|min:1|max:255'
]);
$pageId = $request->get('attachment_link_uploaded_to');
try {
$this->validate($request, [
'attachment_link_uploaded_to' => 'required|integer|exists:pages,id',
'attachment_link_name' => 'required|string|min:1|max:255',
'attachment_link_url' => 'required|string|min:1|max:255'
]);
} catch (ValidationException $exception) {
return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
'pageId' => $pageId,
'errors' => new MessageBag($exception->errors()),
]), 422);
}
$pageId = $request->get('uploaded_to');
$page = $this->pageRepo->getById($pageId);
$this->checkPermission('attachment-create-all');
$this->checkOwnablePermission('page-update', $page);
$attachmentName = $request->get('name');
$link = $request->get('link');
$attachmentName = $request->get('attachment_link_name');
$link = $request->get('attachment_link_url');
$attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
return response()->json($attachment);
return view('attachments.manager-link-form', [
'pageId' => $pageId,
]);
}
/**
@@ -152,7 +175,9 @@ class AttachmentController extends Controller
{
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-view', $page);
return response()->json($page->attachments);
return view('attachments.manager-list', [
'attachments' => $page->attachments->all(),
]);
}
/**
@@ -163,14 +188,13 @@ class AttachmentController extends Controller
public function sortForPage(Request $request, int $pageId)
{
$this->validate($request, [
'files' => 'required|array',
'files.*.id' => 'required|integer',
'order' => 'required|array',
]);
$page = $this->pageRepo->getById($pageId);
$this->checkOwnablePermission('page-update', $page);
$attachments = $request->get('files');
$this->attachmentService->updateFileOrderWithinPage($attachments, $pageId);
$attachmentOrder = $request->get('order');
$this->attachmentService->updateFileOrderWithinPage($attachmentOrder, $pageId);
return response()->json(['message' => trans('entities.attachments_order_updated')]);
}
@@ -179,7 +203,7 @@ class AttachmentController extends Controller
* @throws FileNotFoundException
* @throws NotFoundException
*/
public function get(int $attachmentId)
public function get(string $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
try {
@@ -200,11 +224,9 @@ class AttachmentController extends Controller
/**
* Delete a specific attachment in the system.
* @param $attachmentId
* @return mixed
* @throws Exception
*/
public function delete(int $attachmentId)
public function delete(string $attachmentId)
{
$attachment = $this->attachment->findOrFail($attachmentId);
$this->checkOwnablePermission('attachment-delete', $attachment);

View File

@@ -0,0 +1,51 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Actions\Activity;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\DB;
class AuditLogController extends Controller
{
public function index(Request $request)
{
$this->checkPermission('settings-manage');
$this->checkPermission('users-manage');
$listDetails = [
'order' => $request->get('order', 'desc'),
'event' => $request->get('event', ''),
'sort' => $request->get('sort', 'created_at'),
'date_from' => $request->get('date_from', ''),
'date_to' => $request->get('date_to', ''),
];
$query = Activity::query()
->with(['entity', 'user'])
->orderBy($listDetails['sort'], $listDetails['order']);
if ($listDetails['event']) {
$query->where('key', '=', $listDetails['event']);
}
if ($listDetails['date_from']) {
$query->where('created_at', '>=', $listDetails['date_from']);
}
if ($listDetails['date_to']) {
$query->where('created_at', '<=', $listDetails['date_to']);
}
$activities = $query->paginate(100);
$activities->appends($listDetails);
$keys = DB::table('activities')->select('key')->distinct()->pluck('key');
$this->setPageTitle(trans('settings.audit'));
return view('settings.audit', [
'activities' => $activities,
'listDetails' => $listDetails,
'activityKeys' => $keys,
]);
}
}

View File

@@ -2,6 +2,7 @@
namespace BookStack\Http\Controllers\Auth;
use Activity;
use BookStack\Auth\Access\SocialAuthService;
use BookStack\Exceptions\LoginAttemptEmailNeededException;
use BookStack\Exceptions\LoginAttemptException;
@@ -76,9 +77,13 @@ class LoginController extends Controller
]);
}
// Store the previous location for redirect after login
$previous = url()->previous('');
if (setting('app-public') && $previous && $previous !== url('/login')) {
redirect()->setIntendedUrl($previous);
if ($previous && $previous !== url('/login') && setting('app-public')) {
$isPreviousFromInstance = (strpos($previous, url('/')) === 0);
if ($isPreviousFromInstance) {
redirect()->setIntendedUrl($previous);
}
}
return view('auth.login', [
@@ -98,6 +103,7 @@ class LoginController extends Controller
public function login(Request $request)
{
$this->validateLogin($request);
$username = $request->get($this->username());
// 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
@@ -106,6 +112,7 @@ class LoginController extends Controller
$this->hasTooManyLoginAttempts($request)) {
$this->fireLockoutEvent($request);
Activity::logFailedLogin($username);
return $this->sendLockoutResponse($request);
}
@@ -114,6 +121,7 @@ class LoginController extends Controller
return $this->sendLoginResponse($request);
}
} catch (LoginAttemptException $exception) {
Activity::logFailedLogin($username);
return $this->sendLoginAttemptExceptionResponse($exception, $request);
}
@@ -122,6 +130,7 @@ class LoginController extends Controller
// user surpasses their maximum number of attempts they will get locked out.
$this->incrementLoginAttempts($request);
Activity::logFailedLogin($username);
return $this->sendFailedLoginResponse($request);
}

View File

@@ -10,9 +10,6 @@ class CommentController extends Controller
{
protected $commentRepo;
/**
* CommentController constructor.
*/
public function __construct(CommentRepo $commentRepo)
{
$this->commentRepo = $commentRepo;
@@ -23,11 +20,11 @@ class CommentController extends Controller
* Save a new comment for a Page
* @throws ValidationException
*/
public function savePageComment(Request $request, int $pageId, int $commentId = null)
public function savePageComment(Request $request, int $pageId)
{
$this->validate($request, [
'text' => 'required|string',
'html' => 'required|string',
'parent_id' => 'nullable|integer',
]);
$page = Page::visible()->find($pageId);
@@ -35,8 +32,6 @@ class CommentController extends Controller
return response('Not found', 404);
}
$this->checkOwnablePermission('page-view', $page);
// Prevent adding comments to draft pages
if ($page->draft) {
return $this->jsonError(trans('errors.cannot_add_comment_to_draft'), 400);
@@ -44,7 +39,7 @@ class CommentController extends Controller
// Create a new comment.
$this->checkPermission('comment-create-all');
$comment = $this->commentRepo->create($page, $request->only(['html', 'text', 'parent_id']));
$comment = $this->commentRepo->create($page, $request->get('text'), $request->get('parent_id'));
Activity::add($page, 'commented_on', $page->book->id);
return view('comments.comment', ['comment' => $comment]);
}
@@ -57,14 +52,13 @@ class CommentController extends Controller
{
$this->validate($request, [
'text' => 'required|string',
'html' => 'required|string',
]);
$comment = $this->commentRepo->getById($commentId);
$this->checkOwnablePermission('page-view', $comment->entity);
$this->checkOwnablePermission('comment-update', $comment);
$comment = $this->commentRepo->update($comment, $request->only(['html', 'text']));
$comment = $this->commentRepo->update($comment, $request->get('text'));
return view('comments.comment', ['comment' => $comment]);
}

View File

@@ -8,6 +8,7 @@ use Illuminate\Foundation\Validation\ValidatesRequests;
use Illuminate\Http\Exceptions\HttpResponseException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller as BaseController;
use Illuminate\Validation\ValidationException;
abstract class Controller extends BaseController
{
@@ -132,23 +133,6 @@ abstract class Controller extends BaseController
return response()->json(['message' => $messageText, 'status' => 'error'], $statusCode);
}
/**
* Create the response for when a request fails validation.
* @param \Illuminate\Http\Request $request
* @param array $errors
* @return \Symfony\Component\HttpFoundation\Response
*/
protected function buildFailedValidationResponse(Request $request, array $errors)
{
if ($request->expectsJson()) {
return response()->json(['validation' => $errors], 422);
}
return redirect()->to($this->getRedirectUrl())
->withInput($request->input())
->withErrors($errors, $this->errorBag());
}
/**
* Create a response that forces a download in the browser.
* @param string $content

View File

@@ -69,11 +69,7 @@ class HomeController extends Controller
}
if ($homepageOption === 'bookshelves') {
$shelfRepo = app(BookshelfRepo::class);
$shelves = app(BookshelfRepo::class)->getAllPaginated(18, $commonData['sort'], $commonData['order']);
foreach ($shelves as $shelf) {
$shelf->books = $shelf->visibleBooks;
}
$data = array_merge($commonData, ['shelves' => $shelves]);
return view('common.home-shelves', $data);
}

View File

@@ -30,7 +30,10 @@ class DrawioImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('drawio', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
return response()->json($imgData);
return view('components.image-manager-list', [
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
}
/**
@@ -72,6 +75,7 @@ class DrawioImageController extends Controller
if ($imageData === null) {
return $this->jsonError("Image data could not be found");
}
return response()->json([
'content' => base64_encode($imageData)
]);

View File

@@ -6,6 +6,7 @@ use BookStack\Exceptions\ImageUploadException;
use BookStack\Uploads\ImageRepo;
use Illuminate\Http\Request;
use BookStack\Http\Controllers\Controller;
use Illuminate\Validation\ValidationException;
class GalleryImageController extends Controller
{
@@ -13,7 +14,6 @@ class GalleryImageController extends Controller
/**
* GalleryImageController constructor.
* @param ImageRepo $imageRepo
*/
public function __construct(ImageRepo $imageRepo)
{
@@ -24,8 +24,6 @@ class GalleryImageController extends Controller
/**
* Get a list of gallery images, in a list.
* Can be paged and filtered by entity.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function list(Request $request)
{
@@ -35,14 +33,15 @@ class GalleryImageController extends Controller
$parentTypeFilter = $request->get('filter_type', null);
$imgData = $this->imageRepo->getEntityFiltered('gallery', $parentTypeFilter, $page, 24, $uploadedToFilter, $searchTerm);
return response()->json($imgData);
return view('components.image-manager-list', [
'images' => $imgData['images'],
'hasMore' => $imgData['has_more'],
]);
}
/**
* Store a new gallery image in the system.
* @param Request $request
* @return Illuminate\Http\JsonResponse
* @throws \Exception
* @throws ValidationException
*/
public function create(Request $request)
{

View File

@@ -6,8 +6,11 @@ use BookStack\Http\Controllers\Controller;
use BookStack\Repos\PageRepo;
use BookStack\Uploads\Image;
use BookStack\Uploads\ImageRepo;
use Exception;
use Illuminate\Filesystem\Filesystem as File;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class ImageController extends Controller
{
@@ -17,9 +20,6 @@ class ImageController extends Controller
/**
* ImageController constructor.
* @param Image $image
* @param File $file
* @param ImageRepo $imageRepo
*/
public function __construct(Image $image, File $file, ImageRepo $imageRepo)
{
@@ -31,8 +31,6 @@ class ImageController extends Controller
/**
* Provide an image file from storage.
* @param string $path
* @return mixed
*/
public function showImage(string $path)
{
@@ -47,13 +45,10 @@ class ImageController extends Controller
/**
* Update image details
* @param Request $request
* @param integer $id
* @return \Illuminate\Http\JsonResponse
* @throws ImageUploadException
* @throws \Exception
* @throws ValidationException
*/
public function update(Request $request, $id)
public function update(Request $request, string $id)
{
$this->validate($request, [
'name' => 'required|min:2|string'
@@ -64,47 +59,50 @@ class ImageController extends Controller
$this->checkOwnablePermission('image-update', $image);
$image = $this->imageRepo->updateImageDetails($image, $request->all());
return response()->json($image);
$this->imageRepo->loadThumbs($image);
return view('components.image-manager-form', [
'image' => $image,
'dependantPages' => null,
]);
}
/**
* Show the usage of an image on pages.
* Get the form for editing the given image.
* @throws Exception
*/
public function usage(int $id)
public function edit(Request $request, string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkImagePermission($image);
$pages = Page::visible()->where('html', 'like', '%' . $image->url . '%')->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
$page->html = '';
$page->text = '';
if ($request->has('delete')) {
$dependantPages = $this->imageRepo->getPagesUsingImage($image);
}
$result = count($pages) > 0 ? $pages : false;
return response()->json($result);
$this->imageRepo->loadThumbs($image);
return view('components.image-manager-form', [
'image' => $image,
'dependantPages' => $dependantPages ?? null,
]);
}
/**
* Deletes an image and all thumbnail/image files
* @param int $id
* @return \Illuminate\Http\JsonResponse
* @throws \Exception
* @throws Exception
*/
public function destroy($id)
public function destroy(string $id)
{
$image = $this->imageRepo->getById($id);
$this->checkOwnablePermission('image-delete', $image);
$this->checkImagePermission($image);
$this->imageRepo->destroyImage($image);
return response()->json(trans('components.images_deleted'));
return response('');
}
/**
* Check related page permission and ensure type is drawio or gallery.
* @param Image $image
*/
protected function checkImagePermission(Image $image)
{

View File

@@ -0,0 +1,68 @@
<?php
namespace BookStack\Http\Controllers;
use BookStack\Notifications\TestEmail;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
class MaintenanceController extends Controller
{
/**
* Show the page for application maintenance.
*/
public function index()
{
$this->checkPermission('settings-manage');
$this->setPageTitle(trans('settings.maint'));
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.maintenance', ['version' => $version]);
}
/**
* Action to clean-up images in the system.
*/
public function cleanupImages(Request $request, ImageService $imageService)
{
$this->checkPermission('settings-manage');
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
$dryRun = !($request->has('confirm'));
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
$deleteCount = count($imagesToDelete);
if ($deleteCount === 0) {
$this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
return redirect('/settings/maintenance')->withInput();
}
if ($dryRun) {
session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
} else {
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
}
return redirect('/settings/maintenance#image-cleanup')->withInput();
}
/**
* Action to send a test e-mail to the current user.
*/
public function sendTestEmail()
{
$this->checkPermission('settings-manage');
try {
user()->notify(new TestEmail());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
$this->showErrorNotification($errorMessage);
}
return redirect('/settings/maintenance#image-cleanup')->withInput();
}
}

View File

@@ -163,6 +163,8 @@ class PageController extends Controller
public function getPageAjax(int $pageId)
{
$page = $this->pageRepo->getById($pageId);
$page->setHidden(array_diff($page->getHidden(), ['html', 'markdown']));
$page->addHidden(['book']);
return response()->json($page);
}

View File

@@ -1,5 +1,6 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Entities\Managers\PageContent;
use BookStack\Entities\Repos\PageRepo;
use BookStack\Exceptions\NotFoundException;
use BookStack\Facades\Activity;
@@ -46,6 +47,9 @@ class PageRevisionController extends Controller
}
$page->fill($revision->toArray());
// TODO - Refactor PageContent so we don't need to juggle this
$page->html = $revision->html;
$page->html = (new PageContent($page))->render();
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName' => $page->getShortName()]));
return view('pages.revision', [
@@ -73,6 +77,9 @@ class PageRevisionController extends Controller
$diff = (new Htmldiff)->diff($prevContent, $revision->html);
$page->fill($revision->toArray());
// TODO - Refactor PageContent so we don't need to juggle this
$page->html = $revision->html;
$page->html = (new PageContent($page))->render();
$this->setPageTitle(trans('entities.pages_revision_named', ['pageName'=>$page->getShortName()]));
return view('pages.revision', [

View File

@@ -2,7 +2,9 @@
use BookStack\Auth\Permissions\PermissionsRepo;
use BookStack\Exceptions\PermissionsException;
use Exception;
use Illuminate\Http\Request;
use Illuminate\Validation\ValidationException;
class PermissionController extends Controller
{
@@ -11,7 +13,6 @@ class PermissionController extends Controller
/**
* PermissionController constructor.
* @param \BookStack\Auth\Permissions\PermissionsRepo $permissionsRepo
*/
public function __construct(PermissionsRepo $permissionsRepo)
{
@@ -31,7 +32,6 @@ class PermissionController extends Controller
/**
* Show the form to create a new role
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function createRole()
{
@@ -41,15 +41,13 @@ class PermissionController extends Controller
/**
* Store a new role in the system.
* @param Request $request
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
*/
public function storeRole(Request $request)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => 'required|min:3|max:200',
'description' => 'max:250'
'display_name' => 'required|min:3|max:180',
'description' => 'max:180'
]);
$this->permissionsRepo->saveNewRole($request->all());
@@ -59,11 +57,9 @@ class PermissionController extends Controller
/**
* Show the form for editing a user role.
* @param $id
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
* @throws PermissionsException
*/
public function editRole($id)
public function editRole(string $id)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
@@ -75,18 +71,14 @@ class PermissionController extends Controller
/**
* Updates a user role.
* @param Request $request
* @param $id
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws PermissionsException
* @throws \Illuminate\Validation\ValidationException
* @throws ValidationException
*/
public function updateRole(Request $request, $id)
public function updateRole(Request $request, string $id)
{
$this->checkPermission('user-roles-manage');
$this->validate($request, [
'display_name' => 'required|min:3|max:200',
'description' => 'max:250'
'display_name' => 'required|min:3|max:180',
'description' => 'max:180'
]);
$this->permissionsRepo->updateRole($id, $request->all());
@@ -97,10 +89,8 @@ class PermissionController extends Controller
/**
* Show the view to delete a role.
* Offers the chance to migrate users.
* @param $id
* @return \Illuminate\Contracts\View\Factory|\Illuminate\View\View
*/
public function showDeleteRole($id)
public function showDeleteRole(string $id)
{
$this->checkPermission('user-roles-manage');
$role = $this->permissionsRepo->getRoleById($id);
@@ -113,11 +103,9 @@ class PermissionController extends Controller
/**
* Delete a role from the system,
* Migrate from a previous role if set.
* @param Request $request
* @param $id
* @return \Illuminate\Http\RedirectResponse|\Illuminate\Routing\Redirector
* @throws Exception
*/
public function deleteRole(Request $request, $id)
public function deleteRole(Request $request, string $id)
{
$this->checkPermission('user-roles-manage');

View File

@@ -6,6 +6,7 @@ use BookStack\Entities\Bookshelf;
use BookStack\Entities\Entity;
use BookStack\Entities\Managers\EntityContext;
use BookStack\Entities\SearchService;
use BookStack\Entities\SearchOptions;
use Illuminate\Http\Request;
class SearchController extends Controller
@@ -33,20 +34,22 @@ class SearchController extends Controller
*/
public function search(Request $request)
{
$searchTerm = $request->get('term');
$this->setPageTitle(trans('entities.search_for_term', ['term' => $searchTerm]));
$searchOpts = SearchOptions::fromRequest($request);
$fullSearchString = $searchOpts->toString();
$this->setPageTitle(trans('entities.search_for_term', ['term' => $fullSearchString]));
$page = intval($request->get('page', '0')) ?: 1;
$nextPageLink = url('/search?term=' . urlencode($searchTerm) . '&page=' . ($page+1));
$nextPageLink = url('/search?term=' . urlencode($fullSearchString) . '&page=' . ($page+1));
$results = $this->searchService->searchEntities($searchTerm, 'all', $page, 20);
$results = $this->searchService->searchEntities($searchOpts, 'all', $page, 20);
return view('search.all', [
'entities' => $results['results'],
'totalResults' => $results['total'],
'searchTerm' => $searchTerm,
'searchTerm' => $fullSearchString,
'hasNextPage' => $results['has_more'],
'nextPageLink' => $nextPageLink
'nextPageLink' => $nextPageLink,
'options' => $searchOpts,
]);
}
@@ -84,7 +87,7 @@ class SearchController extends Controller
// Search for entities otherwise show most popular
if ($searchTerm !== false) {
$searchTerm .= ' {type:'. implode('|', $entityTypes) .'}';
$entities = $this->searchService->searchEntities($searchTerm, 'all', 1, 20, $permission)['results'];
$entities = $this->searchService->searchEntities(SearchOptions::fromString($searchTerm), 'all', 1, 20, $permission)['results'];
} else {
$entities = $this->viewService->getPopular(20, 0, $entityTypes, $permission);
}

View File

@@ -1,9 +1,7 @@
<?php namespace BookStack\Http\Controllers;
use BookStack\Auth\User;
use BookStack\Notifications\TestEmail;
use BookStack\Uploads\ImageRepo;
use BookStack\Uploads\ImageService;
use Illuminate\Http\Request;
class SettingController extends Controller
@@ -74,63 +72,4 @@ class SettingController extends Controller
$redirectLocation = '/settings#' . $request->get('section', '');
return redirect(rtrim($redirectLocation, '#'));
}
/**
* Show the page for application maintenance.
*/
public function showMaintenance()
{
$this->checkPermission('settings-manage');
$this->setPageTitle(trans('settings.maint'));
// Get application version
$version = trim(file_get_contents(base_path('version')));
return view('settings.maintenance', ['version' => $version]);
}
/**
* Action to clean-up images in the system.
*/
public function cleanupImages(Request $request, ImageService $imageService)
{
$this->checkPermission('settings-manage');
$checkRevisions = !($request->get('ignore_revisions', 'false') === 'true');
$dryRun = !($request->has('confirm'));
$imagesToDelete = $imageService->deleteUnusedImages($checkRevisions, $dryRun);
$deleteCount = count($imagesToDelete);
if ($deleteCount === 0) {
$this->showWarningNotification(trans('settings.maint_image_cleanup_nothing_found'));
return redirect('/settings/maintenance')->withInput();
}
if ($dryRun) {
session()->flash('cleanup-images-warning', trans('settings.maint_image_cleanup_warning', ['count' => $deleteCount]));
} else {
$this->showSuccessNotification(trans('settings.maint_image_cleanup_success', ['count' => $deleteCount]));
}
return redirect('/settings/maintenance#image-cleanup')->withInput();
}
/**
* Action to send a test e-mail to the current user.
*/
public function sendTestEmail()
{
$this->checkPermission('settings-manage');
try {
user()->notify(new TestEmail());
$this->showSuccessNotification(trans('settings.maint_send_test_email_success', ['address' => user()->email]));
} catch (\Exception $exception) {
$errorMessage = trans('errors.maintenance_test_email_failure') . "\n" . $exception->getMessage();
$this->showErrorNotification($errorMessage);
}
return redirect('/settings/maintenance#image-cleanup')->withInput();
}
}

View File

@@ -10,7 +10,6 @@ class TagController extends Controller
/**
* TagController constructor.
* @param $tagRepo
*/
public function __construct(TagRepo $tagRepo)
{
@@ -18,39 +17,23 @@ class TagController extends Controller
parent::__construct();
}
/**
* Get all the Tags for a particular entity
* @param $entityType
* @param $entityId
* @return \Illuminate\Http\JsonResponse
*/
public function getForEntity($entityType, $entityId)
{
$tags = $this->tagRepo->getForEntity($entityType, $entityId);
return response()->json($tags);
}
/**
* Get tag name suggestions from a given search term.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getNameSuggestions(Request $request)
{
$searchTerm = $request->get('search', false);
$searchTerm = $request->get('search', null);
$suggestions = $this->tagRepo->getNameSuggestions($searchTerm);
return response()->json($suggestions);
}
/**
* Get tag value suggestions from a given search term.
* @param Request $request
* @return \Illuminate\Http\JsonResponse
*/
public function getValueSuggestions(Request $request)
{
$searchTerm = $request->get('search', false);
$tagName = $request->get('name', false);
$searchTerm = $request->get('search', null);
$tagName = $request->get('name', null);
$suggestions = $this->tagRepo->getValueSuggestions($searchTerm, $tagName);
return response()->json($suggestions);
}

View File

@@ -66,8 +66,8 @@ class UserController extends Controller
{
$this->checkPermission('users-manage');
$validationRules = [
'name' => 'required',
'email' => 'required|email|unique:users,email'
'name' => 'required',
'email' => 'required|email|unique:users,email'
];
$authMethod = config('auth.method');

View File

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

View File

@@ -44,6 +44,10 @@ class Authenticate
], 401);
}
if (session()->get('sent-email-confirmation') === true) {
return redirect('/register/confirm');
}
return redirect('/register/confirm/awaiting');
}
}

View File

@@ -19,6 +19,7 @@ class Localization
*/
protected $localeMap = [
'ar' => 'ar',
'bg' => 'bg_BG',
'da' => 'da_DK',
'de' => 'de_DE',
'de_informal' => 'de_DE',

View File

@@ -2,6 +2,10 @@
use BookStack\Auth\User;
/**
* @property int created_by
* @property int updated_by
*/
abstract class Ownable extends Model
{
/**

View File

@@ -3,6 +3,13 @@
use BookStack\Entities\Page;
use BookStack\Ownable;
/**
* @property int id
* @property string name
* @property string path
* @property string extension
* @property bool external
*/
class Attachment extends Ownable
{
protected $fillable = ['name', 'order'];
@@ -30,13 +37,28 @@ class Attachment extends Ownable
/**
* Get the url of this file.
* @return string
*/
public function getUrl()
public function getUrl(): string
{
if ($this->external && strpos($this->path, 'http') !== 0) {
return $this->path;
}
return url('/attachments/' . $this->id);
}
/**
* Generate a HTML link to this attachment.
*/
public function htmlLink(): string
{
return '<a target="_blank" href="'.e($this->getUrl()).'">'.e($this->name).'</a>';
}
/**
* Generate a markdown link to this attachment.
*/
public function markdownLink(): string
{
return '['. $this->name .']('. $this->getUrl() .')';
}
}

View File

@@ -109,14 +109,14 @@ class AttachmentService extends UploadService
}
/**
* Updates the file ordering for a listing of attached files.
* @param array $attachmentList
* @param $pageId
* Updates the ordering for a listing of attached files.
*/
public function updateFileOrderWithinPage($attachmentList, $pageId)
public function updateFileOrderWithinPage(array $attachmentOrder, string $pageId)
{
foreach ($attachmentList as $index => $attachment) {
Attachment::where('uploaded_to', '=', $pageId)->where('id', '=', $attachment['id'])->update(['order' => $index]);
foreach ($attachmentOrder as $index => $attachmentId) {
Attachment::query()->where('uploaded_to', '=', $pageId)
->where('id', '=', $attachmentId)
->update(['order' => $index]);
}
}

View File

@@ -185,7 +185,7 @@ class ImageRepo
* Load thumbnails onto an image object.
* @throws Exception
*/
protected function loadThumbs(Image $image)
public function loadThumbs(Image $image)
{
$image->thumbs = [
'gallery' => $this->getThumbnail($image, 150, 150, false),
@@ -219,4 +219,20 @@ class ImageRepo
return null;
}
}
/**
* Get the user visible pages using the given image.
*/
public function getPagesUsingImage(Image $image): array
{
$pages = Page::visible()
->where('html', 'like', '%' . $image->url . '%')
->get(['id', 'name', 'slug', 'book_id']);
foreach ($pages as $page) {
$page->url = $page->getUrl();
}
return $pages->all();
}
}

View File

@@ -124,29 +124,24 @@ class ImageService extends UploadService
}
/**
* Saves a new image
* @param string $imageName
* @param string $imageData
* @param string $type
* @param int $uploadedTo
* @return Image
* Save a new image into storage.
* @throws ImageUploadException
*/
private function saveNew($imageName, $imageData, $type, $uploadedTo = 0)
private function saveNew(string $imageName, string $imageData, string $type, int $uploadedTo = 0): Image
{
$storage = $this->getStorage($type);
$secureUploads = setting('app-secure-images');
$imageName = str_replace(' ', '-', $imageName);
$fileName = $this->cleanImageFileName($imageName);
$imagePath = '/uploads/images/' . $type . '/' . Date('Y-m') . '/';
while ($storage->exists($imagePath . $imageName)) {
$imageName = Str::random(3) . $imageName;
while ($storage->exists($imagePath . $fileName)) {
$fileName = Str::random(3) . $fileName;
}
$fullPath = $imagePath . $imageName;
$fullPath = $imagePath . $fileName;
if ($secureUploads) {
$fullPath = $imagePath . Str::random(16) . '-' . $imageName;
$fullPath = $imagePath . Str::random(16) . '-' . $fileName;
}
try {
@@ -175,6 +170,23 @@ class ImageService extends UploadService
return $image;
}
/**
* Clean up an image file name to be both URL and storage safe.
*/
protected function cleanImageFileName(string $name): string
{
$name = str_replace(' ', '-', $name);
$nameParts = explode('.', $name);
$extension = array_pop($nameParts);
$name = implode('.', $nameParts);
$name = Str::slug($name);
if (strlen($name) === 0) {
$name = Str::random(10);
}
return $name . '.' . $extension;
}
/**
* Checks if the image is a gif. Returns true if it is, else false.
@@ -223,6 +235,7 @@ class ImageService extends UploadService
$storage->setVisibility($thumbFilePath, 'public');
$this->cache->put('images-' . $image->id . '-' . $thumbFilePath, $thumbFilePath, 60 * 60 * 72);
return $this->getPublicUrl($thumbFilePath);
}
@@ -292,11 +305,9 @@ class ImageService extends UploadService
/**
* Destroys an image at the given path.
* Searches for image thumbnails in addition to main provided path..
* @param string $path
* @return bool
* Searches for image thumbnails in addition to main provided path.
*/
protected function destroyImagesFromPath(string $path)
protected function destroyImagesFromPath(string $path): bool
{
$storage = $this->getStorage();
@@ -306,8 +317,7 @@ class ImageService extends UploadService
// Delete image files
$imagesToDelete = $allImages->filter(function ($imagePath) use ($imageFileName) {
$expectedIndex = strlen($imagePath) - strlen($imageFileName);
return strpos($imagePath, $imageFileName) === $expectedIndex;
return basename($imagePath) === $imageFileName;
});
$storage->delete($imagesToDelete->all());

View File

@@ -153,10 +153,6 @@ function icon(string $name, array $attrs = []): string
* Generate a url with multiple parameters for sorting purposes.
* Works out the logic to set the correct sorting direction
* Discards empty parameters and allows overriding.
* @param string $path
* @param array $data
* @param array $overrideData
* @return string
*/
function sortUrl(string $path, array $data, array $overrideData = []): string
{
@@ -166,7 +162,7 @@ function sortUrl(string $path, array $data, array $overrideData = []): string
// Change sorting direction is already sorted on current attribute
if (isset($overrideData['sort']) && $overrideData['sort'] === $data['sort']) {
$queryData['order'] = ($data['order'] === 'asc') ? 'desc' : 'asc';
} else {
} elseif (isset($overrideData['sort'])) {
$queryData['order'] = 'asc';
}

View File

@@ -13,15 +13,18 @@
"ext-mbstring": "*",
"ext-tidy": "*",
"ext-xml": "*",
"barryvdh/laravel-dompdf": "^0.8.5",
"barryvdh/laravel-snappy": "^0.4.5",
"barryvdh/laravel-dompdf": "^0.8.6",
"barryvdh/laravel-snappy": "^0.4.7",
"doctrine/dbal": "^2.9",
"facade/ignition": "^1.4",
"fideloper/proxy": "^4.0",
"gathercontent/htmldiff": "^0.2.1",
"intervention/image": "^2.5",
"laravel/framework": "^6.12",
"laravel/framework": "^6.18",
"laravel/socialite": "^4.3.2",
"league/commonmark": "^1.4",
"league/flysystem-aws-s3-v3": "^1.0",
"nunomaduro/collision": "^3.0",
"onelogin/php-saml": "^3.3",
"predis/predis": "^1.1",
"socialiteproviders/discord": "^2.0",
@@ -29,9 +32,7 @@
"socialiteproviders/microsoft-azure": "^3.0",
"socialiteproviders/okta": "^1.0",
"socialiteproviders/slack": "^3.0",
"socialiteproviders/twitch": "^5.0",
"facade/ignition": "^1.4",
"nunomaduro/collision": "^3.0"
"socialiteproviders/twitch": "^5.0"
},
"require-dev": {
"barryvdh/laravel-debugbar": "^3.2.8",

2197
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class DropJointPermissionsId extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('joint_permissions', function (Blueprint $table) {
$table->dropColumn('id');
$table->primary(['role_id', 'entity_type', 'entity_id', 'action'], 'joint_primary');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('joint_permissions', function (Blueprint $table) {
$table->dropPrimary(['role_id', 'entity_type', 'entity_id', 'action']);
});
Schema::table('joint_permissions', function (Blueprint $table) {
$table->increments('id')->unsigned();
});
}
}

View File

@@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
class RemoveRoleNameField extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('roles', function (Blueprint $table) {
$table->dropColumn('name');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('roles', function (Blueprint $table) {
$table->string('name')->index();
});
DB::table('roles')->update([
"name" => DB::raw("lower(replace(`display_name`, ' ', '-'))"),
]);
}
}

View File

@@ -0,0 +1,34 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
class AddActivityIndexes extends Migration
{
/**
* Run the migrations.
*
* @return void
*/
public function up()
{
Schema::table('activities', function(Blueprint $table) {
$table->index('key');
$table->index('created_at');
});
}
/**
* Reverse the migrations.
*
* @return void
*/
public function down()
{
Schema::table('activities', function(Blueprint $table) {
$table->dropIndex('activities_key_index');
$table->dropIndex('activities_created_at_index');
});
}
}

View File

@@ -0,0 +1,9 @@
{
"book_id": 1,
"name": "My fantastic new chapter",
"description": "This is a great new chapter that I've created via the API",
"tags": [
{"name": "Category", "value": "Top Content"},
{"name": "Rating", "value": "Highest"}
]
}

View File

@@ -0,0 +1,9 @@
{
"book_id": 1,
"name": "My fantastic updated chapter",
"description": "This is an updated chapter that I've altered via the API",
"tags": [
{"name": "Category", "value": "Kinda Good Content"},
{"name": "Rating", "value": "Medium"}
]
}

View File

@@ -7,15 +7,12 @@
"updated_at": "2020-01-12 14:11:51",
"created_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"image_id": 452,
"tags": [
{
"id": 13,

View File

@@ -0,0 +1,38 @@
{
"book_id": 1,
"priority": 6,
"name": "My fantastic new chapter",
"description": "This is a great new chapter that I've created via the API",
"created_by": 1,
"updated_by": 1,
"slug": "my-fantastic-new-chapter",
"updated_at": "2020-05-22 22:59:55",
"created_at": "2020-05-22 22:59:55",
"id": 74,
"book": {
"id": 1,
"name": "BookStack User Guide",
"slug": "bookstack-user-guide",
"description": "This is a general guide on using BookStack on a day-to-day basis.",
"created_at": "2019-05-05 21:48:46",
"updated_at": "2019-12-11 20:57:31",
"created_by": 1,
"updated_by": 1
},
"tags": [
{
"name": "Category",
"value": "Top Content",
"order": 0,
"created_at": "2020-05-22 22:59:55",
"updated_at": "2020-05-22 22:59:55"
},
{
"name": "Rating",
"value": "Highest",
"order": 0,
"created_at": "2020-05-22 22:59:55",
"updated_at": "2020-05-22 22:59:55"
}
]
}

View File

@@ -0,0 +1,29 @@
{
"data": [
{
"id": 1,
"book_id": 1,
"name": "Content Creation",
"slug": "content-creation",
"description": "How to create documentation on whatever subject you need to write about.",
"priority": 3,
"created_at": "2019-05-05 21:49:56",
"updated_at": "2019-09-28 11:24:23",
"created_by": 1,
"updated_by": 1
},
{
"id": 2,
"book_id": 1,
"name": "Managing Content",
"slug": "managing-content",
"description": "How to keep things organised and orderly in the system for easier navigation and better user experience.",
"priority": 5,
"created_at": "2019-05-05 21:58:07",
"updated_at": "2019-10-17 15:05:34",
"created_by": 3,
"updated_by": 3
}
],
"total": 40
}

View File

@@ -0,0 +1,59 @@
{
"id": 1,
"book_id": 1,
"slug": "content-creation",
"name": "Content Creation",
"description": "How to create documentation on whatever subject you need to write about.",
"priority": 3,
"created_at": "2019-05-05 21:49:56",
"updated_at": "2019-09-28 11:24:23",
"created_by": {
"id": 1,
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin"
},
"tags": [
{
"name": "Category",
"value": "Guide",
"order": 0,
"created_at": "2020-05-22 22:51:51",
"updated_at": "2020-05-22 22:51:51"
}
],
"pages": [
{
"id": 1,
"book_id": 1,
"chapter_id": 1,
"name": "How to create page content",
"slug": "how-to-create-page-content",
"priority": 0,
"created_at": "2019-05-05 21:49:58",
"updated_at": "2019-08-26 14:32:59",
"created_by": 1,
"updated_by": 1,
"draft": 0,
"revision_count": 2,
"template": 0
},
{
"id": 7,
"book_id": 1,
"chapter_id": 1,
"name": "Good book structure",
"slug": "good-book-structure",
"priority": 1,
"created_at": "2019-05-05 22:01:55",
"updated_at": "2019-06-06 12:03:04",
"created_by": 3,
"updated_by": 3,
"draft": 0,
"revision_count": 1,
"template": 0
}
]
}

View File

@@ -0,0 +1,38 @@
{
"id": 75,
"book_id": 1,
"slug": "my-fantastic-updated-chapter",
"name": "My fantastic updated chapter",
"description": "This is an updated chapter that I've altered via the API",
"priority": 7,
"created_at": "2020-05-22 23:03:35",
"updated_at": "2020-05-22 23:07:20",
"created_by": 1,
"updated_by": 1,
"book": {
"id": 1,
"name": "BookStack User Guide",
"slug": "bookstack-user-guide",
"description": "This is a general guide on using BookStack on a day-to-day basis.",
"created_at": "2019-05-05 21:48:46",
"updated_at": "2019-12-11 20:57:31",
"created_by": 1,
"updated_by": 1
},
"tags": [
{
"name": "Category",
"value": "Kinda Good Content",
"order": 0,
"created_at": "2020-05-22 23:07:20",
"updated_at": "2020-05-22 23:07:20"
},
{
"name": "Rating",
"value": "Medium",
"order": 0,
"created_at": "2020-05-22 23:07:20",
"updated_at": "2020-05-22 23:07:20"
}
]
}

View File

@@ -5,15 +5,12 @@
"description": "This is my shelf with some books",
"created_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"updated_by": {
"id": 1,
"name": "Admin",
"image_id": 48
"name": "Admin"
},
"image_id": 501,
"created_at": "2020-04-10 13:24:09",
"updated_at": "2020-04-10 13:31:04",
"tags": [

99
dev/docs/components.md Normal file
View File

@@ -0,0 +1,99 @@
# JavaScript Components
This document details the format for JavaScript components in BookStack. This is a really simple class-based setup with a few helpers provided.
#### Defining a Component in JS
```js
class Dropdown {
setup() {
this.toggle = this.$refs.toggle;
this.menu = this.$refs.menu;
this.speed = parseInt(this.$opts.speed);
}
}
```
All usage of $refs, $manyRefs and $opts should be done at the top of the `setup` function so any requirements can be easily seen.
#### Using a Component in HTML
A component is used like so:
```html
<div component="dropdown"></div>
<!-- or, for multiple -->
<div components="dropdown image-picker"></div>
```
The names will be parsed and new component instance will be created if a matching name is found in the `components/index.js` componentMapping.
#### Element References
Within a component you'll often need to refer to other element instances. This can be done like so:
```html
<div component="dropdown">
<span refs="dropdown@toggle othercomponent@handle">View more</span>
</div>
```
You can then access the span element as `this.$refs.toggle` in your component.
#### Component Options
```html
<div component="dropdown"
option:dropdown:delay="500"
option:dropdown:show>
</div>
```
Will result with `this.$opts` being:
```json
{
"delay": "500",
"show": ""
}
```
#### Global Helpers
There are various global helper libraries which can be used in components:
```js
// HTTP service
window.$http.get(url, params);
window.$http.post(url, data);
window.$http.put(url, data);
window.$http.delete(url, data);
window.$http.patch(url, data);
// Global event system
// Emit a global event
window.$events.emit(eventName, eventData);
// Listen to a global event
window.$events.listen(eventName, callback);
// Show a success message
window.$events.success(message);
// Show an error message
window.$events.error(message);
// Show validation errors, if existing, as an error notification
window.$events.showValidationErrors(error);
// Translator
// Take the given plural text and count to decide on what plural option
// to use, Similar to laravel's trans_choice function but instead
// takes the direction directly instead of a translation key.
window.trans_plural(translationString, count, replacements);
// Component System
// Parse and initialise any components from the given root el down.
window.components.init(rootEl);
// Get the first active component of the given name
window.components.first(name);
```

5765
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,36 +1,33 @@
{
"private": true,
"scripts": {
"build": "webpack",
"production": "NODE_ENV=production webpack && rm -f ./public/dist/*styles.js",
"build-profile": "NODE_ENV=production webpack --profile --json > webpack-stats.json && rm -f ./public/dist/*styles.js",
"build:css:dev": "sass ./resources/sass:./public/dist",
"build:css:watch": "sass ./resources/sass:./public/dist --watch",
"build:css:production": "sass ./resources/sass:./public/dist -s compressed",
"build:js:dev": "esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main",
"build:js:watch": "chokidar \"./resources/**/*.js\" -c \"npm run build:js:dev\"",
"build:js:production": "NODE_ENV=production esbuild --bundle ./resources/js/index.js --outfile=public/dist/app.js --sourcemap --target=es2019 --main-fields=module,main --minify",
"build": "npm-run-all --parallel build:*:dev",
"production": "npm-run-all --parallel build:*:production",
"dev": "npm-run-all --parallel watch livereload",
"watch": "webpack --watch",
"watch": "npm-run-all --parallel build:*:watch",
"livereload": "livereload ./public/dist/",
"permissions": "chown -R $USER:$USER bootstrap/cache storage public/uploads"
},
"devDependencies": {
"css-loader": "^3.4.2",
"chokidar-cli": "^2.1.0",
"esbuild": "0.7.8",
"livereload": "^0.9.1",
"mini-css-extract-plugin": "^0.9.0",
"node-sass": "^4.13.1",
"npm-run-all": "^4.1.5",
"sass-loader": "^8.0.2",
"style-loader": "^1.1.3",
"webpack": "^4.42.1",
"webpack-cli": "^3.3.11"
"punycode": "^2.1.1",
"sass": "^1.26.11"
},
"dependencies": {
"clipboard": "^2.0.6",
"codemirror": "^5.52.2",
"dropzone": "^5.7.0",
"markdown-it": "^10.0.0",
"codemirror": "^5.58.1",
"dropzone": "^5.7.2",
"markdown-it": "^11.0.1",
"markdown-it-task-lists": "^2.1.1",
"sortablejs": "^1.10.2",
"vue": "^2.6.11",
"vuedraggable": "^2.23.2"
},
"browser": {
"vue": "vue/dist/vue.common.js"
"sortablejs": "^1.12.0"
}
}

View File

@@ -3,6 +3,7 @@
<description>The coding standard for BookStack.</description>
<file>app</file>
<exclude-pattern>*/migrations/*</exclude-pattern>
<exclude-pattern>*/tests/*</exclude-pattern>
<arg value="np"/>
<rule ref="PSR2"/>
</ruleset>

View File

@@ -51,5 +51,7 @@
<server name="DEBUGBAR_ENABLED" value="false"/>
<server name="SAML2_ENABLED" value="false"/>
<server name="API_REQUESTS_PER_MIN" value="180"/>
<server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
<server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
</php>
</phpunit>

View File

@@ -11,9 +11,10 @@
# Redirect Trailing Slashes If Not A Folder...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteRule ^(.*)/$ /$1 [L,R=301]
RewriteCond %{REQUEST_URI} (.+)/$
RewriteRule ^ %1 [L,R=301]
# Handle Front Controller...
# Send Requests To Front Controller...
RewriteCond %{REQUEST_FILENAME} !-d
RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^ index.php [L]

95
public/dist/app.js vendored

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -1,2 +1 @@
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#FFF}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}
:root{--color-primary: #206ea7;--color-primary-light: rgba(32,110,167,0.15);--color-page: #206ea7;--color-page-draft: #7e50b1;--color-chapter: #af4d0d;--color-book: #077b70;--color-bookshelf: #a94747}header{display:none}html,body{font-size:12px;background-color:#fff}.page-content{margin:0 auto}.print-hidden{display:none !important}.tri-layout-container{grid-template-columns:1fr;grid-template-areas:"b";margin-inline-start:0;margin-inline-end:0;display:block}.card{box-shadow:none}.content-wrap.card{padding-inline-start:0;padding-inline-end:0}/*# sourceMappingURL=print-styles.css.map */

File diff suppressed because one or more lines are too long

View File

@@ -51,7 +51,7 @@ All development on BookStack is currently done on the master branch. When it's t
* [Node.js](https://nodejs.org/en/) v10.0+
This project uses SASS for CSS development and this is built, along with the JavaScript, using webpack. The below npm commands can be used to install the dependencies & run the build tasks:
This project uses SASS for CSS development and this is built, along with the JavaScript, using a range of npm scripts. The below npm commands can be used to install the dependencies & run the build tasks:
``` bash
# Install NPM Dependencies
@@ -157,8 +157,7 @@ These are the great open-source projects used to help build BookStack:
* [Laravel](http://laravel.com/)
* [TinyMCE](https://www.tinymce.com/)
* [CodeMirror](https://codemirror.net)
* [Vue.js](http://vuejs.org/)
* [Sortable](https://github.com/SortableJS/Sortable) & [Vue.Draggable](https://github.com/SortableJS/Vue.Draggable)
* [Sortable](https://github.com/SortableJS/Sortable)
* [Google Material Icons](https://material.io/icons/)
* [Dropzone.js](http://www.dropzonejs.com/)
* [clipboard.js](https://clipboardjs.com/)
@@ -169,6 +168,6 @@ These are the great open-source projects used to help build BookStack:
* [Snappy (WKHTML2PDF)](https://github.com/barryvdh/laravel-snappy)
* [Laravel IDE helper](https://github.com/barryvdh/laravel-ide-helper)
* [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
* [Draw.io](https://github.com/jgraph/drawio)
* [diagrams.net](https://github.com/jgraph/drawio)
* [Laravel Stats](https://github.com/stefanzweifel/laravel-stats)
* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)

View File

@@ -0,0 +1,54 @@
import {onChildEvent} from "../services/dom";
import {uniqueId} from "../services/util";
/**
* AddRemoveRows
* Allows easy row add/remove controls onto a table.
* Needs a model row to use when adding a new row.
* @extends {Component}
*/
class AddRemoveRows {
setup() {
this.modelRow = this.$refs.model;
this.addButton = this.$refs.add;
this.removeSelector = this.$opts.removeSelector;
this.rowSelector = this.$opts.rowSelector;
this.setupListeners();
}
setupListeners() {
this.addButton.addEventListener('click', this.add.bind(this));
onChildEvent(this.$el, this.removeSelector, 'click', (e) => {
const row = e.target.closest(this.rowSelector);
row.remove();
});
}
// For external use
add() {
const clone = this.modelRow.cloneNode(true);
clone.classList.remove('hidden');
this.setClonedInputNames(clone);
this.modelRow.parentNode.insertBefore(clone, this.modelRow);
window.components.init(clone);
}
/**
* Update the HTML names of a clone to be unique if required.
* Names can use placeholder values. For exmaple, a model row
* may have name="tags[randrowid][name]".
* These are the available placeholder values:
* - randrowid - An random string ID, applied the same across the row.
* @param {HTMLElement} clone
*/
setClonedInputNames(clone) {
const rowId = uniqueId();
const randRowIdElems = clone.querySelectorAll(`[name*="randrowid"]`);
for (const elem of randRowIdElems) {
elem.name = elem.name.split('randrowid').join(rowId);
}
}
}
export default AddRemoveRows;

View File

@@ -0,0 +1,32 @@
/**
* AjaxDelete
* @extends {Component}
*/
import {onSelect} from "../services/dom";
class AjaxDeleteRow {
setup() {
this.row = this.$el;
this.url = this.$opts.url;
this.deleteButtons = this.$manyRefs.delete;
onSelect(this.deleteButtons, this.runDelete.bind(this));
}
runDelete() {
this.row.style.opacity = '0.7';
this.row.style.pointerEvents = 'none';
window.$http.delete(this.url).then(resp => {
if (typeof resp.data === 'object' && resp.data.message) {
window.$events.emit('success', resp.data.message);
}
this.row.remove();
}).catch(err => {
this.row.style.opacity = null;
this.row.style.pointerEvents = null;
});
}
}
export default AjaxDeleteRow;

View File

@@ -0,0 +1,82 @@
import {onEnterPress, onSelect} from "../services/dom";
/**
* Ajax Form
* Will handle button clicks or input enter press events and submit
* the data over ajax. Will always expect a partial HTML view to be returned.
* Fires an 'ajax-form-success' event when submitted successfully.
*
* Will handle a real form if that's what the component is added to
* otherwise will act as a fake form element.
*
* @extends {Component}
*/
class AjaxForm {
setup() {
this.container = this.$el;
this.responseContainer = this.container;
this.url = this.$opts.url;
this.method = this.$opts.method || 'post';
this.successMessage = this.$opts.successMessage;
this.submitButtons = this.$manyRefs.submit || [];
if (this.$opts.responseContainer) {
this.responseContainer = this.container.closest(this.$opts.responseContainer);
}
this.setupListeners();
}
setupListeners() {
if (this.container.tagName === 'FORM') {
this.container.addEventListener('submit', this.submitRealForm.bind(this));
return;
}
onEnterPress(this.container, event => {
this.submitFakeForm();
event.preventDefault();
});
this.submitButtons.forEach(button => onSelect(button, this.submitFakeForm.bind(this)));
}
submitFakeForm() {
const fd = new FormData();
const inputs = this.container.querySelectorAll(`[name]`);
for (const input of inputs) {
fd.append(input.getAttribute('name'), input.value);
}
this.submit(fd);
}
submitRealForm(event) {
event.preventDefault();
const fd = new FormData(this.container);
this.submit(fd);
}
async submit(formData) {
this.responseContainer.style.opacity = '0.7';
this.responseContainer.style.pointerEvents = 'none';
try {
const resp = await window.$http[this.method.toLowerCase()](this.url, formData);
this.$emit('success', {formData});
this.responseContainer.innerHTML = resp.data;
if (this.successMessage) {
window.$events.emit('success', this.successMessage);
}
} catch (err) {
this.responseContainer.innerHTML = err.data;
}
window.components.init(this.responseContainer);
this.responseContainer.style.opacity = null;
this.responseContainer.style.pointerEvents = null;
}
}
export default AjaxForm;

View File

@@ -0,0 +1,79 @@
/**
* Attachments
* @extends {Component}
*/
import {showLoading} from "../services/dom";
class Attachments {
setup() {
this.container = this.$el;
this.pageId = this.$opts.pageId;
this.editContainer = this.$refs.editContainer;
this.listContainer = this.$refs.listContainer;
this.mainTabs = this.$refs.mainTabs;
this.list = this.$refs.list;
this.setupListeners();
}
setupListeners() {
const reloadListBound = this.reloadList.bind(this);
this.container.addEventListener('dropzone-success', reloadListBound);
this.container.addEventListener('ajax-form-success', reloadListBound);
this.container.addEventListener('sortable-list-sort', event => {
this.updateOrder(event.detail.ids);
});
this.container.addEventListener('event-emit-select-edit', event => {
this.startEdit(event.detail.id);
});
this.container.addEventListener('event-emit-select-edit-back', event => {
this.stopEdit();
});
this.container.addEventListener('event-emit-select-insert', event => {
const insertContent = event.target.closest('[data-drag-content]').getAttribute('data-drag-content');
const contentTypes = JSON.parse(insertContent);
window.$events.emit('editor::insert', {
html: contentTypes['text/html'],
markdown: contentTypes['text/plain'],
});
});
}
reloadList() {
this.stopEdit();
this.mainTabs.components.tabs.show('items');
window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
this.list.innerHTML = resp.data;
window.components.init(this.list);
});
}
updateOrder(idOrder) {
window.$http.put(`/attachments/sort/page/${this.pageId}`, {order: idOrder}).then(resp => {
window.$events.emit('success', resp.data.message);
});
}
async startEdit(id) {
this.editContainer.classList.remove('hidden');
this.listContainer.classList.add('hidden');
showLoading(this.editContainer);
const resp = await window.$http.get(`/attachments/edit/${id}`);
this.editContainer.innerHTML = resp.data;
window.components.init(this.editContainer);
}
stopEdit() {
this.editContainer.classList.add('hidden');
this.listContainer.classList.remove('hidden');
}
}
export default Attachments;

View File

@@ -0,0 +1,152 @@
import {escapeHtml} from "../services/util";
import {onChildEvent} from "../services/dom";
const ajaxCache = {};
/**
* AutoSuggest
* @extends {Component}
*/
class AutoSuggest {
setup() {
this.parent = this.$el.parentElement;
this.container = this.$el;
this.type = this.$opts.type;
this.url = this.$opts.url;
this.input = this.$refs.input;
this.list = this.$refs.list;
this.lastPopulated = 0;
this.setupListeners();
}
setupListeners() {
this.input.addEventListener('input', this.requestSuggestions.bind(this));
this.input.addEventListener('focus', this.requestSuggestions.bind(this));
this.input.addEventListener('keydown', event => {
if (event.key === 'Tab') {
this.hideSuggestions();
}
});
this.input.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
this.container.addEventListener('keydown', this.containerKeyDown.bind(this));
onChildEvent(this.list, 'button', 'click', (event, el) => {
this.selectSuggestion(el.textContent);
});
onChildEvent(this.list, 'button', 'keydown', (event, el) => {
if (event.key === 'Enter') {
this.selectSuggestion(el.textContent);
}
});
}
selectSuggestion(value) {
this.input.value = value;
this.lastPopulated = Date.now();
this.input.focus();
this.input.dispatchEvent(new Event('input', {bubbles: true}));
this.input.dispatchEvent(new Event('change', {bubbles: true}));
this.hideSuggestions();
}
containerKeyDown(event) {
if (event.key === 'Enter') event.preventDefault();
if (this.list.classList.contains('hidden')) return;
// Down arrow
if (event.key === 'ArrowDown') {
this.moveFocus(true);
event.preventDefault();
}
// Up Arrow
else if (event.key === 'ArrowUp') {
this.moveFocus(false);
event.preventDefault();
}
// Escape key
else if (event.key === 'Escape') {
this.hideSuggestions();
event.preventDefault();
}
}
moveFocus(forward = true) {
const focusables = Array.from(this.container.querySelectorAll('input,button'));
const index = focusables.indexOf(document.activeElement);
const newFocus = focusables[index + (forward ? 1 : -1)];
if (newFocus) {
newFocus.focus()
}
}
async requestSuggestions() {
if (Date.now() - this.lastPopulated < 50) {
return;
}
const nameFilter = this.getNameFilterIfNeeded();
const search = this.input.value.slice(0, 3).toLowerCase();
const suggestions = await this.loadSuggestions(search, nameFilter);
let toShow = suggestions.slice(0, 6);
if (search.length > 0) {
toShow = suggestions.filter(val => {
return val.toLowerCase().includes(search);
}).slice(0, 6);
}
this.displaySuggestions(toShow);
}
getNameFilterIfNeeded() {
if (this.type !== 'value') return null;
return this.parent.querySelector('input').value;
}
/**
* @param {String} search
* @param {String|null} nameFilter
* @returns {Promise<Object|String|*>}
*/
async loadSuggestions(search, nameFilter = null) {
const params = {search, name: nameFilter};
const cacheKey = `${this.url}:${JSON.stringify(params)}`;
if (ajaxCache[cacheKey]) {
return ajaxCache[cacheKey];
}
const resp = await window.$http.get(this.url, params);
ajaxCache[cacheKey] = resp.data;
return resp.data;
}
/**
* @param {String[]} suggestions
*/
displaySuggestions(suggestions) {
if (suggestions.length === 0) {
return this.hideSuggestions();
}
this.list.innerHTML = suggestions.map(value => `<li><button type="button">${escapeHtml(value)}</button></li>`).join('');
this.list.style.display = 'block';
for (const button of this.list.querySelectorAll('button')) {
button.addEventListener('blur', this.hideSuggestionsIfFocusedLost.bind(this));
}
}
hideSuggestions() {
this.list.style.display = 'none';
}
hideSuggestionsIfFocusedLost(event) {
if (!this.container.contains(event.relatedTarget)) {
this.hideSuggestions();
}
}
}
export default AutoSuggest;

View File

@@ -1,4 +1,4 @@
import {Sortable, MultiDrag} from "sortablejs";
import Sortable from "sortablejs";
// Auto sort control
const sortOperations = {
@@ -43,7 +43,6 @@ class BookSort {
this.input = elem.querySelector('[book-sort-input]');
const initialSortBox = elem.querySelector('.sort-box');
Sortable.mount(new MultiDrag());
this.setupBookSortable(initialSortBox);
this.setupSortPresets();

View File

@@ -0,0 +1,117 @@
import Code from "../services/code";
import {onChildEvent, onEnterPress, onSelect} from "../services/dom";
/**
* Code Editor
* @extends {Component}
*/
class CodeEditor {
setup() {
this.container = this.$refs.container;
this.popup = this.$el;
this.editorInput = this.$refs.editor;
this.languageLinks = this.$manyRefs.languageLink;
this.saveButton = this.$refs.saveButton;
this.languageInput = this.$refs.languageInput;
this.historyDropDown = this.$refs.historyDropDown;
this.historyList = this.$refs.historyList;
this.callback = null;
this.editor = null;
this.history = {};
this.historyKey = 'code_history';
this.setupListeners();
}
setupListeners() {
this.container.addEventListener('keydown', event => {
if (event.ctrlKey && event.key === 'Enter') {
this.save();
}
});
onSelect(this.languageLinks, event => {
const language = event.target.dataset.lang;
this.languageInput.value = language;
this.updateEditorMode(language);
});
onEnterPress(this.languageInput, e => this.save());
onSelect(this.saveButton, e => this.save());
onChildEvent(this.historyList, 'button', 'click', (event, elem) => {
event.preventDefault();
const historyTime = elem.dataset.time;
if (this.editor) {
this.editor.setValue(this.history[historyTime]);
}
});
}
save() {
if (this.callback) {
this.callback(this.editor.getValue(), this.languageInput.value);
}
this.hide();
}
open(code, language, callback) {
this.languageInput.value = language;
this.callback = callback;
this.show();
this.updateEditorMode(language);
Code.setContent(this.editor, code);
}
show() {
if (!this.editor) {
this.editor = Code.popupEditor(this.editorInput, this.languageInput.value);
}
this.loadHistory();
this.popup.components.popup.show(() => {
Code.updateLayout(this.editor);
this.editor.focus();
}, () => {
this.addHistory()
});
}
hide() {
this.popup.components.popup.hide();
this.addHistory();
}
updateEditorMode(language) {
Code.setMode(this.editor, language, this.editor.getValue());
}
loadHistory() {
this.history = JSON.parse(window.sessionStorage.getItem(this.historyKey) || '{}');
const historyKeys = Object.keys(this.history).reverse();
this.historyDropDown.classList.toggle('hidden', historyKeys.length === 0);
this.historyList.innerHTML = historyKeys.map(key => {
const localTime = (new Date(parseInt(key))).toLocaleTimeString();
return `<li><button type="button" data-time="${key}">${localTime}</button></li>`;
}).join('');
}
addHistory() {
if (!this.editor) return;
const code = this.editor.getValue();
if (!code) return;
// Stop if we'd be storing the same as the last item
const lastHistoryKey = Object.keys(this.history).pop();
if (this.history[lastHistoryKey] === code) return;
this.history[String(Date.now())] = code;
const historyString = JSON.stringify(this.history);
window.sessionStorage.setItem(this.historyKey, historyString);
}
}
export default CodeEditor;

View File

@@ -37,7 +37,7 @@ class Collapsible {
}
openIfContainsError() {
const error = this.content.querySelector('.text-neg');
const error = this.content.querySelector('.text-neg.text-small');
if (error) {
this.open();
}

View File

@@ -3,14 +3,16 @@ import {onSelect} from "../services/dom";
/**
* Dropdown
* Provides some simple logic to create simple dropdown menus.
* @extends {Component}
*/
class DropDown {
constructor(elem) {
this.container = elem;
this.menu = elem.querySelector('.dropdown-menu, [dropdown-menu]');
this.moveMenu = elem.hasAttribute('dropdown-move-menu');
this.toggle = elem.querySelector('[dropdown-toggle]');
setup() {
this.container = this.$el;
this.menu = this.$refs.menu;
this.toggle = this.$refs.toggle;
this.moveMenu = this.$opts.moveMenu;
this.direction = (document.dir === 'rtl') ? 'right' : 'left';
this.body = document.body;
this.showing = false;

View File

@@ -0,0 +1,77 @@
import DropZoneLib from "dropzone";
import {fadeOut} from "../services/animations";
/**
* Dropzone
* @extends {Component}
*/
class Dropzone {
setup() {
this.container = this.$el;
this.url = this.$opts.url;
this.successMessage = this.$opts.successMessage;
this.removeMessage = this.$opts.removeMessage;
this.uploadLimitMessage = this.$opts.uploadLimitMessage;
this.timeoutMessage = this.$opts.timeoutMessage;
const _this = this;
this.dz = new DropZoneLib(this.container, {
addRemoveLinks: true,
dictRemoveFile: this.removeMessage,
timeout: Number(window.uploadTimeout) || 60000,
maxFilesize: Number(window.uploadLimit) || 256,
url: this.url,
withCredentials: true,
init() {
this.dz = this;
this.dz.on('sending', _this.onSending.bind(_this));
this.dz.on('success', _this.onSuccess.bind(_this));
this.dz.on('error', _this.onError.bind(_this));
}
});
}
onSending(file, xhr, data) {
const token = window.document.querySelector('meta[name=token]').getAttribute('content');
data.append('_token', token);
xhr.ontimeout = (e) => {
this.dz.emit('complete', file);
this.dz.emit('error', file, this.timeoutMessage);
}
}
onSuccess(file, data) {
this.$emit('success', {file, data});
if (this.successMessage) {
window.$events.emit('success', this.successMessage);
}
fadeOut(file.previewElement, 800, () => {
this.dz.removeFile(file);
});
}
onError(file, errorMessage, xhr) {
this.$emit('error', {file, errorMessage, xhr});
const setMessage = (message) => {
const messsageEl = file.previewElement.querySelector('[data-dz-errormessage]');
messsageEl.textContent = message;
}
if (xhr && xhr.status === 413) {
setMessage(this.uploadLimitMessage);
} else if (errorMessage.file) {
setMessage(errorMessage.file);
}
}
removeAll() {
this.dz.removeAllFiles(true);
}
}
export default Dropzone;

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