]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'master' into 2019-design
authorDan Brown <redacted>
Sun, 10 Mar 2019 21:40:02 +0000 (21:40 +0000)
committerDan Brown <redacted>
Sun, 10 Mar 2019 21:40:02 +0000 (21:40 +0000)
27 files changed:
.env.example.complete
app/Auth/Access/LdapService.php
app/Auth/Permissions/PermissionService.php
app/Http/Controllers/PageController.php
app/Http/Middleware/Localization.php
app/Settings/SettingService.php
app/helpers.php
config/database.php
config/services.php
readme.md
resources/assets/js/components/markdown-editor.js
resources/assets/js/components/wysiwyg-editor.js
resources/assets/js/services/code.js
resources/assets/js/vues/components/dropzone.js
resources/assets/sass/_components.scss
resources/assets/sass/_pages.scss
resources/lang/de/entities.php
resources/lang/de_informal/entities.php
resources/lang/en/validation.php
resources/lang/nl/auth.php
resources/views/common/header.blade.php
resources/views/components/code-editor.blade.php
resources/views/pages/show.blade.php
resources/views/users/edit.blade.php
tests/Auth/LdapTest.php
tests/Entity/BookShelfTest.php
tests/Entity/SortTest.php

index 8851bd26834e75373c4c011928056321a3d8b14c..911d924df75f4c4fa6b6c2892529e154f2b78a0d 100644 (file)
@@ -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
index 654ea2f995c34494f86e54ebc28420464f58b4ca..9ffbbfbb75b57862c5543c4e4002013bd6485d28 100644 (file)
@@ -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
index af2a5e1fd8c694ee36d2e5e25134e0b3f5ec991d..8fc70e916dbd5dec3c5e878222de11861dfd9b45 100644 (file)
@@ -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.
index 7ebf262097a46d06b68ed9c28335e3bf177d11ab..16a7d5a5e45df6df1094bfa14df63fb17cb278f3 100644 (file)
@@ -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 === '') {
index e65b417d5079f43535930ab77d86a915d80ca8b8..ff5526cc70d61f6fbdcde2aa35a63c034d74ff1a 100644 (file)
@@ -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);
index 42a3810608750c9afbdf4e3352c29350d9511d4d..663a6ae3275954a2fa944c3d80fa07d832861038 100644 (file)
@@ -41,6 +41,7 @@ class SettingService
         if ($default === false) {
             $default = config('setting-defaults.' . $key, false);
         }
+
         if (isset($this->localCache[$key])) {
             return $this->localCache[$key];
         }
index e1395d816e987ee468c2af849478a04ebc0a907d..3f7b5e1b1350c1294adfee1b001775fb3a72b62b 100644 (file)
@@ -1,5 +1,7 @@
 <?php
 
+use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Entities\Entity;
 use BookStack\Ownable;
 
 /**
@@ -58,21 +60,34 @@ function hasAppAccess() : bool {
  * Check if the current user has a permission.
  * If an ownable element is passed in the jointPermissions are checked against
  * that particular item.
- * @param $permission
+ * @param string $permission
  * @param Ownable $ownable
  * @return mixed
  */
-function userCan($permission, Ownable $ownable = null)
+function userCan(string $permission, Ownable $ownable = null)
 {
     if ($ownable === null) {
         return user() && user()->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
index 6ca902944ed14644d8ded1631ac4bc3ae0749746..93a44854f092a8d166b9ce994a41da9dacb67a99 100644 (file)
@@ -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);
index f713f9d3897df3caf051805bdb64bd0f25ae9a4a..97cb71ddc73f0cf623907936fe43b02356496269 100644 (file)
@@ -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'),
index 337870c59bdf169aa679caa31bde6a92a3810ad0..037fbedb5eced2af6de1350b3f0643e4ab6c193c 100644 (file)
--- 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<phase>.<feature>.<patch>`. 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:
index 9228cfe2c49445caa7aebe79c0859e2748fdc8cf..b8e2bc040c8fe4db7795259cbcbaf515ff3830f1 100644 (file)
@@ -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 = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
+        const newText = `<div drawio-diagram="${image.id}"><img src="${image.url}"></div>`;
         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 => {
index c70d8271986156c30c2366a2c902a42431076d3d..2767d35c0f80cdf4a68af9a4ad3924080a3b7b89 100644 (file)
@@ -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(`<p><img src="${loadingImage}" id="${id}"></p>`);
-            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));
             }
         };
     }
index cfeabd3be0ce32aa84a5fe4ea6a74f3c38a3915e..bd749033d60b7702e6d3c0130842ad4b13496693 100644 (file)
@@ -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',
index 31a84a267d958831996406bf7a717f9ec85e432b..9d3d22b4dd2fdf7668299a1c65a1799db0e46366 100644 (file)
@@ -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;
         },
index d00d1fe9a433f5497795d5fc765e577683f91d0a..1f34166c61ffda83e9f72434bdf0875c6d2804f6 100644 (file)
@@ -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;
index 3ab5a6a691e112f602e8311ed98f07488a7b55da..1b11930796c569e033f5a1a66d422621aa72c336 100755 (executable)
     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;
index 7c27be17b175c029aac83edc20d1c5818e2878c5..07a92e2c7aac8acc1e6bfe12a9ab66ecc676b6b5 100644 (file)
@@ -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
      */
index 21fdbb13d8ff7651254a896cfcf405f007da265b..1decdd7b78bcd593c160e1d5003670da3c4e603c 100644 (file)
@@ -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
      */
index 0b6a1c170a5fb31d7d470eedf61bb7a89bd96dbb..e05cfca9dbe13222fdbc1f5ee492fb57fa5036d6 100644 (file)
@@ -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' => [
index d8813f07b8398c289ef07bb876e2036884349ebb..31bd330cc3d5ae28acb9663883cfce9f775d3800 100644 (file)
@@ -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
+];
index f1661a14600bc97ef02e5ae3b5a1e7cfb32b2c2f..89aa1078d865d149f21de14c9621d69db5f06843 100644 (file)
@@ -27,7 +27,7 @@
                 <div class="links text-center">
                     @if (hasAppAccess())
                         <a class="hide-over-l" href="{{ baseUrl('/search') }}">@icon('search'){{ trans('common.search') }}</a>
-                        @if(userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
+                        @if(userCanOnAny('view', \BookStack\Entities\Bookshelf::class) || userCan('bookshelf-view-all') || userCan('bookshelf-view-own'))
                             <a href="{{ baseUrl('/shelves') }}">@icon('bookshelf'){{ trans('entities.shelves') }}</a>
                         @endif
                         <a href="{{ baseUrl('/books') }}">@icon('books'){{ trans('entities.books') }}</a>
index cd235f1d2a519558d073245e0d20c5c667242ef1..67e7b77737049504ee3e38a4f00b939c66d1e619 100644 (file)
@@ -21,7 +21,9 @@
                             <a @click="updateLanguage('Java')">Java</a>
                             <a @click="updateLanguage('JavaScript')">JavaScript</a>
                             <a @click="updateLanguage('JSON')">JSON</a>
+                            <a @click="updateLanguage('Lua')">Lua</a>
                             <a @click="updateLanguage('PHP')">PHP</a>
+                            <a @click="updateLanguage('Powershell')">Powershell</a>
                             <a @click="updateLanguage('MarkDown')">MarkDown</a>
                             <a @click="updateLanguage('Nginx')">Nginx</a>
                             <a @click="updateLanguage('Python')">Python</a>
@@ -48,4 +50,4 @@
 
         </div>
     </div>
-</div>
\ No newline at end of file
+</div>
index 8444155a69a7984dde04de4ba9a548be23e352e9..6858661c412b2d6af3f9d6bc1c9584ad8332b3da 100644 (file)
                     <span>@icon('edit')</span>
                     <span>{{ trans('common.edit') }}</span>
                 </a>
+            @endif
+            @if(userCanOnAny('page-create'))
                 <a href="{{ $page->getUrl('/copy') }}" class="icon-list-item">
                     <span>@icon('copy')</span>
                     <span>{{ trans('common.copy') }}</span>
                 </a>
+            @endif
+            @if(userCan('page-update', $page))
                 @if(userCan('page-delete', $page))
                        <a href="{{ $page->getUrl('/move') }}" class="icon-list-item">
                            <span>@icon('folder')</span>
index 42fc2beb1bba9135a82425d0657bb0a6c3a262fe..e6e66665f08bb97e2d1e0edc95d1197ba011324a 100644 (file)
@@ -45,7 +45,7 @@
                         <div>
                             <select name="setting[language]" id="user-language">
                                 @foreach(trans('settings.language_select') as $lang => $label)
-                                    <option @if(setting()->getUser($user, 'language') === $lang) selected @endif value="{{ $lang }}">{{ $label }}</option>
+                                    <option @if(setting()->getUser($user, 'language', config('app.default_locale')) === $lang) selected @endif value="{{ $lang }}">{{ $label }}</option>
                                 @endforeach
                             </select>
                         </div>
@@ -88,4 +88,4 @@
     </div>
 
     @include('components.image-manager', ['imageType' => 'user'])
-@stop
\ No newline at end of file
+@stop
index 16ba113587e772d61a545a0cadcaafd6286ff4ca..5ccb1415e6fed57e2bced5e33e6d04b966cadf2a 100644 (file)
@@ -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]);
+    }
+
 }
index 5d71ec6f6a15f12c5c5e1cd7e057f70ccd5de6bb..bdba812d59a16593516e96f99e0a6a78e64393a8 100644 (file)
@@ -1,5 +1,7 @@
 <?php namespace Tests;
 
+use BookStack\Auth\Role;
+use BookStack\Auth\User;
 use BookStack\Entities\Book;
 use BookStack\Entities\Bookshelf;
 
@@ -27,6 +29,22 @@ class BookShelfTest extends TestCase
         $resp->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');
index 11294f7dfdcd236dc7b0f6f8508650a09656a5e6..a3c20e84c5b517bede1e3b3488516a299c1e853a 100644 (file)
@@ -1,5 +1,6 @@
 <?php namespace Tests;
 
+use BookStack\Auth\Role;
 use BookStack\Entities\Book;
 use BookStack\Entities\Chapter;
 use BookStack\Entities\Page;
@@ -239,4 +240,35 @@ class SortTest extends TestCase
         $this->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