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/application | |
parent | 4ffc399a847ce4f328d4f14adebb48d06ad033f9 (diff) |
Break up actions into individual files
Diffstat (limited to 'src/application')
36 files changed, 1638 insertions, 0 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"); +} |