Make actions agnostic to the request method

And improve some exception structuring.
This commit is contained in:
Florine W. Dekker 2022-12-04 16:58:12 +01:00
parent 1cd9dfc9d2
commit 4c58b2a646
Signed by: FWDekker
GPG Key ID: D3DCFAA8A4560BE0
46 changed files with 355 additions and 340 deletions

View File

@ -60,6 +60,7 @@ $> mv dist/ /var/www/death-notifier/ # Move to public directory
Then, add the following lines to your crontab using `sudo -u www crontab -e`: Then, add the following lines to your crontab using `sudo -u www crontab -e`:
``` ```
* * * * * cd /var/www/death-notifier && php /var/www/death-notifier/api.php process-email-queue secret_password * * * * * cd /var/www/death-notifier && php /var/www/death-notifier/api.php action=process-email-queue password=secret_password
*/5 * * * * cd /var/www/death-notifier && php /var/www/death-notifier/api.php update-all-trackings secret_password */5 * * * * cd /var/www/death-notifier && php /var/www/death-notifier/api.php action=update-all-trackings password=secret_password
``` ```
It is recommended to also use a tool such as `newsyslog` to manage log rotation.

View File

@ -1,7 +1,7 @@
{ {
"name": "fwdekker/death-notifier", "name": "fwdekker/death-notifier",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"version": "0.16.1", "_comment_version": "Also update version in `package.json`!", "version": "0.16.2", "_comment_version": "Also update version in `package.json`!",
"type": "project", "type": "project",
"license": "MIT", "license": "MIT",
"homepage": "https://git.fwdekker.com/tools/death-notifier", "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", "name": "death-notifier",
"version": "0.16.1", "_comment_version": "Also update version in `composer.json`!", "version": "0.16.2", "_comment_version": "Also update version in `composer.json`!",
"description": "Get notified when a famous person dies.", "description": "Get notified when a famous person dies.",
"author": "Florine W. Dekker", "author": "Florine W. Dekker",
"browser": "dist/bundle.js", "browser": "dist/bundle.js",

View File

@ -5,6 +5,7 @@ use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\EmulateCronAction; use com\fwdekker\deathnotifier\EmulateCronAction;
use com\fwdekker\deathnotifier\IllegalArgumentError;
use com\fwdekker\deathnotifier\LoggerUtil; use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\mailer\ProcessEmailQueueAction; use com\fwdekker\deathnotifier\mailer\ProcessEmailQueueAction;
@ -36,73 +37,88 @@ use com\fwdekker\deathnotifier\Util;
require_once __DIR__ . "/.vendor/autoload.php"; require_once __DIR__ . "/.vendor/autoload.php";
// Preamble
$logger = LoggerUtil::with_name(); $logger = LoggerUtil::with_name();
$config = Config::get();
if (hash_equals($config["admin"]["cli_secret"], "REPLACE THIS WITH A SECRET VALUE")) {
$logger->error("You must set a CLI secret in the configuration file before running Death Notifier.");
exit(1);
}
$db = new Database($config["database"]["filename"]); // Wrap everything in try-catch to always return *something* to user
$mediawiki = new MediaWiki();
$mail_manager = new MailManager($db->conn);
$user_manager = new UserManager($db->conn, $mail_manager);
$tracking_manager = new TrackingManager($db->conn);
// Handle request
try { try {
session_start(); // Preamble
$_SESSION["token"] = $_SESSION["token"] ?? Util::generate_csrf_token(); $config = Config::get();
$_POST = Util::parse_post(); if (hash_equals($config["admin"]["cli_secret"], "REPLACE THIS WITH A SECRET VALUE"))
throw new IllegalArgumentError(
"You must set a CLI secret in the configuration file before running Death Notifier."
);
$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);
// Update database
$db->auto_install($mail_manager, $user_manager, $tracking_manager); $db->auto_install($mail_manager, $user_manager, $tracking_manager);
$db->auto_migrate(); $db->auto_migrate();
// Dispatch request session_start();
$_SESSION["token"] = $_SESSION["token"] ?? Util::generate_csrf_token();
// Set up request handlers
$dispatcher = new ActionDispatcher(); $dispatcher = new ActionDispatcher();
// GET actions // GET actions
$dispatcher->register_action(new StartSessionAction($user_manager)); $dispatcher->register_actions([
$dispatcher->register_action(new GetPublicUserDataAction($user_manager)); [ActionMethod::GET, "start-session", new StartSessionAction($user_manager)],
$dispatcher->register_action(new ListTrackingsAction($tracking_manager)); [ActionMethod::GET, "get-user-data", new GetPublicUserDataAction($user_manager)],
$dispatcher->register_action(new ValidatePasswordResetTokenAction($user_manager)); [ActionMethod::GET, "list-trackings", new ListTrackingsAction($tracking_manager)],
[ActionMethod::GET, "validate-password-reset-token", new ValidatePasswordResetTokenAction($user_manager)],
]);
// POST actions // POST actions
$dispatcher->register_action(new RegisterAction($db->conn, $user_manager, $mail_manager)); $dispatcher->register_actions([
$dispatcher->register_action(new LoginAction($user_manager)); [ActionMethod::POST, "register", new RegisterAction($db->conn, $user_manager, $mail_manager)],
$dispatcher->register_action(new LogoutAction()); [ActionMethod::POST, "login", new LoginAction($user_manager)],
$dispatcher->register_action(new ResendVerifyEmailAction($db->conn, $user_manager, $mail_manager)); [ActionMethod::POST, "logout", new LogoutAction()],
$dispatcher->register_action(new VerifyEmailAction($db->conn, $user_manager, $mail_manager)); [ActionMethod::POST, "user-delete", new UserDeleteAction($user_manager)],
$dispatcher->register_action(new ChangeEmailAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new ToggleNotificationsAction($db->conn, $user_manager)); [ActionMethod::POST, "update-email", new ChangeEmailAction($db->conn, $user_manager, $mail_manager)],
$dispatcher->register_action(new ChangePasswordAction($db->conn, $user_manager, $mail_manager)); [ActionMethod::POST, "verify-email", new VerifyEmailAction($db->conn, $user_manager, $mail_manager)],
$dispatcher->register_action(new SendPasswordResetAction($db->conn, $user_manager, $mail_manager)); [ActionMethod::POST, "resend-verify-email", new ResendVerifyEmailAction($db->conn, $user_manager, $mail_manager)],
$dispatcher->register_action(new ResetPasswordAction($db->conn, $user_manager, $mail_manager)); [ActionMethod::POST, "toggle-notifications", new ToggleNotificationsAction($db->conn, $user_manager)],
$dispatcher->register_action(new UserDeleteAction($user_manager));
$dispatcher->register_action(new AddTrackingAction($tracking_manager, $mediawiki)); [ActionMethod::POST, "update-password", new ChangePasswordAction($db->conn, $user_manager, $mail_manager)],
$dispatcher->register_action(new RemoveTrackingAction($tracking_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, "add-tracking", new AddTrackingAction($tracking_manager, $mediawiki)],
[ActionMethod::POST, "remove-tracking", new RemoveTrackingAction($tracking_manager)],
]);
// CLI actions // CLI actions
$cli_actions = [ $cli_actions = [
new UpdateTrackingsAction($db->conn, $tracking_manager, $mediawiki, $mail_manager), new UpdateTrackingsAction($db->conn, $tracking_manager, $mediawiki, $mail_manager),
new ProcessEmailQueueAction($mail_manager), new ProcessEmailQueueAction($mail_manager),
]; ];
$dispatcher->register_action($cli_actions[0]); $dispatcher->register_actions([
$dispatcher->register_action($cli_actions[1]); [ActionMethod::CLI, "update-trackings", $cli_actions[0]],
$dispatcher->register_action(new EmulateCronAction($cli_actions)); [ActionMethod::CLI, "process-email-queue", $cli_actions[1]],
// Dispatch [ActionMethod::CLI, "emulate-cron", new EmulateCronAction($cli_actions)],
if (isset($_GET["action"])) ]);
$response = $dispatcher->handle(ActionMethod::GET);
else if (isset($_POST["action"]))
$response = $dispatcher->handle(ActionMethod::POST); // Handle request
else if ($argc > 1) if (!isset($_SERVER["REQUEST_METHOD"]))
$response = $dispatcher->handle(ActionMethod::CLI); $response = $dispatcher->handle(ActionMethod::CLI, Util::parse_cli());
else if ($_SERVER["REQUEST_METHOD"] === "GET")
$response = $dispatcher->handle(ActionMethod::GET, $_GET);
else if ($_SERVER["REQUEST_METHOD"] === "POST")
$response = $dispatcher->handle(ActionMethod::POST, Util::parse_post());
else else
$response = Response::satisfied(); $response = Response::satisfied();
} catch (Exception $exception) { } catch (Throwable $exception) {
$response = Response::unsatisfied("An unexpected error occurred. Please try again later."); $response = Response::unsatisfied("An unhandled error occurred. Please try again later.");
$logger->error("An unexpected error occurred. Please try again later.", ["cause" => $exception]); $logger->error("An unhandled error occurred. Please try again later.", ["cause" => $exception]);
} }

View File

@ -577,11 +577,12 @@ doAfterLoad(() => {
addTrackingForm.addEventListener("submit", (event: SubmitEvent) => { addTrackingForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault(); event.preventDefault();
const inputName = $("#add-tracking-name").value;
postApi( postApi(
{ {
action: "add-tracking", action: "add-tracking",
token: csrfToken, token: csrfToken,
person_name: $("#add-tracking-name").value, person_name: inputName,
}, },
addTrackingForm, addTrackingForm,
(response: ServerResponse) => { (response: ServerResponse) => {
@ -590,12 +591,12 @@ doAfterLoad(() => {
showMessageSuccess( showMessageSuccess(
addTrackingForm, addTrackingForm,
response.payload["renamed"] inputName.toLowerCase() !== response.payload["normalized_name"].toLowerCase()
? ( ? (
`Successfully added <b>${response.payload["input"]}</b> as ` + `Successfully added <b>${inputName}</b> as ` +
`<b>${response.payload["name"]}</b>!` `<b>${response.payload["normalized_name"]}</b>!`
) )
: `Successfully added <b>${response.payload["name"]}</b>!` : `Successfully added <b>${response.payload["normalized_name"]}</b>!`
); );
} }
); );

View File

@ -6,7 +6,7 @@ use com\fwdekker\deathnotifier\validator\IsEqualToRule;
use com\fwdekker\deathnotifier\validator\IsNotSetRule; use com\fwdekker\deathnotifier\validator\IsNotSetRule;
use com\fwdekker\deathnotifier\validator\IsSetRule; use com\fwdekker\deathnotifier\validator\IsSetRule;
use com\fwdekker\deathnotifier\validator\Rule; use com\fwdekker\deathnotifier\validator\Rule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use InvalidArgumentException; use InvalidArgumentException;
@ -32,21 +32,10 @@ abstract class Action
*/ */
private readonly array $rule_lists; private readonly array $rule_lists;
/**
* @var ActionMethod the method that this action handles
*/
public readonly ActionMethod $method;
/**
* @var string the name of the action that this action handles
*/
public readonly string $name;
/** /**
* Constructs a new action. * Constructs a new action.
* *
* @param ActionMethod $method the method that this action handles
* @param string $name the name of the action that this action handles
* @param bool $require_logged_in `true` if and only if this action requires the user to be logged in * @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_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 * @param bool $require_valid_csrf_token `true` if and only if this action requires the request to have a valid CSRF
@ -54,9 +43,7 @@ abstract class Action
* @param array<string, Rule[]> $rule_lists maps input keys to {@see Rule}s that should be validated before this * @param array<string, Rule[]> $rule_lists maps input keys to {@see Rule}s that should be validated before this
* action is handled * action is handled
*/ */
public function __construct(ActionMethod $method, public function __construct(bool $require_logged_in = false,
string $name,
bool $require_logged_in = false,
bool $require_logged_out = false, bool $require_logged_out = false,
bool $require_valid_csrf_token = false, bool $require_valid_csrf_token = false,
array $rule_lists = []) array $rule_lists = [])
@ -64,9 +51,6 @@ abstract class Action
if ($require_logged_in && $require_logged_out) if ($require_logged_in && $require_logged_out)
throw new InvalidArgumentException("Cannot require that user is both logged in and logged out."); throw new InvalidArgumentException("Cannot require that user is both logged in and logged out.");
$this->method = $method;
$this->name = $name;
// TODO: Move authorisation-related validation to `dispatch` method? // TODO: Move authorisation-related validation to `dispatch` method?
$this->require_logged_in = $require_logged_in; $this->require_logged_in = $require_logged_in;
$this->require_logged_out = $require_logged_out; $this->require_logged_out = $require_logged_out;
@ -78,13 +62,12 @@ abstract class Action
/** /**
* Validates inputs according to `rule_lists`, throwing an exception if any input is invalid. * 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 * @return void if the input is valid
* @throws ValidationException if the input is invalid * @throws InvalidInputException if the input is invalid
*/ */
function validate_inputs(): void public function validate_inputs(array $inputs): void
{ {
$inputs = $this->method->get_inputs();
if ($this->require_logged_in) if ($this->require_logged_in)
(new IsSetRule("You must be logged in to perform this action."))->check($_SESSION, "uuid"); (new IsSetRule("You must be logged in to perform this action."))->check($_SESSION, "uuid");
if ($this->require_logged_out) if ($this->require_logged_out)
@ -101,9 +84,10 @@ abstract class Action
/** /**
* Performs the action. * Performs the action.
* *
* @param array<int|string, mixed> $inputs the inputs to perform the action with
* @return mixed the data requested by the action; may be `null` * @return mixed the data requested by the action; may be `null`
* @throws ActionException if the action could not be performed * @throws ActionException if the action could not be performed
* @throws ValidationException if the inputs are invalid upon further inspection * @throws InvalidInputException if the inputs are invalid upon further inspection
*/ */
abstract function handle(): mixed; abstract function handle(array $inputs): mixed;
} }

View File

@ -2,7 +2,7 @@
namespace com\fwdekker\deathnotifier; namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use InvalidArgumentException; use InvalidArgumentException;
@ -23,46 +23,61 @@ class ActionDispatcher
* Only one action can be registered given a combination of method and action name. An exception is thrown if * 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. * this restriction is violated, because that is likely to be a mistake.
* *
* @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 Action $action the action to register
* @return void * @return void
*/ */
public function register_action(Action $action): void public function register_action(ActionMethod $method, string $action_name, Action $action): void
{ {
$method = $action->method->name; $method_name = $method->name;
if (!isset($this->actions[$method])) if (!isset($this->actions[$method_name]))
$this->actions[$method] = []; $this->actions[$method_name] = [];
if (isset($this->actions[$method][$action->name])) if (isset($this->actions[$method_name][$action_name]))
// TODO: Throw more specific exceptions(?) throw new IllegalArgumentError("Cannot register another handler for $method_name action '$action_name'.");
throw new InvalidArgumentException("Cannot register another handler for $method action '$action->name'.");
$this->actions[$method][$action->name] = $action; $this->actions[$method_name][$action_name] = $action;
}
/**
* Utility method that invokes `#register_action` for each of the given `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
* @return void
*/
public function register_actions(array $actions): void
{
foreach ($actions as $action)
$this->register_action($action[0], $action[1], $action[2]);
} }
/** /**
* Executes the registered action for the given pair of method and action name. * Executes the registered action for the given pair of method and action name.
* *
* @param ActionMethod $method the method of the action to execute * @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 * @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 * unsatisfied response with the exception's message and target
*/ */
public function handle(ActionMethod $method): Response public function handle(ActionMethod $method, array $inputs): Response
{ {
$inputs = $method->get_inputs();
if (!isset($inputs["action"])) if (!isset($inputs["action"]))
throw new InvalidArgumentException("No action specified."); throw new InvalidArgumentException("Malformed request: No action specified.");
$method_name = $method->name;
$action_name = $inputs["action"]; $action_name = $inputs["action"];
if (!isset($this->actions[$method->name]) || !isset($this->actions[$method->name][$action_name])) if (!isset($this->actions[$method_name]) || !isset($this->actions[$method_name][$action_name]))
throw new InvalidArgumentException("No handler for $method->name action '$action_name'."); throw new InvalidArgumentException("Malformed request: Unknown $method_name action '$action_name'.");
$action = $this->actions[$method->name][$action_name]; $action = $this->actions[$method_name][$action_name];
try { try {
$action->validate_inputs(); $action->validate_inputs($inputs);
$payload = $action->handle(); $payload = $action->handle($inputs);
return Response::satisfied($payload); return Response::satisfied($payload);
} catch (ActionException|ValidationException $exception) { } catch (ActionException|InvalidInputException $exception) {
return Response::unsatisfied($exception->getMessage(), $exception->target); return Response::unsatisfied($exception->getMessage(), $exception->target);
} }
} }

View File

@ -20,19 +20,4 @@ enum ActionMethod
* HTTP POST request. * HTTP POST request.
*/ */
case POST; case POST;
/**
* Returns the user's inputs corresponding to this request method.
*
* @return array<int|string, mixed> the user's inputs corresponding to this request method
*/
function get_inputs(): array
{
return match ($this) {
ActionMethod::CLI => Util::parse_cli(),
ActionMethod::GET => $_GET,
ActionMethod::POST => $_POST,
};
}
} }

View File

@ -10,6 +10,7 @@ use InvalidArgumentException;
* *
* Contains global state, but that's fine since it's read-only. * Contains global state, but that's fine since it's read-only.
*/ */
// TODO: Override dynamically from tests
class Config class Config
{ {
/** /**

View File

@ -63,7 +63,7 @@ class Database
if ($stmt->fetch()[0] !== 0) if ($stmt->fetch()[0] !== 0)
return; return;
$this->logger->info("Database does not exist. Installing new database."); $this->logger->notice("Database does not exist. Installing new database.");
// Create `meta` table // Create `meta` table
$this->conn->exec("CREATE TABLE meta(k TEXT NOT NULL UNIQUE PRIMARY KEY, v TEXT);"); $this->conn->exec("CREATE TABLE meta(k TEXT NOT NULL UNIQUE PRIMARY KEY, v TEXT);");
@ -76,7 +76,7 @@ class Database
$user_manager->install(); $user_manager->install();
$tracking_manager->install(); $tracking_manager->install();
$this->logger->info("Installation complete."); $this->logger->notice("Installation complete.");
}); });
} }
@ -95,7 +95,7 @@ class Database
if (Comparator::greaterThanOrEqualTo($db_version, Database::LATEST_VERSION)) if (Comparator::greaterThanOrEqualTo($db_version, Database::LATEST_VERSION))
return; return;
$this->logger->info("Current db is v$db_version. Will migrate to v" . Database::LATEST_VERSION . "."); $this->logger->notice("Current db is v$db_version. Will migrate to v" . Database::LATEST_VERSION . ".");
// Get current version // Get current version
$stmt = $this->conn->prepare("SELECT v FROM meta WHERE k='version';"); $stmt = $this->conn->prepare("SELECT v FROM meta WHERE k='version';");
@ -113,7 +113,7 @@ class Database
$stmt->bindValue(":version", Database::LATEST_VERSION); $stmt->bindValue(":version", Database::LATEST_VERSION);
$stmt->execute(); $stmt->execute();
$this->logger->info("Completed migration to v" . Database::LATEST_VERSION . "."); $this->logger->notice("Completed migration to v" . Database::LATEST_VERSION . ".");
}); });
} }
@ -125,7 +125,7 @@ class Database
*/ */
private function migrate_0_5_0(): void private function migrate_0_5_0(): void
{ {
$this->logger->info("Migrating to v0.5.0."); $this->logger->notice("Migrating to v0.5.0.");
$this->conn->exec("ALTER TABLE users $this->conn->exec("ALTER TABLE users
ADD COLUMN email_notifications_enabled INT NOT NULL DEFAULT(1);"); ADD COLUMN email_notifications_enabled INT NOT NULL DEFAULT(1);");
@ -139,7 +139,7 @@ class Database
*/ */
private function migrate_0_8_0(): void private function migrate_0_8_0(): void
{ {
$this->logger->info("Migrating to v0.8.0."); $this->logger->notice("Migrating to v0.8.0.");
$this->conn->exec("CREATE TABLE new_email_tasks(type TEXT NOT NULL, $this->conn->exec("CREATE TABLE new_email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL, recipient TEXT NOT NULL,
@ -160,7 +160,7 @@ class Database
*/ */
private function migrate_0_10_0(): void private function migrate_0_10_0(): void
{ {
$this->logger->info("Migrating to v0.10.0."); $this->logger->notice("Migrating to v0.10.0.");
$this->conn->exec("CREATE TABLE new_email_tasks(type TEXT NOT NULL, $this->conn->exec("CREATE TABLE new_email_tasks(type TEXT NOT NULL,
recipient TEXT NOT NULL, recipient TEXT NOT NULL,
@ -185,7 +185,7 @@ class Database
*/ */
private function migrate_0_16_0(): void private function migrate_0_16_0(): void
{ {
$this->logger->info("Migrating to v0.16.0."); $this->logger->notice("Migrating to v0.16.0.");
$this->conn->exec("DROP TABLE email_tasks;"); $this->conn->exec("DROP TABLE email_tasks;");
$this->conn->exec("CREATE TABLE email_tasks(type_key TEXT NOT NULL, $this->conn->exec("CREATE TABLE email_tasks(type_key TEXT NOT NULL,

View File

@ -2,7 +2,7 @@
namespace com\fwdekker\deathnotifier; namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
/** /**
@ -30,7 +30,7 @@ class EmulateCronAction extends Action
*/ */
public function __construct(array $actions) public function __construct(array $actions)
{ {
parent::__construct(ActionMethod::CLI, "emulate-cron"); parent::__construct();
$this->actions = $actions; $this->actions = $actions;
} }
@ -38,27 +38,29 @@ class EmulateCronAction extends Action
/** /**
* Validates the inputs of each registered action. * Validates the inputs of each registered action.
* *
* @param array<int|string, mixed> $inputs the inputs to validate
* @return void if the input is valid * @return void if the input is valid
* @throws ValidationException if the input is invalid * @throws InvalidInputException if the input is invalid
*/ */
public function validate_inputs(): void public function validate_inputs(array $inputs): void
{ {
foreach ($this->actions as $action) foreach ($this->actions as $action)
$action->validate_inputs(); $action->validate_inputs($inputs);
} }
/** /**
* Updates all trackings and processes the mail queue at a regular interval. * Updates all trackings and processes the mail queue at a regular interval.
* *
* @param array<int|string, mixed> $inputs ignored
* @return never * @return never
*/ */
public function handle(): never public function handle(array $inputs): never
{ {
// @phpstan-ignore-next-line // @phpstan-ignore-next-line
while (true) { while (true) {
print("Emulating cron jobs.\n"); print("Emulating cron jobs.\n");
foreach ($this->actions as $action) foreach ($this->actions as $action)
$action->handle(); $action->handle($inputs);
print("Done.\n"); print("Done.\n");
sleep(self::INTERVAL); sleep(self::INTERVAL);

View File

@ -0,0 +1,16 @@
<?php
namespace com\fwdekker\deathnotifier;
use Error;
/**
* Thrown if a function receives an argument which it cannot handle.
*
* This is an error, so it always indicates a bug in the program.
*/
class IllegalArgumentError extends Error
{
// Intentionally left empty
}

View File

@ -6,9 +6,11 @@ use Error;
/** /**
* Thrown if something happens that should not be able to happen, and there is no way to recover. * Thrown if something happens that should not be able to happen.
*
* This is an error, so it always indicates a bug in the program.
*/ */
class IllegalStateException extends Error class IllegalStateError extends Error
{ {
// Intentionally left empty // Intentionally left empty
} }

View File

@ -0,0 +1,16 @@
<?php
namespace com\fwdekker\deathnotifier;
use Exception;
/**
* Thrown to indicate that a request to the server was malformed in some way.
*
* This is an exception, not an error, so it indicates that the client that sent the request did something wrong.
*/
class MalformedRequestException extends Exception
{
// Intentionally left empty
}

View File

@ -24,7 +24,7 @@ class StartSessionAction extends Action
*/ */
public function __construct(UserManager $user_manager) public function __construct(UserManager $user_manager)
{ {
parent::__construct(ActionMethod::GET, "start-session"); parent::__construct();
$this->user_manager = $user_manager; $this->user_manager = $user_manager;
} }
@ -33,11 +33,12 @@ class StartSessionAction extends Action
/** /**
* Starts a new user session, or continues an existing one. * Starts a new user session, or continues an existing one.
* *
* @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 * @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 * displayed at the top of the page, if any
* @throws ActionException if no CSRF token could be generated * @throws ActionException if no CSRF token could be generated
*/ */
function handle(): array function handle(array $inputs): array
{ {
$config = Config::get(); $config = Config::get();
$payload = []; $payload = [];

View File

@ -2,7 +2,9 @@
namespace com\fwdekker\deathnotifier; namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use Exception; use Exception;
use JsonException;
/** /**
@ -13,17 +15,24 @@ class Util
/** /**
* Parses POST values from JSON-based inputs. * Parses POST values from JSON-based inputs.
* *
* @return array<int|string, mixed>|null the parsed POST-ed values * @return array<int|string, mixed> the parsed POSTed values, or an empty array if no values were POSTed
* @throws InvalidInputException if there are no POST input values, or if the POST input is not valid JSON
*/ */
static function parse_post(): ?array static function parse_post(): array
{ {
$output = $_POST;
$post_input = file_get_contents("php://input"); $post_input = file_get_contents("php://input");
if ($post_input !== false) if ($post_input === false || trim($post_input) === "")
$output = json_decode($post_input, associative: true); return [];
return $output; try {
$post = json_decode($post_input, associative: true, flags: JSON_THROW_ON_ERROR);
if ($post === null)
throw new InvalidInputException("Malformed request: POST data is `null`.");
return $post;
} catch (JsonException) {
throw new InvalidInputException("Malformed request: POST data could not be parsed as JSON.");
}
} }
/** /**
@ -31,12 +40,13 @@ class Util
* *
* Code from https://www.php.net/manual/en/features.commandline.php#108883. * Code from https://www.php.net/manual/en/features.commandline.php#108883.
* *
* @return array<int|string, mixed> the parsed CLI inputs * @return array<int|string, mixed> the parsed CLI inputs, or an empty array if no values were passed on the CLI
*/ */
static function parse_cli(): array static function parse_cli(): array
{ {
parse_str(implode("&", array_slice($_SERVER["argv"], 1)), $output); if (!isset($_SERVER["argc"]) || $_SERVER["argc"] <= 1) return [];
parse_str(implode("&", array_slice($_SERVER["argv"], 1)), $output);
return $output; return $output;
} }

View File

@ -4,7 +4,6 @@ namespace com\fwdekker\deathnotifier\mailer;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\LoggerUtil; use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\validator\IsEqualToRule; use com\fwdekker\deathnotifier\validator\IsEqualToRule;
@ -37,8 +36,6 @@ class ProcessEmailQueueAction extends Action
public function __construct(MailManager $mail_manager) public function __construct(MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::CLI,
"process-email-queue",
rule_lists: [ rule_lists: [
"password" => [new IsEqualToRule(Config::get()["admin"]["cli_secret"], "Incorrect password.")] "password" => [new IsEqualToRule(Config::get()["admin"]["cli_secret"], "Incorrect password.")]
], ],
@ -52,10 +49,11 @@ class ProcessEmailQueueAction extends Action
/** /**
* Processes the queue. * Processes the queue.
* *
* @param array<int|string, mixed> $inputs ignored
* @return null * @return null
* @throws ActionException if the mailer could not be created or if an email could not be sent * @throws ActionException if the mailer could not be created or if an email could not be sent
*/ */
public function handle(): mixed public function handle(array $inputs): mixed
{ {
$emails = $this->mail_manager->get_queue(); $emails = $this->mail_manager->get_queue();

View File

@ -4,7 +4,7 @@ namespace com\fwdekker\deathnotifier\mediawiki;
use com\fwdekker\deathnotifier\LoggerUtil; use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\validator\Rule; use com\fwdekker\deathnotifier\validator\Rule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use Monolog\Logger; use Monolog\Logger;
@ -45,18 +45,18 @@ class IsPersonPageRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @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 * @return void if `$inputs[$key]` refers to a page about a person on Wikipedia
* @throws ValidationException if `$inputs[$key]` is not set or does not refer 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 public function check(array $inputs, string $key): void
{ {
if (!isset($inputs[$key])) throw new ValidationException($this->override_message ?? "Field must be set.", $key); if (!isset($inputs[$key])) throw new InvalidInputException($this->override_message ?? "Field must be set.", $key);
$person_name = $inputs[$key]; $person_name = $inputs[$key];
try { try {
$info = $this->mediawiki->query_person_info([$person_name]); $info = $this->mediawiki->query_person_info([$person_name]);
} catch (MediaWikiException $exception) { } catch (MediaWikiException $exception) {
$this->logger->error("Failed to query page info.", ["cause" => $exception, "name" => $person_name]); $this->logger->error("Failed to query page info.", ["cause" => $exception, "name" => $person_name]);
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? "Could not reach Wikipedia. Maybe the website is down?" $this->override_message ?? "Could not reach Wikipedia. Maybe the website is down?"
); );
} }
@ -65,7 +65,7 @@ class IsPersonPageRule extends Rule
$type = $info->results[$normalized_name]["type"]; $type = $info->results[$normalized_name]["type"];
if (in_array($normalized_name, $info->missing)) if (in_array($normalized_name, $info->missing))
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? $this->override_message ??
"Wikipedia does not have an article about " . "Wikipedia does not have an article about " .
"<b><a href='https://en.wikipedia.org/wiki/Special:Search?search=" . "<b><a href='https://en.wikipedia.org/wiki/Special:Search?search=" .
@ -75,7 +75,7 @@ class IsPersonPageRule extends Rule
); );
if ($type === ArticleType::Disambiguation) if ($type === ArticleType::Disambiguation)
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? $this->override_message ??
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" . "<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .
htmlentities($normalized_name) . "</a></b> refers to multiple articles. " . htmlentities($normalized_name) . "</a></b> refers to multiple articles. " .
@ -85,7 +85,7 @@ class IsPersonPageRule extends Rule
); );
if ($type === ArticleType::Other) if ($type === ArticleType::Other)
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? $this->override_message ??
"The Wikipedia article about " . "The Wikipedia article about " .
"<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" . "<b><a href='https://en.wikipedia.org/wiki/" . rawurlencode($normalized_name) . "'>" .

View File

@ -4,15 +4,14 @@ namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod; use com\fwdekker\deathnotifier\IllegalStateError;
use com\fwdekker\deathnotifier\IllegalStateException;
use com\fwdekker\deathnotifier\LoggerUtil; use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\mediawiki\IsPersonPageRule; use com\fwdekker\deathnotifier\mediawiki\IsPersonPageRule;
use com\fwdekker\deathnotifier\mediawiki\MediaWiki; use com\fwdekker\deathnotifier\mediawiki\MediaWiki;
use com\fwdekker\deathnotifier\mediawiki\MediaWikiException; use com\fwdekker\deathnotifier\mediawiki\MediaWikiException;
use com\fwdekker\deathnotifier\validator\HasLengthRule; use com\fwdekker\deathnotifier\validator\HasLengthRule;
use com\fwdekker\deathnotifier\validator\IsNotBlankRule; use com\fwdekker\deathnotifier\validator\IsNotBlankRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use Monolog\Logger; use Monolog\Logger;
@ -44,8 +43,6 @@ class AddTrackingAction extends Action
public function __construct(TrackingManager $tracking_manager, MediaWiki $mediawiki) public function __construct(TrackingManager $tracking_manager, MediaWiki $mediawiki)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"add-tracking",
require_logged_in: true, require_logged_in: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: [ rule_lists: [
@ -64,16 +61,18 @@ class AddTrackingAction extends Action
/** /**
* Adds a tracking by the current user of `$_POST["person_name"]`. * Adds a tracking by the current user of the specified person.
* *
* @return array{"name": string, "input": string, "renamed": bool} the * @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
* normalized version of that name
* @throws ActionException if the Wikipedia API could not be reached * @throws ActionException if the Wikipedia API could not be reached
* @throws ValidationException if the user is already tracking this person * @throws InvalidInputException if the user is already tracking this person
*/ */
function handle(): array public function handle(array $inputs): array
{ {
$user_uuid = $_SESSION["uuid"]; $user_uuid = $_SESSION["uuid"];
$person_name = strval($_POST["person_name"]); $person_name = strval($inputs["person_name"]);
// Query API // Query API
try { try {
@ -86,19 +85,18 @@ class AddTrackingAction extends Action
$normalized_name = $info->redirects[$person_name]; $normalized_name = $info->redirects[$person_name];
$status = $info->results[$normalized_name]["status"]; $status = $info->results[$normalized_name]["status"];
if ($status === null) if ($status === null)
throw new IllegalStateException("Article is about person, but person has no status."); throw new IllegalStateError("Article is about person, but person has no status.");
// Add tracking (Transaction is not necessary) // Add tracking (Transaction is not necessary)
if ($this->tracking_manager->has_tracking($user_uuid, $normalized_name)) if ($this->tracking_manager->has_tracking($user_uuid, $normalized_name))
throw new ValidationException("You are already tracking <b>$normalized_name</b>."); throw new InvalidInputException("You are already tracking <b>$normalized_name</b>.");
$this->tracking_manager->add_tracking($user_uuid, $normalized_name, $status); $this->tracking_manager->add_tracking($user_uuid, $normalized_name, $status);
// Respond // Respond
return [ return [
"name" => $normalized_name, "input_name" => $person_name,
"input" => $person_name, "normalized_name" => $normalized_name,
"renamed" => strtolower($person_name) !== strtolower($normalized_name)
]; ];
} }
} }

View File

@ -3,7 +3,6 @@
namespace com\fwdekker\deathnotifier\tracking; namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
/** /**
@ -25,8 +24,6 @@ class ListTrackingsAction extends Action
public function __construct(TrackingManager $tracking_manager) public function __construct(TrackingManager $tracking_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::GET,
"list-trackings",
require_logged_in: true, require_logged_in: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
); );
@ -38,9 +35,10 @@ class ListTrackingsAction extends Action
/** /**
* Returns all trackings of the current user. * Returns all trackings of the current user.
* *
* @param array<int|string, mixed> $inputs ignored
* @return mixed[] all trackings of the current user * @return mixed[] all trackings of the current user
*/ */
function handle(): array public function handle(array $inputs): array
{ {
return $this->tracking_manager->list_trackings($_SESSION["uuid"]); return $this->tracking_manager->list_trackings($_SESSION["uuid"]);
} }

View File

@ -3,7 +3,6 @@
namespace com\fwdekker\deathnotifier\tracking; namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\validator\IsNotBlankRule; use com\fwdekker\deathnotifier\validator\IsNotBlankRule;
@ -26,8 +25,6 @@ class RemoveTrackingAction extends Action
public function __construct(TrackingManager $tracking_manager) public function __construct(TrackingManager $tracking_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"remove-tracking",
require_logged_in: true, require_logged_in: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: ["person_name" => [new IsNotBlankRule()]], rule_lists: ["person_name" => [new IsNotBlankRule()]],
@ -38,13 +35,14 @@ class RemoveTrackingAction extends Action
/** /**
* Removes the current user's tracking of `$_POST["person_name"]`. * Removes the current user's tracking of the specified person.
* *
* @param array<int|string, mixed> $inputs `"person_name": string`: the name of the person to stop tracking
* @return null * @return null
*/ */
function handle(): mixed public function handle(array $inputs): mixed
{ {
$this->tracking_manager->remove_tracking($_SESSION["uuid"], $_POST["person_name"]); $this->tracking_manager->remove_tracking($_SESSION["uuid"], $inputs["person_name"]);
return null; return null;
} }

View File

@ -4,7 +4,6 @@ namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\LoggerUtil; use com\fwdekker\deathnotifier\LoggerUtil;
@ -55,8 +54,6 @@ class UpdateTrackingsAction extends Action
public function __construct(PDO $conn, TrackingManager $tracking_manager, MediaWiki $mediawiki, MailManager $mailer) public function __construct(PDO $conn, TrackingManager $tracking_manager, MediaWiki $mediawiki, MailManager $mailer)
{ {
parent::__construct( parent::__construct(
ActionMethod::CLI,
"update-trackings",
rule_lists: [ rule_lists: [
"password" => [new IsEqualToRule(Config::get()["admin"]["cli_secret"], "Incorrect password.")] "password" => [new IsEqualToRule(Config::get()["admin"]["cli_secret"], "Incorrect password.")]
], ],
@ -73,10 +70,11 @@ class UpdateTrackingsAction extends Action
/** /**
* Updates all trackings that users have added. * Updates all trackings that users have added.
* *
* @param array<int|string, mixed> $inputs ignored
* @return null * @return null
* @throws ActionException if the Wikipedia API could not be reached * @throws ActionException if the Wikipedia API could not be reached
*/ */
public function handle(): mixed public function handle(array $inputs): mixed
{ {
$names = $this->tracking_manager->list_all_unique_person_names(); $names = $this->tracking_manager->list_all_unique_person_names();
if (empty($names)) return null; if (empty($names)) return null;

View File

@ -4,13 +4,12 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\Email; use com\fwdekker\deathnotifier\mailer\Email;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO; use PDO;
@ -43,8 +42,6 @@ class ChangeEmailAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"update-email",
require_logged_in: true, require_logged_in: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: ["email" => [new IsEmailRule()]], rule_lists: ["email" => [new IsEmailRule()]],
@ -59,23 +56,24 @@ class ChangeEmailAction extends Action
/** /**
* Changes the user's email address. * Changes the user's email address.
* *
* @param array<int|string, mixed> $inputs `"email": string`: the email address to change to
* @return null * @return null
* @throws ValidationException if the email address is not different or the email address is already used * @throws InvalidInputException if the email address is not different or the email address is already used
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function () { Database::transaction($this->conn, function () use ($inputs) {
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
if ($user_data === null) if ($user_data === null)
throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
if ($_POST["email"] === $user_data["email"]) if ($inputs["email"] === $user_data["email"])
throw new ValidationException("That is already your email address.", "email"); throw new InvalidInputException("That is already your email address.", "email");
if ($this->user_manager->has_user_with_email($_POST["email"])) if ($this->user_manager->has_user_with_email($inputs["email"]))
throw new ValidationException("That email address is already in use by someone else.", "email"); throw new InvalidInputException("That email address is already in use by someone else.", "email");
$token = $this->user_manager->set_email($_SESSION["uuid"], $_POST["email"]); $token = $this->user_manager->set_email($_SESSION["uuid"], $inputs["email"]);
// TODO: Also send email to old email address // TODO: Also send email to old email address
$this->mail_manager->queue_email(new ChangeEmailEmail($_POST["email"], $token)); $this->mail_manager->queue_email(new ChangeEmailEmail($inputs["email"], $token));
}); });
return null; return null;

View File

@ -4,14 +4,13 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\Email; use com\fwdekker\deathnotifier\mailer\Email;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\validator\HasLengthRule; use com\fwdekker\deathnotifier\validator\HasLengthRule;
use com\fwdekker\deathnotifier\validator\IsSetRule; use com\fwdekker\deathnotifier\validator\IsSetRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO; use PDO;
@ -44,8 +43,6 @@ class ChangePasswordAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"update-password",
require_logged_in: true, require_logged_in: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: [ rule_lists: [
@ -65,19 +62,21 @@ class ChangePasswordAction extends Action
/** /**
* Changes the user's password. * Changes the user's password.
* *
* @param array<int|string, mixed> $inputs `"password_old": string`: the old password, `"password_new": string`: the
* new password
* @return null * @return null
* @throws ValidationException if the old password is incorrect * @throws InvalidInputException if the old password is incorrect
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function () { Database::transaction($this->conn, function () use ($inputs) {
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
if ($user_data === null) if ($user_data === null)
throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
if (!password_verify($user_data["password"], $_POST["password_old"])) if (!password_verify($user_data["password"], $inputs["password_old"]))
throw new ValidationException("Incorrect old password.", "password_old"); throw new InvalidInputException("Incorrect old password.", "password_old");
$this->user_manager->set_password($_SESSION["uuid"], $_POST["password_new"]); $this->user_manager->set_password($_SESSION["uuid"], $inputs["password_new"]);
$this->mail_manager->queue_email(new ChangePasswordEmail($user_data["email"])); $this->mail_manager->queue_email(new ChangePasswordEmail($user_data["email"]));
}); });

View File

@ -4,7 +4,6 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
/** /**
@ -25,12 +24,7 @@ class GetPublicUserDataAction extends Action
*/ */
public function __construct(UserManager $user_manager) public function __construct(UserManager $user_manager)
{ {
parent::__construct( parent::__construct(require_logged_in: true, require_valid_csrf_token: true);
ActionMethod::GET,
"get-user-data",
require_logged_in: true,
require_valid_csrf_token: true,
);
$this->user_manager = $user_manager; $this->user_manager = $user_manager;
} }
@ -39,11 +33,12 @@ class GetPublicUserDataAction extends Action
/** /**
* Returns the user's public data. * Returns the user's public data.
* *
* @param array<int|string, mixed> $inputs ignored
* @return array{"email": string, "email_verified": bool, "email_notifications_enabled": bool, * @return array{"email": string, "email_verified": bool, "email_notifications_enabled": bool,
* "password_last_change": int} the user's public data * "password_last_change": int} the user's public data
* @throws ActionException if the user's data could not be retrieved * @throws ActionException if the user's data could not be retrieved
*/ */
function handle(): array function handle(array $inputs): array
{ {
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
if ($user_data === null) if ($user_data === null)

View File

@ -4,8 +4,8 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\IsSetRule;
/** /**
@ -27,11 +27,12 @@ class LoginAction extends Action
public function __construct(UserManager $user_manager) public function __construct(UserManager $user_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"login",
require_logged_out: true, require_logged_out: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: ["email" => [new IsEmailRule()]], rule_lists: [
"email" => [new IsEmailRule()],
"password" => [new IsSetRule()],
],
); );
$this->user_manager = $user_manager; $this->user_manager = $user_manager;
@ -41,15 +42,17 @@ class LoginAction extends Action
/** /**
* Logs in the user. * Logs in the user.
* *
* @param array<int|string, mixed> $inputs `"email": string`: the email to log in with, `"password": string`: the
* password to log in with
* @return null * @return null
* @throws ActionException if the user's data could not be retrieved or if the given credentials are incorrect * @throws ActionException if the user's data could not be retrieved or if the given credentials are incorrect
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
$user_data = $this->user_manager->get_user_by_email($_POST["email"]); $user_data = $this->user_manager->get_user_by_email($inputs["email"]);
if ($user_data === null) if ($user_data === null)
throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); throw new ActionException("Incorrect combination of email and password.", "password");
if (!password_verify($_POST["password"], $user_data["password"])) if (!password_verify($inputs["password"], $user_data["password"]))
throw new ActionException("Incorrect combination of email and password.", "password"); throw new ActionException("Incorrect combination of email and password.", "password");
$_SESSION["uuid"] = $user_data["uuid"]; $_SESSION["uuid"] = $user_data["uuid"];

View File

@ -4,7 +4,6 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Util; use com\fwdekker\deathnotifier\Util;
use Exception; use Exception;
@ -19,22 +18,18 @@ class LogoutAction extends Action
*/ */
public function __construct() public function __construct()
{ {
parent::__construct( parent::__construct(require_logged_in: true, require_valid_csrf_token: true);
ActionMethod::POST,
"logout",
require_logged_in: true,
require_valid_csrf_token: true,
);
} }
/** /**
* Terminates the current user session. * Terminates the current user session.
* *
* @param array<int|string, mixed> $inputs ignored
* @return null * @return null
* @throws ActionException if no CSRF token could be generated * @throws ActionException if no CSRF token could be generated
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
session_destroy(); session_destroy();
session_start(); session_start();

View File

@ -4,14 +4,13 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\Email; use com\fwdekker\deathnotifier\mailer\Email;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\validator\HasLengthRule; use com\fwdekker\deathnotifier\validator\HasLengthRule;
use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO; use PDO;
@ -44,8 +43,6 @@ class RegisterAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"register",
require_logged_out: true, require_logged_out: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: [ rule_lists: [
@ -63,22 +60,24 @@ class RegisterAction extends Action
/** /**
* Registers a new user. * 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
* @return null * @return null
* @throws ValidationException if the email address is already used * @throws InvalidInputException if the email address is already used
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function () { Database::transaction($this->conn, function () use ($inputs) {
if ($this->user_manager->has_user_with_email($_POST["email"])) if ($this->user_manager->has_user_with_email($inputs["email"]))
throw new ValidationException("That email address already in use by someone else.", "email"); throw new InvalidInputException("That email address already in use by someone else.", "email");
$uuid = $this->user_manager->add_user($_POST["email"], $_POST["password"]); $uuid = $this->user_manager->add_user($inputs["email"], $inputs["password"]);
$user_data = $this->user_manager->get_user_by_uuid($uuid); $user_data = $this->user_manager->get_user_by_uuid($uuid);
if ($user_data === null) if ($user_data === null)
throw new ActionException("Failed to retrieve account data. Refresh the page and try again."); throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
$token = $user_data["email_verification_token"]; $token = $user_data["email_verification_token"];
$this->mail_manager->queue_email(new RegisterEmail($_POST["email"], $token)); $this->mail_manager->queue_email(new RegisterEmail($inputs["email"], $token));
}); });
return null; return null;

View File

@ -3,13 +3,12 @@
namespace com\fwdekker\deathnotifier\user; namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\Email; use com\fwdekker\deathnotifier\mailer\Email;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\Util; use com\fwdekker\deathnotifier\Util;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO; use PDO;
@ -41,12 +40,7 @@ class ResendVerifyEmailAction extends Action
*/ */
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(require_logged_in: true, require_valid_csrf_token: true);
ActionMethod::POST,
"resend-verify-email",
require_logged_in: true,
require_valid_csrf_token: true,
);
$this->conn = $conn; $this->conn = $conn;
$this->user_manager = $user_manager; $this->user_manager = $user_manager;
@ -57,23 +51,24 @@ class ResendVerifyEmailAction extends Action
/** /**
* Resets the email verification process and sends a new verification email. * Resets the email verification process and sends a new verification email.
* *
* @param array<int|string, mixed> $inputs ignored
* @return null * @return null
* @throws ValidationException if the email address is already verified or if a verification email was sent too * @throws InvalidInputException if the email address is already verified or if a verification email was sent too
* recently * recently
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function () { Database::transaction($this->conn, function () use ($inputs) {
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
if ($user_data["email_verification_token"] === null) if ($user_data["email_verification_token"] === null)
throw new ValidationException("Your email address is already verified."); throw new InvalidInputException("Your email address is already verified.");
$minutes_left = Util::minutes_until_interval_elapsed( $minutes_left = Util::minutes_until_interval_elapsed(
$user_data["email_verification_token_timestamp"], $user_data["email_verification_token_timestamp"],
UserManager::MINUTES_BETWEEN_VERIFICATION_EMAILS UserManager::MINUTES_BETWEEN_VERIFICATION_EMAILS
); );
if ($minutes_left > 0) { if ($minutes_left > 0) {
throw new ValidationException( throw new InvalidInputException(
"A verification email was sent recently. " . "A verification email was sent recently. " .
"Please wait $minutes_left more minute(s) before requesting a new email." "Please wait $minutes_left more minute(s) before requesting a new email."
); );

View File

@ -3,14 +3,14 @@
namespace com\fwdekker\deathnotifier\user; namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\Email; use com\fwdekker\deathnotifier\mailer\Email;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\validator\HasLengthRule; use com\fwdekker\deathnotifier\validator\HasLengthRule;
use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use com\fwdekker\deathnotifier\validator\IsSetRule;
use PDO; use PDO;
@ -43,12 +43,11 @@ class ResetPasswordAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"reset-password",
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: [ rule_lists: [
"email" => [new IsEmailRule()], "email" => [new IsEmailRule()],
"password" => [new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)], "password" => [new HasLengthRule(UserManager::MIN_PASSWORD_LENGTH, UserManager::MAX_PASSWORD_LENGTH)],
"reset_token" => [new IsSetRule()],
], ],
); );
@ -61,19 +60,21 @@ class ResetPasswordAction extends Action
/** /**
* Resets the user's password after they forgot it. * 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
* @return null * @return null
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function () { Database::transaction($this->conn, function () use ($inputs) {
$user_data = $this->user_manager->get_user_by_email($_POST["email"]); $user_data = $this->user_manager->get_user_by_email($inputs["email"]);
if ($_GET["reset_token"] !== $user_data["password_reset_token"]) if ($inputs["reset_token"] !== $user_data["password_reset_token"])
throw new ValidationException( throw new InvalidInputException(
"This password reset link is invalid. Maybe you already reset your password?" "This password reset link is invalid. Maybe you already reset your password?"
); );
$this->user_manager->set_password($user_data["uuid"], $_POST["password"]); $this->user_manager->set_password($user_data["uuid"], $inputs["password"]);
$this->mail_manager->queue_email(new ResetPasswordEmail($_POST["email"])); $this->mail_manager->queue_email(new ResetPasswordEmail($inputs["email"]));
}); });
return null; return null;

View File

@ -3,14 +3,13 @@
namespace com\fwdekker\deathnotifier\user; namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config; use com\fwdekker\deathnotifier\Config;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\Email; use com\fwdekker\deathnotifier\mailer\Email;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\Util; use com\fwdekker\deathnotifier\Util;
use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO; use PDO;
@ -43,8 +42,6 @@ class SendPasswordResetAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"send-password-reset",
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: ["email" => [new IsEmailRule()]], rule_lists: ["email" => [new IsEmailRule()]],
); );
@ -58,27 +55,29 @@ class SendPasswordResetAction extends Action
/** /**
* Sends a password reset email. * 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
* @return null * @return null
* @throws ValidationException if another password reset email was sent too recently * @throws InvalidInputException if another password reset email was sent too recently
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function () { Database::transaction($this->conn, function () use ($inputs) {
$user_data = $this->user_manager->get_user_by_email($_POST["email"]); $user_data = $this->user_manager->get_user_by_email($inputs["email"]);
$minutes_left = Util::minutes_until_interval_elapsed( $minutes_left = Util::minutes_until_interval_elapsed(
$user_data["password_reset_token_timestamp"], $user_data["password_reset_token_timestamp"],
UserManager::MINUTES_BETWEEN_PASSWORD_RESETS UserManager::MINUTES_BETWEEN_PASSWORD_RESETS
); );
if ($minutes_left > 0) { if ($minutes_left > 0) {
throw new ValidationException( throw new InvalidInputException(
"A password reset email was sent recently. " . "A password reset email was sent recently. " .
"Please wait $minutes_left more minute(s) before requesting a new email." "Please wait $minutes_left more minute(s) before requesting a new email."
); );
} }
$token = $this->user_manager->register_password_reset($_POST["email"]); $token = $this->user_manager->register_password_reset($inputs["email"]);
$this->mail_manager->queue_email(new SendPasswordResetEmail($_POST["email"], $token)); $this->mail_manager->queue_email(new SendPasswordResetEmail($inputs["email"], $token));
}); });
return null; return null;

View File

@ -3,10 +3,9 @@
namespace com\fwdekker\deathnotifier\user; namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\validator\IsSetRule; use com\fwdekker\deathnotifier\validator\IsSetRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO; use PDO;
@ -34,13 +33,12 @@ class ToggleNotificationsAction extends Action
public function __construct(PDO $conn, UserManager $user_manager) public function __construct(PDO $conn, UserManager $user_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"toggle-notifications",
require_logged_in: true, require_logged_in: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: ["enable_notifications" => [new IsSetRule()]] rule_lists: ["enable_notifications" => [new IsSetRule()]]
); );
$this->conn = $conn;
$this->user_manager = $user_manager; $this->user_manager = $user_manager;
} }
@ -48,17 +46,19 @@ class ToggleNotificationsAction extends Action
/** /**
* Sets whether email notifications are sent. * Sets whether email notifications are sent.
* *
* @param array<int|string, mixed> $inputs `"enable_notifications": bool`: `true` if and only if notifications
* should be enabled
* @return null * @return null
* @throws ValidationException if the user's email address has not been verified * @throws InvalidInputException if the user's email address has not been verified
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function() { Database::transaction($this->conn, function() use ($inputs) {
$user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]); $user_data = $this->user_manager->get_user_by_uuid($_SESSION["uuid"]);
if ($user_data["email_verification_token"] === null) if ($user_data["email_verification_token"] === null)
throw new ValidationException("Please verify your email address before enabling notifications."); throw new InvalidInputException("Please verify your email address before enabling notifications.");
$this->user_manager->set_notifications_enabled($_SESSION["uuid"], $_POST["enable_notifications"] == true); $this->user_manager->set_notifications_enabled($_SESSION["uuid"], $inputs["enable_notifications"] == true);
}); });
return null; return null;

View File

@ -4,7 +4,6 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException; use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Util; use com\fwdekker\deathnotifier\Util;
use com\fwdekker\deathnotifier\validator\HasLengthRule; use com\fwdekker\deathnotifier\validator\HasLengthRule;
use com\fwdekker\deathnotifier\validator\IsSetRule; use com\fwdekker\deathnotifier\validator\IsSetRule;
@ -30,8 +29,6 @@ class UserDeleteAction extends Action
public function __construct(UserManager $user_manager) public function __construct(UserManager $user_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"user-delete",
require_logged_in: true, require_logged_in: true,
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: [ rule_lists: [
@ -49,10 +46,11 @@ class UserDeleteAction extends Action
/** /**
* Deletes the currently logged-in user, and terminates the current user session. * Deletes the currently logged-in user, and terminates the current user session.
* *
* @param array<int|string, mixed> $inputs ignored
* @return null * @return null
* @throws ActionException if no CSRF token could be generated * @throws ActionException if no CSRF token could be generated
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
$this->user_manager->remove_user_by_uuid($_SESSION["uuid"]); $this->user_manager->remove_user_by_uuid($_SESSION["uuid"]);

View File

@ -2,10 +2,7 @@
namespace com\fwdekker\deathnotifier\user; namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\LoggerUtil; use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\Response;
use Monolog\Logger; use Monolog\Logger;
use PDO; use PDO;
@ -49,23 +46,17 @@ class UserManager
* @var PDO the database connection to interact with * @var PDO the database connection to interact with
*/ */
private PDO $conn; private PDO $conn;
/**
* @var MailManager the mailer to send emails with
*/
private MailManager $mailer;
/** /**
* Constructs a new user manager. * Constructs a new user manager.
* *
* @param PDO $conn the database connection to interact with * @param PDO $conn the database connection to interact with
* @param MailManager $mailer the mailer to send emails with
*/ */
public function __construct(PDO $conn, MailManager $mailer) public function __construct(PDO $conn)
{ {
$this->logger = LoggerUtil::with_name($this::class); $this->logger = LoggerUtil::with_name($this::class);
$this->conn = $conn; $this->conn = $conn;
$this->mailer = $mailer;
} }

View File

@ -3,10 +3,9 @@
namespace com\fwdekker\deathnotifier\user; namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\IsSetRule; use com\fwdekker\deathnotifier\validator\IsSetRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
/** /**
@ -28,12 +27,10 @@ class ValidatePasswordResetTokenAction extends Action
public function __construct(UserManager $user_manager) public function __construct(UserManager $user_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::GET,
"validate-password-reset-token",
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: [ rule_lists: [
"reset_token" => [new IsSetRule()],
"email" => [new IsEmailRule()], "email" => [new IsEmailRule()],
"reset_token" => [new IsSetRule()],
], ],
); );
@ -44,14 +41,16 @@ class ValidatePasswordResetTokenAction extends Action
/** /**
* Checks whether the given password reset token is valid. * 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
* @return null * @return null
* @throws ValidationException if the password reset link is valid * @throws InvalidInputException if the password reset link is valid
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
$user_data = $this->user_manager->get_user_by_email($_GET["email"]); $user_data = $this->user_manager->get_user_by_email($inputs["email"]);
if ($_GET["reset_token"] !== $user_data["password_reset_token"]) if ($inputs["reset_token"] !== $user_data["password_reset_token"])
throw new ValidationException( throw new InvalidInputException(
"This password reset link is invalid. Maybe you already reset your password?" "This password reset link is invalid. Maybe you already reset your password?"
); );

View File

@ -3,13 +3,12 @@
namespace com\fwdekker\deathnotifier\user; namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action; use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Database; use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\MailManager; use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\Util; use com\fwdekker\deathnotifier\Util;
use com\fwdekker\deathnotifier\validator\IsEmailRule; use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\IsSetRule; use com\fwdekker\deathnotifier\validator\IsSetRule;
use com\fwdekker\deathnotifier\validator\ValidationException; use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO; use PDO;
@ -42,8 +41,6 @@ class VerifyEmailAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager) public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{ {
parent::__construct( parent::__construct(
ActionMethod::POST,
"verify-email",
require_valid_csrf_token: true, require_valid_csrf_token: true,
rule_lists: [ rule_lists: [
"email" => [new IsEmailRule()], "email" => [new IsEmailRule()],
@ -60,17 +57,19 @@ class VerifyEmailAction extends Action
/** /**
* Verifies the user's email address. * 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
* @return null * @return null
* @throws ValidationException if the email address does not exist, if the email is already verified, or if the * @throws InvalidInputException if the email address does not exist, if the email is already verified, or if the
* token expired * token expired
*/ */
function handle(): mixed function handle(array $inputs): mixed
{ {
Database::transaction($this->conn, function () { Database::transaction($this->conn, function () use ($inputs) {
// TODO: Validate that the email even exists // TODO: Validate that the email even exists
$user_data = $this->user_manager->get_user_by_email($_POST["email"]); $user_data = $this->user_manager->get_user_by_email($inputs["email"]);
if ($user_data["email_verification_token"] !== $_POST["verify_token"]) if ($user_data["email_verification_token"] !== $inputs["verify_token"])
throw new ValidationException( throw new InvalidInputException(
"Failed to verify email address. Maybe you already verified your email address?" "Failed to verify email address. Maybe you already verified your email address?"
); );
@ -79,7 +78,7 @@ class VerifyEmailAction extends Action
UserManager::MINUTES_VALID_VERIFICATION UserManager::MINUTES_VALID_VERIFICATION
); );
if ($minutes_remaining < 0) if ($minutes_remaining < 0)
throw new ValidationException( throw new InvalidInputException(
"This email verification link has expired. Log in and request a new verification email." "This email verification link has expired. Log in and request a new verification email."
); );

View File

@ -41,20 +41,20 @@ class HasLengthRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @param string $key the key in `inputs` of the input to check
* @return void if `$inputs[$key]` is of the specified length * @return void if `$inputs[$key]` is of the specified length
* @throws ValidationException if `$inputs[$key]` is not set or is not of the specified length * @throws InvalidInputException if `$inputs[$key]` is not set or is not of the specified length
*/ */
public function check(array $inputs, string $key): void public function check(array $inputs, string $key): void
{ {
if (!isset($inputs[$key])) if (!isset($inputs[$key]))
throw new ValidationException($this->override_message ?? "Missing input '$key'.", $key); throw new InvalidInputException($this->override_message ?? "Missing input '$key'.", $key);
if ($this->min_length !== null && strlen($inputs[$key]) < $this->min_length) if ($this->min_length !== null && strlen($inputs[$key]) < $this->min_length)
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? "Use at least $this->min_length character(s).", $this->override_message ?? "Use at least $this->min_length character(s).",
$key $key
); );
if ($this->max_length !== null && strlen($inputs[$key]) > $this->max_length) if ($this->max_length !== null && strlen($inputs[$key]) > $this->max_length)
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? "Use at most $this->max_length character(s).", $this->override_message ?? "Use at most $this->max_length character(s).",
$key $key
); );

View File

@ -2,13 +2,16 @@
namespace com\fwdekker\deathnotifier\validator; namespace com\fwdekker\deathnotifier\validator;
use Exception; use com\fwdekker\deathnotifier\MalformedRequestException;
use Throwable;
/** /**
* Thrown if an action could not be handled because an input is invalid. * 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.
*/ */
class ValidationException extends Exception class InvalidInputException extends MalformedRequestException
{ {
/** /**
* @var string|null the input element that caused the exception, or `null` if no such element could be identified * @var string|null the input element that caused the exception, or `null` if no such element could be identified
@ -22,10 +25,11 @@ class ValidationException extends Exception
* @param string $message the message to show to the user * @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 * @param string|null $target the input element that caused the exception, or `null` if no such element could be
* identified * identified
* @param Throwable|null $previous the throwable that caused this one
*/ */
public function __construct(string $message, ?string $target = null) public function __construct(string $message, ?string $target = null, ?Throwable $previous = null)
{ {
parent::__construct($message); parent::__construct($message, previous: $previous);
$this->target = $target; $this->target = $target;
} }

View File

@ -14,11 +14,11 @@ class IsEmailRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @param string $key the key in `inputs` of the input to check
* @return void if `$inputs[$key]` is an email address * @return void if `$inputs[$key]` is an email address
* @throws ValidationException if `$inputs[$key]` is not set or is not an email address * @throws InvalidInputException if `$inputs[$key]` is not set or is not an email address
*/ */
public function check(array $inputs, string $key): void public function check(array $inputs, string $key): void
{ {
if (!isset($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL)) if (!isset($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL))
throw new ValidationException($this->override_message ?? "Enter a valid email address.", $key); throw new InvalidInputException($this->override_message ?? "Enter a valid email address.", $key);
} }
} }

View File

@ -33,12 +33,12 @@ class IsEqualToRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @param string $key the key in `inputs` of the input to check
* @return void if `$inputs[$key]` equals `$expected` * @return void if `$inputs[$key]` equals `$expected`
* @throws ValidationException if `$inputs[$key]` is not set or does not equal `$expected` * @throws InvalidInputException if `$inputs[$key]` is not set or does not equal `$expected`
*/ */
public function check(array $inputs, string $key): void public function check(array $inputs, string $key): void
{ {
if (!isset($inputs[$key]) || $inputs[$key] !== $this->expected) if (!isset($inputs[$key]) || $inputs[$key] !== $this->expected)
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? "Inputs '$key' should equal '$this->expected'.", $this->override_message ?? "Inputs '$key' should equal '$this->expected'.",
$key $key
); );

View File

@ -14,11 +14,11 @@ class IsNotBlankRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @param string $key the key in `inputs` of the input to check
* @return void if `trim($inputs[$key])` is not an empty string * @return void if `trim($inputs[$key])` is not an empty string
* @throws ValidationException if `$inputs[$key]` is not set or if `trim($inputs[$key])` is an empty string * @throws InvalidInputException if `$inputs[$key]` is not set or if `trim($inputs[$key])` is an empty string
*/ */
public function check(array $inputs, string $key): void public function check(array $inputs, string $key): void
{ {
if (!isset($inputs[$key]) || trim($inputs[$key]) === "") if (!isset($inputs[$key]) || trim($inputs[$key]) === "")
throw new ValidationException($this->override_message ?? "Use at least one character.", $key); throw new InvalidInputException($this->override_message ?? "Use at least one character.", $key);
} }
} }

View File

@ -14,12 +14,12 @@ class IsNotSetRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @param string $key the key in `inputs` of the input to check
* @return void if `$inputs[$key]` is not set * @return void if `$inputs[$key]` is not set
* @throws ValidationException if `$inputs[$key]` is set * @throws InvalidInputException if `$inputs[$key]` is set
*/ */
public function check(array $inputs, string $key): void public function check(array $inputs, string $key): void
{ {
if (isset($inputs[$key])) if (isset($inputs[$key]))
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? "Field '" . htmlentities($key) . "' must not be set.", $this->override_message ?? "Field '" . htmlentities($key) . "' must not be set.",
$key $key
); );

View File

@ -14,12 +14,12 @@ class IsSetRule extends Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @param string $key the key in `inputs` of the input to check
* @return void if `$inputs[$key]` is set * @return void if `$inputs[$key]` is set
* @throws ValidationException if `$inputs[$key]` is not set * @throws InvalidInputException if `$inputs[$key]` is not set
*/ */
public function check(array $inputs, string $key): void public function check(array $inputs, string $key): void
{ {
if (!isset($inputs[$key])) if (!isset($inputs[$key]))
throw new ValidationException( throw new InvalidInputException(
$this->override_message ?? "Field '" . htmlentities($key) . "' must be set.", $this->override_message ?? "Field '" . htmlentities($key) . "' must be set.",
$key $key
); );

View File

@ -35,7 +35,7 @@ abstract class Rule
* @param array<int|string, mixed> $inputs the list of inputs in which the value at `key` should be checked * @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 * @param string $key the key in `inputs` of the input to check
* @return void if the rule holds * @return void if the rule holds
* @throws ValidationException if the rule does not hold * @throws InvalidInputException if the rule does not hold
*/ */
public abstract function check(array $inputs, string $key): void; public abstract function check(array $inputs, string $key): void;
} }