diff --git a/Gruntfile.js b/Gruntfile.js index 5da3d60..edd64de 100644 --- a/Gruntfile.js +++ b/Gruntfile.js @@ -17,7 +17,13 @@ module.exports = grunt => { files: [{expand: true, cwd: "src/main/", src: "**/*.html", dest: "dist/", flatten: true}] }, php: { - files: [{expand: true, cwd: "src/main/", src: "**/*.php", dest: "dist/", flatten: false}] + files: [ + {expand: true, cwd: "src/main/", src: "*.php", dest: "dist/", flatten: false}, + {expand: true, cwd: "src/main/php/", src: "**/*.php", dest: "dist/", flatten: false}, + ] + }, + php_test: { + files: [{expand: true, cwd: "src/test/php", src: "**/*.php", dest: "dist/", flatten: false}] }, }, focus: { @@ -63,7 +69,7 @@ module.exports = grunt => { exec: "composer.phar install --no-dev" }, phpunit: { - exec: "vendor/bin/phpunit --testdox src/test" + exec: "cd dist/ && chmod +x .vendor/bin/phpunit && .vendor/bin/phpunit --testdox ." }, stan: { exec: "vendor/bin/phpstan analyse -l 8 src/main src/test" @@ -79,8 +85,8 @@ module.exports = grunt => { tasks: ["copy:html", "replace:dev"], }, php: { - files: ["src/main/**/*.php"], - tasks: ["copy:php", "replace:dev"], + files: ["src/**/*.php"], + tasks: ["copy:php", "copy:php_test", "replace:dev"], }, ts: { files: ["src/main/**/*.ts"], @@ -135,6 +141,7 @@ module.exports = grunt => { "copy:css", "copy:html", "copy:php", + "copy:php_test", // Compile JS "webpack:dev", "replace:dev", diff --git a/composer.json b/composer.json index 11b9dcb..c231225 100644 --- a/composer.json +++ b/composer.json @@ -29,7 +29,7 @@ }, "autoload": { "psr-4": { - "php\\": "php/" + "com\\fwdekker\\deathnotifier\\": "com/fwdekker/deathnotifier/" } }, "minimum-stability": "stable" diff --git a/src/main/api.php b/src/main/api.php index 29581c2..90d6916 100644 --- a/src/main/api.php +++ b/src/main/api.php @@ -1,17 +1,17 @@ > The configuration to use for mailing. - */ - private array $config; - /** - * @var PDO The database connection to interact with. - */ - private PDO $conn; - - - /** - * Constructs a new mailer. - * - * @param array> $config the configuration to use for mailing - * @param Logger $logger the logger to use for logging - * @param PDO $conn the connection to the email database - */ - public function __construct(array $config, Logger $logger, PDO $conn) - { - $this->logger = $logger; - $this->config = $config; - $this->conn = $conn; - } - - - /** - * Populates the database with the necessary structures for emails. - * - * @return void - */ - public function install(): void - { - $this->conn->exec("CREATE TABLE email_tasks(type TEXT NOT NULL, - recipient TEXT NOT NULL, - arg1 TEXT NOT NULL DEFAULT(''), - arg2 TEXT NOT NULL DEFAULT(''), - PRIMARY KEY (type, recipient, arg1, arg2));"); - } - - - /** - * Queues an email to be sent. - * - * @param Email $email the email to queue - * @return Response a satisfied `Response` if the email was queued, or an unsatisfied `Response` otherwise - */ - public function queue_email(Email $email): Response - { - $stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, recipient, arg1, arg2) - VALUES (:type, :recipient, :arg1, :arg2);"); - $stmt->bindValue(":type", $email->type); - $stmt->bindValue(":recipient", $email->recipient); - $stmt->bindValue(":arg1", $email->arg1); - $stmt->bindValue(":arg2", $email->arg2); - - return $stmt->execute() ? Response::satisfied() : Response::unsatisfied(null); - } - - /** - * Sends all emails in the queue. - * - * @return void - */ - public function process_queue(): void - { - // Open mailer - $mailer = new PHPMailer(); - $mailer->IsSMTP(); - $mailer->CharSet = "UTF-8"; - - $mailer->SMTPAuth = true; - $mailer->SMTPDebug = SMTP::DEBUG_OFF; - $mailer->SMTPKeepAlive = true; - $mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; - $mailer->Host = $this->config["mail"]["host"]; - $mailer->Port = $this->config["mail"]["port"]; - $mailer->Username = $this->config["mail"]["username"]; - $mailer->Password = $this->config["mail"]["password"]; - try { - $mailer->setFrom($this->config["mail"]["username"], $this->config["mail"]["from_name"]); - } catch (MailerException $exception) { - $this->logger->error("Failed to set 'from' address while processing queue.", ["cause" => $exception]); - $mailer->smtpClose(); - } - - // Get queue - $stmt = $this->conn->prepare("SELECT type, recipient, arg1, arg2 FROM email_tasks;"); - $stmt->execute(); - $email_tasks = $stmt->fetchAll(PDO::FETCH_ASSOC); - - // Process queue - $stmt = $this->conn->prepare("DELETE FROM email_tasks - WHERE type=:type AND recipient=:recipient AND arg1=:arg1 AND arg2=:arg2;"); - $stmt->bindParam(":type", $type); - $stmt->bindParam(":recipient", $recipient); - $stmt->bindParam(":arg1", $arg1); - $stmt->bindParam(":arg2", $arg2); - foreach ($email_tasks as ["type" => $type, "recipient" => $recipient, "arg1" => $arg1, "arg2" => $arg2]) { - try { - $email = Email::deserialize($type, $recipient, $arg1, $arg2); - $mailer->Subject = $email->getSubject(); - $mailer->Body = $email->getBody($this->config); - - try { - $mailer->addAddress($recipient); - $mailer->send(); - } catch (MailerException $exception) { - $this->logger->error( - "Failed to send mail.", - ["cause" => $exception, "email" => $email] - ); - $mailer->getSMTPInstance()->reset(); - } - - $mailer->clearAddresses(); - } catch (Exception $exception) { - $this->logger->error( - "Failed to send mail.", - ["cause" => $exception] - ); - $mailer->getSMTPInstance()->reset(); - } - - $stmt->execute(); - } - } -} - - -/** - * A serializable email that can be queued in a database and can be sent. - * - * When serialized, an email is represented by the type of email and two arguments. When deserialized, an email can be - * constructed from those arguments, and the implementation returns a specific subject and message. - */ -abstract class Email -{ - /** - * @var string A string identifying the type of email. - */ - public string $type; - /** - * @var string The intended recipient of the email. - */ - public string $recipient; - /** - * @var string The first argument to construct the email. - */ - public string $arg1 = ""; - /** - * @var string The second argument to construct the email. - */ - public string $arg2 = ""; - - - /** - * Returns the subject header of the email. - * - * @return string the subject header of the email - */ - public abstract function getSubject(): string; - - /** - * Returns the body of the email. - * - * @param array> $config the software configuration - * @return string the body of the email - */ - public abstract function getBody(array $config): string; - - - /** - * Deserializes an email back into an instance. - * - * Only types of emails that are known can be deserialized. - * - * @param string $type the type of email to deserialize - * @param string $recipient the intended recipient of the email - * @param string $arg1 the first argument to construct the email - * @param string $arg2 the second argument to construct the email - * @return Email a deserialized email - * @throws Exception if the `type` is not recognized - */ - public static function deserialize(string $type, string $recipient, string $arg1, string $arg2): Email - { - return match ($type) { - RegisterEmail::TYPE => new RegisterEmail($recipient, $arg1), - VerifyEmailEmail::TYPE => new VerifyEmailEmail($recipient, $arg1), - ChangedEmailEmail::TYPE => new ChangedEmailEmail($recipient, $arg1), - ChangedPasswordEmail::TYPE => new ChangedPasswordEmail($recipient), - ResetPasswordEmail::TYPE => new ResetPasswordEmail($recipient, $arg1), - NotifyArticleDeletedEmail::TYPE => new NotifyArticleDeletedEmail($recipient, $arg1), - NotifyArticleUndeletedEmail::TYPE => new NotifyArticleUndeletedEmail($recipient, $arg1), - NotifyStatusChangedEmail::TYPE => new NotifyStatusChangedEmail($recipient, $arg1, $arg2), - default => throw new Exception("Unknown email type $type."), - }; - } -} - -/** - * An email to be sent to a recently registered user, including instructions for email verification. - */ -class RegisterEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "register"; - - /** - * @var string The token to verify the email address with. - */ - public string $token; - - - /** - * Constructs an email to be sent to a recently registered user, including instructions for email verification. - * - * @param string $recipient the intended recipient of the email - * @param string $token the token to verify the email address with - */ - public function __construct(string $recipient, string $token) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - - $this->token = $token; - $this->arg1 = $token; - } - - public function getSubject(): string - { - return "Welcome to Death Notifier"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You have successfully created an account with Death Notifier. " . - "Welcome!" . - "\n\n" . - "Until you verify your email address, you will not receive any notifications. " . - "You can verify your email address by clicking the link below. " . - "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . - "\n" . - "Verify: $verify_path" . - "\n\n" . - "If you did not create this account, you can delete the account. " . - "Go to the Death Notifier website, reset your password, log in, and delete the account." . - "\n\n" . - $base_path; - } -} - -/** - * An email to help a user verify their email address. - */ -class VerifyEmailEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "verify-email"; - - /** - * @var string The token to verify the email address with. - */ - public string $token; - - - /** - * Constructs an email to help a user verify their email address. - * - * @param string $recipient the intended recipient of the email - * @param string $token the token to verify the email address with - */ - public function __construct(string $recipient, string $token) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - - $this->token = $token; - $this->arg1 = $token; - } - - - public function getSubject(): string - { - return "Verify your email address"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You requested a new verification link for your Death Notifier account. " . - "You can verify your email address by clicking the link below. " . - "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes. " . - "Until you verify your email address, you will not receive any notifications." . - "\n" . - "Verify: $verify_path" . - "\n\n" . - $base_path; - } -} - -/** - * An email informing a user that their email has been changed, and needs verification. - */ -class ChangedEmailEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "changed-email"; - - /** - * @var string The token to verify the email address with. - */ - public string $token; - - - /** - * Constructs an email informing a user that their email has been changed, and needs verification. - * - * @param string $recipient the intended recipient of the email - * @param string $token the token to verify the email address with - */ - public function __construct(string $recipient, string $token) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - - $this->token = $token; - $this->arg1 = $token; - } - - - public function getSubject(): string - { - return "Verify your new email address"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You changed the email address of your Death Notifier account. " . - "Until you verify your email address, you will not receive any notifications. " . - "You can verify your new email address by clicking the link below. " . - "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . - "\n" . - "Verify: $verify_path" . - "\n\n" . - $base_path; - } -} - -/** - * An email informing a user that their password has been changed. - */ -class ChangedPasswordEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "changed-password"; - - - /** - * Constructs an email informing a user that their email has been changed, and needs verification. - * - * @param string $recipient the intended recipient of the email - */ - public function __construct(string $recipient) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - } - - - public function getSubject(): string - { - return "Your password has been changed"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - - return - "You changed the password of your Death Notifier account." . - "\n\n" . - "If you did not change the password of your account, go to the Death Notifier website and use the forgot " . - "password option to change your password back." . - "\n\n" . - $base_path; - } -} - -/** - * An email to help a user reset their password. - */ -class ResetPasswordEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "reset-password"; - - /** - * @var string The token to reset the password with. - */ - public string $token; - - - /** - * Constructs an email to help a user reset their password. - * - * @param string $recipient the intended recipient of the email - * @param string $token the token to reset the password with - */ - public function __construct(string $recipient, string $token) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - - $this->token = $token; - $this->arg1 = $token; - } - - - public function getSubject(): string - { - return "Reset your password"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - $verify_path = - "$base_path?action=reset-password&email=" . rawurlencode($this->recipient) . "&token=$this->token"; - - return - "You requested a password reset link for your Death Notifier account. " . - "You can choose a new password by clicking the link below. " . - "This link expires after " . UserManager::MINUTES_VALID_PASSWORD_RESET . " minutes." . - "\n" . - "Reset password: $verify_path" . - "\n\n" . - "If you did not request a new password, you can safely ignore this message." . - "\n\n" . - $base_path; - } -} - -/** - * An email to inform a user that a tracked article has been deleted. - */ -class NotifyArticleDeletedEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "notify-article-deleted"; - - /** - * @var string The name of the article that was deleted. - */ - public string $name; - - - /** - * Constructs an email to inform a user that a tracked article has been deleted. - * - * @param string $recipient the intended recipient of the email - * @param string $name the name of the article that was deleted - */ - public function __construct(string $recipient, string $name) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - - $this->name = $name; - $this->arg1 = $name; - } - - - public function getSubject(): string - { - return "$this->name article has been deleted"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - - return - "The Wikipedia article about $this->name has been deleted. " . - "Death Notifier is now unable to send you a notification if $this->name dies. " . - "If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " . - "article, and you will receive another notification." . - "\n\n" . - "You are receiving this message because of the preferences in your Death Notifier account. " . - "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . - "preferences." . - "\n\n" . - $base_path; - } -} - -/** - * An email to inform a user that a tracked article has been re-created. - */ -class NotifyArticleUndeletedEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "notify-article-undeleted"; - - /** - * @var string The name of the article that was re-created. - */ - public string $name; - - - /** - * Constructs an email to inform a user that a tracked article has been re-created. - * - * @param string $recipient the intended recipient of the email - * @param string $name the name of the article that was re-created - */ - public function __construct(string $recipient, string $name) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - - $this->name = $name; - $this->arg1 = $name; - } - - - public function getSubject(): string - { - return "$this->name article has been re-created"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - - return - "The Wikipedia article about $this->name has been re-created. " . - "Death Notifier will once again track the article and notify you if $this->name dies." . - "\n\n" . - "You are receiving this message because of the preferences in your Death Notifier account. " . - "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . - "preferences." . - "\n\n" . - $base_path; - } -} - -/** - * An email to inform a user a tracker person's status has changed. - */ -class NotifyStatusChangedEmail extends Email -{ - /** - * A string identifying the type of email. - */ - public const TYPE = "notify-status-changed"; - - /** - * @var string The name of the person whose status has changed. - */ - public string $name; - /** - * @var string The new status of the person. - */ - public string $new_status; - - - /** - * Constructs an email to inform a user someone has died. - * - * @param string $recipient the intended recipient of the email - * @param string $name the name of the person who died - * @param string $new_status the new status of the person - */ - public function __construct(string $recipient, string $name, string $new_status) - { - $this->type = self::TYPE; - $this->recipient = $recipient; - - $this->name = $name; - $this->arg1 = $name; - - $this->new_status = $new_status; - $this->arg2 = $new_status; - } - - - public function getSubject(): string - { - return "$this->name may be $this->new_status"; - } - - public function getBody(array $config): string - { - $base_path = $config["server"]["base_path"]; - - return - "Someone has edited Wikipedia to state that $this->name is $this->new_status. " . - "For more information, read their Wikipedia article at " . - "https://en.wikipedia.org/wiki/" . rawurlencode($this->name) . - "\n\n" . - "You are receiving this message because of the preferences in your Death Notifier account. " . - "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . - "preferences." . - "\n\n" . - $base_path; - } -} diff --git a/src/main/php/Validator.php b/src/main/php/Validator.php deleted file mode 100644 index e851eb9..0000000 --- a/src/main/php/Validator.php +++ /dev/null @@ -1,233 +0,0 @@ - $inputs the array of inputs in which to check the values - * @param array $rule_sets maps keys in `inputs` to an array of `Rule`s to be checked - * @return Response|null `null` if all rules are satisfied, or an unsatisfied `Response` otherwise - */ - static function validate_inputs(array $inputs, array $rule_sets): ?Response - { - foreach ($rule_sets as $key => $rules) { - foreach ($rules as $rule) { - $is_valid = $rule->check($inputs, $key); - if ($is_valid !== null) - return $is_valid; - } - } - - return null; - } - - - /** - * Validates that the user is logged in. - * - * @param array $session the session to check - * @return Response|null `null` if the user is logged in, or an unsatisfied `Response` otherwise - */ - static function validate_logged_in(array $session): ?Response - { - if (!isset($session["uuid"])) - return Response::unsatisfied("You must be logged in to perform this action."); - - return null; - } - - /** - * Validates that the user is logged out. - * - * @param array $session the session to check - * @return Response|null `null` if the user is logged out, or an unsatisfied `Response` otherwise - */ - static function validate_logged_out(array $session): ?Response - { - if (isset($session["uuid"])) - return Response::unsatisfied("You must be logged out to perform this action."); - - return null; - } - - /** - * Validates that the array contains the correct token. - * - * @param array $token_array the array with key `token` - * @param string $token the expected token - * @return Response|null `null` if the token is correct, or an unsatisfied `Response` otherwise - */ - static function validate_token(array $token_array, string $token): ?Response - { - if (!isset($token_array["token"]) || $token_array["token"] !== $token) - return Response::unsatisfied("Invalid request token. Please refresh the page and try again."); - - return null; - } -} - - -/** - * A rule/constraint/assertion that should hold over an input. - */ -abstract class Rule -{ - /** - * @var string|null The message to return if the rule does not apply to some input. If `null`, the rule - * implementation can choose an appropriate message. - */ - public ?string $override_message; - - - /** - * Instantiates a new rule. - * - * @param string|null $override_message the message to return if the rule does not apply to some input. If `null`, - * the rule implementation can choose an appropriate message - */ - public function __construct(?string $override_message = null) - { - $this->override_message = $override_message; - } - - - /** - * Checks whether the rule holds for `$inputs[$key]`. - * - * Implementations should never assume that the `$inputs[$key]` is set. - * - * @param array $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 Response|null `null` if the rule holds, or an unsatisfied `Response` otherwise - */ - public abstract function check(array $inputs, string $key): ?Response; -} - -/** - * Requires the input to be a valid email address. - */ -class IsEmailRule extends Rule -{ - /** - * Checks whether the input is a valid email address. - * - * @param array $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 Response|null `null` if `$inputs[$key]` is an email address, or an unsatisfied `Response` otherwise - */ - public function check(array $inputs, string $key): ?Response - { - return !isset($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL) - ? Response::unsatisfied($this->override_message ?? "Enter a valid email address.", $key) - : null; - } -} - -/** - * Requires the input to not be blank. - */ -class IsNotBlankRule extends Rule -{ - /** - * Checks whether the input is not blank. - * - * @param array $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 Response|null `null` if `trim($inputs[$key])` is not an empty string, or an unsatisfied `Response` - * otherwise - */ - public function check(array $inputs, string $key): ?Response - { - return !isset($inputs[$key]) || trim($inputs[$key]) === "" - ? Response::unsatisfied($this->override_message ?? "Use at least one character.", $key) - : null; - } -} - -/** - * Requires the input to be set. - */ -class IsSetRule extends Rule -{ - /** - * Checks whether the input is set. - * - * @param array $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 Response|null `null` if `isset($inputs[$key])`, or an unsatisfied `Response` otherwise - */ - public function check(array $inputs, string $key): ?Response - { - return !isset($inputs[$key]) - ? Response::unsatisfied($this->override_message ?? "Field '" . htmlentities($key) . "' required.", $key) - : null; - } -} - -/** - * Requires the input to be of a specific length. - */ -class LengthRule extends Rule -{ - /** - * @var int|null The minimum length (inclusive), or `null` if there is no minimum length. - */ - private readonly ?int $min_length; - /** - * @var int|null The maximum length (inclusive), or `null` if there is no maximum length. - */ - private readonly ?int $max_length; - - - /** - * Instantiates a new rule. - * - * @param int|null $min_length the minimum length (inclusive), or `null` if there is no minimum length - * @param int|null $max_length the maximum length (inclusive), or `null` if there is no maximum length - * @param string|null $override_message the message to return if the rule does not apply to some input. If `null`, - * the rule implementation can choose an appropriate message - */ - public function __construct(?int $min_length = null, ?int $max_length = null, ?string $override_message = null) - { - parent::__construct($override_message); - - $this->min_length = $min_length; - $this->max_length = $max_length; - } - - - /** - * Checks whether the input is of the specified length. - * - * @param array $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 Response|null `null` if the input is of the specified length, or an unsatisfied `Response` otherwise - */ - public function check(array $inputs, string $key): ?Response - { - if (!isset($inputs[$key])) - return Response::unsatisfied( - $this->override_message ?? "Missing input '$key'.", - $key - ); - else if ($this->min_length !== null && strlen($inputs[$key]) < $this->min_length) - return Response::unsatisfied( - $this->override_message ?? "Use at least $this->min_length character(s).", - $key - ); - else if ($this->max_length !== null && strlen($inputs[$key]) > $this->max_length) - return Response::unsatisfied( - $this->override_message ?? "Use at most $this->max_length character(s).", - $key - ); - else - return null; - } -} diff --git a/src/main/php/Database.php b/src/main/php/com/fwdekker/deathnotifier/Database.php similarity index 98% rename from src/main/php/Database.php rename to src/main/php/com/fwdekker/deathnotifier/Database.php index 09264f3..57fa441 100644 --- a/src/main/php/Database.php +++ b/src/main/php/com/fwdekker/deathnotifier/Database.php @@ -1,7 +1,9 @@ type = self::TYPE; + $this->recipient = $recipient; + + $this->token = $token; + $this->arg1 = $token; + } + + + public function getSubject(): string + { + return "Verify your new email address"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You changed the email address of your Death Notifier account. " . + "Until you verify your email address, you will not receive any notifications. " . + "You can verify your new email address by clicking the link below. " . + "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . + "\n" . + "Verify: $verify_path" . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/ChangedPasswordEmail.php b/src/main/php/com/fwdekker/deathnotifier/mailer/ChangedPasswordEmail.php new file mode 100644 index 0000000..f0b97c5 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/ChangedPasswordEmail.php @@ -0,0 +1,46 @@ +type = self::TYPE; + $this->recipient = $recipient; + } + + + public function getSubject(): string + { + return "Your password has been changed"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + + return + "You changed the password of your Death Notifier account." . + "\n\n" . + "If you did not change the password of your account, go to the Death Notifier website and use the forgot " . + "password option to change your password back." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php b/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php new file mode 100644 index 0000000..167280a --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/Email.php @@ -0,0 +1,76 @@ +> $config the software configuration + * @return string the body of the email + */ + public abstract function getBody(array $config): string; + + + /** + * Deserializes an email back into an instance. + * + * Only types of emails that are known can be deserialized. + * + * @param string $type the type of email to deserialize + * @param string $recipient the intended recipient of the email + * @param string $arg1 the first argument to construct the email + * @param string $arg2 the second argument to construct the email + * @return Email a deserialized email + * @throws Exception if the `type` is not recognized + */ + public static function deserialize(string $type, string $recipient, string $arg1, string $arg2): Email + { + return match ($type) { + RegisterEmail::TYPE => new RegisterEmail($recipient, $arg1), + VerifyEmailEmail::TYPE => new VerifyEmailEmail($recipient, $arg1), + ChangedEmailEmail::TYPE => new ChangedEmailEmail($recipient, $arg1), + ChangedPasswordEmail::TYPE => new ChangedPasswordEmail($recipient), + ResetPasswordEmail::TYPE => new ResetPasswordEmail($recipient, $arg1), + NotifyArticleDeletedEmail::TYPE => new NotifyArticleDeletedEmail($recipient, $arg1), + NotifyArticleUndeletedEmail::TYPE => new NotifyArticleUndeletedEmail($recipient, $arg1), + NotifyStatusChangedEmail::TYPE => new NotifyStatusChangedEmail($recipient, $arg1, $arg2), + default => throw new Exception("Unknown email type $type."), + }; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/Mailer.php b/src/main/php/com/fwdekker/deathnotifier/mailer/Mailer.php new file mode 100644 index 0000000..65d48c1 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/Mailer.php @@ -0,0 +1,149 @@ +> The configuration to use for mailing. + */ + private array $config; + /** + * @var PDO The database connection to interact with. + */ + private PDO $conn; + + + /** + * Constructs a new mailer. + * + * @param array> $config the configuration to use for mailing + * @param Logger $logger the logger to use for logging + * @param PDO $conn the connection to the email database + */ + public function __construct(array $config, Logger $logger, PDO $conn) + { + $this->logger = $logger; + $this->config = $config; + $this->conn = $conn; + } + + + /** + * Populates the database with the necessary structures for emails. + * + * @return void + */ + public function install(): void + { + $this->conn->exec("CREATE TABLE email_tasks(type TEXT NOT NULL, + recipient TEXT NOT NULL, + arg1 TEXT NOT NULL DEFAULT(''), + arg2 TEXT NOT NULL DEFAULT(''), + PRIMARY KEY (type, recipient, arg1, arg2));"); + } + + + /** + * Queues an email to be sent. + * + * @param Email $email the email to queue + * @return Response a satisfied `Response` if the email was queued, or an unsatisfied `Response` otherwise + */ + public function queue_email(Email $email): Response + { + $stmt = $this->conn->prepare("INSERT OR IGNORE INTO email_tasks (type, recipient, arg1, arg2) + VALUES (:type, :recipient, :arg1, :arg2);"); + $stmt->bindValue(":type", $email->type); + $stmt->bindValue(":recipient", $email->recipient); + $stmt->bindValue(":arg1", $email->arg1); + $stmt->bindValue(":arg2", $email->arg2); + + return $stmt->execute() ? Response::satisfied() : Response::unsatisfied(null); + } + + /** + * Sends all emails in the queue. + * + * @return void + */ + public function process_queue(): void + { + // Open mailer + $mailer = new PHPMailer(); + $mailer->IsSMTP(); + $mailer->CharSet = "UTF-8"; + + $mailer->SMTPAuth = true; + $mailer->SMTPDebug = SMTP::DEBUG_OFF; + $mailer->SMTPKeepAlive = true; + $mailer->SMTPSecure = PHPMailer::ENCRYPTION_SMTPS; + $mailer->Host = $this->config["mail"]["host"]; + $mailer->Port = $this->config["mail"]["port"]; + $mailer->Username = $this->config["mail"]["username"]; + $mailer->Password = $this->config["mail"]["password"]; + try { + $mailer->setFrom($this->config["mail"]["username"], $this->config["mail"]["from_name"]); + } catch (MailerException $exception) { + $this->logger->error("Failed to set 'from' address while processing queue.", ["cause" => $exception]); + $mailer->smtpClose(); + } + + // Get queue + $stmt = $this->conn->prepare("SELECT type, recipient, arg1, arg2 FROM email_tasks;"); + $stmt->execute(); + $email_tasks = $stmt->fetchAll(PDO::FETCH_ASSOC); + + // Process queue + $stmt = $this->conn->prepare("DELETE FROM email_tasks + WHERE type=:type AND recipient=:recipient AND arg1=:arg1 AND arg2=:arg2;"); + $stmt->bindParam(":type", $type); + $stmt->bindParam(":recipient", $recipient); + $stmt->bindParam(":arg1", $arg1); + $stmt->bindParam(":arg2", $arg2); + foreach ($email_tasks as ["type" => $type, "recipient" => $recipient, "arg1" => $arg1, "arg2" => $arg2]) { + try { + $email = Email::deserialize($type, $recipient, $arg1, $arg2); + $mailer->Subject = $email->getSubject(); + $mailer->Body = $email->getBody($this->config); + + try { + $mailer->addAddress($recipient); + $mailer->send(); + } catch (MailerException $exception) { + $this->logger->error( + "Failed to send mail.", + ["cause" => $exception, "email" => $email] + ); + $mailer->getSMTPInstance()->reset(); + } + + $mailer->clearAddresses(); + } catch (Exception $exception) { + $this->logger->error( + "Failed to send mail.", + ["cause" => $exception] + ); + $mailer->getSMTPInstance()->reset(); + } + + $stmt->execute(); + } + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyArticleDeletedEmail.php b/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyArticleDeletedEmail.php new file mode 100644 index 0000000..047ca77 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyArticleDeletedEmail.php @@ -0,0 +1,59 @@ +type = self::TYPE; + $this->recipient = $recipient; + + $this->name = $name; + $this->arg1 = $name; + } + + + public function getSubject(): string + { + return "$this->name article has been deleted"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + + return + "The Wikipedia article about $this->name has been deleted. " . + "Death Notifier is now unable to send you a notification if $this->name dies. " . + "If the Wikipedia article is ever re-created, Death Notifier will automatically resume tracking this " . + "article, and you will receive another notification." . + "\n\n" . + "You are receiving this message because of the preferences in your Death Notifier account. " . + "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . + "preferences." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyArticleUndeletedEmail.php b/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyArticleUndeletedEmail.php new file mode 100644 index 0000000..34849dd --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyArticleUndeletedEmail.php @@ -0,0 +1,57 @@ +type = self::TYPE; + $this->recipient = $recipient; + + $this->name = $name; + $this->arg1 = $name; + } + + + public function getSubject(): string + { + return "$this->name article has been re-created"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + + return + "The Wikipedia article about $this->name has been re-created. " . + "Death Notifier will once again track the article and notify you if $this->name dies." . + "\n\n" . + "You are receiving this message because of the preferences in your Death Notifier account. " . + "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . + "preferences." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyStatusChangedEmail.php b/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyStatusChangedEmail.php new file mode 100644 index 0000000..87fda6e --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/NotifyStatusChangedEmail.php @@ -0,0 +1,66 @@ +type = self::TYPE; + $this->recipient = $recipient; + + $this->name = $name; + $this->arg1 = $name; + + $this->new_status = $new_status; + $this->arg2 = $new_status; + } + + + public function getSubject(): string + { + return "$this->name may be $this->new_status"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + + return + "Someone has edited Wikipedia to state that $this->name is $this->new_status. " . + "For more information, read their Wikipedia article at " . + "https://en.wikipedia.org/wiki/" . rawurlencode($this->name) . + "\n\n" . + "You are receiving this message because of the preferences in your Death Notifier account. " . + "To unsubscribe from these messages, go to the Death Notifier website, log in, and change your email " . + "preferences." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/RegisterEmail.php b/src/main/php/com/fwdekker/deathnotifier/mailer/RegisterEmail.php new file mode 100644 index 0000000..43db224 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/RegisterEmail.php @@ -0,0 +1,64 @@ +type = self::TYPE; + $this->recipient = $recipient; + + $this->token = $token; + $this->arg1 = $token; + } + + public function getSubject(): string + { + return "Welcome to Death Notifier"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You have successfully created an account with Death Notifier. " . + "Welcome!" . + "\n\n" . + "Until you verify your email address, you will not receive any notifications. " . + "You can verify your email address by clicking the link below. " . + "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes." . + "\n" . + "Verify: $verify_path" . + "\n\n" . + "If you did not create this account, you can delete the account. " . + "Go to the Death Notifier website, reset your password, log in, and delete the account." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/ResetPasswordEmail.php b/src/main/php/com/fwdekker/deathnotifier/mailer/ResetPasswordEmail.php new file mode 100644 index 0000000..d17cab9 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/ResetPasswordEmail.php @@ -0,0 +1,62 @@ +type = self::TYPE; + $this->recipient = $recipient; + + $this->token = $token; + $this->arg1 = $token; + } + + + public function getSubject(): string + { + return "Reset your password"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + $verify_path = + "$base_path?action=reset-password&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You requested a password reset link for your Death Notifier account. " . + "You can choose a new password by clicking the link below. " . + "This link expires after " . UserManager::MINUTES_VALID_PASSWORD_RESET . " minutes." . + "\n" . + "Reset password: $verify_path" . + "\n\n" . + "If you did not request a new password, you can safely ignore this message." . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/mailer/VerifyEmailEmail.php b/src/main/php/com/fwdekker/deathnotifier/mailer/VerifyEmailEmail.php new file mode 100644 index 0000000..0f333af --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/mailer/VerifyEmailEmail.php @@ -0,0 +1,60 @@ +type = self::TYPE; + $this->recipient = $recipient; + + $this->token = $token; + $this->arg1 = $token; + } + + + public function getSubject(): string + { + return "Verify your email address"; + } + + public function getBody(array $config): string + { + $base_path = $config["server"]["base_path"]; + $verify_path = "$base_path?action=verify-email&email=" . rawurlencode($this->recipient) . "&token=$this->token"; + + return + "You requested a new verification link for your Death Notifier account. " . + "You can verify your email address by clicking the link below. " . + "This link will expire after " . UserManager::MINUTES_VALID_VERIFICATION . " minutes. " . + "Until you verify your email address, you will not receive any notifications." . + "\n" . + "Verify: $verify_path" . + "\n\n" . + $base_path; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/trackings/PersonStatus.php b/src/main/php/com/fwdekker/deathnotifier/trackings/PersonStatus.php new file mode 100644 index 0000000..3bf6f85 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/trackings/PersonStatus.php @@ -0,0 +1,15 @@ + $statuses the current statuses of + * @param array $statuses the current statuses of * people * @return void */ @@ -376,15 +384,3 @@ class TrackingManager } } } - - -/** - * The status assigned to a person. - */ -enum PersonStatus: string -{ - case Alive = "alive"; - case PossiblyAlive = "possibly alive"; - case Missing = "missing"; - case Dead = "dead"; -} diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/IsEmailRule.php b/src/main/php/com/fwdekker/deathnotifier/validator/IsEmailRule.php new file mode 100644 index 0000000..cc8d3f2 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/validator/IsEmailRule.php @@ -0,0 +1,26 @@ + $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 Response|null `null` if `$inputs[$key]` is an email address, or an unsatisfied `Response` otherwise + */ + public function check(array $inputs, string $key): ?Response + { + return !isset($inputs[$key]) || !filter_var($inputs[$key], FILTER_VALIDATE_EMAIL) + ? Response::unsatisfied($this->override_message ?? "Enter a valid email address.", $key) + : null; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/IsNotBlankRule.php b/src/main/php/com/fwdekker/deathnotifier/validator/IsNotBlankRule.php new file mode 100644 index 0000000..3de68af --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/validator/IsNotBlankRule.php @@ -0,0 +1,27 @@ + $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 Response|null `null` if `trim($inputs[$key])` is not an empty string, or an unsatisfied `Response` + * otherwise + */ + public function check(array $inputs, string $key): ?Response + { + return !isset($inputs[$key]) || trim($inputs[$key]) === "" + ? Response::unsatisfied($this->override_message ?? "Use at least one character.", $key) + : null; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/IsSetRule.php b/src/main/php/com/fwdekker/deathnotifier/validator/IsSetRule.php new file mode 100644 index 0000000..ffd2538 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/validator/IsSetRule.php @@ -0,0 +1,26 @@ + $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 Response|null `null` if `isset($inputs[$key])`, or an unsatisfied `Response` otherwise + */ + public function check(array $inputs, string $key): ?Response + { + return !isset($inputs[$key]) + ? Response::unsatisfied($this->override_message ?? "Field '" . htmlentities($key) . "' required.", $key) + : null; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/LengthRule.php b/src/main/php/com/fwdekker/deathnotifier/validator/LengthRule.php new file mode 100644 index 0000000..299609e --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/validator/LengthRule.php @@ -0,0 +1,67 @@ +min_length = $min_length; + $this->max_length = $max_length; + } + + + /** + * Checks whether the input is of the specified length. + * + * @param array $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 Response|null `null` if the input is of the specified length, or an unsatisfied `Response` otherwise + */ + public function check(array $inputs, string $key): ?Response + { + if (!isset($inputs[$key])) + return Response::unsatisfied( + $this->override_message ?? "Missing input '$key'.", + $key + ); + else if ($this->min_length !== null && strlen($inputs[$key]) < $this->min_length) + return Response::unsatisfied( + $this->override_message ?? "Use at least $this->min_length character(s).", + $key + ); + else if ($this->max_length !== null && strlen($inputs[$key]) > $this->max_length) + return Response::unsatisfied( + $this->override_message ?? "Use at most $this->max_length character(s).", + $key + ); + else + return null; + } +} diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/Rule.php b/src/main/php/com/fwdekker/deathnotifier/validator/Rule.php new file mode 100644 index 0000000..26c9dc8 --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/validator/Rule.php @@ -0,0 +1,42 @@ +override_message = $override_message; + } + + + /** + * Checks whether the rule holds for `$inputs[$key]`. + * + * Implementations should never assume that the `$inputs[$key]` is set. + * + * @param array $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 Response|null `null` if the rule holds, or an unsatisfied `Response` otherwise + */ + public abstract function check(array $inputs, string $key): ?Response; +} diff --git a/src/main/php/com/fwdekker/deathnotifier/validator/Validator.php b/src/main/php/com/fwdekker/deathnotifier/validator/Validator.php new file mode 100644 index 0000000..68c2c0f --- /dev/null +++ b/src/main/php/com/fwdekker/deathnotifier/validator/Validator.php @@ -0,0 +1,76 @@ + $inputs the array of inputs in which to check the values + * @param array $rule_sets maps keys in `inputs` to an array of `Rule`s to be checked + * @return Response|null `null` if all rules are satisfied, or an unsatisfied `Response` otherwise + */ + static function validate_inputs(array $inputs, array $rule_sets): ?Response + { + foreach ($rule_sets as $key => $rules) { + foreach ($rules as $rule) { + $is_valid = $rule->check($inputs, $key); + if ($is_valid !== null) + return $is_valid; + } + } + + return null; + } + + + /** + * Validates that the user is logged in. + * + * @param array $session the session to check + * @return Response|null `null` if the user is logged in, or an unsatisfied `Response` otherwise + */ + static function validate_logged_in(array $session): ?Response + { + if (!isset($session["uuid"])) + return Response::unsatisfied("You must be logged in to perform this action."); + + return null; + } + + /** + * Validates that the user is logged out. + * + * @param array $session the session to check + * @return Response|null `null` if the user is logged out, or an unsatisfied `Response` otherwise + */ + static function validate_logged_out(array $session): ?Response + { + if (isset($session["uuid"])) + return Response::unsatisfied("You must be logged out to perform this action."); + + return null; + } + + /** + * Validates that the array contains the correct token. + * + * @param array $token_array the array with key `token` + * @param string $token the expected token + * @return Response|null `null` if the token is correct, or an unsatisfied `Response` otherwise + */ + static function validate_token(array $token_array, string $token): ?Response + { + if (!isset($token_array["token"]) || $token_array["token"] !== $token) + return Response::unsatisfied("Invalid request token. Please refresh the page and try again."); + + return null; + } +} diff --git a/src/test/php/DatabaseTestCase.php b/src/test/php/com/fwdekker/deathnotifier/DatabaseTestCase.php similarity index 94% rename from src/test/php/DatabaseTestCase.php rename to src/test/php/com/fwdekker/deathnotifier/DatabaseTestCase.php index de42289..3fd5d39 100644 --- a/src/test/php/DatabaseTestCase.php +++ b/src/test/php/com/fwdekker/deathnotifier/DatabaseTestCase.php @@ -1,7 +1,9 @@ check(["email" => "exampleexample.com"], "email"); + $is_valid = $rule->check(["email" => "example.com"], "email"); $this->assertNotNull($is_valid); $this->assertEquals("Enter a valid email address.", $is_valid->payload["message"]); diff --git a/src/test/php/IsNotBlankRuleTest.php b/src/test/php/com/fwdekker/deathnotifier/validator/IsNotBlankRuleTest.php similarity index 93% rename from src/test/php/IsNotBlankRuleTest.php rename to src/test/php/com/fwdekker/deathnotifier/validator/IsNotBlankRuleTest.php index 481adcd..64dda58 100644 --- a/src/test/php/IsNotBlankRuleTest.php +++ b/src/test/php/com/fwdekker/deathnotifier/validator/IsNotBlankRuleTest.php @@ -1,12 +1,12 @@