diff options
Diffstat (limited to 'src/application/actions')
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"); +} |