diff --git a/.env.example.complete b/.env.example.complete index 8851bd268..911d924df 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -75,6 +75,12 @@ CACHE_PREFIX=bookstack # For multiple servers separate with a comma MEMCACHED_SERVERS=127.0.0.1:11211:100 +# Redis server configuration +# This follows the following format: HOST:PORT:DATABASE +# or, if using a password: HOST:PORT:DATABASE:PASSWORD +# For multiple servers separate with a comma. These will be clustered. +REDIS_SERVERS=127.0.0.1:6379:0 + # Queue driver to use # Queue not really currently used but may be configurable in the future. # Would advise not to change this for now. @@ -171,6 +177,7 @@ LDAP_USER_FILTER=false LDAP_VERSION=false LDAP_TLS_INSECURE=false LDAP_EMAIL_ATTRIBUTE=mail +LDAP_DISPLAY_NAME_ATTRIBUTE=cn LDAP_FOLLOW_REFERRALS=true # LDAP group sync configuration diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 654ea2f99..9ffbbfbb7 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -80,20 +80,40 @@ class LdapService public function getUserDetails($userName) { $emailAttr = $this->config['email_attribute']; - $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr]); + $displayNameAttr = $this->config['display_name_attribute']; + + $user = $this->getUserWithAttributes($userName, ['cn', 'uid', 'dn', $emailAttr, $displayNameAttr]); if ($user === null) { return null; } + $userCn = $this->getUserResponseProperty($user, 'cn', null); return [ - 'uid' => (isset($user['uid'])) ? $user['uid'][0] : $user['dn'], - 'name' => $user['cn'][0], + 'uid' => $this->getUserResponseProperty($user, 'uid', $user['dn']), + 'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn), 'dn' => $user['dn'], - 'email' => (isset($user[$emailAttr])) ? (is_array($user[$emailAttr]) ? $user[$emailAttr][0] : $user[$emailAttr]) : null + 'email' => $this->getUserResponseProperty($user, $emailAttr, null), ]; } + /** + * Get a property from an LDAP user response fetch. + * Handles properties potentially being part of an array. + * @param array $userDetails + * @param string $propertyKey + * @param $defaultValue + * @return mixed + */ + protected function getUserResponseProperty(array $userDetails, string $propertyKey, $defaultValue) + { + if (isset($userDetails[$propertyKey])) { + return (is_array($userDetails[$propertyKey]) ? $userDetails[$propertyKey][0] : $userDetails[$propertyKey]); + } + + return $defaultValue; + } + /** * @param Authenticatable $user * @param string $username diff --git a/app/Auth/Permissions/PermissionService.php b/app/Auth/Permissions/PermissionService.php index af2a5e1fd..8fc70e916 100644 --- a/app/Auth/Permissions/PermissionService.php +++ b/app/Auth/Permissions/PermissionService.php @@ -556,6 +556,39 @@ class PermissionService return $q; } + /** + * Checks if a user has the given permission for any items in the system. + * Can be passed an entity instance to filter on a specific type. + * @param string $permission + * @param string $entityClass + * @return bool + */ + public function checkUserHasPermissionOnAnything(string $permission, string $entityClass = null) + { + $userRoleIds = $this->currentUser()->roles()->select('id')->pluck('id')->toArray(); + $userId = $this->currentUser()->id; + + $permissionQuery = $this->db->table('joint_permissions') + ->where('action', '=', $permission) + ->whereIn('role_id', $userRoleIds) + ->where(function ($query) use ($userId) { + $query->where('has_permission', '=', 1) + ->orWhere(function ($query2) use ($userId) { + $query2->where('has_permission_own', '=', 1) + ->where('created_by', '=', $userId); + }); + }) ; + + if (!is_null($entityClass)) { + $entityInstance = app()->make($entityClass); + $permissionQuery = $permissionQuery->where('entity_type', '=', $entityInstance->getMorphClass()); + } + + $hasPermission = $permissionQuery->count() > 0; + $this->clean(); + return $hasPermission; + } + /** * Check if an entity has restrictions set on itself or its * parent tree. diff --git a/app/Http/Controllers/PageController.php b/app/Http/Controllers/PageController.php index 7ebf26209..16a7d5a5e 100644 --- a/app/Http/Controllers/PageController.php +++ b/app/Http/Controllers/PageController.php @@ -616,7 +616,7 @@ class PageController extends Controller public function showCopy($bookSlug, $pageSlug) { $page = $this->pageRepo->getPageBySlug($pageSlug, $bookSlug); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission('page-view', $page); session()->flashInput(['name' => $page->name]); return view('pages.copy', [ 'book' => $page->book, @@ -635,7 +635,7 @@ class PageController extends Controller public function copy($bookSlug, $pageSlug, Request $request) { $page = $this->pageRepo->getPageBySlug($pageSlug, $bookSlug); - $this->checkOwnablePermission('page-update', $page); + $this->checkOwnablePermission('page-view', $page); $entitySelection = $request->get('entity_selection', null); if ($entitySelection === null || $entitySelection === '') { diff --git a/app/Http/Middleware/Localization.php b/app/Http/Middleware/Localization.php index e65b417d5..ff5526cc7 100644 --- a/app/Http/Middleware/Localization.php +++ b/app/Http/Middleware/Localization.php @@ -51,6 +51,7 @@ class Localization public function handle($request, Closure $next) { $defaultLang = config('app.locale'); + config()->set('app.default_locale', $defaultLang); if (user()->isDefault() && config('app.auto_detect_locale')) { $locale = $this->autoDetectLocale($request, $defaultLang); @@ -63,8 +64,6 @@ class Localization config()->set('app.rtl', true); } - - app()->setLocale($locale); Carbon::setLocale($locale); $this->setSystemDateLocale($locale); diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index 42a381060..663a6ae32 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -41,6 +41,7 @@ class SettingService if ($default === false) { $default = config('setting-defaults.' . $key, false); } + if (isset($this->localCache[$key])) { return $this->localCache[$key]; } diff --git a/app/helpers.php b/app/helpers.php index e1395d816..3f7b5e1b1 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -1,5 +1,7 @@ can($permission); } // Check permission on ownable item - $permissionService = app(\BookStack\Auth\Permissions\PermissionService::class); + $permissionService = app(PermissionService::class); return $permissionService->checkOwnableUserAccess($ownable, $permission); } +/** + * Check if the current user has the given permission + * on any item in the system. + * @param string $permission + * @param string|null $entityClass + * @return bool + */ +function userCanOnAny(string $permission, string $entityClass = null) +{ + $permissionService = app(PermissionService::class); + return $permissionService->checkUserHasPermissionOnAnything($permission, $entityClass); +} + /** * Helper to access system settings. * @param $key diff --git a/config/database.php b/config/database.php index 6ca902944..93a44854f 100644 --- a/config/database.php +++ b/config/database.php @@ -8,23 +8,39 @@ * Do not edit this file unless you're happy to maintain any changes yourself. */ -// REDIS - Split out configuration into an array +// REDIS +// Split out configuration into an array if (env('REDIS_SERVERS', false)) { - $redisServerKeys = ['host', 'port', 'database']; + + $redisDefaults = ['host' => '127.0.0.1', 'port' => '6379', 'database' => '0', 'password' => null]; $redisServers = explode(',', trim(env('REDIS_SERVERS', '127.0.0.1:6379:0'), ',')); - $redisConfig = [ - 'cluster' => env('REDIS_CLUSTER', false) - ]; + $redisConfig = []; + $cluster = count($redisServers) > 1; + + if ($cluster) { + $redisConfig['clusters'] = ['default' => []]; + } + foreach ($redisServers as $index => $redisServer) { - $redisServerName = ($index === 0) ? 'default' : 'redis-server-' . $index; $redisServerDetails = explode(':', $redisServer); - if (count($redisServerDetails) < 2) $redisServerDetails[] = '6379'; - if (count($redisServerDetails) < 3) $redisServerDetails[] = '0'; - $redisConfig[$redisServerName] = array_combine($redisServerKeys, $redisServerDetails); + + $serverConfig = []; + $configIndex = 0; + foreach ($redisDefaults as $configKey => $configDefault) { + $serverConfig[$configKey] = ($redisServerDetails[$configIndex] ?? $configDefault); + $configIndex++; + } + + if ($cluster) { + $redisConfig['clusters']['default'][] = $serverConfig; + } else { + $redisConfig['default'] = $serverConfig; + } } } -// MYSQL - Split out port from host if set +// MYSQL +// Split out port from host if set $mysql_host = env('DB_HOST', 'localhost'); $mysql_host_exploded = explode(':', $mysql_host); $mysql_port = env('DB_PORT', 3306); diff --git a/config/services.php b/config/services.php index f713f9d38..97cb71ddc 100644 --- a/config/services.php +++ b/config/services.php @@ -141,6 +141,7 @@ return [ 'user_filter' => env('LDAP_USER_FILTER', '(&(uid=${user}))'), 'version' => env('LDAP_VERSION', false), 'email_attribute' => env('LDAP_EMAIL_ATTRIBUTE', 'mail'), + 'display_name_attribute' => env('LDAP_DISPLAY_NAME_ATTRIBUTE', 'cn'), 'follow_referrals' => env('LDAP_FOLLOW_REFERRALS', false), 'user_to_groups' => env('LDAP_USER_TO_GROUPS',false), 'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'), diff --git a/readme.md b/readme.md index 337870c59..037fbedb5 100644 --- a/readme.md +++ b/readme.md @@ -20,6 +20,29 @@ BookStack is not designed as an extensible platform to be used for purposes that In regards to development philosophy, BookStack has a relaxed, open & positive approach. At the end of the day this is free software developed and maintained by people donating their own free time. +## Road Map + +Below is a high-level road map view for BookStack to provide a sense of direction of where the project is going. This can change at any point and does not reflect many features and improvements that will also be included as part of the journey along this road map. For more granular detail of what will be included in upcoming releases you can review the project milestones as defined in the "Release Process" section below. + +- **Design Revamp** *[(In Progress)](https://github.com/BookStackApp/BookStack/pull/1153)* + - *A more organised modern design to clean things up, make BookStack more efficient to use and increase mobile usability.* +- **Platform REST API** + - *A REST API covering, at minimum, control of core content models (Books, Chapters, Pages) for automation and platform extension.* +- **Editor Alignment & Review** + - *Review the page editors with goal of achieving increased interoperability & feature parity while also considering collaborative editing potential.* +- **Permission System Review** + - *Improvement in how permissions are applied and a review of the efficiency of the permission & roles system.* +- **Installation & Deployment Process Revamp** + - *Creation of a streamlined & secure process for users to deploy & update BookStack with reduced development requirements (No git or composer requirement).* + +## Release Versioning & Process + +BookStack releases are each assigned a version number, such as "v0.25.2", in the format `v..`. A change only in the `patch` number indicates a fairly minor release that mainly contains fixes and therefore is very unlikely to cause breakages upon update. A change in the `feature` number indicates a release which will generally bring new features in addition to fixes and enhancements. These releases have a small chance of introducing breaking changes upon update so it's worth checking for any notes in the [update guide](https://www.bookstackapp.com/docs/admin/updates/). A change in the `phase` indicates a much large change in BookStack that will likely incur breakages requiring manual intervention. + +Each BookStack release will have a [milestone](https://github.com/BookStackApp/BookStack/milestones) created with issues & pull requests assigned to it to define what will be in that release. Milestones are built up then worked through until complete at which point, after some testing and documentation updates, the release will be deployed. + +For feature releases, and some patch releases, the release will be accompanied by a post on the [BookStack blog](https://www.bookstackapp.com/blog/) which will provide additional detail on features, changes & updates otherwise the [GitHub release page](https://github.com/BookStackApp/BookStack/releases) will show a list of changes. You can sign up to be alerted to new BookStack blogs posts (once per week maximum) [at this link](http://eepurl.com/cmmq5j). + ## Development & Testing All development on BookStack is currently done on the master branch. When it's time for a release the master branch is merged into release with built & minified CSS & JS then tagged at its version. Here are the current development requirements: diff --git a/resources/assets/js/components/markdown-editor.js b/resources/assets/js/components/markdown-editor.js index 9228cfe2c..b8e2bc040 100644 --- a/resources/assets/js/components/markdown-editor.js +++ b/resources/assets/js/components/markdown-editor.js @@ -8,7 +8,11 @@ class MarkdownEditor { constructor(elem) { this.elem = elem; - this.textDirection = document.getElementById('page-editor').getAttribute('text-direction'); + + const pageEditor = document.getElementById('page-editor'); + this.pageId = pageEditor.getAttribute('page-id'); + this.textDirection = pageEditor.getAttribute('text-direction'); + this.markdown = new MarkdownIt({html: true}); this.markdown.use(mdTasksLists, {label: true}); @@ -98,7 +102,9 @@ class MarkdownEditor { } codeMirrorSetup() { - let cm = this.cm; + const cm = this.cm; + const context = this; + // Text direction // cm.setOption('direction', this.textDirection); cm.setOption('direction', 'ltr'); // Will force to remain as ltr for now due to issues when HTML is in editor. @@ -266,17 +272,18 @@ class MarkdownEditor { } // Insert image into markdown - let id = "image-" + Math.random().toString(16).slice(2); - let placeholderImage = window.baseUrl(`/loading.gif#upload${id}`); - let selectedText = cm.getSelection(); - let placeHolderText = `![${selectedText}](${placeholderImage})`; - let cursor = cm.getCursor(); + const id = "image-" + Math.random().toString(16).slice(2); + const placeholderImage = window.baseUrl(`/loading.gif#upload${id}`); + const selectedText = cm.getSelection(); + const placeHolderText = `![${selectedText}](${placeholderImage})`; + const cursor = cm.getCursor(); cm.replaceSelection(placeHolderText); cm.setCursor({line: cursor.line, ch: cursor.ch + selectedText.length + 3}); - let remoteFilename = "image-" + Date.now() + "." + ext; - let formData = new FormData(); + const remoteFilename = "image-" + Date.now() + "." + ext; + const formData = new FormData(); formData.append('file', file, remoteFilename); + formData.append('uploaded_to', context.pageId); window.$http.post('/images/gallery/upload', formData).then(resp => { const newContent = `[![${selectedText}](${resp.data.thumbs.display})](${resp.data.url})`; @@ -302,7 +309,7 @@ class MarkdownEditor { } actionInsertImage() { - let cursorPos = this.cm.getCursor('from'); + const cursorPos = this.cm.getCursor('from'); window.ImageManager.show(image => { let selectedText = this.cm.getSelection(); let newText = "[![" + (selectedText || image.name) + "](" + image.thumbs.display + ")](" + image.url + ")"; @@ -313,7 +320,7 @@ class MarkdownEditor { } actionShowImageManager() { - let cursorPos = this.cm.getCursor('from'); + const cursorPos = this.cm.getCursor('from'); window.ImageManager.show(image => { this.insertDrawing(image, cursorPos); }, 'drawio'); @@ -321,7 +328,7 @@ class MarkdownEditor { // Show the popup link selector and insert a link when finished actionShowLinkSelector() { - let cursorPos = this.cm.getCursor('from'); + const cursorPos = this.cm.getCursor('from'); window.EntitySelectorPopup.show(entity => { let selectedText = this.cm.getSelection() || entity.name; let newText = `[${selectedText}](${entity.link})`; @@ -357,7 +364,7 @@ class MarkdownEditor { } insertDrawing(image, originalCursor) { - let newText = `
`; + const newText = `
`; this.cm.focus(); this.cm.replaceSelection(newText); this.cm.setCursor(originalCursor.line, originalCursor.ch + newText.length); @@ -365,9 +372,13 @@ class MarkdownEditor { // Show draw.io if enabled and handle save. actionEditDrawing(imgContainer) { - if (document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true') return; - let cursorPos = this.cm.getCursor('from'); - let drawingId = imgContainer.getAttribute('drawio-diagram'); + const drawingDisabled = document.querySelector('[drawio-enabled]').getAttribute('drawio-enabled') !== 'true'; + if (drawingDisabled) { + return; + } + + const cursorPos = this.cm.getCursor('from'); + const drawingId = imgContainer.getAttribute('drawio-diagram'); DrawIO.show(() => { return window.$http.get(window.baseUrl(`/images/base64/${drawingId}`)).then(resp => { diff --git a/resources/assets/js/components/wysiwyg-editor.js b/resources/assets/js/components/wysiwyg-editor.js index c70d82719..2767d35c0 100644 --- a/resources/assets/js/components/wysiwyg-editor.js +++ b/resources/assets/js/components/wysiwyg-editor.js @@ -4,22 +4,24 @@ import DrawIO from "../services/drawio"; /** * Handle pasting images from clipboard. * @param {ClipboardEvent} event + * @param {WysiwygEditor} wysiwygComponent * @param editor */ -function editorPaste(event, editor) { +function editorPaste(event, editor, wysiwygComponent) { if (!event.clipboardData || !event.clipboardData.items) return; - let items = event.clipboardData.items; - for (let i = 0; i < items.length; i++) { - if (items[i].type.indexOf("image") === -1) continue; + for (let clipboardItem of event.clipboardData.items) { + if (clipboardItem.type.indexOf("image") === -1) continue; event.preventDefault(); - let id = "image-" + Math.random().toString(16).slice(2); - let loadingImage = window.baseUrl('/loading.gif'); - let file = items[i].getAsFile(); + const id = "image-" + Math.random().toString(16).slice(2); + const loadingImage = window.baseUrl('/loading.gif'); + const file = clipboardItem.getAsFile(); + setTimeout(() => { editor.insertContent(`

`); - uploadImageFile(file).then(resp => { + + uploadImageFile(file, wysiwygComponent).then(resp => { editor.dom.setAttrib(id, 'src', resp.thumbs.display); }).catch(err => { editor.dom.remove(id); @@ -33,9 +35,12 @@ function editorPaste(event, editor) { /** * Upload an image file to the server * @param {File} file + * @param {WysiwygEditor} wysiwygComponent */ -function uploadImageFile(file) { - if (file === null || file.type.indexOf('image') !== 0) return Promise.reject(`Not an image file`); +async function uploadImageFile(file, wysiwygComponent) { + if (file === null || file.type.indexOf('image') !== 0) { + throw new Error(`Not an image file`); + } let ext = 'png'; if (file.name) { @@ -43,11 +48,13 @@ function uploadImageFile(file) { if (fileNameMatches.length > 1) ext = fileNameMatches[1]; } - let remoteFilename = "image-" + Date.now() + "." + ext; - let formData = new FormData(); + const remoteFilename = "image-" + Date.now() + "." + ext; + const formData = new FormData(); formData.append('file', file, remoteFilename); + formData.append('uploaded_to', wysiwygComponent.pageId); - return window.$http.post(window.baseUrl('/images/gallery/upload'), formData).then(resp => (resp.data)); + const resp = await window.$http.post(window.baseUrl('/images/gallery/upload'), formData); + return resp.data; } function registerEditorShortcuts(editor) { @@ -370,7 +377,10 @@ class WysiwygEditor { constructor(elem) { this.elem = elem; - this.textDirection = document.getElementById('page-editor').getAttribute('text-direction'); + + const pageEditor = document.getElementById('page-editor'); + this.pageId = pageEditor.getAttribute('page-id'); + this.textDirection = pageEditor.getAttribute('text-direction'); this.plugins = "image table textcolor paste link autolink fullscreen imagetools code customhr autosave lists codeeditor media"; this.loadPlugins(); @@ -397,6 +407,9 @@ class WysiwygEditor { } getTinyMceConfig() { + + const context = this; + return { selector: '#html-editor', content_css: [ @@ -586,7 +599,7 @@ class WysiwygEditor { }); // Paste image-uploads - editor.on('paste', event => editorPaste(event, editor)); + editor.on('paste', event => editorPaste(event, editor, context)); } }; } diff --git a/resources/assets/js/services/code.js b/resources/assets/js/services/code.js index cfeabd3be..bd749033d 100644 --- a/resources/assets/js/services/code.js +++ b/resources/assets/js/services/code.js @@ -8,6 +8,7 @@ import 'codemirror/mode/diff/diff'; import 'codemirror/mode/go/go'; import 'codemirror/mode/htmlmixed/htmlmixed'; import 'codemirror/mode/javascript/javascript'; +import 'codemirror/mode/lua/lua'; import 'codemirror/mode/markdown/markdown'; import 'codemirror/mode/nginx/nginx'; import 'codemirror/mode/php/php'; @@ -38,12 +39,13 @@ const modeMap = { javascript: 'javascript', json: {name: 'javascript', json: true}, js: 'javascript', - php: 'php', + lua: 'lua', md: 'markdown', mdown: 'markdown', markdown: 'markdown', nginx: 'nginx', powershell: 'powershell', + php: 'php', py: 'python', python: 'python', ruby: 'ruby', diff --git a/resources/assets/js/vues/components/dropzone.js b/resources/assets/js/vues/components/dropzone.js index 31a84a267..9d3d22b4d 100644 --- a/resources/assets/js/vues/components/dropzone.js +++ b/resources/assets/js/vues/components/dropzone.js @@ -16,6 +16,7 @@ function mounted() { addRemoveLinks: true, dictRemoveFile: trans('components.image_upload_remove'), timeout: Number(window.uploadTimeout) || 60000, + maxFilesize: Number(window.uploadLimit) || 256, url: function() { return _this.uploadUrl; }, diff --git a/resources/assets/sass/_components.scss b/resources/assets/sass/_components.scss index d00d1fe9a..1f34166c6 100644 --- a/resources/assets/sass/_components.scss +++ b/resources/assets/sass/_components.scss @@ -210,7 +210,6 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { .image-manager-sidebar { width: 300px; - margin-left: 1px; overflow-y: auto; overflow-x: hidden; border-left: 1px solid #DDD; @@ -524,8 +523,8 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { font-size: 12px; line-height: 1.2; top: 88px; - left: -26px; - width: 148px; + left: -12px; + width: 160px; background: $negative; padding: $-xs; color: white; @@ -535,7 +534,7 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group { content: ''; position: absolute; top: -6px; - left: 64px; + left: 44px; width: 0; height: 0; border-left: 6px solid transparent; diff --git a/resources/assets/sass/_pages.scss b/resources/assets/sass/_pages.scss index 3ab5a6a69..1b1193079 100755 --- a/resources/assets/sass/_pages.scss +++ b/resources/assets/sass/_pages.scss @@ -48,15 +48,22 @@ margin: $-xs $-s $-xs 0; } .align-right { - float: right !important; + text-align: right !important; } img.align-right, table.align-right { - text-align: right; + float: right !important; margin: $-xs 0 $-xs $-s; } .align-center { text-align: center; } + img.align-center { + display: block; + } + img.align-center, table.align-center { + margin-left: auto; + margin-right: auto; + } img { max-width: 100%; height:auto; diff --git a/resources/lang/de/entities.php b/resources/lang/de/entities.php index 7c27be17b..07a92e2c7 100644 --- a/resources/lang/de/entities.php +++ b/resources/lang/de/entities.php @@ -60,6 +60,39 @@ return [ 'search_created_after' => 'Erstellt nach', 'search_set_date' => 'Datum auswählen', 'search_update' => 'Suche aktualisieren', + + /* + * Shelves + */ + 'shelf' => 'Regal', + 'shelves' => 'Regale', + 'shelves_long' => 'Bücherregal', + 'shelves_empty' => 'Es wurden noch keine Regale angelegt', + 'shelves_create' => 'Erzeuge ein Regal', + 'shelves_popular' => 'Beliebte Regale', + 'shelves_new' => 'Kürzlich erstellte Regale', + 'shelves_popular_empty' => 'Die beliebtesten Regale werden hier angezeigt.', + 'shelves_new_empty' => 'Die neusten Regale werden hier angezeigt.', + 'shelves_save' => 'Regal speichern', + 'shelves_books' => 'Bücher in diesem Regal', + 'shelves_add_books' => 'Buch zu diesem Regal hinzufügen', + 'shelves_drag_books' => 'Bücher hier hin ziehen um sie dem Regal hinzuzufügen', + 'shelves_empty_contents' => 'Diesem Regal sind keine Bücher zugewiesen', + 'shelves_edit_and_assign' => 'Regal bearbeiten um Bücher hinzuzufügen', + 'shelves_edit_named' => 'Bücherregal :name bearbeiten', + 'shelves_edit' => 'Bücherregal bearbeiten', + 'shelves_delete' => 'Bücherregal löschen', + 'shelves_delete_named' => 'Bücherregal :name löschen', + 'shelves_delete_explain' => "Sie sind im Begriff das Bücherregal mit dem Namen ':name' zu löschen. Enthaltene Bücher werden nicht gelöscht.", + 'shelves_delete_confirmation' => 'Sind Sie sicher, dass Sie dieses Bücherregal löschen wollen?', + 'shelves_permissions' => 'Regal-Berechtigungen', + 'shelves_permissions_updated' => 'Regal-Berechtigungen aktualisiert', + 'shelves_permissions_active' => 'Regal-Berechtigungen aktiv', + 'shelves_copy_permissions_to_books' => 'Kopiere die Berechtigungen zum Buch', + 'shelves_copy_permissions' => 'Berechtigungen kopieren', + 'shelves_copy_permissions_explain' => 'Hiermit werden die Berechtigungen des aktuellen Regals auf alle enthaltenen Bücher übertragen. Überprüfen Sie vor der Aktivierung, ob alle Berechtigungsänderungen am aktuellen Regal gespeichert wurden.', + 'shelves_copy_permission_success' => 'Regal-Berechtigungen wurden zu :count Büchern kopiert', + /** * Books */ diff --git a/resources/lang/de_informal/entities.php b/resources/lang/de_informal/entities.php index 21fdbb13d..1decdd7b7 100644 --- a/resources/lang/de_informal/entities.php +++ b/resources/lang/de_informal/entities.php @@ -9,6 +9,13 @@ return [ 'no_pages_recently_created' => 'Du hast bisher keine Seiten angelegt.', 'no_pages_recently_updated' => 'Du hast bisher keine Seiten aktualisiert.', + /** + * Shelves + */ + 'shelves_delete_explain' => "Du bist im Begriff das Bücherregal mit dem Namen ':name' zu löschen. Enthaltene Bücher werden nicht gelöscht.", + 'shelves_delete_confirmation' => 'Bist du sicher, dass du dieses Bücherregal löschen willst?', + 'shelves_copy_permissions_explain' => 'Hiermit werden die Berechtigungen des aktuellen Regals auf alle enthaltenen Bücher übertragen. Überprüfe vor der Aktivierung, ob alle Berechtigungsänderungen am aktuellen Regal gespeichert wurden.', + /** * Books */ diff --git a/resources/lang/en/validation.php b/resources/lang/en/validation.php index 0b6a1c170..e05cfca9d 100644 --- a/resources/lang/en/validation.php +++ b/resources/lang/en/validation.php @@ -69,6 +69,7 @@ return [ 'timezone' => 'The :attribute must be a valid zone.', 'unique' => 'The :attribute has already been taken.', 'url' => 'The :attribute format is invalid.', + 'is_image' => 'The :attribute must be a valid image.', // Custom validation lines 'custom' => [ diff --git a/resources/lang/nl/auth.php b/resources/lang/nl/auth.php index d8813f07b..31bd330cc 100644 --- a/resources/lang/nl/auth.php +++ b/resources/lang/nl/auth.php @@ -27,7 +27,7 @@ return [ 'email' => 'Email', 'password' => 'Wachtwoord', 'password_confirm' => 'Wachtwoord Bevestigen', - 'password_hint' => 'Minimaal 5 tekens', + 'password_hint' => 'Minimaal 6 tekens', 'forgot_password' => 'Wachtwoord vergeten?', 'remember_me' => 'Mij onthouden', 'ldap_email_hint' => 'Geef een email op waarmee je dit account wilt gebruiken.', @@ -73,4 +73,4 @@ return [ 'email_not_confirmed_click_link' => 'Klik op de link in de e-mail die vlak na je registratie is verstuurd.', 'email_not_confirmed_resend' => 'Als je deze e-mail niet kunt vinden kun je deze met onderstaande formulier opnieuw verzenden.', 'email_not_confirmed_resend_button' => 'Bevestigingsmail Opnieuw Verzenden', -]; \ No newline at end of file +]; diff --git a/resources/views/common/header.blade.php b/resources/views/common/header.blade.php index f1661a146..89aa1078d 100644 --- a/resources/views/common/header.blade.php +++ b/resources/views/common/header.blade.php @@ -27,7 +27,7 @@ - \ No newline at end of file + diff --git a/resources/views/pages/show.blade.php b/resources/views/pages/show.blade.php index 8444155a6..6858661c4 100644 --- a/resources/views/pages/show.blade.php +++ b/resources/views/pages/show.blade.php @@ -140,10 +140,14 @@ @icon('edit') {{ trans('common.edit') }} + @endif + @if(userCanOnAny('page-create')) @icon('copy') {{ trans('common.copy') }} + @endif + @if(userCan('page-update', $page)) @if(userCan('page-delete', $page)) @icon('folder') diff --git a/resources/views/users/edit.blade.php b/resources/views/users/edit.blade.php index 42fc2beb1..e6e66665f 100644 --- a/resources/views/users/edit.blade.php +++ b/resources/views/users/edit.blade.php @@ -45,7 +45,7 @@
@@ -88,4 +88,4 @@ @include('components.image-manager', ['imageType' => 'user']) -@stop \ No newline at end of file +@stop diff --git a/tests/Auth/LdapTest.php b/tests/Auth/LdapTest.php index 16ba11358..5ccb1415e 100644 --- a/tests/Auth/LdapTest.php +++ b/tests/Auth/LdapTest.php @@ -23,6 +23,7 @@ class LdapTest extends BrowserKitTest 'auth.method' => 'ldap', 'services.ldap.base_dn' => 'dc=ldap,dc=local', 'services.ldap.email_attribute' => 'mail', + 'services.ldap.display_name_attribute' => 'cn', 'services.ldap.user_to_groups' => false, 'auth.providers.users.driver' => 'ldap', ]); @@ -45,6 +46,15 @@ class LdapTest extends BrowserKitTest }); } + protected function mockUserLogin() + { + return $this->visit('/login') + ->see('Username') + ->type($this->mockUser->name, '#username') + ->type($this->mockUser->password, '#password') + ->press('Log In'); + } + public function test_login() { $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); @@ -60,11 +70,7 @@ class LdapTest extends BrowserKitTest $this->mockLdap->shouldReceive('bind')->times(6)->andReturn(true); $this->mockEscapes(4); - $this->visit('/login') - ->see('Username') - ->type($this->mockUser->name, '#username') - ->type($this->mockUser->password, '#password') - ->press('Log In') + $this->mockUserLogin() ->seePageIs('/login')->see('Please enter an email to use for this account.'); $this->type($this->mockUser->email, '#email') @@ -90,11 +96,7 @@ class LdapTest extends BrowserKitTest $this->mockLdap->shouldReceive('bind')->times(3)->andReturn(true); $this->mockEscapes(2); - $this->visit('/login') - ->see('Username') - ->type($this->mockUser->name, '#username') - ->type($this->mockUser->password, '#password') - ->press('Log In') + $this->mockUserLogin() ->seePageIs('/') ->see($this->mockUser->name) ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $ldapDn]); @@ -115,11 +117,7 @@ class LdapTest extends BrowserKitTest $this->mockLdap->shouldReceive('bind')->times(3)->andReturn(true, true, false); $this->mockEscapes(2); - $this->visit('/login') - ->see('Username') - ->type($this->mockUser->name, '#username') - ->type($this->mockUser->password, '#password') - ->press('Log In') + $this->mockUserLogin() ->seePageIs('/login')->see('These credentials do not match our records.') ->dontSeeInDatabase('users', ['external_auth_id' => $this->mockUser->name]); } @@ -196,12 +194,7 @@ class LdapTest extends BrowserKitTest $this->mockEscapes(5); $this->mockExplodes(6); - $this->visit('/login') - ->see('Username') - ->type($this->mockUser->name, '#username') - ->type($this->mockUser->password, '#password') - ->press('Log In') - ->seePageIs('/'); + $this->mockUserLogin()->seePageIs('/'); $user = User::where('email', $this->mockUser->email)->first(); $this->seeInDatabase('role_user', [ @@ -249,12 +242,7 @@ class LdapTest extends BrowserKitTest $this->mockEscapes(4); $this->mockExplodes(2); - $this->visit('/login') - ->see('Username') - ->type($this->mockUser->name, '#username') - ->type($this->mockUser->password, '#password') - ->press('Log In') - ->seePageIs('/'); + $this->mockUserLogin()->seePageIs('/'); $user = User::where('email', $this->mockUser->email)->first(); $this->seeInDatabase('role_user', [ @@ -303,12 +291,7 @@ class LdapTest extends BrowserKitTest $this->mockEscapes(4); $this->mockExplodes(2); - $this->visit('/login') - ->see('Username') - ->type($this->mockUser->name, '#username') - ->type($this->mockUser->password, '#password') - ->press('Log In') - ->seePageIs('/'); + $this->mockUserLogin()->seePageIs('/'); $user = User::where('email', $this->mockUser->email)->first(); $this->seeInDatabase('role_user', [ @@ -354,12 +337,7 @@ class LdapTest extends BrowserKitTest $this->mockEscapes(5); $this->mockExplodes(6); - $this->visit('/login') - ->see('Username') - ->type($this->mockUser->name, '#username') - ->type($this->mockUser->password, '#password') - ->press('Log In') - ->seePageIs('/'); + $this->mockUserLogin()->seePageIs('/'); $user = User::where('email', $this->mockUser->email)->first(); $this->seeInDatabase('role_user', [ @@ -372,4 +350,63 @@ class LdapTest extends BrowserKitTest ]); } + public function test_login_uses_specified_display_name_attribute() + { + app('config')->set([ + 'services.ldap.display_name_attribute' => 'displayName' + ]); + + $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); + $this->mockLdap->shouldReceive('setVersion')->once(); + $this->mockLdap->shouldReceive('setOption')->times(4); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => ['dc=test' . config('services.ldap.base_dn')], + 'displayName' => 'displayNameAttribute' + ]]); + $this->mockLdap->shouldReceive('bind')->times(6)->andReturn(true); + $this->mockEscapes(4); + + $this->mockUserLogin() + ->seePageIs('/login')->see('Please enter an email to use for this account.'); + + $this->type($this->mockUser->email, '#email') + ->press('Log In') + ->seePageIs('/') + ->see('displayNameAttribute') + ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => 'displayNameAttribute']); + } + + public function test_login_uses_default_display_name_attribute_if_specified_not_present() + { + app('config')->set([ + 'services.ldap.display_name_attribute' => 'displayName' + ]); + + $this->mockLdap->shouldReceive('connect')->once()->andReturn($this->resourceId); + $this->mockLdap->shouldReceive('setVersion')->once(); + $this->mockLdap->shouldReceive('setOption')->times(4); + $this->mockLdap->shouldReceive('searchAndGetEntries')->times(4) + ->with($this->resourceId, config('services.ldap.base_dn'), \Mockery::type('string'), \Mockery::type('array')) + ->andReturn(['count' => 1, 0 => [ + 'uid' => [$this->mockUser->name], + 'cn' => [$this->mockUser->name], + 'dn' => ['dc=test' . config('services.ldap.base_dn')] + ]]); + $this->mockLdap->shouldReceive('bind')->times(6)->andReturn(true); + $this->mockEscapes(4); + + $this->mockUserLogin() + ->seePageIs('/login')->see('Please enter an email to use for this account.'); + + $this->type($this->mockUser->email, '#email') + ->press('Log In') + ->seePageIs('/') + ->see($this->mockUser->name) + ->seeInDatabase('users', ['email' => $this->mockUser->email, 'email_confirmed' => false, 'external_auth_id' => $this->mockUser->name, 'name' => $this->mockUser->name]); + } + } diff --git a/tests/Entity/BookShelfTest.php b/tests/Entity/BookShelfTest.php index 5d71ec6f6..bdba812d5 100644 --- a/tests/Entity/BookShelfTest.php +++ b/tests/Entity/BookShelfTest.php @@ -1,5 +1,7 @@ assertElementContains('header', 'Shelves'); } + public function test_shelves_shows_in_header_if_have_any_shelve_view_permission() + { + $user = factory(User::class)->create(); + $this->giveUserPermissions($user, ['image-create-all']); + $shelf = Bookshelf::first(); + $userRole = $user->roles()->first(); + + $resp = $this->actingAs($user)->get('/'); + $resp->assertElementNotContains('header', 'Shelves'); + + $this->setEntityRestrictions($shelf, ['view'], [$userRole]); + + $resp = $this->get('/'); + $resp->assertElementContains('header', 'Shelves'); + } + public function test_shelves_page_contains_create_link() { $resp = $this->asEditor()->get('/shelves'); diff --git a/tests/Entity/SortTest.php b/tests/Entity/SortTest.php index 11294f7df..a3c20e84c 100644 --- a/tests/Entity/SortTest.php +++ b/tests/Entity/SortTest.php @@ -1,5 +1,6 @@ assertTrue($pageCopy->id !== $page->id, 'Page copy is not the same instance'); } + public function test_page_can_be_copied_without_edit_permission() + { + $page = Page::first(); + $currentBook = $page->book; + $newBook = Book::where('id', '!=', $currentBook->id)->first(); + $viewer = $this->getViewer(); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertDontSee($page->getUrl('/copy')); + + $newBook->created_by = $viewer->id; + $newBook->save(); + $this->giveUserPermissions($viewer, ['page-create-own']); + $this->regenEntityPermissions($newBook); + + $resp = $this->actingAs($viewer)->get($page->getUrl()); + $resp->assertSee($page->getUrl('/copy')); + + $movePageResp = $this->post($page->getUrl('/copy'), [ + 'entity_selection' => 'book:' . $newBook->id, + 'name' => 'My copied test page' + ]); + $movePageResp->assertRedirect(); + + $this->assertDatabaseHas('pages', [ + 'name' => 'My copied test page', + 'created_by' => $viewer->id, + 'book_id' => $newBook->id, + ]); + } + } \ No newline at end of file