]> BookStack Code Mirror - bookstack/commitdiff
Merge branch 'v0.31.x'
authorDan Brown <redacted>
Sat, 6 Feb 2021 14:22:19 +0000 (14:22 +0000)
committerDan Brown <redacted>
Sat, 6 Feb 2021 14:22:19 +0000 (14:22 +0000)
55 files changed:
app/Http/Controllers/HomeController.php
app/Http/Controllers/StatusController.php [new file with mode: 0644]
app/Http/Kernel.php
app/Http/Middleware/GlobalViewData.php [deleted file]
app/Http/Middleware/Localization.php
app/Providers/AppServiceProvider.php
app/Settings/SettingService.php
composer.json
database/migrations/2021_01_30_225441_add_settings_type_column.php [new file with mode: 0644]
docker-compose.yml
phpunit.xml
readme.md
resources/js/components/page-display.js
resources/js/components/wysiwyg-editor.js
resources/js/services/code.js
resources/lang/en/common.php
resources/lang/en/settings.php
resources/sass/_footer.scss [new file with mode: 0644]
resources/sass/_header.scss
resources/sass/_html.scss
resources/sass/_layout.scss
resources/sass/styles.scss
resources/views/base.blade.php
resources/views/books/index.blade.php
resources/views/common/footer.blade.php [new file with mode: 0644]
resources/views/common/header.blade.php
resources/views/common/home-book.blade.php
resources/views/common/home-shelves.blade.php
resources/views/common/home-sidebar.blade.php
resources/views/common/home.blade.php
resources/views/components/expand-toggle.blade.php
resources/views/form/password.blade.php
resources/views/partials/sort.blade.php
resources/views/partials/view-toggle.blade.php
resources/views/settings/footer-links.blade.php [new file with mode: 0644]
resources/views/settings/index.blade.php
resources/views/settings/navbar.blade.php
resources/views/settings/roles/form.blade.php
resources/views/shelves/index.blade.php
resources/views/users/create.blade.php
resources/views/users/edit.blade.php
resources/views/users/form.blade.php
resources/views/users/index.blade.php
routes/web.php
tests/FooterLinksTest.php [new file with mode: 0644]
tests/StatusTest.php [new file with mode: 0644]
tests/Uploads/ImageTest.php
tests/Uploads/UsesImages.php
tests/test-data/bad-php.base64 [new file with mode: 0644]
tests/test-data/bad-phtml-png.base64 [new file with mode: 0644]
tests/test-data/bad-phtml.base64 [new file with mode: 0644]
tests/test-data/bad.php [deleted file]
tests/test-data/bad.phtml [deleted file]
tests/test-data/bad.phtml.png [deleted file]
version

index d97740d2725f01d8bedda10e687fd915d5e08deb..3258f43693d5600a26985caa5de675ab04c6f8a5 100644 (file)
@@ -110,15 +110,16 @@ class HomeController extends Controller
 
     /**
      * Show the view for /robots.txt
-     * @return $this
      */
     public function getRobots()
     {
         $sitePublic = setting('app-public', false);
         $allowRobots = config('app.allow_robots');
+
         if ($allowRobots === null) {
             $allowRobots = $sitePublic;
         }
+
         return response()
             ->view('common.robots', ['allowRobots' => $allowRobots])
             ->header('Content-Type', 'text/plain');
diff --git a/app/Http/Controllers/StatusController.php b/app/Http/Controllers/StatusController.php
new file mode 100644 (file)
index 0000000..9f4ed4d
--- /dev/null
@@ -0,0 +1,47 @@
+<?php namespace BookStack\Http\Controllers;
+
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Session;
+use Illuminate\Support\Str;
+
+class StatusController extends Controller
+{
+
+    /**
+     * Show the system status as a simple json page.
+     */
+    public function show()
+    {
+        $statuses = [
+            'database' => $this->trueWithoutError(function () {
+                return DB::table('migrations')->count() > 0;
+            }),
+            'cache' => $this->trueWithoutError(function () {
+                $rand = Str::random();
+                Cache::set('status_test', $rand);
+                return Cache::get('status_test') === $rand;
+            }),
+            'session' => $this->trueWithoutError(function () {
+                $rand = Str::random();
+                Session::put('status_test', $rand);
+                return Session::get('status_test') === $rand;
+            }),
+        ];
+
+        $hasError = in_array(false, $statuses);
+        return response()->json($statuses, $hasError ? 500 : 200);
+    }
+
+    /**
+     * Check the callable passed returns true and does not throw an exception.
+     */
+    protected function trueWithoutError(callable $test): bool
+    {
+        try {
+            return $test() === true;
+        } catch (\Exception $e) {
+            return false;
+        }
+    }
+}
index 532942f23e5b8f4b396fdd6601c002ddcf4390fd..075c98ec77b7509087d1f66a2981d7ed26a86328 100644 (file)
@@ -29,7 +29,6 @@ class Kernel extends HttpKernel
             \Illuminate\View\Middleware\ShareErrorsFromSession::class,
             \BookStack\Http\Middleware\VerifyCsrfToken::class,
             \BookStack\Http\Middleware\Localization::class,
-            \BookStack\Http\Middleware\GlobalViewData::class,
         ],
         'api' => [
             \BookStack\Http\Middleware\ThrottleApiRequests::class,
diff --git a/app/Http/Middleware/GlobalViewData.php b/app/Http/Middleware/GlobalViewData.php
deleted file mode 100644 (file)
index bc132df..0000000
+++ /dev/null
@@ -1,27 +0,0 @@
-<?php namespace BookStack\Http\Middleware;
-
-use Closure;
-use Illuminate\Http\Request;
-
-/**
- * Class GlobalViewData
- * Sets up data that is accessible to any view rendered by the web routes.
- */
-class GlobalViewData
-{
-
-    /**
-     * Handle an incoming request.
-     *
-     * @param Request $request
-     * @param Closure $next
-     * @return mixed
-     */
-    public function handle(Request $request, Closure $next)
-    {
-        view()->share('signedIn', auth()->check());
-        view()->share('currentUser', user());
-
-        return $next($request);
-    }
-}
index 6a8ec237dd0b47b5a3be5cb7272dc456fecd33f7..597d2836548286ac8afb20f5677119f5b8ca2be3 100644 (file)
@@ -57,12 +57,7 @@ class Localization
         $defaultLang = config('app.locale');
         config()->set('app.default_locale', $defaultLang);
 
-        if (user()->isDefault() && config('app.auto_detect_locale')) {
-            $locale = $this->autoDetectLocale($request, $defaultLang);
-        } else {
-            $locale = setting()->getUser(user(), 'language', $defaultLang);
-        }
-
+        $locale = $this->getUserLocale($request, $defaultLang);
         config()->set('app.lang', str_replace('_', '-', $this->getLocaleIso($locale)));
 
         // Set text direction
@@ -76,14 +71,29 @@ class Localization
         return $next($request);
     }
 
+    /**
+     * Get the locale specifically for the currently logged in user if available.
+     */
+    protected function getUserLocale(Request $request, string $default): string
+    {
+        try {
+            $user = user();
+        } catch (\Exception $exception) {
+            return $default;
+        }
+
+        if ($user->isDefault() && config('app.auto_detect_locale')) {
+            return $this->autoDetectLocale($request, $default);
+        }
+
+        return setting()->getUser($user, 'language', $default);
+    }
+
     /**
      * Autodetect the visitors locale by matching locales in their headers
      * against the locales supported by BookStack.
-     * @param Request $request
-     * @param string $default
-     * @return string
      */
-    protected function autoDetectLocale(Request $request, string $default)
+    protected function autoDetectLocale(Request $request, string $default): string
     {
         $availableLocales = config('app.locales');
         foreach ($request->getLanguages() as $lang) {
@@ -96,10 +106,8 @@ class Localization
 
     /**
      * Get the ISO version of a BookStack language name
-     * @param  string $locale
-     * @return string
      */
-    public function getLocaleIso(string $locale)
+    public function getLocaleIso(string $locale): string
     {
         return $this->localeMap[$locale] ?? $locale;
     }
@@ -107,7 +115,6 @@ class Localization
     /**
      * Set the system date locale for localized date formatting.
      * Will try both the standard locale name and the UTF8 variant.
-     * @param string $locale
      */
     protected function setSystemDateLocale(string $locale)
     {
index 1c6180a1f4b3c3329bbb28632b4bca076cd19b8e..7673050f8f51bd8d1105ac3a4c6f5e79f7a25885 100644 (file)
@@ -8,6 +8,7 @@ use BookStack\Entities\Models\Chapter;
 use BookStack\Entities\Models\Page;
 use BookStack\Settings\Setting;
 use BookStack\Settings\SettingService;
+use Illuminate\Contracts\Cache\Repository;
 use Illuminate\Database\Eloquent\Relations\Relation;
 use Illuminate\Support\Facades\View;
 use Illuminate\Support\ServiceProvider;
@@ -59,7 +60,7 @@ class AppServiceProvider extends ServiceProvider
     public function register()
     {
         $this->app->singleton(SettingService::class, function ($app) {
-            return new SettingService($app->make(Setting::class), $app->make('Illuminate\Contracts\Cache\Repository'));
+            return new SettingService($app->make(Setting::class), $app->make(Repository::class));
         });
     }
 }
index 1c053b3848ea779d480adea01834dce0059d629d..042ae7aa4bf8e4aa86b5e02a50951c0574d23431 100644 (file)
@@ -1,5 +1,6 @@
 <?php namespace BookStack\Settings;
 
+use BookStack\Auth\User;
 use Illuminate\Contracts\Cache\Repository as Cache;
 
 /**
@@ -9,7 +10,6 @@ use Illuminate\Contracts\Cache\Repository as Cache;
  */
 class SettingService
 {
-
     protected $setting;
     protected $cache;
     protected $localCache = [];
@@ -18,8 +18,6 @@ class SettingService
 
     /**
      * SettingService constructor.
-     * @param Setting $setting
-     * @param Cache   $cache
      */
     public function __construct(Setting $setting, Cache $cache)
     {
@@ -30,11 +28,8 @@ class SettingService
     /**
      * Gets a setting from the database,
      * If not found, Returns default, Which is false by default.
-     * @param             $key
-     * @param string|bool $default
-     * @return bool|string
      */
-    public function get($key, $default = false)
+    public function get(string $key, $default = false)
     {
         if ($default === false) {
             $default = config('setting-defaults.' . $key, false);
@@ -44,7 +39,7 @@ class SettingService
             return $this->localCache[$key];
         }
 
-        $value = $this->getValueFromStore($key, $default);
+        $value = $this->getValueFromStore($key) ?? $default;
         $formatted = $this->formatValue($value, $default);
         $this->localCache[$key] = $formatted;
         return $formatted;
@@ -52,25 +47,17 @@ class SettingService
 
     /**
      * Get a value from the session instead of the main store option.
-     * @param $key
-     * @param bool $default
-     * @return mixed
      */
-    protected function getFromSession($key, $default = false)
+    protected function getFromSession(string $key, $default = false)
     {
         $value = session()->get($key, $default);
-        $formatted = $this->formatValue($value, $default);
-        return $formatted;
+        return $this->formatValue($value, $default);
     }
 
     /**
      * Get a user-specific setting from the database or cache.
-     * @param \BookStack\Auth\User $user
-     * @param $key
-     * @param bool $default
-     * @return bool|string
      */
-    public function getUser($user, $key, $default = false)
+    public function getUser(User $user, string $key, $default = false)
     {
         if ($user->isDefault()) {
             return $this->getFromSession($key, $default);
@@ -80,11 +67,8 @@ class SettingService
 
     /**
      * Get a value for the current logged-in user.
-     * @param $key
-     * @param bool $default
-     * @return bool|string
      */
-    public function getForCurrentUser($key, $default = false)
+    public function getForCurrentUser(string $key, $default = false)
     {
         return $this->getUser(user(), $key, $default);
     }
@@ -92,11 +76,9 @@ class SettingService
     /**
      * Gets a setting value from the cache or database.
      * Looks at the system defaults if not cached or in database.
-     * @param $key
-     * @param $default
-     * @return mixed
+     * Returns null if nothing is found.
      */
-    protected function getValueFromStore($key, $default)
+    protected function getValueFromStore(string $key)
     {
         // Check the cache
         $cacheKey = $this->cachePrefix . $key;
@@ -109,18 +91,22 @@ class SettingService
         $settingObject = $this->getSettingObjectByKey($key);
         if ($settingObject !== null) {
             $value = $settingObject->value;
+
+            if ($settingObject->type === 'array') {
+                $value = json_decode($value, true) ?? [];
+            }
+
             $this->cache->forever($cacheKey, $value);
             return $value;
         }
 
-        return $default;
+        return null;
     }
 
     /**
      * Clear an item from the cache completely.
-     * @param $key
      */
-    protected function clearFromCache($key)
+    protected function clearFromCache(string $key)
     {
         $cacheKey = $this->cachePrefix . $key;
         $this->cache->forget($cacheKey);
@@ -131,17 +117,13 @@ class SettingService
 
     /**
      * Format a settings value
-     * @param $value
-     * @param $default
-     * @return mixed
      */
     protected function formatValue($value, $default)
     {
         // Change string booleans to actual booleans
         if ($value === 'true') {
             $value = true;
-        }
-        if ($value === 'false') {
+        } else if ($value === 'false') {
             $value = false;
         }
 
@@ -154,99 +136,97 @@ class SettingService
 
     /**
      * Checks if a setting exists.
-     * @param $key
-     * @return bool
      */
-    public function has($key)
+    public function has(string $key): bool
     {
         $setting = $this->getSettingObjectByKey($key);
         return $setting !== null;
     }
 
-    /**
-     * Check if a user setting is in the database.
-     * @param $key
-     * @return bool
-     */
-    public function hasUser($key)
-    {
-        return $this->has($this->userKey($key));
-    }
-
     /**
      * Add a setting to the database.
-     * @param $key
-     * @param $value
-     * @return bool
+     * Values can be an array or a string.
      */
-    public function put($key, $value)
+    public function put(string $key, $value): bool
     {
-        $setting = $this->setting->firstOrNew([
+        $setting = $this->setting->newQuery()->firstOrNew([
             'setting_key' => $key
         ]);
+        $setting->type = 'string';
+
+        if (is_array($value)) {
+            $setting->type = 'array';
+            $value = $this->formatArrayValue($value);
+        }
+
         $setting->value = $value;
         $setting->save();
         $this->clearFromCache($key);
         return true;
     }
 
+    /**
+     * Format an array to be stored as a setting.
+     * Array setting types are expected to be a flat array of child key=>value array items.
+     * This filters out any child items that are empty.
+     */
+    protected function formatArrayValue(array $value): string
+    {
+        $values = collect($value)->values()->filter(function(array $item) {
+            return count(array_filter($item)) > 0;
+        });
+        return json_encode($values);
+    }
+
     /**
      * Put a user-specific setting into the database.
-     * @param \BookStack\Auth\User $user
-     * @param $key
-     * @param $value
-     * @return bool
      */
-    public function putUser($user, $key, $value)
+    public function putUser(User $user, string $key, string $value): bool
     {
         if ($user->isDefault()) {
-            return session()->put($key, $value);
+            session()->put($key, $value);
+            return true;
         }
+
         return $this->put($this->userKey($user->id, $key), $value);
     }
 
     /**
      * Convert a setting key into a user-specific key.
-     * @param $key
-     * @return string
      */
-    protected function userKey($userId, $key = '')
+    protected function userKey(string $userId, string $key = ''): string
     {
         return 'user:' . $userId . ':' . $key;
     }
 
     /**
      * Removes a setting from the database.
-     * @param $key
-     * @return bool
      */
-    public function remove($key)
+    public function remove(string $key): void
     {
         $setting = $this->getSettingObjectByKey($key);
         if ($setting) {
             $setting->delete();
         }
         $this->clearFromCache($key);
-        return true;
     }
 
     /**
      * Delete settings for a given user id.
-     * @param $userId
-     * @return mixed
      */
-    public function deleteUserSettings($userId)
+    public function deleteUserSettings(string $userId)
     {
-        return $this->setting->where('setting_key', 'like', $this->userKey($userId) . '%')->delete();
+        return $this->setting->newQuery()
+            ->where('setting_key', 'like', $this->userKey($userId) . '%')
+            ->delete();
     }
 
     /**
      * Gets a setting model from the database for the given key.
-     * @param $key
-     * @return mixed
      */
-    protected function getSettingObjectByKey($key)
+    protected function getSettingObjectByKey(string $key): ?Setting
     {
-        return $this->setting->where('setting_key', '=', $key)->first();
+        return $this->setting->newQuery()
+            ->where('setting_key', '=', $key)->first();
     }
 }
index 68d33499bba0ea97926c293ced4e3ba91fb04d81..e6a833291db2db75a38b47a5cc0b2149fd33a78a 100644 (file)
@@ -31,7 +31,7 @@
         "socialiteproviders/okta": "^4.1",
         "socialiteproviders/slack": "^4.1",
         "socialiteproviders/twitch": "^5.3",
-        "ssddanbrown/htmldiff": "^1.0"
+        "ssddanbrown/htmldiff": "^v1.0.1"
     },
     "require-dev": {
         "barryvdh/laravel-debugbar": "^3.5.1",
diff --git a/database/migrations/2021_01_30_225441_add_settings_type_column.php b/database/migrations/2021_01_30_225441_add_settings_type_column.php
new file mode 100644 (file)
index 0000000..61d9bda
--- /dev/null
@@ -0,0 +1,32 @@
+<?php
+
+use Illuminate\Database\Migrations\Migration;
+use Illuminate\Database\Schema\Blueprint;
+use Illuminate\Support\Facades\Schema;
+
+class AddSettingsTypeColumn extends Migration
+{
+    /**
+     * Run the migrations.
+     *
+     * @return void
+     */
+    public function up()
+    {
+        Schema::table('settings', function (Blueprint $table) {
+            $table->string('type', 50)->default('string');
+        });
+    }
+
+    /**
+     * Reverse the migrations.
+     *
+     * @return void
+     */
+    public function down()
+    {
+        Schema::table('settings', function (Blueprint $table) {
+            $table->dropColumn('type');
+        });
+    }
+}
index ea7a61ab554aea7540a28655d0e9c61822550f8a..39f5bdc18d11a997b5bfd71905ff1704966207a4 100644 (file)
@@ -39,6 +39,7 @@ services:
   node:
     image: node:alpine
     working_dir: /app
+    user: node
     volumes:
       - ./:/app
     entrypoint: /app/dev/docker/entrypoint.node.sh
index 8d69a5fddd3a8eee35101cceff94c6e3acd567ec..0332182883ceb77a5b477842593053aa5181ee11 100644 (file)
@@ -55,5 +55,6 @@
         <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>
index fd61a62c7611d72701f84277e02388371d65f3a7..a19341ce4e95dd7769871a137167e9fc90068ec1 100644 (file)
--- a/readme.md
+++ b/readme.md
@@ -4,7 +4,8 @@
 [![license](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/BookStackApp/BookStack/blob/master/LICENSE)
 [![Crowdin](https://badges.crowdin.net/bookstack/localized.svg)](https://crowdin.com/project/bookstack)
 [![Build Status](https://github.com/BookStackApp/BookStack/workflows/phpunit/badge.svg)](https://github.com/BookStackApp/BookStack/actions)
-[![Discord](https://img.shields.io/static/v1?label=Chat&message=Discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
+[![Discord](https://img.shields.io/static/v1?label=chat&message=discord&color=738adb&logo=discord)](https://discord.gg/ztkBqR2)
+[![Repo Stats](https://img.shields.io/static/v1?label=GitHub+project&message=stats&color=f27e3f)](https://gh-stats.bookstackapp.com/)
 
 A platform for storing and organising information and documentation. Details for BookStack can be found on the official website at https://www.bookstackapp.com/.
 
@@ -121,7 +122,7 @@ Feel free to create issues to request new features or to report bugs & problems.
 
 Pull requests are welcome. Unless a small tweak or language update, It may be best to open the pull request early or create an issue for your intended change to discuss how it will fit in to the project and plan out the merge. Just because a feature request exists, or is tagged, does not mean that feature would be accepted into the core project.
 
-Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/assets`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
+Pull requests should be created from the `master` branch since they will be merged back into `master` once done. Please do not build from or request a merge into the `release` branch as this is only for publishing releases. If you are looking to alter CSS or JavaScript content please edit the source files found in `resources/`. Any CSS or JS files within `public` are built from these source files and therefore should not be edited directly.
 
 The project's code of conduct [can be found here](https://github.com/BookStackApp/BookStack/blob/master/.github/CODE_OF_CONDUCT.md).
 
@@ -169,3 +170,5 @@ These are the great open-source projects used to help build BookStack:
 * [WKHTMLtoPDF](http://wkhtmltopdf.org/index.html)
 * [diagrams.net](https://github.com/jgraph/drawio)
 * [OneLogin's SAML PHP Toolkit](https://github.com/onelogin/php-saml)
+* [League/CommonMark](https://commonmark.thephpleague.com/)
+* [League/Flysystem](https://flysystem.thephpleague.com)
index 2be1c1c48b8cc93f5ac147b64023cf910eabc3fa..cc55fe35e1e38caae9de9f9cca013e0c7b1d14f6 100644 (file)
@@ -12,6 +12,7 @@ class PageDisplay {
         Code.highlight();
         this.setupPointer();
         this.setupNavHighlighting();
+        this.setupDetailsCodeBlockRefresh();
 
         // Check the hash on load
         if (window.location.hash) {
@@ -196,6 +197,16 @@ class PageDisplay {
             });
         }
     }
+
+    setupDetailsCodeBlockRefresh() {
+        const onToggle = event => {
+            const codeMirrors = [...event.target.querySelectorAll('.CodeMirror')];
+            codeMirrors.forEach(cm => cm.CodeMirror && cm.CodeMirror.refresh());
+        };
+
+        const details = [...this.elem.querySelectorAll('details')];
+        details.forEach(detail => detail.addEventListener('toggle', onToggle));
+    }
 }
 
 export default PageDisplay;
index 41b2273e2a0ad48918e5e50c334f5c1e29ef319f..a6ab542181b9c20558bf0f030b98254181fc804c 100644 (file)
@@ -212,7 +212,7 @@ function codePlugin() {
             showPopup(editor);
         });
 
-        editor.on('SetContent', function () {
+        function parseCodeMirrorInstances() {
 
             // Recover broken codemirror instances
             $('.CodeMirrorContainer').filter((index ,elem) => {
@@ -231,6 +231,17 @@ function codePlugin() {
                     Code.wysiwygView(elem);
                 });
             });
+        }
+
+        editor.on('init', function() {
+            // Parse code mirror instances on init, but delay a little so this runs after
+            // initial styles are fetched into the editor.
+            parseCodeMirrorInstances();
+            // Parsed code mirror blocks when content is set but wait before setting this handler
+            // to avoid any init 'SetContent' events.
+            setTimeout(() => {
+                editor.on('SetContent', parseCodeMirrorInstances);
+            }, 200);
         });
 
     });
index e2aca1aad9e681d94a23763c95e550712e1bedfa..5727cd2b79a06f4e68b83eb68f1e1e34e2424300 100644 (file)
@@ -238,9 +238,7 @@ function wysiwygView(elem) {
         theme: getTheme(),
         readOnly: true
     });
-    setTimeout(() => {
-        cm.refresh();
-    }, 300);
+
     return {wrap: newWrap, editor: cm};
 }
 
index e87bd11a5e343173fadf78e62a042e1ca0579a4a..e048db90f4c2ff13d17a5fe29d449dac302c95e2 100644 (file)
@@ -77,4 +77,9 @@ return [
     // Email Content
     'email_action_help' => 'If you’re having trouble clicking the ":actionText" button, copy and paste the URL below into your web browser:',
     'email_rights' => 'All rights reserved',
+
+    // Footer Link Options
+    // Not directly used but available for convenience to users.
+    'privacy_policy' => 'Privacy Policy',
+    'terms_of_service' => 'Terms of Service',
 ];
index 414650d21bac0c5187afc841de93f0f0ea40aaed..bd55668a2e94e59b13f395b9fe0655fb28790889 100755 (executable)
@@ -37,6 +37,11 @@ return [
     'app_homepage' => 'Application Homepage',
     'app_homepage_desc' => 'Select a view to show on the homepage instead of the default view. Page permissions are ignored for selected pages.',
     'app_homepage_select' => 'Select a page',
+    'app_footer_links' => 'Footer Links',
+    'app_footer_links_desc' => 'Add links to show within the site footer. These will be displayed at the bottom of most pages, including those that do not require login. You can use a label of "trans::<key>" to use system-defined translations. For example: Using "trans::common.privacy_policy" will provide the translated text "Privacy Policy" and "trans::common.terms_of_service" will provide the translated text "Terms of Service".',
+    'app_footer_links_label' => 'Link Label',
+    'app_footer_links_url' => 'Link URL',
+    'app_footer_links_add' => 'Add Footer Link',
     'app_disable_comments' => 'Disable Comments',
     'app_disable_comments_toggle' => 'Disable comments',
     'app_disable_comments_desc' => 'Disables comments across all pages in the application. <br> Existing comments are not shown.',
diff --git a/resources/sass/_footer.scss b/resources/sass/_footer.scss
new file mode 100644 (file)
index 0000000..1c58bcc
--- /dev/null
@@ -0,0 +1,17 @@
+/**
+ * Includes the footer links.
+ */
+
+ footer {
+    flex-shrink: 0;
+    padding: 1rem 1rem 2rem 1rem;
+    text-align: center;
+  }
+  
+  footer a {
+    margin: 0 .5em;
+  }
+  
+  body.flexbox footer {
+    display: none;
+  }
\ No newline at end of file
index 246ef4b5bdabdfba587b93d96b363edd67407808..3e8c676fdb08bd2d3e838cb9ef7c86f4a77abcfd 100644 (file)
@@ -3,7 +3,7 @@
  */
 
 header .grid {
-  grid-template-columns: auto min-content auto;
+  grid-template-columns: minmax(max-content, 2fr) 1fr minmax(max-content, 2fr);
 }
 
 @include smaller-than($l) {
@@ -77,9 +77,6 @@ header {
 }
 
 
-.header-search {
-  display: inline-block;
-}
 header .search-box {
   display: inline-block;
   margin-top: 10px;
index 57869d6520b04aa6a90ea5405c36d8a95de0927f..0262956691a0f4fc978b72f6ac6d56a25637cf15 100644 (file)
@@ -25,4 +25,8 @@ body {
   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;
+}
index 4873ff2da651ee1575a048a4973f95069fa0a343..c12cae256e6d424dcd3e590c115ecfceca3c20ad 100644 (file)
   }
 }
 
+#content {
+  flex: 1 0 auto;
+}
+
 /**
  * Flexbox layout system
  */
index 78d94f977f8d0043679557148fc00af97a8cd276..614b7f295a5b22e6adb8eaf428207267ed5ec3c1 100644 (file)
@@ -15,6 +15,7 @@
 @import "codemirror";
 @import "components";
 @import "header";
+@import "footer";
 @import "lists";
 @import "pages";
 
index a5404a36506ae9d70225034a6eeef749eeb86d28..29e4acee7bf8355f7bbda9b17f240e1483cf001f 100644 (file)
@@ -35,6 +35,8 @@
         @yield('content')
     </div>
 
+    @include('common.footer')
+
     <div back-to-top class="primary-background print-hidden">
         <div class="inner">
             @icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
index f3c3ee34b1ca2e6b16f869346e936c8d9843995a..81fb66cfcd18148a796cd478ed53b43110a4817c 100644 (file)
@@ -36,7 +36,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
-            @if($currentUser->can('book-create-all'))
+            @if(user()->can('book-create-all'))
                 <a href="{{ url("/create-book") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.books_create') }}</span>
diff --git a/resources/views/common/footer.blade.php b/resources/views/common/footer.blade.php
new file mode 100644 (file)
index 0000000..67b52a6
--- /dev/null
@@ -0,0 +1,7 @@
+@if(count(setting('app-footer-links', [])) > 0)
+<footer>
+    @foreach(setting('app-footer-links', []) as $link)
+        <a href="{{ $link['url'] }}" target="_blank">{{ strpos($link['label'], 'trans::') === 0 ? trans(str_replace('trans::', '', $link['label'])) : $link['label'] }}</a>
+    @endforeach
+</footer>
+@endif
\ No newline at end of file
index 80e79410a04ff3aeee9c4324ca3978e6b4eabf35..43ac273efb648bce16e451e9634bb3dcc260faa7 100644 (file)
@@ -13,7 +13,7 @@
             <div class="mobile-menu-toggle hide-over-l">@icon('more')</div>
         </div>
 
-        <div class="header-search hide-under-l">
+        <div class="flex-container-row justify-center hide-under-l">
             @if (hasAppAccess())
             <form action="{{ url('/search') }}" method="GET" class="search-box" role="search">
                 <button id="header-search-box-button" type="submit" aria-label="{{ trans('common.search') }}" tabindex="-1">@icon('search') </button>
index 3dbcd2875f9eb8457165dea32680a8832623c9d1..1c18edb246bc1ce93040c7af6325d2a0250647c1 100644 (file)
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
+            @if(user()->can('book-create-all'))
+                <a href="{{ url("/create-book") }}" class="icon-list-item">
+                    <span>@icon('add')</span>
+                    <span>{{ trans('entities.books_create') }}</span>
+                </a>
+            @endif
             @include('partials.view-toggle', ['view' => $view, 'type' => 'books'])
             @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
             @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
         </div>
     </div>
-@stop
\ No newline at end of file
+@stop
index fccbef288730d42af2cbd22d860254d943101ebf..957fa6578fffd35e349c550fdf521a237fd09440 100644 (file)
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
+            @if(user()->can('bookshelf-create-all'))
+                <a href="{{ url("/create-shelf") }}" class="icon-list-item">
+                    <span>@icon('add')</span>
+                    <span>{{ trans('entities.shelves_new_action') }}</span>
+                </a>
+            @endif
             @include('partials.view-toggle', ['view' => $view, 'type' => 'shelves'])
             @include('components.expand-toggle', ['target' => '.entity-list.compact .entity-item-snippet', 'key' => 'home-details'])
             @include('partials.dark-mode-toggle', ['classes' => 'text-muted icon-list-item text-primary'])
         </div>
     </div>
-@stop
\ No newline at end of file
+@stop
index 12adda618905a59033b8b40cd6326ee2ab26cf05..4c36ce61a9be9648f48dbc68462b2b75bc73f83a 100644 (file)
@@ -6,11 +6,11 @@
 @endif
 
 <div class="mb-xl">
-    <h5>{{ trans('entities.' . ($signedIn ? 'my_recently_viewed' : 'books_recent')) }}</h5>
+    <h5>{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h5>
     @include('partials.entity-list', [
         'entities' => $recents,
         'style' => 'compact',
-        'emptyText' => $signedIn ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
+        'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
         ])
 </div>
 
index 2631f1a57ed878b01ad5099f7a539e07169eba58..ad503463e46f1db404882fbf58018dfae39b227c 100644 (file)
                     </div>
                 @endif
 
-                <div id="{{ $signedIn ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
-                    <h3 class="card-title">{{ trans('entities.' . ($signedIn ? 'my_recently_viewed' : 'books_recent')) }}</h3>
+                <div id="{{ auth()->check() ? 'recently-viewed' : 'recent-books' }}" class="card mb-xl">
+                    <h3 class="card-title">{{ trans('entities.' . (auth()->check() ? 'my_recently_viewed' : 'books_recent')) }}</h3>
                     <div class="px-m">
                         @include('partials.entity-list', [
                         'entities' => $recents,
                         'style' => 'compact',
-                        'emptyText' => $signedIn ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
+                        'emptyText' => auth()->check() ? trans('entities.no_pages_viewed') : trans('entities.books_empty')
                         ])
                     </div>
                 </div>
index a24f9ac1e9ec79c7d1c55ed95250f177bf9f42ee..0c14490386b0cfb968c00f7d06a535ab2570a6ea 100644 (file)
@@ -4,7 +4,7 @@ $key - Unique key for checking existing stored state.
 --}}
 <?php $isOpen = setting()->getForCurrentUser('section_expansion#'. $key); ?>
 <button type="button" expand-toggle="{{ $target }}"
-   expand-toggle-update-endpoint="{{ url('/settings/users/'. $currentUser->id .'/update-expansion-preference/' . $key) }}"
+   expand-toggle-update-endpoint="{{ url('/settings/users/'. user()->id .'/update-expansion-preference/' . $key) }}"
    expand-toggle-is-open="{{ $isOpen ? 'yes' : 'no' }}"
    class="text-muted icon-list-item text-primary">
     <span>@icon('expand-text')</span>
index a3c868a8dc08ba293e624f27aa8f1229aa5aa039..65df748772598a9f8de8498f4ba705a21041c317 100644 (file)
@@ -1,7 +1,8 @@
 <input type="password" id="{{ $name }}" name="{{ $name }}"
        @if($errors->has($name)) class="text-neg" @endif
        @if(isset($placeholder)) placeholder="{{$placeholder}}" @endif
+       @if(isset($autocomplete)) autocomplete="{{$autocomplete}}" @endif
        @if(old($name)) value="{{ old($name)}}" @endif>
 @if($errors->has($name))
     <div class="text-neg text-small">{{ $errors->first($name) }}</div>
-@endif
\ No newline at end of file
+@endif
index af0981800049322a9062eef2ea81ecf9a9898354..bf90873975c7b89e483a82210ea67c263b20a79c 100644 (file)
@@ -4,7 +4,7 @@
 ?>
 <div class="list-sort-container" list-sort-control>
     <div class="list-sort-label">{{ trans('common.sort') }}</div>
-    <form action="{{ url("/settings/users/{$currentUser->id}/change-sort/{$type}") }}" method="post">
+    <form action="{{ url("/settings/users/". user()->id ."/change-sort/{$type}") }}" method="post">
 
         {!! csrf_field() !!}
         {!! method_field('PATCH') !!}
index 9f911c88231d1775366263e0e29766705690df94..9ff1b49277d035c17f2df0169f90eab1ddbd2bfc 100644 (file)
@@ -1,5 +1,5 @@
 <div>
-    <form action="{{ url("/settings/users/{$currentUser->id}/switch-${type}-view") }}" method="POST" class="inline">
+    <form action="{{ url("/settings/users/". user()->id ."/switch-${type}-view") }}" method="POST" class="inline">
         {!! csrf_field() !!}
         {!! method_field('PATCH') !!}
         <input type="hidden" value="{{ $view === 'list'? 'grid' : 'list' }}" name="view_type">
diff --git a/resources/views/settings/footer-links.blade.php b/resources/views/settings/footer-links.blade.php
new file mode 100644 (file)
index 0000000..10bf756
--- /dev/null
@@ -0,0 +1,34 @@
+{{--
+$value - Setting value
+$name - Setting input name
+--}}
+<div components="add-remove-rows"
+     option:add-remove-rows:row-selector=".card"
+     option:add-remove-rows:remove-selector="button.text-neg">
+
+    <div component="sortable-list"
+         option:sortable-list:handle-selector=".handle">
+        @foreach(array_merge($value, [['label' => '', 'url' => '']]) as $index => $link)
+            <div class="card drag-card {{ $loop->last ? 'hidden' : '' }}" @if($loop->last) refs="add-remove-rows@model" @endif>
+                <div class="handle">@icon('grip')</div>
+                @foreach(['label', 'url'] as $prop)
+                    <div class="outline">
+                        <input value="{{ $link[$prop] ?? '' }}"
+                               placeholder="{{ trans('settings.app_footer_links_' . $prop) }}"
+                               aria-label="{{ trans('settings.app_footer_links_' . $prop) }}"
+                               name="{{ $name }}[{{ $loop->parent->last ? 'randrowid' : $index }}][{{$prop}}]"
+                               type="text"
+                               autocomplete="off"/>
+                    </div>
+                @endforeach
+                <button type="button"
+                        aria-label="{{ trans('common.remove') }}"
+                        class="text-center drag-card-action text-neg">
+                    @icon('close')
+                </button>
+            </div>
+        @endforeach
+    </div>
+
+    <button refs="add-remove-rows@add" type="button" class="text-button">{{ trans('settings.app_footer_links_add') }}</button>
+</div>
\ No newline at end of file
index 8adc1045b46d15a8744e15f06d54b3992d07a3e6..ad03b6c917fdfecc670a3950650bfa092f7c263a 100644 (file)
                         </div>
                     </div>
 
+                    <div>
+                        <label for="setting-app-privacy-link" class="setting-list-label">{{ trans('settings.app_footer_links') }}</label>
+                        <p class="small mb-m">{{ trans('settings.app_footer_links_desc') }}</p>
+                        @include('settings.footer-links', ['name' => 'setting-app-footer-links', 'value' => setting('app-footer-links', [])])
+                    </div>
+
 
                     <div>
                         <label for="setting-app-custom-head" class="setting-list-label">{{ trans('settings.app_custom_html') }}</label>
index af8b2aaf7ee3b134e915511760a33d456905389f..a472196c56e7bded70e893953f7383918257dca0 100644 (file)
@@ -1,16 +1,16 @@
 
 <nav class="active-link-list">
-    @if($currentUser->can('settings-manage'))
+    @if(userCan('settings-manage'))
         <a href="{{ url('/settings') }}" @if($selected == 'settings') class="active" @endif>@icon('settings'){{ trans('settings.settings') }}</a>
         <a href="{{ url('/settings/maintenance') }}" @if($selected == 'maintenance') class="active" @endif>@icon('spanner'){{ trans('settings.maint') }}</a>
     @endif
-    @if($currentUser->can('settings-manage') && $currentUser->can('users-manage'))
+    @if(userCan('settings-manage') && userCan('users-manage'))
         <a href="{{ url('/settings/audit') }}" @if($selected == 'audit') class="active" @endif>@icon('open-book'){{ trans('settings.audit') }}</a>
     @endif
-    @if($currentUser->can('users-manage'))
+    @if(userCan('users-manage'))
         <a href="{{ url('/settings/users') }}" @if($selected == 'users') class="active" @endif>@icon('users'){{ trans('settings.users') }}</a>
     @endif
-    @if($currentUser->can('user-roles-manage'))
+    @if(userCan('user-roles-manage'))
         <a href="{{ url('/settings/roles') }}" @if($selected == 'roles') class="active" @endif>@icon('lock-open'){{ trans('settings.roles') }}</a>
     @endif
 </nav>
\ No newline at end of file
index 43bc2b0242e1f7d0e4fe14a97dad5b3448f508d3..604acbb165021a5f8bc50814106f5447d77dcda0 100644 (file)
                         <img class="avatar small" src="{{ $user->getAvatar(40) }}" alt="{{ $user->name }}">
                     </div>
                     <div>
-                        @if(userCan('users-manage') || $currentUser->id == $user->id)
+                        @if(userCan('users-manage') || user()->id == $user->id)
                             <a href="{{ url("/settings/users/{$user->id}") }}">
                                 @endif
                                 {{ $user->name }}
-                                @if(userCan('users-manage') || $currentUser->id == $user->id)
+                                @if(userCan('users-manage') || user()->id == $user->id)
                             </a>
                         @endif
                     </div>
index 56b76f96f01855e7646d5371b0e372f25fc10c8a..21c33aa9c62d1aba748143b8af0e82ac3d01d351 100644 (file)
@@ -9,7 +9,7 @@
     <div class="actions mb-xl">
         <h5>{{ trans('common.actions') }}</h5>
         <div class="icon-list text-primary">
-            @if($currentUser->can('bookshelf-create-all'))
+            @if(userCan('bookshelf-create-all'))
                 <a href="{{ url("/create-shelf") }}" class="icon-list-item">
                     <span>@icon('add')</span>
                     <span>{{ trans('entities.shelves_new_action') }}</span>
index 9971eeeeb54ca63ba42045982f076e6324936989..d953b646afe8c0ba10643863cb61fb384de064b5 100644 (file)
@@ -19,7 +19,7 @@
                 </div>
 
                 <div class="form-group text-right">
-                    <a href="{{  url($currentUser->can('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
                     <button class="button" type="submit">{{ trans('common.save') }}</button>
                 </div>
 
index f78c25cebf6a93a00a82975b8ba09c308505a730..7fb12bd757389c0128a0e4f62f36fb03cb6e5808 100644 (file)
@@ -8,7 +8,7 @@
         </div>
 
         <section class="card content-wrap">
-            <h1 class="list-heading">{{ $user->id === $currentUser->id ? trans('settings.users_edit_profile') : trans('settings.users_edit') }}</h1>
+            <h1 class="list-heading">{{ $user->id === user()->id ? trans('settings.users_edit_profile') : trans('settings.users_edit') }}</h1>
             <form action="{{ url("/settings/users/{$user->id}") }}" method="post" enctype="multipart/form-data">
                 {!! csrf_field() !!}
                 <input type="hidden" name="_method" value="PUT">
@@ -54,7 +54,7 @@
                 </div>
 
                 <div class="text-right">
-                    <a href="{{  url($currentUser->can('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
+                    <a href="{{  url(userCan('users-manage') ? "/settings/users" : "/") }}" class="button outline">{{ trans('common.cancel') }}</a>
                     @if($authMethod !== 'system')
                         <a href="{{ url("/settings/users/{$user->id}/delete") }}" class="button outline">{{ trans('settings.users_delete') }}</a>
                     @endif
@@ -63,7 +63,7 @@
             </form>
         </section>
 
-        @if($currentUser->id === $user->id && count($activeSocialDrivers) > 0)
+        @if(user()->id === $user->id && count($activeSocialDrivers) > 0)
             <section class="card content-wrap auto-height">
                 <h2 class="list-heading">{{ trans('settings.users_social_accounts') }}</h2>
                 <p class="text-muted">{{ trans('settings.users_social_accounts_info') }}</p>
@@ -88,7 +88,7 @@
             </section>
         @endif
 
-        @if(($currentUser->id === $user->id && userCan('access-api')) || userCan('users-manage'))
+        @if((user()->id === $user->id && userCan('access-api')) || userCan('users-manage'))
             @include('users.api-tokens.list', ['user' => $user])
         @endif
     </div>
index df3d06c2f34f232b17fe033472d4078f293285cb..763c387d4601bca12bba95b71dd9ba2cfecb200f 100644 (file)
@@ -74,7 +74,7 @@
             <div class="grid half mt-m gap-xl">
                 <div>
                     <label for="password">{{ trans('auth.password') }}</label>
-                    @include('form.password', ['name' => 'password'])
+                    @include('form.password', ['name' => 'password', 'autocomplete' => 'new-password'])
                 </div>
                 <div>
                     <label for="password-confirm">{{ trans('auth.password_confirm') }}</label>
index 68641ca644e721df9e64277901cd8ef036722bef..6bc229ec682a1fac2ae1599b4e1bfe0717c406a2 100644 (file)
@@ -21,9 +21,7 @@
                             <input type="text" name="search" placeholder="{{ trans('settings.users_search') }}" @if($listDetails['search']) value="{{$listDetails['search']}}" @endif>
                         </form>
                     </div>
-                    @if(userCan('users-manage'))
-                        <a href="{{ url("/settings/users/create") }}" style="margin-top: 0;" class="outline button">{{ trans('settings.users_add_new') }}</a>
-                    @endif
+                    <a href="{{ url("/settings/users/create") }}" class="outline button mt-none">{{ trans('settings.users_add_new') }}</a>
                 </div>
             </div>
 
                     <tr>
                         <td class="text-center" style="line-height: 0;"><img class="avatar med" src="{{ $user->getAvatar(40)}}" alt="{{ $user->name }}"></td>
                         <td>
-                            @if(userCan('users-manage') || $currentUser->id == $user->id)
-                                <a href="{{ url("/settings/users/{$user->id}") }}">
-                                    @endif
-                                    {{ $user->name }} <br> <span class="text-muted">{{ $user->email }}</span>
-                                    @if(userCan('users-manage') || $currentUser->id == $user->id)
-                                </a>
-                            @endif
+                            <a href="{{ url("/settings/users/{$user->id}") }}">
+                                {{ $user->name }} <br> <span class="text-muted">{{ $user->email }}</span>
+                            </a>
                         </td>
                         <td>
                             @foreach($user->roles as $index => $role)
index e8f217862281a3622b94bede6593e77d975e318c..64d08e165a9659c0bcd09111a8db6fd37a33269a 100644 (file)
@@ -1,5 +1,6 @@
 <?php
 
+Route::get('/status', 'StatusController@show');
 Route::get('/robots.txt', 'HomeController@getRobots');
 
 // Authenticated routes...
diff --git a/tests/FooterLinksTest.php b/tests/FooterLinksTest.php
new file mode 100644 (file)
index 0000000..f0ff0c4
--- /dev/null
@@ -0,0 +1,61 @@
+<?php
+
+use Tests\TestCase;
+
+class FooterLinksTest extends TestCase
+{
+
+    public function test_saving_setting()
+    {
+        $resp = $this->asAdmin()->post("/settings", [
+            'setting-app-footer-links' => [
+                ['label' => 'My custom link 1', 'url' => 'https://example.com/1'],
+                ['label' => 'My custom link 2', 'url' => 'https://example.com/2'],
+            ],
+        ]);
+        $resp->assertRedirect('/settings');
+
+        $result = setting('app-footer-links');
+        $this->assertIsArray($result);
+        $this->assertCount(2, $result);
+        $this->assertEquals('My custom link 2', $result[1]['label']);
+        $this->assertEquals('https://example.com/1', $result[0]['url']);
+    }
+
+    public function test_set_options_visible_on_settings_page()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'My custom link', 'url' => 'https://example.com/link-a'],
+            ['label' => 'Another Link', 'url' => 'https://example.com/link-b'],
+        ]]);
+
+        $resp = $this->asAdmin()->get('/settings');
+        $resp->assertSee('value="My custom link"');
+        $resp->assertSee('value="Another Link"');
+        $resp->assertSee('value="https://example.com/link-a"');
+        $resp->assertSee('value="https://example.com/link-b"');
+    }
+
+    public function test_footer_links_show_on_pages()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'My custom link', 'url' => 'https://example.com/link-a'],
+            ['label' => 'Another Link', 'url' => 'https://example.com/link-b'],
+        ]]);
+
+        $this->get('/login')->assertElementContains('footer a[href="https://example.com/link-a"]', 'My custom link');
+        $this->asEditor()->get('/')->assertElementContains('footer a[href="https://example.com/link-b"]', 'Another link');
+    }
+
+    public function test_using_translation_system_for_labels()
+    {
+        $this->setSettings(['app-footer-links' => [
+            ['label' => 'trans::common.privacy_policy', 'url' => 'https://example.com/privacy'],
+            ['label' => 'trans::common.terms_of_service', 'url' => 'https://example.com/terms'],
+        ]]);
+
+        $resp = $this->get('/login');
+        $resp->assertElementContains('footer a[href="https://example.com/privacy"]', 'Privacy Policy');
+        $resp->assertElementContains('footer a[href="https://example.com/terms"]', 'Terms of Service');
+    }
+}
\ No newline at end of file
diff --git a/tests/StatusTest.php b/tests/StatusTest.php
new file mode 100644 (file)
index 0000000..b4c35cf
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+
+use Illuminate\Cache\ArrayStore;
+use Illuminate\Support\Facades\DB;
+use Illuminate\Support\Facades\Cache;
+use Illuminate\Support\Facades\Session;
+use Tests\TestCase;
+
+class StatusTest extends TestCase
+{
+    public function test_returns_json_with_expected_results()
+    {
+        $resp = $this->get("/status");
+        $resp->assertStatus(200);
+        $resp->assertJson([
+            'database' => true,
+            'cache' => true,
+            'session' => true,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_db_error()
+    {
+        DB::shouldReceive('table')->andThrow(new Exception());
+
+        $resp = $this->get("/status");
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'database' => false,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_wrong_cache_return()
+    {
+        $mockStore = Mockery::mock(new ArrayStore())->makePartial();
+        Cache::swap($mockStore);
+        $mockStore->shouldReceive('get')->andReturn('cat');
+
+        $resp = $this->get("/status");
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'cache' => false,
+        ]);
+    }
+
+    public function test_returns_500_status_and_false_on_wrong_session_return()
+    {
+        $session = Session::getFacadeRoot();
+        $mockSession = Mockery::mock($session)->makePartial();
+        Session::swap($mockSession);
+        $mockSession->shouldReceive('get')->andReturn('cat');
+
+        $resp = $this->get("/status");
+        $resp->assertStatus(500);
+        $resp->assertJson([
+            'session' => false,
+        ]);
+    }
+}
\ No newline at end of file
index 1c736d672d977b8727c8ee5cf08b9f9ee5ba1538..9b0e004b1f7a3cbe8718edc382eb40cbcfdcc581 100644 (file)
@@ -136,7 +136,7 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-php.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
@@ -158,7 +158,7 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-phtml.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/jpeg')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
@@ -175,7 +175,7 @@ class ImageTest extends TestCase
         $relPath = $this->getTestImagePath('gallery', $fileName);
         $this->deleteImage($relPath);
 
-        $file = $this->getTestImage($fileName);
+        $file = $this->newTestImageFromBase64('bad-phtml-png.base64', $fileName);
         $upload = $this->withHeader('Content-Type', 'image/png')->call('POST', '/images/gallery', ['uploaded_to' => $page->id], [], ['file' => $file], []);
         $upload->assertStatus(302);
 
index 64f26dea8a9be7c847909d0d192a780a2ed25f02..a2026d96845050ea8736c1d59e153d40db611ebd 100644 (file)
@@ -6,10 +6,9 @@ use Illuminate\Http\UploadedFile;
 trait UsesImages
 {
     /**
-     * Get the path to our basic test image.
-     * @return string
+     * Get the path to a file in the test-data-directory.
      */
-    protected function getTestImageFilePath(?string $fileName = null)
+    protected function getTestImageFilePath(?string $fileName = null): string
     {
         if (is_null($fileName)) {
             $fileName = 'test-image.png';
@@ -18,14 +17,27 @@ trait UsesImages
         return base_path('tests/test-data/' . $fileName);
     }
 
+    /**
+     * Creates a new temporary image file using the given name,
+     * with the content decoded from the given bas64 file name.
+     * Is generally used for testing sketchy files that could trip AV.
+     */
+    protected function newTestImageFromBase64(string $base64FileName, $imageFileName): UploadedFile
+    {
+        $imagePath = implode(DIRECTORY_SEPARATOR, [sys_get_temp_dir(), $imageFileName]);
+        $base64FilePath = $this->getTestImageFilePath($base64FileName);
+        $data = file_get_contents($base64FilePath);
+        $decoded = base64_decode($data);
+        file_put_contents($imagePath, $decoded);
+        return new UploadedFile($imagePath, $imageFileName, 'image/png', null, true);
+    }
+
     /**
      * Get a test image that can be uploaded
-     * @param $fileName
-     * @return UploadedFile
      */
-    protected function getTestImage($fileName, ?string $testDataFileName = null)
+    protected function getTestImage(string $fileName, ?string $testDataFileName = null): UploadedFile
     {
-        return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', 5238, null, true);
+        return new UploadedFile($this->getTestImageFilePath($testDataFileName), $fileName, 'image/png', null, true);
     }
 
     /**
diff --git a/tests/test-data/bad-php.base64 b/tests/test-data/bad-php.base64
new file mode 100644 (file)
index 0000000..550ce17
--- /dev/null
@@ -0,0 +1,10 @@
+/9j/4AAQSkZJRgABAQEBLAEsAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAAEBAQEBAQEBAQEB
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBD
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB
+AQEBAQEBAQH/wgARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQBAQAA
+AAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADEAAAAT/n/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgB
+AQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k8P3BocCBlY2hvICdiYWRwaHAnOwo=
diff --git a/tests/test-data/bad-phtml-png.base64 b/tests/test-data/bad-phtml-png.base64
new file mode 100644 (file)
index 0000000..7fd9d8f
--- /dev/null
@@ -0,0 +1,3 @@
+iVBORw0KGgoAAAANSUhEUgAAAAUAAAAFCAIAAAACDbGyAAAACXBIWXMAAAsTAAALEwEAmpwYAAAA
+B3RJTUUH4gEcDCo5iYNs+gAAAB1pVFh0Q29tbWVudAAAAAAAQ3JlYXRlZCB3aXRoIEdJTVBkLmUH
+AAAAFElEQVQI12O0jN/KgASYGFABqXwAZtoBV6Sl3hIAAAAASUVORK5CYII=
diff --git a/tests/test-data/bad-phtml.base64 b/tests/test-data/bad-phtml.base64
new file mode 100644 (file)
index 0000000..550ce17
--- /dev/null
@@ -0,0 +1,10 @@
+/9j/4AAQSkZJRgABAQEBLAEsAAD//gATQ3JlYXRlZCB3aXRoIEdJTVD/2wBDAAEBAQEBAQEBAQEB
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQH/2wBD
+AQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEBAQEB
+AQEBAQEBAQH/wgARCAABAAEDAREAAhEBAxEB/8QAFAABAAAAAAAAAAAAAAAAAAAACv/EABQBAQAA
+AAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADEAAAAT/n/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgB
+AQABBQJ//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAwEBPwF//8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAgEBPwF//8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQAGPwJ//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPyF//9oADAMBAAIAAwAAABAf/8QAFBEBAAAAAAAAAAAAAAAA
+AAAAAP/aAAgBAwEBPxB//8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEBPxB//8QAFBABAAAA
+AAAAAAAAAAAAAAAAAP/aAAgBAQABPxB//9k8P3BocCBlY2hvICdiYWRwaHAnOwo=
diff --git a/tests/test-data/bad.php b/tests/test-data/bad.php
deleted file mode 100644 (file)
index 3b7c0f3..0000000
Binary files a/tests/test-data/bad.php and /dev/null differ
diff --git a/tests/test-data/bad.phtml b/tests/test-data/bad.phtml
deleted file mode 100644 (file)
index 3b7c0f3..0000000
Binary files a/tests/test-data/bad.phtml and /dev/null differ
diff --git a/tests/test-data/bad.phtml.png b/tests/test-data/bad.phtml.png
deleted file mode 100644 (file)
index dd15f6e..0000000
Binary files a/tests/test-data/bad.phtml.png and /dev/null differ
diff --git a/version b/version
index 31c2e1d4bb4cfcb6f9e5c7e394aa225b0664fd2e..92d9faea7781a62c65cccb97e9dd1292d1184271 100644 (file)
--- a/version
+++ b/version
@@ -1 +1 @@
-v0.30-dev
+v0.32-dev