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
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.
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
$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),
];
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);
}
$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;
}
// 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.
// 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,
'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),
],
];
'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'),
+ ],
+
];
use Illuminate\Console\Command;
use Illuminate\Database\Connection;
+use Illuminate\Support\Facades\DB;
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.
$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);
}
+++ /dev/null
-<?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';
- }
-}
*/
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');
*/
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 = [
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', [
// 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');
/**
* 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');
--- /dev/null
+<?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;
+ }
+ }
+}
\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,
+++ /dev/null
-<?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);
- }
-}
$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
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) {
/**
* 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;
}
/**
* 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)
{
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;
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));
});
}
}
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;
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);
});
<?php namespace BookStack\Settings;
+use BookStack\Auth\User;
use Illuminate\Contracts\Cache\Repository as Cache;
/**
*/
class SettingService
{
-
protected $setting;
protected $cache;
protected $localCache = [];
/**
* SettingService constructor.
- * @param Setting $setting
- * @param Cache $cache
*/
public function __construct(Setting $setting, Cache $cache)
{
/**
* 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);
}
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;
/**
* 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);
}
/**
* 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);
}
/**
* 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;
$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);
/**
* 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;
}
/**
* 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();
}
}
/**
* 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);
"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",
"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",
--- /dev/null
+<?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');
+ });
+ }
+}
node:
image: node:alpine
working_dir: /app
+ user: node
volumes:
- ./:/app
entrypoint: /app/dev/docker/entrypoint.node.sh
<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"/>
<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>
[](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
[](https://crowdin.com/project/bookstack)
[](https://github.com/BookStackApp/BookStack/actions)
-[](https://discord.gg/ztkBqR2)
+[](https://discord.gg/ztkBqR2)
+[](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/.
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).
* [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)
+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;
});
}
- 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() {
}
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();
}
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;
}
}
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;
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);
// 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();
Code.highlight();
this.setupPointer();
this.setupNavHighlighting();
+ this.setupDetailsCodeBlockRefresh();
// Check the hash on load
if (window.location.hash) {
});
}
}
+
+ 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;
showPopup(editor);
});
- editor.on('SetContent', function () {
+ function parseCodeMirrorInstances() {
// Recover broken codemirror instances
$('.CodeMirrorContainer').filter((index ,elem) => {
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);
});
});
theme: getTheme(),
readOnly: true
});
- setTimeout(() => {
- cm.refresh();
- }, 300);
+
return {wrap: newWrap, editor: cm};
}
// 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',
];
'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.',
--- /dev/null
+/**
+ * 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
*/
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) {
}
-.header-search {
- display: inline-block;
-}
header .search-box {
display: inline-block;
margin-top: 10px;
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;
+}
}
}
+#content {
+ flex: 1 0 auto;
+}
+
/**
* Flexbox layout system
*/
.tri-layout-middle {
grid-area: b;
padding-top: $-m;
+ min-width: 0;
}
}
@include smaller-than($xxl) {
flex: 1;
text-align: start;
}
+ > .content {
+ min-width: 0;
+ }
&:not(.no-hover) {
cursor: pointer;
}
}
.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.
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 {
@import "codemirror";
@import "components";
@import "header";
+@import "footer";
@import "lists";
@import "pages";
.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;
@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>
<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>
<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
--- /dev/null
+@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
<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>
<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
<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
@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>
</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>
<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
--}}
<?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>
<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
</div>
<iframe src="about:blank" class="markdown-display" sandbox="allow-same-origin"></iframe>
</div>
- <input type="hidden" name="html"/>
-
</div>
{!! 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>
<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
?>
<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') !!}
<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">
--- /dev/null
+{{--
+$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
</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>
<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
<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>
<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>
</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>
</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">
</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
</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>
</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>
<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>
<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)
<?php
+Route::get('/status', 'StatusController@show');
Route::get('/robots.txt', 'HomeController@getRobots');
// Authenticated routes...
use BookStack\Auth\Role;
use BookStack\Auth\Access\Ldap;
use BookStack\Auth\User;
+use BookStack\Exceptions\LdapException;
use Mockery\MockInterface;
use Tests\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) {
]);
}
+ 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']);
{
$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'));
}
}
--- /dev/null
+<?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', [
+ '--name' => 'Admin Test',
+ '--password' => 'testing-4',
+ ]);
+ $this->assertTrue($exitCode === 0, 'Command executed successfully');
+
+ $this->assertDatabaseHas('users', [
+ '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
--- /dev/null
+<?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
--- /dev/null
+<?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
--- /dev/null
+<?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
--- /dev/null
+<?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
--- /dev/null
+<?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
--- /dev/null
+<?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
--- /dev/null
+<?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
+++ /dev/null
-<?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', [
- '--name' => 'Admin Test',
- '--password' => 'testing-4',
- ]);
- $this->assertTrue($exitCode === 0, 'Command executed successfully');
-
- $this->assertDatabaseHas('users', [
- '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",
- ]);
- }
-}
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();
<?php namespace Tests\Entity;
-
use BookStack\Entities\Models\Chapter;
use BookStack\Entities\Models\Page;
use Illuminate\Support\Facades\Storage;
$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();
--- /dev/null
+<?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
--- /dev/null
+<?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
$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);
$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);
$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);
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';
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);
}
/**
$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
--- /dev/null
+/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=
--- /dev/null
+iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
+B3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUH
+AAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=
--- /dev/null
+/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=