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:
Florine W. Dekker 2022-12-04 23:56:49 +01:00
parent 4c58b2a646
commit 9591c5ecd2
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
62 changed files with 1803 additions and 1643 deletions

View File

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

BIN
composer.lock generated

Binary file not shown.

BIN
package-lock.json generated

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,6 +1,6 @@
<?php
namespace com\fwdekker\deathnotifier\mediawiki;
namespace com\fwdekker\deathnotifier\wikipedia;
/**

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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