/**
* 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));
});
}
}
<?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 = false)
{
if ($default === false) {
$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 = 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 = false)
{
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();
}
}
"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",
--- /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="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"/>
</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)
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
+ background-color: #F2F2F2;
+ height: 100%;
+ display: flex;
+ flex-direction: column;
+}
}
}
+#content {
+ flex: 1 0 auto;
+}
+
/**
* Flexbox layout system
*/
@import "codemirror";
@import "components";
@import "header";
+@import "footer";
@import "lists";
@import "pages";
@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>
--- /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>
--}}
<?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 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...
--- /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);
}
/**
--- /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=