From bcce2625a8cb0b630d945c6849014049869e10ce Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 27 Nov 2018 12:01:36 +0100 Subject: [PATCH 01/17] Implemented AuthController for login * Moved /login functionality to AuthController * Refactored password handling logic to use the Authenticator --- config/config.default.php | 9 +- config/routes.php | 2 + .../2018_10_01_000000_create_users_tables.php | 2 +- includes/controller/users_controller.php | 13 +- includes/pages/admin_user.php | 2 +- includes/pages/guest_login.php | 119 +--------------- includes/pages/user_settings.php | 5 +- includes/sys_auth.php | 68 --------- includes/view/AngelTypes_view.php | 2 +- includes/view/User_view.php | 2 +- .../lang/de_DE.UTF-8/LC_MESSAGES/default.po | 19 +-- .../lang/en_US.UTF-8/LC_MESSAGES/default.mo | Bin 0 -> 745 bytes .../lang/en_US.UTF-8/LC_MESSAGES/default.po | 26 ++++ .../lang/pt_BR.UTF.8/LC_MESSAGES/default.mo | Bin 0 -> 41129 bytes .../LC_MESSAGES/{pt_BR.po => default.po} | 14 +- .../lang/pt_BR.UTF.8/LC_MESSAGES/pt_BR.mo | Bin 41256 -> 0 bytes resources/views/errors/405.twig | 5 + resources/views/macros/base.twig | 11 ++ resources/views/pages/login.twig | 104 ++++++++++++++ src/Controllers/AuthController.php | 90 +++++++++++- src/Helpers/Authenticator.php | 93 ++++++++++-- src/Helpers/AuthenticatorServiceProvider.php | 4 + src/Middleware/LegacyMiddleware.php | 5 - tests/Unit/Controllers/AuthControllerTest.php | 132 ++++++++++++++++-- .../Stub/ControllerImplementation.php | 8 -- .../AuthenticatorServiceProviderTest.php | 9 ++ tests/Unit/Helpers/AuthenticatorTest.php | 125 ++++++++++++++++- .../Http/UrlGeneratorServiceProviderTest.php | 5 +- 28 files changed, 610 insertions(+), 264 deletions(-) create mode 100644 resources/lang/en_US.UTF-8/LC_MESSAGES/default.mo create mode 100644 resources/lang/en_US.UTF-8/LC_MESSAGES/default.po create mode 100644 resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.mo rename resources/lang/pt_BR.UTF.8/LC_MESSAGES/{pt_BR.po => default.po} (99%) delete mode 100644 resources/lang/pt_BR.UTF.8/LC_MESSAGES/pt_BR.mo create mode 100644 resources/views/errors/405.twig create mode 100644 resources/views/macros/base.twig create mode 100644 resources/views/pages/login.twig diff --git a/config/config.default.php b/config/config.default.php index 693b0d19..9c9505c6 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -95,13 +95,10 @@ return [ // Number of hours that an angel has to sign out own shifts 'last_unsubscribe' => 3, - // Define the algorithm to use for `crypt()` of passwords + // Define the algorithm to use for `password_verify()` // If the user uses an old algorithm the password will be converted to the new format - // MD5 '$1' - // Blowfish '$2y$13' - // SHA-256 '$5$rounds=5000' - // SHA-512 '$6$rounds=5000' - 'crypt_alg' => '$6$rounds=5000', + // See https://secure.php.net/manual/en/password.constants.php for a complete list + 'password_algorithm' => PASSWORD_DEFAULT, // The minimum length for passwords 'min_password_length' => 8, diff --git a/config/routes.php b/config/routes.php index e999d026..02fd3abd 100644 --- a/config/routes.php +++ b/config/routes.php @@ -9,6 +9,8 @@ $route->get('/', 'HomeController@index'); $route->get('/credits', 'CreditsController@index'); // Authentication +$route->get('/login', 'AuthController@login'); +$route->post('/login', 'AuthController@postLogin'); $route->get('/logout', 'AuthController@logout'); // Stats diff --git a/db/migrations/2018_10_01_000000_create_users_tables.php b/db/migrations/2018_10_01_000000_create_users_tables.php index d8422ca0..52b3658f 100644 --- a/db/migrations/2018_10_01_000000_create_users_tables.php +++ b/db/migrations/2018_10_01_000000_create_users_tables.php @@ -28,7 +28,7 @@ class CreateUsersTables extends Migration $table->string('name', 24)->unique(); $table->string('email', 254)->unique(); - $table->string('password', 128); + $table->string('password', 255); $table->string('api_key', 32); $table->dateTime('last_login_at')->nullable(); diff --git a/includes/controller/users_controller.php b/includes/controller/users_controller.php index 7c6bde02..214998dc 100644 --- a/includes/controller/users_controller.php +++ b/includes/controller/users_controller.php @@ -47,6 +47,7 @@ function users_controller() function user_delete_controller() { $user = auth()->user(); + $auth = auth(); $request = request(); if ($request->has('user_id')) { @@ -68,14 +69,12 @@ function user_delete_controller() if ($request->hasPostData('submit')) { $valid = true; - if ( - !( + if (!( $request->has('password') - && verify_password($request->postData('password'), $user->password, $user->id) - ) - ) { + && $auth->verifyPassword($user, $request->postData('password')) + )) { $valid = false; - error(__('Your password is incorrect. Please try it again.')); + error(__('Your password is incorrect. Please try it again.')); } if ($valid) { @@ -341,7 +340,7 @@ function user_password_recovery_set_new_controller() } if ($valid) { - set_password($passwordReset->user->id, $request->postData('password')); + auth()->setPassword($passwordReset->user, $request->postData('password')); success(__('Password saved.')); $passwordReset->delete(); redirect(page_link_to('login')); diff --git a/includes/pages/admin_user.php b/includes/pages/admin_user.php index e6f94180..8482dea5 100644 --- a/includes/pages/admin_user.php +++ b/includes/pages/admin_user.php @@ -291,8 +291,8 @@ function admin_user() $request->postData('new_pw') != '' && $request->postData('new_pw') == $request->postData('new_pw2') ) { - set_password($user_id, $request->postData('new_pw')); $user_source = User::find($user_id); + auth()->setPassword($user_source, $request->postData('new_pw')); engelsystem_log('Set new password for ' . User_Nick_render($user_source, true)); $html .= success('Passwort neu gesetzt.', true); } else { diff --git a/includes/pages/guest_login.php b/includes/pages/guest_login.php index d152a092..3bc10fc3 100644 --- a/includes/pages/guest_login.php +++ b/includes/pages/guest_login.php @@ -8,14 +8,6 @@ use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\State; use Engelsystem\Models\User\User; -/** - * @return string - */ -function login_title() -{ - return __('Login'); -} - /** * @return string */ @@ -226,7 +218,7 @@ function guest_register() // Assign user-group and set password DB::insert('INSERT INTO `UserGroups` (`uid`, `group_id`) VALUES (?, -20)', [$user->id]); - set_password($user->id, $request->postData('password')); + auth()->setPassword($user, $request->postData('password')); // Assign angel-types $user_angel_types_info = []; @@ -369,112 +361,3 @@ function entry_required() { return ''; } - -/** - * @return string - */ -function guest_login() -{ - $nick = ''; - $request = request(); - $session = session(); - $valid = true; - - $session->remove('uid'); - - if ($request->hasPostData('submit')) { - if ($request->has('nick') && !empty($request->input('nick'))) { - $nickValidation = User_validate_Nick($request->input('nick')); - $nick = $nickValidation->getValue(); - $login_user = User::whereName($nickValidation->getValue())->first(); - if ($login_user) { - if ($request->has('password')) { - if (!verify_password($request->postData('password'), $login_user->password, $login_user->id)) { - $valid = false; - error(__('Your password is incorrect. Please try it again.')); - } - } else { - $valid = false; - error(__('Please enter a password.')); - } - } else { - $valid = false; - error(__('No user was found with that Nickname. Please try again. If you are still having problems, ask a Dispatcher.')); - } - } else { - $valid = false; - error(__('Please enter a nickname.')); - } - - if ($valid && $login_user) { - $session->set('uid', $login_user->id); - $session->set('locale', $login_user->settings->language); - - redirect(page_link_to(config('home_site'))); - } - } - - return page([ - div('col-md-12', [ - div('row', [ - EventConfig_countdown_page() - ]), - div('row', [ - div('col-sm-6 col-sm-offset-3 col-md-4 col-md-offset-4', [ - div('panel panel-primary first', [ - div('panel-heading', [ - ' ' . __('Login') - ]), - div('panel-body', [ - msg(), - form([ - form_text_placeholder('nick', __('Nick'), $nick), - form_password_placeholder('password', __('Password')), - form_submit('submit', __('Login')), - !$valid ? buttons([ - button(page_link_to('user_password_recovery'), __('I forgot my password')) - ]) : '' - ]) - ]), - div('panel-footer', [ - glyph('info-sign') . __('Please note: You have to activate cookies!') - ]) - ]) - ]) - ]), - div('row', [ - div('col-sm-6 text-center', [ - heading(register_title(), 2), - get_register_hint() - ]), - div('col-sm-6 text-center', [ - heading(__('What can I do?'), 2), - '

' . __('Please read about the jobs you can do to help us.') . '

', - buttons([ - button( - page_link_to('angeltypes', ['action' => 'about']), - __('Teams/Job description') . ' »' - ) - ]) - ]) - ]) - ]) - ]); -} - -/** - * @return string - */ -function get_register_hint() -{ - if (auth()->can('register') && config('registration_enabled')) { - return join('', [ - '

' . __('Please sign up, if you want to help us!') . '

', - buttons([ - button(page_link_to('register'), register_title() . ' »') - ]) - ]); - } - - return error(__('Registration is disabled.'), true); -} diff --git a/includes/pages/user_settings.php b/includes/pages/user_settings.php index ae29e4d8..f6853191 100644 --- a/includes/pages/user_settings.php +++ b/includes/pages/user_settings.php @@ -101,9 +101,10 @@ function user_settings_main($user_source, $enable_tshirt_size, $tshirt_sizes) function user_settings_password($user_source) { $request = request(); + $auth = auth(); if ( !$request->has('password') - || !verify_password($request->postData('password'), $user_source->password, $user_source->id) + || !$auth->verifyPassword($user_source, $request->postData('password')) ) { error(__('-> not OK. Please try again.')); } elseif (strlen($request->postData('new_password')) < config('min_password_length')) { @@ -111,7 +112,7 @@ function user_settings_password($user_source) } elseif ($request->postData('new_password') != $request->postData('new_password2')) { error(__('Your passwords don\'t match.')); } else { - set_password($user_source->id, $request->postData('new_password')); + $auth->setPassword($user_source, $request->postData('new_password')); success(__('Password saved.')); } redirect(page_link_to('user_settings')); diff --git a/includes/sys_auth.php b/includes/sys_auth.php index 520b13eb..f0485495 100644 --- a/includes/sys_auth.php +++ b/includes/sys_auth.php @@ -1,74 +1,6 @@ password = crypt($password, config('crypt_alg') . '$' . generate_salt(16) . '$'); - $user->save(); -} - -/** - * verify a password given a precomputed salt. - * if $uid is given and $salt is an old-style salt (plain md5), we convert it automatically - * - * @param string $password - * @param string $salt - * @param int $uid - * @return bool - */ -function verify_password($password, $salt, $uid = null) -{ - $crypt_alg = config('crypt_alg'); - $correct = false; - if (substr($salt, 0, 1) == '$') { - // new-style crypt() - $correct = crypt($password, $salt) == $salt; - } elseif (substr($salt, 0, 7) == '{crypt}') { - // old-style crypt() with DES and static salt - not used anymore - $correct = crypt($password, '77') == $salt; - } elseif (strlen($salt) == 32) { - // old-style md5 without salt - not used anymore - $correct = md5($password) == $salt; - } - - if ($correct && substr($salt, 0, strlen($crypt_alg)) != $crypt_alg && intval($uid)) { - // this password is stored in another format than we want it to be. - // let's update it! - // we duplicate the query from the above set_password() function to have the extra safety of checking - // the old hash - $user = User::find($uid); - if ($user->password == $salt) { - $user->password = crypt($password, $crypt_alg . '$' . generate_salt() . '$'); - $user->save(); - } - } - return $correct; -} /** * @param int $user_id diff --git a/includes/view/AngelTypes_view.php b/includes/view/AngelTypes_view.php index f5434e8f..9f9bd736 100644 --- a/includes/view/AngelTypes_view.php +++ b/includes/view/AngelTypes_view.php @@ -578,7 +578,7 @@ function AngelTypes_about_view($angeltypes, $user_logged_in) $buttons[] = button(page_link_to('register'), register_title()); } - $buttons[] = button(page_link_to('login'), login_title()); + $buttons[] = button(page_link_to('login'), __('Login')); } $faqUrl = config('faq_url'); diff --git a/includes/view/User_view.php b/includes/view/User_view.php index 949bba87..21be0c9f 100644 --- a/includes/view/User_view.php +++ b/includes/view/User_view.php @@ -126,7 +126,7 @@ function User_registration_success_view($event_welcome_message) div('col-md-4', [ '

' . __('Login') . '

', form([ - form_text('nick', __('Nick'), ''), + form_text('login', __('Nick'), ''), form_password('password', __('Password')), form_submit('submit', __('Login')), buttons([ diff --git a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po b/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po index d5a7b993..27ceb586 100644 --- a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po +++ b/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po @@ -541,7 +541,7 @@ msgstr "Du kannst Dich nicht selber löschen." #: includes/controller/users_controller.php:78 #: includes/pages/guest_login.php:410 -msgid "Your password is incorrect. Please try it again." +msgid "Your password is incorrect. Please try it again." msgstr "Dein Passwort stimmt nicht. Bitte probiere es nochmal." #: includes/controller/users_controller.php:87 @@ -1530,18 +1530,21 @@ msgid "Entry required!" msgstr "Pflichtfeld!" #: includes/pages/guest_login.php:414 -msgid "Please enter a password." +msgid "auth.no-password" msgstr "Gib bitte ein Passwort ein." #: includes/pages/guest_login.php:418 -msgid "" -"No user was found with that Nickname. Please try again. If you are still " -"having problems, ask a Dispatcher." +msgid "auth.not-found" msgstr "" -"Es wurde kein Engel mit diesem Namen gefunden. Probiere es bitte noch " -"einmal. Wenn das Problem weiterhin besteht, frage einen Dispatcher." +"Es wurde kein Engel gefunden. Probiere es bitte noch einmal. Wenn das Problem " +"weiterhin besteht, melde dich im Himmel." #: includes/pages/guest_login.php:451 includes/view/User_view.php:130 +msgid "auth.no-nickname" +msgstr "Gib bitte einen Nick an." + +#: includes/pages/guest_login.php:481 +#: includes/view/User_view.php:122 msgid "I forgot my password" msgstr "Passwort vergessen" @@ -2357,7 +2360,7 @@ msgid "" "I have my own car with me and am willing to use it for the event (You'll get " "reimbursed for fuel)" msgstr "" -"Ich habe mein eigenes Auto dabei und möchte würde es zum Fahren für das " +"Ich habe mein eigenes Auto dabei und möchte es zum Fahren für das " "Event verwenden (Du wirst für Spritkosten entschädigt)" #: includes/view/UserDriverLicenses_view.php:30 diff --git a/resources/lang/en_US.UTF-8/LC_MESSAGES/default.mo b/resources/lang/en_US.UTF-8/LC_MESSAGES/default.mo new file mode 100644 index 0000000000000000000000000000000000000000..e95ae7038db0dddb2327828cd145df1902bccfaf GIT binary patch literal 745 zcmZ8f&2G~`5H?UQIUvM|1Bc-Tf;C=Wnr_IYA%%z(tTsg)kfNzQaZH_Ev%5~ofeWvI z-~o6OUW8|1T-O*e(x-1{=i8m1-QV~2z6Wf3j0cS8jN6P4jK(U)UB;(t{>prZ@s088 zMiBgDzR$>Rw)}o&YtnObK- zV$DZNy_jX<9a&cxtzfEiD&5X`+CUsegXHZ(Oe~@2sCa>%6vUC-7cv&{0muUt$tRJF z8lgC$ZPDD)>xM!~5${73sd(7x=BV=;a}o=}je^1P?0DLzmz;89v?uMwVpnlCPoMp> zs>DK%AYG$%tkAiF;d$W)@5M{fWYHU|ATg8`9%MKSrQ}fS zi`TSkCgbLX^9q)uoP3k8eYwns6xM15Dt>EwpfZIV>eELuC81+jz`cg$B#5T z9D{s3>d&8=+NLyVyHL=!F-OO}Ha(PWse^7r3P>-{PX9SiXw!AV6^N**AxKK%l33=+ z0z&ul;s1f|2ZX^J1GQuEB|5nJ1tDBt;_VJF=Q)QkTfxrMfR@1dqAX#JTb?Ua)|{8L PaZdr=1-v5Mk`9AE#iiL9 literal 0 HcmV?d00001 diff --git a/resources/lang/en_US.UTF-8/LC_MESSAGES/default.po b/resources/lang/en_US.UTF-8/LC_MESSAGES/default.po new file mode 100644 index 00000000..22566e52 --- /dev/null +++ b/resources/lang/en_US.UTF-8/LC_MESSAGES/default.po @@ -0,0 +1,26 @@ +msgid "" +msgstr "" +"Project-Id-Version: Engelsystem 2.0\n" +"POT-Creation-Date: 2017-12-29 19:01+0100\n" +"PO-Revision-Date: 2018-11-27 00:28+0100\n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"X-Generator: Poedit 1.8.11\n" +"X-Poedit-KeywordsList: _;gettext;gettext_noop\n" +"X-Poedit-Basepath: .\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" +"X-Poedit-SourceCharset: UTF-8\n" +"Last-Translator: \n" +"Language: en_US\n" +"X-Poedit-SearchPath-0: .\n" + +msgid "auth.no-nickname" +msgstr "Please enter a nickname." + +msgid "auth.no-password" +msgstr "Please enter a password." + +msgid "auth.not-found" +msgstr "No user was found. Please try again. If you are still having problems, ask Heaven." diff --git a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.mo b/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.mo new file mode 100644 index 0000000000000000000000000000000000000000..8b8641565705f92b5dbcf78956dbb887cd62b6bb GIT binary patch literal 41129 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 UT)6FvIMERxof*)@;U?k#0R=vo82|tP literal 0 HcmV?d00001 diff --git a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/pt_BR.po b/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.po similarity index 99% rename from resources/lang/pt_BR.UTF.8/LC_MESSAGES/pt_BR.po rename to resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.po index e7307e5d..b9bf420d 100644 --- a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/pt_BR.po +++ b/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: Engelsystem 2.0\n" "POT-Creation-Date: 2017-04-25 05:23+0200\n" -"PO-Revision-Date: 2018-10-05 15:35+0200\n" +"PO-Revision-Date: 2018-11-27 00:29+0100\n" "Last-Translator: samba \n" "Language-Team: \n" "Language: pt_BR\n" @@ -477,7 +477,7 @@ msgstr "Você não pode se deletar." #: includes/controller/users_controller.php:61 #: includes/pages/guest_login.php:315 -msgid "Your password is incorrect. Please try it again." +msgid "Your password is incorrect. Please try it again." msgstr "Sua senha está incorreta. Por favor, tente novamente." #: includes/controller/users_controller.php:71 @@ -1420,19 +1420,17 @@ msgid "Entry required!" msgstr "Campo necessário!" #: includes/pages/guest_login.php:319 -msgid "Please enter a password." +msgid "auth.no-password" msgstr "Por favor digite uma senha." #: includes/pages/guest_login.php:323 -msgid "" -"No user was found with that Nickname. Please try again. If you are still " -"having problems, ask a Dispatcher." +msgid "auth.not-found" msgstr "" -"Nenhum usuário com esse apelido foi encontrado. Por favor tente novamente. \n" +"Nenhum usuário foi encontrado. Por favor tente novamente. \n" "Se você continuar com problemas, pergunte a um Dispatcher." #: includes/pages/guest_login.php:327 -msgid "Please enter a nickname." +msgid "auth.no-nickname" msgstr "Por favor digite um apelido." #: includes/pages/guest_login.php:358 includes/view/User_view.php:101 diff --git a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/pt_BR.mo b/resources/lang/pt_BR.UTF.8/LC_MESSAGES/pt_BR.mo deleted file mode 100644 index 95251feb85fead203c8e17739e7b2a139d008363..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 41256 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 HChq?MsA +{% endmacro %} + +{% macro glyphicon(glyph) %} + +{% endmacro %} + +{% macro alert(message, type) %} +
{{ message }}
+{% endmacro %} diff --git a/resources/views/pages/login.twig b/resources/views/pages/login.twig new file mode 100644 index 00000000..75b98aa1 --- /dev/null +++ b/resources/views/pages/login.twig @@ -0,0 +1,104 @@ +{% extends "layouts/app.twig" %} +{% import 'macros/base.twig' as m %} + +{% block title %}{{ __('Login') }}{% endblock %} + +{% block content %} +
+{% endblock %} diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php index cdaee167..e5fc40e3 100644 --- a/src/Controllers/AuthController.php +++ b/src/Controllers/AuthController.php @@ -2,8 +2,12 @@ namespace Engelsystem\Controllers; +use Carbon\Carbon; +use Engelsystem\Helpers\Authenticator; +use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Http\UrlGeneratorInterface; +use Engelsystem\Models\User\User; use Symfony\Component\HttpFoundation\Session\SessionInterface; class AuthController extends BaseController @@ -17,20 +21,100 @@ class AuthController extends BaseController /** @var UrlGeneratorInterface */ protected $url; - public function __construct(Response $response, SessionInterface $session, UrlGeneratorInterface $url) - { + /** @var Authenticator */ + protected $auth; + + /** @var array */ + protected $permissions = [ + 'login' => 'login', + 'postLogin' => 'login', + ]; + + /** + * @param Response $response + * @param SessionInterface $session + * @param UrlGeneratorInterface $url + * @param Authenticator $auth + */ + public function __construct( + Response $response, + SessionInterface $session, + UrlGeneratorInterface $url, + Authenticator $auth + ) { $this->response = $response; $this->session = $session; $this->url = $url; + $this->auth = $auth; } /** * @return Response */ - public function logout() + public function login() + { + return $this->response->withView('pages/login'); + } + + /** + * Posted login form + * + * @param Request $request + * @return Response + */ + public function postLogin(Request $request): Response + { + $return = $this->authenticateUser($request->get('login', ''), $request->get('password', '')); + if (!$return instanceof User) { + return $this->response->withView( + 'pages/login', + ['errors' => [$return], 'show_password_recovery' => true] + ); + } + + $user = $return; + + $this->session->invalidate(); + $this->session->set('user_id', $user->id); + $this->session->set('locale', $user->settings->language); + + $user->last_login_at = new Carbon(); + $user->save(['touch' => false]); + + return $this->response->redirectTo('news'); + } + + /** + * @return Response + */ + public function logout(): Response { $this->session->invalidate(); return $this->response->redirectTo($this->url->to('/')); } + + /** + * Verify the user and password + * + * @param $login + * @param $password + * @return User|string + */ + protected function authenticateUser(string $login, string $password) + { + if (!$login) { + return 'auth.no-nickname'; + } + + if (!$password) { + return 'auth.no-password'; + } + + if (!$user = $this->auth->authenticate($login, $password)) { + return 'auth.not-found'; + } + + return $user; + } } diff --git a/src/Helpers/Authenticator.php b/src/Helpers/Authenticator.php index 61d07980..db33339b 100644 --- a/src/Helpers/Authenticator.php +++ b/src/Helpers/Authenticator.php @@ -25,6 +25,9 @@ class Authenticator /** @var string[] */ protected $permissions; + /** @var int */ + protected $passwordAlgorithm = PASSWORD_DEFAULT; + /** * @param ServerRequestInterface $request * @param Session $session @@ -48,7 +51,7 @@ class Authenticator return $this->user; } - $userId = $this->session->get('uid'); + $userId = $this->session->get('user_id'); if (!$userId) { return null; } @@ -104,17 +107,15 @@ class Authenticator $abilities = (array)$abilities; if (empty($this->permissions)) { - $userId = $this->user ? $this->user->id : $this->session->get('uid'); + $user = $this->user(); - if ($userId) { - if ($user = $this->user()) { - $this->permissions = $this->getPermissionsByUser($user); + if ($user) { + $this->permissions = $this->getPermissionsByUser($user); - $user->last_login_at = new Carbon(); - $user->save(); - } else { - $this->session->remove('uid'); - } + $user->last_login_at = new Carbon(); + $user->save(); + } elseif ($this->session->get('user_id')) { + $this->session->remove('user_id'); } if (empty($this->permissions)) { @@ -131,6 +132,78 @@ class Authenticator return true; } + /** + * @param string $login + * @param string $password + * @return User|null + */ + public function authenticate(string $login, string $password) + { + /** @var User $user */ + $user = $this->userRepository->whereName($login)->first(); + if (!$user) { + $user = $this->userRepository->whereEmail($login)->first(); + } + + if (!$user) { + return null; + } + + if (!$this->verifyPassword($user, $password)) { + return null; + } + + return $user; + } + + /** + * @param User $user + * @param string $password + * @return bool + */ + public function verifyPassword(User $user, string $password) + { + $algorithm = $this->passwordAlgorithm; + + if (!password_verify($password, $user->password)) { + return false; + } + + if (password_needs_rehash($user->password, $algorithm)) { + $this->setPassword($user, $password); + } + + return true; + } + + /** + * @param UserRepository $user + * @param string $password + */ + public function setPassword(User $user, string $password) + { + $algorithm = $this->passwordAlgorithm; + + $user->password = password_hash($password, $algorithm); + $user->save(); + } + + /** + * @return int + */ + public function getPasswordAlgorithm() + { + return $this->passwordAlgorithm; + } + + /** + * @param int $passwordAlgorithm + */ + public function setPasswordAlgorithm(int $passwordAlgorithm) + { + $this->passwordAlgorithm = $passwordAlgorithm; + } + /** * @param User $user * @return array diff --git a/src/Helpers/AuthenticatorServiceProvider.php b/src/Helpers/AuthenticatorServiceProvider.php index 715a592f..f06e635d 100644 --- a/src/Helpers/AuthenticatorServiceProvider.php +++ b/src/Helpers/AuthenticatorServiceProvider.php @@ -2,14 +2,18 @@ namespace Engelsystem\Helpers; +use Engelsystem\Config\Config; use Engelsystem\Container\ServiceProvider; class AuthenticatorServiceProvider extends ServiceProvider { public function register() { + /** @var Config $config */ + $config = $this->app->get('config'); /** @var Authenticator $authenticator */ $authenticator = $this->app->make(Authenticator::class); + $authenticator->setPasswordAlgorithm($config->get('password_algorithm')); $this->app->instance(Authenticator::class, $authenticator); $this->app->instance('authenticator', $authenticator); diff --git a/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php index af2c6a70..7adcc88d 100644 --- a/src/Middleware/LegacyMiddleware.php +++ b/src/Middleware/LegacyMiddleware.php @@ -19,7 +19,6 @@ class LegacyMiddleware implements MiddlewareInterface 'angeltypes', 'atom', 'ical', - 'login', 'public_dashboard', 'rooms', 'shift_entries', @@ -175,10 +174,6 @@ class LegacyMiddleware implements MiddlewareInterface $title = settings_title(); $content = user_settings(); return [$title, $content]; - case 'login': - $title = login_title(); - $content = guest_login(); - return [$title, $content]; case 'register': $title = register_title(); $content = guest_register(); diff --git a/tests/Unit/Controllers/AuthControllerTest.php b/tests/Unit/Controllers/AuthControllerTest.php index c5349cda..0fad3b6d 100644 --- a/tests/Unit/Controllers/AuthControllerTest.php +++ b/tests/Unit/Controllers/AuthControllerTest.php @@ -3,40 +3,154 @@ namespace Engelsystem\Test\Unit\Controllers; use Engelsystem\Controllers\AuthController; +use Engelsystem\Helpers\Authenticator; +use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Http\UrlGeneratorInterface; +use Engelsystem\Models\User\Settings; +use Engelsystem\Models\User\User; +use Engelsystem\Test\Unit\HasDatabase; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\SessionInterface; class AuthControllerTest extends TestCase { + use HasDatabase; + /** * @covers \Engelsystem\Controllers\AuthController::__construct - * @covers \Engelsystem\Controllers\AuthController::logout + * @covers \Engelsystem\Controllers\AuthController::login */ - public function testLogout() + public function testLogin() { /** @var Response|MockObject $response */ $response = $this->createMock(Response::class); /** @var SessionInterface|MockObject $session */ - $session = $this->getMockForAbstractClass(SessionInterface::class); /** @var UrlGeneratorInterface|MockObject $url */ - $url = $this->getMockForAbstractClass(UrlGeneratorInterface::class); + /** @var Authenticator|MockObject $auth */ + list(, $session, $url, $auth) = $this->getMocks(); + + $response->expects($this->once()) + ->method('withView') + ->with('pages/login') + ->willReturn($response); + + $controller = new AuthController($response, $session, $url, $auth); + $controller->login(); + } + + /** + * @covers \Engelsystem\Controllers\AuthController::postLogin + * @covers \Engelsystem\Controllers\AuthController::authenticateUser + */ + public function testPostLogin() + { + $this->initDatabase(); + + $request = new Request(); + /** @var Response|MockObject $response */ + $response = $this->createMock(Response::class); + /** @var SessionInterface|MockObject $session */ + /** @var UrlGeneratorInterface|MockObject $url */ + /** @var Authenticator|MockObject $auth */ + list(, $session, $url, $auth) = $this->getMocks(); + + $user = new User([ + 'name' => 'foo', + 'password' => '', + 'email' => '', + 'api_key' => '', + 'last_login_at' => null, + ]); + $user->forceFill(['id' => 42,]); + $user->save(); + + $settings = new Settings(['language' => 'de_DE', 'theme' => '']); + $settings->user() + ->associate($user) + ->save(); + + $auth->expects($this->exactly(2)) + ->method('authenticate') + ->with('foo', 'bar') + ->willReturnOnConsecutiveCalls(null, $user); + + $response->expects($this->exactly(3)) + ->method('withView') + ->withConsecutive( + ['pages/login', ['errors' => ['auth.no-nickname'], 'show_password_recovery' => true]], + ['pages/login', ['errors' => ['auth.no-password'], 'show_password_recovery' => true]], + ['pages/login', ['errors' => ['auth.not-found'], 'show_password_recovery' => true]]) + ->willReturn($response); + $response->expects($this->once()) + ->method('redirectTo') + ->with('news') + ->willReturn($response); $session->expects($this->once()) ->method('invalidate'); - $response->expects($this->once()) - ->method('redirectTo') - ->with('https://foo.bar/'); + $session->expects($this->exactly(2)) + ->method('set') + ->withConsecutive( + ['user_id', 42], + ['locale', 'de_DE'] + ); + + $controller = new AuthController($response, $session, $url, $auth); + $controller->postLogin($request); + + $request = new Request(['login' => 'foo']); + $controller->postLogin($request); + + $request = new Request(['login' => 'foo', 'password' => 'bar']); + // No user found + $controller->postLogin($request); + // Authenticated user + $controller->postLogin($request); + + $this->assertNotNull($user->last_login_at); + } + + /** + * @covers \Engelsystem\Controllers\AuthController::logout + */ + public function testLogout() + { + /** @var Response $response */ + /** @var SessionInterface|MockObject $session */ + /** @var UrlGeneratorInterface|MockObject $url */ + /** @var Authenticator|MockObject $auth */ + list($response, $session, $url, $auth) = $this->getMocks(); + + $session->expects($this->once()) + ->method('invalidate'); $url->expects($this->once()) ->method('to') ->with('/') ->willReturn('https://foo.bar/'); - $controller = new AuthController($response, $session, $url); - $controller->logout(); + $controller = new AuthController($response, $session, $url, $auth); + $return = $controller->logout(); + + $this->assertEquals(['https://foo.bar/'], $return->getHeader('location')); + } + + /** + * @return array + */ + protected function getMocks() + { + $response = new Response(); + /** @var SessionInterface|MockObject $session */ + $session = $this->getMockForAbstractClass(SessionInterface::class); + /** @var UrlGeneratorInterface|MockObject $url */ + $url = $this->getMockForAbstractClass(UrlGeneratorInterface::class); + /** @var Authenticator|MockObject $auth */ + $auth = $this->createMock(Authenticator::class); + + return [$response, $session, $url, $auth]; } } diff --git a/tests/Unit/Controllers/Stub/ControllerImplementation.php b/tests/Unit/Controllers/Stub/ControllerImplementation.php index 01d9f250..a8bf538c 100644 --- a/tests/Unit/Controllers/Stub/ControllerImplementation.php +++ b/tests/Unit/Controllers/Stub/ControllerImplementation.php @@ -14,12 +14,4 @@ class ControllerImplementation extends BaseController 'dolor', ], ]; - - /** - * @param array $permissions - */ - public function setPermissions(array $permissions) - { - $this->permissions = $permissions; - } } diff --git a/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php b/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php index b1767ebc..ab9b23ec 100644 --- a/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php +++ b/tests/Unit/Helpers/AuthenticatorServiceProviderTest.php @@ -3,6 +3,7 @@ namespace Engelsystem\Test\Unit\Helpers; use Engelsystem\Application; +use Engelsystem\Config\Config; use Engelsystem\Helpers\Authenticator; use Engelsystem\Helpers\AuthenticatorServiceProvider; use Engelsystem\Http\Request; @@ -19,11 +20,19 @@ class AuthenticatorServiceProviderTest extends ServiceProviderTest $app = new Application(); $app->bind(ServerRequestInterface::class, Request::class); + $config = new Config(); + $config->set('password_algorithm', PASSWORD_DEFAULT); + $app->instance('config', $config); + $serviceProvider = new AuthenticatorServiceProvider($app); $serviceProvider->register(); $this->assertInstanceOf(Authenticator::class, $app->get(Authenticator::class)); $this->assertInstanceOf(Authenticator::class, $app->get('authenticator')); $this->assertInstanceOf(Authenticator::class, $app->get('auth')); + + /** @var Authenticator $auth */ + $auth = $app->get(Authenticator::class); + $this->assertEquals(PASSWORD_DEFAULT, $auth->getPasswordAlgorithm()); } } diff --git a/tests/Unit/Helpers/AuthenticatorTest.php b/tests/Unit/Helpers/AuthenticatorTest.php index 400278f2..83dc72ad 100644 --- a/tests/Unit/Helpers/AuthenticatorTest.php +++ b/tests/Unit/Helpers/AuthenticatorTest.php @@ -4,6 +4,7 @@ namespace Engelsystem\Test\Unit\Helpers; use Engelsystem\Helpers\Authenticator; use Engelsystem\Models\User\User; +use Engelsystem\Test\Unit\HasDatabase; use Engelsystem\Test\Unit\Helpers\Stub\UserModelImplementation; use Engelsystem\Test\Unit\ServiceProviderTest; use PHPUnit\Framework\MockObject\MockObject; @@ -12,6 +13,8 @@ use Symfony\Component\HttpFoundation\Session\Session; class AuthenticatorTest extends ServiceProviderTest { + use HasDatabase; + /** * @covers \Engelsystem\Helpers\Authenticator::__construct( * @covers \Engelsystem\Helpers\Authenticator::user @@ -29,7 +32,7 @@ class AuthenticatorTest extends ServiceProviderTest $session->expects($this->exactly(3)) ->method('get') - ->with('uid') + ->with('user_id') ->willReturnOnConsecutiveCalls( null, 42, @@ -114,16 +117,13 @@ class AuthenticatorTest extends ServiceProviderTest /** @var User|MockObject $user */ $user = $this->createMock(User::class); - $user->expects($this->once()) - ->method('save'); - - $session->expects($this->exactly(2)) + $session->expects($this->once()) ->method('get') - ->with('uid') + ->with('user_id') ->willReturn(42); $session->expects($this->once()) ->method('remove') - ->with('uid'); + ->with('user_id'); /** @var Authenticator|MockObject $auth */ $auth = $this->getMockBuilder(Authenticator::class) @@ -151,4 +151,115 @@ class AuthenticatorTest extends ServiceProviderTest // Permissions cached $this->assertTrue($auth->can('bar')); } + + /** + * @covers \Engelsystem\Helpers\Authenticator::authenticate + */ + public function testAuthenticate() + { + $this->initDatabase(); + + /** @var ServerRequestInterface|MockObject $request */ + $request = $this->getMockForAbstractClass(ServerRequestInterface::class); + /** @var Session|MockObject $session */ + $session = $this->createMock(Session::class); + $userRepository = new User(); + + (new User([ + 'name' => 'lorem', + 'password' => password_hash('testing', PASSWORD_DEFAULT), + 'email' => 'lorem@foo.bar', + 'api_key' => '', + ]))->save(); + (new User([ + 'name' => 'ipsum', + 'password' => '', + 'email' => 'ipsum@foo.bar', + 'api_key' => '', + ]))->save(); + + $auth = new Authenticator($request, $session, $userRepository); + $this->assertNull($auth->authenticate('not-existing', 'foo')); + $this->assertNull($auth->authenticate('ipsum', 'wrong-password')); + $this->assertInstanceOf(User::class, $auth->authenticate('lorem', 'testing')); + $this->assertInstanceOf(User::class, $auth->authenticate('lorem@foo.bar', 'testing')); + } + + /** + * @covers \Engelsystem\Helpers\Authenticator::verifyPassword + */ + public function testVerifyPassword() + { + $this->initDatabase(); + $password = password_hash('testing', PASSWORD_ARGON2I); + $user = new User([ + 'name' => 'lorem', + 'password' => $password, + 'email' => 'lorem@foo.bar', + 'api_key' => '', + ]); + $user->save(); + + /** @var Authenticator|MockObject $auth */ + $auth = $this->getMockBuilder(Authenticator::class) + ->disableOriginalConstructor() + ->setMethods(['setPassword']) + ->getMock(); + + $auth->expects($this->once()) + ->method('setPassword') + ->with($user, 'testing'); + $auth->setPasswordAlgorithm(PASSWORD_BCRYPT); + + $this->assertFalse($auth->verifyPassword($user, 'randomStuff')); + $this->assertTrue($auth->verifyPassword($user, 'testing')); + } + + /** + * @covers \Engelsystem\Helpers\Authenticator::setPassword + */ + public function testSetPassword() + { + $this->initDatabase(); + $user = new User([ + 'name' => 'ipsum', + 'password' => '', + 'email' => 'ipsum@foo.bar', + 'api_key' => '', + ]); + $user->save(); + + $auth = $this->getAuthenticator(); + $auth->setPasswordAlgorithm(PASSWORD_ARGON2I); + + $auth->setPassword($user, 'FooBar'); + $this->assertTrue($user->isClean()); + + $this->assertTrue(password_verify('FooBar', $user->password)); + $this->assertFalse(password_needs_rehash($user->password, PASSWORD_ARGON2I)); + } + + /** + * @covers \Engelsystem\Helpers\Authenticator::setPasswordAlgorithm + * @covers \Engelsystem\Helpers\Authenticator::getPasswordAlgorithm + */ + public function testPasswordAlgorithm() + { + $auth = $this->getAuthenticator(); + + $auth->setPasswordAlgorithm(PASSWORD_ARGON2I); + $this->assertEquals(PASSWORD_ARGON2I, $auth->getPasswordAlgorithm()); + } + + /** + * @return Authenticator + */ + protected function getAuthenticator() + { + return new class extends Authenticator + { + /** @noinspection PhpMissingParentConstructorInspection */ + public function __construct() { } + }; + } } diff --git a/tests/Unit/Http/UrlGeneratorServiceProviderTest.php b/tests/Unit/Http/UrlGeneratorServiceProviderTest.php index 61bf3e7c..6d18f160 100644 --- a/tests/Unit/Http/UrlGeneratorServiceProviderTest.php +++ b/tests/Unit/Http/UrlGeneratorServiceProviderTest.php @@ -19,7 +19,7 @@ class UrlGeneratorServiceProviderTest extends ServiceProviderTest $urlGenerator = $this->getMockBuilder(UrlGenerator::class) ->getMock(); - $app = $this->getApp(); + $app = $this->getApp(['make', 'instance', 'bind']); $this->setExpects($app, 'make', [UrlGenerator::class], $urlGenerator); $app->expects($this->exactly(2)) @@ -29,6 +29,9 @@ class UrlGeneratorServiceProviderTest extends ServiceProviderTest ['http.urlGenerator', $urlGenerator], [UrlGeneratorInterface::class, $urlGenerator] ); + $app->expects($this->once()) + ->method('bind') + ->with(UrlGeneratorInterface::class, UrlGenerator::class); $serviceProvider = new UrlGeneratorServiceProvider($app); $serviceProvider->register(); From e9f157ec5ccdfae73b4c9e82c9ae7c37bcfa1513 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Mon, 3 Dec 2018 23:39:50 +0100 Subject: [PATCH 02/17] Renderer: Added shared data --- src/Controllers/Metrics/MetricsEngine.php | 18 ++++++++--- src/Renderer/Engine.php | 22 +++++++++++++ src/Renderer/EngineInterface.php | 10 ++++-- src/Renderer/HtmlEngine.php | 8 +++-- src/Renderer/TwigEngine.php | 8 +++-- .../Controllers/Metrics/MetricsEngineTest.php | 11 +++++++ tests/Unit/Renderer/EngineTest.php | 25 +++++++++++++++ tests/Unit/Renderer/HtmlEngineTest.php | 5 +-- .../Renderer/Stub/EngineImplementation.php | 32 +++++++++++++++++++ tests/Unit/Renderer/TwigEngineTest.php | 12 +++---- 10 files changed, 130 insertions(+), 21 deletions(-) create mode 100644 src/Renderer/Engine.php create mode 100644 tests/Unit/Renderer/EngineTest.php create mode 100644 tests/Unit/Renderer/Stub/EngineImplementation.php diff --git a/src/Controllers/Metrics/MetricsEngine.php b/src/Controllers/Metrics/MetricsEngine.php index 1e0f6957..21ae8fd0 100644 --- a/src/Controllers/Metrics/MetricsEngine.php +++ b/src/Controllers/Metrics/MetricsEngine.php @@ -9,13 +9,13 @@ class MetricsEngine implements EngineInterface /** * Render metrics * - * @example $data = ['foo' => [['labels' => ['foo'=>'bar'], 'value'=>42]], 'bar'=>123] - * * @param string $path * @param mixed[] $data * @return string + * + * @example $data = ['foo' => [['labels' => ['foo'=>'bar'], 'value'=>42]], 'bar'=>123] */ - public function get($path, $data = []): string + public function get(string $path, array $data = []): string { $return = []; foreach ($data as $name => $list) { @@ -52,7 +52,7 @@ class MetricsEngine implements EngineInterface * @param string $path * @return bool */ - public function canRender($path): bool + public function canRender(string $path): bool { return $path == '/metrics'; } @@ -60,8 +60,8 @@ class MetricsEngine implements EngineInterface /** * @param string $name * @param array|mixed $row - * @see https://prometheus.io/docs/instrumenting/exposition_formats/ * @return string + * @see https://prometheus.io/docs/instrumenting/exposition_formats/ */ protected function formatData($name, $row): string { @@ -135,4 +135,12 @@ class MetricsEngine implements EngineInterface $value ); } + + /** + * Does nothing as shared data will onyly result in unexpected behaviour + * + * @param string|mixed[] $key + * @param mixed $value + */ + public function share($key, $value = null) { } } diff --git a/src/Renderer/Engine.php b/src/Renderer/Engine.php new file mode 100644 index 00000000..60f1d686 --- /dev/null +++ b/src/Renderer/Engine.php @@ -0,0 +1,22 @@ + $value]; + } + + $this->sharedData = array_replace_recursive($this->sharedData, $key); + } +} diff --git a/src/Renderer/EngineInterface.php b/src/Renderer/EngineInterface.php index ca468db5..3bce9c02 100644 --- a/src/Renderer/EngineInterface.php +++ b/src/Renderer/EngineInterface.php @@ -11,11 +11,17 @@ interface EngineInterface * @param mixed[] $data * @return string */ - public function get($path, $data = []); + public function get(string $path, array $data = []): string; /** * @param string $path * @return bool */ - public function canRender($path); + public function canRender(string $path): bool; + + /** + * @param string|mixed[] $key + * @param mixed $value + */ + public function share($key, $value = null); } diff --git a/src/Renderer/HtmlEngine.php b/src/Renderer/HtmlEngine.php index 1feafcda..0ccffa65 100644 --- a/src/Renderer/HtmlEngine.php +++ b/src/Renderer/HtmlEngine.php @@ -2,7 +2,7 @@ namespace Engelsystem\Renderer; -class HtmlEngine implements EngineInterface +class HtmlEngine extends Engine { /** * Render a template @@ -11,9 +11,11 @@ class HtmlEngine implements EngineInterface * @param mixed[] $data * @return string */ - public function get($path, $data = []) + public function get(string $path, array $data = []): string { + $data = array_replace_recursive($this->sharedData, $data); $template = file_get_contents($path); + if (is_array($data)) { foreach ($data as $name => $content) { $template = str_replace('%' . $name . '%', $content, $template); @@ -27,7 +29,7 @@ class HtmlEngine implements EngineInterface * @param string $path * @return bool */ - public function canRender($path) + public function canRender(string $path): bool { return mb_strpos($path, '.htm') !== false && file_exists($path); } diff --git a/src/Renderer/TwigEngine.php b/src/Renderer/TwigEngine.php index 55a2e299..aa51a177 100644 --- a/src/Renderer/TwigEngine.php +++ b/src/Renderer/TwigEngine.php @@ -7,7 +7,7 @@ use Twig_Error_Loader as LoaderError; use Twig_Error_Runtime as RuntimeError; use Twig_Error_Syntax as SyntaxError; -class TwigEngine implements EngineInterface +class TwigEngine extends Engine { /** @var Twig */ protected $twig; @@ -25,8 +25,10 @@ class TwigEngine implements EngineInterface * @return string * @throws LoaderError|RuntimeError|SyntaxError */ - public function get($path, $data = []) + public function get(string $path, array $data = []): string { + $data = array_replace_recursive($this->sharedData, $data); + return $this->twig->render($path, $data); } @@ -34,7 +36,7 @@ class TwigEngine implements EngineInterface * @param string $path * @return bool */ - public function canRender($path) + public function canRender(string $path): bool { return $this->twig->getLoader()->exists($path); } diff --git a/tests/Unit/Controllers/Metrics/MetricsEngineTest.php b/tests/Unit/Controllers/Metrics/MetricsEngineTest.php index 38817b36..87a7dc88 100644 --- a/tests/Unit/Controllers/Metrics/MetricsEngineTest.php +++ b/tests/Unit/Controllers/Metrics/MetricsEngineTest.php @@ -66,4 +66,15 @@ class MetricsEngineTest extends TestCase $this->assertFalse($engine->canRender('/metrics.foo')); $this->assertTrue($engine->canRender('/metrics')); } + + /** + * @covers \Engelsystem\Controllers\Metrics\MetricsEngine::share + */ + public function testShare() + { + $engine = new MetricsEngine(); + + $engine->share('foo', 42); + $this->assertEquals('', $engine->get('/metrics')); + } } diff --git a/tests/Unit/Renderer/EngineTest.php b/tests/Unit/Renderer/EngineTest.php new file mode 100644 index 00000000..659d85c5 --- /dev/null +++ b/tests/Unit/Renderer/EngineTest.php @@ -0,0 +1,25 @@ +share(['foo' => ['bar' => 'baz', 'lorem' => 'ipsum']]); + $engine->share(['foo' => ['lorem' => 'dolor']]); + $engine->share('key', 'value'); + + $this->assertEquals( + ['foo' => ['bar' => 'baz', 'lorem' => 'dolor'], 'key' => 'value'], + $engine->getSharedData() + ); + } +} diff --git a/tests/Unit/Renderer/HtmlEngineTest.php b/tests/Unit/Renderer/HtmlEngineTest.php index 4a31e4bc..f76e7528 100644 --- a/tests/Unit/Renderer/HtmlEngineTest.php +++ b/tests/Unit/Renderer/HtmlEngineTest.php @@ -16,11 +16,12 @@ class HtmlEngineTest extends TestCase public function testGet() { $engine = new HtmlEngine(); + $engine->share('shared_data', 'tester'); - $file = $this->createTempFile('
%main_content%
'); + $file = $this->createTempFile('
%main_content% is a %shared_data%
'); $data = $engine->get($file, ['main_content' => 'Lorem ipsum dolor sit']); - $this->assertEquals('
Lorem ipsum dolor sit
', $data); + $this->assertEquals('
Lorem ipsum dolor sit is a tester
', $data); } /** diff --git a/tests/Unit/Renderer/Stub/EngineImplementation.php b/tests/Unit/Renderer/Stub/EngineImplementation.php new file mode 100644 index 00000000..fc436569 --- /dev/null +++ b/tests/Unit/Renderer/Stub/EngineImplementation.php @@ -0,0 +1,32 @@ +sharedData; + } +} diff --git a/tests/Unit/Renderer/TwigEngineTest.php b/tests/Unit/Renderer/TwigEngineTest.php index 9d0618f1..5e5e59d9 100644 --- a/tests/Unit/Renderer/TwigEngineTest.php +++ b/tests/Unit/Renderer/TwigEngineTest.php @@ -20,16 +20,16 @@ class TwigEngineTest extends TestCase $twig = $this->createMock(Twig::class); $path = 'foo.twig'; - $data = ['lorem' => 'ipsum']; - $twig->expects($this->once()) ->method('render') - ->with($path, $data) - ->willReturn('LoremIpsum!'); + ->with($path, ['lorem' => 'ipsum', 'shared' => 'data']) + ->willReturn('LoremIpsum data!'); $engine = new TwigEngine($twig); - $return = $engine->get($path, $data); - $this->assertEquals('LoremIpsum!', $return); + $engine->share('shared', 'data'); + + $return = $engine->get($path, ['lorem' => 'ipsum']); + $this->assertEquals('LoremIpsum data!', $return); } From f90ab26feedb61615bde2f94bbf5acc7e4f28342 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Mon, 8 Jul 2019 01:31:59 +0200 Subject: [PATCH 03/17] Moved translation helpers to sub namespace --- config/app.php | 2 +- includes/helper/email_helper.php | 3 ++- .../TranslationServiceProvider.php | 2 +- src/Helpers/{ => Translation}/Translator.php | 2 +- src/Middleware/LegacyMiddleware.php | 2 +- src/Middleware/SetLocale.php | 2 +- src/Renderer/Twig/Extensions/Translation.php | 2 +- src/helpers.php | 2 +- .../TranslationServiceProviderTest.php | 8 +++---- .../{ => Translation}/TranslatorTest.php | 22 +++++++++---------- tests/Unit/HelpersTest.php | 2 +- .../Unit/Middleware/LegacyMiddlewareTest.php | 2 +- tests/Unit/Middleware/SetLocaleTest.php | 2 +- .../Twig/Extensions/TranslationTest.php | 2 +- 14 files changed, 28 insertions(+), 27 deletions(-) rename src/Helpers/{ => Translation}/TranslationServiceProvider.php (97%) rename src/Helpers/{ => Translation}/Translator.php (98%) rename tests/Unit/Helpers/{ => Translation}/TranslationServiceProviderTest.php (91%) rename tests/Unit/Helpers/{ => Translation}/TranslatorTest.php (78%) diff --git a/config/app.php b/config/app.php index 17fdee11..5fda67dd 100644 --- a/config/app.php +++ b/config/app.php @@ -17,7 +17,7 @@ return [ \Engelsystem\Database\DatabaseServiceProvider::class, \Engelsystem\Http\RequestServiceProvider::class, \Engelsystem\Http\SessionServiceProvider::class, - \Engelsystem\Helpers\TranslationServiceProvider::class, + \Engelsystem\Helpers\Translation\TranslationServiceProvider::class, \Engelsystem\Http\ResponseServiceProvider::class, \Engelsystem\Http\Psr7ServiceProvider::class, \Engelsystem\Helpers\AuthenticatorServiceProvider::class, diff --git a/includes/helper/email_helper.php b/includes/helper/email_helper.php index bad0d539..3012d5ce 100644 --- a/includes/helper/email_helper.php +++ b/includes/helper/email_helper.php @@ -1,5 +1,6 @@ get('translator'); $locale = $translator->getLocale(); diff --git a/src/Helpers/TranslationServiceProvider.php b/src/Helpers/Translation/TranslationServiceProvider.php similarity index 97% rename from src/Helpers/TranslationServiceProvider.php rename to src/Helpers/Translation/TranslationServiceProvider.php index 4565dfcd..d0cda6a8 100644 --- a/src/Helpers/TranslationServiceProvider.php +++ b/src/Helpers/Translation/TranslationServiceProvider.php @@ -1,6 +1,6 @@ Date: Mon, 8 Jul 2019 01:47:01 +0200 Subject: [PATCH 04/17] Replaced gettext translation with package This allows to check if no translation is available --- README.md | 1 - composer.json | 2 +- config/config.default.php | 6 +- contrib/Dockerfile | 4 +- .../2019_06_12_000000_fix_user_languages.php | 34 ++++++++ .../LC_MESSAGES => de_DE}/default.mo | Bin .../LC_MESSAGES => de_DE}/default.po | 2 +- .../LC_MESSAGES => en_US}/default.mo | Bin .../LC_MESSAGES => en_US}/default.po | 0 .../LC_MESSAGES => pt_BR}/default.mo | Bin .../LC_MESSAGES => pt_BR}/default.po | 0 src/Helpers/Translation/GettextTranslator.php | 53 ++++++++++++ .../Translation/TranslationNotFound.php | 9 ++ .../TranslationServiceProvider.php | 55 ++++++++---- src/Helpers/Translation/Translator.php | 75 ++++++++-------- .../Translation/Assets/fo_OO/default.mo | Bin 0 -> 73 bytes .../Translation/Assets/fo_OO/default.po | 3 + .../Translation/GettextTranslatorTest.php | 67 +++++++++++++++ .../TranslationServiceProviderTest.php | 54 ++++++------ .../Helpers/Translation/TranslatorTest.php | 80 ++++++++++++++---- 20 files changed, 346 insertions(+), 99 deletions(-) create mode 100644 db/migrations/2019_06_12_000000_fix_user_languages.php rename resources/lang/{de_DE.UTF-8/LC_MESSAGES => de_DE}/default.mo (100%) rename resources/lang/{de_DE.UTF-8/LC_MESSAGES => de_DE}/default.po (99%) rename resources/lang/{en_US.UTF-8/LC_MESSAGES => en_US}/default.mo (100%) rename resources/lang/{en_US.UTF-8/LC_MESSAGES => en_US}/default.po (100%) rename resources/lang/{pt_BR.UTF.8/LC_MESSAGES => pt_BR}/default.mo (100%) rename resources/lang/{pt_BR.UTF.8/LC_MESSAGES => pt_BR}/default.po (100%) create mode 100644 src/Helpers/Translation/GettextTranslator.php create mode 100644 src/Helpers/Translation/TranslationNotFound.php create mode 100644 tests/Unit/Helpers/Translation/Assets/fo_OO/default.mo create mode 100644 tests/Unit/Helpers/Translation/Assets/fo_OO/default.po create mode 100644 tests/Unit/Helpers/Translation/GettextTranslatorTest.php diff --git a/README.md b/README.md index db62c6e5..2e06be9f 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,6 @@ To report bugs use [engelsystem/issues](https://github.com/engelsystem/engelsyst * PHP >= 7.1 * Required modules: * dom - * gettext * json * mbstring * PDO diff --git a/composer.json b/composer.json index 3e50226a..b2b70789 100644 --- a/composer.json +++ b/composer.json @@ -15,7 +15,6 @@ ], "require": { "php": ">=7.1.0", - "ext-gettext": "*", "ext-json": "*", "ext-libxml": "*", "ext-mbstring": "*", @@ -24,6 +23,7 @@ "ext-xml": "*", "doctrine/dbal": "^2.9", "erusev/parsedown": "^1.7", + "gettext/gettext": "^4.6", "illuminate/container": "5.8.*", "illuminate/database": "5.8.*", "illuminate/support": "5.8.*", diff --git a/config/config.default.php b/config/config.default.php index 9c9505c6..3fad18bc 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -134,12 +134,12 @@ return [ // Available locales in /locale/ 'locales' => [ - 'de_DE.UTF-8' => 'Deutsch', - 'en_US.UTF-8' => 'English', + 'de_DE' => 'Deutsch', + 'en_US' => 'English', ], // The default locale to use - 'default_locale' => env('DEFAULT_LOCALE', 'en_US.UTF-8'), + 'default_locale' => env('DEFAULT_LOCALE', 'en_US'), // Available T-Shirt sizes, set value to null if not available 'tshirt_sizes' => [ diff --git a/contrib/Dockerfile b/contrib/Dockerfile index dd3bd308..b6e2cb95 100644 --- a/contrib/Dockerfile +++ b/contrib/Dockerfile @@ -35,8 +35,8 @@ RUN rm -f /app/import/* /app/config/config.php # Build the PHP container FROM php:7-fpm-alpine WORKDIR /var/www -RUN apk add --no-cache icu-dev gettext-dev && \ - docker-php-ext-install intl gettext pdo_mysql +RUN apk add --no-cache icu-dev && \ + docker-php-ext-install intl pdo_mysql COPY --from=data /app/ /var/www RUN chown -R www-data:www-data /var/www/import/ /var/www/storage/ && \ rm -r /var/www/html diff --git a/db/migrations/2019_06_12_000000_fix_user_languages.php b/db/migrations/2019_06_12_000000_fix_user_languages.php new file mode 100644 index 00000000..c7d1474c --- /dev/null +++ b/db/migrations/2019_06_12_000000_fix_user_languages.php @@ -0,0 +1,34 @@ +schema->getConnection(); + $connection + ->table('users_settings') + ->update([ + 'language' => $connection->raw('REPLACE(language, ".UTF-8", "")') + ]); + } + + /** + * Reverse the migration + */ + public function down() + { + $connection = $this->schema->getConnection(); + $connection + ->table('users_settings') + ->update([ + 'language' => $connection->raw('CONCAT(language, ".UTF-8")') + ]); + } +} diff --git a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.mo b/resources/lang/de_DE/default.mo similarity index 100% rename from resources/lang/de_DE.UTF-8/LC_MESSAGES/default.mo rename to resources/lang/de_DE/default.mo diff --git a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po b/resources/lang/de_DE/default.po similarity index 99% rename from resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po rename to resources/lang/de_DE/default.po index 27ceb586..cd696610 100644 --- a/resources/lang/de_DE.UTF-8/LC_MESSAGES/default.po +++ b/resources/lang/de_DE/default.po @@ -10,7 +10,7 @@ msgstr "" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: 8bit\n" "X-Generator: Poedit 1.8.11\n" -"X-Poedit-KeywordsList: __;_e;translate;translatePlural;gettext;gettext_noop\n" +"X-Poedit-KeywordsList: __;_e;translate;translatePlural\n" "X-Poedit-Basepath: ../../../..\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" "X-Poedit-SourceCharset: UTF-8\n" diff --git a/resources/lang/en_US.UTF-8/LC_MESSAGES/default.mo b/resources/lang/en_US/default.mo similarity index 100% rename from resources/lang/en_US.UTF-8/LC_MESSAGES/default.mo rename to resources/lang/en_US/default.mo diff --git a/resources/lang/en_US.UTF-8/LC_MESSAGES/default.po b/resources/lang/en_US/default.po similarity index 100% rename from resources/lang/en_US.UTF-8/LC_MESSAGES/default.po rename to resources/lang/en_US/default.po diff --git a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.mo b/resources/lang/pt_BR/default.mo similarity index 100% rename from resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.mo rename to resources/lang/pt_BR/default.mo diff --git a/resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.po b/resources/lang/pt_BR/default.po similarity index 100% rename from resources/lang/pt_BR.UTF.8/LC_MESSAGES/default.po rename to resources/lang/pt_BR/default.po diff --git a/src/Helpers/Translation/GettextTranslator.php b/src/Helpers/Translation/GettextTranslator.php new file mode 100644 index 00000000..7f2299e2 --- /dev/null +++ b/src/Helpers/Translation/GettextTranslator.php @@ -0,0 +1,53 @@ +assertHasTranslation($domain, $context, $original); + + return parent::dpgettext($domain, $context, $original); + } + + /** + * @param string $domain + * @param string $context + * @param string $original + * @param string $plural + * @param string $value + * @return string + * @throws TranslationNotFound + */ + public function dnpgettext($domain, $context, $original, $plural, $value) + { + $this->assertHasTranslation($domain, $context, $original); + + return parent::dnpgettext($domain, $context, $original, $plural, $value); + } + + /** + * @param string $domain + * @param string $context + * @param string $original + * @throws TranslationNotFound + */ + protected function assertHasTranslation($domain, $context, $original) + { + if ($this->getTranslation($domain, $context, $original)) { + return; + } + + throw new TranslationNotFound(implode('/', [$domain, $context, $original])); + } +} diff --git a/src/Helpers/Translation/TranslationNotFound.php b/src/Helpers/Translation/TranslationNotFound.php new file mode 100644 index 00000000..1552838b --- /dev/null +++ b/src/Helpers/Translation/TranslationNotFound.php @@ -0,0 +1,9 @@ +app->get('config'); @@ -17,41 +21,36 @@ class TranslationServiceProvider extends ServiceProvider $locales = $config->get('locales'); $locale = $config->get('default_locale'); + $fallbackLocale = $config->get('fallback_locale', 'en_US'); $sessionLocale = $session->get('locale', $locale); if (isset($locales[$sessionLocale])) { $locale = $sessionLocale; } - $this->initGettext(); $session->set('locale', $locale); $translator = $this->app->make( Translator::class, - ['locale' => $locale, 'locales' => $locales, 'localeChangeCallback' => [$this, 'setLocale']] + [ + 'locale' => $locale, + 'locales' => $locales, + 'fallbackLocale' => $fallbackLocale, + 'getTranslatorCallback' => [$this, 'getTranslator'], + 'localeChangeCallback' => [$this, 'setLocale'], + ] ); $this->app->instance(Translator::class, $translator); $this->app->instance('translator', $translator); } - /** - * @param string $textDomain - * @param string $encoding - * @codeCoverageIgnore - */ - protected function initGettext($textDomain = 'default', $encoding = 'UTF-8') - { - bindtextdomain($textDomain, $this->app->get('path.lang')); - bind_textdomain_codeset($textDomain, $encoding); - textdomain($textDomain); - } - /** * @param string $locale * @codeCoverageIgnore */ - public function setLocale($locale) + public function setLocale(string $locale): void { + $locale .= '.UTF-8'; // Set the users locale putenv('LC_ALL=' . $locale); setlocale(LC_ALL, $locale); @@ -60,4 +59,28 @@ class TranslationServiceProvider extends ServiceProvider putenv('LC_NUMERIC=C'); setlocale(LC_NUMERIC, 'C'); } + + /** + * @param string $locale + * @return GettextTranslator + */ + public function getTranslator(string $locale): GettextTranslator + { + if (!isset($this->translators[$locale])) { + $file = $this->app->get('path.lang') . '/' . $locale . '/default.mo'; + + /** @var GettextTranslator $translator */ + $translator = $this->app->make(GettextTranslator::class); + + /** @var Translations $translations */ + $translations = $this->app->make(Translations::class); + $translations->addFromMoFile($file); + + $translator->loadTranslations($translations); + + $this->translators[$locale] = $translator; + } + + return $this->translators[$locale]; + } } diff --git a/src/Helpers/Translation/Translator.php b/src/Helpers/Translation/Translator.php index 545963eb..8b11ecb4 100644 --- a/src/Helpers/Translation/Translator.php +++ b/src/Helpers/Translation/Translator.php @@ -10,6 +10,12 @@ class Translator /** @var string */ protected $locale; + /** @var string */ + protected $fallbackLocale; + + /** @var callable */ + protected $getTranslatorCallback; + /** @var callable */ protected $localeChangeCallback; @@ -17,15 +23,24 @@ class Translator * Translator constructor. * * @param string $locale + * @param string $fallbackLocale + * @param callable $getTranslatorCallback * @param string[] $locales * @param callable $localeChangeCallback */ - public function __construct(string $locale, array $locales = [], callable $localeChangeCallback = null) - { + public function __construct( + string $locale, + string $fallbackLocale, + callable $getTranslatorCallback, + array $locales = [], + callable $localeChangeCallback = null + ) { $this->localeChangeCallback = $localeChangeCallback; + $this->getTranslatorCallback = $getTranslatorCallback; $this->setLocale($locale); - $this->setLocales($locales); + $this->fallbackLocale = $fallbackLocale; + $this->locales = $locales; } /** @@ -37,9 +52,7 @@ class Translator */ public function translate(string $key, array $replace = []): string { - $translated = $this->translateGettext($key); - - return $this->replaceText($translated, $replace); + return $this->translateText('gettext', [$key], $replace); } /** @@ -53,7 +66,29 @@ class Translator */ public function translatePlural(string $key, string $pluralKey, int $number, array $replace = []): string { - $translated = $this->translateGettextPlural($key, $pluralKey, $number); + return $this->translateText('ngettext', [$key, $pluralKey, $number], $replace); + } + + /** + * @param string $type + * @param array $parameters + * @param array $replace + * @return mixed|string + */ + protected function translateText(string $type, array $parameters, array $replace = []) + { + $translated = $parameters[0]; + + foreach ([$this->locale, $this->fallbackLocale] as $lang) { + /** @var GettextTranslator $translator */ + $translator = call_user_func($this->getTranslatorCallback, $lang); + + try { + $translated = call_user_func_array([$translator, $type], $parameters); + break; + } catch (TranslationNotFound $e) { + } + } return $this->replaceText($translated, $replace); } @@ -74,32 +109,6 @@ class Translator return call_user_func_array('sprintf', array_merge([$key], $replace)); } - /** - * Translate the key via gettext - * - * @param string $key - * @return string - * @codeCoverageIgnore - */ - protected function translateGettext(string $key): string - { - return gettext($key); - } - - /** - * Translate the key via gettext - * - * @param string $key - * @param string $keyPlural - * @param int $number - * @return string - * @codeCoverageIgnore - */ - protected function translateGettextPlural(string $key, string $keyPlural, int $number): string - { - return ngettext($key, $keyPlural, $number); - } - /** * @return string */ diff --git a/tests/Unit/Helpers/Translation/Assets/fo_OO/default.mo b/tests/Unit/Helpers/Translation/Assets/fo_OO/default.mo new file mode 100644 index 0000000000000000000000000000000000000000..96f1f3ca030c2b68c1b5f5b1ef8dafdb1f168a52 GIT binary patch literal 73 zcmca7#4?ou2pEA_28dOFm>Gz5fS4VKEr6Hu!6~sw GkpTeBbqAvW literal 0 HcmV?d00001 diff --git a/tests/Unit/Helpers/Translation/Assets/fo_OO/default.po b/tests/Unit/Helpers/Translation/Assets/fo_OO/default.po new file mode 100644 index 00000000..015bc36d --- /dev/null +++ b/tests/Unit/Helpers/Translation/Assets/fo_OO/default.po @@ -0,0 +1,3 @@ +# Testing content +msgid "foo.bar" +msgstr "Foo Bar!" diff --git a/tests/Unit/Helpers/Translation/GettextTranslatorTest.php b/tests/Unit/Helpers/Translation/GettextTranslatorTest.php new file mode 100644 index 00000000..825cf5b7 --- /dev/null +++ b/tests/Unit/Helpers/Translation/GettextTranslatorTest.php @@ -0,0 +1,67 @@ +getTranslations(); + + $translator = new GettextTranslator(); + $translator->loadTranslations($translations); + + $this->assertEquals('Translation!', $translator->gettext('test.value')); + + $this->expectException(TranslationNotFound::class); + $this->expectExceptionMessage('//foo.bar'); + + $translator->gettext('foo.bar'); + } + + /** + * @covers \Engelsystem\Helpers\Translation\GettextTranslator::dpgettext() + */ + public function testDpgettext() + { + $translations = $this->getTranslations(); + + $translator = new GettextTranslator(); + $translator->loadTranslations($translations); + + $this->assertEquals('Translation!', $translator->dpgettext(null, null, 'test.value')); + } + + /** + * @covers \Engelsystem\Helpers\Translation\GettextTranslator::dnpgettext() + */ + public function testDnpgettext() + { + $translations = $this->getTranslations(); + + $translator = new GettextTranslator(); + $translator->loadTranslations($translations); + + $this->assertEquals('Translations!', $translator->dnpgettext(null, null, 'test.value', 'test.values', 2)); + } + + protected function getTranslations(): Translations + { + $translations = new Translations(); + $translations[] = + (new Translation(null, 'test.value', 'test.values')) + ->setTranslation('Translation!') + ->setPluralTranslations(['Translations!']); + + return $translations; + } +} diff --git a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php index 171b5967..91307bdd 100644 --- a/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php +++ b/tests/Unit/Helpers/Translation/TranslationServiceProviderTest.php @@ -14,11 +14,14 @@ class TranslationServiceProviderTest extends ServiceProviderTest /** * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::register() */ - public function testRegister() + public function testRegister(): void { + $defaultLocale = 'fo_OO'; + $locale = 'te_ST.WTF-9'; + $locales = ['fo_OO' => 'Foo', 'fo_OO.BAR' => 'Foo (Bar)', 'te_ST.WTF-9' => 'WTF\'s Testing?']; + $config = new Config(['locales' => $locales, 'default_locale' => $defaultLocale]); + $app = $this->getApp(['make', 'instance', 'get']); - /** @var Config|MockObject $config */ - $config = $this->createMock(Config::class); /** @var Session|MockObject $session */ $session = $this->createMock(Session::class); /** @var Translator|MockObject $translator */ @@ -27,31 +30,14 @@ class TranslationServiceProviderTest extends ServiceProviderTest /** @var TranslationServiceProvider|MockObject $serviceProvider */ $serviceProvider = $this->getMockBuilder(TranslationServiceProvider::class) ->setConstructorArgs([$app]) - ->setMethods(['initGettext', 'setLocale']) + ->setMethods(['setLocale']) ->getMock(); - $serviceProvider->expects($this->once()) - ->method('initGettext'); - $app->expects($this->exactly(2)) ->method('get') ->withConsecutive(['config'], ['session']) ->willReturnOnConsecutiveCalls($config, $session); - $defaultLocale = 'fo_OO'; - $locale = 'te_ST.WTF-9'; - $locales = ['fo_OO' => 'Foo', 'fo_OO.BAR' => 'Foo (Bar)', 'te_ST.WTF-9' => 'WTF\'s Testing?']; - $config->expects($this->exactly(2)) - ->method('get') - ->withConsecutive( - ['locales'], - ['default_locale'] - ) - ->willReturnOnConsecutiveCalls( - $locales, - $defaultLocale - ); - $session->expects($this->once()) ->method('get') ->with('locale', $defaultLocale) @@ -65,9 +51,11 @@ class TranslationServiceProviderTest extends ServiceProviderTest ->with( Translator::class, [ - 'locale' => $locale, - 'locales' => $locales, - 'localeChangeCallback' => [$serviceProvider, 'setLocale'], + 'locale' => $locale, + 'locales' => $locales, + 'fallbackLocale' => 'en_US', + 'getTranslatorCallback' => [$serviceProvider, 'getTranslator'], + 'localeChangeCallback' => [$serviceProvider, 'setLocale'], ] ) ->willReturn($translator); @@ -81,4 +69,22 @@ class TranslationServiceProviderTest extends ServiceProviderTest $serviceProvider->register(); } + + /** + * @covers \Engelsystem\Helpers\Translation\TranslationServiceProvider::getTranslator() + */ + public function testGetTranslator(): void + { + $app = $this->getApp(['get']); + $serviceProvider = new TranslationServiceProvider($app); + + $this->setExpects($app, 'get', ['path.lang'], __DIR__ . '/Assets'); + + // Get translator + $translator = $serviceProvider->getTranslator('fo_OO'); + $this->assertEquals('Foo Bar!', $translator->gettext('foo.bar')); + + // Retry from cache + $serviceProvider->getTranslator('fo_OO'); + } } diff --git a/tests/Unit/Helpers/Translation/TranslatorTest.php b/tests/Unit/Helpers/Translation/TranslatorTest.php index 7e9c534c..c173209a 100644 --- a/tests/Unit/Helpers/Translation/TranslatorTest.php +++ b/tests/Unit/Helpers/Translation/TranslatorTest.php @@ -2,6 +2,8 @@ namespace Engelsystem\Test\Unit\Helpers\Translation; +use Engelsystem\Helpers\Translation\GettextTranslator; +use Engelsystem\Helpers\Translation\TranslationNotFound; use Engelsystem\Helpers\Translation\Translator; use Engelsystem\Test\Unit\ServiceProviderTest; use PHPUnit\Framework\MockObject\MockObject; @@ -19,18 +21,18 @@ class TranslatorTest extends ServiceProviderTest */ public function testInit() { - $locales = ['te_ST.ER-01' => 'Tests', 'fo_OO' => 'SomeFOO']; - $locale = 'te_ST.ER-01'; + $locales = ['te_ST' => 'Tests', 'fo_OO' => 'SomeFOO']; + $locale = 'te_ST'; - /** @var callable|MockObject $callable */ - $callable = $this->getMockBuilder(stdClass::class) + /** @var callable|MockObject $localeChange */ + $localeChange = $this->getMockBuilder(stdClass::class) ->setMethods(['__invoke']) ->getMock(); - $callable->expects($this->exactly(2)) + $localeChange->expects($this->exactly(2)) ->method('__invoke') - ->withConsecutive(['te_ST.ER-01'], ['fo_OO']); + ->withConsecutive(['te_ST'], ['fo_OO']); - $translator = new Translator($locale, $locales, $callable); + $translator = new Translator($locale, 'fo_OO', function () { }, $locales, $localeChange); $this->assertEquals($locales, $translator->getLocales()); $this->assertEquals($locale, $translator->getLocale()); @@ -43,24 +45,23 @@ class TranslatorTest extends ServiceProviderTest $this->assertEquals($newLocales, $translator->getLocales()); $this->assertTrue($translator->hasLocale('ip_SU-M')); - $this->assertFalse($translator->hasLocale('te_ST.ER-01')); + $this->assertFalse($translator->hasLocale('te_ST')); } /** - * @covers \Engelsystem\Helpers\Translation\Translator::replaceText * @covers \Engelsystem\Helpers\Translation\Translator::translate */ public function testTranslate() { /** @var Translator|MockObject $translator */ $translator = $this->getMockBuilder(Translator::class) - ->setConstructorArgs(['de_DE.UTF-8', ['de_DE.UTF-8' => 'Deutsch']]) - ->setMethods(['translateGettext']) + ->setConstructorArgs(['de_DE', 'en_US', function () { }, ['de_DE' => 'Deutsch']]) + ->setMethods(['translateText']) ->getMock(); $translator->expects($this->exactly(2)) - ->method('translateGettext') - ->withConsecutive(['Hello!'], ['My favourite number is %u!']) - ->willReturnOnConsecutiveCalls('Hallo!', 'Meine Lieblingszahl ist die %u!'); + ->method('translateText') + ->withConsecutive(['gettext', ['Hello!'], []], ['gettext', ['My favourite number is %u!'], [3]]) + ->willReturnOnConsecutiveCalls('Hallo!', 'Meine Lieblingszahl ist die 3!'); $return = $translator->translate('Hello!'); $this->assertEquals('Hallo!', $return); @@ -76,15 +77,58 @@ class TranslatorTest extends ServiceProviderTest { /** @var Translator|MockObject $translator */ $translator = $this->getMockBuilder(Translator::class) - ->setConstructorArgs(['de_DE.UTF-8', ['de_DE.UTF-8' => 'Deutsch']]) - ->setMethods(['translateGettextPlural']) + ->setConstructorArgs(['de_DE', 'en_US', function () { }, ['de_DE' => 'Deutsch']]) + ->setMethods(['translateText']) ->getMock(); $translator->expects($this->once()) - ->method('translateGettextPlural') - ->with('%s apple', '%s apples', 2) + ->method('translateText') + ->with('ngettext', ['%s apple', '%s apples', 2], [2]) ->willReturn('2 Äpfel'); $return = $translator->translatePlural('%s apple', '%s apples', 2, [2]); $this->assertEquals('2 Äpfel', $return); } + + /** + * @covers \Engelsystem\Helpers\Translation\Translator::translatePlural + * @covers \Engelsystem\Helpers\Translation\Translator::translateText + * @covers \Engelsystem\Helpers\Translation\Translator::replaceText + */ + public function testReplaceText() + { + /** @var GettextTranslator|MockObject $gtt */ + $gtt = $this->createMock(GettextTranslator::class); + /** @var callable|MockObject $getTranslator */ + $getTranslator = $this->getMockBuilder(stdClass::class) + ->setMethods(['__invoke']) + ->getMock(); + $getTranslator->expects($this->exactly(5)) + ->method('__invoke') + ->withConsecutive(['te_ST'], ['fo_OO'], ['te_ST'], ['fo_OO'], ['te_ST']) + ->willReturn($gtt); + + $i = 0; + $gtt->expects($this->exactly(4)) + ->method('gettext') + ->willReturnCallback(function () use (&$i) { + $i++; + if ($i != 4) { + throw new TranslationNotFound(); + } + + return 'Lorem %s???'; + }); + $this->setExpects($gtt, 'ngettext', ['foo.barf'], 'Lorem %s!'); + + $translator = new Translator('te_ST', 'fo_OO', $getTranslator, ['te_ST' => 'Test', 'fo_OO' => 'Foo']); + + // No translation + $this->assertEquals('foo.bar', $translator->translate('foo.bar')); + + // Fallback translation + $this->assertEquals('Lorem test2???', $translator->translate('foo.batz', ['test2'])); + + // Successful translation + $this->assertEquals('Lorem test3!', $translator->translatePlural('foo.barf', 'foo.bar2', 3, ['test3'])); + } } From 7414f9b23dbcc66e5f0efda3d0cbfd79372ec780 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 9 Jul 2019 21:43:18 +0200 Subject: [PATCH 05/17] Implemented Validation for controllers --- config/app.php | 1 + src/Controllers/BaseController.php | 4 + src/Http/Exceptions/ValidationException.php | 37 +++ src/Http/Validation/Validates.php | 154 +++++++++ src/Http/Validation/ValidatesRequest.php | 37 +++ .../Validation/ValidationServiceProvider.php | 28 ++ src/Http/Validation/Validator.php | 76 +++++ src/Middleware/ErrorHandler.php | 30 ++ tests/Unit/Controllers/BaseControllerTest.php | 2 + .../Exceptions/ValidationExceptionTest.php | 25 ++ .../Stub/ValidatesRequestImplementation.php | 27 ++ .../Http/Validation/ValidatesRequestTest.php | 46 +++ tests/Unit/Http/Validation/ValidatesTest.php | 308 ++++++++++++++++++ .../ValidationServiceProviderTest.php | 34 ++ tests/Unit/Http/Validation/ValidatorTest.php | 50 +++ tests/Unit/Middleware/ErrorHandlerTest.php | 70 +++- 16 files changed, 927 insertions(+), 2 deletions(-) create mode 100644 src/Http/Exceptions/ValidationException.php create mode 100644 src/Http/Validation/Validates.php create mode 100644 src/Http/Validation/ValidatesRequest.php create mode 100644 src/Http/Validation/ValidationServiceProvider.php create mode 100644 src/Http/Validation/Validator.php create mode 100644 tests/Unit/Http/Exceptions/ValidationExceptionTest.php create mode 100644 tests/Unit/Http/Validation/Stub/ValidatesRequestImplementation.php create mode 100644 tests/Unit/Http/Validation/ValidatesRequestTest.php create mode 100644 tests/Unit/Http/Validation/ValidatesTest.php create mode 100644 tests/Unit/Http/Validation/ValidationServiceProviderTest.php create mode 100644 tests/Unit/Http/Validation/ValidatorTest.php diff --git a/config/app.php b/config/app.php index 5fda67dd..c4503086 100644 --- a/config/app.php +++ b/config/app.php @@ -25,6 +25,7 @@ return [ \Engelsystem\Middleware\RouteDispatcherServiceProvider::class, \Engelsystem\Middleware\RequestHandlerServiceProvider::class, \Engelsystem\Middleware\SessionHandlerServiceProvider::class, + \Engelsystem\Http\Validation\ValidationServiceProvider::class, // Additional services \Engelsystem\Mail\MailerServiceProvider::class, diff --git a/src/Controllers/BaseController.php b/src/Controllers/BaseController.php index cbc00931..655ed759 100644 --- a/src/Controllers/BaseController.php +++ b/src/Controllers/BaseController.php @@ -2,8 +2,12 @@ namespace Engelsystem\Controllers; +use Engelsystem\Http\Validation\ValidatesRequest; + abstract class BaseController { + use ValidatesRequest; + /** @var string[]|string[][] A list of Permissions required to access the controller or certain pages */ protected $permissions = []; diff --git a/src/Http/Exceptions/ValidationException.php b/src/Http/Exceptions/ValidationException.php new file mode 100644 index 00000000..e48fb0c3 --- /dev/null +++ b/src/Http/Exceptions/ValidationException.php @@ -0,0 +1,37 @@ +validator = $validator; + parent::__construct($message, $code, $previous); + } + + /** + * @return Validator + */ + public function getValidator(): Validator + { + return $this->validator; + } +} diff --git a/src/Http/Validation/Validates.php b/src/Http/Validation/Validates.php new file mode 100644 index 00000000..2e3a1a73 --- /dev/null +++ b/src/Http/Validation/Validates.php @@ -0,0 +1,154 @@ +validateParameterCount(2, $parameters, __FUNCTION__); + $size = $this->getSize($value); + + return $size >= $parameters[0] && $size <= $parameters[1]; + } + + /** + * @param mixed $value + * @return bool + */ + public function bool($value): bool + { + return in_array($value, ['1', 1, true, '0', 0, false], true); + } + + /** + * @param mixed $value + * @param array $parameters ['1,2,3,56,7'] + * @return bool + */ + public function in($value, $parameters): bool + { + $this->validateParameterCount(1, $parameters, __FUNCTION__); + + return in_array($value, explode(',', $parameters[0])); + } + + /** + * @param mixed $value + * @return bool + */ + public function int($value): bool + { + return filter_var($value, FILTER_VALIDATE_INT) !== false; + } + + /** + * @param string $value + * @param array $parameters ['max'] + * @return bool + */ + public function max($value, $parameters): bool + { + $this->validateParameterCount(1, $parameters, __FUNCTION__); + $size = $this->getSize($value); + + return $size <= $parameters[0]; + } + + /** + * @param string $value + * @param array $parameters ['min'] + * @return bool + */ + public function min($value, $parameters) + { + $this->validateParameterCount(1, $parameters, __FUNCTION__); + $size = $this->getSize($value); + + return $size >= $parameters[0]; + } + + /** + * @param mixed $value + * @param array $parameters ['1,2,3,56,7'] + * @return bool + */ + public function notIn($value, $parameters): bool + { + $this->validateParameterCount(1, $parameters, __FUNCTION__); + + return !$this->in($value, $parameters); + } + + /** + * @param mixed $value + * @return bool + */ + public function numeric($value): bool + { + return is_numeric($value); + } + + /** + * @param mixed $value + * @return bool + */ + public function required($value): bool + { + if ( + is_null($value) + || (is_string($value) && trim($value) === '') + ) { + return false; + } + + return true; + } + + /** + * @param mixed $value + * @return int|float + */ + protected function getSize($value) + { + if (is_numeric($value)) { + return $value; + } + + return mb_strlen($value); + } + + /** + * @param int $count + * @param array $parameters + * @param string $rule + * + * @throws InvalidArgumentException + */ + protected function validateParameterCount(int $count, array $parameters, string $rule) + { + if (count($parameters) < $count) { + throw new InvalidArgumentException(sprintf( + 'The rule "%s" requires at least %d parameters', + $rule, + $count + )); + } + } +} diff --git a/src/Http/Validation/ValidatesRequest.php b/src/Http/Validation/ValidatesRequest.php new file mode 100644 index 00000000..33ff76af --- /dev/null +++ b/src/Http/Validation/ValidatesRequest.php @@ -0,0 +1,37 @@ +validator->validate( + (array)$request->getParsedBody(), + $rules + )) { + throw new ValidationException($this->validator); + } + + return $this->validator->getData(); + } + + /** + * @param Validator $validator + */ + public function setValidator(Validator $validator) + { + $this->validator = $validator; + } +} diff --git a/src/Http/Validation/ValidationServiceProvider.php b/src/Http/Validation/ValidationServiceProvider.php new file mode 100644 index 00000000..2f1c6359 --- /dev/null +++ b/src/Http/Validation/ValidationServiceProvider.php @@ -0,0 +1,28 @@ +app->make(Validates::class); + $this->app->instance(Validates::class, $validates); + + $validator = $this->app->make(Validator::class); + $this->app->instance(Validator::class, $validator); + $this->app->instance('validator', $validator); + + $this->app->afterResolving(function ($object, Application $app) { + if (!$object instanceof BaseController) { + return; + } + + $object->setValidator($app->get(Validator::class)); + }); + } +} diff --git a/src/Http/Validation/Validator.php b/src/Http/Validation/Validator.php new file mode 100644 index 00000000..a9235a5f --- /dev/null +++ b/src/Http/Validation/Validator.php @@ -0,0 +1,76 @@ +validate = $validate; + } + + /** + * @param array $data + * @param array $rules + * @return bool + */ + public function validate($data, $rules) + { + $this->errors = []; + $this->data = []; + + foreach ($rules as $key => $values) { + foreach (explode('|', $values) as $parameters) { + $parameters = explode(':', $parameters); + $rule = array_shift($parameters); + $rule = Str::camel($rule); + + if (!method_exists($this->validate, $rule)) { + throw new InvalidArgumentException('Unknown validation rule: ' . $rule); + } + + $value = isset($data[$key]) ? $data[$key] : null; + if (!$this->validate->{$rule}($value, $parameters, $data)) { + $this->errors[$key][] = implode('.', ['validation', $key, $rule]); + + continue; + } + + $this->data[$key] = $value; + } + } + + return empty($this->errors); + } + + /** + * @return array + */ + public function getData(): array + { + return $this->data; + } + + /** + * @return string[] + */ + public function getErrors(): array + { + return $this->errors; + } +} diff --git a/src/Middleware/ErrorHandler.php b/src/Middleware/ErrorHandler.php index 29b1fac1..c89edb1a 100644 --- a/src/Middleware/ErrorHandler.php +++ b/src/Middleware/ErrorHandler.php @@ -3,6 +3,8 @@ namespace Engelsystem\Middleware; use Engelsystem\Http\Exceptions\HttpException; +use Engelsystem\Http\Exceptions\ValidationException; +use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; @@ -43,6 +45,21 @@ class ErrorHandler implements MiddlewareInterface $response = $handler->handle($request); } catch (HttpException $e) { $response = $this->createResponse($e->getMessage(), $e->getStatusCode(), $e->getHeaders()); + } catch (ValidationException $e) { + $response = $this->createResponse('', 302, ['Location' => $this->getPreviousUrl($request)]); + + if ($request instanceof Request) { + $session = $request->getSession(); + $session->set( + 'errors', + array_merge_recursive( + $session->get('errors', []), + ['validation' => $e->getValidator()->getErrors()] + ) + ); + + $session->set('form-data', $request->request->all()); + } } $statusCode = $response->getStatusCode(); @@ -106,4 +123,17 @@ class ErrorHandler implements MiddlewareInterface { return response($content, $status, $headers); } + + /** + * @param ServerRequestInterface $request + * @return string + */ + protected function getPreviousUrl(ServerRequestInterface $request) + { + if ($header = $request->getHeader('referer')) { + return array_pop($header); + } + + return '/'; + } } diff --git a/tests/Unit/Controllers/BaseControllerTest.php b/tests/Unit/Controllers/BaseControllerTest.php index 738b538f..2adc9dc7 100644 --- a/tests/Unit/Controllers/BaseControllerTest.php +++ b/tests/Unit/Controllers/BaseControllerTest.php @@ -21,5 +21,7 @@ class BaseControllerTest extends TestCase 'dolor', ], ], $controller->getPermissions()); + + $this->assertTrue(method_exists($controller, 'setValidator')); } } diff --git a/tests/Unit/Http/Exceptions/ValidationExceptionTest.php b/tests/Unit/Http/Exceptions/ValidationExceptionTest.php new file mode 100644 index 00000000..c5a38b5a --- /dev/null +++ b/tests/Unit/Http/Exceptions/ValidationExceptionTest.php @@ -0,0 +1,25 @@ +createMock(Validator::class); + + $exception = new ValidationException($validator); + + $this->assertEquals($validator, $exception->getValidator()); + } +} diff --git a/tests/Unit/Http/Validation/Stub/ValidatesRequestImplementation.php b/tests/Unit/Http/Validation/Stub/ValidatesRequestImplementation.php new file mode 100644 index 00000000..772b1dc9 --- /dev/null +++ b/tests/Unit/Http/Validation/Stub/ValidatesRequestImplementation.php @@ -0,0 +1,27 @@ +validate($request, $rules); + } + + /** + * @return bool + */ + public function hasValidator() + { + return !is_null($this->validator); + } +} diff --git a/tests/Unit/Http/Validation/ValidatesRequestTest.php b/tests/Unit/Http/Validation/ValidatesRequestTest.php new file mode 100644 index 00000000..8011bd03 --- /dev/null +++ b/tests/Unit/Http/Validation/ValidatesRequestTest.php @@ -0,0 +1,46 @@ +createMock(Validator::class); + $validator->expects($this->exactly(2)) + ->method('validate') + ->withConsecutive( + [['foo' => 'bar'], ['foo' => 'required']], + [[], ['foo' => 'required']] + ) + ->willReturnOnConsecutiveCalls( + true, + false + ); + $validator->expects($this->once()) + ->method('getData') + ->willReturn(['foo' => 'bar']); + + $implementation = new ValidatesRequestImplementation(); + $implementation->setValidator($validator); + + $return = $implementation->validateData(new Request([], ['foo' => 'bar']), ['foo' => 'required']); + + $this->assertEquals(['foo' => 'bar'], $return); + + $this->expectException(ValidationException::class); + $implementation->validateData(new Request([], []), ['foo' => 'required']); + } +} diff --git a/tests/Unit/Http/Validation/ValidatesTest.php b/tests/Unit/Http/Validation/ValidatesTest.php new file mode 100644 index 00000000..5cf0447a --- /dev/null +++ b/tests/Unit/Http/Validation/ValidatesTest.php @@ -0,0 +1,308 @@ +assertTrue($val->accepted($value) === $result); + } + + /** + * @return array + */ + public function provideBetween() + { + return [ + ['42', [10, 100]], + [42.5, [42, 43]], + [42, [42, 1000]], + [1337, [0, 99], false], + [-17, [32, 45], false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::between + * @param mixed $value + * @param array $parameters + * @param bool $result + * @dataProvider provideBetween + */ + public function testBetween($value, array $parameters, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->between($value, $parameters) === $result); + } + + /** + * @return array + */ + public function provideBool() + { + return [ + ['1'], + [1], + [true], + ['0'], + [0], + [false], + ['true', false], + ['false', false], + ['yes', false], + ['no', false], + ['bool', false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::bool + * @param mixed $value + * @param bool $result + * @dataProvider provideBool + */ + public function testBool($value, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->bool($value) === $result); + } + + /** + * @return array + */ + public function provideIn() + { + return [ + ['lorem', ['lorem,ipsum,dolor']], + [99, ['66,77,88,99,111']], + [4, ['1,3,5,7'], false], + ['toggle', ['on,off'], false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::in + * @param mixed $value + * @param array $parameters + * @param bool $result + * @dataProvider provideIn + */ + public function testIn($value, array $parameters, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->in($value, $parameters) === $result); + } + + /** + * @return array + */ + public function provideInt() + { + return [ + ['1337'], + [42], + ['0'], + [false, false], + ['12asd1', false], + ['one', false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::int + * @param mixed $value + * @param bool $result + * @dataProvider provideInt + */ + public function testInt($value, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->int($value) === $result); + } + + /** + * @return array + */ + public function provideMax() + { + return [ + ['99', [100]], + [-42, [1024]], + [99, [99]], + [100, [10], false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::max + * @param mixed $value + * @param array $parameters + * @param bool $result + * @dataProvider provideMax + */ + public function testMax($value, array $parameters, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->max($value, $parameters) === $result); + } + + /** + * @return array + */ + public function provideMin() + { + return [ + [32, [0]], + [7, [7]], + ['99', [10]], + [3, [42], false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::min + * @param mixed $value + * @param array $parameters + * @param bool $result + * @dataProvider provideMin + */ + public function testMin($value, array $parameters, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->min($value, $parameters) === $result); + } + + /** + * @return array + */ + public function provideNotIn() + { + return [ + [77, ['50,60,70']], + ['test', ['coding,deployment']], + ['PHP', ['Java,PHP,bash'], false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::notIn + * @param mixed $value + * @param array $parameters + * @param bool $result + * @dataProvider provideNotIn + */ + public function testNotIn($value, array $parameters, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->notIn($value, $parameters) === $result); + } + + /** + * @return array + */ + public function provideNumeric() + { + return [ + [77], + ['42'], + ['1337e0'], + ['123f00', false], + [null, false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::numeric + * @param mixed $value + * @param bool $result + * @dataProvider provideNumeric + */ + public function testNumeric($value, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->numeric($value) === $result); + } + + /** + * @return array + */ + public function provideRequired() + { + return [ + ['Lorem ipsum'], + ['1234'], + [1234], + ['0'], + [0], + ['', false], + [' ', false], + [null, false], + ]; + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::required + * @param mixed $value + * @param bool $result + * @dataProvider provideRequired + */ + public function testRequired($value, bool $result = true) + { + $val = new Validates; + $this->assertTrue($val->required($value) === $result); + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::getSize + */ + public function testGetSize() + { + $val = new Validates; + $this->assertTrue($val->max(42, [999])); + $this->assertTrue($val->max('99', [100])); + $this->assertFalse($val->max('101', [100])); + $this->assertTrue($val->max('lorem', [5])); + $this->assertFalse($val->max('Lorem Ipsum', [5])); + } + + /** + * @covers \Engelsystem\Http\Validation\Validates::validateParameterCount + */ + public function testValidateParameterCount() + { + $val = new Validates; + $this->assertTrue($val->between(42, [1, 100])); + + $this->expectException(InvalidArgumentException::class); + $val->between(42, [1]); + } +} diff --git a/tests/Unit/Http/Validation/ValidationServiceProviderTest.php b/tests/Unit/Http/Validation/ValidationServiceProviderTest.php new file mode 100644 index 00000000..969f4351 --- /dev/null +++ b/tests/Unit/Http/Validation/ValidationServiceProviderTest.php @@ -0,0 +1,34 @@ +register(); + + $this->assertTrue($app->has(Validator::class)); + $this->assertTrue($app->has('validator')); + + /** @var ValidatesRequestImplementation $validatesRequest */ + $validatesRequest = $app->make(ValidatesRequestImplementation::class); + $this->assertTrue($validatesRequest->hasValidator()); + + // Test afterResolving early return + $app->make(stdClass::class); + } +} diff --git a/tests/Unit/Http/Validation/ValidatorTest.php b/tests/Unit/Http/Validation/ValidatorTest.php new file mode 100644 index 00000000..799265ec --- /dev/null +++ b/tests/Unit/Http/Validation/ValidatorTest.php @@ -0,0 +1,50 @@ +assertTrue($val->validate( + ['foo' => 'bar', 'lorem' => 'on'], + ['foo' => 'required|not_in:lorem,ipsum,dolor', 'lorem' => 'accepted'] + )); + $this->assertEquals(['foo' => 'bar', 'lorem' => 'on'], $val->getData()); + + $this->assertFalse($val->validate( + [], + ['lorem' => 'required|min:3'] + )); + $this->assertEquals( + ['lorem' => ['validation.lorem.required', 'validation.lorem.min']], + $val->getErrors() + ); + } + + /** + * @covers \Engelsystem\Http\Validation\Validator::validate + */ + public function testValidateNotImplemented() + { + $val = new Validator(new Validates); + $this->expectException(InvalidArgumentException::class); + + $val->validate( + ['lorem' => 'bar'], + ['foo' => 'never_implemented'] + ); + } +} diff --git a/tests/Unit/Middleware/ErrorHandlerTest.php b/tests/Unit/Middleware/ErrorHandlerTest.php index 6c37b651..ea9cb216 100644 --- a/tests/Unit/Middleware/ErrorHandlerTest.php +++ b/tests/Unit/Middleware/ErrorHandlerTest.php @@ -2,14 +2,23 @@ namespace Engelsystem\Test\Unit\Middleware; +use Engelsystem\Application; use Engelsystem\Http\Exceptions\HttpException; +use Engelsystem\Http\Exceptions\ValidationException; +use Engelsystem\Http\Psr7ServiceProvider; +use Engelsystem\Http\Request; use Engelsystem\Http\Response; +use Engelsystem\Http\ResponseServiceProvider; +use Engelsystem\Http\Validation\Validator; use Engelsystem\Middleware\ErrorHandler; use Engelsystem\Test\Unit\Middleware\Stub\ReturnResponseMiddlewareHandler; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; +use Psr\Http\Server\RequestHandlerInterface; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; use Twig_LoaderInterface as TwigLoader; class ErrorHandlerTest extends TestCase @@ -104,7 +113,7 @@ class ErrorHandlerTest extends TestCase /** * @covers \Engelsystem\Middleware\ErrorHandler::process */ - public function testProcessException() + public function testProcessHttpException() { /** @var ServerRequestInterface|MockObject $request */ $request = $this->createMock(ServerRequestInterface::class); @@ -144,6 +153,63 @@ class ErrorHandlerTest extends TestCase $this->assertEquals($psrResponse, $return); } + /** + * @covers \Engelsystem\Middleware\ErrorHandler::process + * @covers \Engelsystem\Middleware\ErrorHandler::getPreviousUrl + */ + public function testProcessValidationException() + { + /** @var TwigLoader|MockObject $twigLoader */ + $twigLoader = $this->createMock(TwigLoader::class); + $handler = $this->getMockForAbstractClass(RequestHandlerInterface::class); + $validator = $this->createMock(Validator::class); + + $handler->expects($this->exactly(2)) + ->method('handle') + ->willReturnCallback(function () use ($validator) { + throw new ValidationException($validator); + }); + + $validator->expects($this->exactly(2)) + ->method('getErrors') + ->willReturn(['foo' => ['validation.foo.numeric']]); + + $session = new Session(new MockArraySessionStorage()); + $session->set('errors', ['validation' => ['foo' => ['validation.foo.required']]]); + $request = Request::create('/foo/bar', 'POST', ['foo' => 'bar']); + $request->setSession($session); + + /** @var Application $app */ + $app = app(); + (new ResponseServiceProvider($app))->register(); + (new Psr7ServiceProvider($app))->register(); + + $errorHandler = new ErrorHandler($twigLoader); + + $return = $errorHandler->process($request, $handler); + + $this->assertEquals(302, $return->getStatusCode()); + $this->assertEquals('/', $return->getHeaderLine('location')); + $this->assertEquals([ + 'errors' => [ + 'validation' => [ + 'foo' => [ + 'validation.foo.required', + 'validation.foo.numeric', + ], + ], + ], + 'form-data' => [ + 'foo' => 'bar', + ], + ], $session->all()); + + $request = $request->withAddedHeader('referer', '/foo/batz'); + $return = $errorHandler->process($request, $handler); + + $this->assertEquals('/foo/batz', $return->getHeaderLine('location')); + } + /** * @covers \Engelsystem\Middleware\ErrorHandler::process */ @@ -153,7 +219,7 @@ class ErrorHandlerTest extends TestCase $request = $this->createMock(ServerRequestInterface::class); /** @var TwigLoader|MockObject $twigLoader */ $twigLoader = $this->createMock(TwigLoader::class); - $response = new Response('

Hi!

', 500); + $response = new Response('

Hi!

', 500); $returnResponseHandler = new ReturnResponseMiddlewareHandler($response); /** @var ErrorHandler|MockObject $errorHandler */ From 6d5ada252202bfb29eba884cf9567e969d798607 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 9 Jul 2019 22:02:07 +0200 Subject: [PATCH 06/17] Added validation to AuthController --- resources/lang/de_DE/default.mo | Bin 46271 -> 46206 bytes resources/lang/de_DE/default.po | 18 +++-- resources/lang/en_US/default.mo | Bin 745 -> 770 bytes resources/lang/en_US/default.po | 16 +++-- src/Controllers/AuthController.php | 62 ++++++++---------- tests/Unit/Controllers/AuthControllerTest.php | 59 ++++++++++------- 6 files changed, 87 insertions(+), 68 deletions(-) diff --git a/resources/lang/de_DE/default.mo b/resources/lang/de_DE/default.mo index 35ad80b7d015389273df697011f24456154496a6..fb93d59098f243fc9f3d8f04dc65da27eb7ce8d5 100644 GIT binary patch delta 12477 zcmY+~33yJ|zQ^%RBr+m12}wjqB9cfDGbvJHDm81-lpshV#!|ehW>UkesHv!#V{UQK zmKs~taysF(=%7l~F;rVU<$ixz%e}q(c~(E`zt-A&jeEao&w1~s(vPl}c6}36=5>dw zO=-uejL!!-POyjLtcX*o<6N)pII;K>MxlS27qGmHroMqLZFweYg`UA|%)v0+ zit6wvYN=1726_S2@prcTHmcvp8LYoL@~_WwU?ggZSD+rS8a1Gus1?|c8ps!@0bEAS z{0?e{{tX1yTh()bHA_ij}YikT8e$GWkuR{@PB^IHU>Mg8@JFKTLg!n2} z!v{#8PDCTeNx&|ciW6+S4ReS;$68pwvEwww0jT@eqF!g$30v?3@@hIhP0WKDqiz_A z&2cm8K|k3zj7rAfbVto}K57C>P)okSmhV6f>>X5x*HA0z*{ozBE+>GDPHP%!DVw0S zqCM(?eNam{47D|5P)j)#wd6}tTe1Q*kTt01Y(j1Ae$?}iV-5Tiwe-K0)U*EqOivv} zpc*EkI;e{pNE_6Hdf{3ei0arU(`;1$h7(sp4ImBmoCc^BYl)h`aMW|gquR|tZx@-@ z$Y{@(pl0%xjW?l|ZZGPA-&=2^9{3Q|zRXjm-Urn|2)4(n7=;C>6)8sbvl6uho6*Iq z=(DA8Ll-s1=Aq)u-6Fp^aOjI_QWxjNNQ~ zU-TdzjGj0wi}hE-5ftdb<7|WJs3n?>IvWdZyax5a&8Yi!+wudbfgG{%8C1K^P%H2? zs{LKm1ph!y;E~G~ct33#2B1!PEUM$$Hg1j@c?Z-$JKOTU)?wE1sE%i%I$B`M7o!gK zI_rMafL&*7gD+7x{D6AE4J?iKP@mcds6*u4+6=5BYVYGwTh+*xx5EhH-WY?UQO{kB zI(*Bq5^luG^zR%Yqow!;HS_DJnLn_4wqX^BLr^P_g{sd+Z|sTsrstpr`VwlOv#rZe z1KNUmO^>4n@HGbN_y0FCjKuNDHm_R^Od{@%{2)40P&e+!82l8qr*}~Id9^j4>T1aE zkkiD*qcD-U7&U=?sDWI^1oUX-0l?QkP>4evazk0qQ*t=B19ubd1K{ zsDX{gnz#@(v%@$7zr;dp+kxK|{3oj2GaXI4@#xYwe~~RXf$HE2>J#bB$3RA5B-TaE zpp%V<+IR*gQN9*6;IpU!-$b1a-%jSak(ff<614&&IH*Dc+yOPC-l!EAYU{_K20G2w&$X_!Zbxm&dl-tRQ5|1Kp5t=vn~d`q)q!t!Gowfh zBu+x@WmD7&Wuca`Evn;Aw!R1I`_K>7u0N{d!KeX`Ks{$7YUQS3b-n+u*^0dwO2r{m z$LCQ?_C4~$=G?`BSg!|f0(Et;%vWS8p(jUL9Ymu#XpSnMfm+fvsFm4(+JgP4EqK?)$5AWz5o(FA zqE7z}tced$TU@oDDNpam`fG2RQJ}+?je2lT)FJ6>8;(S+hzof!oQ0?XT|>3IiS^NW z!F-AvU=_m7s4Xl+y*(3A6Iy_JYqq}NGKb?J1scc+)FC;C4e$bLDTDa)B?G5o2Rw)x zkk5-|;FU3)I0<#=TB4rU!PfV*^@FS~>k?%&!X2oYA4Z+x4^R&}iCWTg7>_?<6D;4~ z9K!afFXk(#Jw1#A@D6HgyAxEyS1}4V;&?oPRnhfSuBqscY>zVqBk?K@!M`vT2XplG zpgC9rcc8ZH3mlF=U@~?eL=&v++%=LHwJIqlTGwHBs#{Py=ghZHanbJM_cO=#PC+D=-Arej>V*nPv-$ zQ6qjGE8;5D%=V#{`d!ov&!9TEgc`tA)DmAq-G2x5nmt5)ctY|`yEN4IBLlSsZSq zV~87~K2*Ii0t;>ZY!?~5hpSN?9z`wbDb$jFjGFORs3p6G(Rc$jb6?)6>X?cu?}jlr z8ui^+Y~yX1O8hZu!hfMA;))+-_BsXiz}A?A!%$1K6g8tYs6E|pJ&zjT&!{u>8wTNH z)XD^nHg881Y69t~`b^Y)T`*AZe_t}%li{cvU%~QNjOutLcEP=1Cvl& z)6Ciqb$?GA4@Mo@(KrOBqE`4e`sn??Uy|Y9#ZY_WJ=Tn{8fwNVs6B3jAvhFMa58G< zJ5V#)hg#C3sQ3Id*2f=ED^TSnGod65CT@Wt^zU?628W^^I2kpk=O&Zq9aj4_Rh5vsilo8e5<3ZKMY z_$Bh;aye~?rx5cQx7w)`j5fF7bA>^ala`=Va2 z2-NG9j_RmAs^dPW_C?k)Gg*HXyi9>^n2dTIm!WRlY2zcd{!7$=?qDK1v&>nkiJEyA z)P21$3WuQ1&MfrCb*PE_!+LlY>#x22n1ZHw3B9n=Y;($EP&d{?4Xhbzpj~ZTV4aG3 z-iYl&8M~%HY9!tyW?)`hCZ*EpLh;=)y5S>R?m5rQECCZK zZ-r`CfP--%zJPxsue9_0e6uB&Q7huT!2HMPI8&PMD=svD z$uvOiWiQlk!%H^aff~qJ)JojNH1u*C8(=c=5KPCV*d9N^R#h-8Yb{Ku}1JvH0MSr}En!wMft@D4~eA;WE;s&VaJd0Y%A&TkWSwtoj zw_y?Qf$ppP&9*}^+#Eno(+!0mZ3pMgW8;`?q;(4eo+lXp^3@hR%r~zL`P0VANdG9Nr zRw@Q{fBG`kUk%$*pbm1eEEb}caIAHbEuV&3v0_{9M%}l}x(;>!cGQ`A2Q%blcw)PKWu_4QD@~MHbSqJ#-}ijcmlS;HCP{S zpxVc-GOuk%)IePO$Y{yWqu$GF7=>YPn!{HQXAsZFURZH8e~97;WbquYHN5jU9joGZ z*cUz5n%8gurVua2DtH_t)ebx85s%4KqM*ZD=2QTkpNj9OgjO+Y^l?|NDO&8FkpGq=0R}A;f)94?czJ@EmH3?qFqX zxZZq#x}!QAkDACF)C||74&xEj^DkO|L=EgdW@`x_k>Qz6n+@haG~U8|;@TU{)~rIk zF1t|=Jc)YX6)cChtpBz3ew)ld;;hZEJned-Cl1EaSb!z}{$E5!XJ8!0VKHXnZft?~ zunA^tHh;^F#6s0$$)Va}R-z-;pnL@K&2rwrcud`DR-^~&zL8iRzuU_GFC^o$%`{wr ziNwcj{0r(m4f}`r+b#vuiMQfd{08-)?%U0WXaaU8o`?Q;4P)>g>hOi^Fat?P4ZPzH z)<2odcnY4zO_+eUQ8TW%(;S*^sQ0)(Hp6Au950{-TxpkS*An$b?2H~b0KIWA`eG4Q zzzH}8i(O>Ok}0#>914H*BMw6~j75C`>!2Q#jY0UVjdL-8xWGCAwUx6`9j-#H;AZr~ zBdC=*g=Nt7IT>|$5w#N6Y#g%3l*gbC<+ZFC)=X<#Ygg1j`=Z{0QK;v>W?hCF&<4~% zc43&_|NUgt!6#S_FQ8_A9W}E*Y<=0i=7E8zm8gRHP}M+v$+A!@`2q&vVALsp8B5_- zk}e(0&s4y*vXtJxzg-V({uB*jNpF!fs2ijzdKy_4yQ-e2{x?ui6A$2ja3KDRRNZzsjJz(L_>CnwKCRUKm+~g4S$}KW zEVXI{pIl=p8$`-^Qh=L?8*#7e8#2$5YLTwn2CTS~O{{AfM%aNU)`u#NvMCC zx*z{m|1Zi^OtN{! z@#G&X!xd=D+mh!8#i`4^Q%SnIkO}rWlXZ?@4Dns; zN}5b6AiYW1Yb1SbD%ei1sf<`xKKaV-qWJiRA>=NZLZ>BpT~~1)4kqbKK9~3}TxIK* zqo4a+d~mOgxw2NGq&$28$|wnQhDM_#NFLZ z6TZ11wJ(Pm3p41>)7DS`?fndC^WUtS-KWPC9XY>fb0S;gukNg*1ueLkg!} zmoNGK?!yV;*-OZ^ptK`?Ybr~AfuASV)t&UV&DSQsgfxx#5a|z6U1D7m+*K08vn$$` zri>#)e1)JYDTjP*(rxlxu?0>bm7{lk#B_Z~xvs|UiHYGIz9u?OdYh7?IGj|kq~82v z2z)|%OnJ5~*N^lZk~d{fE^m5h>Asp6?rKfxIZ`~SJk=lDR#(Z-Am5sn)kwXG|4qsy z|2zgzH_o;VB_2cKWAFTfvWs|_q-!QAfqS~xw$+H6k`gK7(#M;l>Yi9L)pe34`pE0r zM`}*$LD^tZZ+pAyo+JORjT3Q^%`c;_KIsJUMN%V@{`UY~pOe2!8cF({*x!~PCjO4N zS?Q9GC!aqmey32E+T@b-6Q^q;sS<^KN+Ql7?M~Rd(EsbTIq6UG-&41h$*YFIKu6uS4sNg_sO-Jx*teIqzj}{JhqjsY);(pNkbe( zdW!nllK~DJQ`w6AP1~p@d0lZ>N4r(s-cW;r4D#(r zw`lMwCXi}TJ_27NJ-NJX<{8Qc*zzv6>=%5WviC5Vbd&sa(gM;wlK$+_wU*@GnOr8I zJ{`28LRYH$NOE|ksx~rZ{0oJRquh6rgR}1tl_kAPbuZjQT1oyMX)XCAQj;gQ;#^Wc z#q^`=De7yJce^{JM7qL=rc#C{%Y0|}??6<>P;ijckbFH-XYvb4x_%~Ap-k5&_&OEreL*TF{+=|){ZwjX%4ACFXowh1>O)E)=?ccr@epYY`O2l;vr{7ig2=9> zs3Nz8xp$>TW(C?CD0D)}mm=M^WoIe#B7fe-r_rT`TvNzLl71om<_@eKoZXq6u2|Bq zvs}*TQG-(o3P&XmEF4`hV9%jlfDUXG2D_{5Yt{mD#*zv Xn^TbD{J-j$PG(f2ROMd1H~N177IYAWsf{pESA)%EUm|31&K_cQN(a__o-{ZRhU)$*RN zgDNa@xcZcLoH{r($Z>*w9H%5wrH*qp!EqYm7g!JPqpBiWI8GoYVGT^hYM6!%Fdv)X z3)l$XvYtn^SFUAQe;y~2L}d!PVRcNw033@=G0XZQRw94MdK}f^Nz_2jqc8r5jNkbk zgYY(nU_dK#e|=Ow9&0)t$LUF;3I(H44@|-uI14q?60C^3Q4PO=8sOXLkH@hBeu6GM zgIa-GsOM|7Ca^Px<8ahO^RXWNJ99`h!}X{KPoQS{3+ll?ur5|@<2dy(7S-_ptck<1 zHs+x2Uw|6mdQ>~xQ626+wNK=pgQE$gq2?o;q2R&HmOxCGU}64Zd!qgG%mY9Pl^ z12~16`8TK;-p9rm*xo$X3Uz-XY72W}DE33GxVt^;uaPXKKn<)$&3L=bzlB<=k5B_S zkJ{_+t=CWky^cB?f1@Vk)4{An5NZX&F&Gzf!t z{xs@!x{UPY1ax$qXzYRUm}B!Vp_}|MjK!!#4j>LiJ^upgb@uGH1!s^~)47Xks97iT zz@yle{3=vKU)X#lT4M}OZ`7VYiJHIy)RHf^93U4v;PlB zsG|U;sT;#l9YmrA(jL`NGH%4-sE+TVw(0?fp?{JYKm$}e(Wn(`g_=Mbs-4NGfzCib z4~b$DYG?s!CSIFgiCVf%s0Kf`UO+W?4R!xbRQ+942Nk;UZNM5>567ZbWHzdw#i%V< zg&y8KXM?Rciv!7DLLJK1-OZAXKpnDTRQVRv3_e1=9jCDYUP2yqD)n%j80?EWv<2wK z1^6;vMLoZ;C;J~s;`yHDz1@qN(U+*Dzkr(A4b;g0M9t8*msx=jRDC_0kFohys1CZI z4r4D{pNc-@M`A@B(~I@jjpHd$!&&x*>8K@|g*qE^ZGI`L!Bwc|Hrn!SsDbRZ`FBwF z9Yw9cN!0z{Vg_X+^Q6uk+8fZ6Lo@yOqos8;uCaR-3 zw)|<-p?=Z26*XYbJNAZuqaHYeYT!#OkKds_wU<$c=niT{{Q8)^4?}HLQ&f2(*2aFA zjTz{|)2Q};Lv7`44A=Yb)7SjrQ5Ur&9Z@sxiE8i>s~Z!@=c86)gDrm*{mAb{ecKPB z2K+f{z?ZGJPy?%!Y+ly}7*79A2NHT<2-d+2jK{gy4EG>EjLz4n_dKMZnNcii%TiF! zjYoa5J;?8nv(n~2v0lX(%FFjR6N$yzdjB6I5sfpkC2l~z!p`TYPi%EwW*7EDb(D>I zuNPtzZo&F^3N^s%*c5$J%9hw^i3Q{b;1oQBFJgQugVl{kNw62rb!>tD15G{=)xj{- zCv&#dixK3vp_cTR&0nmNtrO$xLES5Qk-eK3Di zU`PJZN|c~4eqj9siMp=ybo%^gC1f1wPcei(1UYP zc`pXxcGSS$wdGeZko@1+5CeypjuUV!`9ypiS7Rj%8EQ^_1gd=(R>l@s5j%Tqq6b!^ zAlW(!^}1wXUCcu@Sb`dW7lUyFhTuNbj6XuP^BD%=cc_`(!m3z(n6W;p9Zwtyy*8aN z1c%s)Owbp^k^5 z8p^U3p*rxOX0!xr;Cj?v?m?~40n}0+LUnx1)}KIqCq75rcNW$0c~n1F&{yyOZzQyI z_pmYgj4&O*aZ(@OT3F6F?N)B zttVl9z5g3XsNyiT!277TAa1nz^Liv|FE^sLU?1``;T%Q1_W_SGIBbfKV*xTI=O@g? zo?{$mD(=C782OlaEsM~zfP$qYT46XxI2Kbd9A}|AcoEgXUR!=2wWOir%*sTdwxBs` z3tHKHN7M=?p;mY}>hzC8eRs0QvHsfQ<+frAYHvzWGkq7;@JZAe`3%*-52zKniM(l! zZ<-m*!CpZrOaVGMQw)SIegmXV3GJhjRqGsM7^Kmpb$B!`!@1h2jo@rKQ1s3Z4-$_CbHsY>OY-vqK z?O7V?ttdu)kal5zyok{l%c#`wFx1{YgSvkOcE%5}EBa=eiS)q$^640)_dkz>4+V3u zBF;x&T!fX;ixqGk>U3{H)o;hjSc<`T2vz?XYC>P4p1X>E_zUWc-A1jbZw~eJ?=&Kz z(;ST&Kq6MgZn^6B-kiH9@P!FbHAdW-bpNqP08OGoN)LFQMI+TG^O??aWCqEpkV;X8; zQ&G>&n#%fXNjFiTz55t7qffC0p2aG74OM>|wUmFM-hzMv(_wSe;T?>6Ju}gT#i$R` zI;@R*ZT%_K+xESOggW%)UC+vb7<8N^Y-bMY@n=;+3)MQkL`KYaV%DM#g z{92oT9d$?#;AH#|wZg4um^0C#O!mJA3GGb^YJ{1n85g4Vcrk|HE{wxtsF~kH-G3jo zq`ouFdtDXVk#CAxf$}C8sSMSJA|kioJF01D>nZlYK!in zR?deXeKlMSYhokR1UjJJiXo^0Jb_vNT`7`s55XCn_^9V7@A`eYDRge z^3BNC*Ex-?G4X#)eLkwgt*HA>Tkm5l@=;HizkD9W?&Oy}!T$Flah8I%=$cJuI06%K zC2DCuM=j+!)XH47<<~KZ{4G?+?TXEPX{dZ2>aeZAFx-aWco?-(=ZaZ>9hzS$(4Led zuNy*9Take3u#GhlwS?VJ_ot%HKssur@-PBlLapeVs1MHx)K;ChUsDT!sI`*I@w8-X{U!xX*21-@f$T@!e-yjmNz}lqJjGwmSO@jt znr`$sFOi6%;0S8ZuAycSJkPwAb+Hlo_Sg%@U=!Sk`VM@EJmP$XFJanzGvf!?g1rCJ z<}aT_tUx{!wKciuuiyVUB(y|}P%~eFn!#q&jCR`mL0f+uD^Y$1eef$(!xwG-d#p(Q zDptbls1>+n%PTD~D_sS3&YUn3z8Gh1iyA-|)Ry!?4eSxr%D7Pj&c?bp1J&U&>l##t z8&Ct=jhgX()M5Mxb@i&b)Bi4T}Wc~HP2@3Q+UPe85-{u3KF%33C4X885-~i-5&UF5XM(0_x z0x_uP(d>=ff~pMn1tV9M~w88n2yI`*=vMRRP0A}^fflXJGQ>|V%~M~txz2n zqn3Cz#^Mgtr}`W=#Ng-5r?)M3AU_$0<0c%2odk3%p}(`l9(XkHMCdKZ_d3LDc{L>;x<`uirq_UXMTx zBn<;`Hde+(s8hZI^}6lE0NjVw@ja}9r%@C58nuOgVGXRc+@2j&J3TRm{+%(l;AyN$ z{$*^2dr=)<#L@T@vbs*673PqhMZH!RFbFT9I=YP-p#Ms9Uo}*HD28AR`ePUL=#ca! zp$D>2Gnk6Cu-Li+wGz8fr}$kA#V@ci-oUaBR+$0BpjIRiwPgb^9J6itd>l!B`6||5 zBf3t3W?o^nX`nW0FJnD(Efo|-C>R<%=q8s%+$g<|!@)@WV^VstFsOL(o%Tdp-N1dsy*cNx&@*hzB z-B1Hss>=LyX|L;GAB@I4%*Xb4!CGsr`CbghPLw~7Ix8ozBi^yLdfB|zdDxe7FSf(4 zQ1^$dGq0^DiG)V74YlM)k@wO$i}lcNy*YeQxPbgz9EAZJXcQ-)2k+n*oVn3_u+HFE z^0!g1->xnNvLqwP$0o>~NyCVyey0#QNlmZTZWn zrG6C?a0hB-FQB&OhONJcI?O(=svm}4gM=$qz&|d;rzq`=~AY4(njE z&E^Bt7u8`7Y9h0d89FOahp`mZ{t4?B=&z-_Od=U?AcJ!{ZZUrzU%*`Q4Y!)DS%P|7 zHlZ4P3)SGKSQRf=f3fusPy-3wW=uex{(e{yN28}ai8KEt)nsLBxb7=aY-s54|1xv6ievC0#d5^iT4F-|#fj&4K{ctn}U^-UE zJP!#iz4XTgSQ(ev8`h!@=MGc@M==Ob+WbWfB!AU<8?}WMOHD`NSoVR#N|bj(tw;~7 zfSy4l)ZsAH3b<|ldFvX~z_wU-Ti>=Gvz|f?^b6FXyN+ttf3LA7YUT}41Bpd`Lp)9^ z66&BIR>h&HrAbH4tjN~SMKxH0T8WjYmD_+pxD9of-@$UYozSHN_+JV}5bIR&kL#A+ ze_bC@(TI4J&>*f6_0$?y81}%V|M&2C^7=H_!g%f}*3Dc4$m`JdA#zF2C-iA)@rL@(ByomlVsFSJtxG3+OIeE3PTgOWcSg!u_xSUn8H$ zvlnqF5l8$$dD;BgDyKgMx?aLMb|A{@gVdO^xyo?m8_M2}c*@@*0tsDz5Q)SqEnbxy%ninNqUC& znI`o;JxNufWFXcjg1O@Wp)bQN@+XOzqoEzdqp z=o&_}B7GhQ5sl2fqxHXMJ8P(@fw~?ceZ?f5!`OiQZ#a~AlE@=AP_~57S0%`H`UCmr z$cK=hLb|SZPE=HfnxxKAs;d`iUEksJm`Ui1{T%rRxZc*U#VX!!qJl?lBXyLTjl2?)HLFpj; z)>M}L(vBssOP~HXY&wDT3St5IcZolV*5q{+dz;6EC5PKPO&K2$@)yZ8Aks;B+WH?2XqlIK!t{>w?gg^H_ zyaK52>HRe(%#%#%XG9E9jp|eOu5U;`L%KJ2MiQgRe?@d7JqD{%_doXDn&hVw{5m*0 zDf4n^+Itr_jrV-WE&2xQdV}anjG}BZF~&Zwy2nVr zNB9!Wh~WEM{6^cb2Y{``l)DQABdu$J?RwUXKwfun-a|_FTz>G!^_Vm22(cC zmJhLIzu*zd4qG>qe<$Zhyi4_?xQ|#%`gdXz>E=YIhmT?jF^(utKe~EQ--7fq?_+Tho-lIrC`}^% zMOxR3#B|ctY<@raUiRr;x`As8b$zi5JQU`vd&ZPm2xyiD0M z<~u`&RJtg5n@A+xju=e(1wz+PL_NxMoyHYJAHs)tma+)!N1P=VlfOhP_70AZh?`4E zYr33@E@B*!K1lU(@)Q^x0HD*(L=}OO_`EkdLS<#wBn5S-{JWq^D-+$_9}GEm{u?$ z!m77)Mnvj)In33yBWm0+G^}AC;ClvA~L0~mi!P|T47Ja^4_$)_5KeTa(?yz diff --git a/resources/lang/de_DE/default.po b/resources/lang/de_DE/default.po index cd696610..1f0372af 100644 --- a/resources/lang/de_DE/default.po +++ b/resources/lang/de_DE/default.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: Engelsystem\n" "POT-Creation-Date: 2019-04-28 15:23+0200\n" -"PO-Revision-Date: 2019-06-12 16:07+0200\n" +"PO-Revision-Date: 2019-06-13 11:54+0200\n" "Last-Translator: msquare \n" "Language-Team: \n" "Language: de_DE\n" @@ -1529,9 +1529,8 @@ msgstr "Nachname" msgid "Entry required!" msgstr "Pflichtfeld!" -#: includes/pages/guest_login.php:414 -msgid "auth.no-password" -msgstr "Gib bitte ein Passwort ein." +#~ msgid "auth.no-password" +#~ msgstr "Gib bitte ein Passwort ein." #: includes/pages/guest_login.php:418 msgid "auth.not-found" @@ -1539,9 +1538,8 @@ msgstr "" "Es wurde kein Engel gefunden. Probiere es bitte noch einmal. Wenn das Problem " "weiterhin besteht, melde dich im Himmel." -#: includes/pages/guest_login.php:451 includes/view/User_view.php:130 -msgid "auth.no-nickname" -msgstr "Gib bitte einen Nick an." +#~ msgid "auth.no-nickname" +#~ msgstr "Gib bitte einen Nick an." #: includes/pages/guest_login.php:481 #: includes/view/User_view.php:122 @@ -2765,3 +2763,9 @@ msgid "" msgstr "" "Diese Seite existiert nicht oder Du hast keinen Zugriff. Melde Dich an um " "Zugriff zu erhalten!" + +msgid "validation.password.required" +msgstr "Bitte gib ein Passwort an." + +msgid "validation.login.required" +msgstr "Bitte gib einen Loginnamen an." diff --git a/resources/lang/en_US/default.mo b/resources/lang/en_US/default.mo index e95ae7038db0dddb2327828cd145df1902bccfaf..7ef9c3b2c086aec2c1c210507ed820614e9f409d 100644 GIT binary patch delta 237 zcmaFK+Qc?NrJj$0fuRtHC4smBh-H9y77!m~WMJ41q=SICl8J#q8c3f3(jav&fHY8= zffjfJ>38R delta 213 zcmZo-d&xRMrCxx6fuRtHL39%k^8xW>AU?>*z_18NO8{{S6NIh>(m_D}G$0L>W&lzQ z%s>nREFdDWv?N0>FJCt=GdVjiF*g-1P>@($T%KQ)0uw0FP0KIMOJN9@xFb=(Lf6nx y*T`JKz`)ALLfgP_vnFEzqhUZ!YGQG!LTX+~YLP;s0?bG~1~dtn(R!0Bm_h*KoHH;0 diff --git a/resources/lang/en_US/default.po b/resources/lang/en_US/default.po index 22566e52..54847e61 100644 --- a/resources/lang/en_US/default.po +++ b/resources/lang/en_US/default.po @@ -2,7 +2,7 @@ msgid "" msgstr "" "Project-Id-Version: Engelsystem 2.0\n" "POT-Creation-Date: 2017-12-29 19:01+0100\n" -"PO-Revision-Date: 2018-11-27 00:28+0100\n" +"PO-Revision-Date: 2019-06-04 23:41+0200\n" "Language-Team: \n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -16,11 +16,17 @@ msgstr "" "Language: en_US\n" "X-Poedit-SearchPath-0: .\n" -msgid "auth.no-nickname" -msgstr "Please enter a nickname." +#~ msgid "auth.no-nickname" +#~ msgstr "Please enter a nickname." -msgid "auth.no-password" -msgstr "Please enter a password." +#~ msgid "auth.no-password" +#~ msgstr "Please enter a password." msgid "auth.not-found" msgstr "No user was found. Please try again. If you are still having problems, ask Heaven." + +msgid "validation.password.required" +msgstr "The password is required." + +msgid "validation.login.required" +msgstr "The login name is required." diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php index e5fc40e3..a8cc1ace 100644 --- a/src/Controllers/AuthController.php +++ b/src/Controllers/AuthController.php @@ -8,6 +8,8 @@ use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Http\UrlGeneratorInterface; use Engelsystem\Models\User\User; +use Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\Session\SessionInterface; class AuthController extends BaseController @@ -53,7 +55,22 @@ class AuthController extends BaseController */ public function login() { - return $this->response->withView('pages/login'); + return $this->showLogin(); + } + + /** + * @param bool $showRecovery + * @return Response + */ + protected function showLogin($showRecovery = false) + { + $errors = Collection::make(Arr::flatten($this->session->get('errors', []))); + $this->session->remove('errors'); + + return $this->response->withView( + 'pages/login', + ['errors' => $errors, 'show_password_recovery' => $showRecovery] + ); } /** @@ -64,15 +81,18 @@ class AuthController extends BaseController */ public function postLogin(Request $request): Response { - $return = $this->authenticateUser($request->get('login', ''), $request->get('password', '')); - if (!$return instanceof User) { - return $this->response->withView( - 'pages/login', - ['errors' => [$return], 'show_password_recovery' => true] - ); - } + $data = $this->validate($request, [ + 'login' => 'required', + 'password' => 'required', + ]); - $user = $return; + $user = $this->auth->authenticate($data['login'], $data['password']); + + if (!$user instanceof User) { + $this->session->set('errors', $this->session->get('errors', []) + ['auth.not-found']); + + return $this->showLogin(true); + } $this->session->invalidate(); $this->session->set('user_id', $user->id); @@ -93,28 +113,4 @@ class AuthController extends BaseController return $this->response->redirectTo($this->url->to('/')); } - - /** - * Verify the user and password - * - * @param $login - * @param $password - * @return User|string - */ - protected function authenticateUser(string $login, string $password) - { - if (!$login) { - return 'auth.no-nickname'; - } - - if (!$password) { - return 'auth.no-password'; - } - - if (!$user = $this->auth->authenticate($login, $password)) { - return 'auth.not-found'; - } - - return $user; - } } diff --git a/tests/Unit/Controllers/AuthControllerTest.php b/tests/Unit/Controllers/AuthControllerTest.php index 0fad3b6d..d3dbfa4b 100644 --- a/tests/Unit/Controllers/AuthControllerTest.php +++ b/tests/Unit/Controllers/AuthControllerTest.php @@ -4,15 +4,21 @@ namespace Engelsystem\Test\Unit\Controllers; use Engelsystem\Controllers\AuthController; use Engelsystem\Helpers\Authenticator; +use Engelsystem\Http\Exceptions\ValidationException; use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Http\UrlGeneratorInterface; +use Engelsystem\Http\Validation\Validates; +use Engelsystem\Http\Validation\Validator; use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\User; use Engelsystem\Test\Unit\HasDatabase; +use Illuminate\Support\Collection; use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionInterface; +use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; class AuthControllerTest extends TestCase { @@ -21,6 +27,7 @@ class AuthControllerTest extends TestCase /** * @covers \Engelsystem\Controllers\AuthController::__construct * @covers \Engelsystem\Controllers\AuthController::login + * @covers \Engelsystem\Controllers\AuthController::showLogin */ public function testLogin() { @@ -31,6 +38,10 @@ class AuthControllerTest extends TestCase /** @var Authenticator|MockObject $auth */ list(, $session, $url, $auth) = $this->getMocks(); + $session->expects($this->once()) + ->method('get') + ->with('errors', []) + ->willReturn(['foo' => 'bar']); $response->expects($this->once()) ->method('withView') ->with('pages/login') @@ -42,7 +53,6 @@ class AuthControllerTest extends TestCase /** * @covers \Engelsystem\Controllers\AuthController::postLogin - * @covers \Engelsystem\Controllers\AuthController::authenticateUser */ public function testPostLogin() { @@ -51,10 +61,12 @@ class AuthControllerTest extends TestCase $request = new Request(); /** @var Response|MockObject $response */ $response = $this->createMock(Response::class); - /** @var SessionInterface|MockObject $session */ /** @var UrlGeneratorInterface|MockObject $url */ /** @var Authenticator|MockObject $auth */ - list(, $session, $url, $auth) = $this->getMocks(); + list(, , $url, $auth) = $this->getMocks(); + $session = new Session(new MockArraySessionStorage()); + /** @var Validator|MockObject $validator */ + $validator = new Validator(new Validates()); $user = new User([ 'name' => 'foo', @@ -63,7 +75,7 @@ class AuthControllerTest extends TestCase 'api_key' => '', 'last_login_at' => null, ]); - $user->forceFill(['id' => 42,]); + $user->forceFill(['id' => 42]); $user->save(); $settings = new Settings(['language' => 'de_DE', 'theme' => '']); @@ -76,41 +88,42 @@ class AuthControllerTest extends TestCase ->with('foo', 'bar') ->willReturnOnConsecutiveCalls(null, $user); - $response->expects($this->exactly(3)) + $response->expects($this->once()) ->method('withView') - ->withConsecutive( - ['pages/login', ['errors' => ['auth.no-nickname'], 'show_password_recovery' => true]], - ['pages/login', ['errors' => ['auth.no-password'], 'show_password_recovery' => true]], - ['pages/login', ['errors' => ['auth.not-found'], 'show_password_recovery' => true]]) + ->with('pages/login', ['errors' => Collection::make(['auth.not-found']), 'show_password_recovery' => true]) ->willReturn($response); $response->expects($this->once()) ->method('redirectTo') ->with('news') ->willReturn($response); - $session->expects($this->once()) - ->method('invalidate'); - - $session->expects($this->exactly(2)) - ->method('set') - ->withConsecutive( - ['user_id', 42], - ['locale', 'de_DE'] - ); - + // No credentials $controller = new AuthController($response, $session, $url, $auth); - $controller->postLogin($request); + $controller->setValidator($validator); + try { + $controller->postLogin($request); + $this->fail('Login without credentials possible'); + } catch (ValidationException $e) { + } - $request = new Request(['login' => 'foo']); - $controller->postLogin($request); + // Missing password + $request = new Request([], ['login' => 'foo']); + try { + $controller->postLogin($request); + $this->fail('Login without password possible'); + } catch (ValidationException $e) { + } - $request = new Request(['login' => 'foo', 'password' => 'bar']); // No user found + $request = new Request([], ['login' => 'foo', 'password' => 'bar']); $controller->postLogin($request); + $this->assertEquals([], $session->all()); + // Authenticated user $controller->postLogin($request); $this->assertNotNull($user->last_login_at); + $this->assertEquals(['user_id' => 42, 'locale' => 'de_DE'], $session->all()); } /** From 6743106d9a8c760580690aab704f908766731801 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Wed, 10 Jul 2019 13:34:15 +0200 Subject: [PATCH 07/17] Replaced validation with `respect/validation` --- composer.json | 1 + src/Http/Validation/Rules/In.php | 21 ++ src/Http/Validation/Rules/NotIn.php | 15 + src/Http/Validation/Validates.php | 154 --------- .../Validation/ValidationServiceProvider.php | 3 - src/Http/Validation/Validator.php | 61 +++- tests/Unit/Controllers/AuthControllerTest.php | 3 +- tests/Unit/Http/Validation/Rules/InTest.php | 19 ++ .../Unit/Http/Validation/Rules/NotInTest.php | 20 ++ tests/Unit/Http/Validation/ValidatesTest.php | 308 ------------------ tests/Unit/Http/Validation/ValidatorTest.php | 42 ++- 11 files changed, 155 insertions(+), 492 deletions(-) create mode 100644 src/Http/Validation/Rules/In.php create mode 100644 src/Http/Validation/Rules/NotIn.php delete mode 100644 src/Http/Validation/Validates.php create mode 100644 tests/Unit/Http/Validation/Rules/InTest.php create mode 100644 tests/Unit/Http/Validation/Rules/NotInTest.php delete mode 100644 tests/Unit/Http/Validation/ValidatesTest.php diff --git a/composer.json b/composer.json index b2b70789..a1f2101b 100644 --- a/composer.json +++ b/composer.json @@ -32,6 +32,7 @@ "psr/container": "^1.0", "psr/http-server-middleware": "^1.0", "psr/log": "^1.1", + "respect/validation": "^1.1", "swiftmailer/swiftmailer": "^6.2", "symfony/http-foundation": "^4.3", "symfony/psr-http-message-bridge": "^1.2", diff --git a/src/Http/Validation/Rules/In.php b/src/Http/Validation/Rules/In.php new file mode 100644 index 00000000..d585cc3d --- /dev/null +++ b/src/Http/Validation/Rules/In.php @@ -0,0 +1,21 @@ +validateParameterCount(2, $parameters, __FUNCTION__); - $size = $this->getSize($value); - - return $size >= $parameters[0] && $size <= $parameters[1]; - } - - /** - * @param mixed $value - * @return bool - */ - public function bool($value): bool - { - return in_array($value, ['1', 1, true, '0', 0, false], true); - } - - /** - * @param mixed $value - * @param array $parameters ['1,2,3,56,7'] - * @return bool - */ - public function in($value, $parameters): bool - { - $this->validateParameterCount(1, $parameters, __FUNCTION__); - - return in_array($value, explode(',', $parameters[0])); - } - - /** - * @param mixed $value - * @return bool - */ - public function int($value): bool - { - return filter_var($value, FILTER_VALIDATE_INT) !== false; - } - - /** - * @param string $value - * @param array $parameters ['max'] - * @return bool - */ - public function max($value, $parameters): bool - { - $this->validateParameterCount(1, $parameters, __FUNCTION__); - $size = $this->getSize($value); - - return $size <= $parameters[0]; - } - - /** - * @param string $value - * @param array $parameters ['min'] - * @return bool - */ - public function min($value, $parameters) - { - $this->validateParameterCount(1, $parameters, __FUNCTION__); - $size = $this->getSize($value); - - return $size >= $parameters[0]; - } - - /** - * @param mixed $value - * @param array $parameters ['1,2,3,56,7'] - * @return bool - */ - public function notIn($value, $parameters): bool - { - $this->validateParameterCount(1, $parameters, __FUNCTION__); - - return !$this->in($value, $parameters); - } - - /** - * @param mixed $value - * @return bool - */ - public function numeric($value): bool - { - return is_numeric($value); - } - - /** - * @param mixed $value - * @return bool - */ - public function required($value): bool - { - if ( - is_null($value) - || (is_string($value) && trim($value) === '') - ) { - return false; - } - - return true; - } - - /** - * @param mixed $value - * @return int|float - */ - protected function getSize($value) - { - if (is_numeric($value)) { - return $value; - } - - return mb_strlen($value); - } - - /** - * @param int $count - * @param array $parameters - * @param string $rule - * - * @throws InvalidArgumentException - */ - protected function validateParameterCount(int $count, array $parameters, string $rule) - { - if (count($parameters) < $count) { - throw new InvalidArgumentException(sprintf( - 'The rule "%s" requires at least %d parameters', - $rule, - $count - )); - } - } -} diff --git a/src/Http/Validation/ValidationServiceProvider.php b/src/Http/Validation/ValidationServiceProvider.php index 2f1c6359..14530ae6 100644 --- a/src/Http/Validation/ValidationServiceProvider.php +++ b/src/Http/Validation/ValidationServiceProvider.php @@ -10,9 +10,6 @@ class ValidationServiceProvider extends ServiceProvider { public function register() { - $validates = $this->app->make(Validates::class); - $this->app->instance(Validates::class, $validates); - $validator = $this->app->make(Validator::class); $this->app->instance(Validator::class, $validator); $this->app->instance('validator', $validator); diff --git a/src/Http/Validation/Validator.php b/src/Http/Validation/Validator.php index a9235a5f..0bd846bd 100644 --- a/src/Http/Validation/Validator.php +++ b/src/Http/Validation/Validator.php @@ -4,25 +4,23 @@ namespace Engelsystem\Http\Validation; use Illuminate\Support\Str; use InvalidArgumentException; +use Respect\Validation\Exceptions\ComponentException; +use Respect\Validation\Validator as RespectValidator; class Validator { - /** @var Validates */ - protected $validate; - /** @var string[] */ protected $errors = []; /** @var array */ protected $data = []; - /** - * @param Validates $validate - */ - public function __construct(Validates $validate) - { - $this->validate = $validate; - } + /** @var array */ + protected $mapping = [ + 'accepted' => 'TrueVal', + 'int' => 'IntVal', + 'required' => 'NotEmpty', + ]; /** * @param array $data @@ -35,29 +33,56 @@ class Validator $this->data = []; foreach ($rules as $key => $values) { + $v = new RespectValidator(); + $v->with('\\Engelsystem\\Http\\Validation\\Rules', true); + + $value = isset($data[$key]) ? $data[$key] : null; + foreach (explode('|', $values) as $parameters) { $parameters = explode(':', $parameters); $rule = array_shift($parameters); $rule = Str::camel($rule); + $rule = $this->map($rule); - if (!method_exists($this->validate, $rule)) { - throw new InvalidArgumentException('Unknown validation rule: ' . $rule); + try { + call_user_func_array([$v, $rule], $parameters); + } catch (ComponentException $e) { + throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } - $value = isset($data[$key]) ? $data[$key] : null; - if (!$this->validate->{$rule}($value, $parameters, $data)) { - $this->errors[$key][] = implode('.', ['validation', $key, $rule]); - - continue; + if ($v->validate($value)) { + $this->data[$key] = $value; + } else { + $this->errors[$key][] = implode('.', ['validation', $key, $this->mapBack($rule)]); } - $this->data[$key] = $value; + $v->removeRules(); } } return empty($this->errors); } + /** + * @param string $rule + * @return string + */ + protected function map($rule) + { + return $this->mapping[$rule] ?? $rule; + } + + /** + * @param string $rule + * @return string + */ + protected function mapBack($rule) + { + $mapping = array_flip($this->mapping); + + return $mapping[$rule] ?? $rule; + } + /** * @return array */ diff --git a/tests/Unit/Controllers/AuthControllerTest.php b/tests/Unit/Controllers/AuthControllerTest.php index d3dbfa4b..c3d9659c 100644 --- a/tests/Unit/Controllers/AuthControllerTest.php +++ b/tests/Unit/Controllers/AuthControllerTest.php @@ -8,7 +8,6 @@ use Engelsystem\Http\Exceptions\ValidationException; use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Http\UrlGeneratorInterface; -use Engelsystem\Http\Validation\Validates; use Engelsystem\Http\Validation\Validator; use Engelsystem\Models\User\Settings; use Engelsystem\Models\User\User; @@ -66,7 +65,7 @@ class AuthControllerTest extends TestCase list(, , $url, $auth) = $this->getMocks(); $session = new Session(new MockArraySessionStorage()); /** @var Validator|MockObject $validator */ - $validator = new Validator(new Validates()); + $validator = new Validator(); $user = new User([ 'name' => 'foo', diff --git a/tests/Unit/Http/Validation/Rules/InTest.php b/tests/Unit/Http/Validation/Rules/InTest.php new file mode 100644 index 00000000..e5688d90 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/InTest.php @@ -0,0 +1,19 @@ +assertEquals(['foo', 'bar'], $rule->haystack); + } +} diff --git a/tests/Unit/Http/Validation/Rules/NotInTest.php b/tests/Unit/Http/Validation/Rules/NotInTest.php new file mode 100644 index 00000000..9be12336 --- /dev/null +++ b/tests/Unit/Http/Validation/Rules/NotInTest.php @@ -0,0 +1,20 @@ +assertTrue($rule->validate('lorem')); + $this->assertFalse($rule->validate('foo')); + } +} diff --git a/tests/Unit/Http/Validation/ValidatesTest.php b/tests/Unit/Http/Validation/ValidatesTest.php deleted file mode 100644 index 5cf0447a..00000000 --- a/tests/Unit/Http/Validation/ValidatesTest.php +++ /dev/null @@ -1,308 +0,0 @@ -assertTrue($val->accepted($value) === $result); - } - - /** - * @return array - */ - public function provideBetween() - { - return [ - ['42', [10, 100]], - [42.5, [42, 43]], - [42, [42, 1000]], - [1337, [0, 99], false], - [-17, [32, 45], false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::between - * @param mixed $value - * @param array $parameters - * @param bool $result - * @dataProvider provideBetween - */ - public function testBetween($value, array $parameters, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->between($value, $parameters) === $result); - } - - /** - * @return array - */ - public function provideBool() - { - return [ - ['1'], - [1], - [true], - ['0'], - [0], - [false], - ['true', false], - ['false', false], - ['yes', false], - ['no', false], - ['bool', false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::bool - * @param mixed $value - * @param bool $result - * @dataProvider provideBool - */ - public function testBool($value, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->bool($value) === $result); - } - - /** - * @return array - */ - public function provideIn() - { - return [ - ['lorem', ['lorem,ipsum,dolor']], - [99, ['66,77,88,99,111']], - [4, ['1,3,5,7'], false], - ['toggle', ['on,off'], false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::in - * @param mixed $value - * @param array $parameters - * @param bool $result - * @dataProvider provideIn - */ - public function testIn($value, array $parameters, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->in($value, $parameters) === $result); - } - - /** - * @return array - */ - public function provideInt() - { - return [ - ['1337'], - [42], - ['0'], - [false, false], - ['12asd1', false], - ['one', false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::int - * @param mixed $value - * @param bool $result - * @dataProvider provideInt - */ - public function testInt($value, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->int($value) === $result); - } - - /** - * @return array - */ - public function provideMax() - { - return [ - ['99', [100]], - [-42, [1024]], - [99, [99]], - [100, [10], false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::max - * @param mixed $value - * @param array $parameters - * @param bool $result - * @dataProvider provideMax - */ - public function testMax($value, array $parameters, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->max($value, $parameters) === $result); - } - - /** - * @return array - */ - public function provideMin() - { - return [ - [32, [0]], - [7, [7]], - ['99', [10]], - [3, [42], false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::min - * @param mixed $value - * @param array $parameters - * @param bool $result - * @dataProvider provideMin - */ - public function testMin($value, array $parameters, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->min($value, $parameters) === $result); - } - - /** - * @return array - */ - public function provideNotIn() - { - return [ - [77, ['50,60,70']], - ['test', ['coding,deployment']], - ['PHP', ['Java,PHP,bash'], false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::notIn - * @param mixed $value - * @param array $parameters - * @param bool $result - * @dataProvider provideNotIn - */ - public function testNotIn($value, array $parameters, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->notIn($value, $parameters) === $result); - } - - /** - * @return array - */ - public function provideNumeric() - { - return [ - [77], - ['42'], - ['1337e0'], - ['123f00', false], - [null, false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::numeric - * @param mixed $value - * @param bool $result - * @dataProvider provideNumeric - */ - public function testNumeric($value, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->numeric($value) === $result); - } - - /** - * @return array - */ - public function provideRequired() - { - return [ - ['Lorem ipsum'], - ['1234'], - [1234], - ['0'], - [0], - ['', false], - [' ', false], - [null, false], - ]; - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::required - * @param mixed $value - * @param bool $result - * @dataProvider provideRequired - */ - public function testRequired($value, bool $result = true) - { - $val = new Validates; - $this->assertTrue($val->required($value) === $result); - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::getSize - */ - public function testGetSize() - { - $val = new Validates; - $this->assertTrue($val->max(42, [999])); - $this->assertTrue($val->max('99', [100])); - $this->assertFalse($val->max('101', [100])); - $this->assertTrue($val->max('lorem', [5])); - $this->assertFalse($val->max('Lorem Ipsum', [5])); - } - - /** - * @covers \Engelsystem\Http\Validation\Validates::validateParameterCount - */ - public function testValidateParameterCount() - { - $val = new Validates; - $this->assertTrue($val->between(42, [1, 100])); - - $this->expectException(InvalidArgumentException::class); - $val->between(42, [1]); - } -} diff --git a/tests/Unit/Http/Validation/ValidatorTest.php b/tests/Unit/Http/Validation/ValidatorTest.php index 799265ec..0790b7a8 100644 --- a/tests/Unit/Http/Validation/ValidatorTest.php +++ b/tests/Unit/Http/Validation/ValidatorTest.php @@ -2,7 +2,6 @@ namespace Engelsystem\Test\Unit\Http\Validation; -use Engelsystem\Http\Validation\Validates; use Engelsystem\Http\Validation\Validator; use InvalidArgumentException; use PHPUnit\Framework\TestCase; @@ -10,19 +9,18 @@ use PHPUnit\Framework\TestCase; class ValidatorTest extends TestCase { /** - * @covers \Engelsystem\Http\Validation\Validator::__construct * @covers \Engelsystem\Http\Validation\Validator::validate * @covers \Engelsystem\Http\Validation\Validator::getData * @covers \Engelsystem\Http\Validation\Validator::getErrors */ public function testValidate() { - $val = new Validator(new Validates); + $val = new Validator(); $this->assertTrue($val->validate( - ['foo' => 'bar', 'lorem' => 'on'], - ['foo' => 'required|not_in:lorem,ipsum,dolor', 'lorem' => 'accepted'] + ['foo' => 'bar', 'lorem' => 'on', 'dolor' => 'bla'], + ['lorem' => 'accepted'] )); - $this->assertEquals(['foo' => 'bar', 'lorem' => 'on'], $val->getData()); + $this->assertEquals(['lorem' => 'on'], $val->getData()); $this->assertFalse($val->validate( [], @@ -39,7 +37,7 @@ class ValidatorTest extends TestCase */ public function testValidateNotImplemented() { - $val = new Validator(new Validates); + $val = new Validator(); $this->expectException(InvalidArgumentException::class); $val->validate( @@ -47,4 +45,34 @@ class ValidatorTest extends TestCase ['foo' => 'never_implemented'] ); } + + /** + * @covers \Engelsystem\Http\Validation\Validator::map + * @covers \Engelsystem\Http\Validation\Validator::mapBack + */ + public function testValidateMapping() + { + $val = new Validator(); + $this->assertTrue($val->validate( + ['foo' => 'bar'], + ['foo' => 'required'] + )); + $this->assertTrue($val->validate( + ['foo' => '0'], + ['foo' => 'int'] + )); + $this->assertTrue($val->validate( + ['foo' => 'on'], + ['foo' => 'accepted'] + )); + + $this->assertFalse($val->validate( + [], + ['lorem' => 'required'] + )); + $this->assertEquals( + ['lorem' => ['validation.lorem.required']], + $val->getErrors() + ); + } } From b25924e868cdf80944e56a76fa6eed4509d9af7b Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Tue, 16 Jul 2019 01:39:54 +0200 Subject: [PATCH 08/17] Allow nested rules (not and optional) --- src/Http/Validation/Validator.php | 27 ++++++++- tests/Unit/Http/Validation/ValidatorTest.php | 64 ++++++++++++++++++++ 2 files changed, 88 insertions(+), 3 deletions(-) diff --git a/src/Http/Validation/Validator.php b/src/Http/Validation/Validator.php index 0bd846bd..976f5682 100644 --- a/src/Http/Validation/Validator.php +++ b/src/Http/Validation/Validator.php @@ -22,6 +22,9 @@ class Validator 'required' => 'NotEmpty', ]; + /** @var array */ + protected $nestedRules = ['optional', 'not']; + /** * @param array $data * @param array $rules @@ -37,20 +40,38 @@ class Validator $v->with('\\Engelsystem\\Http\\Validation\\Rules', true); $value = isset($data[$key]) ? $data[$key] : null; + $values = explode('|', $values); - foreach (explode('|', $values) as $parameters) { + $packing = []; + foreach ($this->nestedRules as $rule) { + if (in_array($rule, $values)) { + $packing[] = $rule; + } + } + + $values = array_diff($values, $this->nestedRules); + foreach ($values as $parameters) { $parameters = explode(':', $parameters); $rule = array_shift($parameters); $rule = Str::camel($rule); $rule = $this->map($rule); + // To allow rules nesting + $w = $v; try { - call_user_func_array([$v, $rule], $parameters); + foreach (array_reverse(array_merge($packing, [$rule])) as $rule) { + if (!in_array($rule, $this->nestedRules)) { + call_user_func_array([$w, $rule], $parameters); + continue; + } + + $w = call_user_func_array([new RespectValidator(), $rule], [$w]); + } } catch (ComponentException $e) { throw new InvalidArgumentException($e->getMessage(), $e->getCode(), $e); } - if ($v->validate($value)) { + if ($w->validate($value)) { $this->data[$key] = $value; } else { $this->errors[$key][] = implode('.', ['validation', $key, $this->mapBack($rule)]); diff --git a/tests/Unit/Http/Validation/ValidatorTest.php b/tests/Unit/Http/Validation/ValidatorTest.php index 0790b7a8..450e5d4e 100644 --- a/tests/Unit/Http/Validation/ValidatorTest.php +++ b/tests/Unit/Http/Validation/ValidatorTest.php @@ -16,6 +16,7 @@ class ValidatorTest extends TestCase public function testValidate() { $val = new Validator(); + $this->assertTrue($val->validate( ['foo' => 'bar', 'lorem' => 'on', 'dolor' => 'bla'], ['lorem' => 'accepted'] @@ -32,12 +33,39 @@ class ValidatorTest extends TestCase ); } + /** + * @covers \Engelsystem\Http\Validation\Validator::validate + */ + public function testValidateChaining() + { + $val = new Validator(); + + $this->assertTrue($val->validate( + ['lorem' => 10], + ['lorem' => 'required|min:3|max:10'] + )); + $this->assertTrue($val->validate( + ['lorem' => 3], + ['lorem' => 'required|min:3|max:10'] + )); + + $this->assertFalse($val->validate( + ['lorem' => 2], + ['lorem' => 'required|min:3|max:10'] + )); + $this->assertFalse($val->validate( + ['lorem' => 42], + ['lorem' => 'required|min:3|max:10'] + )); + } + /** * @covers \Engelsystem\Http\Validation\Validator::validate */ public function testValidateNotImplemented() { $val = new Validator(); + $this->expectException(InvalidArgumentException::class); $val->validate( @@ -53,6 +81,7 @@ class ValidatorTest extends TestCase public function testValidateMapping() { $val = new Validator(); + $this->assertTrue($val->validate( ['foo' => 'bar'], ['foo' => 'required'] @@ -75,4 +104,39 @@ class ValidatorTest extends TestCase $val->getErrors() ); } + + /** + * @covers \Engelsystem\Http\Validation\Validator::validate + */ + public function testValidateNesting() + { + $val = new Validator(); + + $this->assertTrue($val->validate( + [], + ['foo' => 'not|required'] + )); + + $this->assertTrue($val->validate( + ['foo' => 'foo'], + ['foo' => 'not|int'] + )); + $this->assertFalse($val->validate( + ['foo' => 1], + ['foo' => 'not|int'] + )); + + $this->assertTrue($val->validate( + [], + ['foo' => 'optional|int'] + )); + $this->assertTrue($val->validate( + ['foo' => '33'], + ['foo' => 'optional|int'] + )); + $this->assertFalse($val->validate( + ['foo' => 'T'], + ['foo' => 'optional|int'] + )); + } } From c412f6b009e4cc125089806ca939d928c2efe0a2 Mon Sep 17 00:00:00 2001 From: msquare Date: Thu, 11 Jul 2019 20:09:49 +0200 Subject: [PATCH 09/17] add cccamp19 theme --- config/config.default.php | 1 + resources/assets/themes/theme8.less | 1066 +++++++++++++++++++++++++++ webpack.config.js | 2 +- 3 files changed, 1068 insertions(+), 1 deletion(-) create mode 100644 resources/assets/themes/theme8.less diff --git a/config/config.default.php b/config/config.default.php index 3fad18bc..8c5907f7 100644 --- a/config/config.default.php +++ b/config/config.default.php @@ -59,6 +59,7 @@ return [ // Available themes 'available_themes' => [ + '8' => 'Engelsystem cccamp19 dark (2019)', '7' => 'Engelsystem 35c3 dark (2018)', '6' => 'Engelsystem 34c3 dark (2017)', '5' => 'Engelsystem 34c3 light (2017)', diff --git a/resources/assets/themes/theme8.less b/resources/assets/themes/theme8.less new file mode 100644 index 00000000..22887dab --- /dev/null +++ b/resources/assets/themes/theme8.less @@ -0,0 +1,1066 @@ +@import "../../../node_modules/bootstrap/less/variables"; + +/* +The MIT License (MIT) + +Copyright (c) 2013 Thomas Park + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +*/ + +// Cyborg 3.2.0 +// Variables +// -------------------------------------------------- + +//== Colors +// +//## Gray and brand colors for use across Bootstrap. + +@gray-darker: #222; // #222 +@gray-dark: #282828; // #333 +@gray: #555; // #555 +@gray-light: #888; // #999 +@gray-lighter: #ADAFAE; // #eee + +@brand-primary: #0076ba; +@brand-success: #99ba00; +@brand-info: #ffc600; +@brand-warning: #ffc600; +@brand-danger: #d9534f; + + +//== Scaffolding +// +//## Settings for some of the most global styles. + +//** Background color for ``. +@body-bg: #060606; +//** Global text color on ``. +@text-color: @gray-light; + +//** Global textual link color. +@link-color: @brand-primary; +//** Link hover color set via `darken()` function. +@link-hover-color: @link-color; + + +//== Typography +// +//## Font, line-height, and color for body text, headings, and more. + +@font-family-serif: Georgia, "Times New Roman", Times, serif; +//** Default monospace fonts for ``, ``, and `
`.
+@font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
+@font-family-base:        @font-family-sans-serif;
+
+@font-size-base:          14px;
+@font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
+@font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
+
+@font-size-h1:            34px;
+@font-size-h2:            24px;
+@font-size-h3:            20px;
+@font-size-h4:            20px;
+@font-size-h5:            20px;
+@font-size-h6:            16px;
+
+//** Unit-less `line-height` for use in components like buttons.
+@line-height-base:        1.428571429; // 20/14
+//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
+@line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
+
+//** By default, this inherits from the ``.
+@headings-font-family:    @font-family-base;
+@headings-font-weight:    500;
+@headings-line-height:    1.1;
+@headings-color:          #fff;
+
+
+//== Iconography
+//
+//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+//** File name for all font files.
+@icon-font-name:          "glyphicons-halflings-regular";
+//** Element ID within SVG icon file.
+@icon-font-svg-id:        "glyphicons_halflingsregular";
+
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+
+@padding-base-vertical:     8px;
+@padding-base-horizontal:   12px;
+
+@padding-large-vertical:    14px;
+@padding-large-horizontal:  16px;
+
+@padding-small-vertical:    5px;
+@padding-small-horizontal:  10px;
+
+@padding-xs-vertical:       1px;
+@padding-xs-horizontal:     5px;
+
+@line-height-large:         1.33;
+@line-height-small:         1.5;
+
+@border-radius-base:        4px;
+@border-radius-large:       6px;
+@border-radius-small:       3px;
+
+//** Global color for active items (e.g., navs or dropdowns).
+@component-active-color:    #fff;
+//** Global background color for active items (e.g., navs or dropdowns).
+@component-active-bg:       @brand-primary;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+@caret-width-base:          4px;
+//** Carets increase slightly in size for larger components.
+@caret-width-large:         5px;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ``s.
+@table-cell-padding:            8px;
+//** Padding for cells in `.table-condensed`.
+@table-condensed-cell-padding:  5px;
+
+//** Default background color used for all tables.
+@table-bg:                      darken(@gray-darker, 4%);
+//** Background color used for `.table-striped`.
+@table-bg-accent:               darken(@table-bg, 6%);
+//** Background color used for `.table-hover`.
+@table-bg-hover:                @gray-dark;
+@table-bg-active:               @table-bg-hover;
+
+//** Border color for table and cell borders.
+@table-border-color:            @gray-dark;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+@btn-font-weight:                normal;
+
+@btn-default-color:              #000;
+@btn-default-bg:                 @gray;
+
+@btn-default-border:             darken(@btn-default-bg, 10%);
+
+@btn-primary-color:              @btn-default-color;
+@btn-primary-bg:                 @brand-primary;
+@btn-primary-border:             darken(@btn-default-bg, 10%);
+
+@btn-success-color:              @btn-default-color;
+@btn-success-bg:                 @brand-success;
+@btn-success-border:             darken(@btn-default-bg, 10%);
+
+@btn-info-color:                 @btn-default-color;
+@btn-info-bg:                    @brand-info;
+@btn-info-border:                darken(@btn-default-bg, 10%);
+
+@btn-warning-color:              @btn-default-color;
+@btn-warning-bg:                 @brand-warning;
+@btn-warning-border:             darken(@btn-default-bg, 10%);
+
+@btn-danger-color:               @btn-default-color;
+@btn-danger-bg:                  @brand-danger;
+@btn-danger-border:              darken(@btn-default-bg, 10%);
+
+@btn-link-disabled-color:        @gray-light;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+@input-bg:                       @gray-darker;
+//** `` background color
+@input-bg-disabled:              @gray-dark;
+
+//** Text color for ``s
+@input-color:                    @text-color;
+//** `` border color
+@input-border:                   @gray-dark;
+//** `` border radius
+@input-border-radius:            @border-radius-base;
+//** Border color for inputs on focus
+@input-border-focus:             #66afe9;
+
+//** Placeholder text color
+@input-color-placeholder:        @gray-light;
+
+//** Default `.form-control` height
+@input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);
+//** Large `.form-control` height
+@input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
+//** Small `.form-control` height
+@input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
+
+@legend-color:                   @text-color;
+@legend-border-color:            @gray-dark;
+
+//** Background color for textual input addons
+@input-group-addon-bg:           @gray-lighter;
+//** Border color for textual input addons
+@input-group-addon-border-color: @input-border;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+@dropdown-bg:                    @gray-darker;
+//** Dropdown menu `border-color`.
+@dropdown-border:                rgba(255,255,255,0.1);
+//** Dropdown menu `border-color` **for IE8**.
+@dropdown-fallback-border:       #444;
+//** Divider color for between dropdown items.
+@dropdown-divider-bg:            rgba(255,255,255,0.1);
+
+//** Dropdown link text color.
+@dropdown-link-color:            #fff;
+//** Hover color for dropdown links.
+@dropdown-link-hover-color:      #fff;
+//** Hover background for dropdown links.
+@dropdown-link-hover-bg:         @dropdown-link-active-bg;
+
+//** Active dropdown menu item text color.
+@dropdown-link-active-color:     #fff;
+//** Active dropdown menu item background color.
+@dropdown-link-active-bg:        @component-active-bg;
+
+//** Disabled dropdown menu item background color.
+@dropdown-link-disabled-color:   @text-muted;
+
+//** Text color for headers within dropdown menus.
+@dropdown-header-color:          @text-muted;
+
+//** Deprecated `@dropdown-caret-color` as of v3.1.0
+@dropdown-caret-color:           #000;
+
+
+//-- Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+//
+// Note: These variables are not generated into the Customizer.
+
+@zindex-navbar:            1000;
+@zindex-dropdown:          1000;
+@zindex-popover:           1060;
+@zindex-tooltip:           1070;
+@zindex-navbar-fixed:      1030;
+@zindex-modal-background:  1040;
+@zindex-modal:             1050;
+
+
+//== Media queries breakpoints
+//
+//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
+
+// Extra small screen / phone
+//** Deprecated `@screen-xs` as of v3.0.1
+@screen-xs:                  480px;
+//** Deprecated `@screen-xs-min` as of v3.2.0
+@screen-xs-min:              @screen-xs;
+//** Deprecated `@screen-phone` as of v3.0.1
+@screen-phone:               @screen-xs-min;
+
+// Small screen / tablet
+//** Deprecated `@screen-sm` as of v3.0.1
+@screen-sm:                  768px;
+@screen-sm-min:              @screen-sm;
+//** Deprecated `@screen-tablet` as of v3.0.1
+@screen-tablet:              @screen-sm-min;
+
+// Medium screen / desktop
+//** Deprecated `@screen-md` as of v3.0.1
+@screen-md:                  992px;
+@screen-md-min:              @screen-md;
+//** Deprecated `@screen-desktop` as of v3.0.1
+@screen-desktop:             @screen-md-min;
+
+// Large screen / wide desktop
+//** Deprecated `@screen-lg` as of v3.0.1
+@screen-lg:                  1200px;
+@screen-lg-min:              @screen-lg;
+//** Deprecated `@screen-lg-desktop` as of v3.0.1
+@screen-lg-desktop:          @screen-lg-min;
+
+// So media queries don't overlap when required, provide a maximum
+@screen-xs-max:              (@screen-sm-min - 1);
+@screen-sm-max:              (@screen-md-min - 1);
+@screen-md-max:              (@screen-lg-min - 1);
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+@grid-columns:              12;
+//** Padding between columns. Gets divided in half for the left and right.
+@grid-gutter-width:         30px;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+@grid-float-breakpoint:     @screen-sm-min;
+//** Point at which the navbar begins collapsing.
+@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+@container-tablet:             ((720px + @grid-gutter-width));
+//** For `@screen-sm-min` and up.
+@container-sm:                 @container-tablet;
+
+// Medium screen / desktop
+@container-desktop:            ((940px + @grid-gutter-width));
+//** For `@screen-md-min` and up.
+@container-md:                 @container-desktop;
+
+// Large screen / wide desktop
+@container-large-desktop:      ((1140px + @grid-gutter-width));
+//** For `@screen-lg-min` and up.
+@container-lg:                 @container-large-desktop;
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+@navbar-height:                    50px;
+@navbar-margin-bottom:             @line-height-computed;
+@navbar-border-radius:             @border-radius-base;
+@navbar-padding-horizontal:        floor((@grid-gutter-width / 2));
+@navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);
+@navbar-collapse-max-height:       340px;
+
+@navbar-default-color:             @text-color;
+@navbar-default-bg:                @body-bg;
+@navbar-default-border:            @gray-dark;
+
+// Navbar links
+@navbar-default-link-color:                @text-color;
+@navbar-default-link-hover-color:          #fff;
+@navbar-default-link-hover-bg:             transparent;
+@navbar-default-link-active-color:         #fff;
+@navbar-default-link-active-bg:            transparent;
+@navbar-default-link-disabled-color:       @gray-light;
+@navbar-default-link-disabled-bg:          transparent;
+
+// Navbar brand label
+@navbar-default-brand-color:               #fff;
+@navbar-default-brand-hover-color:         #fff;
+@navbar-default-brand-hover-bg:            transparent;
+
+// Navbar toggle
+@navbar-default-toggle-hover-bg:           @gray-dark;
+@navbar-default-toggle-icon-bar-bg:        #ccc;
+@navbar-default-toggle-border-color:       @gray-dark;
+
+
+// Inverted navbar
+// Reset inverted navbar basics
+@navbar-inverse-color:                      @gray-light;
+@navbar-inverse-bg:                         @gray-darker;
+@navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);
+
+// Inverted navbar links
+@navbar-inverse-link-color:                 @gray-light;
+@navbar-inverse-link-hover-color:           #fff;
+@navbar-inverse-link-hover-bg:              transparent;
+@navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;
+@navbar-inverse-link-active-bg:             transparent;
+@navbar-inverse-link-disabled-color:        #aaa;
+@navbar-inverse-link-disabled-bg:           transparent;
+
+// Inverted navbar brand label
+@navbar-inverse-brand-color:                #fff;
+@navbar-inverse-brand-hover-color:          #fff;
+@navbar-inverse-brand-hover-bg:             transparent;
+
+// Inverted navbar toggle
+@navbar-inverse-toggle-hover-bg:            #333;
+@navbar-inverse-toggle-icon-bar-bg:         #fff;
+@navbar-inverse-toggle-border-color:        #333;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+@nav-link-padding:                          10px 15px;
+@nav-link-hover-bg:                         @gray-darker;
+
+@nav-disabled-link-color:                   @gray-light;
+@nav-disabled-link-hover-color:             @gray-light;
+
+@nav-open-link-hover-color:                 @gray-darker;
+
+//== Tabs
+@nav-tabs-border-color:                     @gray-dark;
+
+@nav-tabs-link-hover-border-color:          transparent;
+
+@nav-tabs-active-link-hover-bg:             @brand-primary;
+@nav-tabs-active-link-hover-color:          #fff;
+@nav-tabs-active-link-hover-border-color:   @gray-dark;
+
+@nav-tabs-justified-link-border-color:            #ddd;
+@nav-tabs-justified-active-link-border-color:     @body-bg;
+
+//== Pills
+@nav-pills-border-radius:                   @border-radius-base;
+@nav-pills-active-link-hover-bg:            @component-active-bg;
+@nav-pills-active-link-hover-color:         @component-active-color;
+
+
+//== Pagination
+//
+//##
+
+@pagination-color:                     #fff;
+@pagination-bg:                        @gray-darker;
+@pagination-border:                    @gray-dark;
+
+@pagination-hover-color:               #fff;
+@pagination-hover-bg:                  @component-active-bg;
+@pagination-hover-border:              transparent;
+
+@pagination-active-color:              #fff;
+@pagination-active-bg:                 @brand-primary;
+@pagination-active-border:             transparent;
+
+@pagination-disabled-color:            @gray-light;
+@pagination-disabled-bg:               @gray-darker;
+@pagination-disabled-border:           @gray-dark;
+
+
+//== Pager
+//
+//##
+
+@pager-bg:                             @pagination-bg;
+@pager-border:                         @pagination-border;
+@pager-border-radius:                  15px;
+
+@pager-hover-bg:                       @pagination-hover-bg;
+
+@pager-active-bg:                      @pagination-active-bg;
+@pager-active-color:                   @pagination-active-color;
+
+@pager-disabled-color:                 @gray-light;
+
+
+//== Jumbotron
+//
+//##
+
+@jumbotron-padding:              30px;
+@jumbotron-color:                inherit;
+@jumbotron-bg:                   darken(@gray-darker, 5%);
+@jumbotron-heading-color:        inherit;
+@jumbotron-font-size:            ceil((@font-size-base * 1.5));
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text:             #000;
+@state-success-bg:               @brand-success;
+@state-success-border:           darken(@state-success-bg, 5%);
+
+@state-info-text:                #000;
+@state-info-bg:                  @brand-info;
+@state-info-border:              darken(@state-info-bg, 7%);
+
+@state-warning-text:             #000;
+@state-warning-bg:               @brand-warning;
+@state-warning-border:           darken(@state-warning-bg, 3%);
+
+@state-danger-text:              #000;
+@state-danger-bg:                @brand-danger;
+@state-danger-border:            darken(@state-danger-bg, 3%);
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+@tooltip-max-width:           200px;
+//** Tooltip text color
+@tooltip-color:               #fff;
+//** Tooltip background color
+@tooltip-bg:                  rgba(0,0,0,.9);
+@tooltip-opacity:             .9;
+
+//** Tooltip arrow width
+@tooltip-arrow-width:         5px;
+//** Tooltip arrow color
+@tooltip-arrow-color:         @tooltip-bg;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+@popover-bg:                          lighten(@body-bg, 10%);
+//** Popover maximum width
+@popover-max-width:                   276px;
+//** Popover border color
+@popover-border-color:                rgba(0,0,0,.2);
+//** Popover fallback border color
+@popover-fallback-border-color:       #999;
+
+//** Popover title background color
+@popover-title-bg:                    darken(@popover-bg, 3%);
+
+//** Popover arrow width
+@popover-arrow-width:                 10px;
+//** Popover arrow color
+@popover-arrow-color:                 @popover-bg;
+
+//** Popover outer arrow width
+@popover-arrow-outer-width:           (@popover-arrow-width + 1);
+//** Popover outer arrow color
+@popover-arrow-outer-color:           fadein(@popover-border-color, 5%);
+//** Popover outer arrow fallback color
+@popover-arrow-outer-fallback-color:  darken(@popover-fallback-border-color, 20%);
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+@label-default-bg:            @btn-default-bg;
+//** Primary label background color
+@label-primary-bg:            @brand-primary;
+//** Success label background color
+@label-success-bg:            @brand-success;
+//** Info label background color
+@label-info-bg:               @brand-info;
+//** Warning label background color
+@label-warning-bg:            @brand-warning;
+//** Danger label background color
+@label-danger-bg:             @brand-danger;
+
+//** Default label text color
+@label-color:                 #fff;
+//** Default text color of a linked label
+@label-link-hover-color:      #fff;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+@modal-inner-padding:         20px;
+
+//** Padding applied to the modal title
+@modal-title-padding:         15px;
+//** Modal title line-height
+@modal-title-line-height:     @line-height-base;
+
+//** Background color of modal content area
+@modal-content-bg:                             lighten(@body-bg, 10%);
+//** Modal content border color
+@modal-content-border-color:                   rgba(0,0,0,.2);
+//** Modal content border color **for IE8**
+@modal-content-fallback-border-color:          #999;
+
+//** Modal backdrop background color
+@modal-backdrop-bg:           #000;
+//** Modal backdrop opacity
+@modal-backdrop-opacity:      .5;
+//** Modal header border color
+@modal-header-border-color:   @gray-dark;
+//** Modal footer border color
+@modal-footer-border-color:   @modal-header-border-color;
+
+@modal-lg:                    900px;
+@modal-md:                    600px;
+@modal-sm:                    300px;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+@alert-padding:               15px;
+@alert-border-radius:         @border-radius-base;
+@alert-link-font-weight:      bold;
+
+@alert-success-bg:            @state-success-bg;
+@alert-success-text:          @state-success-text;
+@alert-success-border:        @state-success-border;
+
+@alert-info-bg:               @state-info-bg;
+@alert-info-text:             @state-info-text;
+@alert-info-border:           @state-info-border;
+
+@alert-warning-bg:            @state-warning-bg;
+@alert-warning-text:          @state-warning-text;
+@alert-warning-border:        @state-warning-border;
+
+@alert-danger-bg:             @state-danger-bg;
+@alert-danger-text:           @state-danger-text;
+@alert-danger-border:         @state-danger-border;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+@progress-bg:                 @gray-darker;
+//** Progress bar text color
+@progress-bar-color:          #fff;
+
+//** Default progress bar color
+@progress-bar-bg:             @brand-primary;
+//** Success progress bar color
+@progress-bar-success-bg:     @brand-success;
+//** Warning progress bar color
+@progress-bar-warning-bg:     @brand-warning;
+//** Danger progress bar color
+@progress-bar-danger-bg:      @brand-danger;
+//** Info progress bar color
+@progress-bar-info-bg:        @brand-info;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+@list-group-bg:                 @gray-darker;
+//** `.list-group-item` border color
+@list-group-border:             @gray-dark;
+//** List group border radius
+@list-group-border-radius:      @border-radius-base;
+
+//** Background color of single list items on hover
+@list-group-hover-bg:           lighten(@list-group-bg, 15%);
+//** Text color of active list items
+@list-group-active-color:       @component-active-color;
+//** Background color of active list items
+@list-group-active-bg:          @component-active-bg;
+//** Border color of active list elements
+@list-group-active-border:      @list-group-active-bg;
+//** Text color for content within active list items
+@list-group-active-text-color:  lighten(@list-group-active-bg, 40%);
+
+//** Text color of disabled list items
+@list-group-disabled-color:      @gray-light;
+//** Background color of disabled list items
+@list-group-disabled-bg:         @gray-lighter;
+//** Text color for content within disabled list items
+@list-group-disabled-text-color: @list-group-disabled-color;
+
+@list-group-link-color:         @text-color;
+@list-group-link-hover-color:   @list-group-link-color;
+@list-group-link-heading-color: #fff;
+
+
+//== Panels
+//
+//##
+
+@panel-bg:                    @gray-darker;
+@panel-body-padding:          15px;
+@panel-heading-padding:       10px 15px;
+@panel-footer-padding:        @panel-heading-padding;
+@panel-border-radius:         @border-radius-base;
+
+//** Border color for elements within panels
+@panel-inner-border:          @gray-dark;
+
+@panel-default-text:          @text-color;
+@panel-default-border:        @panel-inner-border;
+@panel-default-heading-bg:    lighten(@gray-darker, 10%);
+
+@panel-footer-bg:             @panel-default-heading-bg;
+
+@panel-primary-text:          #fff;
+@panel-primary-border:        @brand-primary;
+@panel-primary-heading-bg:    @brand-primary;
+
+@panel-success-text:          @state-success-text;
+@panel-success-border:        @state-success-border;
+@panel-success-heading-bg:    @state-success-bg;
+
+@panel-info-text:             @state-info-text;
+@panel-info-border:           @state-info-border;
+@panel-info-heading-bg:       @state-info-bg;
+
+@panel-warning-text:          @state-warning-text;
+@panel-warning-border:        @state-warning-border;
+@panel-warning-heading-bg:    @state-warning-bg;
+
+@panel-danger-text:           @state-danger-text;
+@panel-danger-border:         @state-danger-border;
+@panel-danger-heading-bg:     @state-danger-bg;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+@thumbnail-padding:           4px;
+//** Thumbnail background color
+@thumbnail-bg:                @gray-dark;
+//** Thumbnail border color
+@thumbnail-border:            @gray-dark;
+//** Thumbnail border radius
+@thumbnail-border-radius:     @border-radius-base;
+
+//** Custom text color for thumbnail captions
+@thumbnail-caption-color:     @text-color;
+//** Padding around the thumbnail caption
+@thumbnail-caption-padding:   9px;
+
+
+//== Wells
+//
+//##
+
+@well-bg:                     darken(@gray-darker, 5%);
+@well-border:                 darken(@well-bg, 7%);
+
+
+//== Badges
+//
+//##
+
+@badge-color:                 #fff;
+//** Linked badge text color on hover
+@badge-link-hover-color:      #fff;
+@badge-bg:                    @brand-primary;
+
+//** Badge text color in active nav link
+@badge-active-color:          @brand-primary;
+//** Badge background color in active nav link
+@badge-active-bg:             #fff;
+
+@badge-font-weight:           bold;
+@badge-line-height:           1;
+@badge-border-radius:         10px;
+
+
+//== Breadcrumbs
+//
+//##
+
+@breadcrumb-padding-vertical:   8px;
+@breadcrumb-padding-horizontal: 15px;
+//** Breadcrumb background color
+@breadcrumb-bg:                 @gray-darker;
+//** Breadcrumb text color
+@breadcrumb-color:              #fff;
+//** Text color of current page in the breadcrumb
+@breadcrumb-active-color:       @text-color;
+//** Textual separator for between breadcrumb elements
+@breadcrumb-separator:          "/";
+
+
+//== Carousel
+//
+//##
+
+@carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
+
+@carousel-control-color:                      #fff;
+@carousel-control-width:                      15%;
+@carousel-control-opacity:                    .5;
+@carousel-control-font-size:                  20px;
+
+@carousel-indicator-active-bg:                #fff;
+@carousel-indicator-border-color:             #fff;
+
+@carousel-caption-color:                      #fff;
+
+
+//== Close
+//
+//##
+
+@close-font-weight:           bold;
+@close-color:                 #000;
+@close-text-shadow:           0 1px 0 #fff;
+
+
+//== Code
+//
+//##
+
+@code-color:                  #c7254e;
+@code-bg:                     #f9f2f4;
+
+@kbd-color:                   #fff;
+@kbd-bg:                      #333;
+
+@pre-bg:                      #f5f5f5;
+@pre-color:                   @gray-dark;
+@pre-border-color:            #ccc;
+@pre-scrollable-max-height:   340px;
+
+
+//== Type
+//
+//##
+
+//** Horizontal offset for forms and lists.
+@component-offset-horizontal: 180px;
+//** Text muted color
+@text-muted:                  @gray-light;
+//** Abbreviations and acronyms border color
+@abbr-border-color:           @gray-light;
+//** Headings small color
+@headings-small-color:        @gray-light;
+//** Blockquote small color
+@blockquote-small-color:      @gray;
+//** Blockquote font size
+@blockquote-font-size:        (@font-size-base * 1.25);
+//** Blockquote border color
+@blockquote-border-color:     @gray-dark;
+//** Page header border color
+@page-header-border-color:    @gray-dark;
+//** Width of horizontal description list titles
+@dl-horizontal-offset:        @component-offset-horizontal;
+//** Horizontal line color.
+@hr-border:                   @gray-dark;
+
+@import "base";
+
+.messages .text-danger {
+  color: #fff;
+}
+
+.messages .text-info {
+  color: #fff;
+}
+
+.messages .caret {
+  color: #fff;
+}
+
+// Cyborg 3.2.0
+// Bootswatch
+// -----------------------------------------------------
+
+// Navbar =====================================================================
+
+// Buttons ====================================================================
+
+// Typography =================================================================
+
+.text-primary,
+.text-primary:hover {
+  color: @brand-primary;
+}
+
+.text-success,
+.text-success:hover {
+  color: @brand-success;
+}
+
+.text-danger,
+.text-danger:hover {
+  color: @brand-danger;
+}
+
+.text-warning,
+.text-warning:hover {
+  color: @brand-warning;
+}
+
+.text-info,
+.text-info:hover {
+  color: @brand-info;
+}
+
+// Tables =====================================================================
+
+table,
+.table {
+  color: #fff;
+
+  a:not(.btn) {
+    color: #fff;
+    text-decoration: underline;
+  }
+
+  .text-muted {
+    color: @text-muted;
+  }
+}
+
+.table-responsive > .table {
+  background-color: @table-bg;
+}
+
+// Forms ======================================================================
+
+.has-warning {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-warning;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-warning;
+  }
+}
+
+.has-error {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-danger;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-danger;
+  }
+}
+
+.has-success {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-success;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-success;
+  }
+}
+
+legend {
+  color: #fff;
+}
+
+.input-group-addon {
+  background-color: @btn-default-bg;
+}
+
+// Navs =======================================================================
+
+.nav-tabs,
+.nav-pills,
+.breadcrumb,
+.pager {
+
+  a {
+    color: #fff;
+  }
+}
+
+// Indicators =================================================================
+
+.alert {
+
+  .alert-link,
+  a {
+    color: @alert-warning-text;
+    text-decoration: underline;
+  }
+
+  .close {
+    text-decoration: none;
+  }
+}
+
+.close {
+  color: #fff;
+  text-decoration: none;
+  opacity: 0.4;
+
+  &:hover,
+  &:focus {
+    color: #fff;
+    opacity: 1;
+  }
+}
+
+// Progress bars ==============================================================
+
+// Containers =================================================================
+
+a.thumbnail:hover,
+a.thumbnail:focus,
+a.thumbnail.active {
+  border-color: @thumbnail-border;
+}
+
+.jumbotron {
+
+  h1, h2, h3, h4, h5, h6 {
+    color: #fff;
+  }
+}
+
+
+// Specials for cccamp19 design
+
+.navbar-brand {
+	.icon-icon_angel {
+	  background-color: @brand-primary;
+  }
+  
+  strong {
+    font-weight: lighter;
+    color: @brand-primary;
+    text-shadow: 0 0 10px @brand-primary;
+  }
+}
+
+h1 {
+  font-weight: lighter;
+  color: @brand-primary;
+  text-shadow: 0 0 10px @brand-primary;
+}
diff --git a/webpack.config.js b/webpack.config.js
index 403c885e..5542460d 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -23,7 +23,7 @@ const plugins = [
 
 
 const themeEntries = {};
-for (let i = 0; i < 8; i++) {
+for (let i = 0; i < 9; i++) {
   themeEntries[`theme${i}`] = `./resources/assets/themes/theme${i}.less`;
 }
 

From 9e9fee25f20e131d4c7d4fa70a0d215dcc37c6f8 Mon Sep 17 00:00:00 2001
From: msquare 
Date: Sun, 14 Jul 2019 13:09:08 +0200
Subject: [PATCH 10/17] all 3 color themes for camp and high contrast theme

---
 config/config.default.php            |    5 +-
 resources/assets/themes/theme1.less  |    4 -
 resources/assets/themes/theme10.less | 1076 ++++++++++++++++++++++++++
 resources/assets/themes/theme11.less | 1049 +++++++++++++++++++++++++
 resources/assets/themes/theme4.less  |    4 -
 resources/assets/themes/theme6.less  |    4 -
 resources/assets/themes/theme7.less  |    4 -
 resources/assets/themes/theme8.less  |   36 +-
 resources/assets/themes/theme9.less  | 1076 ++++++++++++++++++++++++++
 webpack.config.js                    |    2 +-
 10 files changed, 3229 insertions(+), 31 deletions(-)
 create mode 100644 resources/assets/themes/theme10.less
 create mode 100644 resources/assets/themes/theme11.less
 create mode 100644 resources/assets/themes/theme9.less

diff --git a/config/config.default.php b/config/config.default.php
index 8c5907f7..ba343cf4 100644
--- a/config/config.default.php
+++ b/config/config.default.php
@@ -59,13 +59,16 @@ return [
 
     // Available themes
     'available_themes'        => [
-        '8' => 'Engelsystem cccamp19 dark (2019)',
+        '10' => 'Engelsystem cccamp19 green (2019)',
+        '9' => 'Engelsystem cccamp19 yellow (2019)',
+        '8' => 'Engelsystem cccamp19 blue (2019)',
         '7' => 'Engelsystem 35c3 dark (2018)',
         '6' => 'Engelsystem 34c3 dark (2017)',
         '5' => 'Engelsystem 34c3 light (2017)',
         '4' => 'Engelsystem 33c3 (2016)',
         '3' => 'Engelsystem 32c3 (2015)',
         '2' => 'Engelsystem cccamp15',
+        '11' => 'Engelsystem high contrast',
         '0' => 'Engelsystem light',
         '1' => 'Engelsystem dark',
     ],
diff --git a/resources/assets/themes/theme1.less b/resources/assets/themes/theme1.less
index 20af646a..d67daab7 100644
--- a/resources/assets/themes/theme1.less
+++ b/resources/assets/themes/theme1.less
@@ -980,10 +980,6 @@ table,
   }
 }
 
-legend {
-  color: #fff;
-}
-
 .input-group-addon {
   background-color: @btn-default-bg;
 }
diff --git a/resources/assets/themes/theme10.less b/resources/assets/themes/theme10.less
new file mode 100644
index 00000000..7262bbad
--- /dev/null
+++ b/resources/assets/themes/theme10.less
@@ -0,0 +1,1076 @@
+@import "../../../node_modules/bootstrap/less/variables";
+
+/*
+The MIT License (MIT)
+
+Copyright (c) 2013 Thomas Park
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+// Cyborg 3.2.0
+// Variables
+// --------------------------------------------------
+
+//== Colors
+//
+//## Gray and brand colors for use across Bootstrap.
+
+@gray-darker:            #222; // #222
+@gray-dark:              #282828;   // #333
+@gray:                   #555; // #555
+@gray-light:             #888;   // #999
+@gray-lighter:           #ADAFAE; // #eee
+
+@brand-primary:         #99ba00;
+@brand-success:         #99ba00;
+@brand-info:            #0076ba;
+@brand-warning:         #ffc600;
+@brand-danger:          #d9534f;
+
+
+//== Scaffolding
+//
+//## Settings for some of the most global styles.
+
+//** Background color for ``.
+@body-bg:               #060606;
+//** Global text color on ``.
+@text-color:            @gray-light;
+
+//** Global textual link color.
+@link-color:            @brand-primary;
+//** Link hover color set via `darken()` function.
+@link-hover-color:      @link-color;
+
+
+//== Typography
+//
+//## Font, line-height, and color for body text, headings, and more.
+
+@font-family-serif:       Georgia, "Times New Roman", Times, serif;
+//** Default monospace fonts for ``, ``, and `
`.
+@font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
+@font-family-base:        @font-family-sans-serif;
+
+@font-size-base:          14px;
+@font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
+@font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
+
+@font-size-h1:            34px;
+@font-size-h2:            24px;
+@font-size-h3:            20px;
+@font-size-h4:            20px;
+@font-size-h5:            20px;
+@font-size-h6:            16px;
+
+//** Unit-less `line-height` for use in components like buttons.
+@line-height-base:        1.428571429; // 20/14
+//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
+@line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
+
+//** By default, this inherits from the ``.
+@headings-font-family:    @font-family-base;
+@headings-font-weight:    500;
+@headings-line-height:    1.1;
+@headings-color:          #fff;
+
+
+//== Iconography
+//
+//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+//** File name for all font files.
+@icon-font-name:          "glyphicons-halflings-regular";
+//** Element ID within SVG icon file.
+@icon-font-svg-id:        "glyphicons_halflingsregular";
+
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+
+@padding-base-vertical:     8px;
+@padding-base-horizontal:   12px;
+
+@padding-large-vertical:    14px;
+@padding-large-horizontal:  16px;
+
+@padding-small-vertical:    5px;
+@padding-small-horizontal:  10px;
+
+@padding-xs-vertical:       1px;
+@padding-xs-horizontal:     5px;
+
+@line-height-large:         1.33;
+@line-height-small:         1.5;
+
+@border-radius-base:        4px;
+@border-radius-large:       6px;
+@border-radius-small:       3px;
+
+//** Global color for active items (e.g., navs or dropdowns).
+@component-active-color:    #fff;
+//** Global background color for active items (e.g., navs or dropdowns).
+@component-active-bg:       @brand-primary;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+@caret-width-base:          4px;
+//** Carets increase slightly in size for larger components.
+@caret-width-large:         5px;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ``s.
+@table-cell-padding:            8px;
+//** Padding for cells in `.table-condensed`.
+@table-condensed-cell-padding:  5px;
+
+//** Default background color used for all tables.
+@table-bg:                      darken(@gray-darker, 4%);
+//** Background color used for `.table-striped`.
+@table-bg-accent:               darken(@table-bg, 6%);
+//** Background color used for `.table-hover`.
+@table-bg-hover:                @gray-dark;
+@table-bg-active:               @table-bg-hover;
+
+//** Border color for table and cell borders.
+@table-border-color:            @gray-dark;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+@btn-font-weight:                normal;
+
+@btn-default-color:              #000;
+@btn-default-bg:                 @gray-light;
+
+@btn-default-border:             darken(@btn-default-bg, 10%);
+
+@btn-primary-color:              @btn-default-color;
+@btn-primary-bg:                 @brand-primary;
+@btn-primary-border:             darken(@btn-default-bg, 10%);
+
+@btn-success-color:              @btn-default-color;
+@btn-success-bg:                 @brand-success;
+@btn-success-border:             darken(@btn-default-bg, 10%);
+
+@btn-info-color:                 @btn-default-color;
+@btn-info-bg:                    @brand-info;
+@btn-info-border:                darken(@btn-default-bg, 10%);
+
+@btn-warning-color:              @btn-default-color;
+@btn-warning-bg:                 @brand-warning;
+@btn-warning-border:             darken(@btn-default-bg, 10%);
+
+@btn-danger-color:               @btn-default-color;
+@btn-danger-bg:                  @brand-danger;
+@btn-danger-border:              darken(@btn-default-bg, 10%);
+
+@btn-link-disabled-color:        @gray-light;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+@input-bg:                       @gray-darker;
+//** `` background color
+@input-bg-disabled:              @gray-dark;
+
+//** Text color for ``s
+@input-color:                    @text-color;
+//** `` border color
+@input-border:                   @gray-dark;
+//** `` border radius
+@input-border-radius:            @border-radius-base;
+//** Border color for inputs on focus
+@input-border-focus:             #66afe9;
+
+//** Placeholder text color
+@input-color-placeholder:        @gray-light;
+
+//** Default `.form-control` height
+@input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);
+//** Large `.form-control` height
+@input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
+//** Small `.form-control` height
+@input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
+
+@legend-color:                   @text-color;
+@legend-border-color:            @gray-dark;
+
+//** Background color for textual input addons
+@input-group-addon-bg:           @gray-lighter;
+//** Border color for textual input addons
+@input-group-addon-border-color: @input-border;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+@dropdown-bg:                    @gray-darker;
+//** Dropdown menu `border-color`.
+@dropdown-border:                rgba(255,255,255,0.1);
+//** Dropdown menu `border-color` **for IE8**.
+@dropdown-fallback-border:       #444;
+//** Divider color for between dropdown items.
+@dropdown-divider-bg:            rgba(255,255,255,0.1);
+
+//** Dropdown link text color.
+@dropdown-link-color:            #fff;
+//** Hover color for dropdown links.
+@dropdown-link-hover-color:      #fff;
+//** Hover background for dropdown links.
+@dropdown-link-hover-bg:         @dropdown-link-active-bg;
+
+//** Active dropdown menu item text color.
+@dropdown-link-active-color:     #fff;
+//** Active dropdown menu item background color.
+@dropdown-link-active-bg:        @component-active-bg;
+
+//** Disabled dropdown menu item background color.
+@dropdown-link-disabled-color:   @text-muted;
+
+//** Text color for headers within dropdown menus.
+@dropdown-header-color:          @text-muted;
+
+//** Deprecated `@dropdown-caret-color` as of v3.1.0
+@dropdown-caret-color:           #000;
+
+
+//-- Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+//
+// Note: These variables are not generated into the Customizer.
+
+@zindex-navbar:            1000;
+@zindex-dropdown:          1000;
+@zindex-popover:           1060;
+@zindex-tooltip:           1070;
+@zindex-navbar-fixed:      1030;
+@zindex-modal-background:  1040;
+@zindex-modal:             1050;
+
+
+//== Media queries breakpoints
+//
+//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
+
+// Extra small screen / phone
+//** Deprecated `@screen-xs` as of v3.0.1
+@screen-xs:                  480px;
+//** Deprecated `@screen-xs-min` as of v3.2.0
+@screen-xs-min:              @screen-xs;
+//** Deprecated `@screen-phone` as of v3.0.1
+@screen-phone:               @screen-xs-min;
+
+// Small screen / tablet
+//** Deprecated `@screen-sm` as of v3.0.1
+@screen-sm:                  768px;
+@screen-sm-min:              @screen-sm;
+//** Deprecated `@screen-tablet` as of v3.0.1
+@screen-tablet:              @screen-sm-min;
+
+// Medium screen / desktop
+//** Deprecated `@screen-md` as of v3.0.1
+@screen-md:                  992px;
+@screen-md-min:              @screen-md;
+//** Deprecated `@screen-desktop` as of v3.0.1
+@screen-desktop:             @screen-md-min;
+
+// Large screen / wide desktop
+//** Deprecated `@screen-lg` as of v3.0.1
+@screen-lg:                  1200px;
+@screen-lg-min:              @screen-lg;
+//** Deprecated `@screen-lg-desktop` as of v3.0.1
+@screen-lg-desktop:          @screen-lg-min;
+
+// So media queries don't overlap when required, provide a maximum
+@screen-xs-max:              (@screen-sm-min - 1);
+@screen-sm-max:              (@screen-md-min - 1);
+@screen-md-max:              (@screen-lg-min - 1);
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+@grid-columns:              12;
+//** Padding between columns. Gets divided in half for the left and right.
+@grid-gutter-width:         30px;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+@grid-float-breakpoint:     @screen-sm-min;
+//** Point at which the navbar begins collapsing.
+@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+@container-tablet:             ((720px + @grid-gutter-width));
+//** For `@screen-sm-min` and up.
+@container-sm:                 @container-tablet;
+
+// Medium screen / desktop
+@container-desktop:            ((940px + @grid-gutter-width));
+//** For `@screen-md-min` and up.
+@container-md:                 @container-desktop;
+
+// Large screen / wide desktop
+@container-large-desktop:      ((1140px + @grid-gutter-width));
+//** For `@screen-lg-min` and up.
+@container-lg:                 @container-large-desktop;
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+@navbar-height:                    50px;
+@navbar-margin-bottom:             @line-height-computed;
+@navbar-border-radius:             @border-radius-base;
+@navbar-padding-horizontal:        floor((@grid-gutter-width / 2));
+@navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);
+@navbar-collapse-max-height:       340px;
+
+@navbar-default-color:             @text-color;
+@navbar-default-bg:                @body-bg;
+@navbar-default-border:            @gray-dark;
+
+// Navbar links
+@navbar-default-link-color:                @text-color;
+@navbar-default-link-hover-color:          #fff;
+@navbar-default-link-hover-bg:             transparent;
+@navbar-default-link-active-color:         #fff;
+@navbar-default-link-active-bg:            transparent;
+@navbar-default-link-disabled-color:       @gray-light;
+@navbar-default-link-disabled-bg:          transparent;
+
+// Navbar brand label
+@navbar-default-brand-color:               #fff;
+@navbar-default-brand-hover-color:         #fff;
+@navbar-default-brand-hover-bg:            transparent;
+
+// Navbar toggle
+@navbar-default-toggle-hover-bg:           @gray-dark;
+@navbar-default-toggle-icon-bar-bg:        #ccc;
+@navbar-default-toggle-border-color:       @gray-dark;
+
+
+// Inverted navbar
+// Reset inverted navbar basics
+@navbar-inverse-color:                      @gray-light;
+@navbar-inverse-bg:                         @gray-darker;
+@navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);
+
+// Inverted navbar links
+@navbar-inverse-link-color:                 @gray-light;
+@navbar-inverse-link-hover-color:           #fff;
+@navbar-inverse-link-hover-bg:              transparent;
+@navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;
+@navbar-inverse-link-active-bg:             transparent;
+@navbar-inverse-link-disabled-color:        #aaa;
+@navbar-inverse-link-disabled-bg:           transparent;
+
+// Inverted navbar brand label
+@navbar-inverse-brand-color:                #fff;
+@navbar-inverse-brand-hover-color:          #fff;
+@navbar-inverse-brand-hover-bg:             transparent;
+
+// Inverted navbar toggle
+@navbar-inverse-toggle-hover-bg:            #333;
+@navbar-inverse-toggle-icon-bar-bg:         #fff;
+@navbar-inverse-toggle-border-color:        #333;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+@nav-link-padding:                          10px 15px;
+@nav-link-hover-bg:                         @gray-darker;
+
+@nav-disabled-link-color:                   @gray-light;
+@nav-disabled-link-hover-color:             @gray-light;
+
+@nav-open-link-hover-color:                 @gray-darker;
+
+//== Tabs
+@nav-tabs-border-color:                     @gray-dark;
+
+@nav-tabs-link-hover-border-color:          transparent;
+
+@nav-tabs-active-link-hover-bg:             @brand-primary;
+@nav-tabs-active-link-hover-color:          #fff;
+@nav-tabs-active-link-hover-border-color:   @gray-dark;
+
+@nav-tabs-justified-link-border-color:            #ddd;
+@nav-tabs-justified-active-link-border-color:     @body-bg;
+
+//== Pills
+@nav-pills-border-radius:                   @border-radius-base;
+@nav-pills-active-link-hover-bg:            @component-active-bg;
+@nav-pills-active-link-hover-color:         @component-active-color;
+
+
+//== Pagination
+//
+//##
+
+@pagination-color:                     #fff;
+@pagination-bg:                        @gray-darker;
+@pagination-border:                    @gray-dark;
+
+@pagination-hover-color:               #000;
+@pagination-hover-bg:                  @component-active-bg;
+@pagination-hover-border:              transparent;
+
+@pagination-active-color:              #000;
+@pagination-active-bg:                 @brand-primary;
+@pagination-active-border:             transparent;
+
+@pagination-disabled-color:            @gray-light;
+@pagination-disabled-bg:               @gray-darker;
+@pagination-disabled-border:           @gray-dark;
+
+
+//== Pager
+//
+//##
+
+@pager-bg:                             @pagination-bg;
+@pager-border:                         @pagination-border;
+@pager-border-radius:                  15px;
+
+@pager-hover-bg:                       @pagination-hover-bg;
+
+@pager-active-bg:                      @pagination-active-bg;
+@pager-active-color:                   @pagination-active-color;
+
+@pager-disabled-color:                 @gray-light;
+
+
+//== Jumbotron
+//
+//##
+
+@jumbotron-padding:              30px;
+@jumbotron-color:                inherit;
+@jumbotron-bg:                   darken(@gray-darker, 5%);
+@jumbotron-heading-color:        inherit;
+@jumbotron-font-size:            ceil((@font-size-base * 1.5));
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text:             #000;
+@state-success-bg:               @brand-success;
+@state-success-border:           darken(@state-success-bg, 5%);
+
+@state-info-text:                #000;
+@state-info-bg:                  @brand-info;
+@state-info-border:              darken(@state-info-bg, 7%);
+
+@state-warning-text:             #000;
+@state-warning-bg:               @brand-warning;
+@state-warning-border:           darken(@state-warning-bg, 3%);
+
+@state-danger-text:              #000;
+@state-danger-bg:                @brand-danger;
+@state-danger-border:            darken(@state-danger-bg, 3%);
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+@tooltip-max-width:           200px;
+//** Tooltip text color
+@tooltip-color:               #fff;
+//** Tooltip background color
+@tooltip-bg:                  rgba(0,0,0,.9);
+@tooltip-opacity:             .9;
+
+//** Tooltip arrow width
+@tooltip-arrow-width:         5px;
+//** Tooltip arrow color
+@tooltip-arrow-color:         @tooltip-bg;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+@popover-bg:                          lighten(@body-bg, 10%);
+//** Popover maximum width
+@popover-max-width:                   276px;
+//** Popover border color
+@popover-border-color:                rgba(0,0,0,.2);
+//** Popover fallback border color
+@popover-fallback-border-color:       #999;
+
+//** Popover title background color
+@popover-title-bg:                    darken(@popover-bg, 3%);
+
+//** Popover arrow width
+@popover-arrow-width:                 10px;
+//** Popover arrow color
+@popover-arrow-color:                 @popover-bg;
+
+//** Popover outer arrow width
+@popover-arrow-outer-width:           (@popover-arrow-width + 1);
+//** Popover outer arrow color
+@popover-arrow-outer-color:           fadein(@popover-border-color, 5%);
+//** Popover outer arrow fallback color
+@popover-arrow-outer-fallback-color:  darken(@popover-fallback-border-color, 20%);
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+@label-default-bg:            @btn-default-bg;
+//** Primary label background color
+@label-primary-bg:            @brand-primary;
+//** Success label background color
+@label-success-bg:            @brand-success;
+//** Info label background color
+@label-info-bg:               @brand-info;
+//** Warning label background color
+@label-warning-bg:            @brand-warning;
+//** Danger label background color
+@label-danger-bg:             @brand-danger;
+
+//** Default label text color
+@label-color:                 #fff;
+//** Default text color of a linked label
+@label-link-hover-color:      #fff;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+@modal-inner-padding:         20px;
+
+//** Padding applied to the modal title
+@modal-title-padding:         15px;
+//** Modal title line-height
+@modal-title-line-height:     @line-height-base;
+
+//** Background color of modal content area
+@modal-content-bg:                             lighten(@body-bg, 10%);
+//** Modal content border color
+@modal-content-border-color:                   rgba(0,0,0,.2);
+//** Modal content border color **for IE8**
+@modal-content-fallback-border-color:          #999;
+
+//** Modal backdrop background color
+@modal-backdrop-bg:           #000;
+//** Modal backdrop opacity
+@modal-backdrop-opacity:      .5;
+//** Modal header border color
+@modal-header-border-color:   @gray-dark;
+//** Modal footer border color
+@modal-footer-border-color:   @modal-header-border-color;
+
+@modal-lg:                    900px;
+@modal-md:                    600px;
+@modal-sm:                    300px;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+@alert-padding:               15px;
+@alert-border-radius:         @border-radius-base;
+@alert-link-font-weight:      bold;
+
+@alert-success-bg:            @state-success-bg;
+@alert-success-text:          @state-success-text;
+@alert-success-border:        @state-success-border;
+
+@alert-info-bg:               @state-info-bg;
+@alert-info-text:             @state-info-text;
+@alert-info-border:           @state-info-border;
+
+@alert-warning-bg:            @state-warning-bg;
+@alert-warning-text:          @state-warning-text;
+@alert-warning-border:        @state-warning-border;
+
+@alert-danger-bg:             @state-danger-bg;
+@alert-danger-text:           @state-danger-text;
+@alert-danger-border:         @state-danger-border;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+@progress-bg:                 @gray-darker;
+//** Progress bar text color
+@progress-bar-color:          #fff;
+
+//** Default progress bar color
+@progress-bar-bg:             @brand-primary;
+//** Success progress bar color
+@progress-bar-success-bg:     @brand-success;
+//** Warning progress bar color
+@progress-bar-warning-bg:     @brand-warning;
+//** Danger progress bar color
+@progress-bar-danger-bg:      @brand-danger;
+//** Info progress bar color
+@progress-bar-info-bg:        @brand-info;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+@list-group-bg:                 @gray-darker;
+//** `.list-group-item` border color
+@list-group-border:             @gray-dark;
+//** List group border radius
+@list-group-border-radius:      @border-radius-base;
+
+//** Background color of single list items on hover
+@list-group-hover-bg:           lighten(@list-group-bg, 15%);
+//** Text color of active list items
+@list-group-active-color:       @component-active-color;
+//** Background color of active list items
+@list-group-active-bg:          @component-active-bg;
+//** Border color of active list elements
+@list-group-active-border:      @list-group-active-bg;
+//** Text color for content within active list items
+@list-group-active-text-color:  lighten(@list-group-active-bg, 40%);
+
+//** Text color of disabled list items
+@list-group-disabled-color:      @gray-light;
+//** Background color of disabled list items
+@list-group-disabled-bg:         @gray-lighter;
+//** Text color for content within disabled list items
+@list-group-disabled-text-color: @list-group-disabled-color;
+
+@list-group-link-color:         @text-color;
+@list-group-link-hover-color:   @list-group-link-color;
+@list-group-link-heading-color: #fff;
+
+
+//== Panels
+//
+//##
+
+@panel-bg:                    @gray-darker;
+@panel-body-padding:          15px;
+@panel-heading-padding:       10px 15px;
+@panel-footer-padding:        @panel-heading-padding;
+@panel-border-radius:         @border-radius-base;
+
+//** Border color for elements within panels
+@panel-inner-border:          @gray-dark;
+
+@panel-default-text:          @text-color;
+@panel-default-border:        @panel-inner-border;
+@panel-default-heading-bg:    lighten(@gray-darker, 10%);
+
+@panel-footer-bg:             @panel-default-heading-bg;
+
+@panel-primary-text:          #fff;
+@panel-primary-border:        @brand-primary;
+@panel-primary-heading-bg:    @brand-primary;
+
+@panel-success-text:          @state-success-text;
+@panel-success-border:        @state-success-border;
+@panel-success-heading-bg:    @state-success-bg;
+
+@panel-info-text:             @state-info-text;
+@panel-info-border:           @state-info-border;
+@panel-info-heading-bg:       @state-info-bg;
+
+@panel-warning-text:          @state-warning-text;
+@panel-warning-border:        @state-warning-border;
+@panel-warning-heading-bg:    @state-warning-bg;
+
+@panel-danger-text:           @state-danger-text;
+@panel-danger-border:         @state-danger-border;
+@panel-danger-heading-bg:     @state-danger-bg;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+@thumbnail-padding:           4px;
+//** Thumbnail background color
+@thumbnail-bg:                @gray-dark;
+//** Thumbnail border color
+@thumbnail-border:            @gray-dark;
+//** Thumbnail border radius
+@thumbnail-border-radius:     @border-radius-base;
+
+//** Custom text color for thumbnail captions
+@thumbnail-caption-color:     @text-color;
+//** Padding around the thumbnail caption
+@thumbnail-caption-padding:   9px;
+
+
+//== Wells
+//
+//##
+
+@well-bg:                     darken(@gray-darker, 5%);
+@well-border:                 darken(@well-bg, 7%);
+
+
+//== Badges
+//
+//##
+
+@badge-color:                 #000;
+//** Linked badge text color on hover
+@badge-link-hover-color:      #000;
+@badge-bg:                    @brand-primary;
+
+//** Badge text color in active nav link
+@badge-active-color:          @brand-primary;
+//** Badge background color in active nav link
+@badge-active-bg:             #fff;
+
+@badge-font-weight:           bold;
+@badge-line-height:           1;
+@badge-border-radius:         10px;
+
+
+//== Breadcrumbs
+//
+//##
+
+@breadcrumb-padding-vertical:   8px;
+@breadcrumb-padding-horizontal: 15px;
+//** Breadcrumb background color
+@breadcrumb-bg:                 @gray-darker;
+//** Breadcrumb text color
+@breadcrumb-color:              #fff;
+//** Text color of current page in the breadcrumb
+@breadcrumb-active-color:       @text-color;
+//** Textual separator for between breadcrumb elements
+@breadcrumb-separator:          "/";
+
+
+//== Carousel
+//
+//##
+
+@carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
+
+@carousel-control-color:                      #fff;
+@carousel-control-width:                      15%;
+@carousel-control-opacity:                    .5;
+@carousel-control-font-size:                  20px;
+
+@carousel-indicator-active-bg:                #fff;
+@carousel-indicator-border-color:             #fff;
+
+@carousel-caption-color:                      #fff;
+
+
+//== Close
+//
+//##
+
+@close-font-weight:           bold;
+@close-color:                 #000;
+@close-text-shadow:           0 1px 0 #fff;
+
+
+//== Code
+//
+//##
+
+@code-color:                  #c7254e;
+@code-bg:                     #f9f2f4;
+
+@kbd-color:                   #fff;
+@kbd-bg:                      #333;
+
+@pre-bg:                      #f5f5f5;
+@pre-color:                   @gray-dark;
+@pre-border-color:            #ccc;
+@pre-scrollable-max-height:   340px;
+
+
+//== Type
+//
+//##
+
+//** Horizontal offset for forms and lists.
+@component-offset-horizontal: 180px;
+//** Text muted color
+@text-muted:                  @gray-light;
+//** Abbreviations and acronyms border color
+@abbr-border-color:           @gray-light;
+//** Headings small color
+@headings-small-color:        @brand-primary;
+//** Blockquote small color
+@blockquote-small-color:      @gray;
+//** Blockquote font size
+@blockquote-font-size:        (@font-size-base * 1.25);
+//** Blockquote border color
+@blockquote-border-color:     @gray-dark;
+//** Page header border color
+@page-header-border-color:    @gray-dark;
+//** Width of horizontal description list titles
+@dl-horizontal-offset:        @component-offset-horizontal;
+//** Horizontal line color.
+@hr-border:                   @gray-dark;
+
+@import "base";
+
+.messages .text-danger {
+  color: #fff;
+}
+
+.messages .text-info {
+  color: #fff;
+}
+
+.messages .caret {
+  color: #fff;
+}
+
+// Cyborg 3.2.0
+// Bootswatch
+// -----------------------------------------------------
+
+// Navbar =====================================================================
+
+// Buttons ====================================================================
+
+// Typography =================================================================
+
+.text-primary,
+.text-primary:hover {
+  color: @brand-primary;
+}
+
+.text-success,
+.text-success:hover {
+  color: @brand-success;
+}
+
+.text-danger,
+.text-danger:hover {
+  color: @brand-danger;
+}
+
+.text-warning,
+.text-warning:hover {
+  color: @brand-warning;
+}
+
+.text-info,
+.text-info:hover {
+  color: @brand-info;
+}
+
+// Tables =====================================================================
+
+table,
+.table {
+  color: #fff;
+
+  a:not(.btn) {
+    color: #fff;
+    text-decoration: underline;
+  }
+
+  .text-muted {
+    color: @text-muted;
+  }
+}
+
+.table-responsive > .table {
+  background-color: @table-bg;
+}
+
+// Forms ======================================================================
+
+.has-warning {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-warning;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-warning;
+  }
+}
+
+.has-error {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-danger;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-danger;
+  }
+}
+
+.has-success {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-success;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-success;
+  }
+}
+
+.input-group-addon {
+  background-color: @btn-default-bg;
+}
+
+// Navs =======================================================================
+
+.nav-tabs,
+.nav-pills,
+.breadcrumb,
+.pager {
+
+  a {
+    color: #fff;
+  }
+}
+
+// Indicators =================================================================
+
+.alert {
+
+  .alert-link,
+  a {
+    color: @alert-warning-text;
+    text-decoration: underline;
+  }
+
+  .close {
+    text-decoration: none;
+  }
+}
+
+.close {
+  color: #fff;
+  text-decoration: none;
+  opacity: 0.4;
+
+  &:hover,
+  &:focus {
+    color: #fff;
+    opacity: 1;
+  }
+}
+
+// Progress bars ==============================================================
+
+// Containers =================================================================
+
+a.thumbnail:hover,
+a.thumbnail:focus,
+a.thumbnail.active {
+  border-color: @thumbnail-border;
+}
+
+.jumbotron {
+
+  h1, h2, h3, h4, h5, h6 {
+    color: #fff;
+  }
+}
+
+
+// Specials for cccamp19 design
+
+.navbar-brand {
+	.icon-icon_angel {
+	  background-color: @brand-primary;
+  }
+  
+  strong {
+    font-weight: lighter;
+    color: @brand-primary;    
+    text-shadow:
+      0 0 10px @brand-primary,
+      0 0 20px @brand-primary,
+      0 0 30px @brand-primary,
+      0 0 40px @brand-primary,
+      0 0 70px @brand-primary,
+      0 0 80px @brand-primary;
+  }
+}
+
+h1 {
+  font-weight: lighter;
+  color: @brand-primary;
+  text-shadow: 0 0 10px @brand-primary;
+  
+  .icon-icon_angel {
+    background-color: @brand-primary;
+  }
+}
+
+.panel-title {
+  color: #fff;
+}
diff --git a/resources/assets/themes/theme11.less b/resources/assets/themes/theme11.less
new file mode 100644
index 00000000..279f3e13
--- /dev/null
+++ b/resources/assets/themes/theme11.less
@@ -0,0 +1,1049 @@
+@import "../../../node_modules/bootstrap/less/variables";
+
+/*
+The MIT License (MIT)
+
+Copyright (c) 2013 Thomas Park
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+// Cyborg 3.2.0
+// Variables
+// --------------------------------------------------
+
+//== Colors
+//
+//## Gray and brand colors for use across Bootstrap.
+
+@gray-darker:            #222; // #222
+@gray-dark:              #282828;   // #333
+@gray:                   #555; // #555
+@gray-light:             #888;   // #999
+@gray-lighter:           #ADAFAE; // #eee
+
+@brand-primary:         #72abfa;
+@brand-success:         #5cb85c;
+@brand-info:            #5bc0de;
+@brand-warning:         #f0ad4e;
+@brand-danger:          #d9534f;
+
+
+//== Scaffolding
+//
+//## Settings for some of the most global styles.
+
+//** Background color for ``.
+@body-bg:               #000;
+//** Global text color on ``.
+@text-color:            #fff;
+
+//** Global textual link color.
+@link-color:            @brand-primary;
+//** Link hover color set via `darken()` function.
+@link-hover-color:      @link-color;
+
+
+//== Typography
+//
+//## Font, line-height, and color for body text, headings, and more.
+
+@font-family-serif:       Georgia, "Times New Roman", Times, serif;
+//** Default monospace fonts for ``, ``, and `
`.
+@font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
+@font-family-base:        @font-family-sans-serif;
+
+@font-size-base:          14px;
+@font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
+@font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
+
+@font-size-h1:            34px;
+@font-size-h2:            24px;
+@font-size-h3:            20px;
+@font-size-h4:            20px;
+@font-size-h5:            20px;
+@font-size-h6:            16px;
+
+//** Unit-less `line-height` for use in components like buttons.
+@line-height-base:        1.428571429; // 20/14
+//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
+@line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
+
+//** By default, this inherits from the ``.
+@headings-font-family:    @font-family-base;
+@headings-font-weight:    500;
+@headings-line-height:    1.1;
+@headings-color:          #fff;
+
+
+//== Iconography
+//
+//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+//** File name for all font files.
+@icon-font-name:          "glyphicons-halflings-regular";
+//** Element ID within SVG icon file.
+@icon-font-svg-id:        "glyphicons_halflingsregular";
+
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+
+@padding-base-vertical:     8px;
+@padding-base-horizontal:   12px;
+
+@padding-large-vertical:    14px;
+@padding-large-horizontal:  16px;
+
+@padding-small-vertical:    5px;
+@padding-small-horizontal:  10px;
+
+@padding-xs-vertical:       1px;
+@padding-xs-horizontal:     5px;
+
+@line-height-large:         1.33;
+@line-height-small:         1.5;
+
+@border-radius-base:        4px;
+@border-radius-large:       6px;
+@border-radius-small:       3px;
+
+//** Global color for active items (e.g., navs or dropdowns).
+@component-active-color:    #fff;
+//** Global background color for active items (e.g., navs or dropdowns).
+@component-active-bg:       @brand-primary;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+@caret-width-base:          4px;
+//** Carets increase slightly in size for larger components.
+@caret-width-large:         5px;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ``s.
+@table-cell-padding:            8px;
+//** Padding for cells in `.table-condensed`.
+@table-condensed-cell-padding:  5px;
+
+//** Default background color used for all tables.
+@table-bg:                      darken(@gray-darker, 4%);
+//** Background color used for `.table-striped`.
+@table-bg-accent:               darken(@table-bg, 6%);
+//** Background color used for `.table-hover`.
+@table-bg-hover:                @gray-dark;
+@table-bg-active:               @table-bg-hover;
+
+//** Border color for table and cell borders.
+@table-border-color:            @gray-dark;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+@btn-font-weight:                normal;
+
+@btn-default-color:              #fff;
+@btn-default-bg:                 lighten(@gray-dark, 10%);
+
+@btn-default-border:             darken(@btn-default-bg, 10%);
+
+@btn-primary-color:              #000;
+@btn-primary-bg:                 @brand-primary;
+@btn-primary-border:             darken(@btn-default-bg, 10%);
+
+@btn-success-color:              #000;
+@btn-success-bg:                 @brand-success;
+@btn-success-border:             darken(@btn-default-bg, 10%);
+
+@btn-info-color:                 #000;
+@btn-info-bg:                    @brand-info;
+@btn-info-border:                darken(@btn-default-bg, 10%);
+
+@btn-warning-color:              #000;
+@btn-warning-bg:                 @brand-warning;
+@btn-warning-border:             darken(@btn-default-bg, 10%);
+
+@btn-danger-color:               #000;
+@btn-danger-bg:                  @brand-danger;
+@btn-danger-border:              darken(@btn-default-bg, 10%);
+
+@btn-link-disabled-color:        @gray-light;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+@input-bg:                       @gray-darker;
+//** `` background color
+@input-bg-disabled:              @gray-lighter;
+
+//** Text color for ``s
+@input-color:                    @text-color;
+//** `` border color
+@input-border:                   @gray-dark;
+//** `` border radius
+@input-border-radius:            @border-radius-base;
+//** Border color for inputs on focus
+@input-border-focus:             #66afe9;
+
+//** Placeholder text color
+@input-color-placeholder:        @gray-light;
+
+//** Default `.form-control` height
+@input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);
+//** Large `.form-control` height
+@input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
+//** Small `.form-control` height
+@input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
+
+@legend-color:                   @text-color;
+@legend-border-color:            @gray-dark;
+
+//** Background color for textual input addons
+@input-group-addon-bg:           @gray-lighter;
+//** Border color for textual input addons
+@input-group-addon-border-color: @input-border;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+@dropdown-bg:                    @gray-darker;
+//** Dropdown menu `border-color`.
+@dropdown-border:                rgba(255,255,255,0.1);
+//** Dropdown menu `border-color` **for IE8**.
+@dropdown-fallback-border:       #444;
+//** Divider color for between dropdown items.
+@dropdown-divider-bg:            rgba(255,255,255,0.1);
+
+//** Dropdown link text color.
+@dropdown-link-color:            #fff;
+//** Hover color for dropdown links.
+@dropdown-link-hover-color:      #fff;
+//** Hover background for dropdown links.
+@dropdown-link-hover-bg:         @dropdown-link-active-bg;
+
+//** Active dropdown menu item text color.
+@dropdown-link-active-color:     #fff;
+//** Active dropdown menu item background color.
+@dropdown-link-active-bg:        @component-active-bg;
+
+//** Disabled dropdown menu item background color.
+@dropdown-link-disabled-color:   @text-muted;
+
+//** Text color for headers within dropdown menus.
+@dropdown-header-color:          @text-muted;
+
+//** Deprecated `@dropdown-caret-color` as of v3.1.0
+@dropdown-caret-color:           #000;
+
+
+//-- Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+//
+// Note: These variables are not generated into the Customizer.
+
+@zindex-navbar:            1000;
+@zindex-dropdown:          1000;
+@zindex-popover:           1060;
+@zindex-tooltip:           1070;
+@zindex-navbar-fixed:      1030;
+@zindex-modal-background:  1040;
+@zindex-modal:             1050;
+
+
+//== Media queries breakpoints
+//
+//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
+
+// Extra small screen / phone
+//** Deprecated `@screen-xs` as of v3.0.1
+@screen-xs:                  480px;
+//** Deprecated `@screen-xs-min` as of v3.2.0
+@screen-xs-min:              @screen-xs;
+//** Deprecated `@screen-phone` as of v3.0.1
+@screen-phone:               @screen-xs-min;
+
+// Small screen / tablet
+//** Deprecated `@screen-sm` as of v3.0.1
+@screen-sm:                  768px;
+@screen-sm-min:              @screen-sm;
+//** Deprecated `@screen-tablet` as of v3.0.1
+@screen-tablet:              @screen-sm-min;
+
+// Medium screen / desktop
+//** Deprecated `@screen-md` as of v3.0.1
+@screen-md:                  992px;
+@screen-md-min:              @screen-md;
+//** Deprecated `@screen-desktop` as of v3.0.1
+@screen-desktop:             @screen-md-min;
+
+// Large screen / wide desktop
+//** Deprecated `@screen-lg` as of v3.0.1
+@screen-lg:                  1200px;
+@screen-lg-min:              @screen-lg;
+//** Deprecated `@screen-lg-desktop` as of v3.0.1
+@screen-lg-desktop:          @screen-lg-min;
+
+// So media queries don't overlap when required, provide a maximum
+@screen-xs-max:              (@screen-sm-min - 1);
+@screen-sm-max:              (@screen-md-min - 1);
+@screen-md-max:              (@screen-lg-min - 1);
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+@grid-columns:              12;
+//** Padding between columns. Gets divided in half for the left and right.
+@grid-gutter-width:         30px;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+@grid-float-breakpoint:     @screen-sm-min;
+//** Point at which the navbar begins collapsing.
+@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+@container-tablet:             ((720px + @grid-gutter-width));
+//** For `@screen-sm-min` and up.
+@container-sm:                 @container-tablet;
+
+// Medium screen / desktop
+@container-desktop:            ((940px + @grid-gutter-width));
+//** For `@screen-md-min` and up.
+@container-md:                 @container-desktop;
+
+// Large screen / wide desktop
+@container-large-desktop:      ((1140px + @grid-gutter-width));
+//** For `@screen-lg-min` and up.
+@container-lg:                 @container-large-desktop;
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+@navbar-height:                    50px;
+@navbar-margin-bottom:             @line-height-computed;
+@navbar-border-radius:             @border-radius-base;
+@navbar-padding-horizontal:        floor((@grid-gutter-width / 2));
+@navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);
+@navbar-collapse-max-height:       340px;
+
+@navbar-default-color:             @text-color;
+@navbar-default-bg:                @body-bg;
+@navbar-default-border:            @gray-dark;
+
+// Navbar links
+@navbar-default-link-color:                @text-color;
+@navbar-default-link-hover-color:          #fff;
+@navbar-default-link-hover-bg:             transparent;
+@navbar-default-link-active-color:         #fff;
+@navbar-default-link-active-bg:            transparent;
+@navbar-default-link-disabled-color:       @gray-light;
+@navbar-default-link-disabled-bg:          transparent;
+
+// Navbar brand label
+@navbar-default-brand-color:               #fff;
+@navbar-default-brand-hover-color:         #fff;
+@navbar-default-brand-hover-bg:            transparent;
+
+// Navbar toggle
+@navbar-default-toggle-hover-bg:           @gray-dark;
+@navbar-default-toggle-icon-bar-bg:        #ccc;
+@navbar-default-toggle-border-color:       @gray-dark;
+
+
+// Inverted navbar
+// Reset inverted navbar basics
+@navbar-inverse-color:                      @gray-light;
+@navbar-inverse-bg:                         @gray-darker;
+@navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);
+
+// Inverted navbar links
+@navbar-inverse-link-color:                 @gray-light;
+@navbar-inverse-link-hover-color:           #fff;
+@navbar-inverse-link-hover-bg:              transparent;
+@navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;
+@navbar-inverse-link-active-bg:             transparent;
+@navbar-inverse-link-disabled-color:        #aaa;
+@navbar-inverse-link-disabled-bg:           transparent;
+
+// Inverted navbar brand label
+@navbar-inverse-brand-color:                #fff;
+@navbar-inverse-brand-hover-color:          #fff;
+@navbar-inverse-brand-hover-bg:             transparent;
+
+// Inverted navbar toggle
+@navbar-inverse-toggle-hover-bg:            #333;
+@navbar-inverse-toggle-icon-bar-bg:         #fff;
+@navbar-inverse-toggle-border-color:        #333;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+@nav-link-padding:                          10px 15px;
+@nav-link-hover-bg:                         @gray-darker;
+
+@nav-disabled-link-color:                   @gray-light;
+@nav-disabled-link-hover-color:             @gray-light;
+
+@nav-open-link-hover-color:                 @gray-darker;
+
+//== Tabs
+@nav-tabs-border-color:                     @gray-dark;
+
+@nav-tabs-link-hover-border-color:          transparent;
+
+@nav-tabs-active-link-hover-bg:             @brand-primary;
+@nav-tabs-active-link-hover-color:          #fff;
+@nav-tabs-active-link-hover-border-color:   @gray-dark;
+
+@nav-tabs-justified-link-border-color:            #ddd;
+@nav-tabs-justified-active-link-border-color:     @body-bg;
+
+//== Pills
+@nav-pills-border-radius:                   @border-radius-base;
+@nav-pills-active-link-hover-bg:            @component-active-bg;
+@nav-pills-active-link-hover-color:         @component-active-color;
+
+
+//== Pagination
+//
+//##
+
+@pagination-color:                     #fff;
+@pagination-bg:                        @gray-darker;
+@pagination-border:                    @gray-dark;
+
+@pagination-hover-color:               #fff;
+@pagination-hover-bg:                  @component-active-bg;
+@pagination-hover-border:              transparent;
+
+@pagination-active-color:              #000;
+@pagination-active-bg:                 @brand-primary;
+@pagination-active-border:             transparent;
+
+@pagination-disabled-color:            @gray-light;
+@pagination-disabled-bg:               @gray-darker;
+@pagination-disabled-border:           @gray-dark;
+
+
+//== Pager
+//
+//##
+
+@pager-bg:                             @pagination-bg;
+@pager-border:                         @pagination-border;
+@pager-border-radius:                  15px;
+
+@pager-hover-bg:                       @pagination-hover-bg;
+
+@pager-active-bg:                      @pagination-active-bg;
+@pager-active-color:                   @pagination-active-color;
+
+@pager-disabled-color:                 @gray-light;
+
+
+//== Jumbotron
+//
+//##
+
+@jumbotron-padding:              30px;
+@jumbotron-color:                inherit;
+@jumbotron-bg:                   darken(@gray-darker, 5%);
+@jumbotron-heading-color:        inherit;
+@jumbotron-font-size:            ceil((@font-size-base * 1.5));
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text:             #000;
+@state-success-bg:               @brand-success;
+@state-success-border:           darken(@state-success-bg, 5%);
+
+@state-info-text:                #000;
+@state-info-bg:                  @brand-info;
+@state-info-border:              darken(@state-info-bg, 7%);
+
+@state-warning-text:             #000;
+@state-warning-bg:               @brand-warning;
+@state-warning-border:           darken(@state-warning-bg, 3%);
+
+@state-danger-text:              #000;
+@state-danger-bg:                @brand-danger;
+@state-danger-border:            darken(@state-danger-bg, 3%);
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+@tooltip-max-width:           200px;
+//** Tooltip text color
+@tooltip-color:               #fff;
+//** Tooltip background color
+@tooltip-bg:                  rgba(0,0,0,.9);
+@tooltip-opacity:             .9;
+
+//** Tooltip arrow width
+@tooltip-arrow-width:         5px;
+//** Tooltip arrow color
+@tooltip-arrow-color:         @tooltip-bg;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+@popover-bg:                          lighten(@body-bg, 10%);
+//** Popover maximum width
+@popover-max-width:                   276px;
+//** Popover border color
+@popover-border-color:                rgba(0,0,0,.2);
+//** Popover fallback border color
+@popover-fallback-border-color:       #999;
+
+//** Popover title background color
+@popover-title-bg:                    darken(@popover-bg, 3%);
+
+//** Popover arrow width
+@popover-arrow-width:                 10px;
+//** Popover arrow color
+@popover-arrow-color:                 @popover-bg;
+
+//** Popover outer arrow width
+@popover-arrow-outer-width:           (@popover-arrow-width + 1);
+//** Popover outer arrow color
+@popover-arrow-outer-color:           fadein(@popover-border-color, 5%);
+//** Popover outer arrow fallback color
+@popover-arrow-outer-fallback-color:  darken(@popover-fallback-border-color, 20%);
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+@label-default-bg:            lighten(@btn-default-bg, 20%);
+//** Primary label background color
+@label-primary-bg:            @brand-primary;
+//** Success label background color
+@label-success-bg:            @brand-success;
+//** Info label background color
+@label-info-bg:               @brand-info;
+//** Warning label background color
+@label-warning-bg:            @brand-warning;
+//** Danger label background color
+@label-danger-bg:             @brand-danger;
+
+//** Default label text color
+@label-color:                 #000;
+//** Default text color of a linked label
+@label-link-hover-color:      #000;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+@modal-inner-padding:         20px;
+
+//** Padding applied to the modal title
+@modal-title-padding:         15px;
+//** Modal title line-height
+@modal-title-line-height:     @line-height-base;
+
+//** Background color of modal content area
+@modal-content-bg:                             lighten(@body-bg, 10%);
+//** Modal content border color
+@modal-content-border-color:                   rgba(0,0,0,.2);
+//** Modal content border color **for IE8**
+@modal-content-fallback-border-color:          #999;
+
+//** Modal backdrop background color
+@modal-backdrop-bg:           #000;
+//** Modal backdrop opacity
+@modal-backdrop-opacity:      .5;
+//** Modal header border color
+@modal-header-border-color:   @gray-dark;
+//** Modal footer border color
+@modal-footer-border-color:   @modal-header-border-color;
+
+@modal-lg:                    900px;
+@modal-md:                    600px;
+@modal-sm:                    300px;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+@alert-padding:               15px;
+@alert-border-radius:         @border-radius-base;
+@alert-link-font-weight:      bold;
+
+@alert-success-bg:            @state-success-bg;
+@alert-success-text:          @state-success-text;
+@alert-success-border:        @state-success-border;
+
+@alert-info-bg:               @state-info-bg;
+@alert-info-text:             @state-info-text;
+@alert-info-border:           @state-info-border;
+
+@alert-warning-bg:            @state-warning-bg;
+@alert-warning-text:          @state-warning-text;
+@alert-warning-border:        @state-warning-border;
+
+@alert-danger-bg:             @state-danger-bg;
+@alert-danger-text:           @state-danger-text;
+@alert-danger-border:         @state-danger-border;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+@progress-bg:                 @gray-darker;
+//** Progress bar text color
+@progress-bar-color:          #fff;
+
+//** Default progress bar color
+@progress-bar-bg:             @brand-primary;
+//** Success progress bar color
+@progress-bar-success-bg:     @brand-success;
+//** Warning progress bar color
+@progress-bar-warning-bg:     @brand-warning;
+//** Danger progress bar color
+@progress-bar-danger-bg:      @brand-danger;
+//** Info progress bar color
+@progress-bar-info-bg:        @brand-info;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+@list-group-bg:                 @gray-darker;
+//** `.list-group-item` border color
+@list-group-border:             @gray-dark;
+//** List group border radius
+@list-group-border-radius:      @border-radius-base;
+
+//** Background color of single list items on hover
+@list-group-hover-bg:           lighten(@list-group-bg, 15%);
+//** Text color of active list items
+@list-group-active-color:       @component-active-color;
+//** Background color of active list items
+@list-group-active-bg:          @component-active-bg;
+//** Border color of active list elements
+@list-group-active-border:      @list-group-active-bg;
+//** Text color for content within active list items
+@list-group-active-text-color:  lighten(@list-group-active-bg, 40%);
+
+//** Text color of disabled list items
+@list-group-disabled-color:      @gray-light;
+//** Background color of disabled list items
+@list-group-disabled-bg:         @gray-lighter;
+//** Text color for content within disabled list items
+@list-group-disabled-text-color: @list-group-disabled-color;
+
+@list-group-link-color:         @text-color;
+@list-group-link-hover-color:   @list-group-link-color;
+@list-group-link-heading-color: #fff;
+
+
+//== Panels
+//
+//##
+
+@panel-bg:                    @gray-darker;
+@panel-body-padding:          15px;
+@panel-heading-padding:       10px 15px;
+@panel-footer-padding:        @panel-heading-padding;
+@panel-border-radius:         @border-radius-base;
+
+//** Border color for elements within panels
+@panel-inner-border:          @gray-dark;
+
+@panel-default-text:          @text-color;
+@panel-default-border:        @panel-inner-border;
+@panel-default-heading-bg:    lighten(@gray-darker, 10%);
+
+@panel-footer-bg:             @panel-default-heading-bg;
+
+@panel-primary-text:          #fff;
+@panel-primary-border:        @brand-primary;
+@panel-primary-heading-bg:    @brand-primary;
+
+@panel-success-text:          @state-success-text;
+@panel-success-border:        @state-success-border;
+@panel-success-heading-bg:    @state-success-bg;
+
+@panel-info-text:             @state-info-text;
+@panel-info-border:           @state-info-border;
+@panel-info-heading-bg:       @state-info-bg;
+
+@panel-warning-text:          @state-warning-text;
+@panel-warning-border:        @state-warning-border;
+@panel-warning-heading-bg:    @state-warning-bg;
+
+@panel-danger-text:           @state-danger-text;
+@panel-danger-border:         @state-danger-border;
+@panel-danger-heading-bg:     @state-danger-bg;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+@thumbnail-padding:           4px;
+//** Thumbnail background color
+@thumbnail-bg:                @gray-dark;
+//** Thumbnail border color
+@thumbnail-border:            @gray-dark;
+//** Thumbnail border radius
+@thumbnail-border-radius:     @border-radius-base;
+
+//** Custom text color for thumbnail captions
+@thumbnail-caption-color:     @text-color;
+//** Padding around the thumbnail caption
+@thumbnail-caption-padding:   9px;
+
+
+//== Wells
+//
+//##
+
+@well-bg:                     darken(@gray-darker, 5%);
+@well-border:                 darken(@well-bg, 7%);
+
+
+//== Badges
+//
+//##
+
+@badge-color:                 #000;
+//** Linked badge text color on hover
+@badge-link-hover-color:      #fff;
+@badge-bg:                    @brand-primary;
+
+//** Badge text color in active nav link
+@badge-active-color:          @brand-primary;
+//** Badge background color in active nav link
+@badge-active-bg:             #fff;
+
+@badge-font-weight:           bold;
+@badge-line-height:           1;
+@badge-border-radius:         10px;
+
+
+//== Breadcrumbs
+//
+//##
+
+@breadcrumb-padding-vertical:   8px;
+@breadcrumb-padding-horizontal: 15px;
+//** Breadcrumb background color
+@breadcrumb-bg:                 @gray-darker;
+//** Breadcrumb text color
+@breadcrumb-color:              #fff;
+//** Text color of current page in the breadcrumb
+@breadcrumb-active-color:       @text-color;
+//** Textual separator for between breadcrumb elements
+@breadcrumb-separator:          "/";
+
+
+//== Carousel
+//
+//##
+
+@carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
+
+@carousel-control-color:                      #fff;
+@carousel-control-width:                      15%;
+@carousel-control-opacity:                    .5;
+@carousel-control-font-size:                  20px;
+
+@carousel-indicator-active-bg:                #fff;
+@carousel-indicator-border-color:             #fff;
+
+@carousel-caption-color:                      #fff;
+
+
+//== Close
+//
+//##
+
+@close-font-weight:           bold;
+@close-color:                 #000;
+@close-text-shadow:           0 1px 0 #fff;
+
+
+//== Code
+//
+//##
+
+@code-color:                  #c7254e;
+@code-bg:                     #f9f2f4;
+
+@kbd-color:                   #fff;
+@kbd-bg:                      #333;
+
+@pre-bg:                      #f5f5f5;
+@pre-color:                   @gray-dark;
+@pre-border-color:            #ccc;
+@pre-scrollable-max-height:   340px;
+
+
+//== Type
+//
+//##
+
+//** Horizontal offset for forms and lists.
+@component-offset-horizontal: 180px;
+//** Text muted color
+@text-muted:                  @gray-lighter;
+//** Abbreviations and acronyms border color
+@abbr-border-color:           @gray-lighter;
+//** Headings small color
+@headings-small-color:        @gray-lighter;
+//** Blockquote small color
+@blockquote-small-color:      @gray;
+//** Blockquote font size
+@blockquote-font-size:        (@font-size-base * 1.25);
+//** Blockquote border color
+@blockquote-border-color:     @gray-dark;
+//** Page header border color
+@page-header-border-color:    @gray-dark;
+//** Width of horizontal description list titles
+@dl-horizontal-offset:        @component-offset-horizontal;
+//** Horizontal line color.
+@hr-border:                   @gray-dark;
+
+@import "base";
+
+.messages .text-danger {
+  color: #fff;
+}
+
+.messages .text-info {
+  color: #fff;
+}
+
+.messages .caret {
+  color: #fff;
+}
+
+// Cyborg 3.2.0
+// Bootswatch
+// -----------------------------------------------------
+
+// Navbar =====================================================================
+
+// Buttons ====================================================================
+
+// Typography =================================================================
+
+.text-primary,
+.text-primary:hover {
+  color: @brand-primary;
+}
+
+.text-success,
+.text-success:hover {
+  color: @brand-success;
+}
+
+.text-danger,
+.text-danger:hover {
+  color: lighten(@brand-danger, 10%);
+}
+
+.text-warning,
+.text-warning:hover {
+  color: @brand-warning;
+}
+
+.text-info,
+.text-info:hover {
+  color: @brand-info;
+}
+
+// Tables =====================================================================
+
+table,
+.table {
+  color: #fff;
+
+  a:not(.btn) {
+    color: #fff;
+    text-decoration: underline;
+  }
+
+  .text-muted {
+    color: @text-muted;
+  }
+}
+
+.table-responsive > .table {
+  background-color: @table-bg;
+}
+
+// Forms ======================================================================
+
+.has-warning {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-warning;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-warning;
+  }
+}
+
+.has-error {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-danger;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-danger;
+  }
+}
+
+.has-success {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-success;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-success;
+  }
+}
+
+.legend {
+  margin-top: 20px;
+}
+
+.input-group-addon {
+  background-color: @btn-default-bg;
+}
+
+// Navs =======================================================================
+
+.nav-tabs,
+.nav-pills,
+.breadcrumb,
+.pager {
+
+  a {
+    color: #fff;
+  }
+}
+
+// Indicators =================================================================
+
+.alert {
+
+  .alert-link,
+  a {
+    color: @alert-warning-text;
+    text-decoration: underline;
+  }
+
+  .close {
+    text-decoration: none;
+  }
+}
+
+.close {
+  color: #fff;
+  text-decoration: none;
+  opacity: 0.4;
+
+  &:hover,
+  &:focus {
+    color: #fff;
+    opacity: 1;
+  }
+}
+
+// Progress bars ==============================================================
+
+// Containers =================================================================
+
+a.thumbnail:hover,
+a.thumbnail:focus,
+a.thumbnail.active {
+  border-color: @thumbnail-border;
+}
+
+.jumbotron {
+
+  h1, h2, h3, h4, h5, h6 {
+    color: #fff;
+  }
+}
+
+.panel-info .panel-title {
+	color: #000;
+}
diff --git a/resources/assets/themes/theme4.less b/resources/assets/themes/theme4.less
index d0a5f948..d8e0276f 100644
--- a/resources/assets/themes/theme4.less
+++ b/resources/assets/themes/theme4.less
@@ -980,10 +980,6 @@ table,
   }
 }
 
-legend {
-  color: #fff;
-}
-
 .input-group-addon {
   background-color: @btn-default-bg;
 }
diff --git a/resources/assets/themes/theme6.less b/resources/assets/themes/theme6.less
index 71dcbfd8..ed35c12b 100644
--- a/resources/assets/themes/theme6.less
+++ b/resources/assets/themes/theme6.less
@@ -993,10 +993,6 @@ table,
   }
 }
 
-legend {
-  color: #fff;
-}
-
 .input-group-addon {
   background-color: @btn-default-bg;
 }
diff --git a/resources/assets/themes/theme7.less b/resources/assets/themes/theme7.less
index 26afeb8c..fa01a856 100644
--- a/resources/assets/themes/theme7.less
+++ b/resources/assets/themes/theme7.less
@@ -993,10 +993,6 @@ table,
   }
 }
 
-legend {
-  color: @gray-darker;
-}
-
 .input-group-addon {
   background-color: @btn-default-bg;
 }
diff --git a/resources/assets/themes/theme8.less b/resources/assets/themes/theme8.less
index 22887dab..b6d2d5c3 100644
--- a/resources/assets/themes/theme8.less
+++ b/resources/assets/themes/theme8.less
@@ -40,7 +40,7 @@ THE SOFTWARE.
 
 @brand-primary:         #0076ba;
 @brand-success:         #99ba00;
-@brand-info:            #ffc600;
+@brand-info:            #0076ba;
 @brand-warning:         #ffc600;
 @brand-danger:          #d9534f;
 
@@ -164,7 +164,7 @@ THE SOFTWARE.
 @btn-font-weight:                normal;
 
 @btn-default-color:              #000;
-@btn-default-bg:                 @gray;
+@btn-default-bg:                 @gray-light;
 
 @btn-default-border:             darken(@btn-default-bg, 10%);
 
@@ -454,11 +454,11 @@ THE SOFTWARE.
 @pagination-bg:                        @gray-darker;
 @pagination-border:                    @gray-dark;
 
-@pagination-hover-color:               #fff;
+@pagination-hover-color:               #000;
 @pagination-hover-bg:                  @component-active-bg;
 @pagination-hover-border:              transparent;
 
-@pagination-active-color:              #fff;
+@pagination-active-color:              #000;
 @pagination-active-bg:                 @brand-primary;
 @pagination-active-border:             transparent;
 
@@ -769,9 +769,9 @@ THE SOFTWARE.
 //
 //##
 
-@badge-color:                 #fff;
+@badge-color:                 #000;
 //** Linked badge text color on hover
-@badge-link-hover-color:      #fff;
+@badge-link-hover-color:      #000;
 @badge-bg:                    @brand-primary;
 
 //** Badge text color in active nav link
@@ -853,7 +853,7 @@ THE SOFTWARE.
 //** Abbreviations and acronyms border color
 @abbr-border-color:           @gray-light;
 //** Headings small color
-@headings-small-color:        @gray-light;
+@headings-small-color:        @brand-primary;
 //** Blockquote small color
 @blockquote-small-color:      @gray;
 //** Blockquote font size
@@ -980,10 +980,6 @@ table,
   }
 }
 
-legend {
-  color: #fff;
-}
-
 .input-group-addon {
   background-color: @btn-default-bg;
 }
@@ -1054,8 +1050,14 @@ a.thumbnail.active {
   
   strong {
     font-weight: lighter;
-    color: @brand-primary;
-    text-shadow: 0 0 10px @brand-primary;
+    color: @brand-primary;  
+    text-shadow:
+      0 0 10px @brand-primary,
+      0 0 20px @brand-primary,
+      0 0 30px @brand-primary,
+      0 0 40px @brand-primary,
+      0 0 70px @brand-primary,
+      0 0 80px @brand-primary;
   }
 }
 
@@ -1063,4 +1065,12 @@ h1 {
   font-weight: lighter;
   color: @brand-primary;
   text-shadow: 0 0 10px @brand-primary;
+  
+  .icon-icon_angel {
+    background-color: @brand-primary;
+  }
+}
+
+.panel-title {
+	color: #fff;
 }
diff --git a/resources/assets/themes/theme9.less b/resources/assets/themes/theme9.less
new file mode 100644
index 00000000..da2713fb
--- /dev/null
+++ b/resources/assets/themes/theme9.less
@@ -0,0 +1,1076 @@
+@import "../../../node_modules/bootstrap/less/variables";
+
+/*
+The MIT License (MIT)
+
+Copyright (c) 2013 Thomas Park
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+*/
+
+// Cyborg 3.2.0
+// Variables
+// --------------------------------------------------
+
+//== Colors
+//
+//## Gray and brand colors for use across Bootstrap.
+
+@gray-darker:            #222; // #222
+@gray-dark:              #282828;   // #333
+@gray:                   #555; // #555
+@gray-light:             #888;   // #999
+@gray-lighter:           #ADAFAE; // #eee
+
+@brand-primary:         #ffc600;
+@brand-success:         #99ba00;
+@brand-info:            #0076ba;
+@brand-warning:         #ffc600;
+@brand-danger:          #d9534f;
+
+
+//== Scaffolding
+//
+//## Settings for some of the most global styles.
+
+//** Background color for ``.
+@body-bg:               #060606;
+//** Global text color on ``.
+@text-color:            @gray-light;
+
+//** Global textual link color.
+@link-color:            @brand-primary;
+//** Link hover color set via `darken()` function.
+@link-hover-color:      @link-color;
+
+
+//== Typography
+//
+//## Font, line-height, and color for body text, headings, and more.
+
+@font-family-serif:       Georgia, "Times New Roman", Times, serif;
+//** Default monospace fonts for ``, ``, and `
`.
+@font-family-monospace:   Menlo, Monaco, Consolas, "Courier New", monospace;
+@font-family-base:        @font-family-sans-serif;
+
+@font-size-base:          14px;
+@font-size-large:         ceil((@font-size-base * 1.25)); // ~18px
+@font-size-small:         ceil((@font-size-base * 0.85)); // ~12px
+
+@font-size-h1:            34px;
+@font-size-h2:            24px;
+@font-size-h3:            20px;
+@font-size-h4:            20px;
+@font-size-h5:            20px;
+@font-size-h6:            16px;
+
+//** Unit-less `line-height` for use in components like buttons.
+@line-height-base:        1.428571429; // 20/14
+//** Computed "line-height" (`font-size` * `line-height`) for use with `margin`, `padding`, etc.
+@line-height-computed:    floor((@font-size-base * @line-height-base)); // ~20px
+
+//** By default, this inherits from the ``.
+@headings-font-family:    @font-family-base;
+@headings-font-weight:    500;
+@headings-line-height:    1.1;
+@headings-color:          #fff;
+
+
+//== Iconography
+//
+//## Specify custom location and filename of the included Glyphicons icon font. Useful for those including Bootstrap via Bower.
+
+//** File name for all font files.
+@icon-font-name:          "glyphicons-halflings-regular";
+//** Element ID within SVG icon file.
+@icon-font-svg-id:        "glyphicons_halflingsregular";
+
+
+//== Components
+//
+//## Define common padding and border radius sizes and more. Values based on 14px text and 1.428 line-height (~20px to start).
+
+@padding-base-vertical:     8px;
+@padding-base-horizontal:   12px;
+
+@padding-large-vertical:    14px;
+@padding-large-horizontal:  16px;
+
+@padding-small-vertical:    5px;
+@padding-small-horizontal:  10px;
+
+@padding-xs-vertical:       1px;
+@padding-xs-horizontal:     5px;
+
+@line-height-large:         1.33;
+@line-height-small:         1.5;
+
+@border-radius-base:        4px;
+@border-radius-large:       6px;
+@border-radius-small:       3px;
+
+//** Global color for active items (e.g., navs or dropdowns).
+@component-active-color:    #fff;
+//** Global background color for active items (e.g., navs or dropdowns).
+@component-active-bg:       @brand-primary;
+
+//** Width of the `border` for generating carets that indicator dropdowns.
+@caret-width-base:          4px;
+//** Carets increase slightly in size for larger components.
+@caret-width-large:         5px;
+
+
+//== Tables
+//
+//## Customizes the `.table` component with basic values, each used across all table variations.
+
+//** Padding for ``s and ``s.
+@table-cell-padding:            8px;
+//** Padding for cells in `.table-condensed`.
+@table-condensed-cell-padding:  5px;
+
+//** Default background color used for all tables.
+@table-bg:                      darken(@gray-darker, 4%);
+//** Background color used for `.table-striped`.
+@table-bg-accent:               darken(@table-bg, 6%);
+//** Background color used for `.table-hover`.
+@table-bg-hover:                @gray-dark;
+@table-bg-active:               @table-bg-hover;
+
+//** Border color for table and cell borders.
+@table-border-color:            @gray-dark;
+
+
+//== Buttons
+//
+//## For each of Bootstrap's buttons, define text, background and border color.
+
+@btn-font-weight:                normal;
+
+@btn-default-color:              #000;
+@btn-default-bg:                 @gray-light;
+
+@btn-default-border:             darken(@btn-default-bg, 10%);
+
+@btn-primary-color:              @btn-default-color;
+@btn-primary-bg:                 @brand-primary;
+@btn-primary-border:             darken(@btn-default-bg, 10%);
+
+@btn-success-color:              @btn-default-color;
+@btn-success-bg:                 @brand-success;
+@btn-success-border:             darken(@btn-default-bg, 10%);
+
+@btn-info-color:                 @btn-default-color;
+@btn-info-bg:                    @brand-info;
+@btn-info-border:                darken(@btn-default-bg, 10%);
+
+@btn-warning-color:              @btn-default-color;
+@btn-warning-bg:                 @brand-warning;
+@btn-warning-border:             darken(@btn-default-bg, 10%);
+
+@btn-danger-color:               @btn-default-color;
+@btn-danger-bg:                  @brand-danger;
+@btn-danger-border:              darken(@btn-default-bg, 10%);
+
+@btn-link-disabled-color:        @gray-light;
+
+
+//== Forms
+//
+//##
+
+//** `` background color
+@input-bg:                       @gray-darker;
+//** `` background color
+@input-bg-disabled:              @gray-dark;
+
+//** Text color for ``s
+@input-color:                    @text-color;
+//** `` border color
+@input-border:                   @gray-dark;
+//** `` border radius
+@input-border-radius:            @border-radius-base;
+//** Border color for inputs on focus
+@input-border-focus:             #66afe9;
+
+//** Placeholder text color
+@input-color-placeholder:        @gray-light;
+
+//** Default `.form-control` height
+@input-height-base:              (@line-height-computed + (@padding-base-vertical * 2) + 2);
+//** Large `.form-control` height
+@input-height-large:             (ceil(@font-size-large * @line-height-large) + (@padding-large-vertical * 2) + 2);
+//** Small `.form-control` height
+@input-height-small:             (floor(@font-size-small * @line-height-small) + (@padding-small-vertical * 2) + 2);
+
+@legend-color:                   @text-color;
+@legend-border-color:            @gray-dark;
+
+//** Background color for textual input addons
+@input-group-addon-bg:           @gray-lighter;
+//** Border color for textual input addons
+@input-group-addon-border-color: @input-border;
+
+
+//== Dropdowns
+//
+//## Dropdown menu container and contents.
+
+//** Background for the dropdown menu.
+@dropdown-bg:                    @gray-darker;
+//** Dropdown menu `border-color`.
+@dropdown-border:                rgba(255,255,255,0.1);
+//** Dropdown menu `border-color` **for IE8**.
+@dropdown-fallback-border:       #444;
+//** Divider color for between dropdown items.
+@dropdown-divider-bg:            rgba(255,255,255,0.1);
+
+//** Dropdown link text color.
+@dropdown-link-color:            #fff;
+//** Hover color for dropdown links.
+@dropdown-link-hover-color:      #fff;
+//** Hover background for dropdown links.
+@dropdown-link-hover-bg:         @dropdown-link-active-bg;
+
+//** Active dropdown menu item text color.
+@dropdown-link-active-color:     #fff;
+//** Active dropdown menu item background color.
+@dropdown-link-active-bg:        @component-active-bg;
+
+//** Disabled dropdown menu item background color.
+@dropdown-link-disabled-color:   @text-muted;
+
+//** Text color for headers within dropdown menus.
+@dropdown-header-color:          @text-muted;
+
+//** Deprecated `@dropdown-caret-color` as of v3.1.0
+@dropdown-caret-color:           #000;
+
+
+//-- Z-index master list
+//
+// Warning: Avoid customizing these values. They're used for a bird's eye view
+// of components dependent on the z-axis and are designed to all work together.
+//
+// Note: These variables are not generated into the Customizer.
+
+@zindex-navbar:            1000;
+@zindex-dropdown:          1000;
+@zindex-popover:           1060;
+@zindex-tooltip:           1070;
+@zindex-navbar-fixed:      1030;
+@zindex-modal-background:  1040;
+@zindex-modal:             1050;
+
+
+//== Media queries breakpoints
+//
+//## Define the breakpoints at which your layout will change, adapting to different screen sizes.
+
+// Extra small screen / phone
+//** Deprecated `@screen-xs` as of v3.0.1
+@screen-xs:                  480px;
+//** Deprecated `@screen-xs-min` as of v3.2.0
+@screen-xs-min:              @screen-xs;
+//** Deprecated `@screen-phone` as of v3.0.1
+@screen-phone:               @screen-xs-min;
+
+// Small screen / tablet
+//** Deprecated `@screen-sm` as of v3.0.1
+@screen-sm:                  768px;
+@screen-sm-min:              @screen-sm;
+//** Deprecated `@screen-tablet` as of v3.0.1
+@screen-tablet:              @screen-sm-min;
+
+// Medium screen / desktop
+//** Deprecated `@screen-md` as of v3.0.1
+@screen-md:                  992px;
+@screen-md-min:              @screen-md;
+//** Deprecated `@screen-desktop` as of v3.0.1
+@screen-desktop:             @screen-md-min;
+
+// Large screen / wide desktop
+//** Deprecated `@screen-lg` as of v3.0.1
+@screen-lg:                  1200px;
+@screen-lg-min:              @screen-lg;
+//** Deprecated `@screen-lg-desktop` as of v3.0.1
+@screen-lg-desktop:          @screen-lg-min;
+
+// So media queries don't overlap when required, provide a maximum
+@screen-xs-max:              (@screen-sm-min - 1);
+@screen-sm-max:              (@screen-md-min - 1);
+@screen-md-max:              (@screen-lg-min - 1);
+
+
+//== Grid system
+//
+//## Define your custom responsive grid.
+
+//** Number of columns in the grid.
+@grid-columns:              12;
+//** Padding between columns. Gets divided in half for the left and right.
+@grid-gutter-width:         30px;
+// Navbar collapse
+//** Point at which the navbar becomes uncollapsed.
+@grid-float-breakpoint:     @screen-sm-min;
+//** Point at which the navbar begins collapsing.
+@grid-float-breakpoint-max: (@grid-float-breakpoint - 1);
+
+
+//== Container sizes
+//
+//## Define the maximum width of `.container` for different screen sizes.
+
+// Small screen / tablet
+@container-tablet:             ((720px + @grid-gutter-width));
+//** For `@screen-sm-min` and up.
+@container-sm:                 @container-tablet;
+
+// Medium screen / desktop
+@container-desktop:            ((940px + @grid-gutter-width));
+//** For `@screen-md-min` and up.
+@container-md:                 @container-desktop;
+
+// Large screen / wide desktop
+@container-large-desktop:      ((1140px + @grid-gutter-width));
+//** For `@screen-lg-min` and up.
+@container-lg:                 @container-large-desktop;
+
+
+//== Navbar
+//
+//##
+
+// Basics of a navbar
+@navbar-height:                    50px;
+@navbar-margin-bottom:             @line-height-computed;
+@navbar-border-radius:             @border-radius-base;
+@navbar-padding-horizontal:        floor((@grid-gutter-width / 2));
+@navbar-padding-vertical:          ((@navbar-height - @line-height-computed) / 2);
+@navbar-collapse-max-height:       340px;
+
+@navbar-default-color:             @text-color;
+@navbar-default-bg:                @body-bg;
+@navbar-default-border:            @gray-dark;
+
+// Navbar links
+@navbar-default-link-color:                @text-color;
+@navbar-default-link-hover-color:          #fff;
+@navbar-default-link-hover-bg:             transparent;
+@navbar-default-link-active-color:         #fff;
+@navbar-default-link-active-bg:            transparent;
+@navbar-default-link-disabled-color:       @gray-light;
+@navbar-default-link-disabled-bg:          transparent;
+
+// Navbar brand label
+@navbar-default-brand-color:               #fff;
+@navbar-default-brand-hover-color:         #fff;
+@navbar-default-brand-hover-bg:            transparent;
+
+// Navbar toggle
+@navbar-default-toggle-hover-bg:           @gray-dark;
+@navbar-default-toggle-icon-bar-bg:        #ccc;
+@navbar-default-toggle-border-color:       @gray-dark;
+
+
+// Inverted navbar
+// Reset inverted navbar basics
+@navbar-inverse-color:                      @gray-light;
+@navbar-inverse-bg:                         @gray-darker;
+@navbar-inverse-border:                     darken(@navbar-inverse-bg, 10%);
+
+// Inverted navbar links
+@navbar-inverse-link-color:                 @gray-light;
+@navbar-inverse-link-hover-color:           #fff;
+@navbar-inverse-link-hover-bg:              transparent;
+@navbar-inverse-link-active-color:          @navbar-inverse-link-hover-color;
+@navbar-inverse-link-active-bg:             transparent;
+@navbar-inverse-link-disabled-color:        #aaa;
+@navbar-inverse-link-disabled-bg:           transparent;
+
+// Inverted navbar brand label
+@navbar-inverse-brand-color:                #fff;
+@navbar-inverse-brand-hover-color:          #fff;
+@navbar-inverse-brand-hover-bg:             transparent;
+
+// Inverted navbar toggle
+@navbar-inverse-toggle-hover-bg:            #333;
+@navbar-inverse-toggle-icon-bar-bg:         #fff;
+@navbar-inverse-toggle-border-color:        #333;
+
+
+//== Navs
+//
+//##
+
+//=== Shared nav styles
+@nav-link-padding:                          10px 15px;
+@nav-link-hover-bg:                         @gray-darker;
+
+@nav-disabled-link-color:                   @gray-light;
+@nav-disabled-link-hover-color:             @gray-light;
+
+@nav-open-link-hover-color:                 @gray-darker;
+
+//== Tabs
+@nav-tabs-border-color:                     @gray-dark;
+
+@nav-tabs-link-hover-border-color:          transparent;
+
+@nav-tabs-active-link-hover-bg:             @brand-primary;
+@nav-tabs-active-link-hover-color:          #fff;
+@nav-tabs-active-link-hover-border-color:   @gray-dark;
+
+@nav-tabs-justified-link-border-color:            #ddd;
+@nav-tabs-justified-active-link-border-color:     @body-bg;
+
+//== Pills
+@nav-pills-border-radius:                   @border-radius-base;
+@nav-pills-active-link-hover-bg:            @component-active-bg;
+@nav-pills-active-link-hover-color:         @component-active-color;
+
+
+//== Pagination
+//
+//##
+
+@pagination-color:                     #fff;
+@pagination-bg:                        @gray-darker;
+@pagination-border:                    @gray-dark;
+
+@pagination-hover-color:               #000;
+@pagination-hover-bg:                  @component-active-bg;
+@pagination-hover-border:              transparent;
+
+@pagination-active-color:              #000;
+@pagination-active-bg:                 @brand-primary;
+@pagination-active-border:             transparent;
+
+@pagination-disabled-color:            @gray-light;
+@pagination-disabled-bg:               @gray-darker;
+@pagination-disabled-border:           @gray-dark;
+
+
+//== Pager
+//
+//##
+
+@pager-bg:                             @pagination-bg;
+@pager-border:                         @pagination-border;
+@pager-border-radius:                  15px;
+
+@pager-hover-bg:                       @pagination-hover-bg;
+
+@pager-active-bg:                      @pagination-active-bg;
+@pager-active-color:                   @pagination-active-color;
+
+@pager-disabled-color:                 @gray-light;
+
+
+//== Jumbotron
+//
+//##
+
+@jumbotron-padding:              30px;
+@jumbotron-color:                inherit;
+@jumbotron-bg:                   darken(@gray-darker, 5%);
+@jumbotron-heading-color:        inherit;
+@jumbotron-font-size:            ceil((@font-size-base * 1.5));
+
+
+//== Form states and alerts
+//
+//## Define colors for form feedback states and, by default, alerts.
+
+@state-success-text:             #000;
+@state-success-bg:               @brand-success;
+@state-success-border:           darken(@state-success-bg, 5%);
+
+@state-info-text:                #000;
+@state-info-bg:                  @brand-info;
+@state-info-border:              darken(@state-info-bg, 7%);
+
+@state-warning-text:             #000;
+@state-warning-bg:               @brand-warning;
+@state-warning-border:           darken(@state-warning-bg, 3%);
+
+@state-danger-text:              #000;
+@state-danger-bg:                @brand-danger;
+@state-danger-border:            darken(@state-danger-bg, 3%);
+
+
+//== Tooltips
+//
+//##
+
+//** Tooltip max width
+@tooltip-max-width:           200px;
+//** Tooltip text color
+@tooltip-color:               #fff;
+//** Tooltip background color
+@tooltip-bg:                  rgba(0,0,0,.9);
+@tooltip-opacity:             .9;
+
+//** Tooltip arrow width
+@tooltip-arrow-width:         5px;
+//** Tooltip arrow color
+@tooltip-arrow-color:         @tooltip-bg;
+
+
+//== Popovers
+//
+//##
+
+//** Popover body background color
+@popover-bg:                          lighten(@body-bg, 10%);
+//** Popover maximum width
+@popover-max-width:                   276px;
+//** Popover border color
+@popover-border-color:                rgba(0,0,0,.2);
+//** Popover fallback border color
+@popover-fallback-border-color:       #999;
+
+//** Popover title background color
+@popover-title-bg:                    darken(@popover-bg, 3%);
+
+//** Popover arrow width
+@popover-arrow-width:                 10px;
+//** Popover arrow color
+@popover-arrow-color:                 @popover-bg;
+
+//** Popover outer arrow width
+@popover-arrow-outer-width:           (@popover-arrow-width + 1);
+//** Popover outer arrow color
+@popover-arrow-outer-color:           fadein(@popover-border-color, 5%);
+//** Popover outer arrow fallback color
+@popover-arrow-outer-fallback-color:  darken(@popover-fallback-border-color, 20%);
+
+
+//== Labels
+//
+//##
+
+//** Default label background color
+@label-default-bg:            @btn-default-bg;
+//** Primary label background color
+@label-primary-bg:            @brand-primary;
+//** Success label background color
+@label-success-bg:            @brand-success;
+//** Info label background color
+@label-info-bg:               @brand-info;
+//** Warning label background color
+@label-warning-bg:            @brand-warning;
+//** Danger label background color
+@label-danger-bg:             @brand-danger;
+
+//** Default label text color
+@label-color:                 #fff;
+//** Default text color of a linked label
+@label-link-hover-color:      #fff;
+
+
+//== Modals
+//
+//##
+
+//** Padding applied to the modal body
+@modal-inner-padding:         20px;
+
+//** Padding applied to the modal title
+@modal-title-padding:         15px;
+//** Modal title line-height
+@modal-title-line-height:     @line-height-base;
+
+//** Background color of modal content area
+@modal-content-bg:                             lighten(@body-bg, 10%);
+//** Modal content border color
+@modal-content-border-color:                   rgba(0,0,0,.2);
+//** Modal content border color **for IE8**
+@modal-content-fallback-border-color:          #999;
+
+//** Modal backdrop background color
+@modal-backdrop-bg:           #000;
+//** Modal backdrop opacity
+@modal-backdrop-opacity:      .5;
+//** Modal header border color
+@modal-header-border-color:   @gray-dark;
+//** Modal footer border color
+@modal-footer-border-color:   @modal-header-border-color;
+
+@modal-lg:                    900px;
+@modal-md:                    600px;
+@modal-sm:                    300px;
+
+
+//== Alerts
+//
+//## Define alert colors, border radius, and padding.
+
+@alert-padding:               15px;
+@alert-border-radius:         @border-radius-base;
+@alert-link-font-weight:      bold;
+
+@alert-success-bg:            @state-success-bg;
+@alert-success-text:          @state-success-text;
+@alert-success-border:        @state-success-border;
+
+@alert-info-bg:               @state-info-bg;
+@alert-info-text:             @state-info-text;
+@alert-info-border:           @state-info-border;
+
+@alert-warning-bg:            @state-warning-bg;
+@alert-warning-text:          @state-warning-text;
+@alert-warning-border:        @state-warning-border;
+
+@alert-danger-bg:             @state-danger-bg;
+@alert-danger-text:           @state-danger-text;
+@alert-danger-border:         @state-danger-border;
+
+
+//== Progress bars
+//
+//##
+
+//** Background color of the whole progress component
+@progress-bg:                 @gray-darker;
+//** Progress bar text color
+@progress-bar-color:          #fff;
+
+//** Default progress bar color
+@progress-bar-bg:             @brand-primary;
+//** Success progress bar color
+@progress-bar-success-bg:     @brand-success;
+//** Warning progress bar color
+@progress-bar-warning-bg:     @brand-warning;
+//** Danger progress bar color
+@progress-bar-danger-bg:      @brand-danger;
+//** Info progress bar color
+@progress-bar-info-bg:        @brand-info;
+
+
+//== List group
+//
+//##
+
+//** Background color on `.list-group-item`
+@list-group-bg:                 @gray-darker;
+//** `.list-group-item` border color
+@list-group-border:             @gray-dark;
+//** List group border radius
+@list-group-border-radius:      @border-radius-base;
+
+//** Background color of single list items on hover
+@list-group-hover-bg:           lighten(@list-group-bg, 15%);
+//** Text color of active list items
+@list-group-active-color:       @component-active-color;
+//** Background color of active list items
+@list-group-active-bg:          @component-active-bg;
+//** Border color of active list elements
+@list-group-active-border:      @list-group-active-bg;
+//** Text color for content within active list items
+@list-group-active-text-color:  lighten(@list-group-active-bg, 40%);
+
+//** Text color of disabled list items
+@list-group-disabled-color:      @gray-light;
+//** Background color of disabled list items
+@list-group-disabled-bg:         @gray-lighter;
+//** Text color for content within disabled list items
+@list-group-disabled-text-color: @list-group-disabled-color;
+
+@list-group-link-color:         @text-color;
+@list-group-link-hover-color:   @list-group-link-color;
+@list-group-link-heading-color: #fff;
+
+
+//== Panels
+//
+//##
+
+@panel-bg:                    @gray-darker;
+@panel-body-padding:          15px;
+@panel-heading-padding:       10px 15px;
+@panel-footer-padding:        @panel-heading-padding;
+@panel-border-radius:         @border-radius-base;
+
+//** Border color for elements within panels
+@panel-inner-border:          @gray-dark;
+
+@panel-default-text:          @text-color;
+@panel-default-border:        @panel-inner-border;
+@panel-default-heading-bg:    lighten(@gray-darker, 10%);
+
+@panel-footer-bg:             @panel-default-heading-bg;
+
+@panel-primary-text:          #fff;
+@panel-primary-border:        @brand-primary;
+@panel-primary-heading-bg:    @brand-primary;
+
+@panel-success-text:          @state-success-text;
+@panel-success-border:        @state-success-border;
+@panel-success-heading-bg:    @state-success-bg;
+
+@panel-info-text:             @state-info-text;
+@panel-info-border:           @state-info-border;
+@panel-info-heading-bg:       @state-info-bg;
+
+@panel-warning-text:          @state-warning-text;
+@panel-warning-border:        @state-warning-border;
+@panel-warning-heading-bg:    @state-warning-bg;
+
+@panel-danger-text:           @state-danger-text;
+@panel-danger-border:         @state-danger-border;
+@panel-danger-heading-bg:     @state-danger-bg;
+
+
+//== Thumbnails
+//
+//##
+
+//** Padding around the thumbnail image
+@thumbnail-padding:           4px;
+//** Thumbnail background color
+@thumbnail-bg:                @gray-dark;
+//** Thumbnail border color
+@thumbnail-border:            @gray-dark;
+//** Thumbnail border radius
+@thumbnail-border-radius:     @border-radius-base;
+
+//** Custom text color for thumbnail captions
+@thumbnail-caption-color:     @text-color;
+//** Padding around the thumbnail caption
+@thumbnail-caption-padding:   9px;
+
+
+//== Wells
+//
+//##
+
+@well-bg:                     darken(@gray-darker, 5%);
+@well-border:                 darken(@well-bg, 7%);
+
+
+//== Badges
+//
+//##
+
+@badge-color:                 #000;
+//** Linked badge text color on hover
+@badge-link-hover-color:      #000;
+@badge-bg:                    @brand-primary;
+
+//** Badge text color in active nav link
+@badge-active-color:          @brand-primary;
+//** Badge background color in active nav link
+@badge-active-bg:             #fff;
+
+@badge-font-weight:           bold;
+@badge-line-height:           1;
+@badge-border-radius:         10px;
+
+
+//== Breadcrumbs
+//
+//##
+
+@breadcrumb-padding-vertical:   8px;
+@breadcrumb-padding-horizontal: 15px;
+//** Breadcrumb background color
+@breadcrumb-bg:                 @gray-darker;
+//** Breadcrumb text color
+@breadcrumb-color:              #fff;
+//** Text color of current page in the breadcrumb
+@breadcrumb-active-color:       @text-color;
+//** Textual separator for between breadcrumb elements
+@breadcrumb-separator:          "/";
+
+
+//== Carousel
+//
+//##
+
+@carousel-text-shadow:                        0 1px 2px rgba(0,0,0,.6);
+
+@carousel-control-color:                      #fff;
+@carousel-control-width:                      15%;
+@carousel-control-opacity:                    .5;
+@carousel-control-font-size:                  20px;
+
+@carousel-indicator-active-bg:                #fff;
+@carousel-indicator-border-color:             #fff;
+
+@carousel-caption-color:                      #fff;
+
+
+//== Close
+//
+//##
+
+@close-font-weight:           bold;
+@close-color:                 #000;
+@close-text-shadow:           0 1px 0 #fff;
+
+
+//== Code
+//
+//##
+
+@code-color:                  #c7254e;
+@code-bg:                     #f9f2f4;
+
+@kbd-color:                   #fff;
+@kbd-bg:                      #333;
+
+@pre-bg:                      #f5f5f5;
+@pre-color:                   @gray-dark;
+@pre-border-color:            #ccc;
+@pre-scrollable-max-height:   340px;
+
+
+//== Type
+//
+//##
+
+//** Horizontal offset for forms and lists.
+@component-offset-horizontal: 180px;
+//** Text muted color
+@text-muted:                  @gray-light;
+//** Abbreviations and acronyms border color
+@abbr-border-color:           @gray-light;
+//** Headings small color
+@headings-small-color:        @brand-primary;
+//** Blockquote small color
+@blockquote-small-color:      @gray;
+//** Blockquote font size
+@blockquote-font-size:        (@font-size-base * 1.25);
+//** Blockquote border color
+@blockquote-border-color:     @gray-dark;
+//** Page header border color
+@page-header-border-color:    @gray-dark;
+//** Width of horizontal description list titles
+@dl-horizontal-offset:        @component-offset-horizontal;
+//** Horizontal line color.
+@hr-border:                   @gray-dark;
+
+@import "base";
+
+.messages .text-danger {
+  color: #fff;
+}
+
+.messages .text-info {
+  color: #fff;
+}
+
+.messages .caret {
+  color: #fff;
+}
+
+// Cyborg 3.2.0
+// Bootswatch
+// -----------------------------------------------------
+
+// Navbar =====================================================================
+
+// Buttons ====================================================================
+
+// Typography =================================================================
+
+.text-primary,
+.text-primary:hover {
+  color: @brand-primary;
+}
+
+.text-success,
+.text-success:hover {
+  color: @brand-success;
+}
+
+.text-danger,
+.text-danger:hover {
+  color: @brand-danger;
+}
+
+.text-warning,
+.text-warning:hover {
+  color: @brand-warning;
+}
+
+.text-info,
+.text-info:hover {
+  color: @brand-info;
+}
+
+// Tables =====================================================================
+
+table,
+.table {
+  color: #fff;
+
+  a:not(.btn) {
+    color: #fff;
+    text-decoration: underline;
+  }
+
+  .text-muted {
+    color: @text-muted;
+  }
+}
+
+.table-responsive > .table {
+  background-color: @table-bg;
+}
+
+// Forms ======================================================================
+
+.has-warning {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-warning;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-warning;
+  }
+}
+
+.has-error {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-danger;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-danger;
+  }
+}
+
+.has-success {
+  .help-block,
+  .control-label,
+  .form-control-feedback {
+    color: @brand-success;
+  }
+
+  .form-control,
+  .form-control:focus,
+  .input-group-addon {
+    border-color: @brand-success;
+  }
+}
+
+.input-group-addon {
+  background-color: @btn-default-bg;
+}
+
+// Navs =======================================================================
+
+.nav-tabs,
+.nav-pills,
+.breadcrumb,
+.pager {
+
+  a {
+    color: #fff;
+  }
+}
+
+// Indicators =================================================================
+
+.alert {
+
+  .alert-link,
+  a {
+    color: @alert-warning-text;
+    text-decoration: underline;
+  }
+
+  .close {
+    text-decoration: none;
+  }
+}
+
+.close {
+  color: #fff;
+  text-decoration: none;
+  opacity: 0.4;
+
+  &:hover,
+  &:focus {
+    color: #fff;
+    opacity: 1;
+  }
+}
+
+// Progress bars ==============================================================
+
+// Containers =================================================================
+
+a.thumbnail:hover,
+a.thumbnail:focus,
+a.thumbnail.active {
+  border-color: @thumbnail-border;
+}
+
+.jumbotron {
+
+  h1, h2, h3, h4, h5, h6 {
+    color: #fff;
+  }
+}
+
+
+// Specials for cccamp19 design
+
+.navbar-brand {
+	.icon-icon_angel {
+	  background-color: @brand-primary;
+  }
+  
+  strong {
+    font-weight: lighter;
+    color: @brand-primary;  
+    text-shadow:
+      0 0 10px @brand-primary,
+      0 0 20px @brand-primary,
+      0 0 30px @brand-primary,
+      0 0 40px @brand-primary,
+      0 0 70px @brand-primary,
+      0 0 80px @brand-primary;
+  }
+}
+
+h1 {
+  font-weight: lighter;
+  color: @brand-primary;
+  text-shadow: 0 0 10px @brand-primary;
+  
+  .icon-icon_angel {
+    background-color: @brand-primary;
+  }
+}
+
+.panel-title {
+  color: #fff;
+}
diff --git a/webpack.config.js b/webpack.config.js
index 5542460d..f25a37ea 100644
--- a/webpack.config.js
+++ b/webpack.config.js
@@ -23,7 +23,7 @@ const plugins = [
 
 
 const themeEntries = {};
-for (let i = 0; i < 9; i++) {
+for (let i = 0; i < 12; i++) {
   themeEntries[`theme${i}`] = `./resources/assets/themes/theme${i}.less`;
 }
 

From fece50ca0993d2b52e09be8543cb796594681c9a Mon Sep 17 00:00:00 2001
From: Igor Scheller 
Date: Tue, 16 Jul 2019 02:59:33 +0200
Subject: [PATCH 11/17] Basic coverage tests of logger classes for 100% unit
 tests coverage

---
 tests/Unit/HasDatabase.php                  |  1 +
 tests/Unit/Logger/EngelsystemLoggerTest.php | 72 +++++++++++++++++++++
 tests/Unit/Models/LogEntryTest.php          | 43 ++++++++++++
 3 files changed, 116 insertions(+)
 create mode 100644 tests/Unit/Logger/EngelsystemLoggerTest.php
 create mode 100644 tests/Unit/Models/LogEntryTest.php

diff --git a/tests/Unit/HasDatabase.php b/tests/Unit/HasDatabase.php
index 175c244b..7a58bb2b 100644
--- a/tests/Unit/HasDatabase.php
+++ b/tests/Unit/HasDatabase.php
@@ -44,6 +44,7 @@ trait HasDatabase
                 ['migration' => '2018_01_01_000003_fix_old_tables'],
                 ['migration' => '2018_01_01_000004_cleanup_group_privileges'],
                 ['migration' => '2018_01_01_000005_add_angel_supporter_permissions'],
+                ['migration' => '2018_12_27_000000_fix_missing_arrival_dates'],
             ]);
 
         $migration->run(__DIR__ . '/../../db/migrations');
diff --git a/tests/Unit/Logger/EngelsystemLoggerTest.php b/tests/Unit/Logger/EngelsystemLoggerTest.php
new file mode 100644
index 00000000..0f4c8c32
--- /dev/null
+++ b/tests/Unit/Logger/EngelsystemLoggerTest.php
@@ -0,0 +1,72 @@
+createMock(LogEntry::class);
+        $logEntry->expects($this->once())
+            ->method('create')
+            ->with(['level' => LogLevel::INFO, 'message' => 'I\'m an information!']);
+
+        $logger = new EngelsystemLogger($logEntry);
+
+        $logger->log(LogLevel::INFO, 'I\'m an information!');
+    }
+
+    /**
+     * @covers \Engelsystem\Logger\EngelsystemLogger::log
+     * @covers \Engelsystem\Logger\EngelsystemLogger::checkLevel
+     */
+    public function testCheckLevel()
+    {
+        /** @var LogEntry|MockObject $logEntry */
+        $logEntry = $this->createMock(LogEntry::class);
+        $logger = new EngelsystemLogger($logEntry);
+
+        $this->expectException(InvalidArgumentException::class);
+        $logger->log('FooBar', 'Random Stuff');
+    }
+
+    /**
+     * @covers \Engelsystem\Logger\EngelsystemLogger::interpolate
+     */
+    public function testInterpolate()
+    {
+        /** @var LogEntry|MockObject $logEntry */
+        $logEntry = $this->createMock(LogEntry::class);
+        $logEntry->expects($this->exactly(3))
+            ->method('create')
+            ->withConsecutive(
+                [['level' => LogLevel::DEBUG, 'message' => 'User: Foo']],
+                [['level' => LogLevel::NOTICE, 'message' => 'User: {user}']],
+                [['level' => LogLevel::NOTICE, 'message' => 'User: Bar']]
+            );
+
+        $logger = new EngelsystemLogger($logEntry);
+
+        $logger->log(LogLevel::DEBUG, 'User: {user}', ['user' => 'Foo']);
+        $logger->log(LogLevel::NOTICE, 'User: {user}', ['user' => ['name' => 'Lorem']]);
+        $logger->log(LogLevel::NOTICE, 'User: {user}', [
+            'user' =>
+                new class
+                {
+                    public function __toString() { return 'Bar'; }
+                }
+        ]);
+    }
+}
diff --git a/tests/Unit/Models/LogEntryTest.php b/tests/Unit/Models/LogEntryTest.php
new file mode 100644
index 00000000..0a0efa3c
--- /dev/null
+++ b/tests/Unit/Models/LogEntryTest.php
@@ -0,0 +1,43 @@
+ LogLevel::INFO,
+                     '*Insert explosion here*' => LogLevel::EMERGENCY,
+                     'Tracing along'           => LogLevel::DEBUG,
+                     'Oops'                    => LogLevel::ERROR,
+                     'It\'s happening'         => LogLevel::INFO,
+                     'Something is wrong'      => LogLevel::ERROR,
+                     'Ohi'                     => LogLevel::INFO,
+                 ] as $message => $level) {
+            (new LogEntry(['level' => $level, 'message' => $message]))->save();
+        }
+
+        $this->assertCount(7, LogEntry::filter());
+        $this->assertCount(3, LogEntry::filter(LogLevel::INFO));
+        $this->assertCount(1, LogEntry::filter('Oops'));
+    }
+
+    /**
+     * Prepare test
+     */
+    protected function setUp(): void
+    {
+        $this->initDatabase();
+    }
+}

From 4582f808f05205e7a32ecd6ae42dee00295872f1 Mon Sep 17 00:00:00 2001
From: Igor Scheller 
Date: Sun, 21 Jul 2019 02:34:52 +0200
Subject: [PATCH 12/17] Added version to credits and metrics page

---
 .gitlab-ci.yml                                |  4 +-
 config/app.php                                |  1 +
 contrib/Dockerfile                            |  3 ++
 resources/views/pages/credits.twig            |  1 +
 src/Application.php                           |  1 +
 src/Controllers/CreditsController.php         | 13 +++++-
 src/Controllers/Metrics/Controller.php        | 21 +++++++++-
 src/Helpers/Version.php                       | 42 +++++++++++++++++++
 src/Helpers/VersionServiceProvider.php        | 15 +++++++
 storage/app/.gitignore                        |  2 +
 .../Controllers/CreditsControllerTest.php     | 16 ++++---
 .../Controllers/Metrics/ControllerTest.php    | 22 ++++++----
 tests/Unit/Helpers/Stub/files/VERSION         |  1 +
 .../Helpers/VersionServiceProviderTest.php    | 25 +++++++++++
 tests/Unit/Helpers/VersionTest.php            | 28 +++++++++++++
 15 files changed, 179 insertions(+), 16 deletions(-)
 create mode 100644 src/Helpers/Version.php
 create mode 100644 src/Helpers/VersionServiceProvider.php
 create mode 100644 storage/app/.gitignore
 create mode 100644 tests/Unit/Helpers/Stub/files/VERSION
 create mode 100644 tests/Unit/Helpers/VersionServiceProviderTest.php
 create mode 100644 tests/Unit/Helpers/VersionTest.php

diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml
index db26ce4c..b9bb8654 100644
--- a/.gitlab-ci.yml
+++ b/.gitlab-ci.yml
@@ -38,7 +38,9 @@ build-image:
   <<: *docker_definition
   stage: build
   script:
-    - docker build --pull --build-arg NGINX_IMAGE="${TEST_IMAGE}-nginx" -t "${TEST_IMAGE}" -f contrib/Dockerfile .
+    - apk -q add git
+    - VERSION="$(git describe --abbrev=0 --tags)-${CI_COMMIT_REF_NAME}+${CI_PIPELINE_ID}.${CI_COMMIT_SHORT_SHA}"
+    - docker build --pull --build-arg NGINX_IMAGE="${TEST_IMAGE}-nginx" --build-arg VERSION="${VERSION}" -t "${TEST_IMAGE}" -f contrib/Dockerfile .
     - docker push "${TEST_IMAGE}"
 
 test:
diff --git a/config/app.php b/config/app.php
index 17fdee11..8850f74e 100644
--- a/config/app.php
+++ b/config/app.php
@@ -27,6 +27,7 @@ return [
         \Engelsystem\Middleware\SessionHandlerServiceProvider::class,
 
         // Additional services
+        \Engelsystem\Helpers\VersionServiceProvider::class,
         \Engelsystem\Mail\MailerServiceProvider::class,
     ],
 
diff --git a/contrib/Dockerfile b/contrib/Dockerfile
index dd3bd308..f04fff11 100644
--- a/contrib/Dockerfile
+++ b/contrib/Dockerfile
@@ -32,6 +32,9 @@ COPY --from=composer /app/composer.lock /app/
 RUN find /app/storage/ -type f -not -name .gitignore -exec rm {} \;
 RUN rm -f /app/import/* /app/config/config.php
 
+ARG VERSION
+RUN if [[ ! -f /app/storage/app/VERSION ]] && [[ ! -z "${VERSION}" ]]; then echo -n "${VERSION}" > /app/storage/app/VERSION; fi
+
 # Build the PHP container
 FROM php:7-fpm-alpine
 WORKDIR /var/www
diff --git a/resources/views/pages/credits.twig b/resources/views/pages/credits.twig
index eb98c7e7..3bb04895 100644
--- a/resources/views/pages/credits.twig
+++ b/resources/views/pages/credits.twig
@@ -15,6 +15,7 @@
 
             

Source code

+

Version: {{ version }}

The original engelsystem was written by cookie. diff --git a/src/Application.php b/src/Application.php index ac69c20a..99c68231 100644 --- a/src/Application.php +++ b/src/Application.php @@ -111,6 +111,7 @@ class Application extends Container $this->instance('path.lang', $this->get('path.resources') . DIRECTORY_SEPARATOR . 'lang'); $this->instance('path.views', $this->get('path.resources') . DIRECTORY_SEPARATOR . 'views'); $this->instance('path.storage', $appPath . DIRECTORY_SEPARATOR . 'storage'); + $this->instance('path.storage.app', $this->get('path.storage') . DIRECTORY_SEPARATOR . 'app'); $this->instance('path.cache', $this->get('path.storage') . DIRECTORY_SEPARATOR . 'cache'); $this->instance('path.cache.routes', $this->get('path.cache') . DIRECTORY_SEPARATOR . 'routes.cache.php'); $this->instance('path.cache.views', $this->get('path.cache') . DIRECTORY_SEPARATOR . 'views'); diff --git a/src/Controllers/CreditsController.php b/src/Controllers/CreditsController.php index b2805b84..ade97649 100644 --- a/src/Controllers/CreditsController.php +++ b/src/Controllers/CreditsController.php @@ -3,6 +3,7 @@ namespace Engelsystem\Controllers; use Engelsystem\Config\Config; +use Engelsystem\Helpers\Version; use Engelsystem\Http\Response; class CreditsController extends BaseController @@ -13,14 +14,19 @@ class CreditsController extends BaseController /** @var Response */ protected $response; + /** @var Version */ + protected $version; + /** * @param Response $response * @param Config $config + * @param Version $version */ - public function __construct(Response $response, Config $config) + public function __construct(Response $response, Config $config, Version $version) { $this->config = $config; $this->response = $response; + $this->version = $version; } /** @@ -30,7 +36,10 @@ class CreditsController extends BaseController { return $this->response->withView( 'pages/credits.twig', - ['credits' => $this->config->get('credits')] + [ + 'credits' => $this->config->get('credits'), + 'version' => $this->version->getVersion(), + ] ); } } diff --git a/src/Controllers/Metrics/Controller.php b/src/Controllers/Metrics/Controller.php index f6ea3967..ffb2a41b 100644 --- a/src/Controllers/Metrics/Controller.php +++ b/src/Controllers/Metrics/Controller.php @@ -4,6 +4,7 @@ namespace Engelsystem\Controllers\Metrics; use Engelsystem\Config\Config; use Engelsystem\Controllers\BaseController; +use Engelsystem\Helpers\Version; use Engelsystem\Http\Exceptions\HttpForbidden; use Engelsystem\Http\Request; use Engelsystem\Http\Response; @@ -26,25 +27,31 @@ class Controller extends BaseController /** @var Stats */ protected $stats; + /** @var Version */ + protected $version; + /** * @param Response $response * @param MetricsEngine $engine * @param Config $config * @param Request $request * @param Stats $stats + * @param Version $version */ public function __construct( Response $response, MetricsEngine $engine, Config $config, Request $request, - Stats $stats + Stats $stats, + Version $version ) { $this->config = $config; $this->engine = $engine; $this->request = $request; $this->response = $response; $this->stats = $stats; + $this->version = $version; } /** @@ -68,6 +75,18 @@ class Controller extends BaseController $data = [ $this->config->get('app_name') . ' stats', + 'info' => [ + 'type' => 'gauge', + 'help' => 'About the environment', + [ + 'labels' => [ + 'os' => PHP_OS_FAMILY, + 'php' => implode('.', [PHP_MAJOR_VERSION, PHP_MINOR_VERSION]), + 'version' => $this->version->getVersion(), + ], + 'value' => 1, + ], + ], 'users' => [ 'type' => 'gauge', ['labels' => ['state' => 'incoming'], 'value' => $this->stats->newUsers()], diff --git a/src/Helpers/Version.php b/src/Helpers/Version.php new file mode 100644 index 00000000..97fe6ef3 --- /dev/null +++ b/src/Helpers/Version.php @@ -0,0 +1,42 @@ +storage = $storage; + $this->config = $config; + } + + /** + * @return string + */ + public function getVersion() + { + $file = $this->storage . DIRECTORY_SEPARATOR . $this->versionFile; + + $version = 'n/a'; + if (file_exists($file)) { + $version = trim(file_get_contents($file)); + } + + return $this->config->get('version', $version); + } +} diff --git a/src/Helpers/VersionServiceProvider.php b/src/Helpers/VersionServiceProvider.php new file mode 100644 index 00000000..41e10158 --- /dev/null +++ b/src/Helpers/VersionServiceProvider.php @@ -0,0 +1,15 @@ +app->when(Version::class) + ->needs('$storage') + ->give($this->app->get('path.storage.app')); + } +} diff --git a/storage/app/.gitignore b/storage/app/.gitignore new file mode 100644 index 00000000..78d91016 --- /dev/null +++ b/storage/app/.gitignore @@ -0,0 +1,2 @@ +/* +!.gitignore diff --git a/tests/Unit/Controllers/CreditsControllerTest.php b/tests/Unit/Controllers/CreditsControllerTest.php index 42ea4ea1..303bf60e 100644 --- a/tests/Unit/Controllers/CreditsControllerTest.php +++ b/tests/Unit/Controllers/CreditsControllerTest.php @@ -4,9 +4,10 @@ namespace Engelsystem\Test\Unit\Controllers; use Engelsystem\Config\Config; use Engelsystem\Controllers\CreditsController; +use Engelsystem\Helpers\Version; use Engelsystem\Http\Response; +use Engelsystem\Test\Unit\TestCase; use PHPUnit\Framework\MockObject\MockObject; -use PHPUnit\Framework\TestCase; class CreditsControllerTest extends TestCase { @@ -19,12 +20,17 @@ class CreditsControllerTest extends TestCase /** @var Response|MockObject $response */ $response = $this->createMock(Response::class); $config = new Config(['foo' => 'bar', 'credits' => ['lor' => 'em']]); + /** @var Version|MockObject $version */ + $version = $this->createMock(Version::class); - $response->expects($this->once()) - ->method('withView') - ->with('pages/credits.twig', ['credits' => ['lor' => 'em']]); + $this->setExpects( + $response, + 'withView', + ['pages/credits.twig', ['credits' => ['lor' => 'em'], 'version' => '42.1.0-test']] + ); + $this->setExpects($version, 'getVersion', [], '42.1.0-test'); - $controller = new CreditsController($response, $config); + $controller = new CreditsController($response, $config, $version); $controller->index(); } } diff --git a/tests/Unit/Controllers/Metrics/ControllerTest.php b/tests/Unit/Controllers/Metrics/ControllerTest.php index 18daa96a..f203200c 100644 --- a/tests/Unit/Controllers/Metrics/ControllerTest.php +++ b/tests/Unit/Controllers/Metrics/ControllerTest.php @@ -6,6 +6,7 @@ use Engelsystem\Config\Config; use Engelsystem\Controllers\Metrics\Controller; use Engelsystem\Controllers\Metrics\MetricsEngine; use Engelsystem\Controllers\Metrics\Stats; +use Engelsystem\Helpers\Version; use Engelsystem\Http\Exceptions\HttpForbidden; use Engelsystem\Http\Request; use Engelsystem\Http\Response; @@ -28,7 +29,8 @@ class ControllerTest extends TestCase /** @var MetricsEngine|MockObject $engine */ /** @var Stats|MockObject $stats */ /** @var Config $config */ - list($response, $request, $engine, $stats, $config) = $this->getMocks(); + /** @var Version|MockObject $version */ + list($response, $request, $engine, $stats, $config, $version) = $this->getMocks(); $request->server = new ServerBag(); $request->server->set('REQUEST_TIME_FLOAT', 0.0123456789); @@ -37,6 +39,7 @@ class ControllerTest extends TestCase ->method('get') ->willReturnCallback(function ($path, $data) use ($response) { $this->assertEquals('/metrics', $path); + $this->assertArrayHasKey('info', $data); $this->assertArrayHasKey('users', $data); $this->assertArrayHasKey('licenses', $data); $this->assertArrayHasKey('users_working', $data); @@ -122,7 +125,9 @@ class ControllerTest extends TestCase 'XL' => 'X Large', ]); - $controller = new Controller($response, $engine, $config, $request, $stats); + $this->setExpects($version, 'getVersion', [], '0.42.42'); + + $controller = new Controller($response, $engine, $config, $request, $stats, $version); $controller->metrics(); } @@ -137,7 +142,8 @@ class ControllerTest extends TestCase /** @var MetricsEngine|MockObject $engine */ /** @var Stats|MockObject $stats */ /** @var Config $config */ - list($response, $request, $engine, $stats, $config) = $this->getMocks(); + /** @var Version|MockObject $version */ + list($response, $request, $engine, $stats, $config, $version) = $this->getMocks(); $response->expects($this->once()) ->method('withHeader') @@ -168,7 +174,7 @@ class ControllerTest extends TestCase $this->setExpects($stats, 'arrivedUsers', null, 10, $this->exactly(2)); $this->setExpects($stats, 'currentlyWorkingUsers', null, 5); - $controller = new Controller($response, $engine, $config, $request, $stats); + $controller = new Controller($response, $engine, $config, $request, $stats, $version); $controller->stats(); } @@ -182,7 +188,8 @@ class ControllerTest extends TestCase /** @var MetricsEngine|MockObject $engine */ /** @var Stats|MockObject $stats */ /** @var Config $config */ - list($response, $request, $engine, $stats, $config) = $this->getMocks(); + /** @var Version|MockObject $version */ + list($response, $request, $engine, $stats, $config, $version) = $this->getMocks(); $request->expects($this->once()) ->method('get') @@ -191,7 +198,7 @@ class ControllerTest extends TestCase $config->set('api_key', 'fooBar!'); - $controller = new Controller($response, $engine, $config, $request, $stats); + $controller = new Controller($response, $engine, $config, $request, $stats, $version); $this->expectException(HttpForbidden::class); $this->expectExceptionMessage(json_encode(['error' => 'The api_key is invalid'])); @@ -212,7 +219,8 @@ class ControllerTest extends TestCase /** @var Stats|MockObject $stats */ $stats = $this->createMock(Stats::class); $config = new Config(); + $version = $this->createMock(Version::class); - return [$response, $request, $engine, $stats, $config]; + return [$response, $request, $engine, $stats, $config, $version]; } } diff --git a/tests/Unit/Helpers/Stub/files/VERSION b/tests/Unit/Helpers/Stub/files/VERSION new file mode 100644 index 00000000..749a96f3 --- /dev/null +++ b/tests/Unit/Helpers/Stub/files/VERSION @@ -0,0 +1 @@ +0.42.0-testing diff --git a/tests/Unit/Helpers/VersionServiceProviderTest.php b/tests/Unit/Helpers/VersionServiceProviderTest.php new file mode 100644 index 00000000..609c649d --- /dev/null +++ b/tests/Unit/Helpers/VersionServiceProviderTest.php @@ -0,0 +1,25 @@ +instance('path.storage.app', '/tmp'); + + $serviceProvider = new VersionServiceProvider($app); + $serviceProvider->register(); + + $this->assertArrayHasKey(Version::class, $app->contextual); + } +} diff --git a/tests/Unit/Helpers/VersionTest.php b/tests/Unit/Helpers/VersionTest.php new file mode 100644 index 00000000..40569abb --- /dev/null +++ b/tests/Unit/Helpers/VersionTest.php @@ -0,0 +1,28 @@ +assertEquals('n/a', $version->getVersion()); + + $version = new Version(__DIR__ . '/Stub/files', $config); + $this->assertEquals('0.42.0-testing', $version->getVersion()); + + $config->set('version', '1.2.3-dev'); + $this->assertEquals('1.2.3-dev', $version->getVersion()); + } +} From ea4c258e5c26e6528d36cda3afb52458ca0bd801 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 21 Jul 2019 04:53:50 +0200 Subject: [PATCH 13/17] GitLab CI: Removed xdebug beta as it is not php7.3 compatible --- .gitlab-ci.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index db26ce4c..fa23d90d 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -57,7 +57,7 @@ test: junit: ./unittests.xml coverage: '/^\s*Lines:\s*(\d+(?:\.\d+)?%)/' before_script: - - apk add ${PHPIZE_DEPS} && pecl install xdebug-beta && docker-php-ext-enable xdebug + - apk add ${PHPIZE_DEPS} && pecl install xdebug && docker-php-ext-enable xdebug - curl -sS https://getcomposer.org/installer | php -- --no-ansi --install-dir /usr/local/bin/ --filename composer - cp -R tests/ phpunit.xml "${DOCROOT}" - HOMEDIR=$(pwd) @@ -121,6 +121,7 @@ deploy-staging: - master script: # Check if deployment variables where set + - |- - |- if [ -z "${SSH_PRIVATE_KEY}" ] || [ -z "${STAGING_REMOTE}" ] || [ -z "${STAGING_REMOTE_PATH}" ]; then echo "Skipping deployment"; From d5bf7fd065a5ea93dea9fd55e6ac225ee062a3db Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 21 Jul 2019 05:03:58 +0200 Subject: [PATCH 14/17] GitLab CI: Removed xdebug beta as it is now php7.3 compatible --- .gitlab-ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index fa23d90d..a165c604 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -121,7 +121,6 @@ deploy-staging: - master script: # Check if deployment variables where set - - |- - |- if [ -z "${SSH_PRIVATE_KEY}" ] || [ -z "${STAGING_REMOTE}" ] || [ -z "${STAGING_REMOTE_PATH}" ]; then echo "Skipping deployment"; From b03102e3c613bd057f117a145d94aec4c977006c Mon Sep 17 00:00:00 2001 From: msquare Date: Sun, 21 Jul 2019 12:37:01 +0200 Subject: [PATCH 15/17] AuthController return types --- src/Controllers/AuthController.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Controllers/AuthController.php b/src/Controllers/AuthController.php index a8cc1ace..55dd56b0 100644 --- a/src/Controllers/AuthController.php +++ b/src/Controllers/AuthController.php @@ -53,7 +53,7 @@ class AuthController extends BaseController /** * @return Response */ - public function login() + public function login(): Response { return $this->showLogin(); } @@ -62,7 +62,7 @@ class AuthController extends BaseController * @param bool $showRecovery * @return Response */ - protected function showLogin($showRecovery = false) + protected function showLogin($showRecovery = false): Response { $errors = Collection::make(Arr::flatten($this->session->get('errors', []))); $this->session->remove('errors'); From 51a3c6eb44a5dbdf9d7a3cfac678f0d29b0d3eef Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 21 Jul 2019 13:24:47 +0200 Subject: [PATCH 16/17] ErrorHandler: Remove some form fields before serialization --- src/Middleware/ErrorHandler.php | 13 ++++++++++++- tests/Unit/Middleware/ErrorHandlerTest.php | 6 +++++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/Middleware/ErrorHandler.php b/src/Middleware/ErrorHandler.php index c89edb1a..544f35d5 100644 --- a/src/Middleware/ErrorHandler.php +++ b/src/Middleware/ErrorHandler.php @@ -6,6 +6,7 @@ use Engelsystem\Http\Exceptions\HttpException; use Engelsystem\Http\Exceptions\ValidationException; use Engelsystem\Http\Request; use Engelsystem\Http\Response; +use Illuminate\Support\Arr; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; @@ -20,6 +21,16 @@ class ErrorHandler implements MiddlewareInterface /** @var string */ protected $viewPrefix = 'errors/'; + /** + * A list of inputs that are not saved from form input + * + * @var array + */ + protected $formIgnore = [ + 'password', + 'password_confirmation', + ]; + /** * @param TwigLoader $loader */ @@ -58,7 +69,7 @@ class ErrorHandler implements MiddlewareInterface ) ); - $session->set('form-data', $request->request->all()); + $session->set('form-data', Arr::except($request->request->all(), $this->formIgnore)); } } diff --git a/tests/Unit/Middleware/ErrorHandlerTest.php b/tests/Unit/Middleware/ErrorHandlerTest.php index ea9cb216..a9fdd71a 100644 --- a/tests/Unit/Middleware/ErrorHandlerTest.php +++ b/tests/Unit/Middleware/ErrorHandlerTest.php @@ -176,7 +176,11 @@ class ErrorHandlerTest extends TestCase $session = new Session(new MockArraySessionStorage()); $session->set('errors', ['validation' => ['foo' => ['validation.foo.required']]]); - $request = Request::create('/foo/bar', 'POST', ['foo' => 'bar']); + $request = Request::create( + '/foo/bar', + 'POST', + ['foo' => 'bar', 'password' => 'Test123', 'password_confirmation' => 'Test1234'] + ); $request->setSession($session); /** @var Application $app */ From 3d8476efd3709806d254b1c41e26e906080e0b39 Mon Sep 17 00:00:00 2001 From: Igor Scheller Date: Sun, 21 Jul 2019 13:37:35 +0200 Subject: [PATCH 17/17] ErrorHandler: Remove more form fields before serialization --- src/Middleware/ErrorHandler.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Middleware/ErrorHandler.php b/src/Middleware/ErrorHandler.php index 544f35d5..65e2e609 100644 --- a/src/Middleware/ErrorHandler.php +++ b/src/Middleware/ErrorHandler.php @@ -29,6 +29,12 @@ class ErrorHandler implements MiddlewareInterface protected $formIgnore = [ 'password', 'password_confirmation', + 'password2', + 'new_password', + 'new_password2', + 'new_pw', + 'new_pw2', + '_token', ]; /**