Completely revamp docs and input validation
And lots of other things as well, I don't even remember it all.
This commit is contained in:
parent
4c58b2a646
commit
9591c5ecd2
|
@ -1,7 +1,7 @@
|
|||
{
|
||||
"name": "fwdekker/death-notifier",
|
||||
"description": "Get notified when a famous person dies.",
|
||||
"version": "0.16.2", "_comment_version": "Also update version in `package.json`!",
|
||||
"version": "0.17.0", "_comment_version": "Also update version in `package.json`!",
|
||||
"type": "project",
|
||||
"license": "MIT",
|
||||
"homepage": "https://git.fwdekker.com/tools/death-notifier",
|
||||
|
|
Binary file not shown.
Binary file not shown.
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "death-notifier",
|
||||
"version": "0.16.2", "_comment_version": "Also update version in `composer.json`!",
|
||||
"version": "0.17.0", "_comment_version": "Also update version in `composer.json`!",
|
||||
"description": "Get notified when a famous person dies.",
|
||||
"author": "Florine W. Dekker",
|
||||
"browser": "dist/bundle.js",
|
||||
|
|
|
@ -7,15 +7,15 @@ use com\fwdekker\deathnotifier\Database;
|
|||
use com\fwdekker\deathnotifier\EmulateCronAction;
|
||||
use com\fwdekker\deathnotifier\IllegalArgumentError;
|
||||
use com\fwdekker\deathnotifier\LoggerUtil;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\mailer\ProcessEmailQueueAction;
|
||||
use com\fwdekker\deathnotifier\mediawiki\MediaWiki;
|
||||
use com\fwdekker\deathnotifier\wikipedia\Wikipedia;
|
||||
use com\fwdekker\deathnotifier\Response;
|
||||
use com\fwdekker\deathnotifier\StartSessionAction;
|
||||
use com\fwdekker\deathnotifier\tracking\AddTrackingAction;
|
||||
use com\fwdekker\deathnotifier\tracking\ListTrackingsAction;
|
||||
use com\fwdekker\deathnotifier\tracking\RemoveTrackingAction;
|
||||
use com\fwdekker\deathnotifier\tracking\TrackingManager;
|
||||
use com\fwdekker\deathnotifier\tracking\TrackingList;
|
||||
use com\fwdekker\deathnotifier\tracking\UpdateTrackingsAction;
|
||||
use com\fwdekker\deathnotifier\user\GetPublicUserDataAction;
|
||||
use com\fwdekker\deathnotifier\user\LoginAction;
|
||||
|
@ -28,7 +28,7 @@ use com\fwdekker\deathnotifier\user\ToggleNotificationsAction;
|
|||
use com\fwdekker\deathnotifier\user\ChangeEmailAction;
|
||||
use com\fwdekker\deathnotifier\user\ChangePasswordAction;
|
||||
use com\fwdekker\deathnotifier\user\UserDeleteAction;
|
||||
use com\fwdekker\deathnotifier\user\UserManager;
|
||||
use com\fwdekker\deathnotifier\user\UserList;
|
||||
use com\fwdekker\deathnotifier\user\ValidatePasswordResetTokenAction;
|
||||
use com\fwdekker\deathnotifier\user\VerifyEmailAction;
|
||||
use com\fwdekker\deathnotifier\Util;
|
||||
|
@ -51,12 +51,12 @@ try {
|
|||
|
||||
$db = new Database($config["database"]["filename"]);
|
||||
|
||||
$mediawiki = new MediaWiki();
|
||||
$mail_manager = new MailManager($db->conn);
|
||||
$user_manager = new UserManager($db->conn);
|
||||
$tracking_manager = new TrackingManager($db->conn);
|
||||
$wikipedia = new Wikipedia();
|
||||
$email_queue = new EmailQueue($db);
|
||||
$user_list = new UserList($db);
|
||||
$tracking_list = new TrackingList($db);
|
||||
|
||||
$db->auto_install($mail_manager, $user_manager, $tracking_manager);
|
||||
$db->auto_install($email_queue, $user_list, $tracking_list);
|
||||
$db->auto_migrate();
|
||||
|
||||
session_start();
|
||||
|
@ -68,37 +68,37 @@ try {
|
|||
|
||||
// GET actions
|
||||
$dispatcher->register_actions([
|
||||
[ActionMethod::GET, "start-session", new StartSessionAction($user_manager)],
|
||||
[ActionMethod::GET, "get-user-data", new GetPublicUserDataAction($user_manager)],
|
||||
[ActionMethod::GET, "list-trackings", new ListTrackingsAction($tracking_manager)],
|
||||
[ActionMethod::GET, "start-session", new StartSessionAction($user_list)],
|
||||
[ActionMethod::GET, "get-user-data", new GetPublicUserDataAction($user_list)],
|
||||
[ActionMethod::GET, "list-trackings", new ListTrackingsAction($tracking_list)],
|
||||
|
||||
[ActionMethod::GET, "validate-password-reset-token", new ValidatePasswordResetTokenAction($user_manager)],
|
||||
[ActionMethod::GET, "validate-password-reset-token", new ValidatePasswordResetTokenAction($user_list)],
|
||||
]);
|
||||
|
||||
// POST actions
|
||||
$dispatcher->register_actions([
|
||||
[ActionMethod::POST, "register", new RegisterAction($db->conn, $user_manager, $mail_manager)],
|
||||
[ActionMethod::POST, "login", new LoginAction($user_manager)],
|
||||
[ActionMethod::POST, "register", new RegisterAction($user_list, $email_queue)],
|
||||
[ActionMethod::POST, "login", new LoginAction($user_list)],
|
||||
[ActionMethod::POST, "logout", new LogoutAction()],
|
||||
[ActionMethod::POST, "user-delete", new UserDeleteAction($user_manager)],
|
||||
[ActionMethod::POST, "user-delete", new UserDeleteAction($user_list)],
|
||||
|
||||
[ActionMethod::POST, "update-email", new ChangeEmailAction($db->conn, $user_manager, $mail_manager)],
|
||||
[ActionMethod::POST, "verify-email", new VerifyEmailAction($db->conn, $user_manager, $mail_manager)],
|
||||
[ActionMethod::POST, "resend-verify-email", new ResendVerifyEmailAction($db->conn, $user_manager, $mail_manager)],
|
||||
[ActionMethod::POST, "toggle-notifications", new ToggleNotificationsAction($db->conn, $user_manager)],
|
||||
[ActionMethod::POST, "update-email", new ChangeEmailAction($user_list, $email_queue)],
|
||||
[ActionMethod::POST, "verify-email", new VerifyEmailAction($user_list)],
|
||||
[ActionMethod::POST, "resend-verify-email", new ResendVerifyEmailAction($user_list, $email_queue)],
|
||||
[ActionMethod::POST, "toggle-notifications", new ToggleNotificationsAction($user_list)],
|
||||
|
||||
[ActionMethod::POST, "update-password", new ChangePasswordAction($db->conn, $user_manager, $mail_manager)],
|
||||
[ActionMethod::POST, "send-password-reset", new SendPasswordResetAction($db->conn, $user_manager, $mail_manager)],
|
||||
[ActionMethod::POST, "reset-password", new ResetPasswordAction($db->conn, $user_manager, $mail_manager)],
|
||||
[ActionMethod::POST, "update-password", new ChangePasswordAction($user_list, $email_queue)],
|
||||
[ActionMethod::POST, "send-password-reset", new SendPasswordResetAction($user_list, $email_queue)],
|
||||
[ActionMethod::POST, "reset-password", new ResetPasswordAction($user_list, $email_queue)],
|
||||
|
||||
[ActionMethod::POST, "add-tracking", new AddTrackingAction($tracking_manager, $mediawiki)],
|
||||
[ActionMethod::POST, "remove-tracking", new RemoveTrackingAction($tracking_manager)],
|
||||
[ActionMethod::POST, "add-tracking", new AddTrackingAction($tracking_list, $wikipedia)],
|
||||
[ActionMethod::POST, "remove-tracking", new RemoveTrackingAction($tracking_list)],
|
||||
]);
|
||||
|
||||
// CLI actions
|
||||
$cli_actions = [
|
||||
new UpdateTrackingsAction($db->conn, $tracking_manager, $mediawiki, $mail_manager),
|
||||
new ProcessEmailQueueAction($mail_manager),
|
||||
new UpdateTrackingsAction($tracking_list, $wikipedia, $email_queue),
|
||||
new ProcessEmailQueueAction($email_queue),
|
||||
];
|
||||
$dispatcher->register_actions([
|
||||
[ActionMethod::CLI, "update-trackings", $cli_actions[0]],
|
||||
|
@ -115,10 +115,13 @@ try {
|
|||
else if ($_SERVER["REQUEST_METHOD"] === "POST")
|
||||
$response = $dispatcher->handle(ActionMethod::POST, Util::parse_post());
|
||||
else
|
||||
$response = Response::satisfied();
|
||||
$response = new Response(satisfied: true, payload: null);
|
||||
} catch (Throwable $exception) {
|
||||
$response = Response::unsatisfied("An unhandled error occurred. Please try again later.");
|
||||
$logger->error("An unhandled error occurred. Please try again later.", ["cause" => $exception]);
|
||||
$response = new Response(
|
||||
satisfied: false,
|
||||
payload: ["message" => "An unhandled error occurred. Please try again later.", "target" => null]
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -2,12 +2,7 @@
|
|||
|
||||
namespace com\fwdekker\deathnotifier;
|
||||
|
||||
use com\fwdekker\deathnotifier\validator\IsEqualToRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsNotSetRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use com\fwdekker\deathnotifier\validator\Rule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use InvalidArgumentException;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -15,79 +10,15 @@ use InvalidArgumentException;
|
|||
*/
|
||||
abstract class Action
|
||||
{
|
||||
/**
|
||||
* @var bool `true` if and only if this action requires the user to be logged in
|
||||
*/
|
||||
private readonly bool $require_logged_in;
|
||||
/**
|
||||
* @var bool `true` if and only if this action requires the user to be logged out
|
||||
*/
|
||||
private readonly bool $require_logged_out;
|
||||
/**
|
||||
* @var bool `true` if and only if this action requires the request to have a valid CSRF token
|
||||
*/
|
||||
private readonly bool $require_valid_csrf_token;
|
||||
/**
|
||||
* @var array<string, Rule[]> maps input keys to {@see Rule}s that should be validated before this action is handled
|
||||
*/
|
||||
private readonly array $rule_lists;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new action.
|
||||
*
|
||||
* @param bool $require_logged_in `true` if and only if this action requires the user to be logged in
|
||||
* @param bool $require_logged_out `true` if and only if this action requires the user to be logged out
|
||||
* @param bool $require_valid_csrf_token `true` if and only if this action requires the request to have a valid CSRF
|
||||
* token
|
||||
* @param array<string, Rule[]> $rule_lists maps input keys to {@see Rule}s that should be validated before this
|
||||
* action is handled
|
||||
*/
|
||||
public function __construct(bool $require_logged_in = false,
|
||||
bool $require_logged_out = false,
|
||||
bool $require_valid_csrf_token = false,
|
||||
array $rule_lists = [])
|
||||
{
|
||||
if ($require_logged_in && $require_logged_out)
|
||||
throw new InvalidArgumentException("Cannot require that user is both logged in and logged out.");
|
||||
|
||||
// TODO: Move authorisation-related validation to `dispatch` method?
|
||||
$this->require_logged_in = $require_logged_in;
|
||||
$this->require_logged_out = $require_logged_out;
|
||||
$this->require_valid_csrf_token = $require_valid_csrf_token;
|
||||
$this->rule_lists = $rule_lists;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Validates inputs according to `rule_lists`, throwing an exception if any input is invalid.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the inputs to validate
|
||||
* @return void if the input is valid
|
||||
* @throws InvalidInputException if the input is invalid
|
||||
*/
|
||||
public function validate_inputs(array $inputs): void
|
||||
{
|
||||
if ($this->require_logged_in)
|
||||
(new IsSetRule("You must be logged in to perform this action."))->check($_SESSION, "uuid");
|
||||
if ($this->require_logged_out)
|
||||
(new IsNotSetRule("You must be logged out to perform this action."))->check($_SESSION, "uuid");
|
||||
if ($this->require_valid_csrf_token)
|
||||
(new IsEqualToRule($_SESSION["token"], "Invalid request token. Please refresh the page and try again."))
|
||||
->check($inputs, "token");
|
||||
|
||||
foreach ($this->rule_lists as $key => $rule_list)
|
||||
foreach ($rule_list as $rule)
|
||||
$rule->check($inputs, $key);
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the action.
|
||||
*
|
||||
* The specific input and output requirements should be specified and enforced by the implementation.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the inputs to perform the action with
|
||||
* @return mixed the data requested by the action; may be `null`
|
||||
* @throws ActionException if the action could not be performed
|
||||
* @throws InvalidInputException if the inputs are invalid upon further inspection
|
||||
* @return mixed the requested data; may be `null`
|
||||
* @throws InvalidInputException if any of the inputs is invalid
|
||||
* @throws UnexpectedException if the action could not be performed even though the inputs are valid
|
||||
*/
|
||||
abstract function handle(array $inputs): mixed;
|
||||
}
|
||||
|
|
|
@ -4,13 +4,18 @@ namespace com\fwdekker\deathnotifier;
|
|||
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use InvalidArgumentException;
|
||||
use Monolog\Logger;
|
||||
|
||||
|
||||
/**
|
||||
* Dispatches actions to the right implementation.
|
||||
* Dispatches {@see Action Actions} to the right implementation.
|
||||
*/
|
||||
class ActionDispatcher
|
||||
{
|
||||
/**
|
||||
* @var Logger the logger to log with
|
||||
*/
|
||||
private readonly Logger $logger;
|
||||
/**
|
||||
* @var array<string, array<string, Action>> the registered actions
|
||||
*/
|
||||
|
@ -18,14 +23,23 @@ class ActionDispatcher
|
|||
|
||||
|
||||
/**
|
||||
* Registers `action` so that `handle` can find it.
|
||||
* Constructs a new `ActionDispatcher`.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->logger = LoggerUtil::with_name($this::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers {@see $action} so that {@see ActionDispatcher::handle()} can find it.
|
||||
*
|
||||
* Only one action can be registered given a combination of method and action name. An exception is thrown if
|
||||
* this restriction is violated, because that is likely to be a mistake.
|
||||
* Only one `Action` can be registered given a combination of {@see ActionMethod} and action name. An
|
||||
* {@see IllegalArgumentError} is thrown if this restriction is violated.
|
||||
*
|
||||
* @param ActionMethod $method the request method that this action should handle
|
||||
* @param string $action_name the action name that this action should handle
|
||||
* @param Action $action the action to register
|
||||
* @param ActionMethod $method the `ActionMethod` that {@see $action} should handle
|
||||
* @param string $action_name the action name that {@see $action} should handle
|
||||
* @param Action $action the `Action` to register
|
||||
* @return void
|
||||
*/
|
||||
public function register_action(ActionMethod $method, string $action_name, Action $action): void
|
||||
|
@ -42,10 +56,10 @@ class ActionDispatcher
|
|||
}
|
||||
|
||||
/**
|
||||
* Utility method that invokes `#register_action` for each of the given `actions`.
|
||||
* Utility method that invokes {@see ActionDispatcher::register_action()} for each of {@see $actions}.
|
||||
*
|
||||
* @param array<array{ActionMethod, string, Action}> $actions the actions to register, mapped from the request
|
||||
* method and action name that the action should handle
|
||||
* @param array<array{ActionMethod, string, Action}> $actions the actions to register, mapped from the
|
||||
* `ActionMethod` method and action name that the `Action` should handle
|
||||
* @return void
|
||||
*/
|
||||
public function register_actions(array $actions): void
|
||||
|
@ -55,30 +69,38 @@ class ActionDispatcher
|
|||
}
|
||||
|
||||
/**
|
||||
* Executes the registered action for the given pair of method and action name.
|
||||
* Executes the {@see Action} registered with {@see ActionDispatcher::register_action()} that matches both
|
||||
* {@see $method} and the `action` key in {@see $inputs}.
|
||||
*
|
||||
* @param ActionMethod $method the method of the action to execute
|
||||
* @param array<int|string, mixed> $inputs the inputs to the action
|
||||
* @return Response a satisfied response with the action's output if the action did not throw an exception, or an
|
||||
* unsatisfied response with the exception's message and target
|
||||
* @param array<int|string, mixed> $inputs the inputs to the action, where the `action` key specifies which action
|
||||
* to execute
|
||||
* @return Response an unsatisfied `Response` if no registered {@see Action} could be found to handle the request,
|
||||
* an unsatisfied `Response` if an `Action` could be found but threw an {@see UnexpectedException} or an
|
||||
* {@see InvalidInputException}, or a satisfied `Response` containing the return value of `Action` that was invoked
|
||||
* with {@see $inputs}
|
||||
*/
|
||||
public function handle(ActionMethod $method, array $inputs): Response
|
||||
{
|
||||
if (!isset($inputs["action"]))
|
||||
throw new InvalidArgumentException("Malformed request: No action specified.");
|
||||
return Response::unsatisfied(message: "Malformed request: No action specified.");
|
||||
|
||||
$method_name = $method->name;
|
||||
$action_name = $inputs["action"];
|
||||
if (!isset($this->actions[$method_name]) || !isset($this->actions[$method_name][$action_name]))
|
||||
throw new InvalidArgumentException("Malformed request: Unknown $method_name action '$action_name'.");
|
||||
return Response::unsatisfied(message: "Malformed request: Unknown $method_name action '$action_name'.");
|
||||
|
||||
$action = $this->actions[$method_name][$action_name];
|
||||
try {
|
||||
$action->validate_inputs($inputs);
|
||||
$payload = $action->handle($inputs);
|
||||
return Response::satisfied($payload);
|
||||
} catch (ActionException|InvalidInputException $exception) {
|
||||
return Response::unsatisfied($exception->getMessage(), $exception->target);
|
||||
return new Response(
|
||||
satisfied: true,
|
||||
payload: $action->handle($inputs)
|
||||
);
|
||||
} catch (UnexpectedException $exception) {
|
||||
$this->logger->error($exception);
|
||||
return Response::unsatisfied(message: $exception->getMessage());
|
||||
} catch (InvalidInputException $exception) {
|
||||
return Response::unsatisfied(message: $exception->getMessage(), target: $exception->target);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier;
|
||||
|
||||
use Exception;
|
||||
|
||||
|
||||
/**
|
||||
* Thrown if an action could not be completed.
|
||||
*/
|
||||
class ActionException extends Exception
|
||||
{
|
||||
/**
|
||||
* @var string|null the input element that caused the exception, or `null` if no such element could be identified
|
||||
*/
|
||||
public readonly ?string $target;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ActionException`.
|
||||
*
|
||||
* @param string $message the message to show to the user
|
||||
* @param string|null $target the input element that caused the exception, or `null` if no such element could be
|
||||
* identified
|
||||
*/
|
||||
public function __construct(string $message, ?string $target = null)
|
||||
{
|
||||
parent::__construct($message);
|
||||
|
||||
$this->target = $target;
|
||||
}
|
||||
}
|
|
@ -4,7 +4,7 @@ namespace com\fwdekker\deathnotifier;
|
|||
|
||||
|
||||
/**
|
||||
* The method by which a user requests an action.
|
||||
* The method by which a user requests an {@see Action}.
|
||||
*/
|
||||
enum ActionMethod
|
||||
{
|
||||
|
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
namespace com\fwdekker\deathnotifier;
|
||||
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\tracking\TrackingManager;
|
||||
use com\fwdekker\deathnotifier\user\UserManager;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\tracking\TrackingList;
|
||||
use com\fwdekker\deathnotifier\user\UserList;
|
||||
use Composer\Semver\Comparator;
|
||||
use Error;
|
||||
use Monolog\Logger;
|
||||
|
@ -24,11 +24,12 @@ class Database
|
|||
/**
|
||||
* @var Logger the logger to use for logging
|
||||
*/
|
||||
private Logger $logger;
|
||||
private readonly Logger $logger;
|
||||
|
||||
/**
|
||||
* @var PDO the PDO object that connects to the database
|
||||
*/
|
||||
public PDO $conn;
|
||||
public readonly PDO $conn;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -48,15 +49,15 @@ class Database
|
|||
/**
|
||||
* Installs all necessary data structures to get the database working.
|
||||
*
|
||||
* @param MailManager $mail_manager the mail manager to install
|
||||
* @param UserManager $user_manager the user manager to install
|
||||
* @param TrackingManager $tracking_manager the tracking manager to install
|
||||
* @param EmailQueue $email_queue the `EmailQueue` to install
|
||||
* @param UserList $user_list the `UserList` to install
|
||||
* @param TrackingList $tracking_list the `TrackingList` to install
|
||||
* @return void
|
||||
*/
|
||||
public function auto_install(MailManager $mail_manager, UserManager $user_manager,
|
||||
TrackingManager $tracking_manager): void
|
||||
public function auto_install(EmailQueue $email_queue, UserList $user_list,
|
||||
TrackingList $tracking_list): void
|
||||
{
|
||||
self::transaction($this->conn, function () use ($mail_manager, $user_manager, $tracking_manager) {
|
||||
$this->transaction(function () use ($email_queue, $user_list, $tracking_list) {
|
||||
// Check if already installed
|
||||
$stmt = $this->conn->prepare("SELECT count(*) FROM sqlite_master WHERE type = 'table';");
|
||||
$stmt->execute();
|
||||
|
@ -72,9 +73,9 @@ class Database
|
|||
$stmt->execute();
|
||||
|
||||
// Create other tables
|
||||
$mail_manager->install();
|
||||
$user_manager->install();
|
||||
$tracking_manager->install();
|
||||
$email_queue->install();
|
||||
$user_list->install();
|
||||
$tracking_list->install();
|
||||
|
||||
$this->logger->notice("Installation complete.");
|
||||
});
|
||||
|
@ -87,7 +88,7 @@ class Database
|
|||
*/
|
||||
public function auto_migrate(): void
|
||||
{
|
||||
self::transaction($this->conn, function () {
|
||||
$this->transaction(function () {
|
||||
// Check if migration is necessary
|
||||
$stmt = $this->conn->prepare("SELECT v FROM meta WHERE k='version';");
|
||||
$stmt->execute();
|
||||
|
@ -197,32 +198,31 @@ class Database
|
|||
|
||||
|
||||
/**
|
||||
* Executes `lambda` within a transaction, allowing nesting.
|
||||
* Executes {@see $lambda} within a transaction, allowing nesting.
|
||||
*
|
||||
* If no transaction has been started when this function is invoked, a new transaction is started. If `lambda`
|
||||
* If no transaction has been started when this function is invoked, a new transaction is started. If {@see $lambda}
|
||||
* throws an exception, the transaction is rolled back, otherwise, if this method is not invoked while a transaction
|
||||
* was already ongoing, the transaction is committed.
|
||||
*
|
||||
* @param PDO $conn the connection to perform the transaction over
|
||||
* @param callable(): void $lambda the function to execute within a transaction
|
||||
*/
|
||||
public static function transaction(PDO $conn, callable $lambda): void
|
||||
public function transaction(callable $lambda): void
|
||||
{
|
||||
$initially_in_transaction = $conn->inTransaction();
|
||||
$initially_in_transaction = $this->conn->inTransaction();
|
||||
|
||||
if (!$initially_in_transaction)
|
||||
$conn->beginTransaction();
|
||||
$this->conn->beginTransaction();
|
||||
|
||||
try {
|
||||
$lambda();
|
||||
} catch (Error $exception) {
|
||||
if ($conn->inTransaction())
|
||||
$conn->rollBack();
|
||||
if ($this->conn->inTransaction())
|
||||
$this->conn->rollBack();
|
||||
|
||||
throw $exception;
|
||||
}
|
||||
|
||||
if ($conn->inTransaction() && !$initially_in_transaction)
|
||||
$conn->commit();
|
||||
if ($this->conn->inTransaction() && !$initially_in_transaction)
|
||||
$this->conn->commit();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -26,33 +26,22 @@ class EmulateCronAction extends Action
|
|||
/**
|
||||
* Constructs a new `EmulateCronAction`.
|
||||
*
|
||||
* @param Action[] $actions the actions to execute at an interval
|
||||
* @param Action[] $actions the `Action`s to execute at an interval
|
||||
*/
|
||||
public function __construct(array $actions)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->actions = $actions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates the inputs of each registered action.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the inputs to validate
|
||||
* @return void if the input is valid
|
||||
* @throws InvalidInputException if the input is invalid
|
||||
*/
|
||||
public function validate_inputs(array $inputs): void
|
||||
{
|
||||
foreach ($this->actions as $action)
|
||||
$action->validate_inputs($inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates all trackings and processes the mail queue at a regular interval.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* @param array<int|string, mixed> $inputs the inputs to perform the action with
|
||||
* @return never
|
||||
* @throws InvalidInputException if any of the inputs is invalid
|
||||
* @throws UnexpectedException if the action could not be performed even though the inputs are valid
|
||||
* @noinspection PhpDocRedundantThrowsInspection may be thrown by {@see $actions}
|
||||
*/
|
||||
public function handle(array $inputs): never
|
||||
{
|
||||
|
|
|
@ -6,9 +6,9 @@ use Exception;
|
|||
|
||||
|
||||
/**
|
||||
* Thrown to indicate that a request to the server was malformed in some way.
|
||||
* Thrown to indicate that a request to the server was malformed by the client that sent the request.
|
||||
*
|
||||
* This is an exception, not an error, so it indicates that the client that sent the request did something wrong.
|
||||
* @see UnexpectedException
|
||||
*/
|
||||
class MalformedRequestException extends Exception
|
||||
{
|
||||
|
|
|
@ -8,56 +8,44 @@ namespace com\fwdekker\deathnotifier;
|
|||
*/
|
||||
class Response
|
||||
{
|
||||
/**
|
||||
* @var mixed the payload corresponding to the client's query
|
||||
*/
|
||||
public mixed $payload;
|
||||
/**
|
||||
* @var bool `true` if and only if the request was fully completed
|
||||
*/
|
||||
public bool $satisfied;
|
||||
public readonly bool $satisfied;
|
||||
/**
|
||||
* @var mixed the payload corresponding to the client's query
|
||||
*/
|
||||
public readonly mixed $payload;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new response.
|
||||
*
|
||||
* If `satisfied` is `false`, then `payload` must be an array containing at least the strings `target` and
|
||||
* `message`. Otherwise, `payload` may be anything.
|
||||
* If {@see $satisfied} is `false`, then {@see $payload} must be an array containing at least the strings `target`
|
||||
* and `message`. Otherwise, {@see payload} may be anything.
|
||||
*
|
||||
* @param mixed $payload the payload corresponding to the client's query
|
||||
* @param bool $satisfied `true` if and only if the request was fully completed
|
||||
* @param mixed $payload the payload corresponding to the client's query
|
||||
*/
|
||||
public function __construct(mixed $payload, bool $satisfied)
|
||||
public function __construct(bool $satisfied, mixed $payload)
|
||||
{
|
||||
$this->payload = $payload;
|
||||
$this->satisfied = $satisfied;
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a response indicating that the request was completed successfully, with the given `payload` and
|
||||
* `satisfied` set to `true`.
|
||||
*
|
||||
* @param ?mixed $payload the payload to attach to the `Response`
|
||||
* @return Response a response with the given `payload` and `satisfied` set to `true`.
|
||||
*/
|
||||
public static function satisfied(mixed $payload = null): Response
|
||||
{
|
||||
return new Response(payload: $payload, satisfied: true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a response indicating something went wrong, with `payload` describing what went wrong in what place and
|
||||
* `satisfied` set to `false`.
|
||||
* Returns a response indicating something went wrong, with {@see payload} describing what went wrong in what place
|
||||
* and {@see $satisfied} set to `false`.
|
||||
*
|
||||
* @param ?string $message the message to show to the user, or `null` if no message should be shown
|
||||
* @param ?string $target the name of the input field that caused an error, or `null` if no single input is to be
|
||||
* blamed
|
||||
* @return Response a response with `payload` describing what went wrong in what place and `satisfied` set to
|
||||
* `false`
|
||||
* @return Response a response indicating something went wrong, with {@see payload} describing what went wrong in
|
||||
* what place and {@see $satisfied} set to `false`
|
||||
*/
|
||||
public static function unsatisfied(?string $message, ?string $target = null): Response
|
||||
{
|
||||
return new Response(payload: ["message" => $message, "target" => $target], satisfied: false);
|
||||
return new Response(satisfied: false, payload: ["message" => $message, "target" => $target]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
namespace com\fwdekker\deathnotifier;
|
||||
|
||||
use com\fwdekker\deathnotifier\user\UserManager;
|
||||
use com\fwdekker\deathnotifier\user\UserList;
|
||||
use Exception;
|
||||
|
||||
|
||||
|
@ -12,50 +12,53 @@ use Exception;
|
|||
class StartSessionAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var UserManager the manager to validate the session through
|
||||
* @var UserList the list to validate the session against
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
private readonly UserList $user_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `StartSessionAction`.
|
||||
*
|
||||
* @param UserManager $user_manager the manager to validate the session through
|
||||
* @param UserList $user_list the list to validate the session against
|
||||
*/
|
||||
public function __construct(UserManager $user_manager)
|
||||
public function __construct(UserList $user_list)
|
||||
{
|
||||
parent::__construct();
|
||||
|
||||
$this->user_manager = $user_manager;
|
||||
$this->user_list = $user_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Starts a new user session, or continues an existing one.
|
||||
*
|
||||
* Does not require a CSRF token. Does not require the user to be logged in or out.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* @return array{"logged_in": bool, "global_message"?: string} whether the user is logged in, and the message to be
|
||||
* displayed at the top of the page, if any
|
||||
* @throws ActionException if no CSRF token could be generated
|
||||
* @throws UnexpectedException if no new CSRF token could be generated
|
||||
*/
|
||||
function handle(array $inputs): array
|
||||
public function handle(array $inputs): array
|
||||
{
|
||||
$config = Config::get();
|
||||
$payload = [];
|
||||
|
||||
// Check if user is logged in
|
||||
// Check if logged in
|
||||
if (!isset($_SESSION["uuid"])) {
|
||||
$payload["logged_in"] = false;
|
||||
} else if ($this->user_manager->has_user_with_uuid($_SESSION["uuid"])) {
|
||||
} else if ($this->user_list->has_user_with_uuid($_SESSION["uuid"])) {
|
||||
$payload["logged_in"] = true;
|
||||
} else {
|
||||
// User account was deleted
|
||||
// User already logged in, but user was deleted
|
||||
session_destroy();
|
||||
session_start();
|
||||
try {
|
||||
$_SESSION["token"] = Util::generate_csrf_token();
|
||||
} catch (Exception) {
|
||||
throw new ActionException("Failed to generate new CSRF token. Please try again later.", null);
|
||||
} catch (Exception $exception) {
|
||||
throw new UnexpectedException(
|
||||
"Failed to generate new CSRF token. Please try again later.",
|
||||
previous: $exception
|
||||
);
|
||||
}
|
||||
|
||||
$payload["logged_in"] = false;
|
||||
|
|
|
@ -0,0 +1,17 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier;
|
||||
|
||||
use Exception;
|
||||
|
||||
|
||||
/**
|
||||
* Thrown to indicate that a request to the server could not be handled because of something that was out of control of
|
||||
* both the server and the client.
|
||||
*
|
||||
* @see MalformedRequestException
|
||||
*/
|
||||
class UnexpectedException extends Exception
|
||||
{
|
||||
// Intentionally left empty
|
||||
}
|
|
@ -64,13 +64,13 @@ class Util
|
|||
|
||||
|
||||
/**
|
||||
* Returns the number of minutes until `timestamp` was `interval` minutes ago.
|
||||
* Returns the number of minutes until {@see $timestamp} was {@see $interval} minutes ago.
|
||||
*
|
||||
* For example, if `timestamp` was 5 minutes ago, and `interval` is 7, then this function returns 2.
|
||||
* For example, if {@see $timestamp} was 5 minutes ago, and {@see $interval} is 7, then this function returns 2.
|
||||
*
|
||||
* @param string $timestamp the timestamp at which some event occurred
|
||||
* @param int $interval the number of minutes to measure against
|
||||
* @return int the number of minutes until `timestamp` was `interval` minutes ago
|
||||
* @return int the number of minutes until {@see $timestamp} was {@see $interval} minutes ago
|
||||
*/
|
||||
static function minutes_until_interval_elapsed(string $timestamp, int $interval): int
|
||||
{
|
||||
|
|
|
@ -4,12 +4,17 @@ namespace com\fwdekker\deathnotifier\mailer;
|
|||
|
||||
|
||||
/**
|
||||
* An email that can be queued in a database and then sent.
|
||||
* An email is a succinct way of passing around an email based on just a few parameters.
|
||||
*
|
||||
* Two emails may be considered the same even if some parameters are different. Because equality testing is performed
|
||||
* inside a database, it is not possible to, say, require each class to specify its own `equals` method. Instead, two
|
||||
* `Email` instances are equivalent if and only if their {@see Email::$recipient} are the same and their
|
||||
* {@see Email::$type_key} are the same.
|
||||
*/
|
||||
abstract class Email
|
||||
{
|
||||
/**
|
||||
* @var string a string identifying the type of email and distinguishing it from similar instances of the same type
|
||||
* @var string the identifier of this email
|
||||
*/
|
||||
public readonly string $type_key;
|
||||
/**
|
||||
|
@ -19,10 +24,9 @@ abstract class Email
|
|||
|
||||
|
||||
/**
|
||||
* Constructs a new email.
|
||||
* Constructs a new `Email`.
|
||||
*
|
||||
* @param string $type_key a string identifying the type of email and distinguishing it from similar instances of
|
||||
* the same type
|
||||
* @param string $type_key the identifier of this email
|
||||
* @param string $recipient the intended recipient of the email
|
||||
*/
|
||||
public function __construct(string $type_key, string $recipient)
|
||||
|
@ -33,16 +37,16 @@ abstract class Email
|
|||
|
||||
|
||||
/**
|
||||
* Returns the subject header of the email.
|
||||
* Returns the subject header of this email.
|
||||
*
|
||||
* @return string the subject header of the email
|
||||
* @return string the subject header of this email
|
||||
*/
|
||||
public abstract function get_subject(): string;
|
||||
|
||||
/**
|
||||
* Returns the body of the email.
|
||||
* Returns the body of this email.
|
||||
*
|
||||
* @return string the body of the email
|
||||
* @return string the body of this email
|
||||
*/
|
||||
public abstract function get_body(): string;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,109 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mailer;
|
||||
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use PDO;
|
||||
|
||||
|
||||
/**
|
||||
* A queue of {@see Email Emails}, stored in a {@see Database}.
|
||||
*/
|
||||
class EmailQueue
|
||||
{
|
||||
/**
|
||||
* @var Database the database to store the queue in
|
||||
*/
|
||||
private readonly Database $database;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `EmailQueue`.
|
||||
*
|
||||
* @param Database $database the database to store the queue in
|
||||
*/
|
||||
public function __construct(Database $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Populates the {@see Database} with the necessary structures for an `EmailQueue`.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function install(): void
|
||||
{
|
||||
$conn = $this->database->conn;
|
||||
$conn->exec("CREATE TABLE email_tasks(recipient TEXT NOT NULL,
|
||||
type_key TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
PRIMARY KEY (type_key, recipient));");
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes {@see $lambda} within a single database transaction.
|
||||
*
|
||||
* @param callable(): void $lambda the function to execute within a transaction
|
||||
* @return void
|
||||
* @see Database::transaction()
|
||||
*/
|
||||
public function transaction(callable $lambda): void
|
||||
{
|
||||
$this->database->transaction($lambda);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds an {@see Email} to the queue.
|
||||
*
|
||||
* {@see email} is added to the database, with its primary key determined by {@see Email::$type_key} and
|
||||
* {@see Email::$recipient}. If an {@see Email} with this key is already in this queue, it is replaced with
|
||||
* {@see $email}.
|
||||
*
|
||||
* @param Email $email the email to queue
|
||||
* @return void
|
||||
*/
|
||||
public function queue_email(Email $email): void
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("INSERT OR REPLACE INTO email_tasks (recipient, type_key, subject, body)
|
||||
VALUES (:recipient, :type_key, :subject, :body);");
|
||||
$stmt->bindValue(":recipient", $email->recipient);
|
||||
$stmt->bindValue(":type_key", $email->type_key);
|
||||
$stmt->bindValue(":subject", $email->get_subject());
|
||||
$stmt->bindValue(":body", $email->get_body());
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all queued emails.
|
||||
*
|
||||
* @return array<array{"type_key": string, "recipient": string, "subject": string, "body": string}> all queued
|
||||
* emails
|
||||
*/
|
||||
public function get_queue(): array
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("SELECT type_key, recipient, subject, body FROM email_tasks;");
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes mails from the queue.
|
||||
*
|
||||
* @param array<array{"type_key": string, "recipient": string}> $emails the emails to remove from the queue
|
||||
* @return void
|
||||
*/
|
||||
public function unqueue_emails(array $emails): void
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("DELETE FROM email_tasks
|
||||
WHERE type_key=:type_key AND recipient=:recipient;");
|
||||
$stmt->bindParam(":type_key", $type_key);
|
||||
$stmt->bindParam(":recipient", $recipient);
|
||||
|
||||
foreach ($emails as ["type_key" => $type_key, "recipient" => $recipient])
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
|
@ -1,91 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mailer;
|
||||
|
||||
use PDO;
|
||||
|
||||
|
||||
/**
|
||||
* Queues up mails and sends them when appropriate.
|
||||
*/
|
||||
class MailManager
|
||||
{
|
||||
/**
|
||||
* @var PDO the database connection to interact with
|
||||
*/
|
||||
private PDO $conn;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new mailer.
|
||||
*
|
||||
* @param PDO $conn the connection to the email database
|
||||
*/
|
||||
public function __construct(PDO $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Populates the database with the necessary structures for emails.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function install(): void
|
||||
{
|
||||
$this->conn->exec("CREATE TABLE email_tasks(type_key TEXT NOT NULL,
|
||||
recipient TEXT NOT NULL,
|
||||
subject TEXT NOT NULL,
|
||||
body TEXT NOT NULL,
|
||||
PRIMARY KEY (type_key, recipient));");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Queues an email to be sent.
|
||||
*
|
||||
* @param Email $email the email to queue
|
||||
* @return void
|
||||
*/
|
||||
public function queue_email(Email $email): void
|
||||
{
|
||||
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type_key, recipient, subject, body)
|
||||
VALUES (:type_key, :recipient, :subject, :body);");
|
||||
$stmt->bindValue(":type_key", $email->type_key);
|
||||
$stmt->bindValue(":recipient", $email->recipient);
|
||||
$stmt->bindValue(":subject", $email->get_subject());
|
||||
$stmt->bindValue(":body", $email->get_body());
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all queued emails.
|
||||
*
|
||||
* @return array<array{"type_key": string, "recipient": string, "subject": string, "body": string}> the queued
|
||||
* emails
|
||||
*/
|
||||
public function get_queue(): array
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT type_key, recipient, subject, body FROM email_tasks;");
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes mails from the queue.
|
||||
*
|
||||
* @param array<array{"type_key": string, "recipient": string}> $emails the emails to remove from the queue
|
||||
* @return void
|
||||
*/
|
||||
public function unqueue_emails(array $emails): void
|
||||
{
|
||||
$stmt = $this->conn->prepare("DELETE FROM email_tasks
|
||||
WHERE type_key=:type_key AND recipient=:recipient;");
|
||||
$stmt->bindParam(":type_key", $type_key);
|
||||
$stmt->bindParam(":recipient", $recipient);
|
||||
|
||||
foreach ($emails as ["type_key" => $type_key, "recipient" => $recipient])
|
||||
$stmt->execute();
|
||||
}
|
||||
}
|
|
@ -3,61 +3,52 @@
|
|||
namespace com\fwdekker\deathnotifier\mailer;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\LoggerUtil;
|
||||
use com\fwdekker\deathnotifier\validator\IsEqualToRule;
|
||||
use Monolog\Logger;
|
||||
use com\fwdekker\deathnotifier\validator\IsCliPasswordRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use PHPMailer\PHPMailer\Exception as PHPMailerException;
|
||||
use PHPMailer\PHPMailer\PHPMailer;
|
||||
use PHPMailer\PHPMailer\SMTP;
|
||||
|
||||
|
||||
/**
|
||||
* Processes the queue of emails to send.
|
||||
* Sends all emails in an {@see EmailQueue}.
|
||||
*/
|
||||
class ProcessEmailQueueAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var Logger the logger to log with
|
||||
* @var EmailQueue the `EmailQueue` of which all emails should be sent
|
||||
*/
|
||||
private readonly Logger $logger;
|
||||
/**
|
||||
* @var MailManager the manager to process the queue with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly EmailQueue $email_queue;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ProcessEmailQueueAction`.
|
||||
*
|
||||
* @param MailManager $mail_manager the manager to process the queue with
|
||||
* @param EmailQueue $email_queue the `EmailQueue` of which all emails should be sent
|
||||
*/
|
||||
public function __construct(MailManager $mail_manager)
|
||||
public function __construct(EmailQueue $email_queue)
|
||||
{
|
||||
parent::__construct(
|
||||
rule_lists: [
|
||||
"password" => [new IsEqualToRule(Config::get()["admin"]["cli_secret"], "Incorrect password.")]
|
||||
],
|
||||
);
|
||||
|
||||
$this->logger = LoggerUtil::with_name($this::class);
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->email_queue = $email_queue;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Processes the queue.
|
||||
* Sends all emails in the {@see $email_queue}.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* @param array<int|string, mixed> $inputs `"password": string`: the CLI password
|
||||
* @return null
|
||||
* @throws ActionException if the mailer could not be created or if an email could not be sent
|
||||
* @throws InvalidInputException if the CLI password is wrong
|
||||
* @throws UnexpectedException if the mailer fails to send an email
|
||||
*/
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
$emails = $this->mail_manager->get_queue();
|
||||
(new RuleSet(["password" => [new IsCliPasswordRule()]]))->check($inputs);
|
||||
|
||||
$mailer = $this->create_mailer();
|
||||
$emails = $this->email_queue->get_queue();
|
||||
foreach ($emails as $email) {
|
||||
$mailer->Subject = $email["subject"];
|
||||
$mailer->Body = $email["body"];
|
||||
|
@ -67,24 +58,21 @@ class ProcessEmailQueueAction extends Action
|
|||
$mailer->send();
|
||||
} catch (PHPMailerException $exception) {
|
||||
$mailer->getSMTPInstance()->reset();
|
||||
|
||||
$this->logger->error("Failed to send email.", ["cause" => $exception]);
|
||||
throw new ActionException("Failed to send mail.");
|
||||
throw new UnexpectedException("Failed to send email.", previous: $exception);
|
||||
}
|
||||
|
||||
$mailer->clearAddresses();
|
||||
}
|
||||
|
||||
$this->mail_manager->unqueue_emails($emails);
|
||||
$this->email_queue->unqueue_emails($emails);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates a mailer object to send emails with.
|
||||
* Creates a {@see PHPMailer} to send emails with.
|
||||
*
|
||||
* @throws ActionException if the mailer could not be created
|
||||
* @throws UnexpectedException if the {@see PHPMailer} could not be created
|
||||
*/
|
||||
private function create_mailer(): PHPMailer
|
||||
{
|
||||
|
@ -106,9 +94,10 @@ class ProcessEmailQueueAction extends Action
|
|||
$mailer->setFrom($config["mail"]["username"], $config["mail"]["from_name"]);
|
||||
} catch (PHPMailerException $exception) {
|
||||
$mailer->smtpClose();
|
||||
|
||||
$this->logger->error("Failed to set 'from' address while processing email queue.", ["cause" => $exception]);
|
||||
throw new ActionException("Failed to set 'from' address while processing queue.");
|
||||
throw new UnexpectedException(
|
||||
"Failed to set 'from' address while processing email queue.",
|
||||
previous: $exception
|
||||
);
|
||||
}
|
||||
|
||||
return $mailer;
|
||||
|
|
|
@ -1,96 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mediawiki;
|
||||
|
||||
use com\fwdekker\deathnotifier\LoggerUtil;
|
||||
use com\fwdekker\deathnotifier\validator\Rule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use Monolog\Logger;
|
||||
|
||||
|
||||
/**
|
||||
* Verifies that the input refers to a page about a person on Wikipedia.
|
||||
*/
|
||||
class IsPersonPageRule extends Rule
|
||||
{
|
||||
/**
|
||||
* @var Logger the logger to log with
|
||||
*/
|
||||
private Logger $logger;
|
||||
/**
|
||||
* @var MediaWiki the instance to connect to Wikipedia with
|
||||
*/
|
||||
private readonly MediaWiki $mediawiki;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `IsPersonPageRule`.
|
||||
*
|
||||
* @param MediaWiki $mediawiki the instance to connect to Wikipedia with
|
||||
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
|
||||
* the rule implementation can choose an appropriate message
|
||||
*/
|
||||
public function __construct(MediaWiki $mediawiki, ?string $override_message = null)
|
||||
{
|
||||
parent::__construct($override_message);
|
||||
|
||||
$this->logger = LoggerUtil::with_name($this::class);
|
||||
$this->mediawiki = $mediawiki;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verifies that the input refers to a page about a person on Wikipedia.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
|
||||
* @param string $key the key in `inputs` of the input to check
|
||||
* @return void if `$inputs[$key]` refers to a page about a person on Wikipedia
|
||||
* @throws InvalidInputException if `$inputs[$key]` is not set or does not refer to a page about a person on Wikipedia
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
if (!isset($inputs[$key])) throw new InvalidInputException($this->override_message ?? "Field must be set.", $key);
|
||||
|
||||
$person_name = $inputs[$key];
|
||||
try {
|
||||
$info = $this->mediawiki->query_person_info([$person_name]);
|
||||
} catch (MediaWikiException $exception) {
|
||||
$this->logger->error("Failed to query page info.", ["cause" => $exception, "name" => $person_name]);
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ?? "Could not reach Wikipedia. Maybe the website is down?"
|
||||
);
|
||||
}
|
||||
|
||||
$normalized_name = $info->redirects[$person_name];
|
||||
$type = $info->results[$normalized_name]["type"];
|
||||
|
||||
if (in_array($normalized_name, $info->missing))
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ??
|
||||
"Wikipedia does not have an article about " .
|
||||
"<b><a href='https://en.wikipedia.org/wiki/Special:Search?search=" .
|
||||
rawurlencode($normalized_name) . "'>" . htmlentities($person_name) . "</a></b>. " .
|
||||
"Maybe you need to capitalise the surname?",
|
||||
"person_name"
|
||||
);
|
||||
|
||||
if ($type === ArticleType::Disambiguation)
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ??
|
||||
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
|
||||
htmlentities($normalized_name) . "</a></b> refers to multiple articles. " .
|
||||
"<a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>Check Wikipedia</a> " .
|
||||
"to see if your article is listed.",
|
||||
"person_name"
|
||||
);
|
||||
|
||||
if ($type === ArticleType::Other)
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ??
|
||||
"The Wikipedia article about " .
|
||||
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
|
||||
htmlentities($normalized_name) . "</a></b> is not about a real-world person.",
|
||||
"person_name"
|
||||
);
|
||||
}
|
||||
}
|
|
@ -1,13 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mediawiki;
|
||||
|
||||
use Exception;
|
||||
|
||||
|
||||
/**
|
||||
* Thrown if something goes wrong while interacting with the MediaWiki API.
|
||||
*/
|
||||
class MediaWikiException extends Exception {
|
||||
// Empty
|
||||
}
|
|
@ -3,16 +3,18 @@
|
|||
namespace com\fwdekker\deathnotifier\tracking;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\IllegalStateError;
|
||||
use com\fwdekker\deathnotifier\LoggerUtil;
|
||||
use com\fwdekker\deathnotifier\mediawiki\IsPersonPageRule;
|
||||
use com\fwdekker\deathnotifier\mediawiki\MediaWiki;
|
||||
use com\fwdekker\deathnotifier\mediawiki\MediaWikiException;
|
||||
use com\fwdekker\deathnotifier\validator\HasLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
use com\fwdekker\deathnotifier\wikipedia\ArticleType;
|
||||
use com\fwdekker\deathnotifier\wikipedia\PersonStatus;
|
||||
use com\fwdekker\deathnotifier\wikipedia\Wikipedia;
|
||||
use com\fwdekker\deathnotifier\wikipedia\WikipediaException;
|
||||
use com\fwdekker\deathnotifier\validator\HasStringLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsNotBlankRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use Monolog\Logger;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -21,82 +23,118 @@ use Monolog\Logger;
|
|||
class AddTrackingAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var Logger the logger to log with
|
||||
* @var TrackingList the list to add the tracking to
|
||||
*/
|
||||
private Logger $logger;
|
||||
private readonly TrackingList $tracking_list;
|
||||
/**
|
||||
* @var TrackingManager the manager to add the tracking to
|
||||
* @var Wikipedia the API of Wikipedia
|
||||
*/
|
||||
private readonly TrackingManager $tracking_manager;
|
||||
/**
|
||||
* @var MediaWiki the instance to connect to Wikipedia with
|
||||
*/
|
||||
private readonly MediaWiki $mediawiki;
|
||||
private readonly Wikipedia $wikipedia;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `AddTrackingAction`.
|
||||
*
|
||||
* @param TrackingManager $tracking_manager the manager to add the tracking to
|
||||
* @param MediaWiki $mediawiki the instance to connect to Wikipedia with
|
||||
* @param TrackingList $tracking_list the list to add the tracking to
|
||||
* @param Wikipedia $wikipedia the API of Wikipedia
|
||||
*/
|
||||
public function __construct(TrackingManager $tracking_manager, MediaWiki $mediawiki)
|
||||
public function __construct(TrackingList $tracking_list, Wikipedia $wikipedia)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_in: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"person_name" => [
|
||||
new IsNotBlankRule(),
|
||||
new HasLengthRule(TrackingManager::MIN_TITLE_LENGTH, TrackingManager::MAX_TITLE_LENGTH),
|
||||
new IsPersonPageRule($mediawiki)
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->logger = LoggerUtil::with_name($this::class);
|
||||
$this->tracking_manager = $tracking_manager;
|
||||
$this->mediawiki = $mediawiki;
|
||||
$this->tracking_list = $tracking_list;
|
||||
$this->wikipedia = $wikipedia;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds a tracking by the current user of the specified person.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"person_name": string`: the name of the person to track
|
||||
* @return array{"input_name": string, "normalized_name": string} the person's name as input by the user, and the
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"person_name": string`: the name
|
||||
* of the person to track
|
||||
* @return array{"input_name": string, "normalized_name": string} the person's name as given by the user, and the
|
||||
* normalized version of that name
|
||||
* @throws ActionException if the Wikipedia API could not be reached
|
||||
* @throws InvalidInputException if the user is already tracking this person
|
||||
* @throws InvalidInputException if the user is not logged in, if no valid CSRF token is present, if the article
|
||||
* title is a blank string, if the article title is too short or too long, if the specified article does not exist,
|
||||
* if the specified article is not about a person, or if the user is already tracking this article
|
||||
* @throws UnexpectedException if Wikipedia could not be reached
|
||||
*/
|
||||
public function handle(array $inputs): array
|
||||
{
|
||||
$user_uuid = $_SESSION["uuid"];
|
||||
$person_name = strval($inputs["person_name"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"person_name" => [
|
||||
new IsNotBlankRule(),
|
||||
new HasStringLengthRule(TrackingList::MIN_TITLE_LENGTH, TrackingList::MAX_TITLE_LENGTH)
|
||||
],
|
||||
]))->check($inputs);
|
||||
|
||||
// Query API
|
||||
[$normalized_name, $status] = $this->get_and_validate_page_info(strval($inputs["person_name"]));
|
||||
$this->tracking_list->transaction(function () use ($normalized_name, $status) {
|
||||
if ($this->tracking_list->has_tracking($_SESSION["uuid"], $normalized_name))
|
||||
throw new InvalidInputException("You are already tracking <b>$normalized_name</b>.");
|
||||
|
||||
$this->tracking_list->add_tracking($_SESSION["uuid"], $normalized_name, $status);
|
||||
});
|
||||
|
||||
return ["input_name" => $inputs["person_name"], "normalized_name" => $normalized_name];
|
||||
}
|
||||
|
||||
/**
|
||||
* Validates that {@see $person_name} is an article about a person, and returns information about that article.
|
||||
*
|
||||
* @param string $person_name the title of the article about a person to return the information of
|
||||
* @return array{string, PersonStatus} the normalized name and `PersonStatus` of the specified article
|
||||
* @throws InvalidInputException if the article about {@see $person_name} does not exist or is not about a person
|
||||
* @throws UnexpectedException if Wikipedia could not be reached
|
||||
*/
|
||||
private function get_and_validate_page_info(string $person_name): array
|
||||
{
|
||||
try {
|
||||
$info = $this->mediawiki->query_person_info([$person_name]);
|
||||
} catch (MediaWikiException $exception) {
|
||||
$this->logger->error("Failed to query page info.", ["cause" => $exception, "name" => $person_name]);
|
||||
throw new ActionException("Could not reach Wikipedia. Maybe the website is down?");
|
||||
$info = $this->wikipedia->query_person_info([$person_name]);
|
||||
|
||||
$normalized_name = $info->redirects[$person_name];
|
||||
$type = $info->results[$normalized_name]["type"];
|
||||
$status = $info->results[$normalized_name]["status"];
|
||||
} catch (WikipediaException $exception) {
|
||||
throw new UnexpectedException(
|
||||
"Could not reach Wikipedia. Maybe the website is down?",
|
||||
previous: $exception
|
||||
);
|
||||
}
|
||||
|
||||
if (in_array($normalized_name, $info->missing)) {
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ??
|
||||
"Wikipedia does not have an article about " .
|
||||
"<b><a href='https://en.wikipedia.org/wiki/Special:Search?search=" .
|
||||
rawurlencode($normalized_name) . "'>" . htmlentities($normalized_name) . "</a></b>. " .
|
||||
"Maybe you need to capitalise the surname?",
|
||||
"person_name"
|
||||
);
|
||||
} else if ($type === ArticleType::Disambiguation) {
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ??
|
||||
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
|
||||
htmlentities($normalized_name) . "</a></b> refers to multiple articles. " .
|
||||
"<a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>Check Wikipedia</a> " .
|
||||
"to see if your article is listed.",
|
||||
"person_name"
|
||||
);
|
||||
} else if ($type === ArticleType::Other) {
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ??
|
||||
"The Wikipedia article about " .
|
||||
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
|
||||
htmlentities($normalized_name) . "</a></b> is not about a real-world person.",
|
||||
"person_name"
|
||||
);
|
||||
}
|
||||
|
||||
$normalized_name = $info->redirects[$person_name];
|
||||
$status = $info->results[$normalized_name]["status"];
|
||||
if ($status === null)
|
||||
throw new IllegalStateError("Article is about person, but person has no status.");
|
||||
throw new IllegalStateError("Person page does not have a status.");
|
||||
|
||||
// Add tracking (Transaction is not necessary)
|
||||
if ($this->tracking_manager->has_tracking($user_uuid, $normalized_name))
|
||||
throw new InvalidInputException("You are already tracking <b>$normalized_name</b>.");
|
||||
|
||||
$this->tracking_manager->add_tracking($user_uuid, $normalized_name, $status);
|
||||
|
||||
// Respond
|
||||
return [
|
||||
"input_name" => $person_name,
|
||||
"normalized_name" => $normalized_name,
|
||||
];
|
||||
return [$normalized_name, $status];
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,6 +3,10 @@
|
|||
namespace com\fwdekker\deathnotifier\tracking;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -11,35 +15,36 @@ use com\fwdekker\deathnotifier\Action;
|
|||
class ListTrackingsAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var TrackingManager the manager to fetch trackings with
|
||||
* @var TrackingList the list to return the current user's trackings from
|
||||
*/
|
||||
private readonly TrackingManager $tracking_manager;
|
||||
private readonly TrackingList $tracking_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ListTrackingsAction`.
|
||||
*
|
||||
* @param TrackingManager $tracking_manager the manager to fetch trackings with
|
||||
* @param TrackingList $tracking_list the list to return the current user's trackings from
|
||||
*/
|
||||
public function __construct(TrackingManager $tracking_manager)
|
||||
public function __construct(TrackingList $tracking_list)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_in: true,
|
||||
require_valid_csrf_token: true,
|
||||
);
|
||||
|
||||
$this->tracking_manager = $tracking_manager;
|
||||
$this->tracking_list = $tracking_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns all trackings of the current user.
|
||||
* Lists all trackings of the current user.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* @return mixed[] all trackings of the current user
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token
|
||||
* @return array<array{"name": string, "status": string, "is_deleted": bool}> all trackings of the current user
|
||||
* @throws InvalidInputException if the user is not logged in or if no valid CSRF token is present
|
||||
*/
|
||||
public function handle(array $inputs): array
|
||||
{
|
||||
return $this->tracking_manager->list_trackings($_SESSION["uuid"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet(["token" => [new IsValidCsrfTokenRule()]]))->check($inputs);
|
||||
|
||||
return $this->tracking_list->list_trackings($_SESSION["uuid"]);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,46 +3,56 @@
|
|||
namespace com\fwdekker\deathnotifier\tracking;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\IsNotBlankRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsStringRule;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Removes a tracking of the current user.
|
||||
* Removes a tracking.
|
||||
*/
|
||||
class RemoveTrackingAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var TrackingManager the manager to remove the tracking with
|
||||
* @var TrackingList the list to remove the tracking from
|
||||
*/
|
||||
private readonly TrackingManager $tracking_manager;
|
||||
private readonly TrackingList $tracking_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `RemoveTrackingAction`.
|
||||
*
|
||||
* @param TrackingManager $tracking_manager the manager to remove the tracking with
|
||||
* @param TrackingList $tracking_list the list to remove the tracking from
|
||||
*/
|
||||
public function __construct(TrackingManager $tracking_manager)
|
||||
public function __construct(TrackingList $tracking_list)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_in: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: ["person_name" => [new IsNotBlankRule()]],
|
||||
);
|
||||
|
||||
$this->tracking_manager = $tracking_manager;
|
||||
$this->tracking_list = $tracking_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Removes the current user's tracking of the specified person.
|
||||
* Removes the tracking by the current user of the specified person.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"person_name": string`: the name of the person to stop tracking
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"person_name": string`: the name
|
||||
* of the person to stop tracking
|
||||
* @return null
|
||||
* @throws InvalidInputException if the user is not logged in, if no valid CSRF token is present, or if the article
|
||||
* title is not a string
|
||||
*/
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
$this->tracking_manager->remove_tracking($_SESSION["uuid"], $inputs["person_name"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"person_name" => [new IsStringRule()],
|
||||
]))->check($inputs);
|
||||
|
||||
$this->tracking_list->remove_tracking($_SESSION["uuid"], $inputs["person_name"]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
|
@ -0,0 +1,325 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\tracking;
|
||||
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\wikipedia\ArticleType;
|
||||
use com\fwdekker\deathnotifier\wikipedia\PersonStatus;
|
||||
use PDO;
|
||||
|
||||
|
||||
/**
|
||||
* A list of trackings, stored in a {@see Database}.
|
||||
*
|
||||
* A tracking is a `(user, person)` pair, where a `user` is an end-user and a `person` is a real-world person that has
|
||||
* a Wikipedia page. A tracking signifies the fact that the `user` wishes to track a `person`.
|
||||
*/
|
||||
class TrackingList
|
||||
{
|
||||
/**
|
||||
* The minimum length of a Wikipedia article title.
|
||||
*/
|
||||
public const MIN_TITLE_LENGTH = 1;
|
||||
/**
|
||||
* The maximum length of a Wikipedia article title.
|
||||
*/
|
||||
public const MAX_TITLE_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* @var Database the database to store trackings in
|
||||
*/
|
||||
private readonly Database $database;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `TrackingList`.
|
||||
*
|
||||
* @param Database $database the database to store trackings in
|
||||
*/
|
||||
public function __construct(Database $database)
|
||||
{
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Populates the {@see Database} with the necessary structures for a `TrackingList`.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function install(): void
|
||||
{
|
||||
$conn = $this->database->conn;
|
||||
$conn->exec("CREATE TABLE trackings(user_uuid TEXT NOT NULL,
|
||||
person_name TEXT NOT NULL,
|
||||
PRIMARY KEY (user_uuid, person_name),
|
||||
FOREIGN KEY (user_uuid) REFERENCES users (uuid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
FOREIGN KEY (person_name) REFERENCES people (name)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE);");
|
||||
// TODO: Add column for date since tracking
|
||||
$conn->exec("CREATE TABLE people(name TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT(''),
|
||||
is_deleted INT NOT NULL DEFAULT(0));");
|
||||
$conn->exec("CREATE TRIGGER people_cull_orphans
|
||||
AFTER DELETE ON trackings
|
||||
FOR EACH ROW
|
||||
WHEN (SELECT COUNT(*) FROM trackings WHERE person_name=OLD.person_name)=0
|
||||
BEGIN
|
||||
DELETE FROM people WHERE name=OLD.person_name;
|
||||
END;");
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes {@see $lambda} within a single database transaction.
|
||||
*
|
||||
* @param callable(): void $lambda the function to execute within a transaction
|
||||
* @return void
|
||||
* @see Database::transaction()
|
||||
*/
|
||||
public function transaction(callable $lambda): void
|
||||
{
|
||||
$this->database->transaction($lambda);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds a tracking to the database.
|
||||
*
|
||||
* @param string $user_uuid the UUID of the user for whom to add the tracking
|
||||
* @param string $person_name the name of the person to track
|
||||
* @param PersonStatus $status the status of the person to track
|
||||
* @return void
|
||||
*/
|
||||
public function add_tracking(string $user_uuid, string $person_name, PersonStatus $status): void
|
||||
{
|
||||
$this->transaction(function () use ($user_uuid, $person_name, $status) {
|
||||
$conn = $this->database->conn;
|
||||
|
||||
$stmt = $conn->prepare("INSERT OR IGNORE INTO people (name) VALUES (:name);");
|
||||
$stmt->bindValue(":name", $person_name);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $conn->prepare("UPDATE people SET status=:status WHERE name=:name;");
|
||||
$stmt->bindValue(":name", $person_name);
|
||||
$stmt->bindValue(":status", $status->value);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $conn->prepare("INSERT OR IGNORE INTO trackings (user_uuid, person_name)
|
||||
VALUES (:user_uuid, :person_name);");
|
||||
$stmt->bindValue(":user_uuid", $user_uuid);
|
||||
$stmt->bindValue(":person_name", $person_name);
|
||||
$stmt->execute();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if and only if the given user currently tracks the given person.
|
||||
*
|
||||
* @param string $user_uuid the UUID of the user to check
|
||||
* @param string $person_name the person to check
|
||||
* @return bool `true` if and only if the given user currently tracks the given person
|
||||
*/
|
||||
public function has_tracking(string $user_uuid, string $person_name): bool
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("SELECT EXISTS(SELECT 1
|
||||
FROM trackings
|
||||
WHERE user_uuid=:uuid AND person_name=:name);");
|
||||
$stmt->bindValue(":uuid", $user_uuid);
|
||||
$stmt->bindValue(":name", $person_name);
|
||||
$stmt->execute();
|
||||
return $stmt->fetch()[0] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a tracking from the database.
|
||||
*
|
||||
* @param string $user_uuid the user to whom the tracking belongs
|
||||
* @param string $person_name the name of the tracked person to remove
|
||||
* @return void
|
||||
*/
|
||||
public function remove_tracking(string $user_uuid, string $person_name): void
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("DELETE FROM trackings
|
||||
WHERE user_uuid=:user_uuid AND person_name=:person_name;");
|
||||
$stmt->bindValue(":user_uuid", $user_uuid);
|
||||
$stmt->bindValue(":person_name", $person_name);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all trackings of the given user.
|
||||
*
|
||||
* @param string $user_uuid the user to return the trackings of
|
||||
* @return array<array{"name": string, "status": string, "is_deleted": bool}> all trackings of the given user
|
||||
*/
|
||||
public function list_trackings(string $user_uuid): array
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("SELECT people.name, people.status, people.is_deleted
|
||||
FROM trackings
|
||||
INNER JOIN people
|
||||
ON trackings.user_uuid=:user_uuid
|
||||
AND trackings.person_name=people.name;");
|
||||
$stmt->bindValue(":user_uuid", $user_uuid);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the email addresses of all users who should be notified of events relating to the given person.
|
||||
*
|
||||
* @param string $person_name the person to receive subscribed email addresses for
|
||||
* @return string[] the email addresses of all users who should be notified of events relating to the given person
|
||||
*/
|
||||
public function list_trackers(string $person_name): array
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("SELECT users.email
|
||||
FROM users
|
||||
LEFT JOIN trackings
|
||||
WHERE trackings.person_name=:person_name
|
||||
AND trackings.user_uuid=users.uuid
|
||||
AND users.email_verification_token IS NULL
|
||||
AND users.email_notifications_enabled=1;");
|
||||
$stmt->bindParam(":person_name", $person_name);
|
||||
$stmt->execute();
|
||||
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "email");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Lists all unique names being tracked in the database.
|
||||
*
|
||||
* @return string[] all unique names in the database
|
||||
*/
|
||||
public function list_all_unique_person_names(): array
|
||||
{
|
||||
$stmt = $this->database->conn->prepare("SELECT ALL name FROM people;");
|
||||
$stmt->execute();
|
||||
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "name");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames people in the database.
|
||||
*
|
||||
* @param array<string, string> $renamings a map of all changes, from old name to new name
|
||||
* @return void
|
||||
*/
|
||||
public function rename_persons(array $renamings): void
|
||||
{
|
||||
$this->transaction(function () use ($renamings) {
|
||||
$conn = $this->database->conn;
|
||||
|
||||
// Query to rename person
|
||||
$rename = $conn->prepare("UPDATE people SET name=:new_name WHERE name=:old_name;");
|
||||
$rename->bindParam(":old_name", $from);
|
||||
$rename->bindParam(":new_name", $to);
|
||||
|
||||
// Query to see if row with new name already exists
|
||||
$merge_needed = $conn->prepare("SELECT EXISTS(SELECT 1 FROM people WHERE name=:new_name);");
|
||||
$merge_needed->bindParam(":new_name", $to);
|
||||
|
||||
// Queries to merge old name row with new name row
|
||||
$merge_update = $conn->prepare("UPDATE OR IGNORE trackings
|
||||
SET person_name=:new_name
|
||||
WHERE person_name=:old_name;");
|
||||
$merge_update->bindParam(":old_name", $from);
|
||||
$merge_update->bindParam(":new_name", $to);
|
||||
|
||||
$merge_remove = $conn->prepare("DELETE FROM people WHERE name=:old_name;");
|
||||
$merge_remove->bindParam(":old_name", $from);
|
||||
|
||||
// Perform queries
|
||||
foreach ($renamings as $from => $to) {
|
||||
if ($from === $to) continue;
|
||||
|
||||
$merge_needed->execute();
|
||||
if ($merge_needed->fetch()[0] === 1) {
|
||||
$merge_update->execute();
|
||||
$merge_remove->execute();
|
||||
} else {
|
||||
$rename->execute();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Marks people as deleted in the database.
|
||||
*
|
||||
* @param string[] $deletions list of names of people to mark as deleted in the database
|
||||
* @return string[] subset of {@see $deletions} containing the names of people that were newly marked as deleted
|
||||
*/
|
||||
public function delete_persons(array $deletions): array
|
||||
{
|
||||
$new_deletions = [];
|
||||
|
||||
$this->transaction(function () use ($deletions, &$new_deletions) {
|
||||
$delete = $this->database->conn->prepare("UPDATE people
|
||||
SET is_deleted=1
|
||||
WHERE name=:name AND is_deleted<>1
|
||||
RETURNING name;");
|
||||
$delete->bindParam(":name", $deleted_name);
|
||||
|
||||
foreach ($deletions as $deleted_name) {
|
||||
$delete->execute();
|
||||
$newly_deleted = sizeof($delete->fetchAll(PDO::FETCH_ASSOC)) > 0;
|
||||
|
||||
if ($newly_deleted)
|
||||
$new_deletions[] = $deleted_name;
|
||||
}
|
||||
});
|
||||
|
||||
return $new_deletions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates peoples' statuses.
|
||||
*
|
||||
* @param array<string, array{"type": ArticleType, "status": PersonStatus|null}> $statuses the current statuses of
|
||||
* people
|
||||
* @return array{string[], array<string, string>} the list of articles that were actually undeleted, and a mapping
|
||||
* of articles that were actually changes to the new status
|
||||
*/
|
||||
public function update_statuses(array $statuses): array
|
||||
{
|
||||
$undeletions = [];
|
||||
$status_changes = [];
|
||||
|
||||
$this->transaction(function () use ($statuses, &$undeletions, &$status_changes) {
|
||||
$conn = $this->database->conn;
|
||||
|
||||
// Query to mark person as no longer deleted, returning `name` to determine whether something changed
|
||||
// TODO: Split this into two methods, one for `undelete`, one for `update_statuses`?
|
||||
$undelete = $conn->prepare("UPDATE people
|
||||
SET is_deleted=0
|
||||
WHERE name=:name AND is_deleted<>0
|
||||
RETURNING name;");
|
||||
$undelete->bindParam(":name", $person_name);
|
||||
|
||||
// Query to update status, returning `name` to determine whether something changed
|
||||
$set_status = $conn->prepare("UPDATE people
|
||||
SET status=:status
|
||||
WHERE name=:name AND status<>:status
|
||||
RETURNING name;");
|
||||
$set_status->bindParam(":status", $person_status_string);
|
||||
$set_status->bindParam(":name", $person_name);
|
||||
|
||||
foreach ($statuses as $person_name => $person_info) {
|
||||
if ($person_info["status"] === null) continue;
|
||||
$person_status_string = $person_info["status"]->value;
|
||||
|
||||
$undelete->execute();
|
||||
$undeleted = sizeof($undelete->fetchAll(PDO::FETCH_ASSOC)) > 0;
|
||||
if ($undeleted) $undeletions[] = $person_name;
|
||||
|
||||
$set_status->execute();
|
||||
$status_changed = sizeof($set_status->fetchAll(PDO::FETCH_ASSOC)) > 0;
|
||||
if ($status_changed) $status_changes[$person_name] = $person_status_string;
|
||||
}
|
||||
});
|
||||
|
||||
return [$undeletions, $status_changes];
|
||||
}
|
||||
}
|
|
@ -1,306 +0,0 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\tracking;
|
||||
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\mediawiki\ArticleType;
|
||||
use com\fwdekker\deathnotifier\mediawiki\PersonStatus;
|
||||
use PDO;
|
||||
|
||||
|
||||
/**
|
||||
* Manages interaction with the database in the context of trackings.
|
||||
*/
|
||||
class TrackingManager
|
||||
{
|
||||
/**
|
||||
* The minimum length of a Wikipedia article title.
|
||||
*/
|
||||
public const MIN_TITLE_LENGTH = 1;
|
||||
/**
|
||||
* The maximum length of a Wikipedia article title.
|
||||
*/
|
||||
public const MAX_TITLE_LENGTH = 255;
|
||||
|
||||
/**
|
||||
* @var PDO the database connection to interact with
|
||||
*/
|
||||
private PDO $conn;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new tracking manager.
|
||||
*
|
||||
* @param PDO $conn the database connection to interact with
|
||||
*/
|
||||
public function __construct(PDO $conn)
|
||||
{
|
||||
$this->conn = $conn;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Populates the database with the necessary structures for trackings.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function install(): void
|
||||
{
|
||||
$this->conn->exec("CREATE TABLE trackings(user_uuid TEXT NOT NULL,
|
||||
person_name TEXT NOT NULL,
|
||||
PRIMARY KEY (user_uuid, person_name),
|
||||
FOREIGN KEY (user_uuid) REFERENCES users (uuid)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE,
|
||||
FOREIGN KEY (person_name) REFERENCES people (name)
|
||||
ON DELETE CASCADE
|
||||
ON UPDATE CASCADE);");
|
||||
// TODO: Add column for date since tracking
|
||||
$this->conn->exec("CREATE TABLE people(name TEXT NOT NULL UNIQUE PRIMARY KEY,
|
||||
status TEXT NOT NULL DEFAULT(''),
|
||||
is_deleted INT NOT NULL DEFAULT(0));");
|
||||
$this->conn->exec("CREATE TRIGGER people_cull_orphans
|
||||
AFTER DELETE ON trackings
|
||||
FOR EACH ROW
|
||||
WHEN (SELECT COUNT(*) FROM trackings WHERE person_name=OLD.person_name)=0
|
||||
BEGIN
|
||||
DELETE FROM people WHERE name=OLD.person_name;
|
||||
END;");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Adds a tracking to the database.
|
||||
*
|
||||
* @param string $user_uuid the user to whom the tracking belongs
|
||||
* @param string $person_name the name of the person to track
|
||||
* @param PersonStatus $status the status of the person to track
|
||||
* @return void
|
||||
*/
|
||||
public function add_tracking(string $user_uuid, string $person_name, PersonStatus $status): void
|
||||
{
|
||||
Database::transaction(
|
||||
$this->conn,
|
||||
function () use ($user_uuid, $person_name, $status) {
|
||||
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO people (name) VALUES (:name);");
|
||||
$stmt->bindValue(":name", $person_name);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $this->conn->prepare("UPDATE people SET status=:status WHERE name=:name;");
|
||||
$stmt->bindValue(":name", $person_name);
|
||||
$stmt->bindValue(":status", $status->value);
|
||||
$stmt->execute();
|
||||
|
||||
$stmt = $this->conn->prepare("INSERT OR IGNORE INTO trackings (user_uuid, person_name)
|
||||
VALUES (:user_uuid, :person_name);");
|
||||
$stmt->bindValue(":user_uuid", $user_uuid);
|
||||
$stmt->bindValue(":person_name", $person_name);
|
||||
$stmt->execute();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns `true` if and only if the indicated user currently tracks the indicated person.
|
||||
*
|
||||
* @param string $user_uuid the user to check
|
||||
* @param string $person_name the person to check
|
||||
* @return bool `true` if and only if the indicated user currently tracks the indicated person
|
||||
*/
|
||||
public function has_tracking(string $user_uuid, string $person_name): bool
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1
|
||||
FROM trackings
|
||||
WHERE user_uuid=:uuid AND person_name=:name);");
|
||||
$stmt->bindValue(":uuid", $user_uuid);
|
||||
$stmt->bindValue(":name", $person_name);
|
||||
$stmt->execute();
|
||||
return $stmt->fetch()[0] === 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a tracking from the database.
|
||||
*
|
||||
* @param string $user_uuid the user to whom the tracking belongs
|
||||
* @param string $person_name the name of the tracked person to remove
|
||||
* @return void
|
||||
*/
|
||||
public function remove_tracking(string $user_uuid, string $person_name): void
|
||||
{
|
||||
$stmt = $this->conn->prepare("DELETE FROM trackings
|
||||
WHERE user_uuid=:user_uuid AND person_name=:person_name;");
|
||||
$stmt->bindValue(":user_uuid", $user_uuid);
|
||||
$stmt->bindValue(":person_name", $person_name);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all trackings of the indicated user.
|
||||
*
|
||||
* @param string $user_uuid the user to return the trackings of
|
||||
* @return array<array{"name": string, "status": string, "is_deleted": bool}> all trackings of the indicated user
|
||||
*/
|
||||
public function list_trackings(string $user_uuid): array
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT people.name, people.status, people.is_deleted
|
||||
FROM trackings
|
||||
INNER JOIN people
|
||||
ON trackings.user_uuid=:user_uuid
|
||||
AND trackings.person_name=people.name;");
|
||||
$stmt->bindValue(":user_uuid", $user_uuid);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the email addresses of all users who should be notified of events relating to the given person.
|
||||
*
|
||||
* @param string $person_name the person to receive subscribed email addresses for
|
||||
* @return string[] the email addresses of all users who should be notified of events relating to the given person
|
||||
*/
|
||||
public function list_trackers(string $person_name): array
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT users.email
|
||||
FROM users
|
||||
LEFT JOIN trackings
|
||||
WHERE trackings.person_name=:person_name
|
||||
AND trackings.user_uuid=users.uuid
|
||||
AND users.email_verification_token IS NULL
|
||||
AND users.email_notifications_enabled=1;");
|
||||
$stmt->bindParam(":person_name", $person_name);
|
||||
$stmt->execute();
|
||||
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "email");
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Lists all unique names being tracked in the database.
|
||||
*
|
||||
* @return string[] all unique names in the database
|
||||
*/
|
||||
public function list_all_unique_person_names(): array
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT ALL name FROM people;");
|
||||
$stmt->execute();
|
||||
return array_column($stmt->fetchAll(PDO::FETCH_ASSOC), "name");
|
||||
}
|
||||
|
||||
/**
|
||||
* Renames people in the database.
|
||||
*
|
||||
* @param array<string, string> $renamings a map of all changes, from old name to new name
|
||||
* @return void
|
||||
*/
|
||||
public function rename_persons(array $renamings): void
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($renamings) {
|
||||
// Query to rename person
|
||||
$rename = $this->conn->prepare("UPDATE people SET name=:new_name WHERE name=:old_name;");
|
||||
$rename->bindParam(":old_name", $from);
|
||||
$rename->bindParam(":new_name", $to);
|
||||
|
||||
// Query to see if row with new name already exists
|
||||
$merge_needed = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM people WHERE name=:new_name);");
|
||||
$merge_needed->bindParam(":new_name", $to);
|
||||
|
||||
// Queries to merge old name row with new name row
|
||||
$merge_update = $this->conn->prepare("UPDATE OR IGNORE trackings
|
||||
SET person_name=:new_name
|
||||
WHERE person_name=:old_name;");
|
||||
$merge_update->bindParam(":old_name", $from);
|
||||
$merge_update->bindParam(":new_name", $to);
|
||||
|
||||
$merge_remove = $this->conn->prepare("DELETE FROM people WHERE name=:old_name;");
|
||||
$merge_remove->bindParam(":old_name", $from);
|
||||
|
||||
// Perform queries
|
||||
foreach ($renamings as $from => $to) {
|
||||
if ($from === $to) continue;
|
||||
|
||||
$merge_needed->execute();
|
||||
if ($merge_needed->fetch()[0] === 1) {
|
||||
$merge_update->execute();
|
||||
$merge_remove->execute();
|
||||
} else {
|
||||
$rename->execute();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes people from the database.
|
||||
*
|
||||
* @param string[] $deletions list of names of people to remove from the database
|
||||
* @return string[] list of names of people who were actually removed from the database
|
||||
*/
|
||||
public function delete_persons(array $deletions): array
|
||||
{
|
||||
$actual_deletions = [];
|
||||
|
||||
Database::transaction($this->conn, function () use ($deletions, &$actual_deletions) {
|
||||
// Query to delete person, returning `name` to determine whether something changed
|
||||
$delete = $this->conn->prepare("UPDATE people
|
||||
SET is_deleted=1
|
||||
WHERE name=:name AND is_deleted<>1
|
||||
RETURNING name;");
|
||||
$delete->bindParam(":name", $deleted_name);
|
||||
|
||||
foreach ($deletions as $deleted_name) {
|
||||
$delete->execute();
|
||||
$deleted = sizeof($delete->fetchAll(PDO::FETCH_ASSOC)) > 0;
|
||||
|
||||
if ($deleted)
|
||||
$actual_deletions[] = $deleted_name;
|
||||
}
|
||||
});
|
||||
|
||||
return $actual_deletions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates peoples' statuses.
|
||||
*
|
||||
* @param array<string, array{"type": ArticleType, "status": PersonStatus|null}> $statuses the current statuses of
|
||||
* people
|
||||
* @return array{string[], array<string, string>} the list of articles that were actually undeleted, and a mapping
|
||||
* of articles that were actually changes to the new status
|
||||
*/
|
||||
public function update_statuses(array $statuses): array
|
||||
{
|
||||
$undeletions = [];
|
||||
$status_changes = [];
|
||||
|
||||
Database::transaction($this->conn, function () use ($statuses, &$undeletions, &$status_changes) {
|
||||
// Query to mark person as no longer deleted, returning `name` to determine whether something changed
|
||||
// TODO: Split this into two methods, one for `undelete`, one for `update_statuses`?
|
||||
$undelete = $this->conn->prepare("UPDATE people
|
||||
SET is_deleted=0
|
||||
WHERE name=:name AND is_deleted<>0
|
||||
RETURNING name;");
|
||||
$undelete->bindParam(":name", $person_name);
|
||||
|
||||
// Query to update status, returning `name` to determine whether something changed
|
||||
$set_status = $this->conn->prepare("UPDATE people
|
||||
SET status=:status
|
||||
WHERE name=:name AND status<>:status
|
||||
RETURNING name;");
|
||||
$set_status->bindParam(":status", $person_status_string);
|
||||
$set_status->bindParam(":name", $person_name);
|
||||
|
||||
foreach ($statuses as $person_name => $person_info) {
|
||||
if ($person_info["status"] === null) continue;
|
||||
$person_status_string = $person_info["status"]->value;
|
||||
|
||||
$undelete->execute();
|
||||
$undeleted = sizeof($undelete->fetchAll(PDO::FETCH_ASSOC)) > 0;
|
||||
if ($undeleted) $undeletions[] = $person_name;
|
||||
|
||||
$set_status->execute();
|
||||
$status_changed = sizeof($set_status->fetchAll(PDO::FETCH_ASSOC)) > 0;
|
||||
if ($status_changed) $status_changes[$person_name] = $person_status_string;
|
||||
}
|
||||
});
|
||||
|
||||
return [$undeletions, $status_changes];
|
||||
}
|
||||
}
|
|
@ -3,66 +3,58 @@
|
|||
namespace com\fwdekker\deathnotifier\tracking;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\LoggerUtil;
|
||||
use com\fwdekker\deathnotifier\mailer\Email;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\mediawiki\MediaWiki;
|
||||
use com\fwdekker\deathnotifier\mediawiki\MediaWikiException;
|
||||
use com\fwdekker\deathnotifier\validator\IsEqualToRule;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\wikipedia\Wikipedia;
|
||||
use com\fwdekker\deathnotifier\wikipedia\WikipediaException;
|
||||
use com\fwdekker\deathnotifier\validator\IsCliPasswordRule;
|
||||
use Monolog\Logger;
|
||||
use PDO;
|
||||
|
||||
|
||||
/**
|
||||
* Updates all trackings that users have added.
|
||||
*
|
||||
* @see NotifyStatusChangedEmail
|
||||
* @see NotifyArticleDeletedEmail
|
||||
* @see NotifyArticleUndeletedEmail
|
||||
*/
|
||||
class UpdateTrackingsAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var Logger the logger to log with
|
||||
*/
|
||||
private Logger $logger;
|
||||
private readonly Logger $logger;
|
||||
/**
|
||||
* @var PDO the database connection to interact with
|
||||
* @var TrackingList the list of trackings to update
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
private readonly TrackingList $tracking_list;
|
||||
/**
|
||||
* @var TrackingManager the manager through which trackings should be updated
|
||||
* @var Wikipedia the API of Wikipedia
|
||||
*/
|
||||
private readonly TrackingManager $tracking_manager;
|
||||
private readonly Wikipedia $wikipedia;
|
||||
/**
|
||||
* @var MediaWiki the instance to connect to Wikipedia with
|
||||
* @var EmailQueue the queue to add notifications to
|
||||
*/
|
||||
private readonly MediaWiki $mediawiki;
|
||||
/**
|
||||
* @var MailManager the mailer to send emails with
|
||||
*/
|
||||
private readonly MailManager $mailer;
|
||||
private readonly EmailQueue $mailer;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `UpdateTrackingsAction`.
|
||||
*
|
||||
* @param PDO $conn the database connection to interact with
|
||||
* @param TrackingManager $tracking_manager the manager through which trackings should be updated
|
||||
* @param MediaWiki $mediawiki the instance to connect to Wikipedia with
|
||||
* @param MailManager $mailer the mailer to send emails with
|
||||
* @param TrackingList $tracking_list the list of trackings to update
|
||||
* @param Wikipedia $wikipedia the API of Wikipedia
|
||||
* @param EmailQueue $mailer the queue to add notifications to
|
||||
*/
|
||||
public function __construct(PDO $conn, TrackingManager $tracking_manager, MediaWiki $mediawiki, MailManager $mailer)
|
||||
public function __construct(TrackingList $tracking_list, Wikipedia $wikipedia, EmailQueue $mailer)
|
||||
{
|
||||
parent::__construct(
|
||||
rule_lists: [
|
||||
"password" => [new IsEqualToRule(Config::get()["admin"]["cli_secret"], "Incorrect password.")]
|
||||
],
|
||||
);
|
||||
|
||||
$this->logger = LoggerUtil::with_name($this::class);
|
||||
$this->conn = $conn;
|
||||
$this->tracking_manager = $tracking_manager;
|
||||
$this->mediawiki = $mediawiki;
|
||||
$this->tracking_list = $tracking_list;
|
||||
$this->wikipedia = $wikipedia;
|
||||
$this->mailer = $mailer;
|
||||
}
|
||||
|
||||
|
@ -70,54 +62,50 @@ class UpdateTrackingsAction extends Action
|
|||
/**
|
||||
* Updates all trackings that users have added.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* @param array<int|string, mixed> $inputs `"password": string`: the CLI password
|
||||
* @return null
|
||||
* @throws ActionException if the Wikipedia API could not be reached
|
||||
* @throws InvalidInputException if the CLI password is wrong
|
||||
* @throws UnexpectedException if Wikipedia could not be reached
|
||||
* @noinspection PhpDocRedundantThrowsInspection can be thrown through {@see TrackingList::transaction()}
|
||||
*/
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
$names = $this->tracking_manager->list_all_unique_person_names();
|
||||
if (empty($names)) return null;
|
||||
|
||||
// Fetch changes
|
||||
try {
|
||||
$people_statuses = $this->mediawiki->query_person_info($names);
|
||||
} catch (MediaWikiException $exception) {
|
||||
$this->logger->error("Failed to query page info.", ["cause" => $exception, "pages" => $names]);
|
||||
throw new ActionException("Could not reach Wikipedia. Maybe the website is down?");
|
||||
}
|
||||
(new RuleSet(["password" => [new IsCliPasswordRule()]]))->check($inputs);
|
||||
|
||||
// Process changes
|
||||
$actual_deletions = [];
|
||||
$new_deletions = [];
|
||||
$undeletions = [];
|
||||
$status_changes = [];
|
||||
Database::transaction(
|
||||
$this->conn,
|
||||
function () use (
|
||||
$people_statuses,
|
||||
&$actual_deletions,
|
||||
&$undeletions,
|
||||
&$status_changes
|
||||
) {
|
||||
$this->tracking_manager->rename_persons($people_statuses->redirects);
|
||||
$actual_deletions = $this->tracking_manager->delete_persons($people_statuses->missing);
|
||||
[$undeletions, $status_changes] = $this->tracking_manager->update_statuses($people_statuses->results);
|
||||
$this->tracking_list->transaction(function () use (&$new_deletions, &$undeletions, &$status_changes) {
|
||||
$names = $this->tracking_list->list_all_unique_person_names();
|
||||
if (empty($names))
|
||||
return;
|
||||
|
||||
try {
|
||||
$people_statuses = $this->wikipedia->query_person_info($names);
|
||||
} catch (WikipediaException $exception) {
|
||||
$this->logger->error("Failed to query page info.", ["cause" => $exception, "pages" => $names]);
|
||||
throw new UnexpectedException("Could not reach Wikipedia. Maybe the website is down?");
|
||||
}
|
||||
);
|
||||
|
||||
$this->tracking_list->rename_persons($people_statuses->redirects);
|
||||
$new_deletions = $this->tracking_list->delete_persons($people_statuses->missing);
|
||||
[$undeletions, $status_changes] = $this->tracking_list->update_statuses($people_statuses->results);
|
||||
});
|
||||
|
||||
// Send mails
|
||||
// TODO: Restrict number of notifications to 1 per hour (excluding "oops we're not sure" message)
|
||||
// TODO: Reuse `stmt`s for listing trackers and queueing emails to reduce overheads
|
||||
foreach ($actual_deletions as $deletion)
|
||||
foreach ($this->tracking_manager->list_trackers($deletion) as $user_email)
|
||||
$this->mailer->queue_email(new NotifyArticleDeletedEmail($user_email, $deletion));
|
||||
foreach ($new_deletions as $new_deletion)
|
||||
foreach ($this->tracking_list->list_trackers($new_deletion) as $user_email)
|
||||
$this->mailer->queue_email(new NotifyArticleDeletedEmail($user_email, $new_deletion));
|
||||
|
||||
foreach ($undeletions as $undeletion)
|
||||
foreach ($this->tracking_manager->list_trackers($undeletion) as $user_email)
|
||||
foreach ($this->tracking_list->list_trackers($undeletion) as $user_email)
|
||||
$this->mailer->queue_email(new NotifyArticleUndeletedEmail($user_email, $undeletion));
|
||||
|
||||
foreach ($status_changes as $person_name => $person_status)
|
||||
foreach ($this->tracking_manager->list_trackers($person_name) as $user_email)
|
||||
foreach ($this->tracking_list->list_trackers($person_name) as $user_email)
|
||||
$this->mailer->queue_email(new NotifyStatusChangedEmail($user_email, $person_name, $person_status));
|
||||
|
||||
return null;
|
||||
|
@ -125,112 +113,10 @@ class UpdateTrackingsAction extends Action
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* An email to inform a user that a tracked article has been deleted.
|
||||
*/
|
||||
class NotifyArticleDeletedEmail extends Email
|
||||
{
|
||||
/**
|
||||
* A string identifying the type of email.
|
||||
*/
|
||||
public const TYPE = "notify-article-deleted";
|
||||
|
||||
/**
|
||||
* @var string the name of the article that was deleted
|
||||
*/
|
||||
public string $name;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `NotifyArticleDeletedEmail`.
|
||||
*
|
||||
* @param string $recipient the intended recipient of the email
|
||||
* @param string $name the name of the article that was deleted
|
||||
*/
|
||||
public function __construct(string $recipient, string $name)
|
||||
{
|
||||
parent::__construct($this::TYPE . "/" . $name, $recipient);
|
||||
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
|
||||
public function get_subject(): string
|
||||
{
|
||||
return "$this->name article has been deleted";
|
||||
}
|
||||
|
||||
public function get_body(): string
|
||||
{
|
||||
$base_path = Config::get()["server"]["base_path"];
|
||||
|
||||
return
|
||||
"The Wikipedia article about $this->name has been deleted. " .
|
||||
"Death Notifier is now unable to send you a notification if $this->name dies. " .
|
||||
"If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " .
|
||||
"article, and you will receive another notification." .
|
||||
"\n\n" .
|
||||
"You are receiving this message because of the preferences in your Death Notifier account. " .
|
||||
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
|
||||
"preferences." .
|
||||
"\n\n" .
|
||||
$base_path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email to inform a user that a tracked article has been re-created.
|
||||
*/
|
||||
class NotifyArticleUndeletedEmail extends Email
|
||||
{
|
||||
/**
|
||||
* A string identifying the type of email.
|
||||
*/
|
||||
public const TYPE = "notify-article-undeleted";
|
||||
|
||||
/**
|
||||
* @var string the name of the article that was re-created
|
||||
*/
|
||||
public string $name;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `NotifyArticleUndeletedEmail`.
|
||||
*
|
||||
* @param string $recipient the intended recipient of the email
|
||||
* @param string $name the name of the article that was re-created
|
||||
*/
|
||||
public function __construct(string $recipient, string $name)
|
||||
{
|
||||
parent::__construct($this::TYPE . "/" . $name, $recipient);
|
||||
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
|
||||
public function get_subject(): string
|
||||
{
|
||||
return "$this->name article has been re-created";
|
||||
}
|
||||
|
||||
public function get_body(): string
|
||||
{
|
||||
$base_path = Config::get()["server"]["base_path"];
|
||||
|
||||
return
|
||||
"The Wikipedia article about $this->name has been re-created. " .
|
||||
"Death Notifier will once again track the article and notify you if $this->name dies." .
|
||||
"\n\n" .
|
||||
"You are receiving this message because of the preferences in your Death Notifier account. " .
|
||||
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
|
||||
"preferences." .
|
||||
"\n\n" .
|
||||
$base_path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email to inform a user a tracker person's status has changed.
|
||||
*
|
||||
* @see UpdateTrackingsAction
|
||||
*/
|
||||
class NotifyStatusChangedEmail extends Email
|
||||
{
|
||||
|
@ -286,3 +172,111 @@ class NotifyStatusChangedEmail extends Email
|
|||
$base_path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email to inform a user that a tracked article has been deleted.
|
||||
*
|
||||
* @see UpdateTrackingsAction
|
||||
*/
|
||||
class NotifyArticleDeletedEmail extends Email
|
||||
{
|
||||
/**
|
||||
* A string identifying the type of email.
|
||||
*/
|
||||
public const TYPE = "notify-article-deleted";
|
||||
|
||||
/**
|
||||
* @var string the name of the article that was deleted
|
||||
*/
|
||||
public string $name;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `NotifyArticleDeletedEmail`.
|
||||
*
|
||||
* @param string $recipient the intended recipient of the email
|
||||
* @param string $name the name of the article that was deleted
|
||||
*/
|
||||
public function __construct(string $recipient, string $name)
|
||||
{
|
||||
parent::__construct($this::TYPE . "/" . $name, $recipient);
|
||||
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
|
||||
public function get_subject(): string
|
||||
{
|
||||
return "$this->name article has been deleted";
|
||||
}
|
||||
|
||||
public function get_body(): string
|
||||
{
|
||||
$base_path = Config::get()["server"]["base_path"];
|
||||
|
||||
return
|
||||
"The Wikipedia article about $this->name has been deleted. " .
|
||||
"Death Notifier is now unable to send you a notification if $this->name dies. " .
|
||||
"If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " .
|
||||
"article, and you will receive another notification." .
|
||||
"\n\n" .
|
||||
"You are receiving this message because of the preferences in your Death Notifier account. " .
|
||||
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
|
||||
"preferences." .
|
||||
"\n\n" .
|
||||
$base_path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email to inform a user that a tracked article has been re-created.
|
||||
*
|
||||
* @see UpdateTrackingsAction
|
||||
*/
|
||||
class NotifyArticleUndeletedEmail extends Email
|
||||
{
|
||||
/**
|
||||
* A string identifying the type of email.
|
||||
*/
|
||||
public const TYPE = "notify-article-undeleted";
|
||||
|
||||
/**
|
||||
* @var string the name of the article that was re-created
|
||||
*/
|
||||
public string $name;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `NotifyArticleUndeletedEmail`.
|
||||
*
|
||||
* @param string $recipient the intended recipient of the email
|
||||
* @param string $name the name of the article that was re-created
|
||||
*/
|
||||
public function __construct(string $recipient, string $name)
|
||||
{
|
||||
parent::__construct($this::TYPE . "/" . $name, $recipient);
|
||||
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
|
||||
public function get_subject(): string
|
||||
{
|
||||
return "$this->name article has been re-created";
|
||||
}
|
||||
|
||||
public function get_body(): string
|
||||
{
|
||||
$base_path = Config::get()["server"]["base_path"];
|
||||
|
||||
return
|
||||
"The Wikipedia article about $this->name has been re-created. " .
|
||||
"Death Notifier will once again track the article and notify you if $this->name dies." .
|
||||
"\n\n" .
|
||||
"You are receiving this message because of the preferences in your Death Notifier account. " .
|
||||
"To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " .
|
||||
"preferences." .
|
||||
"\n\n" .
|
||||
$base_path;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,77 +3,82 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\mailer\Email;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\tracking\TrackingList;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\validator\IsEmailRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Changes the user's email address.
|
||||
* Changes the user's email address and sends the user a verification link.
|
||||
*
|
||||
* @see ChangeEmailFromEmail
|
||||
* @see ChangeEmailToEmail
|
||||
*/
|
||||
class ChangeEmailAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to change the email address with
|
||||
* @var UserList the list to change the user's email address in
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
private readonly UserList $user_list;
|
||||
/**
|
||||
* @var UserManager the manager to change the email address with
|
||||
* @var EmailQueue the queue to add notifications to
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
/**
|
||||
* @var MailManager the manager to send emails with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly EmailQueue $email_queue;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ChangeEmailAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to change the email address with
|
||||
* @param UserManager $user_manager the manager to change the email address with
|
||||
* @param MailManager $mail_manager the manager to send emails with
|
||||
* @param UserList $user_list the list to change the user's email address in
|
||||
* @param EmailQueue $email_queue the queue to add notifications to
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
|
||||
public function __construct(UserList $user_list, EmailQueue $email_queue)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_in: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: ["email" => [new IsEmailRule()]],
|
||||
);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->user_list = $user_list;
|
||||
$this->email_queue = $email_queue;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Changes the user's email address.
|
||||
* Changes the user's email address and sends the user a verification link.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"email": string`: the email address to change to
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"email": string`: the new email
|
||||
* address
|
||||
* @return null
|
||||
* @throws InvalidInputException if the email address is not different or the email address is already used
|
||||
* @throws InvalidInputException if the user is not logged in, if no valid CSRF token is present, if the email
|
||||
* address is invalid, if the email address is not different, or if the email address is already used
|
||||
* @throws UnexpectedException if the current user has been deleted
|
||||
* @noinspection PhpDocRedundantThrowsInspection can be thrown through {@see TrackingList::transaction()}
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($inputs) {
|
||||
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"email" => [new IsEmailRule()],
|
||||
]))->check($inputs);
|
||||
|
||||
$this->user_list->transaction(function () use ($inputs) {
|
||||
$user_data = $this->user_list->get_user_by_uuid($_SESSION["uuid"]);
|
||||
if ($user_data === null)
|
||||
throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
|
||||
throw new UnexpectedException("Failed to retrieve user data. Refresh the page and try again.");
|
||||
if ($inputs["email"] === $user_data["email"])
|
||||
throw new InvalidInputException("That is already your email address.", "email");
|
||||
if ($this->user_manager->has_user_with_email($inputs["email"]))
|
||||
if ($this->user_list->has_user_with_email($inputs["email"]))
|
||||
throw new InvalidInputException("That email address is already in use by someone else.", "email");
|
||||
|
||||
$token = $this->user_manager->set_email($_SESSION["uuid"], $inputs["email"]);
|
||||
// TODO: Also send email to old email address
|
||||
$this->mail_manager->queue_email(new ChangeEmailEmail($inputs["email"], $token));
|
||||
$token = $this->user_list->set_email($_SESSION["uuid"], $inputs["email"]);
|
||||
$this->email_queue->queue_email(new ChangeEmailFromEmail($user_data["email"], $inputs["email"]));
|
||||
$this->email_queue->queue_email(new ChangeEmailToEmail($inputs["email"], $user_data["email"], $token));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
@ -82,31 +87,94 @@ class ChangeEmailAction extends Action
|
|||
|
||||
|
||||
/**
|
||||
* An email informing a user that their email has been changed, and needs verification.
|
||||
* An email informing a user that their email has been changed and needs to be verified, sent to the user's old email
|
||||
* address.
|
||||
*
|
||||
* @see ChangeEmailAction
|
||||
*/
|
||||
class ChangeEmailEmail extends Email
|
||||
class ChangeEmailFromEmail extends Email
|
||||
{
|
||||
/**
|
||||
* A string identifying the type of email.
|
||||
*/
|
||||
public const TYPE = "changed-email";
|
||||
public const TYPE = "changed-email-from";
|
||||
|
||||
/**
|
||||
* @var string the new email address
|
||||
*/
|
||||
public readonly string $new_email;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ChangeEmailFromEmail`.
|
||||
*
|
||||
* @param string $recipient the intended recipient of the email
|
||||
* @param string $new_email the new email address
|
||||
*/
|
||||
public function __construct(string $recipient, string $new_email)
|
||||
{
|
||||
parent::__construct($this::TYPE, $recipient);
|
||||
|
||||
$this->new_email = $new_email;
|
||||
}
|
||||
|
||||
|
||||
public function get_subject(): string
|
||||
{
|
||||
return "Your email address has been changed";
|
||||
}
|
||||
|
||||
public function get_body(): string
|
||||
{
|
||||
$base_path = Config::get()["server"]["base_path"];
|
||||
|
||||
return
|
||||
"You changed the email address of your Death Notifier account from $this->recipient to $this->new_email. " .
|
||||
"Until you verify your email address, you will not receive any notifications on either email address. " .
|
||||
"Check the inbox of $this->new_email for a verification link." .
|
||||
"\n\n" .
|
||||
"If you did not request a change of email address, log in at the Death Notifier website, change your " .
|
||||
"password, and change your email address back." .
|
||||
"\n\n" .
|
||||
$base_path;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* An email informing a user that their email has been changed and needs to be verified, sent to the user's new email
|
||||
* address that needs to be verified.
|
||||
*
|
||||
* @see ChangeEmailAction
|
||||
*/
|
||||
class ChangeEmailToEmail extends Email
|
||||
{
|
||||
/**
|
||||
* A string identifying the type of email.
|
||||
*/
|
||||
public const TYPE = "changed-email-to";
|
||||
|
||||
/**
|
||||
* @var string the old email address
|
||||
*/
|
||||
public readonly string $old_email;
|
||||
/**
|
||||
* @var string the token to verify the email address with
|
||||
*/
|
||||
public string $token;
|
||||
public readonly string $token;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ChangeEmailEmail`.
|
||||
*
|
||||
* @param string $recipient the intended recipient of the email
|
||||
* @param string $old_email the old email address
|
||||
* @param string $token the token to verify the email address with
|
||||
*/
|
||||
public function __construct(string $recipient, string $token)
|
||||
public function __construct(string $recipient, string $old_email, string $token)
|
||||
{
|
||||
parent::__construct($this::TYPE, $recipient);
|
||||
|
||||
$this->old_email = $old_email;
|
||||
$this->token = $token;
|
||||
}
|
||||
|
||||
|
@ -122,10 +190,10 @@ class ChangeEmailEmail extends Email
|
|||
$verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token";
|
||||
|
||||
return
|
||||
"You changed the email address of your Death Notifier account. " .
|
||||
"You changed the email address of your Death Notifier account from $this->old_email to $this->recipient. " .
|
||||
"Until you verify your email address, you will not receive any notifications. " .
|
||||
"You can verify your new email address by clicking the link below. " .
|
||||
"This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." .
|
||||
"This link will expire after " . UserList::MINUTES_VALID_VERIFICATION . " minutes." .
|
||||
"\n" .
|
||||
"Verify: $verify_path" .
|
||||
"\n\n" .
|
||||
|
|
|
@ -3,81 +3,79 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\mailer\Email;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\validator\HasLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\validator\HasStringLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\IsStringRule;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Changes the user's password.
|
||||
* Changes the user's password and notifies the user by email.
|
||||
*
|
||||
* @see ChangePasswordEmail
|
||||
*/
|
||||
class ChangePasswordAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to change the password with
|
||||
* @var UserList the list to change the user's password in
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
private readonly UserList $user_list;
|
||||
/**
|
||||
* @var UserManager the manager to change the password with
|
||||
* @var EmailQueue the queue to add a notification to
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
/**
|
||||
* @var MailManager the manager to send emails with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly EmailQueue $email_queue;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ChangePasswordAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to change the password with
|
||||
* @param UserManager $user_manager the manager to change the password with
|
||||
* @param MailManager $mail_manager the manager to send emails with
|
||||
* @param UserList $user_list the list to change the user's password in
|
||||
* @param EmailQueue $email_queue the queue to add a notification to
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
|
||||
public function __construct(UserList $user_list, EmailQueue $email_queue)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_in: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"password_old" => [new IsSetRule()],
|
||||
"password_new" => [
|
||||
new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->user_list = $user_list;
|
||||
$this->email_queue = $email_queue;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Changes the user's password.
|
||||
* Changes the user's password and notifies the user by email.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"password_old": string`: the old password, `"password_new": string`: the
|
||||
* new password
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"password_old": string`: the old
|
||||
* password, `"password_new": string`: the new password
|
||||
* @return null
|
||||
* @throws InvalidInputException if the old password is incorrect
|
||||
* @throws InvalidInputException if the user is not logged in, if no valid CSRF token is present, if the old
|
||||
* password is incorrect, or if the new password is too short or too long
|
||||
* @throws UnexpectedException if the current user has been deleted
|
||||
* @noinspection PhpDocRedundantThrowsInspection can be thrown through {@see TrackingList::transaction()}
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($inputs) {
|
||||
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"password_old" => [new IsStringRule()],
|
||||
"password_new" => [new HasStringLengthRule(UserList::MIN_PASSWORD_LENGTH, UserList::MAX_PASSWORD_LENGTH)],
|
||||
]))->check($inputs);
|
||||
|
||||
$this->user_list->transaction(function () use ($inputs) {
|
||||
$user_data = $this->user_list->get_user_by_uuid($_SESSION["uuid"]);
|
||||
if ($user_data === null)
|
||||
throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
|
||||
if (!password_verify($user_data["password"], $inputs["password_old"]))
|
||||
throw new UnexpectedException("Failed to retrieve user data. Refresh the page and try again.");
|
||||
if (!password_verify($inputs["password_old"], $user_data["password"]))
|
||||
throw new InvalidInputException("Incorrect old password.", "password_old");
|
||||
|
||||
$this->user_manager->set_password($_SESSION["uuid"], $inputs["password_new"]);
|
||||
$this->mail_manager->queue_email(new ChangePasswordEmail($user_data["email"]));
|
||||
$this->user_list->set_password($_SESSION["uuid"], $inputs["password_new"]);
|
||||
$this->email_queue->queue_email(new ChangePasswordEmail($user_data["email"]));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
@ -87,6 +85,8 @@ class ChangePasswordAction extends Action
|
|||
|
||||
/**
|
||||
* An email informing a user that their password has been changed.
|
||||
*
|
||||
* @see ChangePasswordAction
|
||||
*/
|
||||
class ChangePasswordEmail extends Email
|
||||
{
|
||||
|
|
|
@ -3,7 +3,11 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -12,37 +16,41 @@ use com\fwdekker\deathnotifier\ActionException;
|
|||
class GetPublicUserDataAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var UserManager the manager to retrieve the data with
|
||||
* @var UserList the list to retrieve public data from
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
private readonly UserList $user_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `GetPublicUserDataAction`.
|
||||
*
|
||||
* @param UserManager $user_manager the manager to retrieve the data with
|
||||
* @param UserList $user_list the list to retrieve public data from
|
||||
*/
|
||||
public function __construct(UserManager $user_manager)
|
||||
public function __construct(UserList $user_list)
|
||||
{
|
||||
parent::__construct(require_logged_in: true, require_valid_csrf_token: true);
|
||||
|
||||
$this->user_manager = $user_manager;
|
||||
$this->user_list = $user_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the user's public data.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token
|
||||
* @return array{"email": string, "email_verified": bool, "email_notifications_enabled": bool,
|
||||
* "password_last_change": int} the user's public data
|
||||
* @throws ActionException if the user's data could not be retrieved
|
||||
* @throws InvalidInputException if the user is not logged in or if no valid CSRF token is present
|
||||
* @throws UnexpectedException if the current user has been deleted
|
||||
*/
|
||||
function handle(array $inputs): array
|
||||
public function handle(array $inputs): array
|
||||
{
|
||||
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet(["token" => [new IsValidCsrfTokenRule()]]))->check($inputs);
|
||||
|
||||
$user_data = $this->user_list->get_user_by_uuid($_SESSION["uuid"]);
|
||||
if ($user_data === null)
|
||||
throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
|
||||
throw new UnexpectedException("Failed to retrieve account data. Refresh the page and try again.");
|
||||
|
||||
return [
|
||||
"email" => $user_data["email"],
|
||||
|
|
|
@ -3,57 +3,61 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\IsEmailRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsStringRule;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Logs in the user.
|
||||
* Logs in the user if the credentials are correct.
|
||||
*/
|
||||
class LoginAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var UserManager the manager to log in with
|
||||
* @var UserList the list of users to check credentials in
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
private readonly UserList $user_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `LoginAction`.
|
||||
*
|
||||
* @param UserManager $user_manager the manager to log in with
|
||||
* @param UserList $user_list the list of users to check credentials in
|
||||
*/
|
||||
public function __construct(UserManager $user_manager)
|
||||
public function __construct(UserList $user_list)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_out: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"email" => [new IsEmailRule()],
|
||||
"password" => [new IsSetRule()],
|
||||
],
|
||||
);
|
||||
|
||||
$this->user_manager = $user_manager;
|
||||
$this->user_list = $user_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Logs in the user.
|
||||
* Logs in the user if the credentials are correct.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"email": string`: the email to log in with, `"password": string`: the
|
||||
* password to log in with
|
||||
* Requires that the user is logged out and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"email": string`: the email to
|
||||
* log in with, `"password": string`: the password to log in with
|
||||
* @return null
|
||||
* @throws ActionException if the user's data could not be retrieved or if the given credentials are incorrect
|
||||
* @throws InvalidInputException if the user is logged in, if no account with the given email address exists, if the
|
||||
* password is wrong, or if no valid CSRF token is present
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
$user_data = $this->user_manager->get_user_by_email($inputs["email"]);
|
||||
(new SessionRuleSet(validate_logged_out: true))->check($_SESSION);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"email" => [new IsEmailRule()],
|
||||
"password" => [new IsStringRule()],
|
||||
]))->check($inputs);
|
||||
|
||||
$user_data = $this->user_list->get_user_by_email($inputs["email"]);
|
||||
if ($user_data === null)
|
||||
throw new ActionException("Incorrect combination of email and password.", "password");
|
||||
throw new InvalidInputException("No user with that email address has been registered.", "password");
|
||||
if (!password_verify($inputs["password"], $user_data["password"]))
|
||||
throw new ActionException("Incorrect combination of email and password.", "password");
|
||||
throw new InvalidInputException("Incorrect password.", "password");
|
||||
|
||||
$_SESSION["uuid"] = $user_data["uuid"];
|
||||
return null;
|
||||
|
|
|
@ -3,40 +3,44 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\Util;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
use Exception;
|
||||
|
||||
|
||||
/**
|
||||
* Terminates the current user session.
|
||||
* Logs out the current user.
|
||||
*/
|
||||
class LogoutAction extends Action
|
||||
{
|
||||
/**
|
||||
* Constructs a new `LogoutAction`.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct(require_logged_in: true, require_valid_csrf_token: true);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Terminates the current user session.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token
|
||||
* @return null
|
||||
* @throws ActionException if no CSRF token could be generated
|
||||
* @throws InvalidInputException if the user is not logged in or if no valid CSRF token is present
|
||||
* @throws UnexpectedException if no new CSRF token could be generated
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet(["token" => [new IsValidCsrfTokenRule()]]))->check($inputs);
|
||||
|
||||
session_destroy();
|
||||
session_start();
|
||||
try {
|
||||
$_SESSION["token"] = Util::generate_csrf_token();
|
||||
} catch (Exception) {
|
||||
throw new ActionException("Failed to generate new CSRF token. Please try again later.", null);
|
||||
} catch (Exception $exception) {
|
||||
throw new UnexpectedException(
|
||||
"Failed to generate new CSRF token. Please try again later.",
|
||||
previous: $exception
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -3,81 +3,73 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\mailer\Email;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\validator\HasLengthRule;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\validator\HasStringLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsEmailRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Registers a new user.
|
||||
*
|
||||
* @see RegisterEmail
|
||||
*/
|
||||
class RegisterAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to register the user with
|
||||
* @var UserList the list to register the user in
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
private readonly UserList $user_list;
|
||||
/**
|
||||
* @var UserManager the manager to register the user with
|
||||
* @var EmailQueue the queue to add a verification email to
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
/**
|
||||
* @var MailManager the manager to send emails with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly EmailQueue $email_queue;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `RegisterAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to register the user with
|
||||
* @param UserManager $user_manager the manager to register the user with
|
||||
* @param MailManager $mail_manager the manager to send emails with
|
||||
* @param UserList $user_list the list to register the user in
|
||||
* @param EmailQueue $email_queue the queue to add a verification email to
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
|
||||
public function __construct(UserList $user_list, EmailQueue $email_queue)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_out: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"email" => [new IsEmailRule()],
|
||||
"password" => [new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)],
|
||||
],
|
||||
);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->user_list = $user_list;
|
||||
$this->email_queue = $email_queue;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers a new user.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"email": string`: the email address to register, `"password": string`:
|
||||
* the password to use for the new account
|
||||
* Requires that the user is logged out and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"email": string`: the email
|
||||
* address to register the account under, `"password": string`: the password to use for the new account
|
||||
* @return null
|
||||
* @throws InvalidInputException if the email address is already used
|
||||
* @throws InvalidInputException if the user is logged in, if no valid CSRF token is present, if the email address
|
||||
* is invalid, if the email address is already in use, or if the password is too short or too long
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($inputs) {
|
||||
if ($this->user_manager->has_user_with_email($inputs["email"]))
|
||||
throw new InvalidInputException("That email address already in use by someone else.", "email");
|
||||
(new SessionRuleSet(validate_logged_out: true))->check($_SESSION);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"email" => [new IsEmailRule()],
|
||||
"password" => [new HasStringLengthRule(UserList::MIN_PASSWORD_LENGTH, UserList::MAX_PASSWORD_LENGTH)],
|
||||
]))->check($inputs);
|
||||
|
||||
$uuid = $this->user_manager->add_user($inputs["email"], $inputs["password"]);
|
||||
$user_data = $this->user_manager->get_user_by_uuid($uuid);
|
||||
if ($user_data === null)
|
||||
throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
|
||||
$token = $user_data["email_verification_token"];
|
||||
$this->user_list->transaction(function () use ($inputs) {
|
||||
if ($this->user_list->has_user_with_email($inputs["email"]))
|
||||
throw new InvalidInputException("That email address already in use.", "email");
|
||||
|
||||
$this->mail_manager->queue_email(new RegisterEmail($inputs["email"], $token));
|
||||
$token = $this->user_list->add_user($inputs["email"], $inputs["password"]);
|
||||
$this->email_queue->queue_email(new RegisterEmail($inputs["email"], $token));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
@ -87,6 +79,8 @@ class RegisterAction extends Action
|
|||
|
||||
/**
|
||||
* An email to be sent to a recently registered user, including instructions for email verification.
|
||||
*
|
||||
* @see RegisterAction
|
||||
*/
|
||||
class RegisterEmail extends Email
|
||||
{
|
||||
|
@ -130,7 +124,7 @@ class RegisterEmail extends Email
|
|||
"\n\n" .
|
||||
"Until you verify your email address, you will not receive any notifications. " .
|
||||
"You can verify your email address by clicking the link below. " .
|
||||
"This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." .
|
||||
"This link will expire after " . UserList::MINUTES_VALID_VERIFICATION . " minutes." .
|
||||
"\n" .
|
||||
"Verify: $verify_path" .
|
||||
"\n\n" .
|
||||
|
|
|
@ -4,68 +4,73 @@ namespace com\fwdekker\deathnotifier\user;
|
|||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\mailer\Email;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\Util;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Resets the email verification process and sends a new verification email.
|
||||
*
|
||||
* @see ResendVerifyEmailEmail
|
||||
*/
|
||||
class ResendVerifyEmailAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to recreate and resend the verification email with
|
||||
* @var UserList the list to reset the verification process in
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
private readonly UserList $user_list;
|
||||
/**
|
||||
* @var UserManager the manager to recreate and resend the verification email with
|
||||
* @var EmailQueue the queue to add a notification to
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
/**
|
||||
* @var MailManager the manager to send emails with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly EmailQueue $email_queue;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ResendVerifyEmailAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to recreate and resend the verification email with
|
||||
* @param UserManager $user_manager the manager to recreate and resend the verification email with
|
||||
* @param MailManager $mail_manager the manager to send emails with
|
||||
* @param UserList $user_list the list to reset the verification process in
|
||||
* @param EmailQueue $email_queue the queue to add a notification to
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
|
||||
public function __construct(UserList $user_list, EmailQueue $email_queue)
|
||||
{
|
||||
parent::__construct(require_logged_in: true, require_valid_csrf_token: true);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->user_list = $user_list;
|
||||
$this->email_queue = $email_queue;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resets the email verification process and sends a new verification email.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token
|
||||
* @return null
|
||||
* @throws InvalidInputException if the email address is already verified or if a verification email was sent too
|
||||
* recently
|
||||
* @throws InvalidInputException if the user is logged out, if no valid CSRF token is present, if the email address
|
||||
* is already verified, or if a verification email was sent too recently
|
||||
* @throws UnexpectedException if the current user has been deleted
|
||||
* @noinspection PhpDocRedundantThrowsInspection can be thrown through {@see TrackingList::transaction()}
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($inputs) {
|
||||
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet(["token" => [new IsValidCsrfTokenRule()]]))->check($inputs);
|
||||
|
||||
$this->user_list->transaction(function () {
|
||||
$user_data = $this->user_list->get_user_by_uuid($_SESSION["uuid"]);
|
||||
if ($user_data === null)
|
||||
throw new UnexpectedException("Failed to retrieve user data. Refresh the page and try again.");
|
||||
if ($user_data["email_verification_token"] === null)
|
||||
throw new InvalidInputException("Your email address is already verified.");
|
||||
|
||||
$minutes_left = Util::minutes_until_interval_elapsed(
|
||||
$user_data["email_verification_token_timestamp"],
|
||||
UserManager::MINUTES_BETWEEN_VERIFICATION_EMAILS
|
||||
UserList::MINUTES_BETWEEN_VERIFICATION_EMAILS
|
||||
);
|
||||
if ($minutes_left > 0) {
|
||||
throw new InvalidInputException(
|
||||
|
@ -74,8 +79,8 @@ class ResendVerifyEmailAction extends Action
|
|||
);
|
||||
}
|
||||
|
||||
$token = $this->user_manager->set_email($_SESSION["uuid"], $user_data["email"]);
|
||||
$this->mail_manager->queue_email(new ResendVerifyEmailEmail($user_data["email"], $token));
|
||||
$token = $this->user_list->set_email($_SESSION["uuid"], $user_data["email"]);
|
||||
$this->email_queue->queue_email(new ResendVerifyEmailEmail($user_data["email"], $token));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
@ -85,6 +90,8 @@ class ResendVerifyEmailAction extends Action
|
|||
|
||||
/**
|
||||
* An email to help a user verify their email address in case they cannot use the original email.
|
||||
*
|
||||
* @see ResendVerifyEmailAction
|
||||
*/
|
||||
class ResendVerifyEmailEmail extends Email
|
||||
{
|
||||
|
@ -96,7 +103,7 @@ class ResendVerifyEmailEmail extends Email
|
|||
/**
|
||||
* @var string the token to verify the email address with
|
||||
*/
|
||||
public string $token;
|
||||
public readonly string $token;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -126,7 +133,7 @@ class ResendVerifyEmailEmail extends Email
|
|||
return
|
||||
"You requested a new verification link for your Death Notifier account. " .
|
||||
"You can verify your email address by clicking the link below. " .
|
||||
"This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes. " .
|
||||
"This link will expire after " . UserList::MINUTES_VALID_VERIFICATION . " minutes. " .
|
||||
"Until you verify your email address, you will not receive any notifications." .
|
||||
"\n" .
|
||||
"Verify: $verify_path" .
|
||||
|
|
|
@ -4,77 +4,74 @@ namespace com\fwdekker\deathnotifier\user;
|
|||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\IllegalStateError;
|
||||
use com\fwdekker\deathnotifier\mailer\Email;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\validator\HasLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsEmailRule;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\validator\HasStringLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Resets the user's password after they forgot it.
|
||||
*
|
||||
* @see ResetPasswordEmail
|
||||
*/
|
||||
class ResetPasswordAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to reset the password with
|
||||
* @var UserList the list to reset the password in
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
private readonly UserList $user_list;
|
||||
/**
|
||||
* @var UserManager the manager to reset the password with
|
||||
* @var EmailQueue the queue to add a notification to
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
/**
|
||||
* @var MailManager the manager to send emails with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly EmailQueue $email_queue;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ResetPasswordAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to reset the password with
|
||||
* @param UserManager $user_manager the manager to reset the password with
|
||||
* @param MailManager $mail_manager the manager to send emails with
|
||||
* @param UserList $user_list the list to reset the password in
|
||||
* @param EmailQueue $email_queue the queue to add a notification to
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
|
||||
public function __construct(UserList $user_list, EmailQueue $email_queue)
|
||||
{
|
||||
parent::__construct(
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"email" => [new IsEmailRule()],
|
||||
"password" => [new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)],
|
||||
"reset_token" => [new IsSetRule()],
|
||||
],
|
||||
);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->user_list = $user_list;
|
||||
$this->email_queue = $email_queue;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Resets the user's password after they forgot it.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"email": string`: the email address of the account to reset the password
|
||||
* of, `"password": string`: the new password to set, `"reset_token": string`: the token to reset the password with
|
||||
* Requires that a valid CSRF token is present. Does not require the user to be logged in or out.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"email": string`: the email
|
||||
* address of the account to reset the password of, `"password": string`: the new password to set,
|
||||
* `"reset_token": string`: the token to reset the password with
|
||||
* @return null
|
||||
* @throws InvalidInputException if no valid CSRF token is present, if no account with the given email address
|
||||
* exists, if the password is too short or too long, if the reset token is invalid, or if the reset token has
|
||||
* expired
|
||||
* @see ValidatePasswordResetTokenAction::handle()
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($inputs) {
|
||||
$user_data = $this->user_manager->get_user_by_email($inputs["email"]);
|
||||
if ($inputs["reset_token"] !== $user_data["password_reset_token"])
|
||||
throw new InvalidInputException(
|
||||
"This password reset link is invalid. Maybe you already reset your password?"
|
||||
);
|
||||
(new RuleSet([
|
||||
"password" => [new HasStringLengthRule(UserList::MIN_PASSWORD_LENGTH, UserList::MAX_PASSWORD_LENGTH)]
|
||||
]))->check($inputs);
|
||||
|
||||
$this->user_manager->set_password($user_data["uuid"], $inputs["password"]);
|
||||
$this->mail_manager->queue_email(new ResetPasswordEmail($inputs["email"]));
|
||||
$this->user_list->transaction(function () use ($inputs) {
|
||||
// TODO: Extract the shared functionality cleanly
|
||||
(new ValidatePasswordResetTokenAction($this->user_list))->handle($inputs);
|
||||
|
||||
$user_data = $this->user_list->get_user_by_email($inputs["email"]);
|
||||
if ($user_data === null)
|
||||
throw new IllegalStateError("User data is `null` despite previous validation.");
|
||||
|
||||
$this->user_list->set_password($user_data["uuid"], $inputs["password"]);
|
||||
$this->email_queue->queue_email(new ResetPasswordEmail($inputs["email"]));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
@ -84,6 +81,8 @@ class ResetPasswordAction extends Action
|
|||
|
||||
/**
|
||||
* An email informing a user that their password has been reset.
|
||||
*
|
||||
* @see ResetPasswordAction
|
||||
*/
|
||||
class ResetPasswordEmail extends Email
|
||||
{
|
||||
|
|
|
@ -4,70 +4,71 @@ namespace com\fwdekker\deathnotifier\user;
|
|||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\mailer\Email;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\Util;
|
||||
use com\fwdekker\deathnotifier\validator\IsEmailRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
|
||||
|
||||
/**
|
||||
* Sends a password reset email.
|
||||
*
|
||||
* @see SendPasswordResetEmail
|
||||
*/
|
||||
class SendPasswordResetAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to send the password reset with
|
||||
* @var UserList the list containing the user to send a password reset email for
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
private readonly UserList $user_list;
|
||||
/**
|
||||
* @var UserManager the manager to send the password reset with
|
||||
* @var EmailQueue the queue to add the password reset email to
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
/**
|
||||
* @var MailManager the manager to send emails with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly EmailQueue $email_queue;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `SendPasswordResetAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to send the password reset with
|
||||
* @param UserManager $user_manager the manager to send the password reset with
|
||||
* @param MailManager $mail_manager the manager to send emails with
|
||||
* @param UserList $user_list the list containing the user to send a password reset email for
|
||||
* @param EmailQueue $email_queue the queue to add the password reset email to
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
|
||||
public function __construct(UserList $user_list, EmailQueue $email_queue)
|
||||
{
|
||||
parent::__construct(
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: ["email" => [new IsEmailRule()]],
|
||||
);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->user_list = $user_list;
|
||||
$this->email_queue = $email_queue;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends a password reset email.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"email": string`: the email address of the account to send a password
|
||||
* reset email for
|
||||
* Requires that a valid CSRF token is present. Does not require the user to be logged in or out.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"email": string`: the email
|
||||
* address of the account to send a password reset email for
|
||||
* @return null
|
||||
* @throws InvalidInputException if another password reset email was sent too recently
|
||||
* @throws InvalidInputException if no valid CSRF token is present, if no account with the given email address
|
||||
* exists, or if a password reset email was sent too recently
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($inputs) {
|
||||
$user_data = $this->user_manager->get_user_by_email($inputs["email"]);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"email" => [new IsEmailRule()],
|
||||
]))->check($inputs);
|
||||
|
||||
$this->user_list->transaction(function () use ($inputs) {
|
||||
$user_data = $this->user_list->get_user_by_email($inputs["email"]);
|
||||
if ($user_data === null)
|
||||
throw new InvalidInputException("No user with that email address has been registered.");
|
||||
|
||||
$minutes_left = Util::minutes_until_interval_elapsed(
|
||||
$user_data["password_reset_token_timestamp"],
|
||||
UserManager::MINUTES_BETWEEN_PASSWORD_RESETS
|
||||
UserList::MINUTES_BETWEEN_PASSWORD_RESETS
|
||||
);
|
||||
if ($minutes_left > 0) {
|
||||
throw new InvalidInputException(
|
||||
|
@ -76,8 +77,8 @@ class SendPasswordResetAction extends Action
|
|||
);
|
||||
}
|
||||
|
||||
$token = $this->user_manager->register_password_reset($inputs["email"]);
|
||||
$this->mail_manager->queue_email(new SendPasswordResetEmail($inputs["email"], $token));
|
||||
$token = $this->user_list->register_password_reset($inputs["email"]);
|
||||
$this->email_queue->queue_email(new SendPasswordResetEmail($inputs["email"], $token));
|
||||
});
|
||||
|
||||
return null;
|
||||
|
@ -87,6 +88,8 @@ class SendPasswordResetAction extends Action
|
|||
|
||||
/**
|
||||
* An email to help a user reset their password.
|
||||
*
|
||||
* @see SendPasswordResetAction
|
||||
*/
|
||||
class SendPasswordResetEmail extends Email
|
||||
{
|
||||
|
@ -129,7 +132,7 @@ class SendPasswordResetEmail extends Email
|
|||
return
|
||||
"You requested a password reset link for your Death Notifier account. " .
|
||||
"You can choose a new password by clicking the link below. " .
|
||||
"This link expires after " . UserManager::MINUTES_VALID_PASSWORD_RESET . " minutes." .
|
||||
"This link expires after " . UserList::MINUTES_VALID_PASSWORD_RESET . " minutes." .
|
||||
"\n" .
|
||||
"Reset password: $verify_path" .
|
||||
"\n\n" .
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -15,50 +17,52 @@ use PDO;
|
|||
class ToggleNotificationsAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to toggle notifications with
|
||||
* @var UserList the list containing the user to toggle notifications for
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
/**
|
||||
* @var UserManager the manager to toggle notifications with
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
private readonly UserList $user_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ToggleNotificationsAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to toggle notifications with
|
||||
* @param UserManager $user_manager the manager to toggle notifications with
|
||||
* @param UserList $user_list the list containing the user to toggle notifications for
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager)
|
||||
public function __construct(UserList $user_list)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_in: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: ["enable_notifications" => [new IsSetRule()]]
|
||||
);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->user_list = $user_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sets whether email notifications are sent.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"enable_notifications": bool`: `true` if and only if notifications
|
||||
* should be enabled
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"enable_notifications": bool`:
|
||||
* `true` if and only if notifications should be enabled
|
||||
* @return null
|
||||
* @throws InvalidInputException if the user's email address has not been verified
|
||||
* @throws InvalidInputException if the user is not logged in, if no valid CSRF token is present, if the toggle
|
||||
* value is not a boolean, or if the user's email address it not verified
|
||||
* @throws UnexpectedException if the current user has been deleted
|
||||
* @noinspection PhpDocRedundantThrowsInspection can be thrown through {@see TrackingList::transaction()}
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function() use ($inputs) {
|
||||
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
// TODO: Do we need an IsBooleanRule? (check the `== true` below)
|
||||
"enable_notifications" => [new IsSetRule()],
|
||||
]))->check($inputs);
|
||||
|
||||
$this->user_list->transaction(function () use ($inputs) {
|
||||
$user_data = $this->user_list->get_user_by_uuid($_SESSION["uuid"]);
|
||||
if ($user_data === null)
|
||||
throw new UnexpectedException("Failed to retrieve user data. Refresh the page and try again.");
|
||||
if ($user_data["email_verification_token"] === null)
|
||||
throw new InvalidInputException("Please verify your email address before enabling notifications.");
|
||||
|
||||
$this->user_manager->set_notifications_enabled($_SESSION["uuid"], $inputs["enable_notifications"] == true);
|
||||
$this->user_list->set_notifications_enabled($_SESSION["uuid"], $inputs["enable_notifications"] == true);
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
|
@ -3,10 +3,12 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\ActionException;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\UnexpectedException;
|
||||
use com\fwdekker\deathnotifier\Util;
|
||||
use com\fwdekker\deathnotifier\validator\HasLengthRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
use com\fwdekker\deathnotifier\validator\SessionRuleSet;
|
||||
use Exception;
|
||||
|
||||
|
||||
|
@ -16,50 +18,48 @@ use Exception;
|
|||
class UserDeleteAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var UserManager the manager to delete the user with
|
||||
* @var UserList the list to delete the user from
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
private readonly UserList $user_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `UserDeleteAction`.
|
||||
*
|
||||
* @param UserManager $user_manager the manager to delete the user with
|
||||
* @param UserList $user_list the list to delete the user from
|
||||
*/
|
||||
public function __construct(UserManager $user_manager)
|
||||
public function __construct(UserList $user_list)
|
||||
{
|
||||
parent::__construct(
|
||||
require_logged_in: true,
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"password_old" => [new IsSetRule()],
|
||||
"password_new" => [
|
||||
new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)
|
||||
],
|
||||
],
|
||||
);
|
||||
|
||||
$this->user_manager = $user_manager;
|
||||
$this->user_list = $user_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Deletes the currently logged-in user, and terminates the current user session.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs ignored
|
||||
* Requires that the user is logged in and that a valid CSRF token is present.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token
|
||||
* @return null
|
||||
* @throws ActionException if no CSRF token could be generated
|
||||
* @throws InvalidInputException if the user is not logged in or if no valid CSRF token is present
|
||||
* @throws UnexpectedException if no new CSRF token could be generated
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
$this->user_manager->remove_user_by_uuid($_SESSION["uuid"]);
|
||||
(new SessionRuleSet(validate_logged_in: true))->check($_SESSION);
|
||||
(new RuleSet(["token" => [new IsValidCsrfTokenRule()]]))->check($inputs);
|
||||
|
||||
$this->user_list->remove_user_by_uuid($_SESSION["uuid"]);
|
||||
|
||||
session_destroy();
|
||||
session_start();
|
||||
try {
|
||||
$_SESSION["token"] = Util::generate_csrf_token();
|
||||
} catch (Exception) {
|
||||
throw new ActionException("Failed to generate new CSRF token. Please try again later.", null);
|
||||
} catch (Exception $exception) {
|
||||
throw new UnexpectedException(
|
||||
"Failed to generate new CSRF token. Please try again later.",
|
||||
previous: $exception
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
|
@ -2,15 +2,14 @@
|
|||
|
||||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\LoggerUtil;
|
||||
use Monolog\Logger;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use PDO;
|
||||
|
||||
|
||||
/**
|
||||
* Manages interaction with the database in the context of users.
|
||||
* A list of trackings, stored in a {@see Database}.
|
||||
*/
|
||||
class UserManager
|
||||
class UserList
|
||||
{
|
||||
/**
|
||||
* The minimum length of a password;
|
||||
|
@ -37,26 +36,20 @@ class UserManager
|
|||
*/
|
||||
public const MINUTES_VALID_PASSWORD_RESET = 60;
|
||||
|
||||
|
||||
/**
|
||||
* @var Logger the logger to use for logging
|
||||
* @var Database the database to store users in
|
||||
*/
|
||||
private Logger $logger; // @phpstan-ignore-line Unused, but useful for debugging
|
||||
/**
|
||||
* @var PDO the database connection to interact with
|
||||
*/
|
||||
private PDO $conn;
|
||||
private readonly Database $database;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new user manager.
|
||||
* Constructs a new `UserList`.
|
||||
*
|
||||
* @param PDO $conn the database connection to interact with
|
||||
* @param Database $database the database to store users in
|
||||
*/
|
||||
public function __construct(PDO $conn)
|
||||
public function __construct(Database $database)
|
||||
{
|
||||
$this->logger = LoggerUtil::with_name($this::class);
|
||||
$this->conn = $conn;
|
||||
$this->database = $database;
|
||||
}
|
||||
|
||||
|
||||
|
@ -67,36 +60,49 @@ class UserManager
|
|||
*/
|
||||
public function install(): void
|
||||
{
|
||||
$this->conn->exec("CREATE TABLE users(uuid TEXT NOT NULL UNIQUE PRIMARY KEY DEFAULT(lower(hex(randomblob(16)))),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verification_token TEXT DEFAULT(lower(hex(randomblob(16)))),
|
||||
email_verification_token_timestamp INT NOT NULL DEFAULT(unixepoch()),
|
||||
email_notifications_enabled INT NOT NULL DEFAULT(1),
|
||||
password TEXT NOT NULL,
|
||||
password_last_change INT NOT NULL DEFAULT(unixepoch()),
|
||||
password_reset_token TEXT DEFAULT(null),
|
||||
password_reset_token_timestamp INT NOT NULL DEFAULT(unixepoch()));");
|
||||
$conn = $this->database->conn;
|
||||
$conn->exec("CREATE TABLE users(uuid TEXT NOT NULL UNIQUE PRIMARY KEY DEFAULT(lower(hex(randomblob(16)))),
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
email_verification_token TEXT DEFAULT(lower(hex(randomblob(16)))),
|
||||
email_verification_token_timestamp INT NOT NULL DEFAULT(unixepoch()),
|
||||
email_notifications_enabled INT NOT NULL DEFAULT(1),
|
||||
password TEXT NOT NULL,
|
||||
password_last_change INT NOT NULL DEFAULT(unixepoch()),
|
||||
password_reset_token TEXT DEFAULT(null),
|
||||
password_reset_token_timestamp INT NOT NULL DEFAULT(unixepoch()));");
|
||||
}
|
||||
|
||||
/**
|
||||
* Executes {@see $lambda} within a single database transaction.
|
||||
*
|
||||
* @param callable(): void $lambda the function to execute within a transaction
|
||||
* @return void
|
||||
* @see Database::transaction()
|
||||
*/
|
||||
public function transaction(callable $lambda): void
|
||||
{
|
||||
$this->database->transaction($lambda);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Registers a new user.
|
||||
*
|
||||
* Throws an exception if a user with the given email address is already registered.
|
||||
* Throws an error if a user with the given email address is already registered.
|
||||
*
|
||||
* @param string $email the user's email address
|
||||
* @param string $password the user's password
|
||||
* @return string the user's UUID
|
||||
* @param string $email the email address to register the user under
|
||||
* @param string $password the password to apply to the user
|
||||
* @return string the email verification token that is generated for the newly-registered user
|
||||
*/
|
||||
public function add_user(string $email, string $password): string
|
||||
{
|
||||
$stmt = $this->conn->prepare("INSERT INTO users (email, password)
|
||||
VALUES (:email, :password)
|
||||
RETURNING uuid;");
|
||||
$stmt = $this->database->conn->prepare("INSERT INTO users (email, password)
|
||||
VALUES (:email, :password)
|
||||
RETURNING email_verification_token;");
|
||||
$stmt->bindValue(":email", $email);
|
||||
$stmt->bindValue(":password", password_hash($password, PASSWORD_BCRYPT));
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["uuid"];
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["email_verification_token"];
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -107,7 +113,7 @@ class UserManager
|
|||
*/
|
||||
public function has_user_with_uuid(string $uuid): bool
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE uuid=:uuid);");
|
||||
$stmt = $this->database->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE uuid=:uuid);");
|
||||
$stmt->bindValue(":uuid", $uuid);
|
||||
$stmt->execute();
|
||||
return $stmt->fetch()[0] === 1;
|
||||
|
@ -121,7 +127,7 @@ class UserManager
|
|||
*/
|
||||
public function has_user_with_email(string $email): bool
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE email=:email);");
|
||||
$stmt = $this->database->conn->prepare("SELECT EXISTS(SELECT 1 FROM users WHERE email=:email);");
|
||||
$stmt->bindValue(":email", $email);
|
||||
$stmt->execute();
|
||||
return $stmt->fetch()[0] === 1;
|
||||
|
@ -135,7 +141,7 @@ class UserManager
|
|||
*/
|
||||
public function remove_user_by_uuid(string $uuid): void
|
||||
{
|
||||
$stmt = $this->conn->prepare("DELETE FROM users WHERE uuid=:uuid;");
|
||||
$stmt = $this->database->conn->prepare("DELETE FROM users WHERE uuid=:uuid;");
|
||||
$stmt->bindValue(":uuid", $uuid);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
@ -147,9 +153,10 @@ class UserManager
|
|||
* @return array<string, mixed>|null all data of the user with the given UUID, or `null` if the user could not be
|
||||
* found
|
||||
*/
|
||||
// TODO: Specify the return signature *exactly*
|
||||
public function get_user_by_uuid(string $uuid): ?array
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE uuid=:uuid;");
|
||||
$stmt = $this->database->conn->prepare("SELECT * FROM users WHERE uuid=:uuid;");
|
||||
$stmt->bindValue(":uuid", $uuid);
|
||||
$stmt->execute();
|
||||
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
@ -160,11 +167,12 @@ class UserManager
|
|||
* Returns all data of the user with the given email address.
|
||||
*
|
||||
* @param string $email the email address of the user to return the data of
|
||||
* @return array<string, mixed>|null all data of the user with the given email address
|
||||
* @return array<string, mixed>|null all data of the user with the given email address, or `null` if the user could
|
||||
* not be found
|
||||
*/
|
||||
public function get_user_by_email(string $email): ?array
|
||||
{
|
||||
$stmt = $this->conn->prepare("SELECT * FROM users WHERE email=:email;");
|
||||
$stmt = $this->database->conn->prepare("SELECT * FROM users WHERE email=:email;");
|
||||
$stmt->bindValue(":email", $email);
|
||||
$stmt->execute();
|
||||
$results = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
@ -175,7 +183,7 @@ class UserManager
|
|||
/**
|
||||
* Updates the email address of the user with the given UUID, and resets the email verification token.
|
||||
*
|
||||
* Settings the email address to the current value resets the email verification token.
|
||||
* Setting the email address to the current value only resets the email verification token.
|
||||
*
|
||||
* @param string $uuid the UUID of the user whose email address should be updated
|
||||
* @param string $email the new email address
|
||||
|
@ -183,12 +191,12 @@ class UserManager
|
|||
*/
|
||||
public function set_email(string $uuid, string $email): string
|
||||
{
|
||||
$stmt = $this->conn->prepare("UPDATE users
|
||||
SET email=:email,
|
||||
email_verification_token=lower(hex(randomblob(16))),
|
||||
email_verification_token_timestamp=unixepoch()
|
||||
WHERE uuid=:uuid
|
||||
RETURNING email_verification_token;");
|
||||
$stmt = $this->database->conn->prepare("UPDATE users
|
||||
SET email=:email,
|
||||
email_verification_token=lower(hex(randomblob(16))),
|
||||
email_verification_token_timestamp=unixepoch()
|
||||
WHERE uuid=:uuid
|
||||
RETURNING email_verification_token;");
|
||||
$stmt->bindValue(":uuid", $uuid);
|
||||
$stmt->bindValue(":email", $email);
|
||||
$stmt->execute();
|
||||
|
@ -203,7 +211,7 @@ class UserManager
|
|||
*/
|
||||
public function set_email_verified(string $uuid): void
|
||||
{
|
||||
$stmt = $this->conn->prepare("UPDATE users SET email_verification_token=null WHERE uuid=:uuid;");
|
||||
$stmt = $this->database->conn->prepare("UPDATE users SET email_verification_token=null WHERE uuid=:uuid;");
|
||||
$stmt->bindValue(":uuid", $uuid);
|
||||
$stmt->execute();
|
||||
}
|
||||
|
@ -217,7 +225,9 @@ class UserManager
|
|||
*/
|
||||
public function set_notifications_enabled(string $uuid, bool $enabled): void
|
||||
{
|
||||
$stmt = $this->conn->prepare("UPDATE users SET email_notifications_enabled=:enabled WHERE uuid=:uuid;");
|
||||
$stmt = $this->database->conn->prepare("UPDATE users
|
||||
SET email_notifications_enabled=:enabled
|
||||
WHERE uuid=:uuid;");
|
||||
$stmt->bindValue(":enabled", $enabled);
|
||||
$stmt->bindValue(":uuid", $uuid);
|
||||
$stmt->execute();
|
||||
|
@ -225,7 +235,7 @@ class UserManager
|
|||
|
||||
|
||||
/**
|
||||
* Sets the indicated user's password.
|
||||
* Sets the given user's password.
|
||||
*
|
||||
* @param string $uuid the UUID of the user whose password should be changed
|
||||
* @param string $password the new password
|
||||
|
@ -233,10 +243,10 @@ class UserManager
|
|||
*/
|
||||
public function set_password(string $uuid, string $password): void
|
||||
{
|
||||
$stmt = $this->conn->prepare("UPDATE users
|
||||
SET password=:password, password_last_change=unixepoch(),
|
||||
password_reset_token=null
|
||||
WHERE uuid=:uuid;");
|
||||
$stmt = $this->database->conn->prepare("UPDATE users
|
||||
SET password=:password, password_last_change=unixepoch(),
|
||||
password_reset_token=null
|
||||
WHERE uuid=:uuid;");
|
||||
$stmt->bindValue(":uuid", $uuid);
|
||||
$stmt->bindValue(":password", password_hash($password, PASSWORD_BCRYPT));
|
||||
$stmt->execute();
|
||||
|
@ -250,11 +260,11 @@ class UserManager
|
|||
*/
|
||||
public function register_password_reset(string $email): string
|
||||
{
|
||||
$stmt = $this->conn->prepare("UPDATE users
|
||||
SET password_reset_token=lower(hex(randomblob(16))),
|
||||
password_reset_token_timestamp=unixepoch()
|
||||
WHERE email=:email
|
||||
RETURNING password_reset_token;");
|
||||
$stmt = $this->database->conn->prepare("UPDATE users
|
||||
SET password_reset_token=lower(hex(randomblob(16))),
|
||||
password_reset_token_timestamp=unixepoch()
|
||||
WHERE email=:email
|
||||
RETURNING password_reset_token;");
|
||||
$stmt->bindValue(":email", $email);
|
||||
$stmt->execute();
|
||||
return $stmt->fetchAll(PDO::FETCH_ASSOC)[0]["password_reset_token"];
|
|
@ -3,9 +3,12 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\Util;
|
||||
use com\fwdekker\deathnotifier\validator\IsEmailRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use com\fwdekker\deathnotifier\validator\IsStringRule;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -14,46 +17,62 @@ use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
|||
class ValidatePasswordResetTokenAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var UserManager the manager to check the password reset token validity with
|
||||
* @var UserList the list to validate the password reset token in
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
private readonly UserList $user_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `ValidatePasswordResetTokenAction`.
|
||||
*
|
||||
* @param UserManager $user_manager the manager to check the password reset token validity with
|
||||
* @param UserList $user_list the list to validate the password reset token in
|
||||
*/
|
||||
public function __construct(UserManager $user_manager)
|
||||
public function __construct(UserList $user_list)
|
||||
{
|
||||
parent::__construct(
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"email" => [new IsEmailRule()],
|
||||
"reset_token" => [new IsSetRule()],
|
||||
],
|
||||
);
|
||||
|
||||
$this->user_manager = $user_manager;
|
||||
$this->user_list = $user_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks whether the given password reset token is valid.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"email"`: the email address to validate the reset token of,
|
||||
* `"reset_token": string`: the token to validate
|
||||
* Requires that a valid CSRF token is present. Does not require the user to be logged in or out.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"email"`: the email address to
|
||||
* validate the password reset token of, `"reset_token": string`: the password reset token to validate
|
||||
* @return null
|
||||
* @throws InvalidInputException if the password reset link is valid
|
||||
* @throws InvalidInputException if no valid CSRF token is present, if no account with the given email address
|
||||
* exists, if the reset token is invalid, or if the reset token has expired
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
$user_data = $this->user_manager->get_user_by_email($inputs["email"]);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"email" => [new IsEmailRule()],
|
||||
"reset_token" => [new IsStringRule()],
|
||||
]))->check($inputs);
|
||||
|
||||
$user_data = $this->user_list->get_user_by_email($inputs["email"]);
|
||||
if ($user_data === null)
|
||||
throw new InvalidInputException("No user with that email address has been registered.");
|
||||
if ($inputs["reset_token"] !== $user_data["password_reset_token"])
|
||||
// TODO: Just tell the user why the link is invalid: Because no request exists, or because the token is wrong
|
||||
// TODO: Also, tell the user what they can do to resolve this
|
||||
throw new InvalidInputException(
|
||||
"This password reset link is invalid. Maybe you already reset your password?"
|
||||
);
|
||||
|
||||
$minutes_left = Util::minutes_until_interval_elapsed(
|
||||
$user_data["password_reset_token_timestamp"],
|
||||
UserList::MINUTES_VALID_PASSWORD_RESET
|
||||
);
|
||||
if ($minutes_left < 0) {
|
||||
throw new InvalidInputException(
|
||||
`This password reset link has expired. <a href="./">Return to the front page</a> and press the ` .
|
||||
`"Forgot password?" button to request a new password reset link.`
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -3,13 +3,12 @@
|
|||
namespace com\fwdekker\deathnotifier\user;
|
||||
|
||||
use com\fwdekker\deathnotifier\Action;
|
||||
use com\fwdekker\deathnotifier\Database;
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\validator\IsValidCsrfTokenRule;
|
||||
use com\fwdekker\deathnotifier\Util;
|
||||
use com\fwdekker\deathnotifier\validator\IsEmailRule;
|
||||
use com\fwdekker\deathnotifier\validator\IsSetRule;
|
||||
use com\fwdekker\deathnotifier\validator\InvalidInputException;
|
||||
use PDO;
|
||||
use com\fwdekker\deathnotifier\validator\IsStringRule;
|
||||
use com\fwdekker\deathnotifier\validator\RuleSet;
|
||||
|
||||
|
||||
/**
|
||||
|
@ -18,71 +17,62 @@ use PDO;
|
|||
class VerifyEmailAction extends Action
|
||||
{
|
||||
/**
|
||||
* @var PDO the connection to verify the email address with
|
||||
* @var UserList the list to verify the email address in
|
||||
*/
|
||||
private readonly PDO $conn;
|
||||
/**
|
||||
* @var UserManager the manager to verify the email address with
|
||||
*/
|
||||
private readonly UserManager $user_manager;
|
||||
/**
|
||||
* @var MailManager the manager to send emails with
|
||||
*/
|
||||
private readonly MailManager $mail_manager;
|
||||
private readonly UserList $user_list;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `VerifyEmailAction`.
|
||||
*
|
||||
* @param PDO $conn the connection to verify the email address with
|
||||
* @param UserManager $user_manager the manager to verify the email address with
|
||||
* @param MailManager $mail_manager the manager to send emails with
|
||||
* @param UserList $user_list the list to verify the email address in
|
||||
*/
|
||||
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
|
||||
public function __construct(UserList $user_list)
|
||||
{
|
||||
parent::__construct(
|
||||
require_valid_csrf_token: true,
|
||||
rule_lists: [
|
||||
"email" => [new IsEmailRule()],
|
||||
"verify_token" => [new IsSetRule()],
|
||||
],
|
||||
);
|
||||
|
||||
$this->conn = $conn;
|
||||
$this->user_manager = $user_manager;
|
||||
$this->mail_manager = $mail_manager;
|
||||
$this->user_list = $user_list;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verifies the user's email address.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"email"`: the email address to verify, `"verify_token": string`: the
|
||||
* token to verify the email address with
|
||||
* Requires that a valid CSRF token is present. Does not require the user to be logged in or out.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs `"token": string`: a valid CSRF token, `"email"`: the email address to
|
||||
* verify, `"verify_token": string`: the token to verify the email address with
|
||||
* @return null
|
||||
* @throws InvalidInputException if the email address does not exist, if the email is already verified, or if the
|
||||
* token expired
|
||||
* @throws InvalidInputException if no valid CSRF token is present, if no account with the given email address
|
||||
* exists, if the reset token is invalid, or if the reset token has expired
|
||||
*/
|
||||
function handle(array $inputs): mixed
|
||||
public function handle(array $inputs): mixed
|
||||
{
|
||||
Database::transaction($this->conn, function () use ($inputs) {
|
||||
// TODO: Validate that the email even exists
|
||||
$user_data = $this->user_manager->get_user_by_email($inputs["email"]);
|
||||
(new RuleSet([
|
||||
"token" => [new IsValidCsrfTokenRule()],
|
||||
"email" => [new IsEmailRule()],
|
||||
"verify_token" => [new IsStringRule()],
|
||||
]))->check($inputs);
|
||||
|
||||
$this->user_list->transaction(function () use ($inputs) {
|
||||
$user_data = $this->user_list->get_user_by_email($inputs["email"]);
|
||||
if ($user_data === null)
|
||||
throw new InvalidInputException("No user with that email address has been registered.");
|
||||
if ($user_data["email_verification_token"] !== $inputs["verify_token"])
|
||||
// TODO: Just tell the user whether the account has been verified or whether the token is plain wrong
|
||||
// TODO: Also, tell the user what they can do to resolve this
|
||||
throw new InvalidInputException(
|
||||
"Failed to verify email address. Maybe you already verified your email address?"
|
||||
);
|
||||
|
||||
$minutes_remaining = Util::minutes_until_interval_elapsed(
|
||||
$minutes_left = Util::minutes_until_interval_elapsed(
|
||||
$user_data["email_verification_token_timestamp"],
|
||||
UserManager::MINUTES_VALID_VERIFICATION
|
||||
UserList::MINUTES_VALID_VERIFICATION
|
||||
);
|
||||
if ($minutes_remaining < 0)
|
||||
if ($minutes_left < 0)
|
||||
throw new InvalidInputException(
|
||||
"This email verification link has expired. Log in and request a new verification email."
|
||||
);
|
||||
|
||||
$this->user_manager->set_email_verified($_SESSION["uuid"]);
|
||||
$this->user_list->set_email_verified($_SESSION["uuid"]);
|
||||
});
|
||||
|
||||
return null;
|
||||
|
|
|
@ -4,9 +4,9 @@ namespace com\fwdekker\deathnotifier\validator;
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input is of the specific length.
|
||||
* Validates that the input is of the specific length.
|
||||
*/
|
||||
class HasLengthRule extends Rule
|
||||
class HasStringLengthRule extends Rule
|
||||
{
|
||||
/**
|
||||
* @var int|null the minimum length (inclusive), or `null` if there is no minimum length
|
||||
|
@ -36,18 +36,21 @@ class HasLengthRule extends Rule
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input is of the specific length.
|
||||
* Validates that the input is of the specific length.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
|
||||
* @param string $key the key in `inputs` of the input to check
|
||||
* @return void if `$inputs[$key]` is of the specified length
|
||||
* @throws InvalidInputException if `$inputs[$key]` is not set or is not of the specified length
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input is of the specified length
|
||||
* @throws InvalidInputException if the checked input is not set or is not of the specified length
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
if (!isset($inputs[$key]))
|
||||
throw new InvalidInputException($this->override_message ?? "Missing input '$key'.", $key);
|
||||
|
||||
if (!is_string($inputs[$key]))
|
||||
throw new InvalidInputException($this->override_message ?? "Field must be a string.", $key);
|
||||
|
||||
if ($this->min_length !== null && strlen($inputs[$key]) < $this->min_length)
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ?? "Use at least $this->min_length character(s).",
|
|
@ -7,9 +7,7 @@ use Throwable;
|
|||
|
||||
|
||||
/**
|
||||
* Thrown if a client-specified input is invalid.
|
||||
*
|
||||
* This is an exception, not an error, so it indicates that the client that sent the request did something wrong.
|
||||
* Thrown to indicate that a request to the server contains an invalid input.
|
||||
*/
|
||||
class InvalidInputException extends MalformedRequestException
|
||||
{
|
||||
|
@ -20,11 +18,10 @@ class InvalidInputException extends MalformedRequestException
|
|||
|
||||
|
||||
/**
|
||||
* Constructs a new `ValidationException`.
|
||||
* Constructs a new `InvalidInputException`.
|
||||
*
|
||||
* @param string $message the message to show to the user
|
||||
* @param string|null $target the input element that caused the exception, or `null` if no such element could be
|
||||
* identified
|
||||
* @param string|null $target the input that caused the exception, or `null` if no such element could be identified
|
||||
* @param Throwable|null $previous the throwable that caused this one
|
||||
*/
|
||||
public function __construct(string $message, ?string $target = null, ?Throwable $previous = null)
|
||||
|
|
|
@ -0,0 +1,26 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\validator;
|
||||
|
||||
use com\fwdekker\deathnotifier\Config;
|
||||
|
||||
|
||||
/**
|
||||
* Validates that the input equals the CLI password.
|
||||
*/
|
||||
class IsCliPasswordRule extends Rule
|
||||
{
|
||||
/**
|
||||
* Validates that the input equals the CLI password.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input equals the CLI password
|
||||
* @throws InvalidInputException if the checked input is does not equal the CLI password
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
if (!isset($inputs[$key]) || !hash_equals(Config::get()["admin"]["cli_secret"], $inputs[$key]))
|
||||
throw new InvalidInputException($this->override_message ?? "Incorrect password.", $key);
|
||||
}
|
||||
}
|
|
@ -4,21 +4,21 @@ namespace com\fwdekker\deathnotifier\validator;
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input is an email address.
|
||||
* Validates that the input is an email address.
|
||||
*/
|
||||
class IsEmailRule extends Rule
|
||||
{
|
||||
/**
|
||||
* Verifies that the input is an email address.
|
||||
* Validates that the input is an email address.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
|
||||
* @param string $key the key in `inputs` of the input to check
|
||||
* @return void if `$inputs[$key]` is an email address
|
||||
* @throws InvalidInputException if `$inputs[$key]` is not set or is not an email address
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input is an email address
|
||||
* @throws InvalidInputException if the checked input is not set, is not a string, or is not an email address
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
if (!isset($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL))
|
||||
if (!isset($inputs[$key]) || !is_string($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL))
|
||||
throw new InvalidInputException($this->override_message ?? "Enter a valid email address.", $key);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,7 +4,7 @@ namespace com\fwdekker\deathnotifier\validator;
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input has the specified value.
|
||||
* Validates that the input has the specified value.
|
||||
*/
|
||||
class IsEqualToRule extends Rule
|
||||
{
|
||||
|
@ -18,6 +18,8 @@ class IsEqualToRule extends Rule
|
|||
* Constructs a new `IsEqualToRule`.
|
||||
*
|
||||
* @param string $expected the value that checked values should be equal to
|
||||
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
|
||||
* the rule implementation can choose an appropriate message
|
||||
*/
|
||||
public function __construct(string $expected, ?string $override_message = null)
|
||||
{
|
||||
|
@ -28,18 +30,18 @@ class IsEqualToRule extends Rule
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input has the specified value.
|
||||
* Validates that the input has the specified value.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
|
||||
* @param string $key the key in `inputs` of the input to check
|
||||
* @return void if `$inputs[$key]` equals `$expected`
|
||||
* @throws InvalidInputException if `$inputs[$key]` is not set or does not equal `$expected`
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input equals {@see $expected}
|
||||
* @throws InvalidInputException if the checked input is not set or does not equal {@see $expected}
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
if (!isset($inputs[$key]) || $inputs[$key] !== $this->expected)
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ?? "Inputs '$key' should equal '$this->expected'.",
|
||||
$this->override_message ?? "Input '$key' should equal '$this->expected'.",
|
||||
$key
|
||||
);
|
||||
}
|
||||
|
|
|
@ -4,21 +4,21 @@ namespace com\fwdekker\deathnotifier\validator;
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input is not blank.
|
||||
* Validates that the input is not a blank string.
|
||||
*/
|
||||
class IsNotBlankRule extends Rule
|
||||
{
|
||||
/**
|
||||
* Verifies that the input is not blank.
|
||||
* Validates that the input is not a blank string.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
|
||||
* @param string $key the key in `inputs` of the input to check
|
||||
* @return void if `trim($inputs[$key])` is not an empty string
|
||||
* @throws InvalidInputException if `$inputs[$key]` is not set or if `trim($inputs[$key])` is an empty string
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input is not a blank string
|
||||
* @throws InvalidInputException if the checked input is not set, is not a string, or is a blank string
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
if (!isset($inputs[$key]) || trim($inputs[$key]) === "")
|
||||
if (!isset($inputs[$key]) || !is_string($inputs[$key]) || trim($inputs[$key]) === "")
|
||||
throw new InvalidInputException($this->override_message ?? "Use at least one character.", $key);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -4,17 +4,17 @@ namespace com\fwdekker\deathnotifier\validator;
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input is not set.
|
||||
* Validates that the input is not set.
|
||||
*/
|
||||
class IsNotSetRule extends Rule
|
||||
{
|
||||
/**
|
||||
* Verifies that the input is not set.
|
||||
* Validates that the input is not set.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
|
||||
* @param string $key the key in `inputs` of the input to check
|
||||
* @return void if `$inputs[$key]` is not set
|
||||
* @throws InvalidInputException if `$inputs[$key]` is set
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input is not set
|
||||
* @throws InvalidInputException if the checked input is set
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
|
|
|
@ -4,17 +4,17 @@ namespace com\fwdekker\deathnotifier\validator;
|
|||
|
||||
|
||||
/**
|
||||
* Verifies that the input is set.
|
||||
* Validates that the input is set.
|
||||
*/
|
||||
class IsSetRule extends Rule
|
||||
{
|
||||
/**
|
||||
* Verifies that the input is set.
|
||||
* Validates that the input is set.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked
|
||||
* @param string $key the key in `inputs` of the input to check
|
||||
* @return void if `$inputs[$key]` is set
|
||||
* @throws InvalidInputException if `$inputs[$key]` is not set
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input is set
|
||||
* @throws InvalidInputException if the checked input is not set
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
|
|
|
@ -0,0 +1,27 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\validator;
|
||||
|
||||
|
||||
/**
|
||||
* Validates that the input is a string.
|
||||
*/
|
||||
class IsStringRule extends Rule
|
||||
{
|
||||
/**
|
||||
* Validates that the input is a string.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs in which the value at {@see $key} should be checked
|
||||
* @param string $key the key in {@see $inputs} of the input to check
|
||||
* @return void if the checked input is a string
|
||||
* @throws InvalidInputException if the checked input is not a string
|
||||
*/
|
||||
public function check(array $inputs, string $key): void
|
||||
{
|
||||
if (!isset($inputs[$key]) || !is_string($inputs[$key]))
|
||||
throw new InvalidInputException(
|
||||
$this->override_message ?? "Field '" . htmlentities($key) . "' must be a string.",
|
||||
$key
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,24 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\validator;
|
||||
|
||||
|
||||
/**
|
||||
* Validates that the input equals the CLI password.
|
||||
*/
|
||||
class IsValidCsrfTokenRule extends IsEqualToRule
|
||||
{
|
||||
/**
|
||||
* Constructs a new `IsValidCsrfTokenRule`.
|
||||
*
|
||||
* @param string|null $override_message the message to return if the rule does not apply to some input. If `null`,
|
||||
* the rule implementation can choose an appropriate message
|
||||
*/
|
||||
public function __construct(?string $override_message = null)
|
||||
{
|
||||
parent::__construct(
|
||||
$_SESSION["token"],
|
||||
$override_message ?? "Invalid request token. Please refresh the page and try again."
|
||||
);
|
||||
}
|
||||
}
|
|
@ -12,7 +12,7 @@ abstract class Rule
|
|||
* @var string|null the message to return if the rule does not apply to some input. If `null`, the rule
|
||||
* implementation can choose an appropriate message.
|
||||
*/
|
||||
public ?string $override_message;
|
||||
public readonly ?string $override_message;
|
||||
|
||||
|
||||
/**
|
||||
|
|
|
@ -0,0 +1,41 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\validator;
|
||||
|
||||
|
||||
/**
|
||||
* A set of {@see Rule Rules} to apply to an array of inputs.
|
||||
*/
|
||||
class RuleSet
|
||||
{
|
||||
/**
|
||||
* @var array<string, array<Rule>> the rules to apply to the inputs
|
||||
*/
|
||||
private readonly array $rule_set;
|
||||
|
||||
|
||||
/**
|
||||
* Constructs a new `RuleSet`.
|
||||
*
|
||||
* @param array<string, array<Rule>> $rules the rules to apply to the inputs
|
||||
*/
|
||||
public function __construct(array $rules)
|
||||
{
|
||||
$this->rule_set = $rules;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Verifies that the input is of the specific length.
|
||||
*
|
||||
* @param array<int|string, mixed> $inputs the list of inputs to validate
|
||||
* @return void if the inputs satisfy the rules of this rule set
|
||||
* @throws InvalidInputException if any input does not satisfy any rule of this rule set
|
||||
*/
|
||||
public function check(array $inputs): void
|
||||
{
|
||||
foreach ($this->rule_set as $key => $rules)
|
||||
foreach ($rules as $rule)
|
||||
$rule->check($inputs, $key);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\validator;
|
||||
|
||||
use com\fwdekker\deathnotifier\IllegalArgumentError;
|
||||
|
||||
|
||||
/**
|
||||
* A {@see RuleSet} specifically to validate the current session.
|
||||
*/
|
||||
class SessionRuleSet extends RuleSet
|
||||
{
|
||||
/**
|
||||
* Constructs a new `RuleSet`.
|
||||
*
|
||||
* @param bool $validate_logged_in `true` if and only if this rule set should validate that the user is logged in
|
||||
* @param bool $validate_logged_out `true` if and only if this rule set should validate that the user is logged out
|
||||
*/
|
||||
public function __construct(bool $validate_logged_in = false,
|
||||
bool $validate_logged_out = false)
|
||||
{
|
||||
if ($validate_logged_in && $validate_logged_out)
|
||||
throw new IllegalArgumentError("Cannot require that user is both logged in and logged out.");
|
||||
|
||||
$rules = [];
|
||||
if ($validate_logged_in)
|
||||
$rules["uuid"] = [new IsSetRule("You must be logged in to perform this action.")];
|
||||
if ($validate_logged_out)
|
||||
$rules["uuid"] = [new IsNotSetRule("You must be logged out to perform this action.")];
|
||||
|
||||
parent::__construct($rules);
|
||||
}
|
||||
}
|
|
@ -1,6 +1,6 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mediawiki;
|
||||
namespace com\fwdekker\deathnotifier\wikipedia;
|
||||
|
||||
|
||||
/**
|
|
@ -1,10 +1,10 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mediawiki;
|
||||
namespace com\fwdekker\deathnotifier\wikipedia;
|
||||
|
||||
|
||||
/**
|
||||
* The status assigned to a person.
|
||||
* The status that Wikipedia assigns to a person.
|
||||
*/
|
||||
enum PersonStatus: string
|
||||
{
|
|
@ -1,12 +1,12 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mediawiki;
|
||||
namespace com\fwdekker\deathnotifier\wikipedia;
|
||||
|
||||
|
||||
/**
|
||||
* Output of a query request sent to Wikipedia's API.
|
||||
* Output of a {@see Wikipedia} query.
|
||||
*
|
||||
* @template T the result type that is returned
|
||||
* @template T the type of result that is returned
|
||||
*/
|
||||
class QueryOutput
|
||||
{
|
||||
|
@ -25,9 +25,11 @@ class QueryOutput
|
|||
|
||||
|
||||
/**
|
||||
* Constructs a new query output.
|
||||
* Constructs a new `QueryOutput`, storing both the result returned by the API, and information on which requested
|
||||
* pages are redirected or missing.
|
||||
*
|
||||
* @param array<string, T> $results the results of the query, either raw from the API or processed in some way
|
||||
* @param array<string, T> $results the results of the query, either raw from {@see Wikipedia} or processed in some
|
||||
* way
|
||||
* @param array<string, string> $redirects mapping of queried names to normalized/redirected names
|
||||
* @param string[] $missing list of missing articles
|
||||
*/
|
|
@ -1,15 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\mediawiki;
|
||||
namespace com\fwdekker\deathnotifier\wikipedia;
|
||||
|
||||
use com\fwdekker\deathnotifier\LoggerUtil;
|
||||
use Monolog\Logger;
|
||||
use JsonException;
|
||||
|
||||
|
||||
/**
|
||||
* Helper class for interacting with Wikipedia's API.
|
||||
*/
|
||||
class MediaWiki
|
||||
class Wikipedia
|
||||
{
|
||||
/**
|
||||
* The URL of Wikipedia's API endpoint.
|
||||
|
@ -33,27 +32,13 @@ class MediaWiki
|
|||
*/
|
||||
private const CATS_PER_QUERY = 500;
|
||||
|
||||
/**
|
||||
* @var Logger the logger to use for logging
|
||||
*/
|
||||
private Logger $logger; // @phpstan-ignore-line Unused, but useful for debugging
|
||||
|
||||
|
||||
/**
|
||||
* Creates a new Mediawiki instance.
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->logger = LoggerUtil::with_name($this::class);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Sends a request to Wikipedia's API and returns its response as a JSON object.
|
||||
*
|
||||
* @param array<string, mixed> $params the request parameters to send to the API
|
||||
* @return mixed a JSON object containing the API's response
|
||||
* @throws MediaWikiException() if the request fails
|
||||
* @throws WikipediaException if the request fails
|
||||
*/
|
||||
private function api_fetch(array $params): mixed
|
||||
{
|
||||
|
@ -65,19 +50,23 @@ class MediaWiki
|
|||
$output = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
if (is_bool($output) || curl_error($ch))
|
||||
throw new MediaWikiException(curl_error($ch));
|
||||
throw new WikipediaException(curl_error($ch));
|
||||
|
||||
return json_decode($output, associative: true);
|
||||
try {
|
||||
return json_decode($output, associative: true, flags: JSON_THROW_ON_ERROR);
|
||||
} catch (JsonException $exception) {
|
||||
throw new WikipediaException($exception->getMessage(), previous: $exception);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Queries Wikipedia's API with continuation and returns its response as a JSON object.
|
||||
* Sends a query request to Wikipedia's API with continuation and returns its response as a JSON object.
|
||||
*
|
||||
* @param array<string, mixed> $params the query parameters to send to the API
|
||||
* @param array<string, mixed> $params the query request parameters to send to the API
|
||||
* @param string|null $continue_name the name of the continue parameter to follow, or `null` if no continuation
|
||||
* should be done
|
||||
* @return mixed[] the query's value of the `query` key as a JSON object
|
||||
* @throws MediaWikiException if the query fails
|
||||
* @return mixed[] a JSON array containing the API's responses merged into one array
|
||||
* @throws WikipediaException if the query fails
|
||||
*/
|
||||
private function api_query_continued(array $params, ?string $continue_name = null): array
|
||||
{
|
||||
|
@ -107,13 +96,13 @@ class MediaWiki
|
|||
}
|
||||
|
||||
/**
|
||||
* Sends a query request to the Wikipedia API in batches of `ARTICLES_PER_QUERY` titles at a time.
|
||||
* Sends a query request to the Wikipedia API in batches of {@see Wikipedia::ARTICLES_PER_QUERY} titles at a time.
|
||||
*
|
||||
* @param array<string, string> $params the parameters to include in each query
|
||||
* @param string[] $titles the titles to query
|
||||
* @param string|null $continue_name the name of the continue parameter used for this request by the API
|
||||
* @return QueryOutput<mixed> the API's response
|
||||
* @throws MediaWikiException if the query fails
|
||||
* @param array<string, mixed> $params the parameters to include in each query
|
||||
* @param string[] $titles the titles of the pages to query
|
||||
* @param string|null $continue_name the name of the continue parameter to follow for this request
|
||||
* @return QueryOutput<mixed> the API's responses merged into a single `QueryOutput`
|
||||
* @throws WikipediaException if the query fails
|
||||
* @noinspection PhpSameParameterValueInspection `$continue_name` may take other values in the future
|
||||
*/
|
||||
private function api_query_batched(array $params, array $titles, ?string $continue_name): QueryOutput
|
||||
|
@ -148,12 +137,34 @@ class MediaWiki
|
|||
return new QueryOutput($articles, $redirects, $missing);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the person's status, or `null` if the title does not refer to an article about a person on Wikipedia.
|
||||
* Returns the current {@see ArticleType} of the article.
|
||||
*
|
||||
* @param mixed $article the article object as returned by the Wikipedia API
|
||||
* @return PersonStatus|null the person's status, or `null` if the title does not refer to an article about a person
|
||||
* on Wikipedia
|
||||
* @return ArticleType the current `ArticleType` of article
|
||||
*/
|
||||
private function article_type(mixed $article): ArticleType
|
||||
{
|
||||
$category_titles = array_column($article["categories"], "title");
|
||||
|
||||
$status = $this->person_status($article);
|
||||
if ($status !== null)
|
||||
return ArticleType::Person;
|
||||
else if (in_array("Category:All set index articles", $category_titles) ||
|
||||
in_array("Category:All disambiguation pages", $category_titles))
|
||||
return ArticleType::Disambiguation;
|
||||
else
|
||||
return ArticleType::Other;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current {@see PersonStatus}, or `null` if the title does not refer to an article about a person on
|
||||
* Wikipedia.
|
||||
*
|
||||
* @param mixed $article the article as returned by the Wikipedia API
|
||||
* @return PersonStatus|null the current `PersonStatus`, or `null` if the title does not refer to an article about a
|
||||
* person on Wikipedia
|
||||
*/
|
||||
private function person_status(mixed $article): ?PersonStatus
|
||||
{
|
||||
|
@ -176,33 +187,14 @@ class MediaWiki
|
|||
}
|
||||
|
||||
/**
|
||||
* Returns the type of the article.
|
||||
* Checks for all {@see $names} what their current {@see ArticleType} and {@see PersonStatus} is according to
|
||||
* Wikipedia.
|
||||
*
|
||||
* @param mixed $article the article object as returned by the Wikipedia API
|
||||
* @return ArticleType the type of article
|
||||
*/
|
||||
private function article_type(mixed $article): ArticleType
|
||||
{
|
||||
$category_titles = array_column($article["categories"], "title");
|
||||
|
||||
$status = $this->person_status($article);
|
||||
if ($status !== null)
|
||||
return ArticleType::Person;
|
||||
else if (in_array("Category:All set index articles", $category_titles) ||
|
||||
in_array("Category:All disambiguation pages", $category_titles))
|
||||
return ArticleType::Disambiguation;
|
||||
else
|
||||
return ArticleType::Other;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Checks for each person what their status is according to Wikipedia's categorization.
|
||||
*
|
||||
* @param string[] $names the names of the people to check aliveness of
|
||||
* @return QueryOutput<array{"type": ArticleType, "status": PersonStatus|null}> a query output with results mapping
|
||||
* each article's normalized title to the article's type and, if the article is about a person, the person's status
|
||||
* @throws MediaWikiException if the query fails
|
||||
* @param string[] $names the names of the people to retrieve the information of
|
||||
* @return QueryOutput<array{"type": ArticleType, "status": PersonStatus|null}> a `QueryOutput` with its
|
||||
* {@see QueryOutput::$results} mapping each normalized title to its `ArticleType` and, if the type is `Person`, the
|
||||
* `PersonStatus`
|
||||
* @throws WikipediaException if the query fails
|
||||
*/
|
||||
public function query_person_info(array $names): QueryOutput
|
||||
{
|
|
@ -0,0 +1,14 @@
|
|||
<?php
|
||||
|
||||
namespace com\fwdekker\deathnotifier\wikipedia;
|
||||
|
||||
use Exception;
|
||||
|
||||
|
||||
/**
|
||||
* Thrown if something goes wrong while interacting with the Wikipedia API.
|
||||
*/
|
||||
class WikipediaException extends Exception
|
||||
{
|
||||
// Intentionally left empty
|
||||
}
|
|
@ -2,9 +2,9 @@
|
|||
|
||||
namespace com\fwdekker\deathnotifier;
|
||||
|
||||
use com\fwdekker\deathnotifier\mailer\MailManager;
|
||||
use com\fwdekker\deathnotifier\tracking\TrackingManager;
|
||||
use com\fwdekker\deathnotifier\user\UserManager;
|
||||
use com\fwdekker\deathnotifier\mailer\EmailQueue;
|
||||
use com\fwdekker\deathnotifier\tracking\TrackingList;
|
||||
use com\fwdekker\deathnotifier\user\UserList;
|
||||
use Exception;
|
||||
use Monolog\Test\TestCase;
|
||||
|
||||
|
@ -29,27 +29,27 @@ abstract class DatabaseTestCase extends TestCase
|
|||
|
||||
|
||||
/**
|
||||
* @return MailManager the `Mailer` to install the database schema of
|
||||
* @return EmailQueue the `EmailQueue` to install the database schema of
|
||||
*/
|
||||
function get_mailer(): MailManager
|
||||
function get_email_queue(): EmailQueue
|
||||
{
|
||||
return $this->createStub(MailManager::class);
|
||||
return $this->createStub(EmailQueue::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return TrackingManager the `TrackingManager` to install the database schema of
|
||||
* @return TrackingList the `TrackingList` to install the database schema of
|
||||
*/
|
||||
function get_tracking_manager(): TrackingManager
|
||||
function get_tracking_list(): TrackingList
|
||||
{
|
||||
return $this->createStub(TrackingManager::class);
|
||||
return $this->createStub(TrackingList::class);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return UserManager the `UserManager` to install the database schema of
|
||||
* @return UserList the `UserList` to install the database schema of
|
||||
*/
|
||||
function get_user_manager(): UserManager
|
||||
function get_user_list(): UserList
|
||||
{
|
||||
return $this->createStub(UserManager::class);
|
||||
return $this->createStub(UserList::class);
|
||||
}
|
||||
|
||||
|
||||
|
@ -87,6 +87,6 @@ abstract class DatabaseTestCase extends TestCase
|
|||
if ($db_tmp_file === false) throw new Exception("Failed to create temporary database file.");
|
||||
|
||||
$this->database = new Database($db_tmp_file);
|
||||
$this->database->auto_install($this->get_mailer(), $this->get_user_manager(), $this->get_tracking_manager());
|
||||
$this->database->auto_install($this->get_email_queue(), $this->get_user_list(), $this->get_tracking_list());
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,7 +10,7 @@ class LengthRuleTest extends RuleTest
|
|||
{
|
||||
function get_rule(?string $override = null): Rule
|
||||
{
|
||||
return new HasLengthRule(1, 6, $override);
|
||||
return new HasStringLengthRule(1, 6, $override);
|
||||
}
|
||||
|
||||
function get_valid_input(): ?string
|
||||
|
@ -26,7 +26,7 @@ class LengthRuleTest extends RuleTest
|
|||
|
||||
public function test_returns_null_if_input_is_exactly_minimum_length(): void
|
||||
{
|
||||
$rule = new HasLengthRule(1, 3);
|
||||
$rule = new HasStringLengthRule(1, 3);
|
||||
|
||||
$is_valid = $rule->check(["input" => "a"], "input");
|
||||
|
||||
|
@ -35,7 +35,7 @@ class LengthRuleTest extends RuleTest
|
|||
|
||||
public function test_returns_null_if_input_is_exactly_maximum_length(): void
|
||||
{
|
||||
$rule = new HasLengthRule(1, 3);
|
||||
$rule = new HasStringLengthRule(1, 3);
|
||||
|
||||
$is_valid = $rule->check(["input" => "123"], "input");
|
||||
|
||||
|
@ -44,7 +44,7 @@ class LengthRuleTest extends RuleTest
|
|||
|
||||
public function test_returns_null_if_input_is_strictly_inside_range(): void
|
||||
{
|
||||
$rule = new HasLengthRule(1, 3);
|
||||
$rule = new HasStringLengthRule(1, 3);
|
||||
|
||||
$is_valid = $rule->check(["input" => "12"], "input");
|
||||
|
||||
|
@ -53,7 +53,7 @@ class LengthRuleTest extends RuleTest
|
|||
|
||||
public function test_returns_not_null_if_input_is_strictly_below_minimum(): void
|
||||
{
|
||||
$rule = new HasLengthRule(1, 3);
|
||||
$rule = new HasStringLengthRule(1, 3);
|
||||
|
||||
$is_valid = $rule->check(["input" => ""], "input");
|
||||
|
||||
|
@ -62,7 +62,7 @@ class LengthRuleTest extends RuleTest
|
|||
|
||||
public function test_returns_not_null_if_input_is_strictly_above_maximum(): void
|
||||
{
|
||||
$rule = new HasLengthRule(1, 3);
|
||||
$rule = new HasStringLengthRule(1, 3);
|
||||
|
||||
$is_valid = $rule->check(["input" => "1234"], "input");
|
||||
|
||||
|
|
Loading…
Reference in New Issue