diff --git a/.phpstorm.meta.php b/.phpstorm.meta.php index 1c091ccaf..dc5b50a36 100644 --- a/.phpstorm.meta.php +++ b/.phpstorm.meta.php @@ -1,7 +1,6 @@ '@', + ] + ) + ); + override( + \DI\Container::make(0), + map( + [ + '' => '@', + ] + ) + ); } diff --git a/bin/console b/bin/console index 00cbee9e5..39eb7fde2 100644 --- a/bin/console +++ b/bin/console @@ -12,9 +12,12 @@ $app = App\AppFactory::create( ] ); +/** @var \Psr\Container\ContainerInterface|\DI\FactoryInterface $di */ $di = $app->getContainer(); -App\Customization::initCli(); +/** @var \App\Locale $locale */ +$locale = $di->make(\App\Locale::class); +$locale->register(); /** @var App\Console\Application $cli */ $cli = $di->get(App\Console\Application::class); diff --git a/composer.json b/composer.json index f67b14924..b75cae42b 100644 --- a/composer.json +++ b/composer.json @@ -69,6 +69,7 @@ "symfony/event-dispatcher": "^5", "symfony/finder": "^5", "symfony/lock": "^5.1", + "symfony/mailer": "^5.2", "symfony/messenger": "^5", "symfony/process": "^5", "symfony/property-access": "^5", diff --git a/composer.lock b/composer.lock index 26b8595d3..26c616742 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "8aed63d3424000ce9590c98912eb5a81", + "content-hash": "8d2dbb48bbce314e087712c31d62a372", "packages": [ { "name": "aws/aws-sdk-php", @@ -102,21 +102,20 @@ "source": { "type": "git", "url": "https://github.com/AzuraCast/azuraforms.git", - "reference": "70e51d4c1892392ad33529949f62cfb93ca5e48a" + "reference": "8002d78f62a34cdb14df8967136547cdf6c04083" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/AzuraCast/azuraforms/zipball/70e51d4c1892392ad33529949f62cfb93ca5e48a", - "reference": "70e51d4c1892392ad33529949f62cfb93ca5e48a", + "url": "https://api.github.com/repos/AzuraCast/azuraforms/zipball/8002d78f62a34cdb14df8967136547cdf6c04083", + "reference": "8002d78f62a34cdb14df8967136547cdf6c04083", "shasum": "" }, "require": { "php": ">=7.2" }, "require-dev": { - "overtrue/phplint": "^1.1", - "phpstan/phpstan": "^0.11.1", - "phpstan/phpstan-strict-rules": "^0.11.0", + "overtrue/phplint": "^2.0", + "phpstan/phpstan": "^0.12", "roave/security-advisories": "dev-master" }, "default-branch": true, @@ -150,7 +149,7 @@ "issues": "https://github.com/AzuraCast/azuraforms/issues", "source": "https://github.com/AzuraCast/azuraforms/tree/main" }, - "time": "2021-02-11T18:01:43+00:00" + "time": "2021-02-24T03:51:40+00:00" }, { "name": "azuracast/nowplaying", @@ -1836,6 +1835,74 @@ }, "time": "2020-10-24T22:13:54+00:00" }, + { + "name": "egulias/email-validator", + "version": "2.1.25", + "source": { + "type": "git", + "url": "https://github.com/egulias/EmailValidator.git", + "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/egulias/EmailValidator/zipball/0dbf5d78455d4d6a41d186da50adc1122ec066f4", + "reference": "0dbf5d78455d4d6a41d186da50adc1122ec066f4", + "shasum": "" + }, + "require": { + "doctrine/lexer": "^1.0.1", + "php": ">=5.5", + "symfony/polyfill-intl-idn": "^1.10" + }, + "require-dev": { + "dominicsayers/isemail": "^3.0.7", + "phpunit/phpunit": "^4.8.36|^7.5.15", + "satooshi/php-coveralls": "^1.0.1" + }, + "suggest": { + "ext-intl": "PHP Internationalization Libraries are required to use the SpoofChecking validation" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "2.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Egulias\\EmailValidator\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Eduardo Gulias Davis" + } + ], + "description": "A library for validating emails against several RFCs", + "homepage": "https://github.com/egulias/EmailValidator", + "keywords": [ + "email", + "emailvalidation", + "emailvalidator", + "validation", + "validator" + ], + "support": { + "issues": "https://github.com/egulias/EmailValidator/issues", + "source": "https://github.com/egulias/EmailValidator/tree/2.1.25" + }, + "funding": [ + { + "url": "https://github.com/egulias", + "type": "github" + } + ], + "time": "2020-12-29T14:50:06+00:00" + }, { "name": "friendsofphp/proxy-manager-lts", "version": "v1.0.3", @@ -6937,6 +7004,87 @@ ], "time": "2021-01-27T11:24:50+00:00" }, + { + "name": "symfony/mailer", + "version": "v5.2.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/mailer.git", + "reference": "1efa11a8f59b8ba706aa6ee112c4675dce4dccf6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mailer/zipball/1efa11a8f59b8ba706aa6ee112c4675dce4dccf6", + "reference": "1efa11a8f59b8ba706aa6ee112c4675dce4dccf6", + "shasum": "" + }, + "require": { + "egulias/email-validator": "^2.1.10", + "php": ">=7.2.5", + "psr/log": "~1.0", + "symfony/event-dispatcher": "^4.4|^5.0", + "symfony/mime": "^5.2", + "symfony/polyfill-php80": "^1.15", + "symfony/service-contracts": "^1.1|^2" + }, + "conflict": { + "symfony/http-kernel": "<4.4" + }, + "require-dev": { + "symfony/amazon-mailer": "^4.4|^5.0", + "symfony/google-mailer": "^4.4|^5.0", + "symfony/http-client-contracts": "^1.1|^2", + "symfony/mailchimp-mailer": "^4.4|^5.0", + "symfony/mailgun-mailer": "^4.4|^5.0", + "symfony/mailjet-mailer": "^4.4|^5.0", + "symfony/messenger": "^4.4|^5.0", + "symfony/postmark-mailer": "^4.4|^5.0", + "symfony/sendgrid-mailer": "^4.4|^5.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mailer\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Helps sending emails", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/mailer/tree/v5.2.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-02T06:10:15+00:00" + }, { "name": "symfony/messenger", "version": "v5.2.3", @@ -7025,6 +7173,88 @@ ], "time": "2021-01-27T11:24:50+00:00" }, + { + "name": "symfony/mime", + "version": "v5.2.3", + "source": { + "type": "git", + "url": "https://github.com/symfony/mime.git", + "reference": "7dee6a43493f39b51ff6c5bb2bd576fe40a76c86" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/mime/zipball/7dee6a43493f39b51ff6c5bb2bd576fe40a76c86", + "reference": "7dee6a43493f39b51ff6c5bb2bd576fe40a76c86", + "shasum": "" + }, + "require": { + "php": ">=7.2.5", + "symfony/deprecation-contracts": "^2.1", + "symfony/polyfill-intl-idn": "^1.10", + "symfony/polyfill-mbstring": "^1.0", + "symfony/polyfill-php80": "^1.15" + }, + "conflict": { + "phpdocumentor/reflection-docblock": "<3.2.2", + "phpdocumentor/type-resolver": "<1.4.0", + "symfony/mailer": "<4.4" + }, + "require-dev": { + "egulias/email-validator": "^2.1.10", + "phpdocumentor/reflection-docblock": "^3.0|^4.0|^5.0", + "symfony/dependency-injection": "^4.4|^5.0", + "symfony/property-access": "^4.4|^5.1", + "symfony/property-info": "^4.4|^5.1", + "symfony/serializer": "^5.2" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Mime\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Allows manipulating MIME messages", + "homepage": "https://symfony.com", + "keywords": [ + "mime", + "mime-type" + ], + "support": { + "source": "https://github.com/symfony/mime/tree/v5.2.3" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-02-02T06:10:15+00:00" + }, { "name": "symfony/polyfill-ctype", "version": "v1.22.1", @@ -7104,6 +7334,93 @@ ], "time": "2021-01-07T16:49:33+00:00" }, + { + "name": "symfony/polyfill-intl-idn", + "version": "v1.22.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-intl-idn.git", + "reference": "2d63434d922daf7da8dd863e7907e67ee3031483" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-intl-idn/zipball/2d63434d922daf7da8dd863e7907e67ee3031483", + "reference": "2d63434d922daf7da8dd863e7907e67ee3031483", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "symfony/polyfill-intl-normalizer": "^1.10", + "symfony/polyfill-php72": "^1.10" + }, + "suggest": { + "ext-intl": "For best performance" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "1.22-dev" + }, + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "psr-4": { + "Symfony\\Polyfill\\Intl\\Idn\\": "" + }, + "files": [ + "bootstrap.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Laurent Bassin", + "email": "laurent@bassin.info" + }, + { + "name": "Trevor Rowbotham", + "email": "trevor.rowbotham@pm.me" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for intl's idn_to_ascii and idn_to_utf8 functions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "idn", + "intl", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-intl-idn/tree/v1.22.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2021-01-22T09:19:47+00:00" + }, { "name": "symfony/polyfill-php80", "version": "v1.22.1", diff --git a/config/assets.php b/config/assets.php index 3d68d50ec..5ba36e802 100644 --- a/config/assets.php +++ b/config/assets.php @@ -1,7 +1,8 @@ [ 'js' => [ function (Request $request) { - $locale = $request->getAttribute('locale', Customization::DEFAULT_LOCALE); + /** @var Locale|null $locale */ + $localeObj = $request->getAttribute(ServerRequest::ATTR_LOCALE); + + $locale = ($localeObj instanceof Locale) + ? $localeObj->getLocale() + : Locale::DEFAULT_LOCALE; + $locale = explode('.', $locale)[0]; $localeShort = substr($locale, 0, 2); $localeWithDashes = str_replace('_', '-', $locale); diff --git a/config/forms/settings.php b/config/forms/settings.php index 89e8aa463..816a296dc 100644 --- a/config/forms/settings.php +++ b/config/forms/settings.php @@ -224,6 +224,95 @@ return [ ], ], + 'mail' => [ + 'tab' => 'services', + 'legend' => __('E-mail Delivery Service'), + 'description' => __('Used for "Forgot Password" functionality, web hooks and other functions.'), + 'use_grid' => true, + + 'elements' => [ + + 'mailEnabled' => [ + 'toggle', + [ + 'label' => __('Enable Mail Delivery'), + 'selected_text' => __('Yes'), + 'deselected_text' => __('No'), + 'default' => false, + 'form_group_class' => 'col-md-12', + ], + ], + + 'mailSenderName' => [ + 'text', + [ + 'label' => __('Sender Name'), + 'default' => 'AzuraCast', + 'form_group_class' => 'col-md-6', + ], + ], + + 'mailSenderEmail' => [ + 'email', + [ + 'label' => __('Sender E-mail Address'), + 'required' => false, + 'default' => '', + 'form_group_class' => 'col-md-6', + ], + ], + + 'mailSmtpHost' => [ + 'text', + [ + 'label' => __('SMTP Host'), + 'default' => '', + 'form_group_class' => 'col-md-4', + ], + ], + + 'mailSmtpPort' => [ + 'number', + [ + 'label' => __('SMTP Port'), + 'default' => 465, + 'form_group_class' => 'col-md-3', + ], + ], + + 'mailSmtpSecure' => [ + 'toggle', + [ + 'label' => __('Use Secure (TLS) SMTP Connection'), + 'description' => __('Usually enabled for port 465, disabled for ports 587 or 25.'), + + 'selected_text' => __('Yes'), + 'deselected_text' => __('No'), + 'default' => true, + 'form_group_class' => 'col-md-5', + ], + ], + + 'mailSmtpUsername' => [ + 'text', + [ + 'label' => __('SMTP Username'), + 'default' => '', + 'form_group_class' => 'col-md-6', + ], + ], + + 'mailSmtpPassword' => [ + 'password', + [ + 'label' => __('SMTP Password'), + 'default' => '', + 'form_group_class' => 'col-md-6', + ], + ], + ], + ], + 'thirdPartyServices' => [ 'tab' => 'services', 'use_grid' => true, diff --git a/config/messagequeue.php b/config/messagequeue.php index 3a2d8b789..5a1141f67 100644 --- a/config/messagequeue.php +++ b/config/messagequeue.php @@ -3,6 +3,7 @@ use App\Message; use App\Radio\Backend\Liquidsoap; use App\Sync\Task; +use Symfony\Component\Mailer; return [ Message\AddNewMediaMessage::class => Task\CheckMediaTask::class, @@ -17,4 +18,6 @@ return [ Message\RunSyncTaskMessage::class => App\Sync\Runner::class, Message\DispatchWebhookMessage::class => App\Webhook\Dispatcher::class, + + Mailer\Messenger\SendEmailMessage::class => Mailer\Messenger\MessageHandler::class, ]; diff --git a/config/routes/base.php b/config/routes/base.php index bf9ebeeee..dfc464697 100644 --- a/config/routes/base.php +++ b/config/routes/base.php @@ -71,6 +71,14 @@ return function (App $app) { ->setName('account:login:2fa') ->add(Middleware\EnableView::class); + $app->map(['GET', 'POST'], '/forgot', Controller\Frontend\Account\ForgotPasswordAction::class) + ->setName('account:forgot') + ->add(Middleware\EnableView::class); + + $app->map(['GET', 'POST'], '/recover/{token}', Controller\Frontend\Account\RecoverAction::class) + ->setName('account:recover') + ->add(Middleware\EnableView::class); + $app->group( '/setup', function (RouteCollectorProxy $group) { diff --git a/config/services.php b/config/services.php index 1cb9c9e70..283579af9 100644 --- a/config/services.php +++ b/config/services.php @@ -365,6 +365,73 @@ return [ ); }, + Symfony\Component\Messenger\MessageBusInterface::class => DI\get( + Symfony\Component\Messenger\MessageBus::class + ), + + // Mail functionality + Symfony\Component\Mailer\Transport\TransportInterface::class => function ( + App\Entity\Repository\SettingsRepository $settingsRepo, + App\EventDispatcher $eventDispatcher, + Monolog\Logger $logger + ) { + $settings = $settingsRepo->readSettings(); + + if ($settings->getMailEnabled()) { + $requiredSettings = [ + 'mailSenderEmail' => $settings->getMailSenderEmail(), + 'mailSmtpHost' => $settings->getMailSmtpHost(), + 'mailSmtpPort' => $settings->getMailSmtpPort(), + ]; + + $hasAllSettings = true; + foreach ($requiredSettings as $settingKey => $setting) { + if (empty($setting)) { + $hasAllSettings = false; + break; + } + } + + if ($hasAllSettings) { + $transport = new Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( + $settings->getMailSmtpHost(), + $settings->getMailSmtpPort(), + $settings->getMailSmtpSecure(), + $eventDispatcher, + $logger + ); + + if (!empty($settings->getMailSmtpUsername())) { + $transport->setUsername($settings->getMailSmtpUsername()); + $transport->setPassword($settings->getMailSmtpPassword()); + } + + return $transport; + } + } + + return new Symfony\Component\Mailer\Transport\NullTransport( + $eventDispatcher, + $logger + ); + }, + + Symfony\Component\Mailer\Mailer::class => function ( + Symfony\Component\Mailer\Transport\TransportInterface $transport, + Symfony\Component\Messenger\MessageBus $messageBus, + App\EventDispatcher $eventDispatcher + ) { + return new Symfony\Component\Mailer\Mailer( + $transport, + $messageBus, + $eventDispatcher + ); + }, + + Symfony\Component\Mailer\MailerInterface::class => DI\get( + Symfony\Component\Mailer\Mailer::class + ), + // Supervisor manager Supervisor\Supervisor::class => function (Environment $settings, Psr\Log\LoggerInterface $logger) { $client = new fXmlRpc\Client( diff --git a/src/Auth.php b/src/Auth.php index 79ebae3d9..1c63ff96e 100644 --- a/src/Auth.php +++ b/src/Auth.php @@ -44,7 +44,7 @@ class Auth * @param string $username * @param string $password */ - public function authenticate($username, $password): ?User + public function authenticate(string $username, string $password): ?User { $user_auth = $this->userRepo->authenticate($username, $password); diff --git a/src/Controller/Frontend/Account/ForgotPasswordAction.php b/src/Controller/Frontend/Account/ForgotPasswordAction.php new file mode 100644 index 000000000..e807af3cd --- /dev/null +++ b/src/Controller/Frontend/Account/ForgotPasswordAction.php @@ -0,0 +1,94 @@ +getFlash(); + $view = $request->getView(); + + $settings = $settingsRepo->readSettings(); + if (!$settings->getMailEnabled()) { + return $view->renderToResponse($response, 'frontend/account/forgot_disabled'); + } + + if ($request->isPost()) { + try { + $rateLimit->checkRequestRateLimit($request, 'forgot', 30, 3); + } catch (RateLimitExceededException $e) { + $flash->addMessage( + sprintf( + '%s
%s', + __('Too many forgot password attempts'), + __( + 'You have attempted to reset your password too many times. Please wait ' + . '30 seconds and try again.' + ) + ), + Flash::ERROR + ); + + return $response->withRedirect($request->getUri()->getPath()); + } + + $email = $request->getParsedBodyParam('email', ''); + $user = $userRepo->findByEmail($email); + + if ($user instanceof Entity\User) { + $email = new Email(); + $email->from(new Address($settings->getMailSenderEmail(), $settings->getMailSenderName())); + $email->to($user->getEmail()); + + $email->subject(__('Account Recovery Link')); + + $loginToken = $loginTokenRepo->createToken($user); + $email->text( + $view->render( + 'mail/forgot', + [ + 'token' => (string)$loginToken, + ] + ) + ); + + $mailer->send($email); + } + + $flash->addMessage( + sprintf( + '%s
%s', + __('Account recovery e-mail sent.'), + __( + 'If the e-mail address you provided is in the system, check your inbox ' + . 'for a password reset message.' + ) + ), + Flash::SUCCESS + ); + + return $response->withRedirect($request->getRouter()->named('home')); + } + + return $view->renderToResponse($response, 'frontend/account/forgot'); + } +} diff --git a/src/Controller/Frontend/Account/LoginAction.php b/src/Controller/Frontend/Account/LoginAction.php index a80f366cf..28dce4bf0 100644 --- a/src/Controller/Frontend/Account/LoginAction.php +++ b/src/Controller/Frontend/Account/LoginAction.php @@ -2,8 +2,7 @@ namespace App\Controller\Frontend\Account; -use App\Entity\Repository\SettingsRepository; -use App\Entity\User; +use App\Entity; use App\Exception\RateLimitExceededException; use App\Http\Response; use App\Http\ServerRequest; @@ -20,7 +19,8 @@ class LoginAction Response $response, EntityManagerInterface $em, RateLimit $rateLimit, - SettingsRepository $settingsRepo + Entity\Repository\SettingsRepository $settingsRepo, + Entity\Repository\UserLoginTokenRepository $loginTokenRepo ): ResponseInterface { $auth = $request->getAuth(); $acl = $request->getAcl(); @@ -64,7 +64,7 @@ class LoginAction $user = $auth->authenticate($request->getParam('username'), $request->getParam('password')); - if ($user instanceof User) { + if ($user instanceof Entity\User) { // If user selects "remember me", extend the cookie/session lifetime. $session = $request->getSession(); if ($session instanceof SessionCookiePersistenceInterface) { diff --git a/src/Controller/Frontend/Account/RecoverAction.php b/src/Controller/Frontend/Account/RecoverAction.php new file mode 100644 index 000000000..823f93812 --- /dev/null +++ b/src/Controller/Frontend/Account/RecoverAction.php @@ -0,0 +1,62 @@ +getFlash(); + + $user = $loginTokenRepo->authenticate($token); + + if (!$user instanceof Entity\User) { + $flash->addMessage( + sprintf( + '%s', + __('Invalid token specified.'), + ), + Flash::ERROR + ); + + return $response->withRedirect($request->getRouter()->named('account:login')); + } + + if ($request->isPost()) { + $newPassword = $request->getParsedBodyParam('password'); + + $user->setNewPassword($newPassword); + $em->persist($user); + $em->flush(); + + $request->getAuth()->setUser($user); + + $loginTokenRepo->revokeForUser($user); + + $flash->addMessage( + sprintf( + '%s
%s', + __('Logged in using account recovery token'), + __('Your password has been updated.') + ), + Flash::SUCCESS + ); + + return $response->withRedirect($request->getRouter()->named('dashboard')); + } + + return $request->getView()->renderToResponse($response, 'frontend/account/recover'); + } +} diff --git a/src/Customization.php b/src/Customization.php index cfd433200..c8a824939 100644 --- a/src/Customization.php +++ b/src/Customization.php @@ -5,13 +5,10 @@ namespace App; use App\Entity; use App\Http\ServerRequest; use App\Service\NChan; -use Gettext\Translator; -use Locale; use Psr\Http\Message\ServerRequestInterface; class Customization { - public const DEFAULT_LOCALE = 'en_US.UTF-8'; public const DEFAULT_THEME = 'light'; public const THEME_LIGHT = 'light'; @@ -23,7 +20,7 @@ class Customization protected Environment $environment; - protected string $locale = self::DEFAULT_LOCALE; + protected Locale $locale; protected string $theme = self::DEFAULT_THEME; @@ -44,8 +41,6 @@ class Customization // Register current user $this->user = $request->getAttribute(ServerRequest::ATTR_USER); - $this->locale = $this->initLocale($request); - // Register current theme $queryParams = $request->getQueryParams(); @@ -59,88 +54,16 @@ class Customization } } - // Set up the PHP translator - $translator = new Translator(); - - $locale_base = $environment->getBaseDirectory() . '/resources/locale/compiled'; - $locale_path = $locale_base . '/' . $this->locale . '.php'; - - if (file_exists($locale_path)) { - $translator->loadTranslations($locale_path); - } - - $translator->register(); - - // Register translation superglobal functions - setlocale(LC_ALL, $this->locale); + // Register locale + $this->locale = new Locale($environment, $request); + $this->locale->register(); } - /** - * Return the user-customized, browser-specified or system default locale. - * - * @param ServerRequestInterface|null $request - */ - protected function initLocale(?ServerRequestInterface $request = null): string - { - $supported_locales = $this->environment->getSupportedLocales(); - $try_locales = []; - - // Prefer user-based profile locale. - if ($this->user !== null && !empty($this->user->getLocale()) && 'default' !== $this->user->getLocale()) { - $try_locales[] = $this->user->getLocale(); - } - - // Attempt to load from browser headers. - if ($request instanceof ServerRequestInterface) { - $server_params = $request->getServerParams(); - $browser_locale = Locale::acceptFromHttp($server_params['HTTP_ACCEPT_LANGUAGE'] ?? null); - - if (!empty($browser_locale)) { - if (2 === strlen($browser_locale)) { - $browser_locale = strtolower($browser_locale) . '_' . strtoupper($browser_locale); - } - - $try_locales[] = substr($browser_locale, 0, 5) . '.UTF-8'; - } - } - - // Attempt to load from environment variable. - $envLocale = $this->environment->getLang(); - if (!empty($envLocale)) { - $try_locales[] = substr($envLocale, 0, 5) . '.UTF-8'; - } - - foreach ($try_locales as $exact_locale) { - // Prefer exact match. - if (isset($supported_locales[$exact_locale])) { - return $exact_locale; - } - - // Use approximate match if available. - foreach ($supported_locales as $lang_code => $lang_name) { - if (strpos($exact_locale, substr($lang_code, 0, 2)) === 0) { - return $lang_code; - } - } - } - - // Default to system option. - return self::DEFAULT_LOCALE; - } - - public function getLocale(): string + public function getLocale(): Locale { return $this->locale; } - /** - * @return string A shortened locale (minus .UTF-8) for use in Vue. - */ - public function getVueLocale(): string - { - return json_encode(substr($this->getLocale(), 0, 5), JSON_THROW_ON_ERROR); - } - /** * Returns the user-customized or system default theme. */ @@ -235,13 +158,4 @@ class Customization return $this->settings->getEnableWebsockets(); } - - /** - * Initialize the CLI without instantiating the Doctrine DB stack (allowing cache clearing, etc.). - */ - public static function initCli(): void - { - $translator = new Translator(); - $translator->register(); - } } diff --git a/src/Entity/ApiKey.php b/src/Entity/ApiKey.php index f74439e42..eca6e41a7 100644 --- a/src/Entity/ApiKey.php +++ b/src/Entity/ApiKey.php @@ -17,24 +17,9 @@ use JsonSerializable; */ class ApiKey implements JsonSerializable { + use Traits\HasSplitTokenFields; use Traits\TruncateStrings; - /** - * @ORM\Column(name="id", type="string", length=16) - * @ORM\Id - * @var string - */ - protected $id; - - /** - * @ORM\Column(name="verifier", type="string", length=128, nullable=false) - * - * @AuditLog\AuditIgnore() - * - * @var string - */ - protected $verifier; - /** * @ORM\ManyToOne(targetEntity="User", inversedBy="api_keys", fetch="EAGER") * @ORM\JoinColumns({ @@ -53,13 +38,7 @@ class ApiKey implements JsonSerializable public function __construct(User $user, SplitToken $token) { $this->user = $user; - $this->id = $token->identifier; - $this->verifier = $token->hashVerifier(); - } - - public function getId(): string - { - return $this->id; + $this->setFromToken($token); } public function getUser(): User @@ -67,17 +46,6 @@ class ApiKey implements JsonSerializable return $this->user; } - /** - * Verify an incoming API key against the verifier on this record. - * - * @param SplitToken $userSuppliedToken - * - */ - public function verify(SplitToken $userSuppliedToken): bool - { - return $userSuppliedToken->verify($this->verifier); - } - /** * @AuditLog\AuditIdentifier */ diff --git a/src/Entity/Migration/Version20210226053617.php b/src/Entity/Migration/Version20210226053617.php new file mode 100644 index 000000000..8f2f83843 --- /dev/null +++ b/src/Entity/Migration/Version20210226053617.php @@ -0,0 +1,32 @@ +addSql('CREATE TABLE user_login_tokens (id VARCHAR(16) NOT NULL, user_id INT DEFAULT NULL, created_at INT NOT NULL, verifier VARCHAR(128) NOT NULL, INDEX IDX_DDF24A16A76ED395 (user_id), PRIMARY KEY(id)) DEFAULT CHARACTER SET utf8mb4 COLLATE `utf8mb4_general_ci` ENGINE = InnoDB'); + $this->addSql('ALTER TABLE user_login_tokens ADD CONSTRAINT FK_DDF24A16A76ED395 FOREIGN KEY (user_id) REFERENCES users (uid) ON DELETE CASCADE'); + } + + public function down(Schema $schema): void + { + // this down() migration is auto-generated, please modify it to your needs + $this->addSql('DROP TABLE user_login_tokens'); + } +} diff --git a/src/Entity/Repository/AbstractSplitTokenRepository.php b/src/Entity/Repository/AbstractSplitTokenRepository.php new file mode 100644 index 000000000..a97380b60 --- /dev/null +++ b/src/Entity/Repository/AbstractSplitTokenRepository.php @@ -0,0 +1,30 @@ +repository->find($userSuppliedToken->identifier); + + if ($tokenEntity instanceof $this->entityClass) { + return ($tokenEntity->verify($userSuppliedToken)) + ? $tokenEntity->getUser() + : null; + } + + return null; + } +} diff --git a/src/Entity/Repository/ApiKeyRepository.php b/src/Entity/Repository/ApiKeyRepository.php index 47e28ab29..242c451dd 100644 --- a/src/Entity/Repository/ApiKeyRepository.php +++ b/src/Entity/Repository/ApiKeyRepository.php @@ -2,29 +2,6 @@ namespace App\Entity\Repository; -use App\Doctrine\Repository; -use App\Entity; -use App\Security\SplitToken; - -class ApiKeyRepository extends Repository +class ApiKeyRepository extends AbstractSplitTokenRepository { - /** - * Given an API key string in the format `identifier:verifier`, find and authenticate an API key. - * - * @param string $key - */ - public function authenticate(string $key): ?Entity\User - { - $userSuppliedToken = SplitToken::fromKeyString($key); - - $api_key = $this->repository->find($userSuppliedToken->identifier); - - if ($api_key instanceof Entity\ApiKey) { - return ($api_key->verify($userSuppliedToken)) - ? $api_key->getUser() - : null; - } - - return null; - } } diff --git a/src/Entity/Repository/UserLoginTokenRepository.php b/src/Entity/Repository/UserLoginTokenRepository.php new file mode 100644 index 000000000..c70199969 --- /dev/null +++ b/src/Entity/Repository/UserLoginTokenRepository.php @@ -0,0 +1,43 @@ +em->persist($loginToken); + $this->em->flush(); + + return $token; + } + + public function revokeForUser(Entity\User $user): void + { + $this->em->createQuery( + <<<'DQL' + DELETE FROM App\Entity\UserLoginToken ult + WHERE ult.user = :user + DQL + )->setParameter('user', $user) + ->execute(); + } + + public function cleanup(): void + { + $threshold = time() - 86400; // One day + + $this->em->createQuery( + <<<'DQL' + DELETE FROM App\Entity\UserLoginToken ut WHERE ut.created_at <= :threshold + DQL + )->setParameter('threshold', $threshold) + ->execute(); + } +} diff --git a/src/Entity/Repository/UserRepository.php b/src/Entity/Repository/UserRepository.php index 4f7fd1799..4e4f45065 100644 --- a/src/Entity/Repository/UserRepository.php +++ b/src/Entity/Repository/UserRepository.php @@ -7,35 +7,36 @@ use App\Entity; class UserRepository extends Repository { - /** - * @param string $username - * @param string $password - * - * @return bool|null|object - */ - public function authenticate($username, $password) + public function find(int $id): ?Entity\User { - $login_info = $this->repository->findOneBy(['email' => $username]); - - if (!($login_info instanceof Entity\User)) { - return false; - } - - if ($login_info->verifyPassword($password)) { - return $login_info; - } - return false; + return $this->repository->find($id); } - /** - * Creates or returns an existing user with the specified e-mail address. - * - * @param string $email - */ - public function getOrCreate($email): Entity\User + public function findByEmail(string $email): ?Entity\User { - $user = $this->repository->findOneBy(['email' => $email]); + return $this->repository->findOneby(['email' => $email]); + } + public function authenticate(string $username, string $password): ?Entity\User + { + $user = $this->findByEmail($username); + + if ($user instanceof Entity\User && $user->verifyPassword($password)) { + return $user; + } + + // Verify a password (and do nothing with it) to avoid timing attacks on authentication. + password_verify( + $password, + '$argon2id$v=19$m=65536,t=4,p=1$WHptOW0xM1UweHp0ZXpmNg$qC5anR37sV/G8k7l09eLKLHukkUD7e5csUdbmjGYsgs' + ); + + return null; + } + + public function getOrCreate(string $email): Entity\User + { + $user = $this->findByEmail($email); if (!($user instanceof Entity\User)) { $user = new Entity\User(); $user->setEmail($email); diff --git a/src/Entity/Settings.php b/src/Entity/Settings.php index 56ab711ed..1bf36a58d 100644 --- a/src/Entity/Settings.php +++ b/src/Entity/Settings.php @@ -784,4 +784,132 @@ class Settings { $this->enableAdvancedFeatures = $enableAdvancedFeatures; } + + /** + * @OA\Property(example="true") + * @var bool Enable e-mail delivery across the application. + */ + protected bool $mailEnabled = false; + + public function getMailEnabled(): bool + { + return $this->mailEnabled; + } + + public function setMailEnabled(bool $mailEnabled): void + { + $this->mailEnabled = $mailEnabled; + } + + /** + * @OA\Property(example="AzuraCast") + * @var string The name of the sender of system e-mails. + */ + protected string $mailSenderName = ''; + + public function getMailSenderName(): string + { + return $this->mailSenderName; + } + + public function setMailSenderName(string $mailSenderName): void + { + $this->mailSenderName = $mailSenderName; + } + + /** + * @OA\Property(example="example@example.com") + * @var string The e-mail address of the sender of system e-mails. + */ + protected string $mailSenderEmail = ''; + + public function getMailSenderEmail(): string + { + return $this->mailSenderEmail; + } + + public function setMailSenderEmail(string $mailSenderEmail): void + { + $this->mailSenderEmail = $mailSenderEmail; + } + + /** + * @OA\Property(example="smtp.example.com") + * @var string The host to send outbound SMTP mail. + */ + protected string $mailSmtpHost = ''; + + public function getMailSmtpHost(): string + { + return $this->mailSmtpHost; + } + + public function setMailSmtpHost(string $mailSmtpHost): void + { + $this->mailSmtpHost = $mailSmtpHost; + } + + /** + * @OA\Property(example=465) + * @var int The port for sending outbound SMTP mail. + */ + protected int $mailSmtpPort = 0; + + public function getMailSmtpPort(): int + { + return $this->mailSmtpPort; + } + + public function setMailSmtpPort(int $mailSmtpPort): void + { + $this->mailSmtpPort = $mailSmtpPort; + } + + /** + * @OA\Property(example="username") + * @var string The username when connecting to SMTP mail. + */ + protected string $mailSmtpUsername = ''; + + public function getMailSmtpUsername(): string + { + return $this->mailSmtpUsername; + } + + public function setMailSmtpUsername(string $mailSmtpUsername): void + { + $this->mailSmtpUsername = $mailSmtpUsername; + } + + /** + * @OA\Property(example="password") + * @var string The password when connecting to SMTP mail. + */ + protected string $mailSmtpPassword = ''; + + public function getMailSmtpPassword(): string + { + return $this->mailSmtpPassword; + } + + public function setMailSmtpPassword(string $mailSmtpPassword): void + { + $this->mailSmtpPassword = $mailSmtpPassword; + } + + /** + * @OA\Property(example="true") + * @var bool Whether to use a secure (TLS) connection when sending SMTP mail. + */ + protected bool $mailSmtpSecure = true; + + public function getMailSmtpSecure(): bool + { + return $this->mailSmtpSecure; + } + + public function setMailSmtpSecure(bool $mailSmtpSecure): void + { + $this->mailSmtpSecure = $mailSmtpSecure; + } } diff --git a/src/Entity/Traits/HasSplitTokenFields.php b/src/Entity/Traits/HasSplitTokenFields.php new file mode 100644 index 000000000..ade94cbda --- /dev/null +++ b/src/Entity/Traits/HasSplitTokenFields.php @@ -0,0 +1,48 @@ +id = $token->identifier; + $this->verifier = $token->hashVerifier(); + } + + public function getId(): string + { + return $this->id; + } + + /** + * Verify an incoming API key against the verifier on this record. + * + * @param SplitToken $userSuppliedToken + * + */ + public function verify(SplitToken $userSuppliedToken): bool + { + return $userSuppliedToken->verify($this->verifier); + } +} diff --git a/src/Entity/UserLoginToken.php b/src/Entity/UserLoginToken.php new file mode 100644 index 000000000..59b103db3 --- /dev/null +++ b/src/Entity/UserLoginToken.php @@ -0,0 +1,44 @@ +user = $user; + $this->setFromToken($token); + $this->created_at = time(); + } + + public function getUser(): User + { + return $this->user; + } +} diff --git a/src/Http/ServerRequest.php b/src/Http/ServerRequest.php index 825a98436..a07185eb5 100644 --- a/src/Http/ServerRequest.php +++ b/src/Http/ServerRequest.php @@ -7,6 +7,7 @@ use App\Auth; use App\Customization; use App\Entity; use App\Exception; +use App\Locale; use App\Radio; use App\RateLimit; use App\Session; @@ -22,6 +23,7 @@ final class ServerRequest extends \Slim\Http\ServerRequest public const ATTR_ROUTER = 'app_router'; public const ATTR_RATE_LIMIT = 'app_rate_limit'; public const ATTR_ACL = 'acl'; + public const ATTR_LOCALE = 'locale'; public const ATTR_CUSTOMIZATION = 'customization'; public const ATTR_AUTH = 'auth'; public const ATTR_STATION = 'station'; @@ -60,6 +62,11 @@ final class ServerRequest extends \Slim\Http\ServerRequest return $this->getAttributeOfClass(self::ATTR_RATE_LIMIT, RateLimit::class); } + public function getLocale(): Locale + { + return $this->getAttributeOfClass(self::ATTR_LOCALE, Locale::class); + } + public function getCustomization(): Customization { return $this->getAttributeOfClass(self::ATTR_CUSTOMIZATION, Customization::class); diff --git a/src/Locale.php b/src/Locale.php new file mode 100644 index 000000000..9de3c9df3 --- /dev/null +++ b/src/Locale.php @@ -0,0 +1,116 @@ +environment = $environment; + $this->request = $request; + + $this->locale = $this->determineLocale(); + } + + protected function determineLocale(): string + { + $possibleLocales = []; + + // Attempt to load from request if provided. + if ($this->request instanceof ServerRequestInterface) { + // Prefer user-based profile locale. + $user = $this->request->getAttribute(ServerRequest::ATTR_USER); + if (null !== $user && !empty($user->getLocale()) && 'default' !== $user->getLocale()) { + $possibleLocales[] = $user->getLocale(); + } + + $server_params = $this->request->getServerParams(); + $browser_locale = \Locale::acceptFromHttp($server_params['HTTP_ACCEPT_LANGUAGE'] ?? null); + + if (!empty($browser_locale)) { + if (2 === strlen($browser_locale)) { + $browser_locale = strtolower($browser_locale) . '_' . strtoupper($browser_locale); + } + + $possibleLocales[] = substr($browser_locale, 0, 5) . '.UTF-8'; + } + } + + // Attempt to load from environment variable. + $envLocale = $this->environment->getLang(); + if (!empty($envLocale)) { + $possibleLocales[] = substr($envLocale, 0, 5) . '.UTF-8'; + } + + return $this->getValidLocale($possibleLocales); + } + + protected function getValidLocale(array $possibleLocales): string + { + $supportedLocales = $this->environment->getSupportedLocales(); + + foreach ($possibleLocales as $locale) { + // Prefer exact match. + if (isset($supportedLocales[$locale])) { + return $locale; + } + + // Use approximate match if available. + foreach ($supportedLocales as $langCode => $langName) { + if (strpos($locale, substr($langCode, 0, 2)) === 0) { + return $langCode; + } + } + } + + return self::DEFAULT_LOCALE; + } + + public function getLocale(): string + { + return $this->locale; + } + + /** + * @return string A shortened locale (minus .UTF-8) for use in Vue. + */ + public function getVueLocale(): string + { + return json_encode(substr($this->locale, 0, 5), JSON_THROW_ON_ERROR); + } + + public function setLocale(string $newLocale = self::DEFAULT_LOCALE): void + { + $this->locale = $newLocale; + } + + public function register(): void + { + $translator = new Translator(); + + $localeBase = $this->environment->getBaseDirectory() . '/resources/locale/compiled'; + $localePath = $localeBase . '/' . $this->locale . '.php'; + if (file_exists($localePath)) { + $translator->loadTranslations($localePath); + } + + $translator->register(); + + // Register translation superglobal functions + setlocale(LC_ALL, $this->locale); + } +} diff --git a/src/MessageQueue/QueueManager.php b/src/MessageQueue/QueueManager.php index 892ec18c7..1bd908780 100644 --- a/src/MessageQueue/QueueManager.php +++ b/src/MessageQueue/QueueManager.php @@ -39,7 +39,9 @@ class QueueManager implements SendersLocatorInterface $message = $envelope->getMessage(); if (!$message instanceof AbstractMessage) { - return []; + return [ + $this->getTransport(self::QUEUE_NORMAL_PRIORITY), + ]; } $queue = $message->getQueue(); diff --git a/src/Middleware/GetCurrentUser.php b/src/Middleware/GetCurrentUser.php index 6c7d211d6..d2eccc5d9 100644 --- a/src/Middleware/GetCurrentUser.php +++ b/src/Middleware/GetCurrentUser.php @@ -42,6 +42,8 @@ class GetCurrentUser implements MiddlewareInterface ->withAttribute('is_logged_in', (null !== $user)); // Initialize Customization (timezones, locales, etc) based on the current logged in user. + + /** @var Customization $customization */ $customization = $this->factory->make( Customization::class, [ @@ -50,6 +52,8 @@ class GetCurrentUser implements MiddlewareInterface ); // Initialize ACL (can only be initialized after Customization as it contains localizations). + + /** @var Acl $acl */ $acl = $this->factory->make( Acl::class, [ @@ -58,7 +62,7 @@ class GetCurrentUser implements MiddlewareInterface ); $request = $request - ->withAttribute('locale', $customization->getLocale()) + ->withAttribute(ServerRequest::ATTR_LOCALE, $customization->getLocale()) ->withAttribute(ServerRequest::ATTR_CUSTOMIZATION, $customization) ->withAttribute(ServerRequest::ATTR_ACL, $acl); diff --git a/src/Sync/Task/CleanupLoginTokensTask.php b/src/Sync/Task/CleanupLoginTokensTask.php new file mode 100644 index 000000000..92adb04d7 --- /dev/null +++ b/src/Sync/Task/CleanupLoginTokensTask.php @@ -0,0 +1,27 @@ +loginTokenRepo = $loginTokenRepo; + } + + public function run(bool $force = false): void + { + $this->loginTokenRepo->cleanup(); + } +} diff --git a/src/Sync/TaskLocator.php b/src/Sync/TaskLocator.php index 799b4092c..95f0cf109 100644 --- a/src/Sync/TaskLocator.php +++ b/src/Sync/TaskLocator.php @@ -34,6 +34,7 @@ class TaskLocator GetSyncTasks::SYNC_LONG => [ Task\RunAnalyticsTask::class, Task\RunAutomatedAssignmentTask::class, + Task\CleanupLoginTokensTask::class, Task\CleanupHistoryTask::class, Task\CleanupStorageTask::class, Task\RotateLogsTask::class, diff --git a/src/View.php b/src/View.php index 4a99b4a72..0862e1f9c 100644 --- a/src/View.php +++ b/src/View.php @@ -2,6 +2,7 @@ namespace App; +use App\Http\Router; use App\Http\ServerRequest; use DI\FactoryInterface; use Doctrine\Inflector\InflectorFactory; @@ -16,24 +17,18 @@ class View extends Engine { protected Assets $assets; + protected ?ServerRequestInterface $request = null; + public function __construct( FactoryInterface $factory, Environment $environment, EventDispatcher $dispatcher, Version $version, - ServerRequestInterface $request + ?ServerRequestInterface $request = null ) { parent::__construct($environment->getViewsDirectory(), 'phtml'); // Add non-request-dependent content. - $this->addData( - [ - 'environment' => $environment, - 'version' => $version, - ] - ); - - // Add request-dependent content. $this->assets = $factory->make( Assets::class, [ @@ -43,17 +38,35 @@ class View extends Engine $this->addData( [ - 'request' => $request, - 'router' => $request->getAttribute(ServerRequest::ATTR_ROUTER), - 'auth' => $request->getAttribute(ServerRequest::ATTR_AUTH), - 'acl' => $request->getAttribute(ServerRequest::ATTR_ACL), - 'customization' => $request->getAttribute(ServerRequest::ATTR_CUSTOMIZATION), - 'flash' => $request->getAttribute(ServerRequest::ATTR_SESSION_FLASH), - 'user' => $request->getAttribute(ServerRequest::ATTR_USER), + 'environment' => $environment, + 'version' => $version, 'assets' => $this->assets, ] ); + // Add request-dependent content. + $this->request = $request; + + if ($request instanceof ServerRequestInterface) { + $this->addData( + [ + 'request' => $request, + 'router' => $request->getAttribute(ServerRequest::ATTR_ROUTER), + 'auth' => $request->getAttribute(ServerRequest::ATTR_AUTH), + 'acl' => $request->getAttribute(ServerRequest::ATTR_ACL), + 'customization' => $request->getAttribute(ServerRequest::ATTR_CUSTOMIZATION), + 'flash' => $request->getAttribute(ServerRequest::ATTR_SESSION_FLASH), + 'user' => $request->getAttribute(ServerRequest::ATTR_USER), + ] + ); + } else { + $this->addData( + [ + 'router' => $factory->make(Router::class), + ] + ); + } + $this->registerFunction( 'escapeJs', function ($string) { diff --git a/templates/frontend/account/forgot.phtml b/templates/frontend/account/forgot.phtml new file mode 100644 index 000000000..ba165b829 --- /dev/null +++ b/templates/frontend/account/forgot.phtml @@ -0,0 +1,35 @@ +layout( + 'minimal', + [ + 'title' => __('Forgot Password'), + 'page_class' => 'login-content', + ] +); +?> + +
+
+
+

+ +
+
+ + +
+ +
+
+
+
diff --git a/templates/frontend/account/forgot_disabled.phtml b/templates/frontend/account/forgot_disabled.phtml new file mode 100644 index 000000000..a3b5acacd --- /dev/null +++ b/templates/frontend/account/forgot_disabled.phtml @@ -0,0 +1,35 @@ +layout( + 'minimal', + [ + 'title' => __('Forgot Password'), + 'page_class' => 'login-content', + ] +); +?> + +
+
+
+
+
+

+
+
+ +

+ +

+

+ +

+ + + + +
+
+
diff --git a/templates/frontend/account/login.phtml b/templates/frontend/account/login.phtml index 4e10371ff..3766f0dcc 100644 --- a/templates/frontend/account/login.phtml +++ b/templates/frontend/account/login.phtml @@ -40,7 +40,7 @@ $this->layout( - @@ -49,7 +49,7 @@ $this->layout( - @@ -69,8 +69,9 @@ $this->layout(

Forgot your password?'), - 'https://docs.azuracast.com/en/administration/users#resetting-an-account-password' + '%s', + $router->named('account:forgot'), + __('Forgot your password?') )?>

diff --git a/templates/frontend/account/recover.phtml b/templates/frontend/account/recover.phtml new file mode 100644 index 000000000..cc74790f8 --- /dev/null +++ b/templates/frontend/account/recover.phtml @@ -0,0 +1,39 @@ +layout( + 'minimal', + [ + 'title' => __('Recover Account'), + 'page_class' => 'login-content', + ] +); + +/** @var \App\Assets $assets */ +$assets->load('zxcvbn'); +?> + +
+
+
+

+

+ +
+
+ + +
+ +
+
+
+
diff --git a/templates/frontend/setup/register.phtml b/templates/frontend/setup/register.phtml index bac304360..78a8223d8 100644 --- a/templates/frontend/setup/register.phtml +++ b/templates/frontend/setup/register.phtml @@ -36,7 +36,7 @@ $assets->load('zxcvbn'); - + @@ -46,7 +46,7 @@ $assets->load('zxcvbn'); - + diff --git a/templates/mail/forgot.phtml b/templates/mail/forgot.phtml new file mode 100644 index 000000000..06fd1d2cc --- /dev/null +++ b/templates/mail/forgot.phtml @@ -0,0 +1,18 @@ + + + +getAppName())?> + + + + + +named( + 'account:recover', + ['token' => $token], + [], + true +)?> diff --git a/util/phpstan.php b/util/phpstan.php index 440b1be4a..a3e65367d 100644 --- a/util/phpstan.php +++ b/util/phpstan.php @@ -8,10 +8,18 @@ ini_set('display_errors', 1); $autoloader = require dirname(__DIR__) . '/vendor/autoload.php'; -$app = App\AppFactory::create($autoloader, [ - App\Environment::BASE_DIR => dirname(__DIR__), -]); +$app = App\AppFactory::create( + $autoloader, + [ + App\Environment::BASE_DIR => dirname(__DIR__), + ] +); $di = $app->getContainer(); -App\Customization::initCli(); +/** @var \Psr\Container\ContainerInterface|\DI\FactoryInterface $di */ +$di = $app->getContainer(); + +/** @var \App\Locale $locale */ +$locale = $di->make(\App\Locale::class); +$locale->register();