\n"; include __DIR__ . "/application/views/" . $___NAME . ".php"; echo "\n"; } 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_NO_FILE) continue; foreach ($file_keys as $key) { $file_ary[$i][$key] = $file_post[$key][$i]; } } return $file_ary; } function renderPost(string $contents): string { $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) === "
" . implode("\n", $lineBuf) . ""; } else { $contents .= implode("\n", $lineBuf); } return $contents; } require_once __DIR__ . "/vendor/autoload.php"; $db = null; try { $db = new Database(Database::getConnectionString("db", getenv("POSTGRES_USER"), getenv("POSTGRES_PASSWORD"), getenv("POSTGRES_DBNAME"))); } catch (DatabaseConnectionException $ex) { Messaging::error([ Messaging::bold("Failed to connect to database!"), Messaging::italic($ex->getMessage()), ]); 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)); $superuser->name = "superuser"; $superuser->email = ""; $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")) { $username = RequestUtils::getRequiredField("username"); $password = RequestUtils::getRequiredField("password"); $user = new User(); $user->name = $username; if (!$db->fetchWhere($user, "name") || !password_verify($password, $user->passwordHash)) { RequestUtils::triggerFormError("Username or password incorrect!"); } if (!$user->activated) { RequestUtils::triggerFormError("Please activate your user account first!"); } RequestUtils::setAuthorizedUser($user); header("Location: " . $_GET["next"] ?? "."); } else { _view("template_start", ["_title" => "Forum"]); _view("template_navigation_start"); _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]); _view("template_navigation_end"); _view("form_login"); _view("template_end"); } } elseif ($_action === "register") { if ($currentUser) { header("Location: " . $_GET["next"] ?? "."); exit; } if (!REGISTRATION_ENABLED) { http_response_code(403); Messaging::error("Public registration disabled"); exit; } if (RequestUtils::isRequestMethod("POST")) { $username = RequestUtils::getRequiredField("username"); $password = RequestUtils::getRequiredField("password"); $passwordRetype = RequestUtils::getRequiredField("password_retype"); $email = trim(RequestUtils::getRequiredField("email")); $displayName = RequestUtils::getRequiredField("display_name"); // usernames are always lowercase $username = strtolower($username); if ($password !== $passwordRetype) { RequestUtils::triggerFormError("Passwords do not match!"); } if (strlen($password) < 8) { RequestUtils::triggerFormError("Password too short! Your password must consist of 8 or more characters"); } if (!ValidationUtils::isUsernameValid($username)) { RequestUtils::triggerFormError("Username has an invalid format"); } if (filter_var($email, FILTER_VALIDATE_EMAIL) === false) { RequestUtils::triggerFormError("Invalid email address"); } $user = new User(); $user->name = $username; $user->email = $email; if ($db->fetchWhere($user, "name")) { RequestUtils::triggerFormError("This username is already taken!"); } if ($db->fetchWhere($user, "email")) { RequestUtils::triggerFormError("This email address is already in use!"); } // 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(); // TODO Send verification email $db->insert($user); Messaging::info([ "Your account has been created!", //"Please check your emails for an activation link!", Messaging::html('
Please click here to log in!
'), ]); } else { _view("template_start", ["_title" => "Forum"]); _view("template_navigation_start"); _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]); _view("template_navigation_end"); _view("form_register"); _view("template_end"); } } elseif ($_action === "logout") { RequestUtils::unsetAuthorizedUser(); header("Location: " . $_GET["next"] ?? "."); } elseif ($_action === "viewtopic") { $topicId = $_GET["topic"] ?? throw new Exception("Missing topic id"); $topic = new Topic(); $topic->id = $topicId; if (!$db->fetch($topic)) { http_response_code(404); Messaging::error("No topic exists with this id"); exit; } if (RequestUtils::isRequestMethod("POST")) { if (!$currentUser) { http_response_code(403); Messaging::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"); // check all attachments before saving one foreach ($attachments as $att) { if ($att["size"] > MAX_ATTACHMENT_SIZE) { RequestUtils::triggerFormError("Individual file size exceeded"); } } $message = trim(RequestUtils::getRequiredField("message")); if (strlen($message) < 1 || strlen($message) > 0x8000) { RequestUtils::triggerFormError("Message too short or too long!"); } $post = new Post(); $post->id = $db->generateId(); $post->authorId = $currentUser->id; $post->topicId = $topicId; $post->content = $message; $post->postDate = new DateTimeImmutable(); $post->deleted = 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" => "Forum"]); _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 $topicAuthor */ $topicAuthor = 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])) $topicAuthor = &$userCache[$post->authorId]; $attachments = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $post->id ]); _view("view_post", [ "post" => $post, "postAuthor" => $topicAuthor, "attachments" => $attachments, ]); } _view("view_topic_end"); if ($currentUser) { _view("form_addpost"); } _view("template_end"); } } elseif ($_action === "newtopic") { if (!$currentUser) { http_response_code(403); Messaging::error("You need to be logged in to create new topics!"); exit; } if (RequestUtils::isRequestMethod("POST")) { $title = trim(RequestUtils::getRequiredField("title")); $message = trim(RequestUtils::getRequiredField("message")); if (strlen($title) < 1 || strlen($title) > 255) { RequestUtils::triggerFormError("Title too short or too long!"); } if (strlen($message) < 1 || strlen($message) > 0x8000) { RequestUtils::triggerFormError("Message too short or too long!"); } $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; $db->insert($post); header("Location: ?_action=viewtopic&topic=" . urlencode($topic->id)); } else { _view("template_start", ["_title" => "Forum"]); _view("template_navigation_start"); _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]); _view("template_navigation_end"); _view("form_newtopic"); _view("template_end"); } } 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); Messaging::error("No user with name @$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); Messaging::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")) { $displayName = RequestUtils::getRequiredField("display_name"); $pfpAction = RequestUtils::getRequiredField("pfp_action"); $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!"); } else { if (!ValidationUtils::isUsernameValid($userName)) RequestUtils::triggerFormError("Invalid username!"); if (!ValidationUtils::isUsernameAvailable($db, $userName)) RequestUtils::triggerFormError("This username is already taken!"); $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"); } $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"); /** @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"); break; } if (!$db->update($user)) RequestUtils::triggerFormError("Failed to save changes"); header("Location: $_SERVER[REQUEST_URI]"); } else { $posts = $db->fetchCustom(Post::class, 'WHERE author_id = $1 ORDER BY post_date DESC', [ $userId ]); $topics = []; foreach ($posts as $post) { if (isset($topics[$post->topicId])) continue; $topic = new Topic(); $topic->id = $post->topicId; if (!$db->fetch($topic)) continue; $topics[$post->topicId] = $topic; } _view("template_start", ["_title" => "Forum"]); _view("template_navigation_start"); _view("template_navigation", ["user" => $currentUser]); _view("template_navigation_end"); _view("view_user", [ "user" => $user, "posts" => $posts, "topics" => $topics, "lastNameChangeTooRecent" => $lastNameChangeTooRecent, ]); _view("template_end"); } } elseif ($_action === "attachment") { if (!$currentUser) { http_response_code(403); Messaging::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); Messaging::error("No attachment exists with this id"); exit; } $extension = pathinfo($attachment->name, PATHINFO_EXTENSION); header("Content-Type: " . FileUtils::getMimeTypeForExtension($extension)); header("Content-Length: " . strlen($attachment->contents)); header("Cache-Control: no-cache"); header("Content-Disposition: inline; filename=\"" . $attachment->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); Messaging::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); Messaging::error("No attachment exists with this id"); exit; } if (!str_starts_with($attachment->mimeType, "image/")) { http_response_code(400); Messaging::error("Attachment is not an image"); 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; } } $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); Messaging::error("You need to be logged in to delete posts!"); exit; } $postId = RequestUtils::getRequiredField("post"); $post = new Post(); $post->id = $postId; if (!$db->fetch($post) || $post->deleted) { http_response_code(404); Messaging::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); Messaging::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); Messaging::error("Invalid confirmation"); exit; } $post->deleted = true; $post->content = ""; if (!$db->update($post)) { http_response_code(500); Messaging::error("Failed to delete post"); exit; } foreach ($attachments as $attachment) { if (!$db->delete($attachment)) { http_response_code(500); Messaging::error("Failed to delete attachment"); exit; } } header("Location: ?_action=viewtopic&topic=" . urlencode($post->topicId)); } else { _view("template_start", ["_title" => "Forum"]); _view("template_navigation_start"); _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]); _view("template_navigation_end"); _view("form_delete_post_confirm", [ "post" => $post, "postAuthor" => $topicAuthor, "attachments" => $attachments, ]); _view("template_end"); } } elseif ($_action === "deletetopic") { RequestUtils::ensureRequestMethod("POST"); if (!$currentUser) { http_response_code(403); Messaging::error("You need to be logged in to delete topics!"); exit; } $topicId = RequestUtils::getRequiredField("topic"); $topic = new Topic(); $topic->id = $topicId; if (!$db->fetch($topic)) { http_response_code(404); Messaging::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); Messaging::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); Messaging::error("Invalid confirmation"); exit; } if (!$db->delete($topic)) { http_response_code(500); Messaging::error("Failed to delete topic"); exit; } header("Location: ."); } else { _view("template_start", ["_title" => "Forum"]); _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"); } } elseif ($_action === "updatetopic") { RequestUtils::ensureRequestMethod("POST"); if (!$currentUser) { http_response_code(403); Messaging::error("You need to be logged in to update topics!"); exit; } $topicId = RequestUtils::getRequiredField("topic"); $title = RequestUtils::getRequiredField("title"); $topic = new Topic(); $topic->id = $topicId; if (!$db->fetch($topic)) { http_response_code(404); Messaging::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); Messaging::error("You don't have permission to update this topic"); exit; } $topic->title = $title; if (!$db->update($topic)) { http_response_code(500); Messaging::error("Failed to update topic"); exit; } header("Location: ./?_action=viewtopic&topic=" . urlencode($topicId)); } elseif ($_action === null) { _view("template_start", ["_title" => "Forum"]); _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"); } else { http_response_code(404); Messaging::error("Invalid or unknown action $_action"); }