diff options
| author | Jonas Kohl | 2024-10-10 17:33:13 +0200 | 
|---|---|---|
| committer | Jonas Kohl | 2024-10-10 17:33:13 +0200 | 
| commit | 64b1ec0fabbf7328a79a20ff58502ebfa80fad8b (patch) | |
| tree | 88f2281295b347bdd3beee5bc45f68314f2051dc /src | |
| parent | 4ffc399a847ce4f328d4f14adebb48d06ad033f9 (diff) | |
Break up actions into individual files
Diffstat (limited to 'src')
37 files changed, 1662 insertions, 1528 deletions
| diff --git a/src/application/actions/_default/get.php b/src/application/actions/_default/get.php new file mode 100644 index 0000000..cd0c21c --- /dev/null +++ b/src/application/actions/_default/get.php @@ -0,0 +1,11 @@ +<?php + +use mystic\forum\orm\Topic; +use mystic\forum\utils\RequestUtils; + +_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()]); diff --git a/src/application/actions/attachment/get.php b/src/application/actions/attachment/get.php new file mode 100644 index 0000000..598bdb9 --- /dev/null +++ b/src/application/actions/attachment/get.php @@ -0,0 +1,41 @@ +<?php + +use mystic\forum\orm\Attachment; +use mystic\forum\utils\FileUtils; + +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; diff --git a/src/application/actions/auth/_common.php b/src/application/actions/auth/_common.php new file mode 100644 index 0000000..2b8911a --- /dev/null +++ b/src/application/actions/auth/_common.php @@ -0,0 +1,6 @@ +<?php + +if ($currentUser) { +    header("Location: " . ($_GET["next"] ?? ".")); +    exit; +} diff --git a/src/application/actions/auth/get.php b/src/application/actions/auth/get.php new file mode 100644 index 0000000..2ff38ff --- /dev/null +++ b/src/application/actions/auth/get.php @@ -0,0 +1,10 @@ +<?php + +use mystic\forum\utils\RequestUtils; + +_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()]); diff --git a/src/application/actions/auth/post.php b/src/application/actions/auth/post.php new file mode 100644 index 0000000..e9b5138 --- /dev/null +++ b/src/application/actions/auth/post.php @@ -0,0 +1,21 @@ +<?php + +use mystic\forum\orm\User; +use mystic\forum\utils\RequestUtils; + +$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"] ?? ".")); diff --git a/src/application/actions/captcha/get.php b/src/application/actions/captcha/get.php new file mode 100644 index 0000000..a75b60d --- /dev/null +++ b/src/application/actions/captcha/get.php @@ -0,0 +1,12 @@ +<?php + +use Gregwar\Captcha\CaptchaBuilder; + +$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); diff --git a/src/application/actions/ctheme/get.php b/src/application/actions/ctheme/get.php new file mode 100644 index 0000000..f58b0bf --- /dev/null +++ b/src/application/actions/ctheme/get.php @@ -0,0 +1,75 @@ +<?php + +// options +$enableLogging = true; +$etag_strip_gzip_suffix = true; + +$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"; +}; + +$buffer = ""; + +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'); + +$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 = __ROOT__ . '/themes/' . $themeName . '/theme.json'; +$themeDefaultPath = __ROOT__ . '/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); + +$ifNoneMatch = $_SERVER["HTTP_IF_NONE_MATCH"] ?? null; +if ($ifNoneMatch !== null) { +    $ifNoneMatch = trim($ifNoneMatch, '"'); +    if ($etag_strip_gzip_suffix) +        $ifNoneMatch = preg_replace('/-gzip$/', '', $ifNoneMatch); +} + +header("ETag: \"$etag\""); +if ($ifNoneMatch === $etag) +    http_response_code(304); +else +    echo $buffer; diff --git a/src/application/actions/deletepost/post.php b/src/application/actions/deletepost/post.php new file mode 100644 index 0000000..b711021 --- /dev/null +++ b/src/application/actions/deletepost/post.php @@ -0,0 +1,90 @@ +<?php + +use mystic\forum\orm\Attachment; +use mystic\forum\orm\Post; +use mystic\forum\orm\Topic; +use mystic\forum\orm\User; +use mystic\forum\orm\UserPermissions; +use mystic\forum\utils\RequestUtils; + +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()]); +} diff --git a/src/application/actions/deletetopic/post.php b/src/application/actions/deletetopic/post.php new file mode 100644 index 0000000..e67cadf --- /dev/null +++ b/src/application/actions/deletetopic/post.php @@ -0,0 +1,67 @@ +<?php + +use mystic\forum\orm\Topic; +use mystic\forum\orm\User; +use mystic\forum\orm\UserPermissions; +use mystic\forum\utils\RequestUtils; + +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()]); +} diff --git a/src/application/actions/ji18n/get.php b/src/application/actions/ji18n/get.php new file mode 100644 index 0000000..e92b181 --- /dev/null +++ b/src/application/actions/ji18n/get.php @@ -0,0 +1,4 @@ +<?php + +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"; diff --git a/src/application/actions/locktopic/post.php b/src/application/actions/locktopic/post.php new file mode 100644 index 0000000..52c60b1 --- /dev/null +++ b/src/application/actions/locktopic/post.php @@ -0,0 +1,74 @@ +<?php + +use mystic\forum\orm\Topic; +use mystic\forum\orm\TopicLogMessage; +use mystic\forum\orm\User; +use mystic\forum\orm\UserPermissions; +use mystic\forum\utils\RequestUtils; + +$topicId = $_POST["topic"] ?? null; +if ($topicId === null) { +    http_response_code(400); +    msg_error(__("Missing topic id")); +    exit; +} +RequestUtils::setFormErrorDestination($dest = "./?_action=viewtopic&topic=" . urlencode($topicId)); +$dest = "Location: $dest"; + +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); diff --git a/src/application/actions/logout/_any.php b/src/application/actions/logout/_any.php new file mode 100644 index 0000000..1a5cf05 --- /dev/null +++ b/src/application/actions/logout/_any.php @@ -0,0 +1,6 @@ +<?php + +use mystic\forum\utils\RequestUtils; + +RequestUtils::unsetAuthorizedUser(); +header("Location: " . ($_GET["next"] ?? ".")); diff --git a/src/application/actions/lookupuser/get.php b/src/application/actions/lookupuser/get.php new file mode 100644 index 0000000..ca6c6f3 --- /dev/null +++ b/src/application/actions/lookupuser/get.php @@ -0,0 +1,16 @@ +<?php + +use mystic\forum\orm\User; + +$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)); diff --git a/src/application/actions/newtopic/_common.php b/src/application/actions/newtopic/_common.php new file mode 100644 index 0000000..b3b709b --- /dev/null +++ b/src/application/actions/newtopic/_common.php @@ -0,0 +1,7 @@ +<?php + +if (!$currentUser) { +    http_response_code(403); +    msg_error("You need to be logged in to create new topics!"); +    exit; +} diff --git a/src/application/actions/newtopic/get.php b/src/application/actions/newtopic/get.php new file mode 100644 index 0000000..366caac --- /dev/null +++ b/src/application/actions/newtopic/get.php @@ -0,0 +1,10 @@ +<?php + +use mystic\forum\utils\RequestUtils; + +_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()]); diff --git a/src/application/actions/newtopic/post.php b/src/application/actions/newtopic/post.php new file mode 100644 index 0000000..ca79599 --- /dev/null +++ b/src/application/actions/newtopic/post.php @@ -0,0 +1,68 @@ +<?php + +use mystic\forum\orm\Attachment; +use mystic\forum\orm\Post; +use mystic\forum\orm\Topic; +use mystic\forum\utils\RequestUtils; + +$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)); diff --git a/src/application/actions/profilepicture/get.php b/src/application/actions/profilepicture/get.php new file mode 100644 index 0000000..c4860f1 --- /dev/null +++ b/src/application/actions/profilepicture/get.php @@ -0,0 +1,39 @@ +<?php + +use mystic\forum\orm\User; + +$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 = __ROOT__ . "/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; +} diff --git a/src/application/actions/pwreset/_common.php b/src/application/actions/pwreset/_common.php new file mode 100644 index 0000000..4dc0cf0 --- /dev/null +++ b/src/application/actions/pwreset/_common.php @@ -0,0 +1,6 @@ +<?php + +if ($currentUser) { +    header("Location: ."); +    exit; +} diff --git a/src/application/actions/pwreset/get.php b/src/application/actions/pwreset/get.php new file mode 100644 index 0000000..c66b28d --- /dev/null +++ b/src/application/actions/pwreset/get.php @@ -0,0 +1,29 @@ +<?php + +$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()]); +} diff --git a/src/application/actions/pwreset/post.php b/src/application/actions/pwreset/post.php new file mode 100644 index 0000000..772b09c --- /dev/null +++ b/src/application/actions/pwreset/post.php @@ -0,0 +1,107 @@ +<?php + +use mystic\forum\orm\User; +use mystic\forum\utils\RequestUtils; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; + +$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); +} diff --git a/src/application/actions/register/_common.php b/src/application/actions/register/_common.php new file mode 100644 index 0000000..8423e72 --- /dev/null +++ b/src/application/actions/register/_common.php @@ -0,0 +1,12 @@ +<?php + +if ($currentUser) { +    header("Location: " . ($_GET["next"] ?? ".")); +    exit; +} + +if (!REGISTRATION_ENABLED) { +    http_response_code(403); +    msg_error(__("Public registration disabled")); +    exit; +} diff --git a/src/application/actions/register/get.php b/src/application/actions/register/get.php new file mode 100644 index 0000000..914ea4e --- /dev/null +++ b/src/application/actions/register/get.php @@ -0,0 +1,10 @@ +<?php + +use mystic\forum\utils\RequestUtils; + +_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()]); diff --git a/src/application/actions/register/post.php b/src/application/actions/register/post.php new file mode 100644 index 0000000..f953b88 --- /dev/null +++ b/src/application/actions/register/post.php @@ -0,0 +1,98 @@ +<?php + +use mystic\forum\orm\User; +use mystic\forum\orm\UserPermissions; +use mystic\forum\utils\RequestUtils; +use mystic\forum\utils\ValidationUtils; +use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; + +$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!")); diff --git a/src/application/actions/search/get.php b/src/application/actions/search/get.php new file mode 100644 index 0000000..d3de970 --- /dev/null +++ b/src/application/actions/search/get.php @@ -0,0 +1,65 @@ +<?php + +use mystic\forum\orm\Attachment; +use mystic\forum\orm\Post; +use mystic\forum\orm\Topic; +use mystic\forum\orm\User; +use mystic\forum\utils\RequestUtils; + +$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()]); +}
\ No newline at end of file diff --git a/src/application/actions/setlang/post.php b/src/application/actions/setlang/post.php new file mode 100644 index 0000000..7c3272a --- /dev/null +++ b/src/application/actions/setlang/post.php @@ -0,0 +1,6 @@ +<?php + +$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"); diff --git a/src/application/actions/settheme/post.php b/src/application/actions/settheme/post.php new file mode 100644 index 0000000..06669f0 --- /dev/null +++ b/src/application/actions/settheme/post.php @@ -0,0 +1,6 @@ +<?php + +$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"); diff --git a/src/application/actions/thumb/get.php b/src/application/actions/thumb/get.php new file mode 100644 index 0000000..c34bf92 --- /dev/null +++ b/src/application/actions/thumb/get.php @@ -0,0 +1,101 @@ +<?php + +use FFMpeg\Coordinate\TimeCode; +use FFMpeg\FFMpeg; +use FFMpeg\FFProbe; +use mystic\forum\orm\Attachment; + +$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); diff --git a/src/application/actions/updatepost/post.php b/src/application/actions/updatepost/post.php new file mode 100644 index 0000000..fb4b58a --- /dev/null +++ b/src/application/actions/updatepost/post.php @@ -0,0 +1,66 @@ +<?php + +use mystic\forum\orm\Post; +use mystic\forum\orm\Topic; +use mystic\forum\orm\User; +use mystic\forum\orm\UserPermissions; +use mystic\forum\utils\RequestUtils; + +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)); diff --git a/src/application/actions/updatetopic/post.php b/src/application/actions/updatetopic/post.php new file mode 100644 index 0000000..2a757c6 --- /dev/null +++ b/src/application/actions/updatetopic/post.php @@ -0,0 +1,71 @@ +<?php + +use mystic\forum\orm\Topic; +use mystic\forum\orm\TopicLogMessage; +use mystic\forum\orm\User; +use mystic\forum\orm\UserPermissions; +use mystic\forum\utils\RequestUtils; + +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)); diff --git a/src/application/actions/verifyemail/get.php b/src/application/actions/verifyemail/get.php new file mode 100644 index 0000000..77a1ef4 --- /dev/null +++ b/src/application/actions/verifyemail/get.php @@ -0,0 +1,123 @@ +<?php + +use mystic\forum\orm\User; +use Symfony\Component\Mailer\Exception\TransportException; +use Symfony\Component\Mailer\Transport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; + +$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>', +        ] +    )); +} diff --git a/src/application/actions/viewtopic/_common.php b/src/application/actions/viewtopic/_common.php new file mode 100644 index 0000000..7f249bb --- /dev/null +++ b/src/application/actions/viewtopic/_common.php @@ -0,0 +1,13 @@ +<?php + +use mystic\forum\orm\Topic; + +$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; +} diff --git a/src/application/actions/viewtopic/get.php b/src/application/actions/viewtopic/get.php new file mode 100644 index 0000000..45dc824 --- /dev/null +++ b/src/application/actions/viewtopic/get.php @@ -0,0 +1,76 @@ +<?php + +/** @var Post[] $posts */ + +use mystic\forum\orm\Attachment; +use mystic\forum\orm\Post; +use mystic\forum\orm\TopicLogMessage; +use mystic\forum\orm\User; +use mystic\forum\utils\RequestUtils; + +$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()]); diff --git a/src/application/actions/viewtopic/post.php b/src/application/actions/viewtopic/post.php new file mode 100644 index 0000000..1038222 --- /dev/null +++ b/src/application/actions/viewtopic/post.php @@ -0,0 +1,64 @@ +<?php + +use mystic\forum\orm\Attachment; +use mystic\forum\orm\Post; +use mystic\forum\utils\RequestUtils; + +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"); diff --git a/src/application/actions/viewuser/_common.php b/src/application/actions/viewuser/_common.php new file mode 100644 index 0000000..3f8236c --- /dev/null +++ b/src/application/actions/viewuser/_common.php @@ -0,0 +1,21 @@ +<?php + +use mystic\forum\orm\User; + +$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; +} diff --git a/src/application/actions/viewuser/get.php b/src/application/actions/viewuser/get.php new file mode 100644 index 0000000..c98df16 --- /dev/null +++ b/src/application/actions/viewuser/get.php @@ -0,0 +1,34 @@ +<?php + +use mystic\forum\orm\Attachment; +use mystic\forum\orm\Post; +use mystic\forum\orm\Topic; + +$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()]); diff --git a/src/application/actions/viewuser/post.php b/src/application/actions/viewuser/post.php new file mode 100644 index 0000000..e16a6a8 --- /dev/null +++ b/src/application/actions/viewuser/post.php @@ -0,0 +1,173 @@ +<?php + +use mystic\forum\orm\User; +use mystic\forum\orm\UserPermissions; +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; + +$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"); +} 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());  } |