From 086e2d2668784469ec114f6e6fd2b3dace3d7c3b Mon Sep 17 00:00:00 2001 From: Jonas Kohl Date: Thu, 12 Sep 2024 19:49:17 +0200 Subject: Way more progress on forum --- src/index.php | 509 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 506 insertions(+), 3 deletions(-) (limited to 'src/index.php') diff --git a/src/index.php b/src/index.php index 8e2ce4e..109cd2e 100644 --- a/src/index.php +++ b/src/index.php @@ -3,27 +3,99 @@ use mystic\forum\Database; use mystic\forum\exceptions\DatabaseConnectionException; use mystic\forum\Messaging; +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; +use mystic\forum\utils\ValidationUtils; + +header_remove("X-Powered-By"); function exception_error_handler($errno, $errstr, $errfile, $errline ) { throw new ErrorException(html_entity_decode($errstr), $errno, 0, $errfile, $errline); } set_error_handler("exception_error_handler"); +define("REGISTRATION_ENABLED", true); + session_name("fsid"); session_start(); +const MAX_ATTACHMENT_SIZE = 0x200000; +const MAX_ATTACHMENT_COUNT = 4; +const THUMB_MAX_DIM = 100; + $_rq_method = $_SERVER["REQUEST_METHOD"] ?? "GET"; $_action = $_GET["_action"] ?? null; +$GLOBALS["action"] = $_action; + +function _view(string $name, array $params = []): void { + $___NAME = $name; + extract($params); + echo "\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) === "
") + continue; + } + $lineBuf []= $ln; + } + } + if ($inQuote) { + $contents .= "
" . 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", "postgres", "postgres", "postgres")); + $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!"), @@ -32,9 +104,12 @@ try { 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"; @@ -42,9 +117,14 @@ 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); @@ -59,13 +139,436 @@ if (!$db->fetch($superuser)) { exit; } +$currentUser = RequestUtils::getAuthorizedUser($db); +$GLOBALS["currentUser"] = &$currentUser; + // initialization finished if ($_action === "auth") { + if ($currentUser) { + header("Location: ."); + 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: ."); + } 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: ."); + 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: ."); +} 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 $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, + "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 === "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; + } + _view("template_start", ["_title" => "Forum"]); + _view("template_navigation_start"); + _view("template_navigation", ["user" => RequestUtils::getAuthorizedUser($db)]); + _view("template_navigation_end"); + _view("view_user", ["user" => $user]); + _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; + } + + header("Content-Type: " . $attachment->mimeType); + header("Content-Length: " . strlen($attachment->contents)); + header("Cache-Control: no-cache"); + echo $attachment->contents; +} 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; + } + + // TODO Cache thumbnail + $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: no-cache"); + imagejpeg($thumb, null, 40); + imagedestroy($thumb); +} elseif ($_action === "deletepost") { RequestUtils::ensureRequestMethod("POST"); - // TODO Login logic + + 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)) { + http_response_code(404); + Messaging::error("No post exists with this id"); + exit; + } + + $postAuthor = new User(); + $postAuthor->id = $post->authorId; + + if (!$db->fetch($postAuthor)) + $postAuthor = null; + + $canDelete = ($currentUser->id === $postAuthor?->id && $postAuthor?->hasPermission(UserPermissions::DELETE_OWN_POST)) + || ($currentUser->hasPermission(UserPermissions::DELETE_OTHER_POST)); + + if (!$canDelete) { + http_response_code(403); + Messaging::error("You don't have permission to delete this post"); + exit; + } + + $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; + } + + $attachments = $db->fetchCustom(Attachment::class, 'WHERE post_id = $1', [ $post->id ]); + + 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]); + _view("template_end"); + } } elseif ($_action === null) { - echo "Hello"; + _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"); -- cgit v1.2.3