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()); } |