diff options
Diffstat (limited to 'src')
30 files changed, 815 insertions, 135 deletions
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/AuthController.php b/src/Controllers/AuthController.php index cdaee167..55dd56b0 100644 --- a/src/Controllers/AuthController.php +++ b/src/Controllers/AuthController.php @@ -2,8 +2,14 @@ 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 Illuminate\Support\Arr; +use Illuminate\Support\Collection; use Symfony\Component\HttpFoundation\Session\SessionInterface; class AuthController extends BaseController @@ -17,17 +23,91 @@ 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 login(): Response + { + return $this->showLogin(); + } + + /** + * @param bool $showRecovery + * @return Response + */ + protected function showLogin($showRecovery = false): Response + { + $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] + ); + } + + /** + * Posted login form + * + * @param Request $request + * @return Response + */ + public function postLogin(Request $request): Response + { + $data = $this->validate($request, [ + 'login' => 'required', + 'password' => 'required', + ]); + + $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); + $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() + public function logout(): Response { $this->session->invalidate(); 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/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/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/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)) { @@ -132,6 +133,78 @@ class Authenticator } /** + * @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 * @codeCoverageIgnore 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/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 @@ +<?php + +namespace Engelsystem\Helpers\Translation; + +use Gettext\Translator; + +class GettextTranslator extends Translator +{ + /** + * @param string $domain + * @param string $context + * @param string $original + * @return string + * @throws TranslationNotFound + */ + public function dpgettext($domain, $context, $original) + { + $this->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 @@ +<?php + +namespace Engelsystem\Helpers\Translation; + +use Exception; + +class TranslationNotFound extends Exception +{ +} diff --git a/src/Helpers/Translation/TranslationServiceProvider.php b/src/Helpers/Translation/TranslationServiceProvider.php new file mode 100644 index 00000000..09337dad --- /dev/null +++ b/src/Helpers/Translation/TranslationServiceProvider.php @@ -0,0 +1,86 @@ +<?php + +namespace Engelsystem\Helpers\Translation; + +use Engelsystem\Config\Config; +use Engelsystem\Container\ServiceProvider; +use Gettext\Translations; +use Symfony\Component\HttpFoundation\Session\Session; + +class TranslationServiceProvider extends ServiceProvider +{ + /** @var GettextTranslator */ + protected $translators = []; + + public function register(): void + { + /** @var Config $config */ + $config = $this->app->get('config'); + /** @var Session $session */ + $session = $this->app->get('session'); + + $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; + } + + $session->set('locale', $locale); + + $translator = $this->app->make( + Translator::class, + [ + '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 $locale + * @codeCoverageIgnore + */ + public function setLocale(string $locale): void + { + $locale .= '.UTF-8'; + // Set the users locale + putenv('LC_ALL=' . $locale); + setlocale(LC_ALL, $locale); + + // Reset numeric formatting to allow output of floats + 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/Translator.php b/src/Helpers/Translation/Translator.php index 94fbd795..8b11ecb4 100644 --- a/src/Helpers/Translator.php +++ b/src/Helpers/Translation/Translator.php @@ -1,6 +1,6 @@ <?php -namespace Engelsystem\Helpers; +namespace Engelsystem\Helpers\Translation; class Translator { @@ -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); } @@ -75,32 +110,6 @@ class Translator } /** - * 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 */ public function getLocale(): string diff --git a/src/Helpers/TranslationServiceProvider.php b/src/Helpers/TranslationServiceProvider.php deleted file mode 100644 index 4565dfcd..00000000 --- a/src/Helpers/TranslationServiceProvider.php +++ /dev/null @@ -1,63 +0,0 @@ -<?php - -namespace Engelsystem\Helpers; - -use Engelsystem\Config\Config; -use Engelsystem\Container\ServiceProvider; -use Symfony\Component\HttpFoundation\Session\Session; - -class TranslationServiceProvider extends ServiceProvider -{ - public function register() - { - /** @var Config $config */ - $config = $this->app->get('config'); - /** @var Session $session */ - $session = $this->app->get('session'); - - $locales = $config->get('locales'); - $locale = $config->get('default_locale'); - - $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']] - ); - $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) - { - // Set the users locale - putenv('LC_ALL=' . $locale); - setlocale(LC_ALL, $locale); - - // Reset numeric formatting to allow output of floats - putenv('LC_NUMERIC=C'); - setlocale(LC_NUMERIC, 'C'); - } -} 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 @@ +<?php + +namespace Engelsystem\Helpers; + +use Engelsystem\Config\Config; + +class Version +{ + /** @var Config */ + protected $config; + + /** @vat string */ + protected $storage; + + /** @var string */ + protected $versionFile = 'VERSION'; + + /** + * @param string $storage + * @param Config $config + */ + public function __construct(string $storage, Config $config) + { + $this->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 @@ +<?php + +namespace Engelsystem\Helpers; + +use Engelsystem\Container\ServiceProvider; + +class VersionServiceProvider extends ServiceProvider +{ + public function register() + { + $this->app->when(Version::class) + ->needs('$storage') + ->give($this->app->get('path.storage.app')); + } +} 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 @@ +<?php + +namespace Engelsystem\Http\Exceptions; + +use Engelsystem\Http\Validation\Validator; +use RuntimeException; +use Throwable; + +class ValidationException extends RuntimeException +{ + /** @var Validator */ + protected $validator; + + /** + * @param Validator $validator + * @param string $message + * @param int $code + * @param Throwable|null $previous + */ + public function __construct( + Validator $validator, + string $message = '', + int $code = 0, + Throwable $previous = null + ) { + $this->validator = $validator; + parent::__construct($message, $code, $previous); + } + + /** + * @return Validator + */ + public function getValidator(): Validator + { + return $this->validator; + } +} 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 @@ +<?php + +namespace Engelsystem\Http\Validation\Rules; + +use Respect\Validation\Rules\In as RespectIn; + +class In extends RespectIn +{ + /** + * @param mixed $haystack + * @param bool $compareIdentical + */ + public function __construct($haystack, $compareIdentical = false) + { + if (!is_array($haystack)) { + $haystack = explode(',', $haystack); + } + + parent::__construct($haystack, $compareIdentical); + } +} diff --git a/src/Http/Validation/Rules/NotIn.php b/src/Http/Validation/Rules/NotIn.php new file mode 100644 index 00000000..7f223c42 --- /dev/null +++ b/src/Http/Validation/Rules/NotIn.php @@ -0,0 +1,15 @@ +<?php + +namespace Engelsystem\Http\Validation\Rules; + +class NotIn extends In +{ + /** + * @param mixed $input + * @return bool + */ + public function validate($input) + { + return !parent::validate($input); + } +} 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 @@ +<?php + +namespace Engelsystem\Http\Validation; + +use Engelsystem\Http\Exceptions\ValidationException; +use Engelsystem\Http\Request; + +trait ValidatesRequest +{ + /** @var Validator */ + protected $validator; + + /** + * @param Request $request + * @param array $rules + * @return array + */ + protected function validate(Request $request, array $rules) + { + if (!$this->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..14530ae6 --- /dev/null +++ b/src/Http/Validation/ValidationServiceProvider.php @@ -0,0 +1,25 @@ +<?php + +namespace Engelsystem\Http\Validation; + +use Engelsystem\Application; +use Engelsystem\Container\ServiceProvider; +use Engelsystem\Controllers\BaseController; + +class ValidationServiceProvider extends ServiceProvider +{ + public function register() + { + $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..976f5682 --- /dev/null +++ b/src/Http/Validation/Validator.php @@ -0,0 +1,122 @@ +<?php + +namespace Engelsystem\Http\Validation; + +use Illuminate\Support\Str; +use InvalidArgumentException; +use Respect\Validation\Exceptions\ComponentException; +use Respect\Validation\Validator as RespectValidator; + +class Validator +{ + /** @var string[] */ + protected $errors = []; + + /** @var array */ + protected $data = []; + + /** @var array */ + protected $mapping = [ + 'accepted' => 'TrueVal', + 'int' => 'IntVal', + 'required' => 'NotEmpty', + ]; + + /** @var array */ + protected $nestedRules = ['optional', 'not']; + + /** + * @param array $data + * @param array $rules + * @return bool + */ + public function validate($data, $rules) + { + $this->errors = []; + $this->data = []; + + foreach ($rules as $key => $values) { + $v = new RespectValidator(); + $v->with('\\Engelsystem\\Http\\Validation\\Rules', true); + + $value = isset($data[$key]) ? $data[$key] : null; + $values = explode('|', $values); + + $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 { + 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 ($w->validate($value)) { + $this->data[$key] = $value; + } else { + $this->errors[$key][] = implode('.', ['validation', $key, $this->mapBack($rule)]); + } + + $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 + */ + 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..65e2e609 100644 --- a/src/Middleware/ErrorHandler.php +++ b/src/Middleware/ErrorHandler.php @@ -3,7 +3,10 @@ namespace Engelsystem\Middleware; 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; @@ -19,6 +22,22 @@ class ErrorHandler implements MiddlewareInterface protected $viewPrefix = 'errors/'; /** + * A list of inputs that are not saved from form input + * + * @var array + */ + protected $formIgnore = [ + 'password', + 'password_confirmation', + 'password2', + 'new_password', + 'new_password2', + 'new_pw', + 'new_pw2', + '_token', + ]; + + /** * @param TwigLoader $loader */ public function __construct(TwigLoader $loader) @@ -43,6 +62,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', Arr::except($request->request->all(), $this->formIgnore)); + } } $statusCode = $response->getStatusCode(); @@ -106,4 +140,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/src/Middleware/LegacyMiddleware.php b/src/Middleware/LegacyMiddleware.php index af2c6a70..27a15faa 100644 --- a/src/Middleware/LegacyMiddleware.php +++ b/src/Middleware/LegacyMiddleware.php @@ -3,7 +3,7 @@ namespace Engelsystem\Middleware; use Engelsystem\Helpers\Authenticator; -use Engelsystem\Helpers\Translator; +use Engelsystem\Helpers\Translation\Translator; use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Psr\Container\ContainerInterface; @@ -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/src/Middleware/SetLocale.php b/src/Middleware/SetLocale.php index 86fa0b7f..568adbe6 100644 --- a/src/Middleware/SetLocale.php +++ b/src/Middleware/SetLocale.php @@ -2,7 +2,7 @@ namespace Engelsystem\Middleware; -use Engelsystem\Helpers\Translator; +use Engelsystem\Helpers\Translation\Translator; use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\ServerRequestInterface; use Psr\Http\Server\MiddlewareInterface; 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 @@ +<?php + +namespace Engelsystem\Renderer; + +abstract class Engine implements EngineInterface +{ + /** @var array */ + protected $sharedData = []; + + /** + * @param mixed[]|string $key + * @param null $value + */ + public function share($key, $value = null) + { + if (!is_array($key)) { + $key = [$key => $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/Twig/Extensions/Translation.php b/src/Renderer/Twig/Extensions/Translation.php index 41619c19..3e6f30b4 100644 --- a/src/Renderer/Twig/Extensions/Translation.php +++ b/src/Renderer/Twig/Extensions/Translation.php @@ -2,7 +2,7 @@ namespace Engelsystem\Renderer\Twig\Extensions; -use Engelsystem\Helpers\Translator; +use Engelsystem\Helpers\Translation\Translator; use Twig_Extension as TwigExtension; use Twig_Extensions_TokenParser_Trans as TranslationTokenParser; use Twig_Filter as TwigFilter; 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/src/helpers.php b/src/helpers.php index 111141e4..051b78a3 100644 --- a/src/helpers.php +++ b/src/helpers.php @@ -4,7 +4,7 @@ use Engelsystem\Application; use Engelsystem\Config\Config; use Engelsystem\Helpers\Authenticator; -use Engelsystem\Helpers\Translator; +use Engelsystem\Helpers\Translation\Translator; use Engelsystem\Http\Request; use Engelsystem\Http\Response; use Engelsystem\Http\UrlGeneratorInterface; |