From 64b1ec0fabbf7328a79a20ff58502ebfa80fad8b Mon Sep 17 00:00:00 2001
From: Jonas Kohl
Date: Thu, 10 Oct 2024 17:33:13 +0200
Subject: Break up actions into individual files

---
 src/index.php | 1552 +--------------------------------------------------------
 1 file changed, 24 insertions(+), 1528 deletions(-)

(limited to 'src/index.php')

diff --git a/src/index.php b/src/index.php
index 39ff77a..88c555e 100644
--- a/src/index.php
+++ b/src/index.php
@@ -1,9 +1,5 @@
 <?php
 
-use FFMpeg\Coordinate\TimeCode;
-use FFMpeg\FFMpeg;
-use FFMpeg\FFProbe;
-use Gregwar\Captcha\CaptchaBuilder;
 use mystic\forum\Database;
 use mystic\forum\exceptions\DatabaseConnectionException;
 use mystic\forum\Messaging;
@@ -12,19 +8,11 @@ use mystic\forum\orm\Post;
 use mystic\forum\orm\Topic;
 use mystic\forum\orm\TopicLogMessage;
 use mystic\forum\orm\User;
-use mystic\forum\orm\UserPermissions;
-use mystic\forum\utils\FileUtils;
 use mystic\forum\utils\RequestUtils;
-use mystic\forum\utils\ValidationUtils;
-use Symfony\Component\Mailer\Exception\TransportException;
-use Symfony\Component\Mailer\Transport;
-use Symfony\Component\Mime\Address;
-use Symfony\Component\Mime\Email;
-use Symfony\Contracts\Service\Attribute\Required;
 
 header_remove("X-Powered-By");
 
-const MYSTICBB_VERSION = "0.3.1";
+const MYSTICBB_VERSION = "0.4.0";
 
 if (($_SERVER["HTTP_USER_AGENT"] ?? "") === "") {
     http_response_code(403);
@@ -33,6 +21,8 @@ if (($_SERVER["HTTP_USER_AGENT"] ?? "") === "") {
 
 define("REGISTRATION_ENABLED", isTrue(env("REGISTRATION_ENABLED") ?? ""));
 
+const __ROOT__ = __DIR__;
+
 session_name("fsid");
 session_start();
 
@@ -299,1526 +289,32 @@ $GLOBALS["currentUser"] = &$currentUser;
 
 // initialization finished
 
-if ($_action === "auth") {
-    if ($currentUser) {
-        header("Location: " . ($_GET["next"] ?? "."));
-        exit;
-    }
-
-    if (RequestUtils::isRequestMethod("POST")) {
-        $formId = "login";
-        $username = RequestUtils::getRequiredField("username", $formId);
-        $password = RequestUtils::getRequiredField("password", $formId);
-
-        $user = new User();
-        $user->name = $username;
-        if (!$db->fetchWhere($user, "name") || !password_verify($password, $user->passwordHash)) {
-            RequestUtils::triggerFormError(__("Username or password incorrect!"), $formId);
-        }
-        
-        if (!$user->activated) {
-            RequestUtils::triggerFormError(__("Please activate your user account first!"), $formId);
-        }
-
-        RequestUtils::setAuthorizedUser($user);
-        header("Location: " . ($_GET["next"] ?? "."));
-    } else {
-        _view("template_start", ["_title" => __("Log in")]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("form_login");
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-} elseif ($_action === "register") {
-    if ($currentUser) {
-        header("Location: " . ($_GET["next"] ?? "."));
-        exit;
-    }
-
-    if (!REGISTRATION_ENABLED) {
-        http_response_code(403);
-        msg_error(__("Public registration disabled"));
-        exit;
-    }
-
-    if (RequestUtils::isRequestMethod("POST")) {
-        $formId = "register";
-        $doNotFill = $_POST["username"] ?? null;
-        if (!empty($doNotFill)) {
-            sleep(10);
-            http_response_code(204);
-            exit;
-        }
-        $username = RequestUtils::getRequiredField("df82a9bc21", $formId);
-        $password = RequestUtils::getRequiredField("password", $formId);
-        $passwordRetype = RequestUtils::getRequiredField("password_retype", $formId);
-        $email = trim(RequestUtils::getRequiredField("email", $formId));
-        $displayName = RequestUtils::getRequiredField("display_name", $formId);
-        $captcha = RequestUtils::getRequiredField("captcha", $formId);
-
-        if ($captcha !== ($_SESSION["captchaPhrase"] ?? null)) {
-            RequestUtils::triggerFormError(__("Incorrect CAPTCHA text!"), $formId);
-        }
-        
-        // usernames are always lowercase
-        $username = strtolower($username);
-
-        if ($password !== $passwordRetype) {
-            RequestUtils::triggerFormError(__("Passwords do not match!"), $formId);
-        }
-
-        if (strlen($password) < 8) {
-            RequestUtils::triggerFormError(__("Password too short! Your password must consist of 8 or more characters"), $formId);
-        }
-
-        if (!ValidationUtils::isUsernameValid($username)) {
-            RequestUtils::triggerFormError(__("Username has an invalid format"), $formId);
-        }
-        
-        if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) {
-            RequestUtils::triggerFormError(__("Invalid email address"), $formId);
-        }
-
-        $user = new User();
-        $user->name = $username;
-        $user->email = $email;
-
-        if ($db->fetchWhere($user, "name")) {
-            RequestUtils::triggerFormError(__("This username is already taken!"), $formId);
-        }
-
-        if ($db->fetchWhere($user, "email")) {
-            RequestUtils::triggerFormError(__("This email address is already in use!"), $formId);
-        }
-
-        // re-create user so we don't forget to clear properties set by the above queries
-
-        $user = new User();
-        $user->id = $db->generateId();
-        $user->displayName = $displayName;
-        $user->name = $username;
-        $user->email = $email;
-        $user->passwordHash = password_hash($password, PASSWORD_DEFAULT);
-        $user->permissionMask = UserPermissions::GROUP_USER;
-        $user->passwordResetRequired = false;
-        $user->activated = false;
-        $user->activationToken = $db->generateId(12);
-        $user->created = new \DateTimeImmutable();
-
-        Transport::fromDsn(env("MAILER_DSN"))->send(
-            (new Email())
-                ->from(env("MAILER_FROM"))
-                ->to(new Address($email, $displayName))
-                ->text(__(
-                    "Welcome to %forum_title%, %user_display_name%!\n" .
-                    "\n" .
-                    "Please activate your account by clicking the link below:\n" .
-                    "%activation_link%\n" .
-                    "\n" .
-                    "Kind regards,\n" .
-                    "%forum_copyright%",
-                    params: [
-                        "forum_title" => (env("MYSTIC_FORUM_TITLE") ?? "Forum"),
-                        "user_display_name" => $displayName,
-                        "activation_link" => env("PUBLIC_URL") . "?_action=verifyemail&token=" . urlencode($user->activationToken) . "&sig=" . urlencode(base64_encode(hash("sha256", env("SECRET") . $user->activationToken . $user->id, true))),
-                        "forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
-                    ]
-                ))
-                ->subject(__("Please activate your account"))
-        );
-
-        $db->insert($user);
-
-        msg_info(__("Your account has been created!\nPlease check your emails for an activation link!"));
-    } else {
-        _view("template_start", ["_title" => __("Register")]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("form_register");
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-} elseif ($_action === "verifyemail") {
-    RequestUtils::ensureRequestMethod("GET");
-    $token = $_GET["token"] ?? throw new Exception("Missing token");
-    $sig = $_GET["sig"] ?? throw new Exception("Missing signature");
-
-    $user = new User();
-    $user->activationToken = $token;
-
-    if (!$db->fetchWhere($user, "activation_token")) {
-        http_response_code(400);
-        msg_error(__("Invalid token"));
-        exit;
-    }
-
-    $expectedSignature = base64_encode(hash("sha256", env("SECRET") . $user->activationToken . $user->id, true));
-    
-    if ($expectedSignature !== $sig) {
-        http_response_code(400);
-        msg_error(__("Invalid signature."));
-        exit;
-    }
-
-    $isActivation = !$user->activated;
-    if ($isActivation) {
-        $user->activated = true;
-        $user->activationToken = "";
-
-        if (!$db->update($user)) {
-            http_response_code(400);
-            msg_error(__("Failed to update user"));
-            exit;
-        }
-
-        msg_info("?!HTML::" . __(
-            "Your account has been activated!\nPlease click %link%here%/link% to log in!",
-            [
-                "link" => '<a href="?_action=auth">',
-                "/link" => '</a>',
-            ]
-        ));
-    } else {
-        $oldEmail = $user->email;
-        $newEmail = $user->pendingEmail;
-
-        $user->activationToken = "";
-        $user->email = $user->pendingEmail;
-        $user->pendingEmail = null;
-        $user->pendingEmailCreated = null;
-
-        if (!$db->update($user)) {
-            http_response_code(400);
-            msg_error(__("Failed to update user"));
-            exit;
-        }
-
-        $transport = Transport::fromDsn(env("MAILER_DSN"));
-
-        try {
-            $transport->send(
-                (new Email())
-                    ->from(env("MAILER_FROM"))
-                    ->to(new Address($oldEmail, $user->displayName))
-                    ->text(__(
-                        "Hello, %user_display_name%!\n" .
-                        "\n" .
-                        "Your email address has been successfully changed from %old_email% to %new_email%!\n" .
-                        "\n" .
-                        "Kind regards,\n" .
-                        "%forum_copyright%",
-                        params: [
-                            "forum_title" => (env("MYSTIC_FORUM_TITLE") ?? "Forum"),
-                            "user_display_name" => $user->displayName,
-                            "old_email" => $oldEmail,
-                            "new_email" => $newEmail,
-                            "forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
-                        ]
-                    ))
-                    ->subject(__("Email address changed"))
-            );
-        } catch (TransportException $_) {
-            // fail silently
-        }
-
-        try {
-            $transport->send(
-                (new Email())
-                    ->from(env("MAILER_FROM"))
-                    ->to(new Address($newEmail, $user->displayName))
-                    ->text(__(
-                        "Hello, %user_display_name%!\n" .
-                        "\n" .
-                        "Your email address has been successfully changed from %old_email% to %new_email%!\n" .
-                        "\n" .
-                        "Kind regards,\n" .
-                        "%forum_copyright%",
-                        params: [
-                            "forum_title" => (env("MYSTIC_FORUM_TITLE") ?? "Forum"),
-                            "user_display_name" => $user->displayName,
-                            "old_email" => $oldEmail,
-                            "new_email" => $newEmail,
-                            "forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
-                        ]
-                    ))
-                    ->subject(__("Email address changed"))
-            );
-        } catch (TransportException $_) {
-            // fail silently
-        }
-
-        msg_info("?!HTML::" . __(
-            "Your email address has been changed successfully!\nPlease click %link%here%/link% to return to your profile!",
-            [
-                "link" => '<a href="?_action=viewuser&user=' . htmlentities(urlencode($user->id)) . '">',
-                "/link" => '</a>',
-            ]
-        ));
-    }
-} elseif ($_action === "logout") {
-    RequestUtils::unsetAuthorizedUser();
-    header("Location: " . ($_GET["next"] ?? "."));
-} elseif ($_action === "viewtopic") {
-    $formId = "addpost";
-    $topicId = $_GET["topic"] ?? throw new Exception("Missing topic id");
-    $topic = new Topic();
-    $topic->id = $topicId;
-    if (!$db->fetch($topic)) {
-        http_response_code(404);
-        msg_error("No topic exists with this id");
-        exit;
-    }
-
-    if (RequestUtils::isRequestMethod("POST")) {
-        if (!$currentUser) {
-            http_response_code(403);
-            msg_error("You need to be logged in to add new posts!");
-            exit;
-        }
-        
-        if ($topic->isLocked) {
-            http_response_code(403);
-            msg_error("This topic is locked!");
-            exit;
-        }
-
-        $attachments = reArrayFiles($_FILES["files"]);
-
-        if (count($attachments) > MAX_ATTACHMENT_COUNT)
-            RequestUtils::triggerFormError(__("Too many attachments"), $formId);
-
-        // check all attachments before saving one
-        foreach ($attachments as $att) {
-            if ($att["size"] > MAX_ATTACHMENT_SIZE) {
-                RequestUtils::triggerFormError(__("Individual file size exceeded"), $formId);
-            }
-        }
-
-        $message = trim(RequestUtils::getRequiredField("message", $formId));
-
-        if (strlen($message) < 1 || strlen($message) > 0x8000) {
-            RequestUtils::triggerFormError(__("Message too short or too long!"), $formId);
-        }
-
-        $item = new Post();
-        $item->id = $db->generateId();
-        $item->authorId = $currentUser->id;
-        $item->topicId = $topicId;
-        $item->content = $message;
-        $item->postDate = new DateTimeImmutable();
-        $item->deleted = false;
-        $item->edited = false;
-
-        $db->insert($item);
-
-        foreach ($attachments as $att) {
-            [
-                "name" => $name,
-                "type" => $type,
-                "tmp_name" => $tmpName,
-            ] = $att;
-            $attachment = new Attachment();
-            $attachment->id = $db->generateId();
-            $attachment->name = $name;
-            $attachment->mimeType = $type;
-            $attachment->postId = $item->id;
-            $attachment->contents = file_get_contents($tmpName);
-            
-            $db->insert($attachment);
-        }
-
-        header("Location: ?_action=viewtopic&topic=" . urlencode($topicId) . "#form");
-    } else {
-        /** @var Post[] $posts */
-        $posts = $db->fetchCustom(Post::class, 'WHERE topic_id = $1 ORDER BY post_date', [ $topicId ]);
-        /** @var TopicLogMessage[] $logMessages */
-        $logMessages = $db->fetchCustom(TopicLogMessage::class, 'WHERE topic_id = $1 ORDER BY post_date', [ $topicId ]);
-        $userCache = [];
-
-        $topicAuthor = null;
-        if ($topic->createdBy !== null) {
-            $topicAuthor = new User();
-            $topicAuthor->id = $topic->createdBy;
-            if (!$db->fetch($topicAuthor)) {
-                $topicAuthor = null;
-            }
-        }
-
-        $allItems = [...$posts, ...$logMessages];
-        usort($allItems, fn(Post|TopicLogMessage $a, Post|TopicLogMessage $b): int => $a->postDate <=> $b->postDate);
-
-        _view("template_start", ["_title" => $topic->title]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("view_topic_start", ["topic" => $topic, "topicAuthor" => $topicAuthor]);
-
-        foreach ($allItems as $item) {
-            /** @var ?User $postAuthor */
-            $postAuthor = null;
-            if ($item->authorId !== null && !isset($userCache[$item->authorId])) {
-                $usr = new User();
-                $usr->id = $item->authorId;
-                if ($db->fetch($usr))
-                    $userCache[$item->authorId] = &$usr;
-            }
-            if (isset($userCache[$item->authorId]))
-                $postAuthor = &$userCache[$item->authorId];
-
-            if ($item instanceof Post) {
-                $attachments = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $item->id ]);
-
-                _view("view_post", [
-                    "post" => $item,
-                    "postAuthor" => $postAuthor,
-                    "topicAuthor" => $topicAuthor,
-                    "attachments" => $attachments,
-                    "topic" => $topic,
-                ]);
-            } else {
-                _view("view_topiclog", [
-                    "logMessage" => $item,
-                    "postAuthor" => $postAuthor,
-                    "topicAuthor" => $topicAuthor,
-                    "topic" => $topic,
-                ]);
-            }
-        }
-
-        _view("view_topic_end");
-
-        if ($topic->isLocked) {
-            _view("view_topic_locked");
-        } elseif ($currentUser) {
-            _view("form_addpost");
-        } else {
-            _view("view_logintoreply");
-        }
-
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-
-} elseif ($_action === "newtopic") {
-    if (!$currentUser) {
-        http_response_code(403);
-        msg_error("You need to be logged in to create new topics!");
-        exit;
-    }
-
-    if (RequestUtils::isRequestMethod("POST")) {
-        $formId = "newtopic";
-        $title = trim(RequestUtils::getRequiredField("title", $formId));
-        $message = trim(RequestUtils::getRequiredField("message", $formId));
-
-        $attachments = reArrayFiles($_FILES["files"]);
-
-        if (count($attachments) > MAX_ATTACHMENT_COUNT)
-            RequestUtils::triggerFormError(__("Too many attachments"), $formId);
-
-        // check all attachments before saving one
-        foreach ($attachments as $att) {
-            if ($att["size"] > MAX_ATTACHMENT_SIZE) {
-                RequestUtils::triggerFormError(__("Individual file size exceeded"), $formId);
-            }
-        }
-
-        if (strlen($title) < 1 || strlen($title) > 255) {
-            RequestUtils::triggerFormError(__("Title too short or too long!"), $formId);
-        }
-
-        if (strlen($message) < 1 || strlen($message) > 0x8000) {
-            RequestUtils::triggerFormError(__("Message too short or too long!"), $formId);
-        }
-
-        $topic = new Topic();
-        $topic->createdBy = $currentUser->id;
-        $topic->id = $db->generateId();
-        $topic->title = $title;
-        $topic->creationDate = new DateTimeImmutable();
-        $topic->isLocked = false;
-
-        $db->insert($topic);
-
-        $item = new Post();
-        $item->id = $db->generateId();
-        $item->authorId = $currentUser->id;
-        $item->topicId = $topic->id;
-        $item->content = $message;
-        $item->postDate = $topic->creationDate;
-        $item->deleted = false;
-        $item->edited = false;
-
-        $db->insert($item);
-
-        foreach ($attachments as $att) {
-            [
-                "name" => $name,
-                "type" => $type,
-                "tmp_name" => $tmpName,
-            ] = $att;
-            $attachment = new Attachment();
-            $attachment->id = $db->generateId();
-            $attachment->name = $name;
-            $attachment->mimeType = $type;
-            $attachment->postId = $item->id;
-            $attachment->contents = file_get_contents($tmpName);
-            
-            $db->insert($attachment);
-        }
-
-        header("Location: ?_action=viewtopic&topic=" . urlencode($topic->id));
-    } else {
-        _view("template_start", ["_title" => __("New topic")]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("form_newtopic");
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-} elseif ($_action === "lookupuser") {
-    RequestUtils::ensureRequestMethod("GET");
-    $userHandle = $_GET["handle"] ?? throw new Exception("Missing handle");
-
-    $user = new User();
-    $user->name = $userHandle;
-
-    if (!$db->fetchWhere($user, "name")) {
-        http_response_code(404);
-        msg_error(__("No user with name @%user_handle%", [ "user_handle" => $userHandle ]));
-        exit;
-    }
-
-    header("Location: ./?_action=viewuser&user=" . urlencode($user->id));
-} elseif ($_action === "viewuser") {
-    $userId = $_GET["user"] ?? throw new Exception("Missing user id");
-    $user = new User();
-    $user->id = $userId;
-    if (!$db->fetch($user)) {
-        http_response_code(404);
-        msg_error(__("No user exists with this id"));
-        exit;
-    }
-
-    $lastNameChangeTooRecent = false;
-    $isOwnProfile = $user->id === $currentUser?->id;
-    if ($isOwnProfile && $user->nameLastChanged !== null) {
-        $diff = $user->nameLastChanged->diff(new DateTime());
-        $diffSeconds = (new DateTime())->setTimestamp(0)->add($diff)->getTimestamp();
-        $diffDays = $diffSeconds / 60.0 / 60.0 / 24.0 / 30.0;
-        $lastNameChangeTooRecent = $diffDays <= 30;
-    }
-
-    if (RequestUtils::isRequestMethod("POST")) {
-        $formId = $_POST["form_id"] ?? null;
-        if ($formId === null) {
-            http_response_code(400);
-            msg_error("Missing form_id");
-            exit;
-        }
-
-        if ($formId === "update_password") {
-            if (!$currentUser) {
-                http_response_code(403);
-                msg_error(__("You must be logged in to update your password"));
-                exit;
-            }
-
-            if (!$isOwnProfile) {
-                RequestUtils::triggerFormError(__("You don't have permission to update this user's password"), $formId);
-            }
-
-            RequestUtils::ensureRequestMethod("POST");
-            $currentPassword = RequestUtils::getRequiredField("current_password", $formId);
-            $newPassword = RequestUtils::getRequiredField("new_password", $formId);
-            $retypePassword = RequestUtils::getRequiredField("retype_password", $formId);
-
-            if (!password_verify($currentPassword, $currentUser->passwordHash)) {
-                RequestUtils::triggerFormError(__("Current password is incorrect"), $formId);
-            }
-
-            if ($newPassword !== $retypePassword) {
-                RequestUtils::triggerFormError(__("New passwords don't match"), $formId);
-            }
-
-            if (strlen($newPassword) < 8) {
-                RequestUtils::triggerFormError(__("Password too short! Your password must consist of 8 or more characters"), $formId);
-            }
-
-            $currentUser->passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);
-            
-            if (!$db->update($currentUser)) {
-                RequestUtils::triggerFormError(__("Failed to update password"), $formId);
-            }
-
-            header("Location: $_SERVER[REQUEST_URI]");
-        } elseif ($formId === "update_profile") {
-            if (!$currentUser) {
-                http_response_code(403);
-                msg_error(__("You must be logged in to update your profile"));
-                exit;
-            }
-
-            $canEdit = ($currentUser?->id === $user?->id && $user?->hasPermission(UserPermissions::EDIT_OWN_USER))
-                    || ($currentUser?->hasPermission(UserPermissions::EDIT_OTHER_USER));
-
-            if (!$canEdit) {
-                http_response_code(403);
-                msg_error(__("You don't have permission to update this profile"));
-                exit;
-            }
-
-            $displayName = RequestUtils::getRequiredField("display_name", $formId);
-            $pfpAction = RequestUtils::getRequiredField("pfp_action", $formId);
-
-            $userName = $_POST["name"] ?? $user->name;
-            $email = $_POST["email"] ?? $user->email;
-
-            $user->displayName = $displayName;
-
-            $userName = strtolower($userName);
-
-            if ($userName !== $user->name) {
-                if ($lastNameChangeTooRecent) {
-                    RequestUtils::triggerFormError(__("You can only change your username every 30 days!"), $formId);
-                } else {
-                    if (!ValidationUtils::isUsernameValid($userName))
-                        RequestUtils::triggerFormError(__("Invalid username!"), $formId);
-                    if (!ValidationUtils::isUsernameAvailable($db, $userName))
-                        RequestUtils::triggerFormError(__("This username is already taken!"), $formId);
-                    $user->name = $userName;
-                    $user->nameLastChanged = new DateTimeImmutable();
-                }
-            }
-
-            if ($email !== $user->email) {
-                if ($user->pendingEmailCreated !== null) {
-                    RequestUtils::triggerFormError(__("Please verify your email first!"), $formId);
-                } else {
-                    $queryUser = new User();
-                    $queryUser->email = $email;
-                    $queryUser->pendingEmail = $email;
-                    if ($db->fetchWhere($queryUser, "email") || $db->fetchWhere($queryUser, "pending_email")) {
-                        RequestUtils::triggerFormError(__("This email address is already in use!"), $formId);
-                    }
-                    $user->pendingEmail = $email;
-                    $user->pendingEmailCreated = new DateTimeImmutable();
-                    $user->activationToken = $db->generateId(12);
-
-                    try {
-                        Transport::fromDsn(env("MAILER_DSN"))->send(
-                            (new Email())
-                                ->from(env("MAILER_FROM"))
-                                ->to(new Address($email, $displayName))
-                                ->text(__(
-                                    "Hello, %user_display_name%!\n" .
-                                    "\n" .
-                                    "Please verify your new email address by clicking the link below:\n" .
-                                    "%verify_link%\n" .
-                                    "\n" .
-                                    "Kind regards,\n" .
-                                    "%forum_copyright%",
-                                    params: [
-                                        "forum_title" => (env("MYSTIC_FORUM_TITLE") ?? "Forum"),
-                                        "user_display_name" => $displayName,
-                                        "verify_link" => env("PUBLIC_URL") . "?_action=verifyemail&token=" . urlencode($user->activationToken) . "&sig=" . urlencode(base64_encode(hash("sha256", env("SECRET") . $user->activationToken . $user->id, true))),
-                                        "forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
-                                    ]
-                                ))
-                                ->subject(__("Please verify your email address"))
-                        );
-                    } catch (TransportException $_) {
-                        RequestUtils::triggerFormError(__("Failed to send verification email"), $formId);
-                    }
-                }
-            }
-
-            switch ($pfpAction) {
-                case "keep":
-                    // Do nothing
-                    break;
-                case "remove":
-                    $user->profilePicture = null;
-                    break;
-                case "replace": {
-                    if (!isset($_FILES["pfp"]) || $_FILES["pfp"]["error"] !== UPLOAD_ERR_OK) {
-                        RequestUtils::triggerFormError(__("Please upload an image to change your profile picture"), $formId);
-                    }
-                    $im = @imagecreatefromjpeg($_FILES["pfp"]["tmp_name"]);
-                    if ($im === false)
-                        $im = @imagecreatefrompng($_FILES["pfp"]["tmp_name"]);
-                    if ($im === false)
-                        RequestUtils::triggerFormError(__("Please upload a valid PNG or JPEG file"), $formId);
-                    /** @var \GdImage $im */
-                    $thumb = imagecreatetruecolor(64, 64);
-                    imagecopyresampled($thumb, $im, 0, 0, 0, 0, 64, 64, imagesx($im), imagesy($im));
-                    imagedestroy($im);
-                    $stream = fopen("php://memory", "w+");
-                    imagejpeg($thumb, $stream, 50);
-                    rewind($stream);
-                    imagedestroy($thumb);
-                    $user->profilePicture = stream_get_contents($stream);
-                    fclose($stream);
-                } break;
-                default:
-                    RequestUtils::triggerFormError("Invalid value for pfp_action", $formId);
-                    break;
-            }
-
-            if (!$db->update($user))
-                RequestUtils::triggerFormError(__("Failed to save changes", context: "Update profile"), $formId);
-
-            header("Location: $_SERVER[REQUEST_URI]");
-        } else {
-            msg_error("Invalid formId");
-        }
-    } else {
-        $posts = $db->fetchCustom(Post::class, 'WHERE author_id = $1 ORDER BY post_date DESC', [ $userId ]);
-        $topics = [];
-        $attachments = [];
-        foreach ($posts as $item) {
-            if (!isset($topics[$item->topicId])) {
-                $topic = new Topic();
-                $topic->id = $item->topicId;
-                if ($db->fetch($topic))
-                    $topics[$item->topicId] = $topic;
-            }
-            $attachs = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $item->id ]);
-            $attachments[$item->id] = $attachs;
-        }
-        _view("template_start", ["_title" => $user->displayName]);
-        _view("template_navigation_start");
-        _view("template_navigation", [
-            "user" => $currentUser,
-            "isViewingOwnProfile" => $isOwnProfile,
-        ]);
-        _view("template_navigation_end");
-        _view("view_user", [
-            "user" => $user,
-            "posts" => $posts,
-            "topics" => $topics,
-            "attachments" => $attachments,
-            "lastNameChangeTooRecent" => $lastNameChangeTooRecent,
-        ]);
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-} elseif ($_action === "attachment") {
-    if (!$currentUser) {
-        http_response_code(403);
-        msg_error(__("You must be logged in to view attachments"));
-        exit;
-    }
-
-    $attId = $_GET["attachment"] ?? throw new Exception("Missing attachment id");
-    $attachment = new Attachment();
-    $attachment->id = $attId;
-    if (!$db->fetch($attachment)) {
-        http_response_code(404);
-        msg_error(__("No attachment exists with this id"));
-        exit;
-    }
-
-    $name = preg_replace('/[\r\n\t\/]/', '_', $attachment->name);
-    
-    $extension = pathinfo($attachment->name, PATHINFO_EXTENSION);
-
-    $mime = FileUtils::getMimeTypeForExtension($extension);
-    switch ($mime) {
-        case "text/html":
-        case "text/css":
-        case "text/javascript":
-        case "text/xml":
-        case "application/css":
-        case "application/javascript":
-        case "application/xml":
-            $mime = "text/plain";
-            break;
-    }
-    header("Content-Type: " . $mime);
-    header("Content-Length: " . strlen($attachment->contents));
-    header("Cache-Control: no-cache");
-    header("Content-Disposition: inline; filename=\"" . $name . "\"");
-    echo $attachment->contents;
-} elseif ($_action === "profilepicture") {
-    $userId = $_GET["user"] ?? throw new Exception("Missing user id");
-    $user = new User();
-    $user->id = $userId;
-    if (!$db->fetch($user)) {
-        http_response_code(404);
-        msg_error(__("No user exists with this id"));
-        exit;
-    }
-    
-    $ifNoneMatch = $_SERVER["HTTP_IF_NONE_MATCH"] ?? null;
-    if ($ifNoneMatch !== null)
-        $ifNoneMatch = trim($ifNoneMatch, '"');
-    
-    if ($user->profilePicture === null) {
-        $fallback = __DIR__ . "/application/assets/user-fallback.jpg";
-        $etag = md5("\0");
-        header("Content-Type: image/jpeg");
-        header("Content-Length: " . filesize($fallback));
-        header("Cache-Control: no-cache");
-        header("ETag: \"" . $etag . "\"");
-        if ($ifNoneMatch === $etag)
-            http_response_code(304);
-        else
-            readfile($fallback);
-    } else {
-        $etag = md5($user->profilePicture);
-        header("Content-Type: image/jpeg");
-        header("Content-Length: " . strlen($user->profilePicture));
-        header("Cache-Control: no-cache");
-        header("ETag: \"" . $etag . "\"");
-        if ($ifNoneMatch === $etag)
-            http_response_code(304);
-        else
-            echo $user->profilePicture;
-    }
-} elseif ($_action === "thumb") {
-
-    $attId = $_GET["attachment"] ?? throw new Exception("Missing attachment id");
-    $attachment = new Attachment();
-    $attachment->id = $attId;
-    if (!$db->fetch($attachment)) {
-        http_response_code(404);
-        msg_error(__("No attachment exists with this id"));
-        exit;
-    }
-
-    $isImage = str_starts_with($attachment->mimeType, "image/");
-    $isVideo = str_starts_with($attachment->mimeType, "video/");
-
-    if (!$isImage && !$isVideo) {
-        http_response_code(400);
-        msg_error(__("Attachment is neither an image nor a video"));
-        exit;
-    }
-
-    $contentHash = hash("sha256", $attachment->contents);
-    
-    $cacheId = bin2hex($attachment->id);
-    $cacheDir = sys_get_temp_dir() . "/mystic/forum/0/cache/thumbs/" . substr($cacheId, 0, 2) . "/" . substr($cacheId, 0, 8) . "/";
-    if (!is_dir($cacheDir))
-        mkdir($cacheDir, recursive: true);
-
-    $cacheFileData = $cacheDir . $cacheId . ".data";
-    $cacheFileInfo = $cacheDir . $cacheId . ".info";
-
-    if (is_file($cacheFileData) && is_file($cacheFileInfo)) {
-        $info = json_decode(file_get_contents($cacheFileInfo));
-        if ($info->contentHash === $contentHash) {
-            header("Content-Type: image/jpeg");
-            header("Cache-Control: max-age=86400");
-            //header("X-Debug-Content: $cacheFileData");
-            readfile($cacheFileData);
-            exit;
-        }
-    }
-
-    if ($isVideo) {
-        $suffix = (microtime(true) * 1000) . "-" . random_int(0, 99999);
-        $tempVid = sys_get_temp_dir() . "/video_" . $suffix;
-        file_put_contents($tempVid, $attachment->contents);
-        $tempImg = sys_get_temp_dir() . "/image_" . $suffix . ".jpg";
-
-        try {
-            $ffprobe = FFProbe::create();
-            /** @var string $duration */
-            $duration = $ffprobe
-                ->format($tempVid)
-                ->get("duration", "0");
-
-            $screenshotFramePoint = TimeCode::fromSeconds(floatval($duration) / 2.0);
-            
-            $ffmpeg = FFMpeg::create();
-            $video = $ffmpeg->open($tempVid);
-            $screenshot = $video
-                ->frame($screenshotFramePoint)
-                ->save($tempImg);
-            $im = imagecreatefromjpeg($tempImg);
-        } finally {
-            if (is_file($tempVid)) unlink($tempVid);
-            if (is_file($tempImg)) unlink($tempImg);
-        }
-    } elseif ($isImage) {
-        $im = imagecreatefromstring($attachment->contents);
-    }
-    
-    $w = imagesx($im);
-    $h = imagesy($im);
-    $r = $w / floatval($h);
-
-    if ($w > $h) {
-        $nw = THUMB_MAX_DIM;
-        $nh = floor($nw / $r);
-    } else {
-        $nh = THUMB_MAX_DIM;
-        $nw = floor($r * $nh);
-    }
-
-    $thumb = imagecreatetruecolor($nw, $nh);
-    imagecopyresampled($thumb, $im, 0, 0, 0, 0, $nw, $nh, $w, $h);
-    imagedestroy($im);
-    
-    header("Content-Type: image/jpeg");
-    header("Cache-Control: max-age=86400");
-    imagejpeg($thumb, $cacheFileData, 40);
-    imagedestroy($thumb);
-    file_put_contents($cacheFileInfo, json_encode([
-        "format" => 1,
-        "contentHash" => $contentHash,
-        "created" => time(),
-    ], JSON_UNESCAPED_SLASHES));
-    readfile($cacheFileData);
-} elseif ($_action === "deletepost") {
-    RequestUtils::ensureRequestMethod("POST");
-
-    if (!$currentUser) {
-        http_response_code(403);
-        msg_error("You need to be logged in to delete posts!");
-        exit;
-    }
-    $formId = "deletepost";
-    $postId = RequestUtils::getRequiredField("post", $formId);
-
-    $item = new Post();
-    $item->id = $postId;
-
-    if (!$db->fetch($item) || $item->deleted) {
-        http_response_code(404);
-        msg_error("No post exists with this id");
-        exit;
-    }
-
-    $topicAuthor = new User();
-    $topicAuthor->id = $item->authorId;
-
-    if (!$db->fetch($topicAuthor))
-        $topicAuthor = null;
-
-    $topic = new Topic();
-    $topic->id = $item->topicId;
-
-    if (!$db->fetch($topic))
-        $topic = null;
-
-    $canEdit = ($currentUser->id === $topicAuthor?->id && $topicAuthor?->hasPermission(UserPermissions::DELETE_OWN_POST))
-              || ($currentUser->hasPermission(UserPermissions::DELETE_OTHER_POST));
-
-    if (!$canEdit) {
-        http_response_code(403);
-        msg_error("You don't have permission to delete this post");
-        exit;
-    }
-
-    $attachments = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $item->id ]);
-
-    $confirm = $_POST["confirm"] ?? null;
-    if ($confirm !== null) {
-        $expectedConfirm = base64_encode(hash("sha256", "confirm" . $item->id, true));
-        if ($confirm !== $expectedConfirm) {
-            http_response_code(400);
-            msg_error("Invalid confirmation");
-            exit;
-        }
-
-        $item->deleted = true;
-        $item->content = "";
-
-        if (!$db->update($item)) {
-            http_response_code(500);
-            msg_error("Failed to delete post");
-            exit;
-        }
-        
-        foreach ($attachments as $attachment) {
-            if (!$db->delete($attachment)) {
-                http_response_code(500);
-                msg_error("Failed to delete attachment");
-                exit;
-            }
-        }
-
-        header("Location: ?_action=viewtopic&topic=" . urlencode($item->topicId));
-    } else {
-        _view("template_start", ["_title" => __("Delete post")]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("form_delete_post_confirm", [
-            "post" => $item,
-            "postAuthor" => $topicAuthor,
-            "topicAuthor" => null,
-            "attachments" => $attachments,
-            "topic" => $topic,
-        ]);
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-} elseif ($_action === "updatepost") {
-    RequestUtils::ensureRequestMethod("POST");
-
-    if (!$currentUser) {
-        http_response_code(403);
-        msg_error(__("You need to be logged in to update posts!"));
-        exit;
-    }
-
-    $formId = "updatepost";
-    $postId = RequestUtils::getRequiredField("post", $formId);
-    $message = RequestUtils::getRequiredField("message", $formId);
-
-    $item = new Post();
-    $item->id = $postId;
-
-    if (!$db->fetch($item) || $item->deleted) {
-        http_response_code(404);
-        msg_error(__("No post exists with this id"));
-        exit;
-    }
-
-    $topicAuthor = new User();
-    $topicAuthor->id = $item->authorId;
-
-    if (!$db->fetch($topicAuthor))
-        $topicAuthor = null;
-
-    $canEdit = ($currentUser->id === $topicAuthor?->id && $topicAuthor?->hasPermission(UserPermissions::EDIT_OWN_POST))
-              || ($currentUser->hasPermission(UserPermissions::EDIT_OTHER_POST));
-
-    $topic = new Topic();
-    $topic->id = $item->topicId;
-
-    if (!$db->fetch($topic))
-        $topic = null;
-
-    if ($topic->isLocked) {
-        http_response_code(403);
-        msg_error(__("This topic has been locked"));
-        exit;
-    }
-
-    if (!$canEdit) {
-        http_response_code(403);
-        msg_error(__("You don't have permission to edit this post"));
-        exit;
-    }
-
-    $confirm = $_POST["confirm"] ?? null;
-
-    $item->content = $message;
-    $item->edited = true;
-
-    if (!$db->update($item)) {
-        http_response_code(500);
-        msg_error(__("Failed to update post"));
-        exit;
-    }
-
-    header("Location: ?_action=viewtopic&topic=" . urlencode($item->topicId) . "#post-" . urlencode($postId));
-} elseif ($_action === "deletetopic") {
-    RequestUtils::ensureRequestMethod("POST");
-
-    if (!$currentUser) {
-        http_response_code(403);
-        msg_error(__("You need to be logged in to delete topics!"));
-        exit;
-    }
-
-    $formId = "deletetopic";
-    $topicId = RequestUtils::getRequiredField("topic", $formId);
-
-    $topic = new Topic();
-    $topic->id = $topicId;
-
-    if (!$db->fetch($topic)) {
-        http_response_code(404);
-        msg_error(__("No topic exists with this id"));
-        exit;
-    }
-
-    $topicAuthor = new User();
-    $topicAuthor->id = $topic->createdBy;
-
-    if (!$db->fetch($topicAuthor))
-        $topicAuthor = null;
-
-    $canEdit = ($currentUser->id === $topicAuthor?->id && $topicAuthor?->hasPermission(UserPermissions::DELETE_OWN_TOPIC))
-              || ($currentUser->hasPermission(UserPermissions::DELETE_OTHER_TOPIC));
-
-    if (!$canEdit) {
-        http_response_code(403);
-        msg_error(__("You don't have permission to delete this topic"));
-        exit;
-    }
-
-    $confirm = $_POST["confirm"] ?? null;
-    if ($confirm !== null) {
-        $expectedConfirm = base64_encode(hash("sha256", "confirm" . $topic->id, true));
-        if ($confirm !== $expectedConfirm) {
-            http_response_code(400);
-            msg_error(__("Invalid confirmation"));
-            exit;
-        }
-
-        if (!$db->delete($topic)) {
-            http_response_code(500);
-            msg_error(__("Failed to delete topic"));
-            exit;
-        }
-
-        header("Location: .");
-    } else {
-        _view("template_start", ["_title" => "Delete topic"]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("form_delete_topic_confirm", [
-            "topic" => $topic,
-            "topicAuthor" => $topicAuthor,
-        ]);
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-} elseif ($_action === "updatetopic") {
-    RequestUtils::ensureRequestMethod("POST");
-
-    if (!$currentUser) {
-        http_response_code(403);
-        msg_error(__("You need to be logged in to update topics!"));
-        exit;
-    }
-
-    $formId = "updatetopic";
-    $topicId = RequestUtils::getRequiredField("topic", $formId);
-    $title = RequestUtils::getRequiredField("title", $formId);
-
-    $topic = new Topic();
-    $topic->id = $topicId;
-
-    if (!$db->fetch($topic)) {
-        http_response_code(404);
-        msg_error(__("No topic exists with this id"));
-        exit;
-    }
-
-    $topicAuthor = new User();
-    $topicAuthor->id = $topic->createdBy;
-
-    if (!$db->fetch($topicAuthor))
-        $topicAuthor = null;
-
-    if ($topic->isLocked) {
-        http_response_code(403);
-        msg_error(__("This topic has been locked"));
-        exit;
-    }
-
-    $canEdit = ($currentUser->id === $topicAuthor?->id && $topicAuthor?->hasPermission(UserPermissions::EDIT_OWN_TOPIC))
-            || ($currentUser->hasPermission(UserPermissions::EDIT_OTHER_TOPIC));
-
-    if (!$canEdit) {
-        http_response_code(403);
-        msg_error(__("You don't have permission to update this topic"));
-        exit;
-    }
-
-    $prevTitle = $topic->title;
-    $topic->title = $title;
-
-    $log = new TopicLogMessage();
-    $log->id = $db->generateId();
-    $log->topicId = $topic->id;
-    $log->authorId = $currentUser->id;
-    $log->params = [
-        "old_value" => $prevTitle,
-        "new_value" => $title,
-    ];
-    $log->type = TopicLogMessage::TITLE_CHANGED;
-    $log->postDate = new \DateTimeImmutable();
-
-    $db->insert($log);
-
-    if (!$db->update($topic)) {
-        http_response_code(500);
-        msg_error(__("Failed to update topic"));
-        exit;
-    }
-
-    header("Location: ./?_action=viewtopic&topic=" . urlencode($topicId));
-} elseif ($_action === "locktopic") {
-    RequestUtils::ensureRequestMethod("POST");
-    $topicId = $_POST["topic"] ?? null;
-    if ($topicId === null) {
-        http_response_code(400);
-        msg_error(__("Missing topic id"));
-        exit;
-    }
-    RequestUtils::setFormErrorDestination($dest = "Location: ./?_action=viewtopic&topic=" . urlencode($topicId));
-
-    if (!$currentUser) {
-        http_response_code(403);
-        msg_error(__("You need to be logged in to lock topics!"));
-        exit;
-    }
-
-    $formId = "locktopic";
-    $locked = RequestUtils::getRequiredField("locked", $formId);
-    if ($locked === "true") {
-        $locked = true;
-    } elseif ($locked === "false") {
-        $locked = false;
-    } else RequestUtils::triggerFormError("Invalid value", $formId);
-
-    $topic = new Topic();
-    $topic->id = $topicId;
-
-    if (!$db->fetch($topic)) {
-        http_response_code(404);
-        msg_error(__("No topic exists with this id"));
-        exit;
-    }
-
-    $topicAuthor = new User();
-    $topicAuthor->id = $topic->createdBy;
-
-    if (!$db->fetch($topicAuthor))
-        $topicAuthor = null;
-
-    $canEdit = ($currentUser->id === $topicAuthor?->id && $topicAuthor?->hasPermission(UserPermissions::EDIT_OWN_TOPIC))
-            || ($currentUser->hasPermission(UserPermissions::EDIT_OTHER_TOPIC));
-
-    if (!$canEdit) {
-        http_response_code(403);
-        msg_error(__("You don't have permission to lock or unlock this topic"));
-        exit;
-    }
-
-    $topic->isLocked = $locked;
-
-    $log = new TopicLogMessage();
-    $log->id = $db->generateId();
-    $log->topicId = $topic->id;
-    $log->authorId = $currentUser->id;
-    $log->params = [];
-    $log->type = $locked ? TopicLogMessage::LOCKED : TopicLogMessage::UNLOCKED;
-    $log->postDate = new \DateTimeImmutable();
-
-    $db->insert($log);
-
-    if (!$db->update($topic)) {
-        http_response_code(500);
-        msg_error(__("Failed to lock or unlock topic"));
-        exit;
-    }
-
-    header($dest);
-} elseif ($_action === "search") {
-    $query = $_GET["query"] ?? null;
-    if ($query !== null) {
-        $start_time = microtime(true);
-        /** @var Post[] $posts */
-        $posts = $db->execCustomQuery(<<<SQL
-            SELECT posts.* FROM topics, posts
-            WHERE
-                NOT posts.deleted
-                AND to_tsvector('english', topics.title || ' ' || posts.content) @@ websearch_to_tsquery('english', $1)
-            ORDER BY posts.post_date DESC
-            ;
-        SQL, [ $query ], Post::class);
-
-        $topicLookup = [];
-        $attachmentLookup = [];
-        $userLookup = [];
-        foreach ($posts as $item) {
-            if (!isset($topicLookup[$item->topicId])) {
-                $topic = new Topic;
-                $topic->id = $item->topicId;
-                if ($db->fetch($topic))
-                    $topicLookup[$topic->id] = &$topic;
-            }
-            if (!isset($attachmentLookup[$item->id])) {
-                $attachmentLookup[$item->id] = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $item->id ]);
-            }
-            if (!isset($userLookup[$item->authorId])) {
-                $user = new User;
-                $user->id = $item->authorId;
-                if ($db->fetch($user))
-                    $userLookup[$item->authorId] = $user;
-            }
-        }
-        $end_time = microtime(true);
-        $search_duration = $end_time - $start_time;
-        
-        _view("template_start", ["_title" => __("Search results for “%query%”", [ "query" => $query ])]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("form_search", [ "query" => $query ]);
-        _view("view_search_results", [
-            "posts" => &$posts,
-            "topics" => &$topicLookup,
-            "users" => &$userLookup,
-            "attachments" => &$attachmentLookup,
-            "search_duration" => $search_duration,
-        ]);
-        _view("template_end", [...getThemeAndLangInfo()]);
-    } else {
-        _view("template_start", ["_title" => __("Search")]);
-        _view("template_navigation_start");
-        _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-        _view("template_navigation_end");
-        _view("form_search");
-        _view("template_end", [...getThemeAndLangInfo()]);
-    }
-} elseif ($_action === "captcha") {
-    $phrase = generateCaptchaText();
-    $builder = new CaptchaBuilder($phrase);
-    $builder->build(192, 48);
-    $_SESSION["captchaPhrase"] = $phrase;
-    header("Content-Type: image/jpeg");
-    header("Pragma: no-cache");
-    header("Cache-Control: no-cache");
-    $builder->save(null, 40);
-} elseif ($_action === "ji18n") {
-    header("Content-Type: application/javascript; charset=UTF-8");
-    echo 'var I18N_MESSAGES = ' . json_encode(i18n_get_message_store(i18n_get_current_locale()), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) . ";\n";
-} elseif ($_action === "debug/list-themes") {
-    header("Content-Type: text/plain");
-    foreach (scandir($dir = __DIR__ . '/themes/') as $ent) {
-        if ($ent[0] === "." || !is_dir($dir . "/" . $ent) || !is_file($theme_file = $dir . "/" . $ent . "/theme.json"))
-            continue;
-        $theme_info = json_decode(file_get_contents($theme_file));
-        echo $ent . "\t" . $theme_info->name . "\n";
-    }
-} elseif ($_action === "settheme") {
-    RequestUtils::ensureRequestMethod("POST");
-    $theme = $_POST["theme"] ?? exit(msg_error("Missing required field 'theme'") ?? 1);
-    $next = $_POST["next"] ?? ".";
-    setcookie("theme", $theme, time()+60*60*24*30);
-    header("Location: $next");
-} elseif ($_action === "setlang") {
-    RequestUtils::ensureRequestMethod("POST");
-    $lang = $_POST["lang"] ?? exit(msg_error("Missing required field 'lang'") ?? 1);
-    $next = $_POST["next"] ?? ".";
-    setcookie("lang", $lang, time()+60*60*24*30);
-    header("Location: $next");
-} elseif ($_action === "pwreset") {
-    if ($currentUser) {
-        header("Location: .");
-        exit;
-    }
-
-    if (RequestUtils::isRequestMethod("POST")) {
-        $token = $_GET["token"] ?? null;
-        $signature = $_GET["sig"] ?? null;
-
-        if ($token !== null && $signature !== null) {
-            RequestUtils::setFormErrorDestination("?_action=pwreset&token=" . urlencode($token) . "&sig=" . urlencode($signature));
-            $formId = "pwnew";
-            $newPassword = RequestUtils::getRequiredField("new_password", $formId);
-            $retypePassword = RequestUtils::getRequiredField("retype_password", $formId);
-            $resetUser = decodePasswordResetLink($db, $token, $signature);
-
-            if ($resetUser === null) {
-                http_response_code(400);
-                msg_error(__("The password reset link is either invalid or it expired"), true);
-                exit;
-            }
-
-            if ($newPassword !== $retypePassword) {
-                RequestUtils::triggerFormError(__("New passwords don't match"), $formId);
-            }
-
-            if (strlen($newPassword) < 8) {
-                RequestUtils::triggerFormError(__("Password too short! Your password must consist of 8 or more characters"), $formId);
-            }
-
-            $resetUser->passwordHash = password_hash($newPassword, PASSWORD_DEFAULT);
-            $resetUser->passwordResetToken = null;
-            $resetUser->passwordResetTokenCreated = null;
-            
-            if (!$db->update($resetUser)) {
-                RequestUtils::triggerFormError(__("Failed to update password"), $formId);
-            }
-
-            Transport::fromDsn(env("MAILER_DSN"))->send(
-                (new Email())
-                    ->from(env("MAILER_FROM"))
-                    ->to(new Address($resetUser->email, $resetUser->displayName))
-                    ->text(__(
-                        "Hello, %user_display_name%!\n" .
-                        "\n" .
-                        "We are sending this email to let you know your passwort has been reset successfully!\n" .
-                        "\n" .
-                        "Kind regards,\n" .
-                        "%forum_copyright%",
-                        params: [
-                            "forum_title" => (env("MYSTIC_FORUM_TITLE") ?? "Forum"),
-                            "user_display_name" => $resetUser->displayName,
-                            "forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
-                        ]
-                    ))
-                    ->subject(__("Password reset successfully!"))
-            );
-
-            msg_info(__("Password reset successfully!"), true);
-        } else {
-            $formId = "pwreset";
-            $email = RequestUtils::getRequiredField("email", $formId);
-
-            $user = new User();
-            $user->email = $email;
-
-            if ($db->fetchWhere($user, "email")) {
-                try {
-                    Transport::fromDsn(env("MAILER_DSN"))->send(
-                        (new Email())
-                            ->from(env("MAILER_FROM"))
-                            ->to(new Address($user->email, $user->displayName))
-                            ->text(__(
-                                "Hello, %user_display_name%!\n" .
-                                "\n" .
-                                "A password reset has been requested successfully! Please click the link below to set a new password:\n" .
-                                "%reset_link%\n" .
-                                "\n" .
-                                "If this wasn't you, you can safely ignore this email. The link will only be valid for one hour.\n" .
-                                "\n" .
-                                "Kind regards,\n" .
-                                "%forum_copyright%",
-                                params: [
-                                    "forum_title" => (env("MYSTIC_FORUM_TITLE") ?? "Forum"),
-                                    "user_display_name" => $user->displayName,
-                                    "reset_link" => generatePasswordResetLink($db, $user),
-                                    "forum_copyright" => (env("MYSTIC_FORUM_COPYRIGHT") ?? env("MYSTIC_FORUM_TITLE") ?? "Forum")
-                                ]
-                            ))
-                            ->subject(__("Forgot your password? No problem!"))
-                );
-                } catch (TransportException $_) {
-                    // fail silently
-                }
-            } else {
-                // don't make the delay difference too obvious
-                usleep(random_int(900, 4500) * 1000);
-
-                // ideally, at some point we would just want to queue up the email
-                // and send it asynchronously, but this'll have to do for now
-            }
-
-            msg_info(__("If an account exists with the given email address, we will have sent a password reset link to that email address."), true);
-        }
-    } else {
-        $token = $_GET["token"] ?? null;
-        $signature = $_GET["sig"] ?? null;
-
-        if ($token !== null && $signature !== null) {
-            $resetUser = decodePasswordResetLink($db, $token, $signature);
-            if ($resetUser === null) {
-                http_response_code(400);
-                msg_error(__("The password reset link is either invalid or it expired"), true);
-                exit;
-            }
-
-            _view("template_start", [ "_title" => __("Reset password") ]);
-            _view("template_navigation_start");
-            _view("template_navigation_end");
-            _view("form_new_password", [
-                "token" => $token,
-                "signature" => $signature,
-            ]);
-            _view("template_end", [...getThemeAndLangInfo()]);
-        } else {
-            _view("template_start", [ "_title" => __("Reset password") ]);
-            _view("template_navigation_start");
-            _view("template_navigation", ["user" => null]);
-            _view("template_navigation_end");
-            _view("form_password_reset");
-            _view("template_end", [...getThemeAndLangInfo()]);
-        }
-    }
-} elseif ($_action === "ctheme") {
-    // options
-    $enableLogging = true;
-    $etag_strip_gzip_suffix = true;
+function invalid_action(string $_action): never {
+    http_response_code(404);
+    msg_error(__("Invalid or unknown action $_action"));
+    exit;
+}
 
-    $cssFatal = function(string $msg): never {
-        if (!headers_sent())
-            http_response_code(500);
-        echo "/*!FATAL $msg */\n";
-        exit;
-    };
-    $cssError = function(string &$buffer, string $msg) use($enableLogging): void {
-        if ($enableLogging)
-            $buffer .= "/*!ERROR $msg */\n";
-    };
-    $cssWarning = function(string &$buffer, string $msg) use ($enableLogging): void {
-        if ($enableLogging)
-            $buffer .= "/*!WARN $msg */\n";
-    };
+if ($_action !== null && !preg_match('/^[a-z][a-z0-9_]*$/', $_action))
+    invalid_action($_action);
 
-    $buffer = "";
+$_action ??= "_default";
 
-    header("Content-Type: text/css; charset=UTF-8");
-    header("Cache-Control: no-cache");
-    // Disable Apache's gzip filter, as it interferes with our ETag
-    // (Apache adds '-gzip' as a ETag suffix)
-    if (!$etag_strip_gzip_suffix)
-        apache_setenv('no-gzip', '1');
+$actionDir = __DIR__ . "/application/actions/$_action";
 
-    $themeName = $_GET["theme"] ?? $_COOKIE["theme"] ?? env("MYSTIC_FORUM_THEME") ?? "default";
-    if (!preg_match('/^[a-z0-9_-]+$/i', $themeName)) {
-        $cssWarning($buffer, "Invalid theme '" . str_replace('*/', '*\\/', $themeName) . "'");
-        $cssWarning($buffer, "Loading default theme");
-        $themeName = "default";
-    }
-    $themePath = __DIR__ . '/themes/' . $themeName . '/theme.json';
-    $themeDefaultPath = __DIR__ . '/themes/default/theme.json';
-    if (!is_file($themePath) && is_file($themeDefaultPath)) {
-        $cssWarning($buffer, "Invalid theme '" . str_replace('*/', '*\\/', $themeName) . "'");
-        $cssWarning($buffer, "Loading default theme");
-        $themePath = $themeDefaultPath;
-    } elseif (!is_file($themePath) && !is_file($themeDefaultPath)) {
-        $cssFatal("Failed to load default theme");
-    }
-    $themeDir = dirname($themePath);
-    $theme = json_decode(file_get_contents($themePath));
-    if ($theme->{'$format'} !== 1)
-        $cssFatal("Invalid theme format");
-    foreach ($theme->files as $file) {
-        if (is_array($file)) {
-            if ($enableLogging) $buffer .= "/*!INLINE start */\n";
-            $buffer .= implode("\n", $file);
-            if ($enableLogging) $buffer .= "/*!INLINE end */\n";
-        } elseif (is_file($filePath = $themeDir . "/" . $file)) {
-            if ($enableLogging) $buffer .= "/*!INCLUDE " . basename($file) . " */\n";
-            $buffer .= file_get_contents($filePath);
-            if ($enableLogging) $buffer .= "/*!INCLUDE end */\n";
-        } else
-            $cssError($buffer, "Could not include file $file");
-    }
-    $etag = md5($buffer);
+if (!is_dir($actionDir))
+    invalid_action($_action);
 
-    $ifNoneMatch = $_SERVER["HTTP_IF_NONE_MATCH"] ?? null;
-    if ($ifNoneMatch !== null) {
-        $ifNoneMatch = trim($ifNoneMatch, '"');
-        if ($etag_strip_gzip_suffix)
-            $ifNoneMatch = preg_replace('/-gzip$/', '', $ifNoneMatch);
-    }
+$actionMethod = strtolower(preg_replace('/[^A-Za-z]/', '', $_rq_method));
 
-    header("ETag: \"$etag\"");
-    if ($ifNoneMatch === $etag)
-        http_response_code(304);
-    else
-        echo $buffer;
+if (is_file($commonFile = $actionDir . "/_common.php"))
+    include $commonFile;
 
-} elseif ($_action === null) {
-    _view("template_start");
-    _view("template_navigation_start");
-    _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]);
-    _view("template_navigation_end");
-    _view("view_topics", ["topics" => $db->fetchCustom(Topic::class, "ORDER BY creation_date DESC")]);
-    _view("template_end", [...getThemeAndLangInfo()]);
-} else {
-    http_response_code(404);
-    msg_error(__("Invalid or unknown action $_action"));
+if (is_file($anyFile = $actionDir . "/_any.php"))
+    include $anyFile;
+elseif (is_file($methodFile = $actionDir . "/$actionMethod.php"))
+    include $methodFile;
+else {
+    http_response_code(405);
+    Messaging::error("Invalid request method " . RequestUtils::getRequestMethod());
 }
-- 
cgit v1.2.3