Implement SMTP Mail Delivery and Self-Service Password Reset (#3848)

This commit is contained in:
Buster "Silver Eagle" Neece 2021-02-27 20:50:45 -06:00 committed by GitHub
parent 4cd090decd
commit c24f5dfc69
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 1376 additions and 218 deletions

View File

@ -1,7 +1,6 @@
<?php
namespace PHPSTORM_META {
override(
\Psr\Container\ContainerInterface::get(0),
map(
@ -18,4 +17,21 @@ namespace PHPSTORM_META {
]
)
);
override(
\DI\FactoryInterface::make(0),
map(
[
'' => '@',
]
)
);
override(
\DI\Container::make(0),
map(
[
'' => '@',
]
)
);
}

View File

@ -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);

View File

@ -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",

333
composer.lock generated
View File

@ -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",

View File

@ -1,7 +1,8 @@
<?php
use App\Customization;
use App\Environment;
use App\Http\ServerRequest;
use App\Locale;
use Psr\Http\Message\ServerRequestInterface as Request;
/**
@ -132,7 +133,13 @@ return [
'inline' => [
'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);

View File

@ -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,

View File

@ -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,
];

View File

@ -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) {

View File

@ -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(

View File

@ -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);

View File

@ -0,0 +1,94 @@
<?php
namespace App\Controller\Frontend\Account;
use App\Entity;
use App\Exception\RateLimitExceededException;
use App\Http\Response;
use App\Http\ServerRequest;
use App\RateLimit;
use App\Session\Flash;
use Psr\Http\Message\ResponseInterface;
use Symfony\Component\Mailer\MailerInterface;
use Symfony\Component\Mime\Address;
use Symfony\Component\Mime\Email;
class ForgotPasswordAction
{
public function __invoke(
ServerRequest $request,
Response $response,
Entity\Repository\SettingsRepository $settingsRepo,
Entity\Repository\UserRepository $userRepo,
Entity\Repository\UserLoginTokenRepository $loginTokenRepo,
RateLimit $rateLimit,
MailerInterface $mailer
): ResponseInterface {
$flash = $request->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(
'<b>%s</b><br>%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(
'<b>%s</b><br>%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');
}
}

View File

@ -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) {

View File

@ -0,0 +1,62 @@
<?php
namespace App\Controller\Frontend\Account;
use App\Entity;
use App\Http\Response;
use App\Http\ServerRequest;
use App\Session\Flash;
use Doctrine\ORM\EntityManagerInterface;
use Psr\Http\Message\ResponseInterface;
class RecoverAction
{
public function __invoke(
ServerRequest $request,
Response $response,
string $token,
Entity\Repository\UserLoginTokenRepository $loginTokenRepo,
EntityManagerInterface $em
): ResponseInterface {
$flash = $request->getFlash();
$user = $loginTokenRepo->authenticate($token);
if (!$user instanceof Entity\User) {
$flash->addMessage(
sprintf(
'<b>%s</b>',
__('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(
'<b>%s</b><br>%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');
}
}

View File

@ -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();
}
}

View File

@ -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
*/

View File

@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace App\Entity\Migration;
use Doctrine\DBAL\Schema\Schema;
use Doctrine\Migrations\AbstractMigration;
/**
* Auto-generated Migration: Please modify to your needs!
*/
final class Version20210226053617 extends AbstractMigration
{
public function getDescription(): string
{
return 'Create the user login token table.';
}
public function up(Schema $schema): void
{
// this up() migration is auto-generated, please modify it to your needs
$this->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');
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Entity\Repository;
use App\Doctrine\Repository;
use App\Entity;
use App\Security\SplitToken;
abstract class AbstractSplitTokenRepository extends Repository
{
/**
* 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);
$tokenEntity = $this->repository->find($userSuppliedToken->identifier);
if ($tokenEntity instanceof $this->entityClass) {
return ($tokenEntity->verify($userSuppliedToken))
? $tokenEntity->getUser()
: null;
}
return null;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Entity\Repository;
use App\Entity;
use App\Security\SplitToken;
class UserLoginTokenRepository extends AbstractSplitTokenRepository
{
public function createToken(Entity\User $user): SplitToken
{
$token = SplitToken::generate();
$loginToken = new Entity\UserLoginToken($user, $token);
$this->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();
}
}

View File

@ -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);

View File

@ -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;
}
}

View File

@ -0,0 +1,48 @@
<?php
namespace App\Entity\Traits;
use App\Annotations\AuditLog;
use App\Security\SplitToken;
use Doctrine\ORM\Mapping as ORM;
trait HasSplitTokenFields
{
/**
* @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;
protected function setFromToken(SplitToken $token): void
{
$this->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);
}
}

View File

@ -0,0 +1,44 @@
<?php
/** @noinspection PhpMissingFieldTypeInspection */
namespace App\Entity;
use App\Security\SplitToken;
use Doctrine\ORM\Mapping as ORM;
/**
* @ORM\Table(name="user_login_tokens")
* @ORM\Entity(readOnly=true)
*/
class UserLoginToken
{
use Traits\HasSplitTokenFields;
/**
* @ORM\ManyToOne(targetEntity="User", inversedBy="api_keys", fetch="EAGER")
* @ORM\JoinColumns({
* @ORM\JoinColumn(name="user_id", referencedColumnName="uid", onDelete="CASCADE")
* })
* @var User
*/
protected $user;
/**
* @ORM\Column(name="created_at", type="integer")
* @var int
*/
protected $created_at;
public function __construct(User $user, SplitToken $token)
{
$this->user = $user;
$this->setFromToken($token);
$this->created_at = time();
}
public function getUser(): User
{
return $this->user;
}
}

View File

@ -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);

116
src/Locale.php Normal file
View File

@ -0,0 +1,116 @@
<?php
namespace App;
use App\Http\ServerRequest;
use Gettext\Translator;
use Psr\Http\Message\ServerRequestInterface;
class Locale
{
public const DEFAULT_LOCALE = 'en_US.UTF-8';
protected Environment $environment;
protected ?ServerRequestInterface $request = null;
protected string $locale = self::DEFAULT_LOCALE;
public function __construct(
Environment $environment,
?ServerRequestInterface $request = null
) {
$this->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);
}
}

View File

@ -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();

View File

@ -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);

View File

@ -0,0 +1,27 @@
<?php
namespace App\Sync\Task;
use App\Doctrine\ReloadableEntityManagerInterface;
use App\Entity;
use Psr\Log\LoggerInterface;
class CleanupLoginTokensTask extends AbstractTask
{
protected Entity\Repository\UserLoginTokenRepository $loginTokenRepo;
public function __construct(
ReloadableEntityManagerInterface $em,
LoggerInterface $logger,
Entity\Repository\UserLoginTokenRepository $loginTokenRepo
) {
parent::__construct($em, $logger);
$this->loginTokenRepo = $loginTokenRepo;
}
public function run(bool $force = false): void
{
$this->loginTokenRepo->cleanup();
}
}

View File

@ -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,

View File

@ -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) {

View File

@ -0,0 +1,35 @@
<?php
$this->layout(
'minimal',
[
'title' => __('Forgot Password'),
'page_class' => 'login-content',
]
);
?>
<div class="public-page">
<div class="card">
<div class="card-body">
<h2 class="card-title mb-4 text-center"><?=__('Forgot Password')?></h2>
<form id="login-form" action="" method="post">
<div class="form-group">
<label for="email" class="mb-2">
<i class="material-icons mr-1" aria-hidden="true">email</i>
<strong><?=__('E-mail Address')?></strong>
</label>
<input type="email" id="email" name="email" class="form-control" placeholder="<?=__(
'name@example.com'
)?>" aria-label="<?=__('E-mail Address')?>" required autofocus>
</div>
<button type="submit" role="button" title="<?=__(
'Sign in'
)?>" class="btn btn-login btn-primary btn-block mt-2 mb-3">
<?=__('Send Recovery E-mail')?>
</button>
</form>
</div>
</div>
</div>

View File

@ -0,0 +1,35 @@
<?php
$this->layout(
'minimal',
[
'title' => __('Forgot Password'),
'page_class' => 'login-content',
]
);
?>
<div class="public-page">
<div class="card">
<div class="card-body">
<div class="row mb-4">
<div class="col-sm">
<h2 class="card-title mb-0 text-center"><?=__('Forgot Password')?></h2>
</div>
</div>
<p>
<?=__('This installation\'s administrator has not configured this functionality.')?>
</p>
<p>
<?=__(
'Contact an administrator to reset your password following the instructions in our documentation:'
)?>
</p>
<a class="btn btn-primary btn-block mt-2 mb-3" href="https://docs.azuracast.com/en/administration/users#resetting-an-account-password" target="_blank">
<?=__('Password Reset Instructions')?>
</a>
</div>
</div>
</div>

View File

@ -40,7 +40,7 @@ $this->layout(
<i class="material-icons mr-1" aria-hidden="true">email</i>
<strong><?=__('E-mail Address')?></strong>
</label>
<input type="email" name="username" class="form-control" placeholder="<?=__(
<input type="email" id="username" name="username" class="form-control" placeholder="<?=__(
'name@example.com'
)?>" aria-label="<?=__('E-mail Address')?>" required autofocus>
</div>
@ -49,7 +49,7 @@ $this->layout(
<i class="material-icons mr-1" aria-hidden="true">vpn_key</i>
<strong><?=__('Password')?></strong>
</label>
<input type="password" name="password" class="form-control" placeholder="<?=__(
<input type="password" id="password" name="password" class="form-control" placeholder="<?=__(
'Enter your password'
)?>" aria-label="<?=__('Password')?>" required>
</div>
@ -69,8 +69,9 @@ $this->layout(
</form>
<p class="text-center"><?=__('Please log in to continue.')?> <?=sprintf(
__('<a href="%s" target="_blank">Forgot your password?</a>'),
'https://docs.azuracast.com/en/administration/users#resetting-an-account-password'
'<a href="%s">%s</a>',
$router->named('account:forgot'),
__('Forgot your password?')
)?></p>
</div>
</div>

View File

@ -0,0 +1,39 @@
<?php
$this->layout(
'minimal',
[
'title' => __('Recover Account'),
'page_class' => 'login-content',
]
);
/** @var \App\Assets $assets */
$assets->load('zxcvbn');
?>
<div class="public-page">
<div class="card">
<div class="card-body">
<h2 class="card-title mb-2 text-center"><?=__('Recover Account')?></h2>
<p class="text-center mb-3"><?=__('Choose a new password for your account.')?></p>
<form id="login-form" action="" method="post">
<div class="form-group">
<label for="password" class="mb-2">
<i class="material-icons mr-1" aria-hidden="true">vpn_key</i>
<strong><?=__('Password')?></strong>
</label>
<input type="password" id="password" name="password" class="strength form-control" placeholder="<?=__(
'Enter your password'
)?>" aria-label="<?=__('Password')?>" autocomplete="new-password" required autofocus>
</div>
<button type="submit" role="button" title="<?=__(
'Sign in'
)?>" class="btn btn-login btn-primary btn-block mt-2">
<?=__('Recover Account')?>
</button>
</form>
</div>
</div>
</div>

View File

@ -36,7 +36,7 @@ $assets->load('zxcvbn');
<i class="material-icons mr-1" aria-hidden="true">email</i>
<strong><?=__('E-mail Address') ?></strong>
</label>
<input type="email" name="username" class="form-control" placeholder="" required>
<input type="email" id="username" name="username" class="form-control" placeholder="" required>
</div>
</div>
@ -46,7 +46,7 @@ $assets->load('zxcvbn');
<i class="material-icons mr-1" aria-hidden="true">vpn_key</i>
<strong><?=__('Password') ?></strong>
</label>
<input type="password" name="password" class="form-control" placeholder="" required>
<input type="password" id="password" name="password" class="strength form-control" placeholder="" required>
</div>
</div>

View File

@ -0,0 +1,18 @@
<?php
/** @var */
?><?=__('Account Recovery')?>
<?=__('An account recovery link has been requested for your account on "%s".', $environment->getAppName())?>
<?=__('Click the link below to log in to your account.')?>
<?=$router->named(
'account:recover',
['token' => $token],
[],
true
)?>

View File

@ -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();