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',
+ ]
+);
+?>
+
+
+ =__('This installation\'s administrator has not configured this functionality.')?> +
++ =__( + 'Contact an administrator to reset your password following the instructions in our documentation:' + )?> +
+ + + =__('Password Reset Instructions')?> + +=__('Please log in to continue.')?> =sprintf( - __('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'); +?> + +=__('Choose a new password for your account.')?>
+ + +