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