__("Error")]); _view("template_navigation_start"); if (!$skipLoginCheck) _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($GLOBALS["db"])]); _view("template_navigation_end"); _view("alert_error", ["message" => $err]); _view("template_end", [...getThemeAndLangInfo()]); } function msg_info(string $msg, bool $skipLoginCheck = false): void { _view("template_start", ["_title" => __("Information")]); _view("template_navigation_start"); if (!$skipLoginCheck) _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($GLOBALS["db"])]); _view("template_navigation_end"); _view("alert_info", ["message" => $msg]); _view("template_end", [...getThemeAndLangInfo()]); } function generateCaptchaText(): string { $phrase = ""; for ($i = 0; $i < CAPTCHA_PHRASE_LENGTH; ++$i) $phrase .= CAPTCHA_CHARSET[random_int(0, strlen(CAPTCHA_CHARSET) - 1)]; return $phrase; } function generatePasswordResetLink(Database &$db, User &$user): ?string { $token = $db->generateId(20); $user->passwordResetToken = $token; $user->passwordResetTokenCreated = new \DateTimeImmutable(); if (!$db->update($user)) { return null; } return env("PUBLIC_URL") . "?_action=pwreset&token=" . urlencode($user->passwordResetToken) . "&sig=" . urlencode(base64_encode(hash("sha256", env("SECRET") . $user->passwordResetToken . $user->id . $user->passwordHash, true))); } function decodePasswordResetLink(Database &$db, string $token, string $signature): ?User { $user = new User(); $user->passwordResetToken = $token; if (!$db->fetchWhere($user, "password_reset_token")) { return null; } $then = $user->passwordResetTokenCreated; $now = new \DateTimeImmutable(); if (($now->getTimestamp() - $then->getTimestamp()) >= 3600) { return null; } $expectedSignature = base64_encode(hash("sha256", env("SECRET") . $user->passwordResetToken . $user->id . $user->passwordHash, true)); if ($expectedSignature !== $signature) { return null; } return $user; } function getThemeAndLangInfo(): array { $availableThemes = []; $currentTheme = $_GET["theme"] ?? $_COOKIE["theme"] ?? env("MYSTIC_FORUM_THEME") ?? "default"; 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)); $availableThemes[$ent] = $theme_info; } $availableLangs = [ "en" => "English", ]; foreach (i18n_get_available_locales() as $loc) { if (isset($availableLangs[$loc])) continue; $metadata = i18n_metadata($loc); $availableLangs[$loc] = $metadata["langName"] ?? $loc; } return [ "availableThemes" => $availableThemes, "currentTheme" => $currentTheme, "availableLangs" => $availableLangs, "currentLang" => i18n_get_current_locale(), ]; } function _view(string $___NAME, array $___PARAMS = []): void { $___PATH = __DIR__ . "/application/views/" . $___NAME . ".php"; if (!is_file($___PATH)) { echo "\n"; echo "

Failed to include " . htmlentities($___NAME) . "


\n"; echo "\n"; } else { extract($___PARAMS); echo "\n"; include $___PATH; echo "\n"; } } function isTrue(string $str): bool { $str = strtolower($str); return in_array($str, ["yes","true","y","t","on","enabled","1","?1"]); } function reArrayFiles(&$file_post) { $file_ary = []; $file_count = count($file_post['name']); $file_keys = array_keys($file_post); for ($i=0; $i<$file_count; $i++) { if ($file_post["error"][$i] !== UPLOAD_ERR_OK) continue; foreach ($file_keys as $key) { $file_ary[$i][$key] = $file_post[$key][$i]; } } return $file_ary; } function renderPost(string $contents): string { $contents = preg_replace('~\R~u', "\n", $contents); $contents = trim($contents); $contents = preg_replace('/\n{3,}/', "\n\n", $contents); $contents = htmlentities($contents); $contents = nl2br($contents, false); $lines = explode("\n", $contents); $contents = ""; $lineBuf = []; $inQuote = false; foreach ($lines as $ln) { if (str_starts_with($ln, "> ")) { if (!$inQuote) { $contents .= implode("\n", $lineBuf); $lineBuf = []; $inQuote = true; } $lineBuf []= substr($ln, 5); } else { if ($inQuote) { $contents .= "
\n" . implode("\n", $lineBuf) . "
\n"; $lineBuf = []; $inQuote = false; if (trim($ln) === "
") continue; } $lineBuf []= $ln; } } if ($inQuote) { $contents .= "
" . implode("\n", $lineBuf) . "
"; } else { $contents .= implode("\n", $lineBuf); } return $contents; } function env(string $key): ?string { $val = getenv($key); if ($val === false) return null; return $val; } require_once __DIR__ . "/vendor/autoload.php"; require_once __DIR__ . "/application/i18n.php"; if ($_SERVER["REQUEST_METHOD"] === "GET" && isset($_GET["lang"])) { parse_str($_SERVER["QUERY_STRING"], $query); if (empty($query["lang"])) { setcookie("lang", "", 100); } else { setcookie("lang", $query["lang"], time()+60*60*24*30); } unset($query["lang"]); $path = strtok($_SERVER["REQUEST_URI"], "?"); header("Location: $path?" . http_build_query($query)); exit; } $user_locale = env("LOCALE") ?? $_COOKIE["lang"] ?? locale_accept_from_http($_SERVER["HTTP_ACCEPT_LANGUAGE"] ?? ""); $chosen_locale = locale_lookup(i18n_get_available_locales(), $user_locale, true, "en"); i18n_locale($user_locale); $db = null; try { $db = new Database(Database::getConnectionString("db", getenv("POSTGRES_USER"), getenv("POSTGRES_PASSWORD"), getenv("POSTGRES_DBNAME"))); } catch (DatabaseConnectionException $ex) { msg_error( __("Failed to connect to database:\n%details%", [ "details" => $ex->getMessage(), ]), true ); exit; } $GLOBALS["db"] = &$db; $db->ensureTable(User::class); $db->ensureTable(Topic::class); $db->ensureTable(Post::class); $db->ensureTable(Attachment::class); $superuser = new User(); $superuser->id = "SUPERUSER"; if (!$db->fetch($superuser)) { $superUserPassword = base64_encode(random_bytes(12)); $suEmail = env("MYSTIC_FORUM_SUPERUSER_EMAIL"); if ($suEmail === null) { http_response_code(500); Messaging::error("No superuser email defined. Please set the 'MYSTIC_FORUM_SUPERUSER_EMAIL' environment variable accordingly"); exit; } $superuser->name = "superuser"; $superuser->email = $suEmail; $superuser->passwordHash = password_hash($superUserPassword, PASSWORD_DEFAULT); $superuser->displayName = "SuperUser"; $superuser->created = new \DateTimeImmutable(); $superuser->permissionMask = PHP_INT_MAX; $superuser->passwordResetRequired = false; $superuser->activated = true; $superuser->activationToken = ""; $db->insert($superuser); Messaging::info([ Messaging::bold("Superuser account created"), [ "Username" => $superuser->name, "Password" => $superUserPassword, ], "Please note that the password can only be shown this time, so please note it down!", ]); exit; } $currentUser = RequestUtils::getAuthorizedUser($db); $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->activated = false; $user->activationToken = $token; if (!$db->fetchWhere($user, [ "activated", "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; } $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" => '', "/link" => '', ] )); } 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; } $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); } $post = new Post(); $post->id = $db->generateId(); $post->authorId = $currentUser->id; $post->topicId = $topicId; $post->content = $message; $post->postDate = new DateTimeImmutable(); $post->deleted = false; $post->edited = false; $db->insert($post); 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 = $post->id; $attachment->contents = file_get_contents($tmpName); $db->insert($attachment); } header("Location: ?_action=viewtopic&topic=" . urlencode($topicId) . "#form"); } else { $posts = $db->fetchCustom(Post::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; } } _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]); /** @var Post $post */ foreach ($posts as $post) { /** @var ?User $postAuthor */ $postAuthor = null; if ($post->authorId !== null && !isset($userCache[$post->authorId])) { $usr = new User(); $usr->id = $post->authorId; if ($db->fetch($usr)) $userCache[$post->authorId] = &$usr; } if (isset($userCache[$post->authorId])) $postAuthor = &$userCache[$post->authorId]; $attachments = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $post->id ]); _view("view_post", [ "post" => $post, "postAuthor" => $postAuthor, "topicAuthor" => $topicAuthor, "attachments" => $attachments, ]); } _view("view_topic_end"); if ($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(); $db->insert($topic); $post = new Post(); $post->id = $db->generateId(); $post->authorId = $currentUser->id; $post->topicId = $topic->id; $post->content = $message; $post->postDate = $topic->creationDate; $post->deleted = false; $post->edited = false; $db->insert($post); 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 = $post->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; $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(); } } 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 $post) { if (!isset($topics[$post->topicId])) { $topic = new Topic(); $topic->id = $post->topicId; if ($db->fetch($topic)) $topics[$post->topicId] = $topic; } $attachs = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $post->id ]); $attachments[$post->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); $post = new Post(); $post->id = $postId; if (!$db->fetch($post) || $post->deleted) { http_response_code(404); msg_error("No post exists with this id"); exit; } $topicAuthor = new User(); $topicAuthor->id = $post->authorId; if (!$db->fetch($topicAuthor)) $topicAuthor = 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', [ $post->id ]); $confirm = $_POST["confirm"] ?? null; if ($confirm !== null) { $expectedConfirm = base64_encode(hash("sha256", "confirm" . $post->id, true)); if ($confirm !== $expectedConfirm) { http_response_code(400); msg_error("Invalid confirmation"); exit; } $post->deleted = true; $post->content = ""; if (!$db->update($post)) { 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($post->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" => $post, "postAuthor" => $topicAuthor, "topicAuthor" => null, "attachments" => $attachments, ]); _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); $post = new Post(); $post->id = $postId; if (!$db->fetch($post) || $post->deleted) { http_response_code(404); msg_error(__("No post exists with this id")); exit; } $topicAuthor = new User(); $topicAuthor->id = $post->authorId; if (!$db->fetch($topicAuthor)) $topicAuthor = null; $canEdit = ($currentUser->id === $topicAuthor?->id && $topicAuthor?->hasPermission(UserPermissions::EDIT_OWN_POST)) || ($currentUser->hasPermission(UserPermissions::EDIT_OTHER_POST)); if (!$canEdit) { http_response_code(403); msg_error(__("You don't have permission to edit this post")); exit; } $confirm = $_POST["confirm"] ?? null; $post->content = $message; $post->edited = true; if (!$db->update($post)) { http_response_code(500); msg_error(__("Failed to update post")); exit; } header("Location: ?_action=viewtopic&topic=" . urlencode($post->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; $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; } $topic->title = $title; if (!$db->update($topic)) { http_response_code(500); msg_error(__("Failed to update topic")); exit; } header("Location: ./?_action=viewtopic&topic=" . urlencode($topicId)); } elseif ($_action === "search") { $query = $_GET["query"] ?? null; if ($query !== null) { $start_time = microtime(true); /** @var Post[] $posts */ $posts = $db->execCustomQuery(<<topicId])) { $topic = new Topic; $topic->id = $post->topicId; if ($db->fetch($topic)) $topicLookup[$topic->id] = &$topic; } if (!isset($attachmentLookup[$post->id])) { $attachmentLookup[$post->id] = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $post->id ]); } if (!isset($userLookup[$post->authorId])) { $user = new User; $user->id = $post->authorId; if ($db->fetch($user)) $userLookup[$post->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); } 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; $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 = __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); $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; } 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")); }