From: Dan Brown Date: Tue, 2 Mar 2021 21:43:30 +0000 (+0000) Subject: Merge branch 'v0.31.x' X-Git-Tag: v21.04~1^2~11^2~35 X-Git-Url: http://source.bookstackapp.com/bookstack/commitdiff_plain/60030a774d8641a1eabc5ce44274bc3b1f8eb25e?hp=26730e56ead5598e8845d7f7dbd957f60573341f Merge branch 'v0.31.x' --- diff --git a/.env.example.complete b/.env.example.complete index e3dbdb857..a42054b6b 100644 --- a/.env.example.complete +++ b/.env.example.complete @@ -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. diff --git a/app/Auth/Access/Ldap.php b/app/Auth/Access/Ldap.php index 6b7bd9b9b..352231df5 100644 --- a/app/Auth/Access/Ldap.php +++ b/app/Auth/Access/Ldap.php @@ -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 diff --git a/app/Auth/Access/LdapService.php b/app/Auth/Access/LdapService.php index 92234edcf..a438c0984 100644 --- a/app/Auth/Access/LdapService.php +++ b/app/Auth/Access/LdapService.php @@ -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; } diff --git a/app/Config/app.php b/app/Config/app.php index 762845e9f..ea9738da4 100755 --- a/app/Config/app.php +++ b/app/Config/app.php @@ -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, diff --git a/app/Config/services.php b/app/Config/services.php index fcde621d2..699339614 100644 --- a/app/Config/services.php +++ b/app/Config/services.php @@ -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), ], ]; diff --git a/app/Config/setting-defaults.php b/app/Config/setting-defaults.php index d84c0c264..879c636bc 100644 --- a/app/Config/setting-defaults.php +++ b/app/Config/setting-defaults.php @@ -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'), + ], + ]; diff --git a/app/Console/Commands/UpdateUrl.php b/app/Console/Commands/UpdateUrl.php index b95e277d1..2a1688468 100644 --- a/app/Console/Commands/UpdateUrl.php +++ b/app/Console/Commands/UpdateUrl.php @@ -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. diff --git a/app/Entities/Repos/PageRepo.php b/app/Entities/Repos/PageRepo.php index bc6476824..6a4eaeb15 100644 --- a/app/Entities/Repos/PageRepo.php +++ b/app/Entities/Repos/PageRepo.php @@ -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 index 80feef89b..000000000 --- a/app/Facades/Setting.php +++ /dev/null @@ -1,16 +0,0 @@ -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'); diff --git a/app/Http/Controllers/BookshelfController.php b/app/Http/Controllers/BookshelfController.php index 32c22e185..8574c1b48 100644 --- a/app/Http/Controllers/BookshelfController.php +++ b/app/Http/Controllers/BookshelfController.php @@ -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', [ diff --git a/app/Http/Controllers/HomeController.php b/app/Http/Controllers/HomeController.php index d97740d27..31736e1b0 100644 --- a/app/Http/Controllers/HomeController.php +++ b/app/Http/Controllers/HomeController.php @@ -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 index 000000000..9f4ed4d89 --- /dev/null +++ b/app/Http/Controllers/StatusController.php @@ -0,0 +1,47 @@ + $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; + } + } +} diff --git a/app/Http/Kernel.php b/app/Http/Kernel.php index 532942f23..075c98ec7 100644 --- a/app/Http/Kernel.php +++ b/app/Http/Kernel.php @@ -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 index bc132dfc3..000000000 --- a/app/Http/Middleware/GlobalViewData.php +++ /dev/null @@ -1,27 +0,0 @@ -share('signedIn', auth()->check()); - view()->share('currentUser', user()); - - return $next($request); - } -} diff --git a/app/Http/Middleware/Localization.php b/app/Http/Middleware/Localization.php index 6a8ec237d..597d28365 100644 --- a/app/Http/Middleware/Localization.php +++ b/app/Http/Middleware/Localization.php @@ -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) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 1c6180a1f..7673050f8 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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)); }); } } diff --git a/app/Providers/CustomFacadeProvider.php b/app/Providers/CustomFacadeProvider.php index b4158187c..0918c0aba 100644 --- a/app/Providers/CustomFacadeProvider.php +++ b/app/Providers/CustomFacadeProvider.php @@ -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); }); diff --git a/app/Settings/SettingService.php b/app/Settings/SettingService.php index 1c053b384..feb54c30a 100644 --- a/app/Settings/SettingService.php +++ b/app/Settings/SettingService.php @@ -1,5 +1,6 @@ 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(); } } diff --git a/app/helpers.php b/app/helpers.php index c090bfd05..c1d72b91d 100644 --- a/app/helpers.php +++ b/app/helpers.php @@ -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); diff --git a/composer.json b/composer.json index 68d33499b..e6a833291 100644 --- a/composer.json +++ b/composer.json @@ -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", diff --git a/composer.lock b/composer.lock index 8b8502052..7a8682ef6 100644 --- a/composer.lock +++ b/composer.lock @@ -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 index 000000000..61d9bda41 --- /dev/null +++ b/database/migrations/2021_01_30_225441_add_settings_type_column.php @@ -0,0 +1,32 @@ +string('type', 50)->default('string'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('settings', function (Blueprint $table) { + $table->dropColumn('type'); + }); + } +} diff --git a/docker-compose.yml b/docker-compose.yml index ea7a61ab5..39f5bdc18 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -39,6 +39,7 @@ services: node: image: node:alpine working_dir: /app + user: node volumes: - ./:/app entrypoint: /app/dev/docker/entrypoint.node.sh diff --git a/phpunit.xml b/phpunit.xml index 8d69a5fdd..dd3e53c08 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -36,6 +36,7 @@ + @@ -55,5 +56,7 @@ + + diff --git a/readme.md b/readme.md index fd61a62c7..a19341ce4 100644 --- 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) diff --git a/resources/js/components/entity-selector.js b/resources/js/components/entity-selector.js index 58879a20c..6d9d06f86 100644 --- a/resources/js/components/entity-selector.js +++ b/resources/js/components/entity-selector.js @@ -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; diff --git a/resources/js/components/markdown-editor.js b/resources/js/components/markdown-editor.js index bd107f2bf..78581ec44 100644 --- a/resources/js/components/markdown-editor.js +++ b/resources/js/components/markdown-editor.js @@ -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(); diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js index 2be1c1c48..cc55fe35e 100644 --- a/resources/js/components/page-display.js +++ b/resources/js/components/page-display.js @@ -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; diff --git a/resources/js/components/wysiwyg-editor.js b/resources/js/components/wysiwyg-editor.js index 41b2273e2..a6ab54218 100644 --- a/resources/js/components/wysiwyg-editor.js +++ b/resources/js/components/wysiwyg-editor.js @@ -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); }); }); diff --git a/resources/js/services/code.js b/resources/js/services/code.js index e2aca1aad..5727cd2b7 100644 --- a/resources/js/services/code.js +++ b/resources/js/services/code.js @@ -238,9 +238,7 @@ function wysiwygView(elem) { theme: getTheme(), readOnly: true }); - setTimeout(() => { - cm.refresh(); - }, 300); + return {wrap: newWrap, editor: cm}; } diff --git a/resources/lang/en/common.php b/resources/lang/en/common.php index e87bd11a5..e048db90f 100644 --- a/resources/lang/en/common.php +++ b/resources/lang/en/common.php @@ -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', ]; diff --git a/resources/lang/en/settings.php b/resources/lang/en/settings.php index 414650d21..bd55668a2 100755 --- a/resources/lang/en/settings.php +++ b/resources/lang/en/settings.php @@ -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::" 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.
Existing comments are not shown.', diff --git a/resources/sass/_footer.scss b/resources/sass/_footer.scss new file mode 100644 index 000000000..1c58bccd9 --- /dev/null +++ b/resources/sass/_footer.scss @@ -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 diff --git a/resources/sass/_header.scss b/resources/sass/_header.scss index 246ef4b5b..3e8c676fd 100644 --- a/resources/sass/_header.scss +++ b/resources/sass/_header.scss @@ -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; diff --git a/resources/sass/_html.scss b/resources/sass/_html.scss index 57869d652..1d5defa97 100644 --- a/resources/sass/_html.scss +++ b/resources/sass/_html.scss @@ -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; +} diff --git a/resources/sass/_layout.scss b/resources/sass/_layout.scss index 4873ff2da..60205eaaa 100644 --- a/resources/sass/_layout.scss +++ b/resources/sass/_layout.scss @@ -95,6 +95,10 @@ } } +#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) { diff --git a/resources/sass/_lists.scss b/resources/sass/_lists.scss index a3a58e6c6..d6ea66350 100644 --- a/resources/sass/_lists.scss +++ b/resources/sass/_lists.scss @@ -430,6 +430,9 @@ ul.pagination { flex: 1; text-align: start; } + > .content { + min-width: 0; + } &:not(.no-hover) { cursor: pointer; } diff --git a/resources/sass/_tinymce.scss b/resources/sass/_tinymce.scss index dfaf6683e..05f48b073 100644 --- a/resources/sass/_tinymce.scss +++ b/resources/sass/_tinymce.scss @@ -63,8 +63,14 @@ } .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. diff --git a/resources/sass/export-styles.scss b/resources/sass/export-styles.scss index 6d9a1a718..278e5b6c5 100644 --- a/resources/sass/export-styles.scss +++ b/resources/sass/export-styles.scss @@ -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 { diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss index 78d94f977..743db9888 100644 --- a/resources/sass/styles.scss +++ b/resources/sass/styles.scss @@ -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; diff --git a/resources/views/base.blade.php b/resources/views/base.blade.php index a5404a365..29e4acee7 100644 --- a/resources/views/base.blade.php +++ b/resources/views/base.blade.php @@ -35,6 +35,8 @@ @yield('content') + @include('common.footer') +