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`:
```
* * * * * cd /var/www/death-notifier && php /var/www/death-notifier/api.php process-email-queue secret_password
*/5 * * * * cd /var/www/death-notifier && php /var/www/death-notifier/api.php update-all-trackings 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 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",
"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",
"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.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.",
"author": "Florine W. Dekker",
"browser": "dist/bundle.js",

View File

@ -5,6 +5,7 @@ use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Config;
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\ProcessEmailQueueAction;
@ -36,73 +37,88 @@ use com\fwdekker\deathnotifier\Util;
require_once __DIR__ . "/.vendor/autoload.php";
// Preamble
$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"]);
$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
// Wrap everything in try-catch to always return *something* to user
try {
session_start();
$_SESSION["token"] = $_SESSION["token"] ?? Util::generate_csrf_token();
$_POST = Util::parse_post();
// Preamble
$config = Config::get();
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_migrate();
// Dispatch request
session_start();
$_SESSION["token"] = $_SESSION["token"] ?? Util::generate_csrf_token();
// Set up request handlers
$dispatcher = new ActionDispatcher();
// GET actions
$dispatcher->register_action(new StartSessionAction($user_manager));
$dispatcher->register_action(new GetPublicUserDataAction($user_manager));
$dispatcher->register_action(new ListTrackingsAction($tracking_manager));
$dispatcher->register_action(new ValidatePasswordResetTokenAction($user_manager));
$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, "validate-password-reset-token", new ValidatePasswordResetTokenAction($user_manager)],
]);
// POST actions
$dispatcher->register_action(new RegisterAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new LoginAction($user_manager));
$dispatcher->register_action(new LogoutAction());
$dispatcher->register_action(new ResendVerifyEmailAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new VerifyEmailAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new ChangeEmailAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new ToggleNotificationsAction($db->conn, $user_manager));
$dispatcher->register_action(new ChangePasswordAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new SendPasswordResetAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new ResetPasswordAction($db->conn, $user_manager, $mail_manager));
$dispatcher->register_action(new UserDeleteAction($user_manager));
$dispatcher->register_action(new AddTrackingAction($tracking_manager, $mediawiki));
$dispatcher->register_action(new RemoveTrackingAction($tracking_manager));
$dispatcher->register_actions([
[ActionMethod::POST, "register", new RegisterAction($db->conn, $user_manager, $mail_manager)],
[ActionMethod::POST, "login", new LoginAction($user_manager)],
[ActionMethod::POST, "logout", new LogoutAction()],
[ActionMethod::POST, "user-delete", new UserDeleteAction($user_manager)],
[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-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, "add-tracking", new AddTrackingAction($tracking_manager, $mediawiki)],
[ActionMethod::POST, "remove-tracking", new RemoveTrackingAction($tracking_manager)],
]);
// CLI actions
$cli_actions = [
new UpdateTrackingsAction($db->conn, $tracking_manager, $mediawiki, $mail_manager),
new ProcessEmailQueueAction($mail_manager),
];
$dispatcher->register_action($cli_actions[0]);
$dispatcher->register_action($cli_actions[1]);
$dispatcher->register_action(new EmulateCronAction($cli_actions));
// Dispatch
if (isset($_GET["action"]))
$response = $dispatcher->handle(ActionMethod::GET);
else if (isset($_POST["action"]))
$response = $dispatcher->handle(ActionMethod::POST);
else if ($argc > 1)
$response = $dispatcher->handle(ActionMethod::CLI);
$dispatcher->register_actions([
[ActionMethod::CLI, "update-trackings", $cli_actions[0]],
[ActionMethod::CLI, "process-email-queue", $cli_actions[1]],
[ActionMethod::CLI, "emulate-cron", new EmulateCronAction($cli_actions)],
]);
// Handle request
if (!isset($_SERVER["REQUEST_METHOD"]))
$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
$response = Response::satisfied();
} catch (Exception $exception) {
$response = Response::unsatisfied("An unexpected error occurred. Please try again later.");
$logger->error("An unexpected error occurred. Please try again later.", ["cause" => $exception]);
} 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]);
}

View File

@ -577,11 +577,12 @@ doAfterLoad(() => {
addTrackingForm.addEventListener("submit", (event: SubmitEvent) => {
event.preventDefault();
const inputName = $("#add-tracking-name").value;
postApi(
{
action: "add-tracking",
token: csrfToken,
person_name: $("#add-tracking-name").value,
person_name: inputName,
},
addTrackingForm,
(response: ServerResponse) => {
@ -590,12 +591,12 @@ doAfterLoad(() => {
showMessageSuccess(
addTrackingForm,
response.payload["renamed"]
inputName.toLowerCase() !== response.payload["normalized_name"].toLowerCase()
? (
`Successfully added <b>${response.payload["input"]}</b> as ` +
`<b>${response.payload["name"]}</b>!`
`Successfully added <b>${inputName}</b> as ` +
`<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\IsSetRule;
use com\fwdekker\deathnotifier\validator\Rule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use InvalidArgumentException;
@ -32,21 +32,10 @@ abstract class Action
*/
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.
*
* @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_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
@ -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
* action is handled
*/
public function __construct(ActionMethod $method,
string $name,
bool $require_logged_in = false,
public function __construct(bool $require_logged_in = false,
bool $require_logged_out = false,
bool $require_valid_csrf_token = false,
array $rule_lists = [])
@ -64,9 +51,6 @@ abstract class Action
if ($require_logged_in && $require_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?
$this->require_logged_in = $require_logged_in;
$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.
*
* @param array<int|string, mixed> $inputs the inputs to validate
* @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)
(new IsSetRule("You must be logged in to perform this action."))->check($_SESSION, "uuid");
if ($this->require_logged_out)
@ -101,9 +84,10 @@ abstract class 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`
* @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;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
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
* 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
* @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]))
$this->actions[$method] = [];
if (!isset($this->actions[$method_name]))
$this->actions[$method_name] = [];
if (isset($this->actions[$method][$action->name]))
// TODO: Throw more specific exceptions(?)
throw new InvalidArgumentException("Cannot register another handler for $method action '$action->name'.");
if (isset($this->actions[$method_name][$action_name]))
throw new IllegalArgumentError("Cannot register another handler for $method_name 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.
*
* @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
*/
public function handle(ActionMethod $method): Response
public function handle(ActionMethod $method, array $inputs): Response
{
$inputs = $method->get_inputs();
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"];
if (!isset($this->actions[$method->name]) || !isset($this->actions[$method->name][$action_name]))
throw new InvalidArgumentException("No handler for $method->name action '$action_name'.");
if (!isset($this->actions[$method_name]) || !isset($this->actions[$method_name][$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 {
$action->validate_inputs();
$payload = $action->handle();
$action->validate_inputs($inputs);
$payload = $action->handle($inputs);
return Response::satisfied($payload);
} catch (ActionException|ValidationException $exception) {
} catch (ActionException|InvalidInputException $exception) {
return Response::unsatisfied($exception->getMessage(), $exception->target);
}
}

View File

@ -20,19 +20,4 @@ enum ActionMethod
* HTTP POST request.
*/
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.
*/
// TODO: Override dynamically from tests
class Config
{
/**

View File

@ -63,7 +63,7 @@ class Database
if ($stmt->fetch()[0] !== 0)
return;
$this->logger->info("Database does not exist. Installing new database.");
$this->logger->notice("Database does not exist. Installing new database.");
// Create `meta` table
$this->conn->exec("CREATE TABLE meta(k TEXT NOT NULL UNIQUE PRIMARY KEY, v TEXT);");
@ -76,7 +76,7 @@ class Database
$user_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))
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
$stmt = $this->conn->prepare("SELECT v FROM meta WHERE k='version';");
@ -113,7 +113,7 @@ class Database
$stmt->bindValue(":version", Database::LATEST_VERSION);
$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
{
$this->logger->info("Migrating to v0.5.0.");
$this->logger->notice("Migrating to v0.5.0.");
$this->conn->exec("ALTER TABLE users
ADD COLUMN email_notifications_enabled INT NOT NULL DEFAULT(1);");
@ -139,7 +139,7 @@ class Database
*/
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,
recipient TEXT NOT NULL,
@ -160,7 +160,7 @@ class Database
*/
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,
recipient TEXT NOT NULL,
@ -185,7 +185,7 @@ class Database
*/
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("CREATE TABLE email_tasks(type_key TEXT NOT NULL,

View File

@ -2,7 +2,7 @@
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)
{
parent::__construct(ActionMethod::CLI, "emulate-cron");
parent::__construct();
$this->actions = $actions;
}
@ -38,27 +38,29 @@ class EmulateCronAction extends 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
* @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)
$action->validate_inputs();
$action->validate_inputs($inputs);
}
/**
* Updates all trackings and processes the mail queue at a regular interval.
*
* @param array<int|string, mixed> $inputs ignored
* @return never
*/
public function handle(): never
public function handle(array $inputs): never
{
// @phpstan-ignore-next-line
while (true) {
print("Emulating cron jobs.\n");
foreach ($this->actions as $action)
$action->handle();
$action->handle($inputs);
print("Done.\n");
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
}

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

View File

@ -2,7 +2,9 @@
namespace com\fwdekker\deathnotifier;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use Exception;
use JsonException;
/**
@ -13,17 +15,24 @@ class Util
/**
* 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");
if ($post_input !== false)
$output = json_decode($post_input, associative: true);
if ($post_input === false || trim($post_input) === "")
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.
*
* @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
{
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;
}

View File

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

View File

@ -4,7 +4,7 @@ namespace com\fwdekker\deathnotifier\mediawiki;
use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\validator\Rule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
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 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 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
{
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];
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 ValidationException(
throw new InvalidInputException(
$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"];
if (in_array($normalized_name, $info->missing))
throw new ValidationException(
throw new InvalidInputException(
$this->override_message ??
"Wikipedia does not have an article about " .
"<b><a href='https://en.wikipedia.org/wiki/Special:Search?search=" .
@ -75,7 +75,7 @@ class IsPersonPageRule extends Rule
);
if ($type === ArticleType::Disambiguation)
throw new ValidationException(
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. " .
@ -85,7 +85,7 @@ class IsPersonPageRule extends Rule
);
if ($type === ArticleType::Other)
throw new ValidationException(
throw new InvalidInputException(
$this->override_message ??
"The Wikipedia article about " .
"<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\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\IllegalStateException;
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\IsNotBlankRule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use Monolog\Logger;
@ -44,8 +43,6 @@ class AddTrackingAction extends Action
public function __construct(TrackingManager $tracking_manager, MediaWiki $mediawiki)
{
parent::__construct(
ActionMethod::POST,
"add-tracking",
require_logged_in: true,
require_valid_csrf_token: true,
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 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"];
$person_name = strval($_POST["person_name"]);
$person_name = strval($inputs["person_name"]);
// Query API
try {
@ -86,19 +85,18 @@ class AddTrackingAction extends Action
$normalized_name = $info->redirects[$person_name];
$status = $info->results[$normalized_name]["status"];
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)
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);
// Respond
return [
"name" => $normalized_name,
"input" => $person_name,
"renamed" => strtolower($person_name) !== strtolower($normalized_name)
"input_name" => $person_name,
"normalized_name" => $normalized_name,
];
}
}

View File

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

View File

@ -3,7 +3,6 @@
namespace com\fwdekker\deathnotifier\tracking;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\validator\IsNotBlankRule;
@ -26,8 +25,6 @@ class RemoveTrackingAction extends Action
public function __construct(TrackingManager $tracking_manager)
{
parent::__construct(
ActionMethod::POST,
"remove-tracking",
require_logged_in: true,
require_valid_csrf_token: true,
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
*/
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;
}

View File

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

View File

@ -4,14 +4,13 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
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\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO;
@ -44,8 +43,6 @@ class ChangePasswordAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{
parent::__construct(
ActionMethod::POST,
"update-password",
require_logged_in: true,
require_valid_csrf_token: true,
rule_lists: [
@ -65,19 +62,21 @@ class ChangePasswordAction extends Action
/**
* 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
* @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"]);
if ($user_data === null)
throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
if (!password_verify($user_data["password"], $_POST["password_old"]))
throw new ValidationException("Incorrect old password.", "password_old");
if (!password_verify($user_data["password"], $inputs["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"]));
});

View File

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

View File

@ -4,8 +4,8 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
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)
{
parent::__construct(
ActionMethod::POST,
"login",
require_logged_out: true,
require_valid_csrf_token: true,
rule_lists: ["email" => [new IsEmailRule()]],
rule_lists: [
"email" => [new IsEmailRule()],
"password" => [new IsSetRule()],
],
);
$this->user_manager = $user_manager;
@ -41,15 +42,17 @@ class LoginAction extends Action
/**
* 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
* @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)
throw new ActionException("Failed to retrieve account data. Refresh the page and try again.");
if (!password_verify($_POST["password"], $user_data["password"]))
throw new ActionException("Incorrect combination of email and password.", "password");
if (!password_verify($inputs["password"], $user_data["password"]))
throw new ActionException("Incorrect combination of email and password.", "password");
$_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\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Util;
use Exception;
@ -19,22 +18,18 @@ class LogoutAction extends Action
*/
public function __construct()
{
parent::__construct(
ActionMethod::POST,
"logout",
require_logged_in: true,
require_valid_csrf_token: true,
);
parent::__construct(require_logged_in: true, require_valid_csrf_token: true);
}
/**
* Terminates the current user session.
*
* @param array<int|string, mixed> $inputs ignored
* @return null
* @throws ActionException if no CSRF token could be generated
*/
function handle(): mixed
function handle(array $inputs): mixed
{
session_destroy();
session_start();

View File

@ -4,14 +4,13 @@ namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionException;
use com\fwdekker\deathnotifier\ActionMethod;
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\IsEmailRule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO;
@ -44,8 +43,6 @@ class RegisterAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{
parent::__construct(
ActionMethod::POST,
"register",
require_logged_out: true,
require_valid_csrf_token: true,
rule_lists: [
@ -63,22 +60,24 @@ class RegisterAction extends Action
/**
* 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
* @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 () {
if ($this->user_manager->has_user_with_email($_POST["email"]))
throw new ValidationException("That email address already in use by someone else.", "email");
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");
$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);
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->mail_manager->queue_email(new RegisterEmail($_POST["email"], $token));
$this->mail_manager->queue_email(new RegisterEmail($inputs["email"], $token));
});
return null;

View File

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

View File

@ -3,14 +3,14 @@
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
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\IsEmailRule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use com\fwdekker\deathnotifier\validator\IsSetRule;
use PDO;
@ -43,12 +43,11 @@ class ResetPasswordAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{
parent::__construct(
ActionMethod::POST,
"reset-password",
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()],
],
);
@ -61,19 +60,21 @@ class ResetPasswordAction extends Action
/**
* 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
*/
function handle(): mixed
function handle(array $inputs): mixed
{
Database::transaction($this->conn, function () {
$user_data = $this->user_manager->get_user_by_email($_POST["email"]);
if ($_GET["reset_token"] !== $user_data["password_reset_token"])
throw new ValidationException(
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?"
);
$this->user_manager->set_password($user_data["uuid"], $_POST["password"]);
$this->mail_manager->queue_email(new ResetPasswordEmail($_POST["email"]));
$this->user_manager->set_password($user_data["uuid"], $inputs["password"]);
$this->mail_manager->queue_email(new ResetPasswordEmail($inputs["email"]));
});
return null;

View File

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

View File

@ -3,10 +3,9 @@
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\validator\IsSetRule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO;
@ -34,13 +33,12 @@ class ToggleNotificationsAction extends Action
public function __construct(PDO $conn, UserManager $user_manager)
{
parent::__construct(
ActionMethod::POST,
"toggle-notifications",
require_logged_in: true,
require_valid_csrf_token: true,
rule_lists: ["enable_notifications" => [new IsSetRule()]]
);
$this->conn = $conn;
$this->user_manager = $user_manager;
}
@ -48,17 +46,19 @@ class ToggleNotificationsAction extends Action
/**
* 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
* @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"]);
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;

View File

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

View File

@ -2,10 +2,7 @@
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\LoggerUtil;
use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\Response;
use Monolog\Logger;
use PDO;
@ -49,23 +46,17 @@ class UserManager
* @var PDO the database connection to interact with
*/
private PDO $conn;
/**
* @var MailManager the mailer to send emails with
*/
private MailManager $mailer;
/**
* Constructs a new user manager.
*
* @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->conn = $conn;
$this->mailer = $mailer;
}

View File

@ -3,10 +3,9 @@
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\validator\IsEmailRule;
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)
{
parent::__construct(
ActionMethod::GET,
"validate-password-reset-token",
require_valid_csrf_token: true,
rule_lists: [
"reset_token" => [new IsSetRule()],
"email" => [new IsEmailRule()],
"reset_token" => [new IsSetRule()],
],
);
@ -44,14 +41,16 @@ class ValidatePasswordResetTokenAction extends Action
/**
* 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
* @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"]);
if ($_GET["reset_token"] !== $user_data["password_reset_token"])
throw new ValidationException(
$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?"
);

View File

@ -3,13 +3,12 @@
namespace com\fwdekker\deathnotifier\user;
use com\fwdekker\deathnotifier\Action;
use com\fwdekker\deathnotifier\ActionMethod;
use com\fwdekker\deathnotifier\Database;
use com\fwdekker\deathnotifier\mailer\MailManager;
use com\fwdekker\deathnotifier\Util;
use com\fwdekker\deathnotifier\validator\IsEmailRule;
use com\fwdekker\deathnotifier\validator\IsSetRule;
use com\fwdekker\deathnotifier\validator\ValidationException;
use com\fwdekker\deathnotifier\validator\InvalidInputException;
use PDO;
@ -42,8 +41,6 @@ class VerifyEmailAction extends Action
public function __construct(PDO $conn, UserManager $user_manager, MailManager $mail_manager)
{
parent::__construct(
ActionMethod::POST,
"verify-email",
require_valid_csrf_token: true,
rule_lists: [
"email" => [new IsEmailRule()],
@ -60,17 +57,19 @@ class VerifyEmailAction extends Action
/**
* 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
* @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
*/
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
$user_data = $this->user_manager->get_user_by_email($_POST["email"]);
if ($user_data["email_verification_token"] !== $_POST["verify_token"])
throw new ValidationException(
$user_data = $this->user_manager->get_user_by_email($inputs["email"]);
if ($user_data["email_verification_token"] !== $inputs["verify_token"])
throw new InvalidInputException(
"Failed to verify email address. Maybe you already verified your email address?"
);
@ -79,7 +78,7 @@ class VerifyEmailAction extends Action
UserManager::MINUTES_VALID_VERIFICATION
);
if ($minutes_remaining < 0)
throw new ValidationException(
throw new InvalidInputException(
"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 string $key the key in `inputs` of the input to check
* @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
{
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)
throw new ValidationException(
throw new InvalidInputException(
$this->override_message ?? "Use at least $this->min_length character(s).",
$key
);
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).",
$key
);

View File

@ -2,13 +2,16 @@
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
@ -22,10 +25,11 @@ class ValidationException extends Exception
* @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 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;
}

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 string $key the key in `inputs` of the input to check
* @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
{
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 string $key the key in `inputs` of the input to check
* @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
{
if (!isset($inputs[$key]) || $inputs[$key] !== $this->expected)
throw new ValidationException(
throw new InvalidInputException(
$this->override_message ?? "Inputs '$key' should equal '$this->expected'.",
$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 string $key the key in `inputs` of the input to check
* @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
{
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 string $key the key in `inputs` of the input to check
* @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
{
if (isset($inputs[$key]))
throw new ValidationException(
throw new InvalidInputException(
$this->override_message ?? "Field '" . htmlentities($key) . "' must not be set.",
$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 string $key the key in `inputs` of the input to check
* @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
{
if (!isset($inputs[$key]))
throw new ValidationException(
throw new InvalidInputException(
$this->override_message ?? "Field '" . htmlentities($key) . "' must be set.",
$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 string $key the key in `inputs` of the input to check
* @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;
}