]> BookStack Code Mirror - bookstack/commitdiff
Merge pull request #2283 from BookStackApp/recycle_bin
authorDan Brown <redacted>
Sat, 7 Nov 2020 15:10:17 +0000 (15:10 +0000)
committerGitHub <redacted>
Sat, 7 Nov 2020 15:10:17 +0000 (15:10 +0000)
Recycle Bin Implementation

39 files changed:
.env.example.complete
.github/translators.txt
app/Auth/Permissions/PermissionService.php
app/Entities/Managers/PageContent.php
app/Http/Controllers/AttachmentController.php
app/Http/Controllers/Images/ImageController.php
app/Providers/AppServiceProvider.php
app/Uploads/AttachmentService.php
app/helpers.php
artisan
bootstrap/init.php [deleted file]
composer.json
dev/docker/entrypoint.app.sh
dev/docker/entrypoint.node.sh
package-lock.json
package.json
phpunit.xml
public/.htaccess
public/index.php
readme.md
resources/js/components/book-sort.js
resources/js/components/markdown-editor.js
resources/js/services/code.js
resources/js/services/http.js
resources/lang/en/validation.php
resources/lang/es/settings.php
resources/lang/zh_CN/settings.php
resources/views/attachments/manager-edit-form.blade.php
resources/views/attachments/manager-link-form.blade.php
resources/views/attachments/manager.blade.php
resources/views/components/code-editor.blade.php
resources/views/pages/form.blade.php
tests/Entity/PageContentTest.php
tests/Permissions/RestrictionsTest.php
tests/Uploads/AttachmentTest.php
tests/Uploads/DrawioTest.php
tests/User/UserApiTokenTest.php
tests/User/UserPreferencesTest.php
tests/User/UserProfileTest.php

index 0e62b3ea6206a382b457321984c45d0a3994b7d2..19643a49f6d83aa6a576fc7d171f52c38a483f2f 100644 (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
 
index 6f06c8844b076837fad34f54853ff694d4ef02d6..c74fe394374053fb9dd9fd4e674787f592ffd7eb 100644 (file)
@@ -122,3 +122,4 @@ fadiapp :: Arabic
 Jakub Bouček (jakubboucek) :: Czech
 Marco (cdrfun) :: German
 10935336 :: Chinese Simplified
+孟繁阳 (FanyangMeng) :: Chinese Simplified
index 2609779bfa3e79805bd1c2839c3020add1caad29..d9a52c1beb152e900bf89c60df90c0161b97246c 100644 (file)
@@ -3,11 +3,8 @@
 use BookStack\Auth\Permissions;
 use BookStack\Auth\Role;
 use BookStack\Entities\Book;
-use BookStack\Entities\Bookshelf;
-use BookStack\Entities\Chapter;
 use BookStack\Entities\Entity;
 use BookStack\Entities\EntityProvider;
-use BookStack\Entities\Page;
 use BookStack\Ownable;
 use Illuminate\Database\Connection;
 use Illuminate\Database\Eloquent\Builder;
index e417b1caad6e2b07bb457889c3b1489354e942ad..7338a36b393631289a8f30ce09888d403d52763e 100644 (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];
     }
 
     /**
@@ -279,6 +296,24 @@ class PageContent
             $scriptElem->parentNode->removeChild($scriptElem);
         }
 
+        // Remove clickable links to JavaScript URI
+        $badLinks = $xPath->query('//*[contains(@href, \'javascript:\')]');
+        foreach ($badLinks as $badLink) {
+            $badLink->parentNode->removeChild($badLink);
+        }
+
+        // Remove forms with calls to JavaScript URI
+        $badForms = $xPath->query('//*[contains(@action, \'javascript:\')] | //*[contains(@formaction, \'javascript:\')]');
+        foreach ($badForms as $badForm) {
+            $badForm->parentNode->removeChild($badForm);
+        }
+
+        // Remove meta tag to prevent external redirects
+        $metaTags = $xPath->query('//meta[contains(@content, \'url\')]');
+        foreach ($metaTags as $metaTag) {
+            $metaTag->parentNode->removeChild($metaTag);
+        }
+
         // Remove data or JavaScript iFrames
         $badIframes = $xPath->query('//*[contains(@src, \'data:\')] | //*[contains(@src, \'javascript:\')] | //*[@srcdoc]');
         foreach ($badIframes as $badIframe) {
index 0830693bc6a73769a51a26f3cda8239d163eac8e..f52143292de060b4b0eaf883c73e9d6e7adb6789 100644 (file)
@@ -110,7 +110,7 @@ class AttachmentController extends Controller
         try {
             $this->validate($request, [
                 'attachment_edit_name' => 'required|string|min:1|max:255',
-                'attachment_edit_url' =>  'string|min:1|max:255'
+                'attachment_edit_url' =>  'string|min:1|max:255|safe_url'
             ]);
         } catch (ValidationException $exception) {
             return response()->view('attachments.manager-edit-form', array_merge($request->only(['attachment_edit_name', 'attachment_edit_url']), [
@@ -145,7 +145,7 @@ class AttachmentController extends Controller
             $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'
+                'attachment_link_url' =>  'required|string|min:1|max:255|safe_url'
             ]);
         } catch (ValidationException $exception) {
             return response()->view('attachments.manager-link-form', array_merge($request->only(['attachment_link_name', 'attachment_link_url']), [
@@ -161,7 +161,7 @@ class AttachmentController extends Controller
 
         $attachmentName = $request->get('attachment_link_name');
         $link = $request->get('attachment_link_url');
-        $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, $pageId);
+        $attachment = $this->attachmentService->saveNewFromLink($attachmentName, $link, intval($pageId));
 
         return view('attachments.manager-link-form', [
             'pageId' => $pageId,
index 7d06facffe14b963a53d645eb47d56d8a655b290..52cc463c8543753803b556057af1db7caab280da 100644 (file)
@@ -3,7 +3,7 @@
 use BookStack\Entities\Page;
 use BookStack\Exceptions\ImageUploadException;
 use BookStack\Http\Controllers\Controller;
-use BookStack\Repos\PageRepo;
+use BookStack\Entities\Repos\PageRepo;
 use BookStack\Uploads\Image;
 use BookStack\Uploads\ImageRepo;
 use Exception;
index 1cc3e09c22dc6677a517af03ddbbd0efeb3eac3b..f418153997286e5636e754279be147e753171ec7 100644 (file)
@@ -43,6 +43,13 @@ class AppServiceProvider extends ServiceProvider
             return substr_count($uploadName, '.') < 2;
         });
 
+        Validator::extend('safe_url', function ($attribute, $value, $parameters, $validator) {
+            $cleanLinkName = strtolower(trim($value));
+            $isJs = strpos($cleanLinkName, 'javascript:') === 0;
+            $isData = strpos($cleanLinkName, 'data:') === 0;
+            return !$isJs && !$isData;
+        });
+
         // Custom blade view directives
         Blade::directive('icon', function ($expression) {
             return "<?php echo icon($expression); ?>";
index 02220771aaee631e1d7bd0643d865d9196932b3e..e85901e17c7d34fae1e9967b27c119f824a708dc 100644 (file)
@@ -88,12 +88,8 @@ class AttachmentService extends UploadService
 
     /**
      * Save a new File attachment from a given link and name.
-     * @param string $name
-     * @param string $link
-     * @param int $page_id
-     * @return Attachment
      */
-    public function saveNewFromLink($name, $link, $page_id)
+    public function saveNewFromLink(string $name, string $link, int $page_id): Attachment
     {
         $largestExistingOrder = Attachment::where('uploaded_to', '=', $page_id)->max('order');
         return Attachment::forceCreate([
@@ -123,13 +119,11 @@ class AttachmentService extends UploadService
 
     /**
      * Update the details of a file.
-     * @param Attachment $attachment
-     * @param $requestData
-     * @return Attachment
      */
-    public function updateFile(Attachment $attachment, $requestData)
+    public function updateFile(Attachment $attachment, array $requestData): Attachment
     {
         $attachment->name = $requestData['name'];
+
         if (isset($requestData['link']) && trim($requestData['link']) !== '') {
             $attachment->path = $requestData['link'];
             if (!$attachment->external) {
@@ -137,6 +131,7 @@ class AttachmentService extends UploadService
                 $attachment->external = true;
             }
         }
+
         $attachment->save();
         return $attachment;
     }
index 83017c37dddda3c81043c7f29b1aad080e8e346e..935d4d8daee4a2d4600dc0394e85c28c7184c48e 100644 (file)
@@ -7,9 +7,6 @@ use BookStack\Settings\SettingService;
 
 /**
  * Get the path to a versioned file.
- *
- * @param  string $file
- * @return string
  * @throws Exception
  */
 function versioned_asset(string $file = ''): string
@@ -33,7 +30,6 @@ function versioned_asset(string $file = ''): string
 /**
  * Helper method to get the current User.
  * Defaults to public 'Guest' user if not logged in.
- * @return User
  */
 function user(): User
 {
@@ -57,9 +53,8 @@ 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.
+ * Check if the current user has a permission. If an ownable element
+ * is passed in the jointPermissions are checked against that particular item.
  */
 function userCan(string $permission, Ownable $ownable = null): bool
 {
@@ -75,9 +70,6 @@ function userCan(string $permission, Ownable $ownable = null): bool
 /**
  * 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): bool
 {
@@ -87,27 +79,26 @@ function userCanOnAny(string $permission, string $entityClass = null): bool
 
 /**
  * Helper to access system settings.
- * @param string $key
- * @param $default
  * @return bool|string|SettingService
  */
 function setting(string $key = null, $default = false)
 {
     $settingService = resolve(SettingService::class);
+
     if (is_null($key)) {
         return $settingService;
     }
+
     return $settingService->get($key, $default);
 }
 
 /**
  * Get a path to a theme resource.
- * @param string $path
- * @return string
  */
 function theme_path(string $path = ''): string
 {
     $theme = config('view.theme');
+
     if (!$theme) {
         return '';
     }
@@ -121,9 +112,6 @@ function theme_path(string $path = ''): string
  * to the 'resources/assets/icons' folder.
  *
  * Returns an empty string if icon file not found.
- * @param $name
- * @param array $attrs
- * @return mixed
  */
 function icon(string $name, array $attrs = []): string
 {
@@ -139,6 +127,7 @@ function icon(string $name, array $attrs = []): string
 
     $iconPath = resource_path('icons/' . $name . '.svg');
     $themeIconPath = theme_path('icons/' . $name . '.svg');
+
     if ($themeIconPath && file_exists($themeIconPath)) {
         $iconPath = $themeIconPath;
     } else if (!file_exists($iconPath)) {
diff --git a/artisan b/artisan
index dad16dcdefdee1989b99de8cddffffe06d10a381..d5c6aaf98542479db38a44dce76de952dbc34de6 100755 (executable)
--- a/artisan
+++ b/artisan
@@ -5,15 +5,17 @@ define('LARAVEL_START', microtime(true));
 
 /*
 |--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
 |--------------------------------------------------------------------------
 |
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader
+| for our application. We just need to utilize it! We'll require it
+| into the script here so that we do not have to worry about the
+| loading of any our classes "manually". Feels great to relax.
 |
 */
 
-require __DIR__.'/bootstrap/init.php';
+require __DIR__.'/vendor/autoload.php';
 
 $app = require_once __DIR__.'/bootstrap/app.php';
 
diff --git a/bootstrap/init.php b/bootstrap/init.php
deleted file mode 100644 (file)
index 7d9e43f..0000000
+++ /dev/null
@@ -1,25 +0,0 @@
-<?php
-
-/*
-|--------------------------------------------------------------------------
-| Load Our Own Helpers
-|--------------------------------------------------------------------------
-|
-| This custom function loads any helpers, before the Laravel Framework
-| is built so we can override any helpers as we please.
-|
-*/
-require __DIR__.'/../app/helpers.php';
-
-/*
-|--------------------------------------------------------------------------
-| Register The Composer Auto Loader
-|--------------------------------------------------------------------------
-|
-| Composer provides a convenient, automatically generated class loader
-| for our application. We just need to utilize it! We'll require it
-| into the script here so that we do not have to worry about the
-| loading of any our classes "manually". Feels great to relax.
-|
-*/
-require __DIR__.'/../vendor/autoload.php';
\ No newline at end of file
index 59fc909d6b1e9f2298a510db246246c06bd46ebd..8a2b7d656552e4c05aaad75242f61aba45156594 100644 (file)
         ],
         "psr-4": {
             "BookStack\\": "app/"
-        }
+        },
+               "files": [
+                       "app/helpers.php"
+               ]
     },
     "autoload-dev": {
         "psr-4": {
index ff44f0c8d3f5d7b3af67a55818022805d8d113cf..e91d34a713377a7e579a6594000c01fb5c98ba57 100755 (executable)
@@ -7,8 +7,9 @@ env
 if [[ -n "$1" ]]; then
     exec "$@"
 else
+    composer install
     wait-for-it db:3306 -t 45
     php artisan migrate --database=mysql
     chown -R www-data:www-data storage
     exec apache2-foreground
-fi
\ No newline at end of file
+fi
index e59e1e8a027b55f73a7466812754692a468d9185..a8f33fd3d93c2be93d34f5c3bb4b01f7578815de 100755 (executable)
@@ -5,4 +5,4 @@ set -e
 npm install
 npm rebuild node-sass
 
-exec npm run watch
\ No newline at end of file
+SHELL=/bin/sh exec npm run watch
index cea03187cd0532014cef6e0ef355c29ad4f0b2a7..06f13e8d296d5b89c7d5c5e0daf688f561ae8359 100644 (file)
       }
     },
     "esbuild": {
-      "version": "0.6.30",
-      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.6.30.tgz",
-      "integrity": "sha512-ZSZY461UPzTYYC3rqy1QiMtngk2WyXf+58MgC7tC22jkI90FXNgEl0hN3ipfn/UgZYzTW2GBcHiO7t0rSbHT7g==",
+      "version": "0.7.8",
+      "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.7.8.tgz",
+      "integrity": "sha512-6UT1nZB+8ja5avctUC6d3kGOUAhy6/ZYHljL4nk3++1ipadghBhUCAcwsTHsmUvdu04CcGKzo13mE+ZQ2O3zrA==",
       "dev": true
     },
     "escape-string-regexp": {
       "dev": true
     },
     "markdown-it": {
-      "version": "11.0.0",
-      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.0.tgz",
-      "integrity": "sha512-+CvOnmbSubmQFSA9dKz1BRiaSMV7rhexl3sngKqFyXSagoA3fBdJQ8oZWtRy2knXdpDXaBw44euz37DeJQ9asg==",
+      "version": "11.0.1",
+      "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-11.0.1.tgz",
+      "integrity": "sha512-aU1TzmBKcWNNYvH9pjq6u92BML+Hz3h5S/QpfTFwiQF852pLT+9qHsrhM9JYipkOXZxGn+sGH8oyJE9FD9WezQ==",
       "requires": {
         "argparse": "^1.0.7",
         "entities": "~2.0.0",
       }
     },
     "sass": {
-      "version": "1.26.10",
-      "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.10.tgz",
-      "integrity": "sha512-bzN0uvmzfsTvjz0qwccN1sPm2HxxpNI/Xa+7PlUEMS+nQvbyuEK7Y0qFqxlPHhiNHb1Ze8WQJtU31olMObkAMw==",
+      "version": "1.26.11",
+      "resolved": "https://registry.npmjs.org/sass/-/sass-1.26.11.tgz",
+      "integrity": "sha512-W1l/+vjGjIamsJ6OnTe0K37U2DBO/dgsv2Z4c89XQ8ZOO6l/VwkqwLSqoYzJeJs6CLuGSTRWc91GbQFL3lvrvw==",
       "dev": true,
       "requires": {
         "chokidar": ">=2.0.0 <4.0.0"
       "dev": true
     },
     "sortablejs": {
-      "version": "1.10.2",
-      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.10.2.tgz",
-      "integrity": "sha512-YkPGufevysvfwn5rfdlGyrGjt7/CRHwvRPogD/lC+TnvcN29jDpCifKP+rBqf+LRldfXSTh+0CGLcSg0VIxq3A=="
+      "version": "1.12.0",
+      "resolved": "https://registry.npmjs.org/sortablejs/-/sortablejs-1.12.0.tgz",
+      "integrity": "sha512-bPn57rCjBRlt2sC24RBsu40wZsmLkSo2XeqG8k6DC1zru5eObQUIPPZAQG7W2SJ8FZQYq+BEJmvuw1Zxb3chqg=="
     },
     "spdx-correct": {
       "version": "3.1.1",
index 0c3c69a07be6df87325c535b5f018a6125e08701..d5e93a31e3dc4aaca1e4835196656da0c1f28d4f 100644 (file)
@@ -4,9 +4,9 @@
     "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=es2020",
-    "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 --minify",
+    "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 --initial \"./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",
   },
   "devDependencies": {
     "chokidar-cli": "^2.1.0",
-    "esbuild": "0.6.30",
+    "esbuild": "0.7.8",
     "livereload": "^0.9.1",
     "npm-run-all": "^4.1.5",
     "punycode": "^2.1.1",
-    "sass": "^1.26.10"
+    "sass": "^1.26.11"
   },
   "dependencies": {
     "clipboard": "^2.0.6",
     "codemirror": "^5.58.1",
     "dropzone": "^5.7.2",
-    "markdown-it": "^11.0.0",
+    "markdown-it": "^11.0.1",
     "markdown-it-task-lists": "^2.1.1",
-    "sortablejs": "^1.10.2"
+    "sortablejs": "^1.12.0"
   }
 }
index 70f1c1f9c3ed341b39af5baa54e79af2de7a70ab..ad7c6f43a5d551eec767dadda32f047d64ded014 100644 (file)
@@ -1,7 +1,7 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <phpunit backupGlobals="false"
          backupStaticAttributes="false"
-         bootstrap="bootstrap/init.php"
+         bootstrap="vendor/autoload.php"
          colors="true"
          convertErrorsToExceptions="true"
          convertNoticesToExceptions="true"
index abe87b39de7a73abd817cedd264d3c1f7b9ec17b..3aec5e27e5db801fa9e321c0a97acbb49e10908f 100644 (file)
 
     # 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]
index 8205764728cdb1dc6bd8bdfb78f20ebec5525ac3..9d890e90a4ef4cd9ade7b25b434248c63766d3d0 100644 (file)
@@ -11,15 +11,17 @@ define('LARAVEL_START', microtime(true));
 
 /*
 |--------------------------------------------------------------------------
-| Initialize The App
+| Register The Auto Loader
 |--------------------------------------------------------------------------
 |
-| We need to get things going before we start up the app.
-| The init file loads everything in, in the correct order.
+| Composer provides a convenient, automatically generated class loader for
+| our application. We just need to utilize it! We'll simply require it
+| into the script here so that we don't have to worry about manual
+| loading any of our classes later on. It feels great to relax.
 |
 */
 
-require __DIR__.'/../bootstrap/init.php';
+require __DIR__.'/../vendor/autoload.php';
 
 /*
 |--------------------------------------------------------------------------
index 0c0626f5b5436ff203c8adca88acaaef7467c352..bf6dfac2ddc584439e357be29e6d9dadabcb3da1 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -93,12 +93,11 @@ To get started, make sure you meet the following requirements:
 
 If all the conditions are met, you can proceed with the following steps:
 
-1. Install PHP/Composer dependencies with **`docker-compose run app composer install`** (first time can take a while because the image has to be built).
-2. **Copy `.env.example` to `.env`** and change `APP_KEY` to a random 32 char string.
-3. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
-4. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
-5. **Run `docker-compose up`** and wait until all database migrations have been done.
-6. You can now login with `[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
+1. **Copy `.env.example` to `.env`**, change `APP_KEY` to a random 32 char string and set `APP_ENV` to `local`.
+2. Make sure **port 8080 is unused** *or else* change `DEV_PORT` to a free port on your host.
+3. **Run `chgrp -R docker storage`**. The development container will chown the `storage` directory to the `www-data` user inside the container so BookStack can write to it. You need to change the group to your host's `docker` group here to not lose access to the `storage` directory.
+4. **Run `docker-compose up`** and wait until the image is built and all database migrations have been done.
+5. You can now login with `[email protected]` and `password` as password on `localhost:8080` (or another port if specified).
 
 If needed, You'll be able to run any artisan commands via docker-compose like so:
 
@@ -168,6 +167,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)
\ No newline at end of file
+* [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
index b0d64ad172f1e0abe7b4e457a415c588a083da1c..2b94ca4a7a19a68ff82b31345efbc52fc28dc56e 100644 (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();
 
index c371a983991dfa333429b824122d0758628a4599..19d26d4a987f561f2aa0a3d007283f0655463d83 100644 (file)
@@ -440,10 +440,10 @@ class MarkdownEditor {
 
             const data = {
                 image: pngData,
-                uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
+                uploaded_to: Number(this.pageId),
             };
 
-            window.$http.post(window.baseUrl('/images/drawio'), data).then(resp => {
+            window.$http.post("/images/drawio", data).then(resp => {
                 this.insertDrawing(resp.data, cursorPos);
                 DrawIO.close();
             }).catch(err => {
@@ -476,10 +476,10 @@ class MarkdownEditor {
 
             let data = {
                 image: pngData,
-                uploaded_to: Number(document.getElementById('page-editor').getAttribute('page-id'))
+                uploaded_to: Number(this.pageId),
             };
 
-            window.$http.post(window.baseUrl(`/images/drawio`), data).then(resp => {
+            window.$http.post("/images/drawio", data).then(resp => {
                 let newText = `<div drawio-diagram="${resp.data.id}"><img src="${resp.data.url}"></div>`;
                 let newContent = this.cm.getValue().split('\n').map(line => {
                     if (line.indexOf(`drawio-diagram="${drawingId}"`) !== -1) {
index a7dfa587f203dcea41b2c781c36887e0d6b28d82..e2aca1aad9e681d94a23763c95e550712e1bedfa 100644 (file)
@@ -26,6 +26,7 @@ import 'codemirror/mode/rust/rust';
 import 'codemirror/mode/shell/shell';
 import 'codemirror/mode/sql/sql';
 import 'codemirror/mode/toml/toml';
+import 'codemirror/mode/vbscript/vbscript';
 import 'codemirror/mode/xml/xml';
 import 'codemirror/mode/yaml/yaml';
 
@@ -84,6 +85,8 @@ const modeMap = {
     bash: 'shell',
     toml: 'toml',
     sql: 'text/x-sql',
+    vbs: 'vbscript',
+    vbscript: 'vbscript',
     xml: 'xml',
     yaml: 'yaml',
     yml: 'yaml',
index 8ecd6c109168d26a48024c3dbb847b130e3d1592..b05dd23bfd7222834eade6395f655d5916b1b2c1 100644 (file)
@@ -141,10 +141,14 @@ async function request(url, options = {}) {
 /**
  * Get the content from a fetch response.
  * Checks the content-type header to determine the format.
- * @param response
+ * @param {Response} response
  * @returns {Promise<Object|String>}
  */
 async function getResponseContent(response) {
+    if (response.status === 204) {
+        return null;
+    }
+
     const responseContentType = response.headers.get('Content-Type');
     const subType = responseContentType.split('/').pop();
 
index 76b57a2a3b58ddb8ef41e0562c5187359cc6e542..578ea999fc31618997be7834012fb30aed3d7b1f 100644 (file)
@@ -90,6 +90,7 @@ return [
     'required_without'     => 'The :attribute field is required when :values is not present.',
     'required_without_all' => 'The :attribute field is required when none of :values are present.',
     'same'                 => 'The :attribute and :other must match.',
+    'safe_url'             => 'The provided link may not be safe.',
     'size'                 => [
         'numeric' => 'The :attribute must be :size.',
         'file'    => 'The :attribute must be :size kilobytes.',
index 48473acef1c09333725d0eb78b29e10163c6acfb..82cb2861553f03b5e8cff10b59d3e2859d8be434 100644 (file)
@@ -179,7 +179,7 @@ return [
     'user_api_token_name_desc' => 'Dale a tu token un nombre legible como un recordatorio futuro de su propósito.',
     'user_api_token_expiry' => 'Fecha de expiración',
     'user_api_token_expiry_desc' => 'Establece una fecha en la que este token expira. Después de esta fecha, las solicitudes realizadas usando este token ya no funcionarán. Dejar este campo en blanco fijará un vencimiento de 100 años en el futuro.',
-    'user_api_token_create_secret_message' => 'Immediately after creating this token a "Token ID" & "Token Secret" will be generated and displayed. The secret will only be shown a single time so be sure to copy the value to somewhere safe and secure before proceeding.',
+    'user_api_token_create_secret_message' => 'Inmediatamente después de crear este token se generarán y mostrarán sus correspondientes "Token ID" y "Token Secret". El "Token Secret" sólo se mostrará una vez, así que asegúrese de copiar el valor a un lugar seguro antes de proceder.',
     'user_api_token_create_success' => 'Token API creado correctamente',
     'user_api_token_update_success' => 'Token API actualizado correctamente',
     'user_api_token' => 'Token API',
@@ -187,8 +187,8 @@ return [
     'user_api_token_id_desc' => 'Este es un identificador no editable generado por el sistema y único para este token que necesitará ser proporcionado en solicitudes de API.',
     'user_api_token_secret' => 'Token Secret',
     'user_api_token_secret_desc' => 'Esta es una clave no editable generada por el sistema que necesitará ser proporcionada en solicitudes de API. Solo se monstraré esta vez así que guarde su valor en un lugar seguro.',
-    'user_api_token_created' => 'Token created :timeAgo',
-    'user_api_token_updated' => 'Token updated :timeAgo',
+    'user_api_token_created' => 'Token creado :timeAgo',
+    'user_api_token_updated' => 'Token actualizado :timeAgo',
     'user_api_token_delete' => 'Borrar token',
     'user_api_token_delete_warning' => 'Esto eliminará completamente este token API con el nombre \':tokenName\' del sistema.',
     'user_api_token_delete_confirm' => '¿Está seguro de que desea borrar este API token?',
index 7b8d87b49aa34bb7593a1aa3512df64cccacffbd..3467b49a4267a82706d75e6bf9add60279cc1397 100755 (executable)
@@ -57,7 +57,7 @@ return [
     'reg_enable_desc' => '启用注册后,用户将可以自己注册为站点用户。 注册后,他们将获得一个默认的单一用户角色。',
     'reg_default_role' => '注册后的默认用户角色',
     'reg_enable_external_warning' => '当启用外部LDAP或者SAML认证时,上面的选项会被忽略。当使用外部系统认证认证成功时,将自动创建非现有会员的用户账户。',
-    'reg_email_confirmation' => '邮箱确认n',
+    'reg_email_confirmation' => '邮件确认',
     'reg_email_confirmation_toggle' => '需要电子邮件确认',
     'reg_confirm_email_desc' => '如果使用域名限制,则需要Email验证,并且该值将被忽略。',
     'reg_confirm_restrict_domain' => '域名限制',
@@ -92,8 +92,8 @@ return [
     'audit_table_event' => '事件',
     'audit_table_item' => '相关项目',
     'audit_table_date' => '活动日期',
-    'audit_date_from' => 'Date Range From',
-    'audit_date_to' => 'Date Range To',
+    'audit_date_from' => '日期范围从',
+    'audit_date_to' => '日期范围至',
 
     // Role Settings
     'roles' => '角色',
index f3f11a0fc3a8b14501382a2cc6558479883ad0a6..ee86dc24006510ee905aadc16aef601aeb2af259 100644 (file)
@@ -1,6 +1,7 @@
 <div component="ajax-form"
      option:ajax-form:url="/attachments/{{ $attachment->id }}"
      option:ajax-form:method="put"
+     option:ajax-form:response-container=".attachment-edit-container"
      option:ajax-form:success-message="{{ trans('entities.attachments_updated_success') }}">
     <h5>{{ trans('entities.attachments_edit_file') }}</h5>
 
index 6f22abb328c199048d815ff7bccf9eb805e6ee29..b51daa40e061a6f22e85fc0ef978b94e5e5326db 100644 (file)
@@ -4,6 +4,7 @@
 <div component="ajax-form"
      option:ajax-form:url="/attachments/link"
      option:ajax-form:method="post"
+     option:ajax-form:response-container=".link-form-container"
      option:ajax-form:success-message="{{ trans('entities.attachments_link_attached') }}">
     <input type="hidden" name="attachment_link_uploaded_to" value="{{ $pageId }}">
     <p class="text-muted small">{{ trans('entities.attachments_explain_link') }}</p>
index 4bfa97608580bcdf6de5b1006f3bc6f5c4673114..4628f7495650def200565f8babea00a06b96b464 100644 (file)
                         'successMessage' => trans('entities.attachments_file_uploaded'),
                     ])
                 </div>
-                <div refs="tabs@contentLinks" class="hidden">
+                <div refs="tabs@contentLinks" class="hidden link-form-container">
                     @include('attachments.manager-link-form', ['pageId' => $page->id])
                 </div>
             </div>
 
         </div>
 
-        <div refs="attachments@editContainer" class="hidden">
+        <div refs="attachments@editContainer" class="hidden attachment-edit-container">
 
         </div>
 
index 6822bb28d00ad1823571c0bcfceaa044d0682a6b..011840465a63ff480f048f91b9e9f18bda3baf37 100644 (file)
@@ -34,6 +34,7 @@
                             <a refs="code-editor@languageLink" data-lang="Ruby">Ruby</a>
                             <a refs="code-editor@languageLink" data-lang="shell">Shell/Bash</a>
                             <a refs="code-editor@languageLink" data-lang="SQL">SQL</a>
+                            <a refs="code-editor@languageLink" data-lang="VBScript">VBScript</a>
                             <a refs="code-editor@languageLink" data-lang="XML">XML</a>
                             <a refs="code-editor@languageLink" data-lang="YAML">YAML</a>
                         </small>
@@ -66,4 +67,4 @@
 
         </div>
     </div>
-</div>
\ No newline at end of file
+</div>
index d153aed99af71e0598ecd4a7aa443a85e619159f..7e8b2fdd64409f18a72378c7e620af5f8a14691a 100644 (file)
@@ -1,7 +1,7 @@
 <div component="page-editor" class="page-editor flex-fill flex"
      option:page-editor:drafts-enabled="{{ $draftsEnabled ? 'true' : 'false' }}"
      @if(config('services.drawio'))
-        drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://www.draw.io/?embed=1&proto=json&spin=1' }}"
+        drawio-url="{{ is_string(config('services.drawio')) ? config('services.drawio') : 'https://embed.diagrams.net/?embed=1&proto=json&spin=1' }}"
      @endif
      @if($model->name === trans('entities.pages_initial_name'))
         option:page-editor:has-default-title="true"
index 69b46b06e4e73b43bbfa290dd93faa9716977107..e97df2c7edd80725bb9e830f9cebf8146def5c69 100644 (file)
@@ -159,6 +159,72 @@ class PageContentTest extends TestCase
 
     }
 
+    public function test_javascript_uri_links_are_removed()
+    {
+        $checks = [
+            '<a id="xss" href="javascript:alert(document.cookie)>Click me</a>',
+            '<a id="xss" href="javascript: alert(document.cookie)>Click me</a>'
+        ];
+
+        $this->asEditor();
+        $page = Page::first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<a id="xss">');
+            $pageView->assertElementNotContains('.page-content', 'href=javascript:');
+        }
+    }
+    public function test_form_actions_with_javascript_are_removed()
+    {
+        $checks = [
+            '<form><input id="xss" type=submit formaction=javascript:alert(document.domain) value=Submit><input></form>',
+            '<form ><button id="xss" formaction=javascript:alert(document.domain)>Click me</button></form>',
+            '<form id="xss" action=javascript:alert(document.domain)><input type=submit value=Submit></form>'
+        ];
+
+        $this->asEditor();
+        $page = Page::first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<button id="xss"');
+            $pageView->assertElementNotContains('.page-content', '<input id="xss"');
+            $pageView->assertElementNotContains('.page-content', '<form id="xss"');
+            $pageView->assertElementNotContains('.page-content', 'action=javascript:');
+            $pageView->assertElementNotContains('.page-content', 'formaction=javascript:');
+        }
+    }
+    
+    public function test_metadata_redirects_are_removed()
+    {
+        $checks = [
+            '<meta http-equiv="refresh" content="0; url=//external_url">',
+        ];
+
+        $this->asEditor();
+        $page = Page::first();
+
+        foreach ($checks as $check) {
+            $page->html = $check;
+            $page->save();
+
+            $pageView = $this->get($page->getUrl());
+            $pageView->assertStatus(200);
+            $pageView->assertElementNotContains('.page-content', '<meta>');
+            $pageView->assertElementNotContains('.page-content', '</meta>');
+            $pageView->assertElementNotContains('.page-content', 'content=');
+            $pageView->assertElementNotContains('.page-content', 'external_url');
+        }
+    }
     public function test_page_inline_on_attributes_removed_by_default()
     {
         $this->asEditor();
@@ -262,6 +328,23 @@ class PageContentTest extends TestCase
         $this->assertEquals(substr_count($updatedPage->html, "bkmrk-test\""), 1);
     }
 
+    public function test_anchors_referencing_non_bkmrk_ids_rewritten_after_save()
+    {
+        $this->asEditor();
+        $page = Page::first();
+
+        $content = '<h1 id="non-standard-id">test</h1><p><a href="#non-standard-id">link</a></p>';
+        $this->put($page->getUrl(), [
+            'name' => $page->name,
+            'html' => $content,
+            'summary' => ''
+        ]);
+
+        $updatedPage = Page::where('id', '=', $page->id)->first();
+        $this->assertStringContainsString('id="bkmrk-test"', $updatedPage->html);
+        $this->assertStringContainsString('href="#bkmrk-test"', $updatedPage->html);
+    }
+
     public function test_get_page_nav_sets_correct_properties()
     {
         $content = '<h1 id="testa">Hello</h1><h2 id="testb">There</h2><h3 id="testc">Donkey</h3>';
index 7d6c1831afdd0eeab6ecceb36b3b5f8127f61981..a43a65e5865c16744eddf636fa208ccce054176f 100644 (file)
@@ -58,7 +58,7 @@ class RestrictionsTest extends BrowserKitTest
 
     public function test_bookshelf_update_restriction()
     {
-        $shelf = BookShelf::first();
+        $shelf = Bookshelf::first();
 
         $this->actingAs($this->user)
             ->visit($shelf->getUrl('/edit'))
index 4614c8e22489b7eaec565aaa2649772a0344d868..5b73aa6ae1a2d925ac9c0991d8e57fecc837b3fc 100644 (file)
@@ -5,39 +5,51 @@ use BookStack\Entities\Repos\PageRepo;
 use BookStack\Uploads\Attachment;
 use BookStack\Entities\Page;
 use BookStack\Auth\Permissions\PermissionService;
+use BookStack\Uploads\AttachmentService;
+use Illuminate\Http\UploadedFile;
 use Tests\TestCase;
+use Tests\TestResponse;
 
 class AttachmentTest extends TestCase
 {
     /**
      * Get a test file that can be uploaded
-     * @param $fileName
-     * @return \Illuminate\Http\UploadedFile
      */
-    protected function getTestFile($fileName)
+    protected function getTestFile(string $fileName): UploadedFile
     {
-        return new \Illuminate\Http\UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
+        return new UploadedFile(base_path('tests/test-data/test-file.txt'), $fileName, 'text/plain', 55, null, true);
     }
 
     /**
      * Uploads a file with the given name.
-     * @param $name
-     * @param int $uploadedTo
-     * @return \Illuminate\Foundation\Testing\TestResponse
      */
-    protected function uploadFile($name, $uploadedTo = 0)
+    protected function uploadFile(string $name, int $uploadedTo = 0): \Illuminate\Foundation\Testing\TestResponse
     {
         $file = $this->getTestFile($name);
         return $this->call('POST', '/attachments/upload', ['uploaded_to' => $uploadedTo], [], ['file' => $file], []);
     }
 
+    /**
+     * Create a new attachment
+     */
+    protected function createAttachment(Page $page): Attachment
+    {
+        $this->post('attachments/link', [
+            'attachment_link_url' => 'https://example.com',
+            'attachment_link_name' => 'Example Attachment Link',
+            'attachment_link_uploaded_to' => $page->id,
+        ]);
+
+        return Attachment::query()->latest()->first();
+    }
+
     /**
      * Delete all uploaded files.
      * To assist with cleanup.
      */
     protected function deleteUploads()
     {
-        $fileService = $this->app->make(\BookStack\Uploads\AttachmentService::class);
+        $fileService = $this->app->make(AttachmentService::class);
         foreach (Attachment::all() as $file) {
             $fileService->deleteFile($file);
         }
@@ -147,21 +159,14 @@ class AttachmentTest extends TestCase
         $page = Page::first();
         $this->asAdmin();
 
-        $this->call('POST', 'attachments/link', [
-            'attachment_link_url' => 'https://example.com',
-            'attachment_link_name' => 'Example Attachment Link',
-            'attachment_link_uploaded_to' => $page->id,
-        ]);
-
-        $attachmentId = Attachment::first()->id;
-
-        $update = $this->call('PUT', 'attachments/' . $attachmentId, [
+        $attachment = $this->createAttachment($page);
+        $update = $this->call('PUT', 'attachments/' . $attachment->id, [
             'attachment_edit_name' => 'My new attachment name',
             'attachment_edit_url' => 'https://test.example.com'
         ]);
 
         $expectedData = [
-            'id' => $attachmentId,
+            'id' => $attachment->id,
             'path' => 'https://test.example.com',
             'name' => 'My new attachment name',
             'uploaded_to' => $page->id
@@ -245,4 +250,45 @@ class AttachmentTest extends TestCase
 
         $this->deleteUploads();
     }
+
+    public function test_data_and_js_links_cannot_be_attached_to_a_page()
+    {
+        $page = Page::first();
+        $this->asAdmin();
+
+        $badLinks = [
+            'javascript:alert("bunny")',
+            ' javascript:alert("bunny")',
+            'JavaScript:alert("bunny")',
+            "\t\n\t\nJavaScript:alert(\"bunny\")",
+            "data:text/html;<a></a>",
+            "Data:text/html;<a></a>",
+            "Data:text/html;<a></a>",
+        ];
+
+        foreach ($badLinks as $badLink) {
+            $linkReq = $this->post('attachments/link', [
+                'attachment_link_url' => $badLink,
+                'attachment_link_name' => 'Example Attachment Link',
+                'attachment_link_uploaded_to' => $page->id,
+            ]);
+            $linkReq->assertStatus(422);
+            $this->assertDatabaseMissing('attachments', [
+                'path' => $badLink,
+            ]);
+        }
+
+        $attachment = $this->createAttachment($page);
+
+        foreach ($badLinks as $badLink) {
+            $linkReq = $this->put('attachments/' . $attachment->id, [
+                'attachment_edit_url' => $badLink,
+                'attachment_edit_name' => 'Example Attachment Link',
+            ]);
+            $linkReq->assertStatus(422);
+            $this->assertDatabaseMissing('attachments', [
+                'path' => $badLink,
+            ]);
+        }
+    }
 }
index f940a0a5d9c9ee97c3f3e14808108c6efbe01260..3fc009c8ab11b7fd58a447d349a74f08dbdfd0a4 100644 (file)
@@ -69,7 +69,7 @@ class DrawioTest extends TestCase
         $editor = $this->getEditor();
 
         $resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
-        $resp->assertSee('drawio-url="https://www.draw.io/?embed=1&amp;proto=json&amp;spin=1"');
+        $resp->assertSee('drawio-url="https://embed.diagrams.net/?embed=1&amp;proto=json&amp;spin=1"');
 
         config()->set('services.drawio', false);
         $resp = $this->actingAs($editor)->get($page->getUrl('/edit'));
index f738eb579e4f9a836bc7f818e7de39e59a78ace9..c89a590f0dafc5539fc58884657a8f4c9320a7c8 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace Test\User;
+<?php namespace Tests\User;
 
 use BookStack\Api\ApiToken;
 use Carbon\Carbon;
index 0db4f803aff0bf268f4004b2339280dba10cc3fd..7ffc8f9db7085958b9ef5e5a80bc4bfed22a3022 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace Test\User;
+<?php namespace Tests\User;
 
 use Tests\TestCase;
 
index 0a3a1a6b202fcf776457f9478ddba01e39cbd76b..b564ed8c235a55e42d19d06cef4b8f2e199038aa 100644 (file)
@@ -1,4 +1,4 @@
-<?php namespace Test\User;
+<?php namespace Tests\User;
 
 use Activity;
 use BookStack\Auth\User;