]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'v0.31.x'
authorDan Brown <redacted>
Tue, 2 Mar 2021 21:43:30 +0000 (21:43 +0000)
committerDan Brown <redacted>
Tue, 2 Mar 2021 21:43:30 +0000 (21:43 +0000)
92 files changed:
.env.example.complete
app/Auth/Access/Ldap.php
app/Auth/Access/LdapService.php
app/Config/app.php
app/Config/services.php
app/Config/setting-defaults.php
app/Console/Commands/UpdateUrl.php
app/Entities/Repos/PageRepo.php
app/Facades/Setting.php [deleted file]
app/Http/Controllers/BookController.php
app/Http/Controllers/BookshelfController.php
app/Http/Controllers/HomeController.php
app/Http/Controllers/StatusController.php [new file with mode: 0644]
app/Http/Kernel.php
app/Http/Middleware/GlobalViewData.php [deleted file]
app/Http/Middleware/Localization.php
app/Providers/AppServiceProvider.php
app/Providers/CustomFacadeProvider.php
app/Settings/SettingService.php
app/helpers.php
composer.json
composer.lock
database/migrations/2021_01_30_225441_add_settings_type_column.php [new file with mode: 0644]
docker-compose.yml
phpunit.xml
readme.md
resources/js/components/entity-selector.js
resources/js/components/markdown-editor.js
resources/js/components/page-display.js
resources/js/components/wysiwyg-editor.js
resources/js/services/code.js
resources/lang/en/common.php
resources/lang/en/settings.php
resources/sass/_footer.scss [new file with mode: 0644]
resources/sass/_header.scss
resources/sass/_html.scss
resources/sass/_layout.scss
resources/sass/_lists.scss
resources/sass/_tinymce.scss
resources/sass/export-styles.scss
resources/sass/styles.scss
resources/views/base.blade.php
resources/views/books/index.blade.php
resources/views/books/list-item.blade.php
resources/views/common/footer.blade.php [new file with mode: 0644]
resources/views/common/header.blade.php
resources/views/common/home-book.blade.php
resources/views/common/home-shelves.blade.php
resources/views/common/home-sidebar.blade.php
resources/views/common/home.blade.php
resources/views/components/entity-selector.blade.php
resources/views/components/expand-toggle.blade.php
resources/views/form/password.blade.php
resources/views/pages/markdown-editor.blade.php
resources/views/pages/move.blade.php
resources/views/partials/entity-export-meta.blade.php
resources/views/partials/sort.blade.php
resources/views/partials/view-toggle.blade.php
resources/views/settings/footer-links.blade.php [new file with mode: 0644]
resources/views/settings/index.blade.php
resources/views/settings/navbar.blade.php
resources/views/settings/roles/form.blade.php
resources/views/shelves/index.blade.php
resources/views/users/create.blade.php
resources/views/users/edit.blade.php
resources/views/users/form.blade.php
resources/views/users/index.blade.php
routes/web.php
tests/Auth/LdapTest.php
tests/Commands/AddAdminCommandTest.php [new file with mode: 0644]
tests/Commands/ClearActivityCommandTest.php [new file with mode: 0644]
tests/Commands/ClearRevisionsCommandTest.php [new file with mode: 0644]
tests/Commands/ClearViewsCommandTest.php [new file with mode: 0644]
tests/Commands/CopyShelfPermissionsCommandTest.php [new file with mode: 0644]
tests/Commands/RegenerateCommentContentCommandTest.php [new file with mode: 0644]
tests/Commands/RegeneratePermissionsCommandTest.php [new file with mode: 0644]
tests/Commands/UpdateUrlCommandTest.php [new file with mode: 0644]
tests/CommandsTest.php [deleted file]
tests/Entity/BookShelfTest.php
tests/Entity/ExportTest.php
tests/FooterLinksTest.php [new file with mode: 0644]
tests/StatusTest.php [new file with mode: 0644]
tests/Uploads/ImageTest.php
tests/Uploads/UsesImages.php
tests/User/UserPreferencesTest.php
tests/test-data/bad-php.base64 [new file with mode: 0644]
tests/test-data/bad-phtml-png.base64 [new file with mode: 0644]
tests/test-data/bad-phtml.base64 [new file with mode: 0644]
tests/test-data/bad.php [deleted file]
tests/test-data/bad.phtml [deleted file]
tests/test-data/bad.phtml.png [deleted file]
version

index e3dbdb857ddde1c7393e944a17ab9bea0f6b7104..a42054b6b6147f5bf97b2f7be068ff6b77986d3b 100644 (file)
@@ -195,6 +195,7 @@ LDAP_DN=false
 LDAP_PASS=false
 LDAP_USER_FILTER=false
 LDAP_VERSION=false
+LDAP_START_TLS=false
 LDAP_TLS_INSECURE=false
 LDAP_ID_ATTRIBUTE=uid
 LDAP_EMAIL_ATTRIBUTE=mail
@@ -245,10 +246,15 @@ AVATAR_URL=
 DRAWIO=true
 
 # Default item listing view
-# Used for public visitors and user's without a preference
-# Can be 'list' or 'grid'
+# Used for public visitors and user's without a preference.
+# Can be 'list' or 'grid'.
 APP_VIEWS_BOOKS=list
 APP_VIEWS_BOOKSHELVES=grid
+APP_VIEWS_BOOKSHELF=grid
+
+# Use dark mode by default
+# Will be overriden by any user/session preference.
+APP_DEFAULT_DARK_MODE=false
 
 # Page revision limit
 # Number of page revisions to keep in the system before deleting old revisions.
index 6b7bd9b9bf2a4d4699a74609ab507d33cc72f98d..352231df54a8e3633a68fbcacb52fc07c332f92f 100644 (file)
@@ -31,6 +31,14 @@ class Ldap
         return ldap_set_option($ldapConnection, $option, $value);
     }
 
+    /**
+     * Start TLS on the given LDAP connection.
+     */
+    public function startTls($ldapConnection): bool
+    {
+        return ldap_start_tls($ldapConnection);
+    }
+
     /**
      * Set the version number for the given ldap connection.
      * @param $ldapConnection
index 92234edcf906bc2934a47443af011de388d2f853..a438c098490586f44f06b607ff15892e726b60d9 100644 (file)
@@ -85,9 +85,9 @@ class LdapService extends ExternalAuthService
 
         $userCn = $this->getUserResponseProperty($user, 'cn', null);
         $formatted = [
-            'uid'   => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
-            'name'  => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
-            'dn'    => $user['dn'],
+            'uid' => $this->getUserResponseProperty($user, $idAttr, $user['dn']),
+            'name' => $this->getUserResponseProperty($user, $displayNameAttr, $userCn),
+            'dn' => $user['dn'],
             'email' => $this->getUserResponseProperty($user, $emailAttr, null),
         ];
 
@@ -187,8 +187,8 @@ class LdapService extends ExternalAuthService
             throw new LdapException(trans('errors.ldap_extension_not_installed'));
         }
 
-         // Check if TLS_INSECURE is set. The handle is set to NULL due to the nature of
-         // the LDAP_OPT_X_TLS_REQUIRE_CERT option. It can only be set globally and not per handle.
+        // Disable certificate verification.
+        // This option works globally and must be set before a connection is created.
         if ($this->config['tls_insecure']) {
             $this->ldap->setOption(null, LDAP_OPT_X_TLS_REQUIRE_CERT, LDAP_OPT_X_TLS_NEVER);
         }
@@ -205,6 +205,14 @@ class LdapService extends ExternalAuthService
             $this->ldap->setVersion($ldapConnection, $this->config['version']);
         }
 
+        // Start and verify TLS if it's enabled
+        if ($this->config['start_tls']) {
+            $started = $this->ldap->startTls($ldapConnection);
+            if (!$started) {
+                throw new LdapException('Could not start TLS connection');
+            }
+        }
+
         $this->ldapConnection = $ldapConnection;
         return $this->ldapConnection;
     }
index 762845e9f2bb681d47504c66a72cf8d37d49cc85..ea9738da443814a7499e946bf22b8f7a03b7aaed 100755 (executable)
@@ -19,13 +19,6 @@ return [
     // private configuration variables so should remain disabled in public.
     'debug' => env('APP_DEBUG', false),
 
-    // Set the default view type for various lists. Can be overridden by user preferences.
-    // These will be used for public viewers and users that have not set a preference.
-    'views' => [
-        'books' => env('APP_VIEWS_BOOKS', 'list'),
-        'bookshelves' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
-    ],
-
     // The number of revisions to keep in the database.
     // Once this limit is reached older revisions will be deleted.
     // If set to false then a limit will not be enforced.
@@ -190,7 +183,6 @@ return [
 
         // Custom BookStack
         'Activity' => BookStack\Facades\Activity::class,
-        'Setting'  => BookStack\Facades\Setting::class,
         'Views'    => BookStack\Facades\Views::class,
         'Images'   => BookStack\Facades\Images::class,
         'Permissions' => BookStack\Facades\Permissions::class,
index fcde621d2b5296bd9239085adc6409d7d39784a3..6993396147af9b02eec0a778c81b7bb83a6764c3 100644 (file)
@@ -132,6 +132,7 @@ return [
         'group_attribute' => env('LDAP_GROUP_ATTRIBUTE', 'memberOf'),
         'remove_from_groups' => env('LDAP_REMOVE_FROM_GROUPS', false),
         'tls_insecure' => env('LDAP_TLS_INSECURE', false),
+        'start_tls' => env('LDAP_START_TLS', false),
     ],
 
 ];
index d84c0c2641397700da7b551414122c66e88fa24b..879c636bcda2c2f4961cf3687fccf3f4eb0860b7 100644 (file)
@@ -24,4 +24,12 @@ return [
     'app-custom-head'      => false,
     'registration-enabled' => false,
 
+    // User-level default settings
+    'user' => [
+        'dark-mode-enabled' => env('APP_DEFAULT_DARK_MODE', false),
+        'bookshelves_view_type' => env('APP_VIEWS_BOOKSHELVES', 'grid'),
+        'bookshelf_view_type' =>env('APP_VIEWS_BOOKSHELF', 'grid'),
+        'books_view_type' => env('APP_VIEWS_BOOKS', 'grid'),
+    ],
+
 ];
index b95e277d176f6ba2f50779dd82bdd3badd6c9cb3..2a16884680729e3dc502909cdc71d76addb70177 100644 (file)
@@ -4,6 +4,7 @@ namespace BookStack\Console\Commands;
 
 use Illuminate\Console\Command;
 use Illuminate\Database\Connection;
+use Illuminate\Support\Facades\DB;
 
 class UpdateUrl extends Command
 {
@@ -60,22 +61,50 @@ class UpdateUrl extends Command
             "attachments" => ["path"],
             "pages" => ["html", "text", "markdown"],
             "images" => ["url"],
+            "settings" => ["value"],
             "comments" => ["html", "text"],
         ];
 
         foreach ($columnsToUpdateByTable as $table => $columns) {
             foreach ($columns as $column) {
-                $changeCount = $this->db->table($table)->update([
-                    $column => $this->db->raw("REPLACE({$column}, '{$oldUrl}', '{$newUrl}')")
-                ]);
+                $changeCount = $this->replaceValueInTable($table, $column, $oldUrl, $newUrl);
                 $this->info("Updated {$changeCount} rows in {$table}->{$column}");
             }
         }
 
+        $jsonColumnsToUpdateByTable = [
+            "settings" => ["value"],
+        ];
+
+        foreach ($jsonColumnsToUpdateByTable as $table => $columns) {
+            foreach ($columns as $column) {
+                $oldJson = trim(json_encode($oldUrl), '"');
+                $newJson = trim(json_encode($newUrl), '"');
+                $changeCount = $this->replaceValueInTable($table, $column, $oldJson, $newJson);
+                $this->info("Updated {$changeCount} JSON encoded rows in {$table}->{$column}");
+            }
+        }
+
         $this->info("URL update procedure complete.");
+        $this->info('============================================================================');
+        $this->info('Be sure to run "php artisan cache:clear" to clear any old URLs in the cache.');
+        $this->info('============================================================================');
         return 0;
     }
 
+    /**
+     * Perform a find+replace operations in the provided table and column.
+     * Returns the count of rows changed.
+     */
+    protected function replaceValueInTable(string $table, string $column, string $oldUrl, string $newUrl): int
+    {
+        $oldQuoted = $this->db->getPdo()->quote($oldUrl);
+        $newQuoted = $this->db->getPdo()->quote($newUrl);
+        return $this->db->table($table)->update([
+            $column => $this->db->raw("REPLACE({$column}, {$oldQuoted}, {$newQuoted})")
+        ]);
+    }
+
     /**
      * Warn the user of the dangers of this operation.
      * Returns a boolean indicating if they've accepted the warnings.
index bc6476824bee48932325e214c00c1486fa2ea981..6a4eaeb1553f834ccafdd3e6b642d160e96d81d3 100644 (file)
@@ -190,11 +190,11 @@ class PageRepo
         $this->getUserDraftQuery($page)->delete();
 
         // Save a revision after updating
-        $summary = $input['summary'] ?? null;
+        $summary = trim($input['summary'] ?? "");
         $htmlChanged = isset($input['html']) && $input['html'] !== $oldHtml;
         $nameChanged = isset($input['name']) && $input['name'] !== $oldName;
         $markdownChanged = isset($input['markdown']) && $input['markdown'] !== $oldMarkdown;
-        if ($htmlChanged || $nameChanged || $markdownChanged || $summary !== null) {
+        if ($htmlChanged || $nameChanged || $markdownChanged || $summary) {
             $this->savePageRevision($page, $summary);
         }
 
diff --git a/app/Facades/Setting.php b/app/Facades/Setting.php
deleted file mode 100644 (file)
index 80feef8..0000000
+++ /dev/null
@@ -1,16 +0,0 @@
-<?php namespace BookStack\Facades;
-
-use Illuminate\Support\Facades\Facade;
-
-class Setting extends Facade
-{
-    /**
-     * Get the registered name of the component.
-     *
-     * @return string
-     */
-    protected static function getFacadeAccessor()
-    {
-        return 'setting';
-    }
-}
index 3d695ba85dbdae692f35d24e429e4fc553effbd5..59c205d0a9241770e6fd84c8d09e0f5f196cfb3b 100644 (file)
@@ -30,7 +30,7 @@ class BookController extends Controller
      */
     public function index()
     {
-        $view = setting()->getForCurrentUser('books_view_type', config('app.views.books'));
+        $view = setting()->getForCurrentUser('books_view_type');
         $sort = setting()->getForCurrentUser('books_sort', 'name');
         $order = setting()->getForCurrentUser('books_sort_order', 'asc');
 
index 32c22e185fa20116cabc0d3221d621c0e3f70d12..8574c1b48589970039ccf36aee51a4c85b0d3aaa 100644 (file)
@@ -32,7 +32,7 @@ class BookshelfController extends Controller
      */
     public function index()
     {
-        $view = setting()->getForCurrentUser('bookshelves_view_type', config('app.views.bookshelves', 'grid'));
+        $view = setting()->getForCurrentUser('bookshelves_view_type');
         $sort = setting()->getForCurrentUser('bookshelves_sort', 'name');
         $order = setting()->getForCurrentUser('bookshelves_sort_order', 'asc');
         $sortOptions = [
@@ -103,7 +103,7 @@ class BookshelfController extends Controller
 
         Views::add($shelf);
         $this->entityContextManager->setShelfContext($shelf->id);
-        $view = setting()->getForCurrentUser('bookshelf_view_type', config('app.views.books'));
+        $view = setting()->getForCurrentUser('bookshelf_view_type');
 
         $this->setPageTitle($shelf->getShortName());
         return view('shelves.show', [
index d97740d2725f01d8bedda10e687fd915d5e08deb..31736e1b09ab92bcb778c9d61dd928e3dbfa884a 100644 (file)
@@ -56,7 +56,7 @@ class HomeController extends Controller
         // Add required list ordering & sorting for books & shelves views.
         if ($homepageOption === 'bookshelves' || $homepageOption === 'books') {
             $key = $homepageOption;
-            $view = setting()->getForCurrentUser($key . '_view_type', config('app.views.' . $key));
+            $view = setting()->getForCurrentUser($key . '_view_type');
             $sort = setting()->getForCurrentUser($key . '_sort', 'name');
             $order = setting()->getForCurrentUser($key . '_sort_order', 'asc');
 
@@ -110,15 +110,16 @@ class HomeController extends Controller
 
     /**
      * Show the view for /robots.txt
-     * @return $this
      */
     public function getRobots()
     {
         $sitePublic = setting('app-public', false);
         $allowRobots = config('app.allow_robots');
+
         if ($allowRobots === null) {
             $allowRobots = $sitePublic;
         }
+
         return response()
             ->view('common.robots', ['allowRobots' => $allowRobots])
             ->header('Content-Type', 'text/plain');
diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php
new file mode 100644 (file)
index 0000000..9f4ed4d
--- /dev/null
@@ -0,0 +1,47 @@
+<?php namespace BookStack\Http\Controllers;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Session;
+use Illuminate\Support\Str;
+
+class StatusController extends Controller
+{
+
+    /**
+     * Show the system status as a simple json page.
+     */
+    public function show()
+    {
+        $statuses = [
+            'database' => $this->trueWithoutError(function () {
+                return DB::table('migrations')->count() > 0;
+            }),
+            'cache' => $this->trueWithoutError(function () {
+                $rand = Str::random();
+                Cache::set('status_test', $rand);
+                return Cache::get('status_test') === $rand;
+            }),
+            'session' => $this->trueWithoutError(function () {
+                $rand = Str::random();
+                Session::put('status_test', $rand);
+                return Session::get('status_test') === $rand;
+            }),
+        ];
+
+        $hasError = in_array(false, $statuses);
+        return response()->json($statuses, $hasError ? 500 : 200);
+    }
+
+    /**
+     * Check the callable passed returns true and does not throw an exception.
+     */
+    protected function trueWithoutError(callable $test): bool
+    {
+        try {
+            return $test() === true;
+        } catch (\Exception $e) {
+            return false;
+        }
+    }
+}
index 532942f23e5b8f4b396fdd6601c002ddcf4390fd..075c98ec77b7509087d1f66a2981d7ed26a86328 100644 (file)
@@ -29,7 +29,6 @@ class Kernel extends HttpKernel
             \Illuminate\View\Middleware\ShareErrorsFromSession::class,
             \BookStack\Http\Middleware\VerifyCsrfToken::class,
             \BookStack\Http\Middleware\Localization::class,
-            \BookStack\Http\Middleware\GlobalViewData::class,
         ],
         'api' => [
             \BookStack\Http\Middleware\ThrottleApiRequests::class,
diff --git a/app/Http/Middleware/GlobalViewData.php b/app/Http/Middleware/GlobalViewData.php
deleted file mode 100644 (file)
index bc132df..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php namespace BookStack\Http\Middleware;
-
-use Closure;
-use Illuminate\Http\Request;
-
-/**
- * Class GlobalViewData
- * Sets up data that is accessible to any view rendered by the web routes.
- */
-class GlobalViewData
-{
-
-    /**
-     * Handle an incoming request.
-     *
-     * @param Request $request
-     * @param Closure $next
-     * @return mixed
-     */
-    public function handle(Request $request, Closure $next)
-    {
-        view()->share('signedIn', auth()->check());
-        view()->share('currentUser', user());
-
-        return $next($request);
-    }
-}
index 6a8ec237dd0b47b5a3be5cb7272dc456fecd33f7..597d2836548286ac8afb20f5677119f5b8ca2be3 100644 (file)
@@ -57,12 +57,7 @@ class Localization
         $defaultLang = config('app.locale');
         config()->set('app.default_locale', $defaultLang);
 
-        if (user()->isDefault() && config('app.auto_detect_locale')) {
-            $locale = $this->autoDetectLocale($request, $defaultLang);
-        } else {
-            $locale = setting()->getUser(user(), 'language', $defaultLang);
-        }
-
+        $locale = $this->getUserLocale($request, $defaultLang);
         config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
 
         // Set text direction
@@ -76,14 +71,29 @@ class Localization
         return $next($request);
     }
 
+    /**
+     * Get the locale specifically for the currently logged in user if available.
+     */
+    protected function getUserLocale(Request $request, string $default): string
+    {
+        try {
+            $user = user();
+        } catch (\Exception $exception) {
+            return $default;
+        }
+
+        if ($user->isDefault() && config('app.auto_detect_locale')) {
+            return $this->autoDetectLocale($request, $default);
+        }
+
+        return setting()->getUser($user, 'language', $default);
+    }
+
     /**
      * Autodetect the visitors locale by matching locales in their headers
      * against the locales supported by BookStack.
-     * @param Request $request
-     * @param string $default
-     * @return string
      */
-    protected function autoDetectLocale(Request $request, string $default)
+    protected function autoDetectLocale(Request $request, string $default): string
     {
         $availableLocales = config('app.locales');
         foreach ($request->getLanguages() as $lang) {
@@ -96,10 +106,8 @@ class Localization
 
     /**
      * Get the ISO version of a BookStack language name
-     * @param  string $locale
-     * @return string
      */
-    public function getLocaleIso(string $locale)
+    public function getLocaleIso(string $locale): string
     {
         return $this->localeMap[$locale] ?? $locale;
     }
@@ -107,7 +115,6 @@ class Localization
     /**
      * Set the system date locale for localized date formatting.
      * Will try both the standard locale name and the UTF8 variant.
-     * @param string $locale
      */
     protected function setSystemDateLocale(string $locale)
     {
index 1c6180a1f4b3c3329bbb28632b4bca076cd19b8e..7673050f8f51bd8d1105ac3a4c6f5e79f7a25885 100644 (file)
@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Settings\Setting;
 use BookStack\Settings\SettingService;
+use Illuminate\Contracts\Cache\Repository;
 use Illuminate\Database\Eloquent\Relations\Relation;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
@@ -59,7 +60,7 @@ class AppServiceProvider extends ServiceProvider
     public function register()
     {
         $this->app->singleton(SettingService::class, function ($app) {
-            return new SettingService($app->make(Setting::class), $app->make('Illuminate\Contracts\Cache\Repository'));
+            return new SettingService($app->make(Setting::class), $app->make(Repository::class));
         });
     }
 }
index b4158187cd5fe5225b68aa013d033d67d05edb30..0918c0aba9f594adb90eff4f29ca71b459b4b9f1 100644 (file)
@@ -5,7 +5,6 @@ namespace BookStack\Providers;
 use BookStack\Actions\ActivityService;
 use BookStack\Actions\ViewService;
 use BookStack\Auth\Permissions\PermissionService;
-use BookStack\Settings\SettingService;
 use BookStack\Uploads\ImageService;
 use Illuminate\Support\ServiceProvider;
 
@@ -36,10 +35,6 @@ class CustomFacadeProvider extends ServiceProvider
             return $this->app->make(ViewService::class);
         });
 
-        $this->app->singleton('setting', function () {
-            return $this->app->make(SettingService::class);
-        });
-
         $this->app->singleton('images', function () {
             return $this->app->make(ImageService::class);
         });
index 1c053b3848ea779d480adea01834dce0059d629d..feb54c30a6d95d82d5eb28b195d05da47180acdd 100644 (file)
@@ -1,5 +1,6 @@
 <?php namespace BookStack\Settings;
 
+use BookStack\Auth\User;
 use Illuminate\Contracts\Cache\Repository as Cache;
 
 /**
@@ -9,7 +10,6 @@ use Illuminate\Contracts\Cache\Repository as Cache;
  */
 class SettingService
 {
-
     protected $setting;
     protected $cache;
     protected $localCache = [];
@@ -18,8 +18,6 @@ class SettingService
 
     /**
      * SettingService constructor.
-     * @param Setting $setting
-     * @param Cache   $cache
      */
     public function __construct(Setting $setting, Cache $cache)
     {
@@ -30,13 +28,10 @@ class SettingService
     /**
      * Gets a setting from the database,
      * If not found, Returns default, Which is false by default.
-     * @param             $key
-     * @param string|bool $default
-     * @return bool|string
      */
-    public function get($key, $default = false)
+    public function get(string $key, $default = null)
     {
-        if ($default === false) {
+        if (is_null($default)) {
             $default = config('setting-defaults.' . $key, false);
         }
 
@@ -44,7 +39,7 @@ class SettingService
             return $this->localCache[$key];
         }
 
-        $value = $this->getValueFromStore($key, $default);
+        $value = $this->getValueFromStore($key) ?? $default;
         $formatted = $this->formatValue($value, $default);
         $this->localCache[$key] = $formatted;
         return $formatted;
@@ -52,26 +47,22 @@ class SettingService
 
     /**
      * Get a value from the session instead of the main store option.
-     * @param $key
-     * @param bool $default
-     * @return mixed
      */
-    protected function getFromSession($key, $default = false)
+    protected function getFromSession(string $key, $default = false)
     {
         $value = session()->get($key, $default);
-        $formatted = $this->formatValue($value, $default);
-        return $formatted;
+        return $this->formatValue($value, $default);
     }
 
     /**
      * Get a user-specific setting from the database or cache.
-     * @param \BookStack\Auth\User $user
-     * @param $key
-     * @param bool $default
-     * @return bool|string
      */
-    public function getUser($user, $key, $default = false)
+    public function getUser(User $user, string $key, $default = null)
     {
+        if (is_null($default)) {
+            $default = config('setting-defaults.user.' . $key, false);
+        }
+
         if ($user->isDefault()) {
             return $this->getFromSession($key, $default);
         }
@@ -80,11 +71,8 @@ class SettingService
 
     /**
      * Get a value for the current logged-in user.
-     * @param $key
-     * @param bool $default
-     * @return bool|string
      */
-    public function getForCurrentUser($key, $default = false)
+    public function getForCurrentUser(string $key, $default = null)
     {
         return $this->getUser(user(), $key, $default);
     }
@@ -92,11 +80,9 @@ class SettingService
     /**
      * Gets a setting value from the cache or database.
      * Looks at the system defaults if not cached or in database.
-     * @param $key
-     * @param $default
-     * @return mixed
+     * Returns null if nothing is found.
      */
-    protected function getValueFromStore($key, $default)
+    protected function getValueFromStore(string $key)
     {
         // Check the cache
         $cacheKey = $this->cachePrefix . $key;
@@ -109,18 +95,22 @@ class SettingService
         $settingObject = $this->getSettingObjectByKey($key);
         if ($settingObject !== null) {
             $value = $settingObject->value;
+
+            if ($settingObject->type === 'array') {
+                $value = json_decode($value, true) ?? [];
+            }
+
             $this->cache->forever($cacheKey, $value);
             return $value;
         }
 
-        return $default;
+        return null;
     }
 
     /**
      * Clear an item from the cache completely.
-     * @param $key
      */
-    protected function clearFromCache($key)
+    protected function clearFromCache(string $key)
     {
         $cacheKey = $this->cachePrefix . $key;
         $this->cache->forget($cacheKey);
@@ -131,17 +121,13 @@ class SettingService
 
     /**
      * Format a settings value
-     * @param $value
-     * @param $default
-     * @return mixed
      */
     protected function formatValue($value, $default)
     {
         // Change string booleans to actual booleans
         if ($value === 'true') {
             $value = true;
-        }
-        if ($value === 'false') {
+        } else if ($value === 'false') {
             $value = false;
         }
 
@@ -154,99 +140,97 @@ class SettingService
 
     /**
      * Checks if a setting exists.
-     * @param $key
-     * @return bool
      */
-    public function has($key)
+    public function has(string $key): bool
     {
         $setting = $this->getSettingObjectByKey($key);
         return $setting !== null;
     }
 
-    /**
-     * Check if a user setting is in the database.
-     * @param $key
-     * @return bool
-     */
-    public function hasUser($key)
-    {
-        return $this->has($this->userKey($key));
-    }
-
     /**
      * Add a setting to the database.
-     * @param $key
-     * @param $value
-     * @return bool
+     * Values can be an array or a string.
      */
-    public function put($key, $value)
+    public function put(string $key, $value): bool
     {
-        $setting = $this->setting->firstOrNew([
+        $setting = $this->setting->newQuery()->firstOrNew([
             'setting_key' => $key
         ]);
+        $setting->type = 'string';
+
+        if (is_array($value)) {
+            $setting->type = 'array';
+            $value = $this->formatArrayValue($value);
+        }
+
         $setting->value = $value;
         $setting->save();
         $this->clearFromCache($key);
         return true;
     }
 
+    /**
+     * Format an array to be stored as a setting.
+     * Array setting types are expected to be a flat array of child key=>value array items.
+     * This filters out any child items that are empty.
+     */
+    protected function formatArrayValue(array $value): string
+    {
+        $values = collect($value)->values()->filter(function(array $item) {
+            return count(array_filter($item)) > 0;
+        });
+        return json_encode($values);
+    }
+
     /**
      * Put a user-specific setting into the database.
-     * @param \BookStack\Auth\User $user
-     * @param $key
-     * @param $value
-     * @return bool
      */
-    public function putUser($user, $key, $value)
+    public function putUser(User $user, string $key, string $value): bool
     {
         if ($user->isDefault()) {
-            return session()->put($key, $value);
+            session()->put($key, $value);
+            return true;
         }
+
         return $this->put($this->userKey($user->id, $key), $value);
     }
 
     /**
      * Convert a setting key into a user-specific key.
-     * @param $key
-     * @return string
      */
-    protected function userKey($userId, $key = '')
+    protected function userKey(string $userId, string $key = ''): string
     {
         return 'user:' . $userId . ':' . $key;
     }
 
     /**
      * Removes a setting from the database.
-     * @param $key
-     * @return bool
      */
-    public function remove($key)
+    public function remove(string $key): void
     {
         $setting = $this->getSettingObjectByKey($key);
         if ($setting) {
             $setting->delete();
         }
         $this->clearFromCache($key);
-        return true;
     }
 
     /**
      * Delete settings for a given user id.
-     * @param $userId
-     * @return mixed
      */
-    public function deleteUserSettings($userId)
+    public function deleteUserSettings(string $userId)
     {
-        return $this->setting->where('setting_key', 'like', $this->userKey($userId) . '%')->delete();
+        return $this->setting->newQuery()
+            ->where('setting_key', 'like', $this->userKey($userId) . '%')
+            ->delete();
     }
 
     /**
      * Gets a setting model from the database for the given key.
-     * @param $key
-     * @return mixed
      */
-    protected function getSettingObjectByKey($key)
+    protected function getSettingObjectByKey(string $key): ?Setting
     {
-        return $this->setting->where('setting_key', '=', $key)->first();
+        return $this->setting->newQuery()
+            ->where('setting_key', '=', $key)->first();
     }
 }
index c090bfd055acc400e1ad4c46e7059b6c19820f60..c1d72b91da4fb7f5bb3efba3a2de46490ec3dce9 100644 (file)
@@ -79,9 +79,9 @@ function userCanOnAny(string $permission, string $entityClass = null): bool
 
 /**
  * Helper to access system settings.
- * @return bool|string|SettingService
+ * @return mixed|SettingService
  */
-function setting(string $key = null, $default = false)
+function setting(string $key = null, $default = null)
 {
     $settingService = resolve(SettingService::class);
 
index 68d33499bba0ea97926c293ced4e3ba91fb04d81..e6a833291db2db75a38b47a5cc0b2149fd33a78a 100644 (file)
@@ -31,7 +31,7 @@
         "socialiteproviders/okta": "^4.1",
         "socialiteproviders/slack": "^4.1",
         "socialiteproviders/twitch": "^5.3",
-        "ssddanbrown/htmldiff": "^1.0"
+        "ssddanbrown/htmldiff": "^v1.0.1"
     },
     "require-dev": {
         "barryvdh/laravel-debugbar": "^3.5.1",
index 8b8502052215bccc0e12e0bbc4bfa09b6c2d283d..7a8682ef62a0bc160b533ad81c654ab9aaee1ef3 100644 (file)
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "d73b8bdf23e32f109f052ac18e20458f",
+    "content-hash": "fc011a4d14d89014565f53626a0e331c",
     "packages": [
         {
             "name": "aws/aws-sdk-php",
diff --git a/database/migrations/2021_01_30_225441_add_settings_type_column.php b/database/migrations/2021_01_30_225441_add_settings_type_column.php
new file mode 100644 (file)
index 0000000..61d9bda
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddSettingsTypeColumn extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('settings', function (Blueprint $table) {
+            $table->string('type', 50)->default('string');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('settings', function (Blueprint $table) {
+            $table->dropColumn('type');
+        });
+    }
+}
index ea7a61ab554aea7540a28655d0e9c61822550f8a..39f5bdc18d11a997b5bfd71905ff1704966207a4 100644 (file)
@@ -39,6 +39,7 @@ services:
   node:
     image: node:alpine
     working_dir: /app
+    user: node
     volumes:
       - ./:/app
     entrypoint: /app/dev/docker/entrypoint.node.sh
index 8d69a5fddd3a8eee35101cceff94c6e3acd567ec..dd3e53c08f6a41b1e5b5b7ed85bd51c234d86c6a 100644 (file)
@@ -36,6 +36,7 @@
         <server name="AUTH_METHOD" value="standard"/>
         <server name="DISABLE_EXTERNAL_SERVICES" value="true"/>
         <server name="AVATAR_URL" value=""/>
+        <server name="LDAP_START_TLS" value="false"/>
         <server name="LDAP_VERSION" value="3"/>
         <server name="SESSION_SECURE_COOKIE" value="null"/>
         <server name="STORAGE_TYPE" value="local"/>
@@ -55,5 +56,7 @@
         <server name="API_REQUESTS_PER_MIN" value="180"/>
         <server name="LOG_FAILED_LOGIN_MESSAGE" value=""/>
         <server name="LOG_FAILED_LOGIN_CHANNEL" value="testing"/>
+        <server name="WKHTMLTOPDF" value="false"/>
+        <server name="APP_DEFAULT_DARK_MODE" value="false"/>
     </php>
 </phpunit>
index fd61a62c7611d72701f84277e02388371d65f3a7..a19341ce4e95dd7769871a137167e9fc90068ec1 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -4,7 +4,8 @@
 [![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
 [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
 [![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
-[![Discord](https://img.shields.io/static/v1?label=Chat&message=Discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
+[![Discord](https://img.shields.io/static/v1?label=chat&message=discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
+[![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
 
 A platform for storing and organising information and documentation. Details for BookStack can be found on the official website at https://www.bookstackapp.com/.
 
@@ -121,7 +122,7 @@ Feel free to create issues to request new features or to report bugs & problems.
 
 Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
 
-Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
+Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
 
 The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
 
@@ -169,3 +170,5 @@ These are the great open-source projects used to help build BookStack:
 * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
 * [diagrams.net](https://github.com/jgraph/drawio)
 * [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
+* [League/CommonMark](https://commonmark.thephpleague.com/)
+* [League/Flysystem](https://flysystem.thephpleague.com)
index 58879a20c0c5d8c76534e3af2bfc5a2da59c9e46..6d9d06f860329b402c9166db51ffc646537ca988 100644 (file)
@@ -1,22 +1,32 @@
+import {onChildEvent} from "../services/dom";
 
+/**
+ * Entity Selector
+ * @extends {Component}
+ */
 class EntitySelector {
 
-    constructor(elem) {
-        this.elem = elem;
+    setup() {
+        this.elem = this.$el;
+        this.entityTypes = this.$opts.entityTypes || 'page,book,chapter';
+        this.entityPermission = this.$opts.entityPermission || 'view';
+
+        this.input = this.$refs.input;
+        this.searchInput = this.$refs.search;
+        this.loading = this.$refs.loading;
+        this.resultsContainer = this.$refs.results;
+        this.addButton = this.$refs.add;
+
         this.search = '';
         this.lastClick = 0;
         this.selectedItemData = null;
 
-        const entityTypes = elem.hasAttribute('entity-types') ? elem.getAttribute('entity-types') : 'page,book,chapter';
-        const entityPermission = elem.hasAttribute('entity-permission') ? elem.getAttribute('entity-permission') : 'view';
-        this.searchUrl = window.baseUrl(`/ajax/search/entities?types=${encodeURIComponent(entityTypes)}&permission=${encodeURIComponent(entityPermission)}`);
-
-        this.input = elem.querySelector('[entity-selector-input]');
-        this.searchInput = elem.querySelector('[entity-selector-search]');
-        this.loading = elem.querySelector('[entity-selector-loading]');
-        this.resultsContainer = elem.querySelector('[entity-selector-results]');
-        this.addButton = elem.querySelector('[entity-selector-add-button]');
+        this.setupListeners();
+        this.showLoading();
+        this.initialLoad();
+    }
 
+    setupListeners() {
         this.elem.addEventListener('click', this.onClick.bind(this));
 
         let lastSearch = 0;
@@ -42,8 +52,39 @@ class EntitySelector {
             });
         }
 
-        this.showLoading();
-        this.initialLoad();
+        // Keyboard navigation
+        onChildEvent(this.$el, '[data-entity-type]', 'keydown', (e, el) => {
+            if (e.ctrlKey && e.code === 'Enter') {
+                const form = this.$el.closest('form');
+                if (form) {
+                    form.submit();
+                    e.preventDefault();
+                    return;
+                }
+            }
+
+            if (e.code === 'ArrowDown') {
+                this.focusAdjacent(true);
+            }
+            if (e.code === 'ArrowUp') {
+                this.focusAdjacent(false);
+            }
+        });
+
+        this.searchInput.addEventListener('keydown', e => {
+            if (e.code === 'ArrowDown') {
+                this.focusAdjacent(true);
+            }
+        })
+    }
+
+    focusAdjacent(forward = true) {
+        const items = Array.from(this.resultsContainer.querySelectorAll('[data-entity-type]'));
+        const selectedIndex = items.indexOf(document.activeElement);
+        const newItem = items[selectedIndex+ (forward ? 1 : -1)] || items[0];
+        if (newItem) {
+            newItem.focus();
+        }
     }
 
     showLoading() {
@@ -57,15 +98,19 @@ class EntitySelector {
     }
 
     initialLoad() {
-        window.$http.get(this.searchUrl).then(resp => {
+        window.$http.get(this.searchUrl()).then(resp => {
             this.resultsContainer.innerHTML = resp.data;
             this.hideLoading();
         })
     }
 
+    searchUrl() {
+        return `/ajax/search/entities?types=${encodeURIComponent(this.entityTypes)}&permission=${encodeURIComponent(this.entityPermission)}`;
+    }
+
     searchEntities(searchTerm) {
         this.input.value = '';
-        let url = `${this.searchUrl}&term=${encodeURIComponent(searchTerm)}`;
+        const url = `${this.searchUrl()}&term=${encodeURIComponent(searchTerm)}`;
         window.$http.get(url).then(resp => {
             this.resultsContainer.innerHTML = resp.data;
             this.hideLoading();
@@ -73,8 +118,8 @@ class EntitySelector {
     }
 
     isDoubleClick() {
-        let now = Date.now();
-        let answer = now - this.lastClick < 300;
+        const now = Date.now();
+        const answer = now - this.lastClick < 300;
         this.lastClick = now;
         return answer;
     }
@@ -123,8 +168,8 @@ class EntitySelector {
     }
 
     unselectAll() {
-        let selected = this.elem.querySelectorAll('.selected');
-        for (let selectedElem of selected) {
+        const selected = this.elem.querySelectorAll('.selected');
+        for (const selectedElem of selected) {
             selectedElem.classList.remove('selected', 'primary-background');
         }
         this.selectedItemData = null;
index bd107f2bf7a00f53ed3404a27c95bcf3c1c7d1c3..78581ec447f5cf099d64d681c976295d3c6875af 100644 (file)
@@ -22,7 +22,6 @@ class MarkdownEditor {
 
         this.displayStylesLoaded = false;
         this.input = this.elem.querySelector('textarea');
-        this.htmlInput = this.elem.querySelector('input[name=html]');
         this.cm = code.markdownEditor(this.input);
 
         this.onMarkdownScroll = this.onMarkdownScroll.bind(this);
@@ -125,7 +124,6 @@ class MarkdownEditor {
         // Set body content
         this.displayDoc.body.className = 'page-content';
         this.displayDoc.body.innerHTML = html;
-        this.htmlInput.value = html;
 
         // Copy styles from page head and set custom styles for editor
         this.loadStylesIntoDisplay();
index 2be1c1c48b8cc93f5ac147b64023cf910eabc3fa..cc55fe35e1e38caae9de9f9cca013e0c7b1d14f6 100644 (file)
@@ -12,6 +12,7 @@ class PageDisplay {
         Code.highlight();
         this.setupPointer();
         this.setupNavHighlighting();
+        this.setupDetailsCodeBlockRefresh();
 
         // Check the hash on load
         if (window.location.hash) {
@@ -196,6 +197,16 @@ class PageDisplay {
             });
         }
     }
+
+    setupDetailsCodeBlockRefresh() {
+        const onToggle = event => {
+            const codeMirrors = [...event.target.querySelectorAll('.CodeMirror')];
+            codeMirrors.forEach(cm => cm.CodeMirror && cm.CodeMirror.refresh());
+        };
+
+        const details = [...this.elem.querySelectorAll('details')];
+        details.forEach(detail => detail.addEventListener('toggle', onToggle));
+    }
 }
 
 export default PageDisplay;
index 41b2273e2a0ad48918e5e50c334f5c1e29ef319f..a6ab542181b9c20558bf0f030b98254181fc804c 100644 (file)
@@ -212,7 +212,7 @@ function codePlugin() {
             showPopup(editor);
         });
 
-        editor.on('SetContent', function () {
+        function parseCodeMirrorInstances() {
 
             // Recover broken codemirror instances
             $('.CodeMirrorContainer').filter((index ,elem) => {
@@ -231,6 +231,17 @@ function codePlugin() {
                     Code.wysiwygView(elem);
                 });
             });
+        }
+
+        editor.on('init', function() {
+            // Parse code mirror instances on init, but delay a little so this runs after
+            // initial styles are fetched into the editor.
+            parseCodeMirrorInstances();
+            // Parsed code mirror blocks when content is set but wait before setting this handler
+            // to avoid any init 'SetContent' events.
+            setTimeout(() => {
+                editor.on('SetContent', parseCodeMirrorInstances);
+            }, 200);
         });
 
     });
index e2aca1aad9e681d94a23763c95e550712e1bedfa..5727cd2b79a06f4e68b83eb68f1e1e34e2424300 100644 (file)
@@ -238,9 +238,7 @@ function wysiwygView(elem) {
         theme: getTheme(),
         readOnly: true
     });
-    setTimeout(() => {
-        cm.refresh();
-    }, 300);
+
     return {wrap: newWrap, editor: cm};
 }
 
index e87bd11a5e343173fadf78e62a042e1ca0579a4a..e048db90f4c2ff13d17a5fe29d449dac302c95e2 100644 (file)
@@ -77,4 +77,9 @@ return [
     // Email Content
     'email_action_help' => 'If you’re having trouble clicking the ":actionText" button, copy and paste the URL below into your web browser:',
     'email_rights' => 'All rights reserved',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacy Policy',
+    'terms_of_service' => 'Terms of Service',
 ];
index 414650d21bac0c5187afc841de93f0f0ea40aaed..bd55668a2e94e59b13f395b9fe0655fb28790889 100755 (executable)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Application Homepage',
     'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
     'app_homepage_select' => 'Select a page',
+    'app_footer_links' => 'Footer Links',
+    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
+    'app_footer_links_label' => 'Link Label',
+    'app_footer_links_url' => 'Link URL',
+    'app_footer_links_add' => 'Add Footer Link',
     'app_disable_comments' => 'Disable Comments',
     'app_disable_comments_toggle' => 'Disable comments',
     'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
diff --git a/resources/sass/_footer.scss b/resources/sass/_footer.scss
new file mode 100644 (file)
index 0000000..1c58bcc
--- /dev/null
@@ -0,0 +1,17 @@
+/**
+ * Includes the footer links.
+ */
+
+ footer {
+    flex-shrink: 0;
+    padding: 1rem 1rem 2rem 1rem;
+    text-align: center;
+  }
+  
+  footer a {
+    margin: 0 .5em;
+  }
+  
+  body.flexbox footer {
+    display: none;
+  }
\ No newline at end of file
index 246ef4b5bdabdfba587b93d96b363edd67407808..3e8c676fdb08bd2d3e838cb9ef7c86f4a77abcfd 100644 (file)
@@ -3,7 +3,7 @@
  */
 
 header .grid {
-  grid-template-columns: auto min-content auto;
+  grid-template-columns: minmax(max-content, 2fr) 1fr minmax(max-content, 2fr);
 }
 
 @include smaller-than($l) {
@@ -77,9 +77,6 @@ header {
 }
 
 
-.header-search {
-  display: inline-block;
-}
 header .search-box {
   display: inline-block;
   margin-top: 10px;
index 57869d6520b04aa6a90ea5405c36d8a95de0927f..1d5defa9765fea5fdb4d2fca441fcc3a71d15691 100644 (file)
@@ -25,4 +25,7 @@ body {
   line-height: 1.6;
   @include lightDark(color, #444, #AAA);
   -webkit-font-smoothing: antialiased;
-}
\ No newline at end of file
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+}
index 4873ff2da651ee1575a048a4973f95069fa0a343..60205eaaacc3f42088800874b6760f963bc9ae26 100644 (file)
   }
 }
 
+#content {
+  flex: 1 0 auto;
+}
+
 /**
  * Flexbox layout system
  */
@@ -252,6 +256,7 @@ body.flexbox {
   .tri-layout-middle {
     grid-area: b;
     padding-top: $-m;
+    min-width: 0;
   }
 }
 @include smaller-than($xxl) {
index a3a58e6c6cd5c66f678d2681d6744f6847748a31..d6ea66350ebecf15fd0a59b95528e713923a990c 100644 (file)
@@ -430,6 +430,9 @@ ul.pagination {
     flex: 1;
     text-align: start;
   }
+  > .content {
+    min-width: 0;
+  }
   &:not(.no-hover) {
     cursor: pointer;
   }
index dfaf6683ed5888a236d1db4a42aecd21618d62fa..05f48b073c0385cabd655940cb924daf17ca3c25 100644 (file)
 }
 
 .page-content.mce-content-body {
-  padding-top: 16px;
+  padding-block-start: 1rem;
+  padding-block-end: 1rem;
   outline: none;
+  display: block;
+}
+
+.page-content.mce-content-body > :last-child {
+  margin-bottom: 3rem;
 }
 
 // Fix to prevent 'No color' option from not being clickable.
index 6d9a1a7182afb7aa734175ee4c2bdc898f32a6f7..278e5b6c500af3a42ebc2c532c40abd0e45b326e 100644 (file)
@@ -19,6 +19,7 @@ body {
   font-family: 'DejaVu Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", "Oxygen", "Ubuntu", "Roboto", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
   margin: 0;
   padding: 0;
+  display: block;
 }
 
 table {
index 78d94f977f8d0043679557148fc00af97a8cd276..743db9888f771b69bc42d429cbfb8d81e88e1031 100644 (file)
@@ -15,6 +15,7 @@
 @import "codemirror";
 @import "components";
 @import "header";
+@import "footer";
 @import "lists";
 @import "pages";
 
@@ -192,8 +193,12 @@ $btt-size: 40px;
   .entity-list-item p {
     margin-bottom: 0;
   }
+  .entity-list-item:focus {
+    outline: 2px dotted var(--color-primary);
+    outline-offset: -4px;
+  }
   .entity-list-item.selected {
-    background-color: rgba(0, 0, 0, 0.05) !important;
+    @include lightDark(background-color, rgba(0, 0, 0, 0.05), rgba(255, 255, 255, 0.05));
   }
   .loading {
     height: 400px;
index a5404a36506ae9d70225034a6eeef749eeb86d28..29e4acee7bf8355f7bbda9b17f240e1483cf001f 100644 (file)
@@ -35,6 +35,8 @@
         @yield('content')
     </div>
 
+    @include('common.footer')
+
     <div back-to-top class="primary-background print-hidden">
         <div class="inner">
             @icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
index f3c3ee34b1ca2e6b16f869346e936c8d9843995a..81fb66cfcd18148a796cd478ed53b43110a4817c 100644 (file)
@@ -36,7 +36,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
-            @if($currentUser->can('book-create-all'))
+            @if(user()->can('book-create-all'))
                 <a href="{{ url("/create-book") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.books_create') }}</span>
index 17cf4c71f9878117471b6afef38f3eccff615331..a3ff0971f117cd6a8d7f748718d7176ba2af1b8f 100644 (file)
@@ -5,7 +5,7 @@
     <div class="content">
         <h4 class="entity-list-item-name break-text">{{ $book->name }}</h4>
         <div class="entity-item-snippet">
-            <p class="text-muted break-text mb-s">{{ $book->getExcerpt() }}</p>
+            <p class="text-muted break-text mb-s text-limit-lines-1">{{ $book->description }}</p>
         </div>
     </div>
 </a>
\ No newline at end of file
diff --git a/resources/views/common/footer.blade.php b/resources/views/common/footer.blade.php
new file mode 100644 (file)
index 0000000..67b52a6
--- /dev/null
@@ -0,0 +1,7 @@
+@if(count(setting('app-footer-links', [])) > 0)
+<footer>
+    @foreach(setting('app-footer-links', []) as $link)
+        <a href="{{ $link['url'] }}" target="_blank">{{ strpos($link['label'], 'trans::') === 0 ? trans(str_replace('trans::', '', $link['label'])) : $link['label'] }}</a>
+    @endforeach
+</footer>
+@endif
\ No newline at end of file
index 80e79410a04ff3aeee9c4324ca3978e6b4eabf35..43ac273efb648bce16e451e9634bb3dcc260faa7 100644 (file)
@@ -13,7 +13,7 @@
             <div class="mobile-menu-toggle hide-over-l">@icon('more')</div>
         </div>
 
-        <div class="header-search hide-under-l">
+        <div class="flex-container-row justify-center hide-under-l">
             @if (hasAppAccess())
             <form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
                 <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
index 3dbcd2875f9eb8457165dea32680a8832623c9d1..1c18edb246bc1ce93040c7af6325d2a0250647c1 100644 (file)
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
+            @if(user()->can('book-create-all'))
+                <a href="{{ url("/create-book") }}" class="icon-list-item">
+                    <span>@icon('add')</span>
+                    <span>{{ trans('entities.books_create') }}</span>
+                </a>
+            @endif
             @include('partials.view-toggle', ['view' => $view, 'type' => 'books'])
             @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
             @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
         </div>
     </div>
-@stop
\ No newline at end of file
+@stop
index fccbef288730d42af2cbd22d860254d943101ebf..957fa6578fffd35e349c550fdf521a237fd09440 100644 (file)
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
+            @if(user()->can('bookshelf-create-all'))
+                <a href="{{ url("/create-shelf") }}" class="icon-list-item">
+                    <span>@icon('add')</span>
+                    <span>{{ trans('entities.shelves_new_action') }}</span>
+                </a>
+            @endif
             @include('partials.view-toggle', ['view' => $view, 'type' => 'shelves'])
             @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
             @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
         </div>
     </div>
-@stop
\ No newline at end of file
+@stop
index 12adda618905a59033b8b40cd6326ee2ab26cf05..4c36ce61a9be9648f48dbc68462b2b75bc73f83a 100644 (file)
@@ -6,11 +6,11 @@
 @endif
 
 <div class="mb-xl">
-    <h5>{{ trans('entities.' . ($signedIn ? 'my_recently_viewed' : 'books_recent')) }}</h5>
+    <h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
     @include('partials.entity-list', [
         'entities' => $recents,
         'style' => 'compact',
-        'emptyText' => $signedIn ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
+        'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
         ])
 </div>
 
index 2631f1a57ed878b01ad5099f7a539e07169eba58..ad503463e46f1db404882fbf58018dfae39b227c 100644 (file)
                     </div>
                 @endif
 
-                <div id="{{ $signedIn ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
-                    <h3 class="card-title">{{ trans('entities.' . ($signedIn ? 'my_recently_viewed' : 'books_recent')) }}</h3>
+                <div id="{{ auth()->check() ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
+                    <h3 class="card-title">{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h3>
                     <div class="px-m">
                         @include('partials.entity-list', [
                         'entities' => $recents,
                         'style' => 'compact',
-                        'emptyText' => $signedIn ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
+                        'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
                         ])
                     </div>
                 </div>
index cb41950cb0e1426cc21100be417613e6187a1d33..c71fdff633ad3e8f7ab1c9d002b29e253b661398 100644 (file)
@@ -1,12 +1,15 @@
 <div class="form-group entity-selector-container">
-    <div entity-selector class="entity-selector {{$selectorSize ?? ''}}" entity-types="{{ $entityTypes ?? 'book,chapter,page' }}" entity-permission="{{ $entityPermission ?? 'view' }}">
-        <input type="hidden" entity-selector-input name="{{$name}}" value="">
-        <input type="text" placeholder="{{ trans('common.search') }}" entity-selector-search>
-        <div class="text-center loading" entity-selector-loading>@include('partials.loading-icon')</div>
-        <div entity-selector-results></div>
+    <div component="entity-selector"
+         class="entity-selector {{$selectorSize ?? ''}}"
+         option:entity-selector:entity-types="{{ $entityTypes ?? 'book,chapter,page' }}"
+         option:entity-selector:entity-permission="{{ $entityPermission ?? 'view' }}">
+        <input refs="entity-selector@input" type="hidden" name="{{$name}}" value="">
+        <input type="text" placeholder="{{ trans('common.search') }}" @if($autofocus ?? false) autofocus @endif refs="entity-selector@search">
+        <div class="text-center loading" refs="entity-selector@loading">@include('partials.loading-icon')</div>
+        <div refs="entity-selector@results"></div>
         @if($showAdd ?? false)
             <div class="entity-selector-add">
-                <button entity-selector-add-button type="button"
+                <button refs="entity-selector@add" type="button"
                         class="button outline">@icon('add'){{ trans('common.add') }}</button>
             </div>
         @endif
index a24f9ac1e9ec79c7d1c55ed95250f177bf9f42ee..0c14490386b0cfb968c00f7d06a535ab2570a6ea 100644 (file)
@@ -4,7 +4,7 @@ $key - Unique key for checking existing stored state.
 --}}
 <?php $isOpen = setting()->getForCurrentUser('section_expansion#'. $key); ?>
 <button type="button" expand-toggle="{{ $target }}"
-   expand-toggle-update-endpoint="{{ url('/settings/users/'. $currentUser->id .'/update-expansion-preference/' . $key) }}"
+   expand-toggle-update-endpoint="{{ url('/settings/users/'. user()->id .'/update-expansion-preference/' . $key) }}"
    expand-toggle-is-open="{{ $isOpen ? 'yes' : 'no' }}"
    class="text-muted icon-list-item text-primary">
     <span>@icon('expand-text')</span>
index a3c868a8dc08ba293e624f27aa8f1229aa5aa039..65df748772598a9f8de8498f4ba705a21041c317 100644 (file)
@@ -1,7 +1,8 @@
 <input type="password" id="{{ $name }}" name="{{ $name }}"
        @if($errors->has($name)) class="text-neg" @endif
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
+       @if(isset($autocomplete)) autocomplete="{{$autocomplete}}" @endif
        @if(old($name)) value="{{ old($name)}}" @endif>
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
-@endif
\ No newline at end of file
+@endif
index a9d1f1174bbbabf9f3266f09a6e59a4694beed33..017a971ff1769ab6c57c33e511477f2e3e55d1fc 100644 (file)
@@ -35,8 +35,6 @@
         </div>
         <iframe src="about:blank" class="markdown-display" sandbox="allow-same-origin"></iframe>
     </div>
-    <input type="hidden" name="html"/>
-
 </div>
 
 
index 3bf1db5e46671f98ead3e3cd8c879f0bb92887ec..26b872cdd769952aa843232daf17d68f32d591b3 100644 (file)
@@ -23,7 +23,7 @@
                 {!! csrf_field() !!}
                 <input type="hidden" name="_method" value="PUT">
 
-                @include('components.entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create'])
+                @include('components.entity-selector', ['name' => 'entity_selection', 'selectorSize' => 'large', 'entityTypes' => 'book,chapter', 'entityPermission' => 'page-create', 'autofocus' => true])
 
                 <div class="form-group text-right">
                     <a href="{{ $page->getUrl() }}" class="button outline">{{ trans('common.cancel') }}</a>
index fa1394ed47d5a2d1ae1840f9aa850fb292ab79f3..a84d0ae85eb277d6e0c65dc39f61059f37bc5841 100644 (file)
@@ -1,33 +1,16 @@
 <div class="entity-meta">
-    @if($entity->isA('revision'))
-        @icon('history'){{ trans('entities.pages_revision') }}
-        {{ trans('entities.pages_revisions_number') }}{{ $entity->revision_number == 0 ? '' : $entity->revision_number }}
-        <br>
-    @endif
-
     @if ($entity->isA('page'))
-        @if (userCan('page-update', $entity)) <a href="{{ $entity->getUrl('/revisions') }}"> @endif
         @icon('history'){{ trans('entities.meta_revision', ['revisionCount' => $entity->revision_count]) }} <br>
-        @if (userCan('page-update', $entity))</a>@endif
-    @endif
-
-    @if ($entity->createdBy)
-        @icon('star'){!! trans('entities.meta_created_name', [
-            'timeLength' => '<span>'.$entity->created_at->toDayDateTimeString() . '</span>',
-            'user' => "<a href='{$entity->createdBy->getProfileUrl()}'>".htmlentities($entity->createdBy->name). "</a>"
-            ]) !!}
-    @else
-        @icon('star')<span>{{ trans('entities.meta_created', ['timeLength' => $entity->created_at->toDayDateTimeString()]) }}</span>
     @endif
 
+    @icon('star'){!! trans('entities.meta_created' . ($entity->createdBy ? '_name' : ''), [
+        'timeLength' => $entity->created_at->toDayDateTimeString(),
+        'user' => htmlentities($entity->createdBy->name),
+        ]) !!}
     <br>
 
-    @if ($entity->updatedBy)
-        @icon('edit'){!! trans('entities.meta_updated_name', [
-                'timeLength' => '<span>' . $entity->updated_at->toDayDateTimeString() .'</span>',
-                'user' => "<a href='{$entity->updatedBy->getProfileUrl()}'>".htmlentities($entity->updatedBy->name). "</a>"
-            ]) !!}
-    @elseif (!$entity->isA('revision'))
-        @icon('edit')<span>{{ trans('entities.meta_updated', ['timeLength' => $entity->updated_at->toDayDateTimeString()]) }}</span>
-    @endif
+    @icon('edit'){!! trans('entities.meta_updated' . ($entity->updatedBy ? '_name' : ''), [
+            'timeLength' => $entity->updated_at->toDayDateTimeString(),
+            'user' => htmlentities($entity->updatedBy->name)
+        ]) !!}
 </div>
\ No newline at end of file
index af0981800049322a9062eef2ea81ecf9a9898354..bf90873975c7b89e483a82210ea67c263b20a79c 100644 (file)
@@ -4,7 +4,7 @@
 ?>
 <div class="list-sort-container" list-sort-control>
     <div class="list-sort-label">{{ trans('common.sort') }}</div>
-    <form action="{{ url("/settings/users/{$currentUser->id}/change-sort/{$type}") }}" method="post">
+    <form action="{{ url("/settings/users/". user()->id ."/change-sort/{$type}") }}" method="post">
 
         {!! csrf_field() !!}
         {!! method_field('PATCH') !!}
index 9f911c88231d1775366263e0e29766705690df94..9ff1b49277d035c17f2df0169f90eab1ddbd2bfc 100644 (file)
@@ -1,5 +1,5 @@
 <div>
-    <form action="{{ url("/settings/users/{$currentUser->id}/switch-${type}-view") }}" method="POST" class="inline">
+    <form action="{{ url("/settings/users/". user()->id ."/switch-${type}-view") }}" method="POST" class="inline">
         {!! csrf_field() !!}
         {!! method_field('PATCH') !!}
         <input type="hidden" value="{{ $view === 'list'? 'grid' : 'list' }}" name="view_type">
diff --git a/resources/views/settings/footer-links.blade.php b/resources/views/settings/footer-links.blade.php
new file mode 100644 (file)
index 0000000..10bf756
--- /dev/null
@@ -0,0 +1,34 @@
+{{--
+$value - Setting value
+$name - Setting input name
+--}}
+<div components="add-remove-rows"
+     option:add-remove-rows:row-selector=".card"
+     option:add-remove-rows:remove-selector="button.text-neg">
+
+    <div component="sortable-list"
+         option:sortable-list:handle-selector=".handle">
+        @foreach(array_merge($value, [['label' => '', 'url' => '']]) as $index => $link)
+            <div class="card drag-card {{ $loop->last ? 'hidden' : '' }}" @if($loop->last) refs="add-remove-rows@model" @endif>
+                <div class="handle">@icon('grip')</div>
+                @foreach(['label', 'url'] as $prop)
+                    <div class="outline">
+                        <input value="{{ $link[$prop] ?? '' }}"
+                               placeholder="{{ trans('settings.app_footer_links_' . $prop) }}"
+                               aria-label="{{ trans('settings.app_footer_links_' . $prop) }}"
+                               name="{{ $name }}[{{ $loop->parent->last ? 'randrowid' : $index }}][{{$prop}}]"
+                               type="text"
+                               autocomplete="off"/>
+                    </div>
+                @endforeach
+                <button type="button"
+                        aria-label="{{ trans('common.remove') }}"
+                        class="text-center drag-card-action text-neg">
+                    @icon('close')
+                </button>
+            </div>
+        @endforeach
+    </div>
+
+    <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('settings.app_footer_links_add') }}</button>
+</div>
\ No newline at end of file
index 8adc1045b46d15a8744e15f06d54b3992d07a3e6..ad03b6c917fdfecc670a3950650bfa092f7c263a 100644 (file)
                         </div>
                     </div>
 
+                    <div>
+                        <label for="setting-app-privacy-link" class="setting-list-label">{{ trans('settings.app_footer_links') }}</label>
+                        <p class="small mb-m">{{ trans('settings.app_footer_links_desc') }}</p>
+                        @include('settings.footer-links', ['name' => 'setting-app-footer-links', 'value' => setting('app-footer-links', [])])
+                    </div>
+
 
                     <div>
                         <label for="setting-app-custom-head" class="setting-list-label">{{ trans('settings.app_custom_html') }}</label>
index af8b2aaf7ee3b134e915511760a33d456905389f..a472196c56e7bded70e893953f7383918257dca0 100644 (file)
@@ -1,16 +1,16 @@
 
 <nav class="active-link-list">
-    @if($currentUser->can('settings-manage'))
+    @if(userCan('settings-manage'))
         <a href="{{ url('/settings') }}" @if($selected == 'settings') class="active" @endif>@icon('settings'){{ trans('settings.settings') }}</a>
         <a href="{{ url('/settings/maintenance') }}" @if($selected == 'maintenance') class="active" @endif>@icon('spanner'){{ trans('settings.maint') }}</a>
     @endif
-    @if($currentUser->can('settings-manage') && $currentUser->can('users-manage'))
+    @if(userCan('settings-manage') && userCan('users-manage'))
         <a href="{{ url('/settings/audit') }}" @if($selected == 'audit') class="active" @endif>@icon('open-book'){{ trans('settings.audit') }}</a>
     @endif
-    @if($currentUser->can('users-manage'))
+    @if(userCan('users-manage'))
         <a href="{{ url('/settings/users') }}" @if($selected == 'users') class="active" @endif>@icon('users'){{ trans('settings.users') }}</a>
     @endif
-    @if($currentUser->can('user-roles-manage'))
+    @if(userCan('user-roles-manage'))
         <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
     @endif
 </nav>
\ No newline at end of file
index 43bc2b0242e1f7d0e4fe14a97dad5b3448f508d3..604acbb165021a5f8bc50814106f5447d77dcda0 100644 (file)
                         <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
                     </div>
                     <div>
-                        @if(userCan('users-manage') || $currentUser->id == $user->id)
+                        @if(userCan('users-manage') || user()->id == $user->id)
                             <a href="{{ url("/settings/users/{$user->id}") }}">
                                 @endif
                                 {{ $user->name }}
-                                @if(userCan('users-manage') || $currentUser->id == $user->id)
+                                @if(userCan('users-manage') || user()->id == $user->id)
                             </a>
                         @endif
                     </div>
index 56b76f96f01855e7646d5371b0e372f25fc10c8a..21c33aa9c62d1aba748143b8af0e82ac3d01d351 100644 (file)
@@ -9,7 +9,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
-            @if($currentUser->can('bookshelf-create-all'))
+            @if(userCan('bookshelf-create-all'))
                 <a href="{{ url("/create-shelf") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.shelves_new_action') }}</span>
index 9971eeeeb54ca63ba42045982f076e6324936989..d953b646afe8c0ba10643863cb61fb384de064b5 100644 (file)
@@ -19,7 +19,7 @@
                 </div>
 
                 <div class="form-group text-right">
-                    <a href="{{  url($currentUser->can('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
                     <button class="button" type="submit">{{ trans('common.save') }}</button>
                 </div>
 
index f78c25cebf6a93a00a82975b8ba09c308505a730..7fb12bd757389c0128a0e4f62f36fb03cb6e5808 100644 (file)
@@ -8,7 +8,7 @@
         </div>
 
         <section class="card content-wrap">
-            <h1 class="list-heading">{{ $user->id === $currentUser->id ? trans('settings.users_edit_profile') : trans('settings.users_edit') }}</h1>
+            <h1 class="list-heading">{{ $user->id === user()->id ? trans('settings.users_edit_profile') : trans('settings.users_edit') }}</h1>
             <form action="{{ url("/settings/users/{$user->id}") }}" method="post" enctype="multipart/form-data">
                 {!! csrf_field() !!}
                 <input type="hidden" name="_method" value="PUT">
@@ -54,7 +54,7 @@
                 </div>
 
                 <div class="text-right">
-                    <a href="{{  url($currentUser->can('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
                     @if($authMethod !== 'system')
                         <a href="{{ url("/settings/users/{$user->id}/delete") }}" class="button outline">{{ trans('settings.users_delete') }}</a>
                     @endif
@@ -63,7 +63,7 @@
             </form>
         </section>
 
-        @if($currentUser->id === $user->id && count($activeSocialDrivers) > 0)
+        @if(user()->id === $user->id && count($activeSocialDrivers) > 0)
             <section class="card content-wrap auto-height">
                 <h2 class="list-heading">{{ trans('settings.users_social_accounts') }}</h2>
                 <p class="text-muted">{{ trans('settings.users_social_accounts_info') }}</p>
@@ -88,7 +88,7 @@
             </section>
         @endif
 
-        @if(($currentUser->id === $user->id && userCan('access-api')) || userCan('users-manage'))
+        @if((user()->id === $user->id && userCan('access-api')) || userCan('users-manage'))
             @include('users.api-tokens.list', ['user' => $user])
         @endif
     </div>
index df3d06c2f34f232b17fe033472d4078f293285cb..763c387d4601bca12bba95b71dd9ba2cfecb200f 100644 (file)
@@ -74,7 +74,7 @@
             <div class="grid half mt-m gap-xl">
                 <div>
                     <label for="password">{{ trans('auth.password') }}</label>
-                    @include('form.password', ['name' => 'password'])
+                    @include('form.password', ['name' => 'password', 'autocomplete' => 'new-password'])
                 </div>
                 <div>
                     <label for="password-confirm">{{ trans('auth.password_confirm') }}</label>
index 68641ca644e721df9e64277901cd8ef036722bef..6bc229ec682a1fac2ae1599b4e1bfe0717c406a2 100644 (file)
@@ -21,9 +21,7 @@
                             <input type="text" name="search" placeholder="{{ trans('settings.users_search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
                         </form>
                     </div>
-                    @if(userCan('users-manage'))
-                        <a href="{{ url("/settings/users/create") }}" style="margin-top: 0;" class="outline button">{{ trans('settings.users_add_new') }}</a>
-                    @endif
+                    <a href="{{ url("/settings/users/create") }}" class="outline button mt-none">{{ trans('settings.users_add_new') }}</a>
                 </div>
             </div>
 
                     <tr>
                         <td class="text-center" style="line-height: 0;"><img class="avatar med" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></td>
                         <td>
-                            @if(userCan('users-manage') || $currentUser->id == $user->id)
-                                <a href="{{ url("/settings/users/{$user->id}") }}">
-                                    @endif
-                                    {{ $user->name }} <br> <span class="text-muted">{{ $user->email }}</span>
-                                    @if(userCan('users-manage') || $currentUser->id == $user->id)
-                                </a>
-                            @endif
+                            <a href="{{ url("/settings/users/{$user->id}") }}">
+                                {{ $user->name }} <br> <span class="text-muted">{{ $user->email }}</span>
+                            </a>
                         </td>
                         <td>
                             @foreach($user->roles as $index => $role)
index e8f217862281a3622b94bede6593e77d975e318c..64d08e165a9659c0bcd09111a8db6fd37a33269a 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 
+Route::get('/status', 'StatusController@show');
 Route::get('/robots.txt', 'HomeController@getRobots');
 
 // Authenticated routes...
index 3cb39ca2c59c63a8315a76c44ebbaed1881bbfe2..840dfd630eeeea91c1cf3a8dd9e98b6d7aa6fb97 100644 (file)
@@ -4,6 +4,7 @@ use BookStack\Auth\Access\LdapService;
 use BookStack\Auth\Role;
 use BookStack\Auth\Access\Ldap;
 use BookStack\Auth\User;
+use BookStack\Exceptions\LdapException;
 use Mockery\MockInterface;
 use Tests\BrowserKitTest;
 
@@ -40,6 +41,14 @@ class LdapTest extends BrowserKitTest
         $this->mockUser = factory(User::class)->make();
     }
 
+    protected function runFailedAuthLogin()
+    {
+        $this->commonLdapMocks(1, 1, 1, 1, 1);
+        $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
+            ->andReturn(['count' => 0]);
+        $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
+    }
+
     protected function mockEscapes($times = 1)
     {
         $this->mockLdap->shouldReceive('escape')->times($times)->andReturnUsing(function($val) {
@@ -550,6 +559,22 @@ class LdapTest extends BrowserKitTest
         ]);
     }
 
+    public function test_start_tls_called_if_option_set()
+    {
+        config()->set(['services.ldap.start_tls' => true]);
+        $this->mockLdap->shouldReceive('startTls')->once()->andReturn(true);
+        $this->runFailedAuthLogin();
+    }
+
+    public function test_connection_fails_if_tls_fails()
+    {
+        config()->set(['services.ldap.start_tls' => true]);
+        $this->mockLdap->shouldReceive('startTls')->once()->andReturn(false);
+        $this->commonLdapMocks(1, 1, 0, 0, 0);
+        $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
+        $this->assertResponseStatus(500);
+    }
+
     public function test_ldap_attributes_can_be_binary_decoded_if_marked()
     {
         config()->set(['services.ldap.id_attribute' => 'BIN;uid']);
@@ -640,12 +665,7 @@ class LdapTest extends BrowserKitTest
     {
         $log = $this->withTestLogger();
         config()->set(['logging.failed_login.message' => 'Failed login for %u']);
-
-        $this->commonLdapMocks(1, 1, 1, 1, 1);
-        $this->mockLdap->shouldReceive('searchAndGetEntries')->times(1)
-            ->andReturn(['count' => 0]);
-
-        $this->post('/login', ['username' => 'timmyjenkins', 'password' => 'cattreedog']);
+        $this->runFailedAuthLogin();
         $this->assertTrue($log->hasWarningThatContains('Failed login for timmyjenkins'));
     }
 }
diff --git a/tests/Commands/AddAdminCommandTest.php b/tests/Commands/AddAdminCommandTest.php
new file mode 100644 (file)
index 0000000..6b03c86
--- /dev/null
@@ -0,0 +1,25 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Auth\User;
+use Tests\TestCase;
+
+class AddAdminCommandTest extends TestCase
+{
+    public function test_add_admin_command()
+    {
+        $exitCode = \Artisan::call('bookstack:create-admin', [
+            '--email' => '[email protected]',
+            '--name' => 'Admin Test',
+            '--password' => 'testing-4',
+        ]);
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseHas('users', [
+            'email' => '[email protected]',
+            'name' => 'Admin Test'
+        ]);
+
+        $this->assertTrue(User::query()->where('email', '=', '[email protected]')->first()->hasSystemRole('admin'), 'User has admin role as expected');
+        $this->assertTrue(\Auth::attempt(['email' => '[email protected]', 'password' => 'testing-4']), 'Password stored as expected');
+    }
+}
\ No newline at end of file
diff --git a/tests/Commands/ClearActivityCommandTest.php b/tests/Commands/ClearActivityCommandTest.php
new file mode 100644 (file)
index 0000000..60f7567
--- /dev/null
@@ -0,0 +1,29 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Actions\ActivityType;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class ClearActivityCommandTest extends TestCase
+{
+    public function test_clear_activity_command()
+    {
+        $this->asEditor();
+        $page = Page::first();
+        \Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
+
+        $this->assertDatabaseHas('activities', [
+            'type' => 'page_update',
+            'entity_id' => $page->id,
+            'user_id' => $this->getEditor()->id
+        ]);
+
+        $exitCode = \Artisan::call('bookstack:clear-activity');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+
+        $this->assertDatabaseMissing('activities', [
+            'type' => 'page_update'
+        ]);
+    }
+}
\ No newline at end of file
diff --git a/tests/Commands/ClearRevisionsCommandTest.php b/tests/Commands/ClearRevisionsCommandTest.php
new file mode 100644 (file)
index 0000000..e0293fa
--- /dev/null
@@ -0,0 +1,47 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Entities\Models\Page;
+use BookStack\Entities\Repos\PageRepo;
+use Illuminate\Support\Facades\Artisan;
+use Tests\TestCase;
+
+class ClearRevisionsCommandTest extends TestCase
+{
+    public function test_clear_revisions_command()
+    {
+        $this->asEditor();
+        $pageRepo = app(PageRepo::class);
+        $page = Page::first();
+        $pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
+        $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '<p>new content in draft</p>', 'summary' => 'page revision testing']);
+
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'type' => 'version'
+        ]);
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'type' => 'update_draft'
+        ]);
+
+        $exitCode = Artisan::call('bookstack:clear-revisions');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('page_revisions', [
+            'page_id' => $page->id,
+            'type' => 'version'
+        ]);
+        $this->assertDatabaseHas('page_revisions', [
+            'page_id' => $page->id,
+            'type' => 'update_draft'
+        ]);
+
+        $exitCode = Artisan::call('bookstack:clear-revisions', ['--all' => true]);
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('page_revisions', [
+            'page_id' => $page->id,
+            'type' => 'update_draft'
+        ]);
+    }
+}
\ No newline at end of file
diff --git a/tests/Commands/ClearViewsCommandTest.php b/tests/Commands/ClearViewsCommandTest.php
new file mode 100644 (file)
index 0000000..d553ead
--- /dev/null
@@ -0,0 +1,29 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class ClearViewsCommandTest extends TestCase
+{
+
+    public function test_clear_views_command()
+    {
+        $this->asEditor();
+        $page = Page::first();
+
+        $this->get($page->getUrl());
+
+        $this->assertDatabaseHas('views', [
+            'user_id' => $this->getEditor()->id,
+            'viewable_id' => $page->id,
+            'views' => 1
+        ]);
+
+        $exitCode = \Artisan::call('bookstack:clear-views');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('views', [
+            'user_id' => $this->getEditor()->id
+        ]);
+    }
+}
\ No newline at end of file
diff --git a/tests/Commands/CopyShelfPermissionsCommandTest.php b/tests/Commands/CopyShelfPermissionsCommandTest.php
new file mode 100644 (file)
index 0000000..87199bd
--- /dev/null
@@ -0,0 +1,54 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Entities\Models\Bookshelf;
+use Tests\TestCase;
+
+class CopyShelfPermissionsCommandTest extends TestCase
+{
+    public function test_copy_shelf_permissions_command_shows_error_when_no_required_option_given()
+    {
+        $this->artisan('bookstack:copy-shelf-permissions')
+            ->expectsOutput('Either a --slug or --all option must be provided.')
+            ->assertExitCode(0);
+    }
+
+    public function test_copy_shelf_permissions_command_using_slug()
+    {
+        $shelf = Bookshelf::first();
+        $child = $shelf->books()->first();
+        $editorRole = $this->getEditor()->roles()->first();
+        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
+        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+
+        $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
+        $this->artisan('bookstack:copy-shelf-permissions', [
+            '--slug' => $shelf->slug,
+        ]);
+        $child = $shelf->books()->first();
+
+        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
+        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+    }
+
+    public function test_copy_shelf_permissions_command_using_all()
+    {
+        $shelf = Bookshelf::query()->first();
+        Bookshelf::query()->where('id', '!=', $shelf->id)->delete();
+        $child = $shelf->books()->first();
+        $editorRole = $this->getEditor()->roles()->first();
+        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
+        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
+
+        $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
+        $this->artisan('bookstack:copy-shelf-permissions --all')
+            ->expectsQuestion('Permission settings for all shelves will be cascaded. Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. Are you sure you want to proceed?', 'y');
+        $child = $shelf->books()->first();
+
+        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
+        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
+        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
+    }
+}
\ No newline at end of file
diff --git a/tests/Commands/RegenerateCommentContentCommandTest.php b/tests/Commands/RegenerateCommentContentCommandTest.php
new file mode 100644 (file)
index 0000000..1deeaa7
--- /dev/null
@@ -0,0 +1,29 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Actions\Comment;
+use Tests\TestCase;
+
+class RegenerateCommentContentCommandTest extends TestCase
+{
+    public function test_regenerate_comment_content_command()
+    {
+        Comment::query()->forceCreate([
+            'html' => 'some_old_content',
+            'text' => 'some_fresh_content',
+        ]);
+
+        $this->assertDatabaseHas('comments', [
+            'html' => 'some_old_content',
+        ]);
+
+        $exitCode = \Artisan::call('bookstack:regenerate-comment-content');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseMissing('comments', [
+            'html' => 'some_old_content',
+        ]);
+        $this->assertDatabaseHas('comments', [
+            'html' => "<p>some_fresh_content</p>\n",
+        ]);
+    }
+}
\ No newline at end of file
diff --git a/tests/Commands/RegeneratePermissionsCommandTest.php b/tests/Commands/RegeneratePermissionsCommandTest.php
new file mode 100644 (file)
index 0000000..7965237
--- /dev/null
@@ -0,0 +1,21 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Auth\Permissions\JointPermission;
+use BookStack\Entities\Models\Page;
+use Tests\TestCase;
+
+class RegeneratePermissionsCommandTest extends TestCase
+{
+    public function test_regen_permissions_command()
+    {
+        JointPermission::query()->truncate();
+        $page = Page::first();
+
+        $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]);
+
+        $exitCode = \Artisan::call('bookstack:regenerate-permissions');
+        $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+        $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]);
+    }
+}
\ No newline at end of file
diff --git a/tests/Commands/UpdateUrlCommandTest.php b/tests/Commands/UpdateUrlCommandTest.php
new file mode 100644 (file)
index 0000000..7043ce0
--- /dev/null
@@ -0,0 +1,59 @@
+<?php namespace Tests\Commands;
+
+use BookStack\Entities\Models\Page;
+use Symfony\Component\Console\Exception\RuntimeException;
+use Tests\TestCase;
+
+class UpdateUrlCommandTest extends TestCase
+{
+    public function test_command_updates_page_content()
+    {
+        $page = Page::query()->first();
+        $page->html = '<a href="https://example.com/donkeys"></a>';
+        $page->save();
+
+        $this->artisan('bookstack:update-url https://example.com https://cats.example.com')
+            ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with  \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y')
+            ->expectsQuestion("This operation could cause issues if used incorrectly. Have you made a backup of your existing database?", 'y');
+
+        $this->assertDatabaseHas('pages', [
+            'id' => $page->id,
+            'html' => '<a href="https://cats.example.com/donkeys"></a>'
+        ]);
+    }
+
+    public function test_command_requires_valid_url()
+    {
+        $badUrlMessage = "The given urls are expected to be full urls starting with http:// or https://";
+        $this->artisan('bookstack:update-url //example.com https://cats.example.com')->expectsOutput($badUrlMessage);
+        $this->artisan('bookstack:update-url https://example.com htts://cats.example.com')->expectsOutput($badUrlMessage);
+        $this->artisan('bookstack:update-url example.com https://cats.example.com')->expectsOutput($badUrlMessage);
+
+        $this->expectException(RuntimeException::class);
+        $this->artisan('bookstack:update-url https://cats.example.com');
+    }
+
+    public function test_command_updates_settings()
+    {
+        setting()->put('my-custom-item', 'https://example.com/donkey/cat');
+        $this->runUpdate('https://example.com', 'https://cats.example.com');
+
+        $settingVal = setting('my-custom-item');
+        $this->assertEquals('https://cats.example.com/donkey/cat', $settingVal);
+    }
+
+    public function test_command_updates_array_settings()
+    {
+        setting()->put('my-custom-array-item', [['name' => 'a https://example.com/donkey/cat url']]);
+        $this->runUpdate('https://example.com', 'https://cats.example.com');
+        $settingVal = setting('my-custom-array-item');
+        $this->assertEquals('a https://cats.example.com/donkey/cat url', $settingVal[0]['name']);
+    }
+
+    protected function runUpdate(string $oldUrl, string $newUrl)
+    {
+        $this->artisan("bookstack:update-url {$oldUrl} {$newUrl}")
+            ->expectsQuestion("This will search for \"{$oldUrl}\" in your database and replace it with  \"{$newUrl}\".\nAre you sure you want to proceed?", 'y')
+            ->expectsQuestion("This operation could cause issues if used incorrectly. Have you made a backup of your existing database?", 'y');
+    }
+}
\ No newline at end of file
diff --git a/tests/CommandsTest.php b/tests/CommandsTest.php
deleted file mode 100644 (file)
index 8c6ea84..0000000
+++ /dev/null
@@ -1,222 +0,0 @@
-<?php namespace Tests;
-
-use BookStack\Actions\ActivityType;
-use BookStack\Actions\Comment;
-use BookStack\Actions\CommentRepo;
-use BookStack\Auth\Permissions\JointPermission;
-use BookStack\Entities\Models\Bookshelf;
-use BookStack\Entities\Models\Page;
-use BookStack\Auth\User;
-use BookStack\Entities\Repos\PageRepo;
-use Symfony\Component\Console\Exception\RuntimeException;
-
-class CommandsTest extends TestCase
-{
-
-    public function test_clear_views_command()
-    {
-        $this->asEditor();
-        $page = Page::first();
-
-        $this->get($page->getUrl());
-
-        $this->assertDatabaseHas('views', [
-            'user_id' => $this->getEditor()->id,
-            'viewable_id' => $page->id,
-            'views' => 1
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-views');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('views', [
-            'user_id' => $this->getEditor()->id
-        ]);
-    }
-
-    public function test_clear_activity_command()
-    {
-        $this->asEditor();
-        $page = Page::first();
-        \Activity::addForEntity($page, ActivityType::PAGE_UPDATE);
-
-        $this->assertDatabaseHas('activities', [
-            'type' => 'page_update',
-            'entity_id' => $page->id,
-            'user_id' => $this->getEditor()->id
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-activity');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-
-        $this->assertDatabaseMissing('activities', [
-            'type' => 'page_update'
-        ]);
-    }
-
-    public function test_clear_revisions_command()
-    {
-        $this->asEditor();
-        $pageRepo = app(PageRepo::class);
-        $page = Page::first();
-        $pageRepo->update($page, ['name' => 'updated page', 'html' => '<p>new content</p>', 'summary' => 'page revision testing']);
-        $pageRepo->updatePageDraft($page, ['name' => 'updated page', 'html' => '<p>new content in draft</p>', 'summary' => 'page revision testing']);
-
-        $this->assertDatabaseHas('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'version'
-        ]);
-        $this->assertDatabaseHas('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'update_draft'
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-revisions');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'version'
-        ]);
-        $this->assertDatabaseHas('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'update_draft'
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:clear-revisions', ['--all' => true]);
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('page_revisions', [
-            'page_id' => $page->id,
-            'type' => 'update_draft'
-        ]);
-    }
-
-    public function test_regen_permissions_command()
-    {
-        JointPermission::query()->truncate();
-        $page = Page::first();
-
-        $this->assertDatabaseMissing('joint_permissions', ['entity_id' => $page->id]);
-
-        $exitCode = \Artisan::call('bookstack:regenerate-permissions');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseHas('joint_permissions', ['entity_id' => $page->id]);
-    }
-
-    public function test_add_admin_command()
-    {
-        $exitCode = \Artisan::call('bookstack:create-admin', [
-            '--email' => '[email protected]',
-            '--name' => 'Admin Test',
-            '--password' => 'testing-4',
-        ]);
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseHas('users', [
-            'email' => '[email protected]',
-            'name' => 'Admin Test'
-        ]);
-
-        $this->assertTrue(User::where('email', '=', '[email protected]')->first()->hasSystemRole('admin'), 'User has admin role as expected');
-        $this->assertTrue(\Auth::attempt(['email' => '[email protected]', 'password' => 'testing-4']), 'Password stored as expected');
-    }
-
-    public function test_copy_shelf_permissions_command_shows_error_when_no_required_option_given()
-    {
-        $this->artisan('bookstack:copy-shelf-permissions')
-            ->expectsOutput('Either a --slug or --all option must be provided.')
-            ->assertExitCode(0);
-    }
-
-    public function test_copy_shelf_permissions_command_using_slug()
-    {
-        $shelf = Bookshelf::first();
-        $child = $shelf->books()->first();
-        $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
-        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
-
-        $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
-        $this->artisan('bookstack:copy-shelf-permissions', [
-            '--slug' => $shelf->slug,
-        ]);
-        $child = $shelf->books()->first();
-
-        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
-        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
-    }
-
-    public function test_copy_shelf_permissions_command_using_all()
-    {
-        $shelf = Bookshelf::query()->first();
-        Bookshelf::query()->where('id', '!=', $shelf->id)->delete();
-        $child = $shelf->books()->first();
-        $editorRole = $this->getEditor()->roles()->first();
-        $this->assertFalse(boolval($child->restricted), "Child book should not be restricted by default");
-        $this->assertTrue($child->permissions()->count() === 0, "Child book should have no permissions by default");
-
-        $this->setEntityRestrictions($shelf, ['view', 'update'], [$editorRole]);
-        $this->artisan('bookstack:copy-shelf-permissions --all')
-            ->expectsQuestion('Permission settings for all shelves will be cascaded. Books assigned to multiple shelves will receive only the permissions of it\'s last processed shelf. Are you sure you want to proceed?', 'y');
-        $child = $shelf->books()->first();
-
-        $this->assertTrue(boolval($child->restricted), "Child book should now be restricted");
-        $this->assertTrue($child->permissions()->count() === 2, "Child book should have copied permissions");
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'view', 'role_id' => $editorRole->id]);
-        $this->assertDatabaseHas('entity_permissions', ['restrictable_id' => $child->id, 'action' => 'update', 'role_id' => $editorRole->id]);
-    }
-
-    public function test_update_url_command_updates_page_content()
-    {
-        $page = Page::query()->first();
-        $page->html = '<a href="https://example.com/donkeys"></a>';
-        $page->save();
-
-        $this->artisan('bookstack:update-url https://example.com https://cats.example.com')
-            ->expectsQuestion("This will search for \"https://example.com\" in your database and replace it with  \"https://cats.example.com\".\nAre you sure you want to proceed?", 'y')
-            ->expectsQuestion("This operation could cause issues if used incorrectly. Have you made a backup of your existing database?", 'y');
-
-        $this->assertDatabaseHas('pages', [
-            'id' => $page->id,
-            'html' => '<a href="https://cats.example.com/donkeys"></a>'
-        ]);
-    }
-
-    public function test_update_url_command_requires_valid_url()
-    {
-        $badUrlMessage = "The given urls are expected to be full urls starting with http:// or https://";
-        $this->artisan('bookstack:update-url //example.com https://cats.example.com')->expectsOutput($badUrlMessage);
-        $this->artisan('bookstack:update-url https://example.com htts://cats.example.com')->expectsOutput($badUrlMessage);
-        $this->artisan('bookstack:update-url example.com https://cats.example.com')->expectsOutput($badUrlMessage);
-
-        $this->expectException(RuntimeException::class);
-        $this->artisan('bookstack:update-url https://cats.example.com');
-    }
-
-    public function test_regenerate_comment_content_command()
-    {
-        Comment::query()->forceCreate([
-            'html' => 'some_old_content',
-            'text' => 'some_fresh_content',
-        ]);
-
-        $this->assertDatabaseHas('comments', [
-            'html' => 'some_old_content',
-        ]);
-
-        $exitCode = \Artisan::call('bookstack:regenerate-comment-content');
-        $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
-        $this->assertDatabaseMissing('comments', [
-            'html' => 'some_old_content',
-        ]);
-        $this->assertDatabaseHas('comments', [
-            'html' => "<p>some_fresh_content</p>\n",
-        ]);
-    }
-}
index 9b3290370c197a14bd1d558a9728e1c96cae89e6..29fac5ec56c21777465d2550299f7cf99eba57fe 100644 (file)
@@ -59,7 +59,7 @@ class BookShelfTest extends TestCase
     public function test_book_not_visible_in_shelf_list_view_if_user_cant_view_shelf()
     {
         config()->set([
-            'app.views.bookshelves' => 'list',
+            'setting-defaults.user.bookshelves_view_type' => 'list',
         ]);
         $shelf = Bookshelf::query()->first();
         $book = $shelf->books()->first();
index 1e44f015a5a0b69f8520c9227b971e79f17c0b63..05672c6ca47b09252ea8ff87bf00b190c5a20b5c 100644 (file)
@@ -1,6 +1,5 @@
 <?php namespace Tests\Entity;
 
-
 use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use Illuminate\Support\Facades\Storage;
@@ -151,6 +150,16 @@ class ExportTest extends TestCase
         $resp->assertDontSee($page->updated_at->diffForHumans());
     }
 
+    public function test_page_export_does_not_include_user_or_revision_links()
+    {
+        $page = Page::first();
+
+        $resp = $this->asEditor()->get($page->getUrl('/export/html'));
+        $resp->assertDontSee($page->getUrl('/revisions'));
+        $resp->assertDontSee($page->createdBy->getProfileUrl());
+        $resp->assertSee($page->createdBy->name);
+    }
+
     public function test_page_export_sets_right_data_type_for_svg_embeds()
     {
         $page = Page::first();
diff --git a/tests/FooterLinksTest.php b/tests/FooterLinksTest.php
new file mode 100644 (file)
index 0000000..f0ff0c4
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+
+use Tests\TestCase;
+
+class FooterLinksTest extends TestCase
+{
+
+    public function test_saving_setting()
+    {
+        $resp = $this->asAdmin()->post("/settings", [
+            'setting-app-footer-links' => [
+                ['label' => 'My custom link 1', 'url' => 'https://example.com/1'],
+                ['label' => 'My custom link 2', 'url' => 'https://example.com/2'],
+            ],
+        ]);
+        $resp->assertRedirect('/settings');
+
+        $result = setting('app-footer-links');
+        $this->assertIsArray($result);
+        $this->assertCount(2, $result);
+        $this->assertEquals('My custom link 2', $result[1]['label']);
+        $this->assertEquals('https://example.com/1', $result[0]['url']);
+    }
+
+    public function test_set_options_visible_on_settings_page()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'My custom link', 'url' => 'https://example.com/link-a'],
+            ['label' => 'Another Link', 'url' => 'https://example.com/link-b'],
+        ]]);
+
+        $resp = $this->asAdmin()->get('/settings');
+        $resp->assertSee('value="My custom link"');
+        $resp->assertSee('value="Another Link"');
+        $resp->assertSee('value="https://example.com/link-a"');
+        $resp->assertSee('value="https://example.com/link-b"');
+    }
+
+    public function test_footer_links_show_on_pages()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'My custom link', 'url' => 'https://example.com/link-a'],
+            ['label' => 'Another Link', 'url' => 'https://example.com/link-b'],
+        ]]);
+
+        $this->get('/login')->assertElementContains('footer a[href="https://example.com/link-a"]', 'My custom link');
+        $this->asEditor()->get('/')->assertElementContains('footer a[href="https://example.com/link-b"]', 'Another link');
+    }
+
+    public function test_using_translation_system_for_labels()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'trans::common.privacy_policy', 'url' => 'https://example.com/privacy'],
+            ['label' => 'trans::common.terms_of_service', 'url' => 'https://example.com/terms'],
+        ]]);
+
+        $resp = $this->get('/login');
+        $resp->assertElementContains('footer a[href="https://example.com/privacy"]', 'Privacy Policy');
+        $resp->assertElementContains('footer a[href="https://example.com/terms"]', 'Terms of Service');
+    }
+}
\ No newline at end of file
diff --git a/tests/StatusTest.php b/tests/StatusTest.php
new file mode 100644 (file)
index 0000000..b4c35cf
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Session;
+use Tests\TestCase;
+
+class StatusTest extends TestCase
+{
+    public function test_returns_json_with_expected_results()
+    {
+        $resp = $this->get("/status");
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'database' => true,
+            'cache' => true,
+            'session' => true,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_db_error()
+    {
+        DB::shouldReceive('table')->andThrow(new Exception());
+
+        $resp = $this->get("/status");
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'database' => false,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_wrong_cache_return()
+    {
+        $mockStore = Mockery::mock(new ArrayStore())->makePartial();
+        Cache::swap($mockStore);
+        $mockStore->shouldReceive('get')->andReturn('cat');
+
+        $resp = $this->get("/status");
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'cache' => false,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_wrong_session_return()
+    {
+        $session = Session::getFacadeRoot();
+        $mockSession = Mockery::mock($session)->makePartial();
+        Session::swap($mockSession);
+        $mockSession->shouldReceive('get')->andReturn('cat');
+
+        $resp = $this->get("/status");
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'session' => false,
+        ]);
+    }
+}
\ No newline at end of file
index 1c736d672d977b8727c8ee5cf08b9f9ee5ba1538..9b0e004b1f7a3cbe8718edc382eb40cbcfdcc581 100644 (file)
@@ -136,7 +136,7 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-php.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
@@ -158,7 +158,7 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-phtml.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
@@ -175,7 +175,7 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-phtml-png.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/png')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
index 64f26dea8a9be7c847909d0d192a780a2ed25f02..a2026d96845050ea8736c1d59e153d40db611ebd 100644 (file)
@@ -6,10 +6,9 @@ use Illuminate\Http\UploadedFile;
 trait UsesImages
 {
     /**
-     * Get the path to our basic test image.
-     * @return string
+     * Get the path to a file in the test-data-directory.
      */
-    protected function getTestImageFilePath(?string $fileName = null)
+    protected function getTestImageFilePath(?string $fileName = null): string
     {
         if (is_null($fileName)) {
             $fileName = 'test-image.png';
@@ -18,14 +17,27 @@ trait UsesImages
         return base_path('tests/test-data/' . $fileName);
     }
 
+    /**
+     * Creates a new temporary image file using the given name,
+     * with the content decoded from the given bas64 file name.
+     * Is generally used for testing sketchy files that could trip AV.
+     */
+    protected function newTestImageFromBase64(string $base64FileName, $imageFileName): UploadedFile
+    {
+        $imagePath = implode(DIRECTORY_SEPARATOR, [sys_get_temp_dir(), $imageFileName]);
+        $base64FilePath = $this->getTestImageFilePath($base64FileName);
+        $data = file_get_contents($base64FilePath);
+        $decoded = base64_decode($data);
+        file_put_contents($imagePath, $decoded);
+        return new UploadedFile($imagePath, $imageFileName, 'image/png', null, true);
+    }
+
     /**
      * Get a test image that can be uploaded
-     * @param $fileName
-     * @return UploadedFile
      */
-    protected function getTestImage($fileName, ?string $testDataFileName = null)
+    protected function getTestImage(string $fileName, ?string $testDataFileName = null): UploadedFile
     {
-        return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', 5238, null, true);
+        return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', null, true);
     }
 
     /**
index 7ffc8f9db7085958b9ef5e5a80bc4bfed22a3022..49c49188b2451f0b74cf017210f0b5baafd23f80 100644 (file)
@@ -92,4 +92,17 @@ class UserPreferencesTest extends TestCase
         $home->assertDontSee('Dark Mode');
         $home->assertSee('Light Mode');
     }
+
+    public function test_dark_mode_defaults_to_config_option()
+    {
+        config()->set('setting-defaults.user.dark-mode-enabled', false);
+        $this->assertEquals(false, setting()->getForCurrentUser('dark-mode-enabled'));
+        $home = $this->get('/login');
+        $home->assertElementNotExists('.dark-mode');
+
+        config()->set('setting-defaults.user.dark-mode-enabled', true);
+        $this->assertEquals(true, setting()->getForCurrentUser('dark-mode-enabled'));
+        $home = $this->get('/login');
+        $home->assertElementExists('.dark-mode');
+    }
 }
\ No newline at end of file
diff --git a/tests/test-data/bad-php.base64 b/tests/test-data/bad-php.base64
new file mode 100644 (file)
index 0000000..550ce17
--- /dev/null
@@ -0,0 +1,10 @@
+/9j/4AAQSkZJRgABAQEBLAEsAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAAEBAQEBAQEBAQEB
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBD
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB
+AQEBAQEBAQH/wgARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQBAQAA
+AAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADEAAAAT/n/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgB
+AQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k8P3BocCBlY2hvICdiYWRwaHAnOwo=
diff --git a/tests/test-data/bad-phtml-png.base64 b/tests/test-data/bad-phtml-png.base64
new file mode 100644 (file)
index 0000000..7fd9d8f
--- /dev/null
@@ -0,0 +1,3 @@
+iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
+B3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUH
+AAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=
diff --git a/tests/test-data/bad-phtml.base64 b/tests/test-data/bad-phtml.base64
new file mode 100644 (file)
index 0000000..550ce17
--- /dev/null
@@ -0,0 +1,10 @@
+/9j/4AAQSkZJRgABAQEBLAEsAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAAEBAQEBAQEBAQEB
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBD
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB
+AQEBAQEBAQH/wgARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQBAQAA
+AAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADEAAAAT/n/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgB
+AQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k8P3BocCBlY2hvICdiYWRwaHAnOwo=
diff --git a/tests/test-data/bad.php b/tests/test-data/bad.php
deleted file mode 100644 (file)
index 3b7c0f3..0000000
Binary files a/tests/test-data/bad.php and /dev/null differ
diff --git a/tests/test-data/bad.phtml b/tests/test-data/bad.phtml
deleted file mode 100644 (file)
index 3b7c0f3..0000000
Binary files a/tests/test-data/bad.phtml and /dev/null differ
diff --git a/tests/test-data/bad.phtml.png b/tests/test-data/bad.phtml.png
deleted file mode 100644 (file)
index dd15f6e..0000000
Binary files a/tests/test-data/bad.phtml.png and /dev/null differ
diff --git a/version b/version
index 31c2e1d4bb4cfcb6f9e5c7e394aa225b0664fd2e..92d9faea7781a62c65cccb97e9dd1292d1184271 100644 (file)
--- a/version
+++ b/version
@@ -1 +1 @@
-v0.30-dev
+v0.32-dev