From 0633a48f09611e9f9b1bdeae79fd8127513c7b3e Mon Sep 17 00:00:00 2001
From: flashwave <me@flash.moe>
Date: Wed, 21 Oct 2020 22:33:21 +0000
Subject: [PATCH] // whoa i can't believe it's more progress!

---
 misuzu.php                           |   3 -
 public/forum/post.php                | 157 ++++--------
 public/forum/posting.php             |  52 ++--
 public/forum/topic.php               |  31 +--
 public/search.php                    |   3 +-
 src/Console/Commands/CronCommand.php |  12 +-
 src/Forum/ForumCategory.php          |  76 +++++-
 src/Forum/ForumPost.php              | 178 ++++++++++++-
 src/Forum/ForumTopic.php             |  36 ++-
 src/Forum/ForumTopicTrack.php        |  52 ++++
 src/Forum/forum.php                  | 147 -----------
 src/Forum/perms.php                  |  21 ++
 src/Forum/post.php                   | 358 ---------------------------
 src/Forum/topic.php                  |  56 -----
 src/Users/User.php                   |   1 +
 templates/forum/macros.twig          |   8 +-
 templates/forum/posting.twig         |  24 +-
 17 files changed, 456 insertions(+), 759 deletions(-)
 delete mode 100644 src/Forum/forum.php
 delete mode 100644 src/Forum/post.php
 delete mode 100644 src/Forum/topic.php

diff --git a/misuzu.php b/misuzu.php
index 5d3d0166..b8f30afd 100644
--- a/misuzu.php
+++ b/misuzu.php
@@ -76,9 +76,6 @@ require_once 'src/perms.php';
 require_once 'src/manage.php';
 require_once 'src/url.php';
 require_once 'src/Forum/perms.php';
-require_once 'src/Forum/forum.php';
-require_once 'src/Forum/post.php';
-require_once 'src/Forum/topic.php';
 
 $dbConfig = parse_ini_file(MSZ_CONFIG . '/config.ini', true, INI_SCANNER_TYPED);
 
diff --git a/public/forum/post.php b/public/forum/post.php
index 7a559873..fecc9903 100644
--- a/public/forum/post.php
+++ b/public/forum/post.php
@@ -2,6 +2,8 @@
 namespace Misuzu;
 
 use Misuzu\AuditLog;
+use Misuzu\Forum\ForumPost;
+use Misuzu\Forum\ForumPostNotFoundException;
 use Misuzu\Users\User;
 use Misuzu\Users\UserSession;
 
@@ -55,59 +57,34 @@ if($isXHR) {
     header(CSRF::header());
 }
 
-$postInfo = forum_post_get($postId, true);
-$perms = empty($postInfo)
-    ? 0
-    : forum_perms_get_user($postInfo['forum_id'], $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+try {
+    $postInfo = ForumPost::byId($postId);
+    $perms = forum_perms_get_user($postInfo->getCategoryId(), $currentUserId)[MSZ_FORUM_PERMS_GENERAL];
+} catch(ForumPostNotFoundException $ex) {
+    $postInfo = null;
+    $perms = 0;
+}
 
 switch($postMode) {
     case 'delete':
-        $canDelete = forum_post_can_delete($postInfo, $currentUserId);
-        $canDeleteMsg = '';
-        $responseCode = 200;
+        $canDeleteCodes = [
+            'view' => 404,
+            'deleted' => 404,
+            'owner' => 403,
+            'age' => 403,
+            'permission' => 403,
+            '' => 200,
+        ];
+        $canDelete = $postInfo->canBeDeleted($currentUser);
+        $canDeleteMsg = ForumPost::canBeDeletedErrorString($canDelete);
+        $responseCode = $canDeleteCodes[$canDelete] ?? 500;
 
-        switch($canDelete) {
-            case MSZ_E_FORUM_POST_DELETE_USER: // i don't think this is ever reached but we may as well have it
-                $responseCode = 401;
-                $canDeleteMsg = 'You must be logged in to delete posts.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_POST:
-                $responseCode = 404;
-                $canDeleteMsg = "This post doesn't exist.";
-                break;
-            case MSZ_E_FORUM_POST_DELETE_DELETED:
-                $responseCode = 404;
-                $canDeleteMsg = 'This post has already been marked as deleted.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OWNER:
-                $responseCode = 403;
-                $canDeleteMsg = 'You can only delete your own posts.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OLD:
-                $responseCode = 401;
-                $canDeleteMsg = 'This post has existed for too long. Ask a moderator to remove if it absolutely necessary.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_PERM:
-                $responseCode = 401;
-                $canDeleteMsg = 'You are not allowed to delete posts.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OP:
-                $responseCode = 403;
-                $canDeleteMsg = 'This is the opening post of a topic, it may not be deleted without deleting the entire topic as well.';
-                break;
-            case MSZ_E_FORUM_POST_DELETE_OK:
-                break;
-            default:
-                $responseCode = 500;
-                $canDeleteMsg = sprintf('Unknown error \'%d\'', $canDelete);
-        }
-
-        if($canDelete !== MSZ_E_FORUM_POST_DELETE_OK) {
+        if($canDelete !== '') {
             if($isXHR) {
                 http_response_code($responseCode);
                 echo json_encode([
                     'success' => false,
-                    'post_id' => $postInfo['post_id'],
+                    'post_id' => $postInfo->getId(),
                     'code' => $canDelete,
                     'message' => $canDeleteMsg,
                 ]);
@@ -121,17 +98,17 @@ switch($postMode) {
         if(!$isXHR) {
             if($postRequestVerified && !$submissionConfirmed) {
                 url_redirect('forum-post', [
-                    'post' => $postInfo['post_id'],
-                    'post_fragment' => 'p' . $postInfo['post_id'],
+                    'post' => $postInfo->getId(),
+                    'post_fragment' => 'p' . $postInfo->getId(),
                 ]);
                 break;
             } elseif(!$postRequestVerified) {
                 Template::render('forum.confirm', [
                     'title' => 'Confirm post deletion',
                     'class' => 'far fa-trash-alt',
-                    'message' => sprintf('You are about to delete post #%d. Are you sure about that?', $postInfo['post_id']),
+                    'message' => sprintf('You are about to delete post #%d. Are you sure about that?', $postInfo->getId()),
                     'params' => [
-                        'p' => $postInfo['post_id'],
+                        'p' => $postInfo->getId(),
                         'm' => 'delete',
                     ],
                 ]);
@@ -139,16 +116,13 @@ switch($postMode) {
             }
         }
 
-        $deletePost = forum_post_delete($postInfo['post_id']);
-
-        if($deletePost) {
-            AuditLog::create(AuditLog::FORUM_POST_DELETE, [$postInfo['post_id']]);
-        }
+        $postInfo->delete();
+        AuditLog::create(AuditLog::FORUM_POST_DELETE, [$postInfo->getId()]);
 
         if($isXHR) {
             echo json_encode([
                 'success' => $deletePost,
-                'post_id' => $postInfo['post_id'],
+                'post_id' => $postInfo->getId(),
                 'message' => $deletePost ? 'Post deleted!' : 'Failed to delete post.',
             ]);
             break;
@@ -159,7 +133,7 @@ switch($postMode) {
             break;
         }
 
-        url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
+        url_redirect('forum-topic', ['topic' => $postInfo->getTopicId()]);
         break;
 
     case 'nuke':
@@ -171,17 +145,17 @@ switch($postMode) {
         if(!$isXHR) {
             if($postRequestVerified && !$submissionConfirmed) {
                 url_redirect('forum-post', [
-                    'post' => $postInfo['post_id'],
-                    'post_fragment' => 'p' . $postInfo['post_id'],
+                    'post' => $postInfo->getId(),
+                    'post_fragment' => 'p' . $postInfo->getId(),
                 ]);
                 break;
             } elseif(!$postRequestVerified) {
                 Template::render('forum.confirm', [
                     'title' => 'Confirm post nuke',
                     'class' => 'fas fa-radiation',
-                    'message' => sprintf('You are about to PERMANENTLY DELETE post #%d. Are you sure about that?', $postInfo['post_id']),
+                    'message' => sprintf('You are about to PERMANENTLY DELETE post #%d. Are you sure about that?', $postInfo->getId()),
                     'params' => [
-                        'p' => $postInfo['post_id'],
+                        'p' => $postInfo->getId(),
                         'm' => 'nuke',
                     ],
                 ]);
@@ -189,18 +163,12 @@ switch($postMode) {
             }
         }
 
-        $nukePost = forum_post_nuke($postInfo['post_id']);
-
-        if(!$nukePost) {
-            echo render_error(500);
-            break;
-        }
-
-        AuditLog::create(AuditLog::FORUM_POST_NUKE, [$postInfo['post_id']]);
+        $postInfo->nuke();
+        AuditLog::create(AuditLog::FORUM_POST_NUKE, [$postInfo->getId()]);
         http_response_code(204);
 
         if(!$isXHR) {
-            url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
+            url_redirect('forum-topic', ['topic' => $postInfo->getTopicId()]);
         }
         break;
 
@@ -213,17 +181,17 @@ switch($postMode) {
         if(!$isXHR) {
             if($postRequestVerified && !$submissionConfirmed) {
                 url_redirect('forum-post', [
-                    'post' => $postInfo['post_id'],
-                    'post_fragment' => 'p' . $postInfo['post_id'],
+                    'post' => $postInfo->getId(),
+                    'post_fragment' => 'p' . $postInfo->getId(),
                 ]);
                 break;
             } elseif(!$postRequestVerified) {
                 Template::render('forum.confirm', [
                     'title' => 'Confirm post restore',
                     'class' => 'fas fa-magic',
-                    'message' => sprintf('You are about to restore post #%d. Are you sure about that?', $postInfo['post_id']),
+                    'message' => sprintf('You are about to restore post #%d. Are you sure about that?', $postInfo->getId()),
                     'params' => [
-                        'p' => $postInfo['post_id'],
+                        'p' => $postInfo->getId(),
                         'm' => 'restore',
                     ],
                 ]);
@@ -231,49 +199,12 @@ switch($postMode) {
             }
         }
 
-        $restorePost = forum_post_restore($postInfo['post_id']);
-
-        if(!$restorePost) {
-            echo render_error(500);
-            break;
-        }
-
-        AuditLog::create(AuditLog::FORUM_POST_RESTORE, [$postInfo['post_id']]);
+        $postInfo->restore();
+        AuditLog::create(AuditLog::FORUM_POST_RESTORE, [$postInfo->getId()]);
         http_response_code(204);
 
         if(!$isXHR) {
-            url_redirect('forum-topic', ['topic' => $postInfo['topic_id']]);
+            url_redirect('forum-topic', ['topic' => $postInfo->getTopicId()]);
         }
         break;
-
-    default: // function as an alt for topic.php?p= by default
-        $canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
-
-        if(!empty($postInfo['post_deleted']) && !$canDeleteAny) {
-            echo render_error(404);
-            break;
-        }
-
-        $postFind = forum_post_find($postInfo['post_id'], $currentUserId);
-
-        if(empty($postFind)) {
-            echo render_error(404);
-            break;
-        }
-
-        if($canDeleteAny) {
-            $postInfo['preceeding_post_count'] += $postInfo['preceeding_post_deleted_count'];
-        }
-
-        unset($postInfo['preceeding_post_deleted_count']);
-
-        if($isXHR) {
-            echo json_encode($postFind);
-            break;
-        }
-
-        url_redirect('forum-topic', [
-            'topic' => $postFind['topic_id'],
-            'page' => floor($postFind['preceeding_post_count'] / \Misuzu\Forum\ForumPost::PER_PAGE) + 1,
-        ]);
 }
diff --git a/public/forum/posting.php b/public/forum/posting.php
index 729dd5b9..70326428 100644
--- a/public/forum/posting.php
+++ b/public/forum/posting.php
@@ -8,6 +8,8 @@ use Misuzu\Forum\ForumTopicNotFoundException;
 use Misuzu\Forum\ForumTopicCreationFailedException;
 use Misuzu\Forum\ForumTopicUpdateFailedException;
 use Misuzu\Forum\ForumPost;
+use Misuzu\Forum\ForumPostCreationFailedException;
+use Misuzu\Forum\ForumPostUpdateFailedException;
 use Misuzu\Forum\ForumPostNotFoundException;
 use Misuzu\Net\IPAddress;
 use Misuzu\Parsers\Parser;
@@ -71,13 +73,11 @@ if(empty($postId) && empty($topicId) && empty($forumId)) {
     return;
 }
 
-if(!empty($postId)) {
-    $post = forum_post_get($postId);
-
-    if(isset($post['topic_id'])) { // should automatic cross-quoting be a thing? if so, check if $topicId is < 1 first
-        $topicId = (int)$post['topic_id'];
-    }
-}
+if(!empty($postId))
+    try {
+        $postInfo = ForumPost::byId($postId);
+        $topicId = $postInfo->getTopicId();
+    } catch(ForumPostNotFoundException $ex) {}
 
 if(!empty($topicId))
     try {
@@ -122,12 +122,12 @@ if($mode === 'create' || $mode === 'edit') {
 
 // edit mode stuff
 if($mode === 'edit') {
-    if(empty($post)) {
+    if(empty($postInfo)) {
         echo render_error(404);
         return;
     }
 
-    if(!perms_check($perms, $post['poster_id'] === $currentUserId ? MSZ_FORUM_PERM_EDIT_POST : MSZ_FORUM_PERM_EDIT_ANY_POST)) {
+    if(!perms_check($perms, $postInfo->getUserId() === $currentUserId ? MSZ_FORUM_PERM_EDIT_POST : MSZ_FORUM_PERM_EDIT_ANY_POST)) {
         echo render_error(403);
         return;
     }
@@ -140,16 +140,16 @@ if(!empty($_POST)) {
     $topicTitle = $_POST['post']['title'] ?? '';
     $postText = $_POST['post']['text'] ?? '';
     $postParser = (int)($_POST['post']['parser'] ?? Parser::BBCODE);
-    $topicType = isset($_POST['post']['type']) ? (int)$_POST['post']['type'] : null;
+    $topicType = isset($_POST['post']['type']) ? (int)$_POST['post']['type'] : ForumTopic::TYPE_DISCUSSION;
     $postSignature = isset($_POST['post']['signature']);
 
     if(!CSRF::validateRequest()) {
         $notices[] = 'Could not verify request.';
     } else {
-        $isEditingTopic = $isNewTopic || ($mode === 'edit' && $post['is_opening_post']);
+        $isEditingTopic = $isNewTopic || ($mode === 'edit' && $postInfo->isOpeningPost());
 
         if($mode === 'create') {
-            $timeoutCheck = max(1, forum_timeout($forumInfo->getId(), $currentUserId));
+            $timeoutCheck = max(1, $forumInfo->checkCooldown($currentUser));
 
             if($timeoutCheck < 5) {
                 $notices[] = sprintf("You're posting too quickly! Please wait %s seconds before posting again.", number_format($timeoutCheck));
@@ -192,21 +192,25 @@ if(!empty($_POST)) {
                         $topicId = $topicInfo->getId();
                     }
 
-                    $postId = forum_post_create(
-                        $topicId,
-                        $forumInfo->getId(),
-                        $currentUserId,
-                        IPAddress::remote(),
-                        $postText,
-                        $postParser,
-                        $postSignature
-                    );
-                    forum_topic_mark_read($currentUserId, $topicId, $forumInfo->getId());
+                    $postInfo = ForumPost::create($topicInfo, $currentUser, IPAddress::remote(), $postText, $postParser, $postSignature);
+                    $postId = $postInfo->getId();
+
+                    $topicInfo->markRead($currentUser);
                     $forumInfo->increaseTopicPostCount($isNewTopic);
                     break;
 
                 case 'edit':
-                    if(!forum_post_update($postId, IPAddress::remote(), $postText, $postParser, $postSignature, $postText !== $post['post_text'])) {
+                    if($postText !== $postInfo->getBody() && $postInfo->shouldBumpEdited())
+                        $postInfo->bumpEdited();
+
+                    $postInfo->setRemoteAddress(IPAddress::remote())
+                        ->setBody($postText)
+                        ->setBodyParser($postParser)
+                        ->setDisplaySignature($postSignature);
+
+                    try {
+                        $postInfo->update();
+                    } catch(ForumPostUpdateFailedException $ex) {
                         $notices[] = 'Post edit failed.';
                     }
 
@@ -240,7 +244,7 @@ if(!$isNewTopic && !empty($topicInfo)) {
 }
 
 if($mode === 'edit') { // $post is pretty much sure to be populated at this point
-    Template::set('posting_post', $post);
+    Template::set('posting_post', $postInfo);
 }
 
 Template::render('forum.posting', [
diff --git a/public/forum/topic.php b/public/forum/topic.php
index 6c851b37..cee90e80 100644
--- a/public/forum/topic.php
+++ b/public/forum/topic.php
@@ -19,12 +19,14 @@ $submissionConfirmed =         filter_input(INPUT_GET, 'confirm') === '1';
 $topicUser = User::getCurrent();
 $topicUserId = $topicUser === null ? 0 : $topicUser->getId();
 
-if($topicId < 1 && $postId > 0) {
-    $postInfo = forum_post_find($postId, $topicUserId);
-
-    if(!empty($postInfo['topic_id']))
-        $topicId = (int)$postInfo['topic_id'];
-}
+if($topicId < 1 && $postId > 0)
+    try {
+        $postInfo = ForumPost::byId($postId);
+        $topicId = $postInfo->getTopicId();
+    } catch(ForumPostNotFoundException $ex) {
+        echo render_error(404);
+        return;
+    }
 
 try {
     $topicInfo = ForumTopic::byId($topicId);
@@ -113,8 +115,8 @@ if(in_array($moderationMode, $validModerationModes, true)) {
                 'posts' => 403,
                 '' => 200,
             ];
-            $canDelete = $topicInfo->canDelete($topicUser);
-            $canDeleteMsg = ForumTopic::canDeleteErrorString($canDelete);
+            $canDelete = $topicInfo->canBeDeleted($topicUser);
+            $canDeleteMsg = ForumTopic::canBeDeletedErrorString($canDelete);
             $responseCode = $canDeleteCodes[$canDelete] ?? 500;
 
             if($canDelete !== '') {
@@ -283,15 +285,8 @@ if(in_array($moderationMode, $validModerationModes, true)) {
 
 $topicPagination = new Pagination($topicInfo->getActualPostCount($canDeleteAny), \Misuzu\Forum\ForumPost::PER_PAGE, 'page');
 
-if(isset($postInfo['preceeding_post_count'])) {
-    $preceedingPosts = $postInfo['preceeding_post_count'];
-
-    if($canDeleteAny) {
-        $preceedingPosts += $postInfo['preceeding_post_deleted_count'];
-    }
-
-    $topicPagination->setPage(floor($preceedingPosts / $topicPagination->getRange()), true);
-}
+if(isset($postInfo))
+    $topicPagination->setPage($postInfo->getTopicPage($canDeleteAny, $topicPagination->getRange()));
 
 if(!$topicPagination->hasValidOffset()) {
     echo render_error(404);
@@ -300,7 +295,7 @@ if(!$topicPagination->hasValidOffset()) {
 
 $canReply = !$topicInfo->isArchived() && !$topicInfo->isLocked() && !$topicInfo->isDeleted() && perms_check($perms, MSZ_FORUM_PERM_CREATE_POST);
 
-forum_topic_mark_read($topicUserId, $topicInfo->getId(), $topicInfo->getCategoryId());
+$topicInfo->markRead($topicUser);
 
 Template::render('forum.topic', [
     'topic_perms' => $perms,
diff --git a/public/search.php b/public/search.php
index d8ca137b..2f834003 100644
--- a/public/search.php
+++ b/public/search.php
@@ -2,6 +2,7 @@
 namespace Misuzu;
 
 use Misuzu\Forum\ForumTopic;
+use Misuzu\Forum\ForumPost;
 use Misuzu\News\NewsPost;
 use Misuzu\Users\User;
 
@@ -11,7 +12,7 @@ $searchQuery = !empty($_GET['q']) && is_string($_GET['q']) ? $_GET['q'] : '';
 
 if(!empty($searchQuery)) {
     $forumTopics = ForumTopic::bySearchQuery($searchQuery);
-    $forumPosts = forum_post_search($searchQuery);
+    $forumPosts = ForumPost::bySearchQuery($searchQuery);
     $newsPosts = NewsPost::bySearchQuery($searchQuery);
 
     $findUsers = DB::prepare(sprintf(
diff --git a/src/Console/Commands/CronCommand.php b/src/Console/Commands/CronCommand.php
index b9299a5a..7e06463d 100644
--- a/src/Console/Commands/CronCommand.php
+++ b/src/Console/Commands/CronCommand.php
@@ -28,11 +28,19 @@ class CronCommand implements CommandInterface {
                     case 'func':
                         call_user_func($task['command']);
                         break;
+
+                    case 'selffunc':
+                        call_user_func(self::class . '::' . $task['command']);
+                        break;
                 }
             }
         }
     }
 
+    private static function syncForum(): void {
+        \Misuzu\Forum\ForumCategory::root()->synchronise(true);
+    }
+
     private const TASKS = [
         [
             'name' => 'Ensures main role exists.',
@@ -147,9 +155,9 @@ class CronCommand implements CommandInterface {
         ],
         [
             'name' => 'Recount forum topics and posts.',
-            'type' => 'func',
+            'type' => 'selffunc',
             'slow' => true,
-            'command' => 'forum_count_synchronise',
+            'command' => 'syncForum',
         ],
         [
             'name' => 'Clean up expired tfa tokens.',
diff --git a/src/Forum/ForumCategory.php b/src/Forum/ForumCategory.php
index 0d4675cc..ff8fe6a8 100644
--- a/src/Forum/ForumCategory.php
+++ b/src/Forum/ForumCategory.php
@@ -351,15 +351,77 @@ class ForumCategory {
         return $this->checkLegacyPermission($user, MSZ_FORUM_PERM_SET_READ);
     }
 
-    public function hasUnread(?User $user): bool {
-        if($user === null)
-            return false;
-        return forum_topics_unread($this->getId(), $user->getId());
+    public function hasRead(User $user): bool {
+        static $cache = [];
+
+        $cacheId = $user->getId() . ':' . $this->getId();
+        if(isset($cache[$cacheId]))
+            return $cache[$cacheId];
+
+        if(!$this->canView($user))
+            return $cache[$cacheId] = true;
+
+        $countUnread = (int)DB::prepare(
+            'SELECT COUNT(*) FROM `' . DB::PREFIX . ForumTopic::TABLE . '` AS ti'
+            . ' LEFT JOIN `' . DB::PREFIX . ForumTopicTrack::TABLE . '` AS tt'
+            . ' ON tt.`topic_id` = ti.`topic_id` AND tt.`user_id` = :user'
+            . ' WHERE ti.`forum_id` = :forum AND ti.`topic_deleted` IS NULL'
+            . ' AND ti.`topic_bumped` >= NOW() - INTERVAL :limit SECOND'
+            . ' AND (tt.`track_last_read` IS NULL OR tt.`track_last_read` < ti.`topic_bumped`)'
+        )->bind('forum', $this->getId())
+         ->bind('user', $user->getId())
+         ->bind('limit', ForumTopic::UNREAD_TIME_LIMIT)
+         ->fetchColumn();
+
+        if($countUnread > 0)
+            return $cache[$cacheId] = false;
+
+        foreach($this->getChildren() as $child)
+            if(!$child->hasRead($user))
+                return $cache[$cacheId] = false;
+
+        return $cache[$cacheId] = true;
     }
+
     public function markAsRead(User $user, bool $recursive = true): void {
-        // Recursion is implied for now
-        // Also forego recursion if we're root and just mark the entire forum as expected
-        forum_mark_read($this->isRoot() ? null : $this->getId(), $user->getId());
+        if($this->isRoot()) {
+            if(!$recursive)
+                return;
+            $recursive = false;
+        }
+
+        if($recursive) {
+            $children = $this->getChildren($user);
+            foreach($children as $child)
+                $child->markAsRead($user, true);
+        }
+
+        $mark = DB::prepare(
+            'INSERT INTO `' . DB::PREFIX . ForumTopicTrack::TABLE . '`'
+            . ' (`user_id`, `topic_id`, `forum_id`, `track_last_read`)'
+            . ' SELECT u.`user_id`, t.`topic_id`, t.`forum_id`, NOW()'
+            . ' FROM `msz_forum_topics` AS t'
+            . ' LEFT JOIN `msz_users` AS u ON u.`user_id` = :user'
+            . ' WHERE t.`topic_deleted` IS NULL'
+            . ' AND t.`topic_bumped` >= NOW() - INTERVAL :limit SECOND'
+            . ($this->isRoot() ? '' : ' AND t.`forum_id` = :forum')
+            . ' GROUP BY t.`topic_id`'
+            . ' ON DUPLICATE KEY UPDATE `track_last_read` = NOW()'
+        )->bind('user', $user->getId())
+         ->bind('limit', ForumTopic::UNREAD_TIME_LIMIT);
+
+        if(!$this->isRoot())
+            $mark->bind('forum', $this->getId());
+
+        $mark->execute();
+    }
+
+    public function checkCooldown(User $user): int {
+        return (int)DB::prepare(
+            'SELECT TIMESTAMPDIFF(SECOND, COALESCE(MAX(`post_created`), NOW() - INTERVAL 1 YEAR), NOW())'
+            . ' FROM `' . DB::PREFIX . ForumPost::TABLE . '`'
+            . ' WHERE `forum_id` = :forum AND `user_id` = :user'
+        )->bind('forum', $this->getId())->bind('user', $user->getId())->fetchColumn();
     }
 
     public function getLatestTopic(?User $viewer = null): ?ForumTopic {
diff --git a/src/Forum/ForumPost.php b/src/Forum/ForumPost.php
index 2fcc6d1a..86b9b480 100644
--- a/src/Forum/ForumPost.php
+++ b/src/Forum/ForumPost.php
@@ -11,6 +11,7 @@ use Misuzu\Users\UserNotFoundException;
 class ForumPostException extends ForumException {}
 class ForumPostNotFoundException extends ForumPostException {}
 class ForumPostCreationFailedException extends ForumPostException {}
+class ForumPostUpdateFailedException extends ForumPostException {}
 
 class ForumPost {
     public const PER_PAGE = 10;
@@ -18,6 +19,10 @@ class ForumPost {
     public const BODY_MIN_LENGTH = 1;
     public const BODY_MAX_LENGTH = 60000;
 
+    public const EDIT_BUMP_THRESHOLD = 60 * 5;
+
+    public const DELETE_AGE_LIMIT = 60 * 60 * 24 * 7;
+
     // Database fields
     private $post_id = -1;
     private $topic_id = -1;
@@ -117,6 +122,10 @@ class ForumPost {
     public function getRemoteAddress(): string {
         return $this->post_ip;
     }
+    public function setRemoteAddress(string $remoteAddress): self {
+        $this->post_ip = $remoteAddress;
+        return $this;
+    }
 
     public function getBody(): string {
         return $this->post_text;
@@ -157,6 +166,12 @@ class ForumPost {
     public function getCreatedTime(): int {
         return $this->post_created === null ? -1 : $this->post_created;
     }
+    public function getAge(): int {
+        return time() - $this->getCreatedTime();
+    }
+    public function shouldBumpEdited(): bool {
+        return $this->getAge() > self::EDIT_BUMP_THRESHOLD;
+    }
 
     public function getEditedTime(): int {
         return $this->post_edited === null ? -1 : $this->post_edited;
@@ -188,6 +203,17 @@ class ForumPost {
         return $this->getTopic()->isTopicAuthor($this->getUser());
     }
 
+    public function getTopicOffset(bool $includeDeleted = false): int {
+        return (int)DB::prepare(
+            'SELECT COUNT(`post_id`) FROM `' . DB::PREFIX . self::TABLE . '`'
+            . ' WHERE `topic_id` = :topic AND `post_id` < :post'
+            . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL')
+        )->bind('topic', $this->getTopicId())->bind('post', $this->getId())->fetchColumn();
+    }
+    public function getTopicPage(bool $includeDeleted = false, int $postsPerPage = self::PER_PAGE): int {
+        return floor($this->getTopicOffset() / $postsPerPage) + 1;
+    }
+
     public function canBeSeen(?User $user): bool {
         if($user === null && $this->isDeleted())
             return false;
@@ -202,13 +228,6 @@ class ForumPost {
         return $this->getUser()->getId() === $user->getId();
     }
 
-    // complete this implementation
-    public function canBeDeleted(?User $user): bool {
-        if($user === null)
-            return false;
-        return $this->getUser()->getId() === $user->getId();
-    }
-
     public static function validateBody(string $body): string {
         $length = mb_strlen(trim($body));
         if($length < self::BODY_MIN_LENGTH)
@@ -230,6 +249,73 @@ class ForumPost {
         }
     }
 
+    public function canBeDeleted(User $user): string {
+        if(false) // check if viewable
+            return 'view';
+
+        if($this->isOpeningPost())
+            return 'opening';
+
+        // check if user can view deleted posts/is mod
+        $canDeleteAny = false;
+
+        if($this->isDeleted())
+            return $canDeleteAny ? 'deleted' : 'view';
+
+        if(!$canDeleteAny) {
+            if(false) // check if user can delete posts
+                return 'permission';
+            if($user->getId() !== $this->getUserId())
+                return 'owner';
+            if($this->getCreatedTime() <= time() - self::DELETE_AGE_LIMIT)
+                return 'age';
+        }
+
+        return '';
+    }
+    public static function canBeDeletedErrorString(string $error): string {
+        switch($error) {
+            case 'view':
+                return 'This post doesn\'t exist.';
+            case 'deleted':
+                return 'This post has already been marked as deleted.';
+            case 'permission':
+                return 'You aren\'t allowed to this post.';
+            case 'owner':
+                return 'You can only delete your own posts.';
+            case 'age':
+                return 'This post is too old to be deleted. Ask a moderator to remove it if you deem it absolutely necessary.';
+            case '':
+                return 'Post can be deleted!';
+            default:
+                return 'Post cannot be deleted.';
+        }
+    }
+
+    public function delete(): void {
+        if($this->isDeleted())
+            return;
+        $this->post_deleted = time();
+        DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `post_deleted` = NOW() WHERE `post_id` = :post')
+            ->bind('post', $this->getId())
+            ->execute();
+    }
+    public function restore(): void {
+        if(!$this->isDeleted())
+            return;
+        $this->post_deleted = null;
+        DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `post_deleted` = NULL WHERE `post_id` = :post')
+            ->bind('post', $this->getId())
+            ->execute();
+    }
+    public function nuke(): void {
+        if(!$this->isDeleted())
+            return;
+        DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `post_id` = :post')
+            ->bind('post', $this->getId())
+            ->execute();
+    }
+
     public static function deleteTopic(ForumTopic $topic): void {
         // Deleting posts should only be possible while the topic is already in a deleted state
         if(!$topic->isDeleted())
@@ -261,6 +347,62 @@ class ForumPost {
             ->execute();
     }
 
+    public static function create(
+        ForumTopic $topic,
+        User $user,
+        string $ipAddress,
+        string $text,
+        int $parser = Parser::PLAIN,
+        bool $displaySignature = true
+    ): ForumPost {
+        $create = DB::prepare(
+            'INSERT INTO `msz_forum_posts` ('
+            .  '`topic_id`, `forum_id`, `user_id`, `post_ip`, `post_text`, `post_parse`, `post_display_signature`'
+            . ') VALUES (:topic, :forum, :user, INET6_ATON(:ip), :body, :parser, :display_signature)'
+        )->bind('topic', $topic->getId())
+         ->bind('forum', $topic->getCategoryId())
+         ->bind('user', $user->getId())
+         ->bind('ip', $ipAddress)
+         ->bind('body', $text)
+         ->bind('parser', $parser)
+         ->bind('display_signature', $displaySignature ? 1 : 0)
+         ->execute();
+        if(!$create)
+            throw new ForumPostCreationFailedException;
+
+        $postId = DB::lastId();
+        if($postId < 1)
+            throw new ForumPostCreationFailedException;
+
+        try {
+            return self::byId($postId);
+        } catch(ForumPostNotFoundException $ex) {
+            throw new ForumPostCreationFailedException;
+        }
+    }
+
+    public function update(): void {
+        if($this->getId() < 1)
+            throw new ForumPostUpdateFailedException;
+
+        if(!DB::prepare(
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
+            . ' SET `post_ip` = INET6_ATON(:ip),'
+            .     ' `post_text` = :body,'
+            .     ' `post_parse` = :parser,'
+            .     ' `post_display_signature` = :display_signature,'
+            .     ' `post_edited` = FROM_UNIXTIME(:edited)'
+            . ' WHERE `post_id` = :post'
+        )->bind('post', $this->getId())
+         ->bind('ip', $this->getRemoteAddress())
+         ->bind('body', $this->getBody())
+         ->bind('parser', $this->getBodyParser())
+         ->bind('display_signature', $this->shouldDisplaySignature() ? 1 : 0)
+         ->bind('edited', $this->isEdited() ? $this->getEditedTime() : null)
+         ->execute())
+            throw new ForumPostUpdateFailedException;
+    }
+
     private static function countQueryBase(): string {
         return sprintf(self::QUERY_SELECT, sprintf('COUNT(*)', self::TABLE));
     }
@@ -321,4 +463,26 @@ class ForumPost {
             $memoizer->insert($objects[] = $object);
         return $objects;
     }
+    public static function bySearchQuery(string $search, bool $includeDeleted = false, ?Pagination $pagination = null): array {
+        $query = self::byQueryBase()
+                . ' WHERE MATCH(`post_text`) AGAINST (:search IN NATURAL LANGUAGE MODE)'
+                . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL')
+                . ' ORDER BY `post_id`';
+
+        if($pagination !== null)
+            $query .= ' LIMIT :range OFFSET :offset';
+
+        $getObjects = DB::prepare($query)
+            ->bind('search', $search);
+
+        if($pagination !== null)
+            $getObjects->bind('range', $pagination->getRange())
+                ->bind('offset', $pagination->getOffset());
+
+        $objects = [];
+        $memoizer = self::memoizer();
+        while($object = $getObjects->fetchObject(self::class))
+            $memoizer->insert($objects[] = $object);
+        return $objects;
+    }
 }
diff --git a/src/Forum/ForumTopic.php b/src/Forum/ForumTopic.php
index d3373e94..b38dd2b9 100644
--- a/src/Forum/ForumTopic.php
+++ b/src/Forum/ForumTopic.php
@@ -43,6 +43,8 @@ class ForumTopic {
     public const DELETE_AGE_LIMIT = 60 * 60 * 24;
     public const DELETE_POST_LIMIT = 2;
 
+    public const UNREAD_TIME_LIMIT = 60 * 60 * 24 * 31;
+
     // Database fields
     private $topic_id = -1;
     private $forum_id = -1;
@@ -155,7 +157,7 @@ class ForumTopic {
         if($this->hasPriorityVoting())
             return 'far fa-star fa-fw';
 
-        return ($this->hasUnread($viewer) ? 'fas' : 'far') . ' fa-comment fa-fw';
+        return ($viewer === null || $this->hasRead($viewer) ? 'far' : 'fas') . ' fa-comment fa-fw';
     }
 
     public function getTitle(): string {
@@ -180,6 +182,12 @@ class ForumTopic {
     public function getViewCount(): int {
         return $this->topic_count_views;
     }
+    public function incrementViewCount(): void {
+        ++$this->topic_count_views;
+        DB::prepare('UPDATE `msz_forum_topics` SET `topic_count_views` = `topic_count_views` + 1 WHERE `topic_id` = :topic')
+            ->bind('topic', $this->getId())
+            ->execute();
+    }
 
     public function getFirstPostId(): int {
         return $this->topic_post_first < 1 ? -1 : $this->topic_post_first;
@@ -274,10 +282,24 @@ class ForumTopic {
         return $this->polls;
     }
 
-    public function hasUnread(?User $user): bool {
-        if($user === null)
+    public function isAbandoned(): bool {
+        return $this->getBumpedTime() < time() - self::UNREAD_TIME_LIMIT;
+    }
+    public function hasRead(User $user): bool {
+        if($this->isAbandoned())
+            return true;
+
+        try {
+            $trackInfo = ForumTopicTrack::byTopicAndUser($this, $user);
+            return $trackInfo->getReadTime() >= $this->getBumpedTime();
+        } catch(ForumTopicTrackNotFoundException $ex) {
             return false;
-        return mt_rand(0, 10) >= 5;
+        }
+    }
+    public function markRead(User $user): void {
+        if(!$this->hasRead($user))
+            $this->incrementViewCount();
+        ForumTopicTrack::bump($this, $user);
     }
 
     public function hasParticipated(?User $user): bool {
@@ -307,7 +329,7 @@ class ForumTopic {
         return $this->getCategory()->canView($user);
     }
 
-    public function canDelete(User $user): string {
+    public function canBeDeleted(User $user): string {
         if(false) // check if viewable
             return 'view';
 
@@ -330,7 +352,7 @@ class ForumTopic {
 
         return '';
     }
-    public static function canDeleteErrorString(string $error): string {
+    public static function canBeDeletedErrorString(string $error): string {
         switch($error) {
             case 'view':
                 return 'This topic doesn\'t exist.';
@@ -402,7 +424,7 @@ class ForumTopic {
             throw new ForumTopicUpdateFailedException;
 
         if(!DB::prepare(
-            'UPDATE `msz_forum_topics`'
+            'UPDATE `' . DB::PREFIX . self::TABLE . '`'
             . ' SET `topic_title` = :title,'
             .     ' `topic_type` = :type'
             . ' WHERE `topic_id` = :topic'
diff --git a/src/Forum/ForumTopicTrack.php b/src/Forum/ForumTopicTrack.php
index fb2fb365..045f3264 100644
--- a/src/Forum/ForumTopicTrack.php
+++ b/src/Forum/ForumTopicTrack.php
@@ -2,8 +2,12 @@
 namespace Misuzu\Forum;
 
 use Misuzu\DB;
+use Misuzu\Memoizer;
 use Misuzu\Users\User;
 
+class ForumTopicTrackException extends ForumException {}
+class ForumTopicTrackNotFoundException extends ForumTopicTrackException {}
+
 class ForumTopicTrack {
     // Database fields
     private $user_id = -1;
@@ -50,4 +54,52 @@ class ForumTopicTrack {
     public function getReadTime(): int {
         return $this->track_last_read === null ? -1 : $this->track_last_read;
     }
+
+    public static function bump(ForumTopic $topic, User $user): void {
+        DB::prepare(
+            'REPLACE INTO `' . DB::PREFIX . self::TABLE . '`'
+            . ' (`user_id`, `topic_id`, `forum_id`, `track_last_read`)'
+            . ' VALUES (:user, :topic, :forum, NOW())'
+        )->bind('user', $user->getId())
+         ->bind('topic', $topic->getId())
+         ->bind('forum', $topic->getCategoryId())
+         ->execute();
+    }
+
+    private static function memoizer() {
+        static $memoizer = null;
+        if($memoizer === null)
+            $memoizer = new Memoizer;
+        return $memoizer;
+    }
+
+    private static function byQueryBase(): string {
+        return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE));
+    }
+    public static function byTopicAndUser(ForumTopic $topic, User $user): ForumTopicTrack {
+        return self::memoizer()->find(function($track) use ($topic, $user) {
+            return $track->getTopicId() === $topic->getId() && $track->getUserId() === $user->getId();
+        }, function() use ($topic, $user) {
+            $obj = DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic AND `user_id` = :user')
+                ->bind('topic', $topic->getId())
+                ->bind('user', $user->getId())
+                ->fetchObject(self::class);
+            if(!$obj)
+                throw new ForumTopicTrackNotFoundException;
+            return $obj;
+        });
+    }
+    public static function byCategoryAndUser(ForumCategory $category, User $user): ForumTopicTrack {
+        return self::memoizer()->find(function($track) use ($category, $user) {
+            return $track->getCategoryId() === $category->getId() && $track->getUserId() === $user->getId();
+        }, function() use ($category, $user) {
+            $obj = DB::prepare(self::byQueryBase() . ' WHERE `forum_id` = :category AND `user_id` = :user')
+                ->bind('category', $category->getId())
+                ->bind('user', $user->getId())
+                ->fetchObject(self::class);
+            if(!$obj)
+                throw new ForumTopicTrackNotFoundException;
+            return $obj;
+        });
+    }
 }
diff --git a/src/Forum/forum.php b/src/Forum/forum.php
deleted file mode 100644
index 9d99be16..00000000
--- a/src/Forum/forum.php
+++ /dev/null
@@ -1,147 +0,0 @@
-<?php
-function forum_get_parent_id(int $forumId): int {
-    if($forumId < 1) {
-        return 0;
-    }
-
-    static $memoized = [];
-
-    if(array_key_exists($forumId, $memoized)) {
-        return $memoized[$forumId];
-    }
-
-    $getParent = \Misuzu\DB::prepare('
-        SELECT `forum_parent`
-        FROM `msz_forum_categories`
-        WHERE `forum_id` = :forum_id
-    ');
-    $getParent->bind('forum_id', $forumId);
-
-    return (int)$getParent->fetchColumn();
-}
-
-function forum_get_child_ids(int $forumId): array {
-    if($forumId < 1) {
-        return [];
-    }
-
-    static $memoized = [];
-
-    if(array_key_exists($forumId, $memoized)) {
-        return $memoized[$forumId];
-    }
-
-    $getChildren = \Misuzu\DB::prepare('
-        SELECT `forum_id`
-        FROM `msz_forum_categories`
-        WHERE `forum_parent` = :forum_id
-    ');
-    $getChildren->bind('forum_id', $forumId);
-    $children = $getChildren->fetchAll();
-
-    return $memoized[$forumId] = array_column($children, 'forum_id');
-}
-
-function forum_topics_unread(int $forumId, int $userId): int {
-    if($userId < 1 || $forumId < 1) {
-        return false;
-    }
-
-    static $memoized = [];
-    $memoId = "{$forumId}-{$userId}";
-
-    if(array_key_exists($memoId, $memoized)) {
-        return $memoized[$memoId];
-    }
-
-    $memoized[$memoId] = 0;
-    $children = forum_get_child_ids($forumId);
-
-    foreach($children as $child) {
-        $memoized[$memoId] += forum_topics_unread($child, $userId);
-    }
-
-    if(forum_perms_check_user(MSZ_FORUM_PERMS_GENERAL, $forumId, $userId, MSZ_FORUM_PERM_SET_READ)) {
-        $countUnread = \Misuzu\DB::prepare('
-            SELECT COUNT(ti.`topic_id`)
-            FROM `msz_forum_topics` AS ti
-            LEFT JOIN `msz_forum_topics_track` AS tt
-            ON tt.`topic_id` = ti.`topic_id` AND tt.`user_id` = :user_id
-            WHERE ti.`forum_id` = :forum_id
-            AND ti.`topic_deleted` IS NULL
-            AND ti.`topic_bumped` >= NOW() - INTERVAL 1 MONTH
-            AND (
-                tt.`track_last_read` IS NULL
-                OR tt.`track_last_read` < ti.`topic_bumped`
-            )
-        ');
-        $countUnread->bind('forum_id', $forumId);
-        $countUnread->bind('user_id', $userId);
-        $memoized[$memoId] += (int)$countUnread->fetchColumn();
-    }
-
-    return $memoized[$memoId];
-}
-
-function forum_timeout(int $forumId, int $userId): int {
-    $checkTimeout = \Misuzu\DB::prepare('
-        SELECT TIMESTAMPDIFF(SECOND, COALESCE(MAX(`post_created`), NOW() - INTERVAL 1 YEAR), NOW())
-        FROM `msz_forum_posts`
-        WHERE `forum_id` = :forum_id
-        AND `user_id` = :user_id
-    ');
-    $checkTimeout->bind('forum_id', $forumId);
-    $checkTimeout->bind('user_id', $userId);
-
-    return (int)$checkTimeout->fetchColumn();
-}
-
-// $forumId == null marks all forums as read
-function forum_mark_read(?int $forumId, int $userId): void {
-    if(($forumId !== null && $forumId < 1) || $userId < 1) {
-        return;
-    }
-
-    $entireForum = $forumId === null;
-
-    if(!$entireForum) {
-        $children = forum_get_child_ids($forumId);
-
-        foreach($children as $child) {
-            forum_mark_read($child, $userId);
-        }
-    }
-
-    $doMark = \Misuzu\DB::prepare(sprintf(
-        '
-            INSERT INTO `msz_forum_topics_track`
-                (`user_id`, `topic_id`, `forum_id`, `track_last_read`)
-            SELECT u.`user_id`, t.`topic_id`, t.`forum_id`, NOW()
-            FROM `msz_forum_topics` AS t
-            LEFT JOIN `msz_users` AS u
-            ON u.`user_id` = :user
-            WHERE t.`topic_deleted` IS NULL
-            AND t.`topic_bumped` >= NOW() - INTERVAL 1 MONTH
-            %1$s
-            GROUP BY t.`topic_id`
-            ON DUPLICATE KEY UPDATE
-                `track_last_read` = NOW()
-        ',
-        $entireForum ? '' : 'AND t.`forum_id` = :forum'
-    ));
-    $doMark->bind('user', $userId);
-
-    if(!$entireForum) {
-        $doMark->bind('forum', $forumId);
-    }
-
-    $doMark->execute();
-}
-
-function forum_count_synchronise(int $forumId = \Misuzu\Forum\ForumCategory::ROOT_ID, bool $save = true): array {
-    try {
-        return \Misuzu\Forum\ForumCategory::byId($forumId)->synchronise($save);
-    } catch(\Misuzu\Forum\ForumCategoryNotFoundException $ex) {
-        return ['topics' => 0, 'posts' => 0];
-    }
-}
diff --git a/src/Forum/perms.php b/src/Forum/perms.php
index c8ddd9cc..acb45ce4 100644
--- a/src/Forum/perms.php
+++ b/src/Forum/perms.php
@@ -43,6 +43,27 @@ define('MSZ_FORUM_PERM_MODES', [
     MSZ_FORUM_PERMS_GENERAL,
 ]);
 
+function forum_get_parent_id(int $forumId): int {
+    if($forumId < 1) {
+        return 0;
+    }
+
+    static $memoized = [];
+
+    if(array_key_exists($forumId, $memoized)) {
+        return $memoized[$forumId];
+    }
+
+    $getParent = \Misuzu\DB::prepare('
+        SELECT `forum_parent`
+        FROM `msz_forum_categories`
+        WHERE `forum_id` = :forum_id
+    ');
+    $getParent->bind('forum_id', $forumId);
+
+    return (int)$getParent->fetchColumn();
+}
+
 function forum_perms_get_user(?int $forum, int $user): array {
     $perms = perms_get_blank(MSZ_FORUM_PERM_MODES);
 
diff --git a/src/Forum/post.php b/src/Forum/post.php
deleted file mode 100644
index 34908660..00000000
--- a/src/Forum/post.php
+++ /dev/null
@@ -1,358 +0,0 @@
-<?php
-function forum_post_create(
-    int $topicId,
-    int $forumId,
-    int $userId,
-    string $ipAddress,
-    string $text,
-    int $parser = \Misuzu\Parsers\Parser::PLAIN,
-    bool $displaySignature = true
-): int {
-    $createPost = \Misuzu\DB::prepare('
-        INSERT INTO `msz_forum_posts`
-            (`topic_id`, `forum_id`, `user_id`, `post_ip`, `post_text`, `post_parse`, `post_display_signature`)
-        VALUES
-            (:topic_id, :forum_id, :user_id, INET6_ATON(:post_ip), :post_text, :post_parse, :post_display_signature)
-    ');
-    $createPost->bind('topic_id', $topicId);
-    $createPost->bind('forum_id', $forumId);
-    $createPost->bind('user_id', $userId);
-    $createPost->bind('post_ip', $ipAddress);
-    $createPost->bind('post_text', $text);
-    $createPost->bind('post_parse', $parser);
-    $createPost->bind('post_display_signature', $displaySignature ? 1 : 0);
-
-    return $createPost->execute() ? \Misuzu\DB::lastId() : 0;
-}
-
-function forum_post_update(
-    int $postId,
-    string $ipAddress,
-    string $text,
-    int $parser = \Misuzu\Parsers\Parser::PLAIN,
-    bool $displaySignature = true,
-    bool $bumpUpdate = true
-): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $updatePost = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts`
-        SET `post_ip` = INET6_ATON(:post_ip),
-            `post_text` = :post_text,
-            `post_parse` = :post_parse,
-            `post_display_signature` = :post_display_signature,
-            `post_edited` = IF(:bump, NOW(), `post_edited`)
-        WHERE `post_id` = :post_id
-    ');
-    $updatePost->bind('post_id', $postId);
-    $updatePost->bind('post_ip', $ipAddress);
-    $updatePost->bind('post_text', $text);
-    $updatePost->bind('post_parse', $parser);
-    $updatePost->bind('post_display_signature', $displaySignature ? 1 : 0);
-    $updatePost->bind('bump', $bumpUpdate ? 1 : 0);
-
-    return $updatePost->execute();
-}
-
-function forum_post_find(int $postId, int $userId): array {
-    $getPostInfo = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                p.`post_id`, p.`topic_id`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    AND `post_id` < p.`post_id`
-                    AND `post_deleted` IS NULL
-                    ORDER BY `post_id`
-                ) as `preceeding_post_count`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    AND `post_id` < p.`post_id`
-                    AND `post_deleted` IS NOT NULL
-                    ORDER BY `post_id`
-                ) as `preceeding_post_deleted_count`
-            FROM `msz_forum_posts` AS p
-            WHERE p.`post_id` = :post_id
-        '));
-    $getPostInfo->bind('post_id', $postId);
-    return $getPostInfo->fetch();
-}
-
-function forum_post_get(int $postId, bool $allowDeleted = false): array {
-    $getPost = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                p.`post_id`, p.`post_text`, p.`post_created`, p.`post_parse`, p.`post_display_signature`,
-                p.`topic_id`, p.`post_deleted`, p.`post_edited`, p.`topic_id`, p.`forum_id`,
-                INET6_NTOA(p.`post_ip`) AS `post_ip`,
-                u.`user_id` AS `poster_id`, u.`username` AS `poster_name`,
-                u.`user_created` AS `poster_joined`, u.`user_country` AS `poster_country`,
-                COALESCE(u.`user_colour`, r.`role_colour`) AS `poster_colour`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `user_id` = p.`user_id`
-                    AND `post_deleted` IS NULL
-                ) AS `poster_post_count`,
-                (
-                    SELECT MIN(`post_id`) = p.`post_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                ) AS `is_opening_post`,
-                (
-                    SELECT `user_id` = u.`user_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    ORDER BY `post_id`
-                    LIMIT 1
-                ) AS `is_original_poster`
-            FROM `msz_forum_posts` AS p
-            LEFT JOIN `msz_users` AS u
-            ON u.`user_id` = p.`user_id`
-            LEFT JOIN `msz_roles` AS r
-            ON r.`role_id` = u.`display_role`
-            WHERE `post_id` = :post_id
-            %1$s
-            ORDER BY `post_id`
-        ',
-        $allowDeleted ? '' : 'AND `post_deleted` IS NULL'
-    ));
-    $getPost->bind('post_id', $postId);
-    return $getPost->fetch();
-}
-
-function forum_post_search(string $query): array {
-    $searchPosts = \Misuzu\DB::prepare('
-        SELECT
-            p.`post_id`, p.`post_text`, p.`post_created`, p.`post_parse`, p.`post_display_signature`,
-            p.`topic_id`, p.`post_deleted`, p.`post_edited`, p.`topic_id`, p.`forum_id`,
-            INET6_NTOA(p.`post_ip`) AS `post_ip`,
-            u.`user_id` AS `poster_id`, u.`username` AS `poster_name`,
-            u.`user_created` AS `poster_joined`, u.`user_country` AS `poster_country`,
-            u.`user_signature_content` AS `poster_signature_content`, u.`user_signature_parser` AS `poster_signature_parser`,
-            COALESCE(u.`user_colour`, r.`role_colour`) AS `poster_colour`,
-            COALESCE(u.`user_title`, r.`role_title`) AS `poster_title`,
-            (
-                SELECT COUNT(`post_id`)
-                FROM `msz_forum_posts`
-                WHERE `user_id` = p.`user_id`
-                AND `post_deleted` IS NULL
-            ) AS `poster_post_count`,
-            (
-                SELECT MIN(`post_id`) = p.`post_id`
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = p.`topic_id`
-            ) AS `is_opening_post`,
-            (
-                SELECT `user_id` = u.`user_id`
-                FROM `msz_forum_posts`
-                WHERE `topic_id` = p.`topic_id`
-                ORDER BY `post_id`
-                LIMIT 1
-            ) AS `is_original_poster`
-        FROM `msz_forum_posts` AS p
-        LEFT JOIN `msz_users` AS u
-        ON u.`user_id` = p.`user_id`
-        LEFT JOIN `msz_roles` AS r
-        ON r.`role_id` = u.`display_role`
-        WHERE MATCH(p.`post_text`)
-        AGAINST (:query IN NATURAL LANGUAGE MODE)
-        AND `post_deleted` IS NULL
-        ORDER BY `post_id`
-    ');
-    $searchPosts->bind('query', $query);
-    return $searchPosts->fetchAll();
-}
-
-function forum_post_count_user(int $userId, bool $showDeleted = false): int {
-    $getPosts = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT COUNT(p.`post_id`)
-            FROM `msz_forum_posts` AS p
-            WHERE `user_id` = :user_id
-            %1$s
-        ',
-        $showDeleted ? '' : 'AND `post_deleted` IS NULL'
-    ));
-    $getPosts->bind('user_id', $userId);
-
-    return (int)$getPosts->fetchColumn();
-}
-
-function forum_post_listing(
-    int $topicId,
-    int $offset = 0,
-    int $take = 0,
-    bool $showDeleted = false,
-    bool $selectAuthor = false
-): array {
-    $hasPagination = $offset >= 0 && $take > 0;
-    $getPosts = \Misuzu\DB::prepare(sprintf(
-        '
-            SELECT
-                p.`post_id`, p.`post_text`, p.`post_created`, p.`post_parse`,
-                p.`topic_id`, p.`post_deleted`, p.`post_edited`, p.`post_display_signature`,
-                INET6_NTOA(p.`post_ip`) AS `post_ip`,
-                u.`user_id` AS `poster_id`, u.`username` AS `poster_name`,
-                u.`user_created` AS `poster_joined`, u.`user_country` AS `poster_country`,
-                u.`user_signature_content` AS `poster_signature_content`, u.`user_signature_parser` AS `poster_signature_parser`,
-                COALESCE(u.`user_colour`, r.`role_colour`) AS `poster_colour`,
-                COALESCE(u.`user_title`, r.`role_title`) AS `poster_title`,
-                (
-                    SELECT COUNT(`post_id`)
-                    FROM `msz_forum_posts`
-                    WHERE `user_id` = p.`user_id`
-                    AND `post_deleted` IS NULL
-                ) AS `poster_post_count`,
-                (
-                    SELECT MIN(`post_id`) = p.`post_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                ) AS `is_opening_post`,
-                (
-                    SELECT `user_id` = u.`user_id`
-                    FROM `msz_forum_posts`
-                    WHERE `topic_id` = p.`topic_id`
-                    ORDER BY `post_id`
-                    LIMIT 1
-                ) AS `is_original_poster`
-            FROM `msz_forum_posts` AS p
-            LEFT JOIN `msz_users` AS u
-            ON u.`user_id` = p.`user_id`
-            LEFT JOIN `msz_roles` AS r
-            ON r.`role_id` = u.`display_role`
-            WHERE %3$s = :topic_id
-            %1$s
-            ORDER BY `post_id`
-            %2$s
-        ',
-        $showDeleted ? '' : 'AND `post_deleted` IS NULL',
-        $hasPagination ? 'LIMIT :offset, :take' : '',
-        $selectAuthor ? 'p.`user_id`' : 'p.`topic_id`'
-    ));
-    $getPosts->bind('topic_id', $topicId);
-
-    if($hasPagination) {
-        $getPosts->bind('offset', $offset);
-        $getPosts->bind('take', $take);
-    }
-
-    return $getPosts->fetchAll();
-}
-
-define('MSZ_E_FORUM_POST_DELETE_OK', 0);        // deleting is fine
-define('MSZ_E_FORUM_POST_DELETE_USER', 1);      // invalid user
-define('MSZ_E_FORUM_POST_DELETE_POST', 2);      // post doesn't exist
-define('MSZ_E_FORUM_POST_DELETE_DELETED', 3);   // post is already marked as deleted
-define('MSZ_E_FORUM_POST_DELETE_OWNER', 4);     // you may only delete your own posts
-define('MSZ_E_FORUM_POST_DELETE_OLD', 5);       // posts has existed for too long to be deleted
-define('MSZ_E_FORUM_POST_DELETE_PERM', 6);      // you aren't allowed to delete posts
-define('MSZ_E_FORUM_POST_DELETE_OP', 7);        // this is the opening post of a topic
-
-// only allow posts made within a week of posting to be deleted by normal users
-define('MSZ_FORUM_POST_DELETE_LIMIT', 60 * 60 * 24 * 7);
-
-// set $userId to null for system request, make sure this is NEVER EVER null on user request
-// $postId can also be a the return value of forum_post_get if you already grabbed it once before
-function forum_post_can_delete($postId, ?int $userId = null): int {
-    if($userId !== null && $userId < 1) {
-        return MSZ_E_FORUM_POST_DELETE_USER;
-    }
-
-    if(is_array($postId)) {
-        $post = $postId;
-    } else {
-        $post = forum_post_get((int)$postId, true);
-    }
-
-    if(empty($post)) {
-        return MSZ_E_FORUM_POST_DELETE_POST;
-    }
-
-    $isSystemReq    = $userId === null;
-    $perms          = $isSystemReq ? 0      : forum_perms_get_user($post['forum_id'], $userId)[MSZ_FORUM_PERMS_GENERAL];
-    $canDeleteAny   = $isSystemReq ? true   : perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST);
-    $canViewPost    = $isSystemReq ? true   : perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM);
-    $postIsDeleted  = !empty($post['post_deleted']);
-
-    if(!$canViewPost) {
-        return MSZ_E_FORUM_POST_DELETE_POST;
-    }
-
-    if($post['is_opening_post']) {
-        return MSZ_E_FORUM_POST_DELETE_OP;
-    }
-
-    if($postIsDeleted) {
-        return $canDeleteAny ? MSZ_E_FORUM_POST_DELETE_DELETED : MSZ_E_FORUM_POST_DELETE_POST;
-    }
-
-    if($isSystemReq) {
-        return MSZ_E_FORUM_POST_DELETE_OK;
-    }
-
-    if(!$canDeleteAny) {
-        if(!perms_check($perms, MSZ_FORUM_PERM_DELETE_POST)) {
-            return MSZ_E_FORUM_POST_DELETE_PERM;
-        }
-
-        if($post['poster_id'] !== $userId) {
-            return MSZ_E_FORUM_POST_DELETE_OWNER;
-        }
-
-        if(strtotime($post['post_created']) <= time() - MSZ_FORUM_POST_DELETE_LIMIT) {
-            return MSZ_E_FORUM_POST_DELETE_OLD;
-        }
-    }
-
-    return MSZ_E_FORUM_POST_DELETE_OK;
-}
-
-function forum_post_delete(int $postId): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $markDeleted = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts`
-        SET `post_deleted` = NOW()
-        WHERE `post_id` = :post
-        AND `post_deleted` IS NULL
-    ');
-    $markDeleted->bind('post', $postId);
-    return $markDeleted->execute();
-}
-
-function forum_post_restore(int $postId): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $markDeleted = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_posts`
-        SET `post_deleted` = NULL
-        WHERE `post_id` = :post
-        AND `post_deleted` IS NOT NULL
-    ');
-    $markDeleted->bind('post', $postId);
-    return $markDeleted->execute();
-}
-
-function forum_post_nuke(int $postId): bool {
-    if($postId < 1) {
-        return false;
-    }
-
-    $markDeleted = \Misuzu\DB::prepare('
-        DELETE FROM `msz_forum_posts`
-        WHERE `post_id` = :post
-    ');
-    $markDeleted->bind('post', $postId);
-    return $markDeleted->execute();
-}
diff --git a/src/Forum/topic.php b/src/Forum/topic.php
deleted file mode 100644
index b9d5cdf4..00000000
--- a/src/Forum/topic.php
+++ /dev/null
@@ -1,56 +0,0 @@
-<?php
-function forum_topic_views_increment(int $topicId): void {
-    if($topicId < 1) {
-        return;
-    }
-
-    $bumpViews = \Misuzu\DB::prepare('
-        UPDATE `msz_forum_topics`
-        SET `topic_count_views` = `topic_count_views` + 1
-        WHERE `topic_id` = :topic_id
-    ');
-    $bumpViews->bind('topic_id', $topicId);
-    $bumpViews->execute();
-}
-
-function forum_topic_mark_read(int $userId, int $topicId, int $forumId): void {
-    if($userId < 1) {
-        return;
-    }
-
-    // previously a TRIGGER was used to achieve this behaviour,
-    // but those explode when running on a lot of queries (like forum_mark_read() does)
-    // so instead we get to live with this garbage now
-    // JUST TO CLARIFY: "this behaviour" refers to forum_topic_views_increment only being executed when the topic is viewed for the first time
-    try {
-        $markAsRead = \Misuzu\DB::prepare('
-            INSERT INTO `msz_forum_topics_track`
-                (`user_id`, `topic_id`, `forum_id`, `track_last_read`)
-            VALUES
-                (:user_id, :topic_id, :forum_id, NOW())
-        ');
-        $markAsRead->bind('user_id', $userId);
-        $markAsRead->bind('topic_id', $topicId);
-        $markAsRead->bind('forum_id', $forumId);
-
-        if($markAsRead->execute()) {
-            forum_topic_views_increment($topicId);
-        }
-    } catch(PDOException $ex) {
-        if($ex->getCode() != '23000') {
-            throw $ex;
-        }
-
-        $markAsRead = \Misuzu\DB::prepare('
-            UPDATE `msz_forum_topics_track`
-            SET `track_last_read` = NOW(),
-                `forum_id` = :forum_id
-            WHERE `user_id` = :user_id
-            AND `topic_id` = :topic_id
-        ');
-        $markAsRead->bind('user_id', $userId);
-        $markAsRead->bind('topic_id', $topicId);
-        $markAsRead->bind('forum_id', $forumId);
-        $markAsRead->execute();
-    }
-}
diff --git a/src/Users/User.php b/src/Users/User.php
index dc99d276..1caacf4f 100644
--- a/src/Users/User.php
+++ b/src/Users/User.php
@@ -10,6 +10,7 @@ use Misuzu\HasRankInterface;
 use Misuzu\Memoizer;
 use Misuzu\Pagination;
 use Misuzu\TOTP;
+use Misuzu\Forum\ForumCategory;
 use Misuzu\Net\IPAddress;
 use Misuzu\Parsers\Parser;
 use Misuzu\Users\Assets\UserAvatarAsset;
diff --git a/templates/forum/macros.twig b/templates/forum/macros.twig
index 6ef1b27f..25d05cdf 100644
--- a/templates/forum/macros.twig
+++ b/templates/forum/macros.twig
@@ -156,7 +156,7 @@
         <a href="{{ url('forum-category', {'forum': category.id}) }}" class="forum__category__link"></a>
 
         <div class="forum__category__container">
-            <div class="forum__category__icon forum__category__icon--{{ category.unread(user) ? 'un' : '' }}read">
+            <div class="forum__category__icon forum__category__icon--{{ user is null or category.hasRead(user) ? '' : 'un' }}read">
                 <span class="{{ category.icon }}"></span>
             </div>
 
@@ -176,7 +176,7 @@
                         {% for child in category.children %}
                         {% if child.canView(user) %}
                             <a href="{{ url('forum-category', {'forum': child.id}) }}"
-                                class="forum__category__subforum{% if child.unread(user) %} forum__category__subforum--unread{% endif %}">
+                                class="forum__category__subforum{% if user is not null and not child.hasRead(user) %} forum__category__subforum--unread{% endif %}">
                                 {{ child.name }}
                             </a>
                         {% endif %}
@@ -401,7 +401,7 @@
         <a href="{{ url('forum-topic', {'topic': topic.id}) }}" class="forum__topic__link"></a>
 
         <div class="forum__topic__container">
-            <div class="forum__topic__icon forum__topic__icon--{{ topic.hasUnread(user) ? 'un' : '' }}read{% if topic.hasPriorityVoting %} forum__topic__icon--wide{% endif %}">
+            <div class="forum__topic__icon forum__topic__icon--{{ user is null or topic.hasRead(user) ? '' : 'un' }}read{% if topic.hasPriorityVoting %} forum__topic__icon--wide{% endif %}">
                 <i class="{{ topic.icon(user) }}{% if topic.hasPriorityVoting %} forum__topic__icon--faded{% endif %}"></i>
 
                 {% if topic.hasPriorityVoting %}
@@ -727,7 +727,7 @@
         perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_ANY_POST')) or (
             user_id == post.poster_id
             and perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_POST'))
-            and post.post_created|date('U') > ''|date('U') - constant('MSZ_FORUM_POST_DELETE_LIMIT')
+            and post.post_created|date('U') > ''|date('U') - constant('\\Misuzu\\Forum\\ForumPost::DELETE_AGE_LIMIT')
         )
     ) %}
 
diff --git a/templates/forum/posting.twig b/templates/forum/posting.twig
index 3925d337..7a1b0f70 100644
--- a/templates/forum/posting.twig
+++ b/templates/forum/posting.twig
@@ -5,7 +5,7 @@
 
 {% set title = 'Posting' %}
 {% set is_reply = posting_topic is defined %}
-{% set is_opening = not is_reply or posting_post.is_opening_post|default(false) %}
+{% set is_opening = not is_reply or posting_post.isOpeningPost|default(false) %}
 
 {% block content %}
     <form method="post" action="{{ url('forum-' ~ (is_reply ? 'post' : 'topic') ~ '-create') }}">
@@ -32,7 +32,7 @@
         ) }}
 
         {% if posting_post is defined %}
-            {{ input_hidden('post[id]', posting_post.post_id) }}
+            {{ input_hidden('post[id]', posting_post.id) }}
         {% endif %}
 
         {% if posting_notices|length > 0 %}
@@ -45,21 +45,21 @@
             </div>
         {% endif %}
 
-        <div class="container forum__post js-forum-posting" style="{{ posting_post.poster_colour|default(current_user.colour.raw)|html_colour('--accent-colour') }}">
+        <div class="container forum__post js-forum-posting" style="{{ posting_post.user.colour.raw|default(current_user.colour.raw)|html_colour('--accent-colour') }}">
             <div class="forum__post__info">
                 <div class="forum__post__info__background"></div>
                 <div class="forum__post__info__content">
-                    <span class="forum__post__avatar">{{ avatar(posting_post.poster_id|default(current_user.id), 120, posting_post.poster_name|default(current_user.username)) }}</span>
+                    <span class="forum__post__avatar">{{ avatar(posting_post.user.id|default(current_user.id), 120, posting_post.user.username|default(current_user.username)) }}</span>
 
-                    <span class="forum__post__username">{{ posting_post.poster_name|default(current_user.username) }}</span>
+                    <span class="forum__post__username">{{ posting_post.user.username|default(current_user.username) }}</span>
 
                     <div class="forum__post__icons">
-                        <div class="flag flag--{{ posting_post.poster_country|default(posting_user.country)|lower }}" title="{{ posting_post.poster_country|default(posting_user.country)|country_name }}"></div>
-                        <div class="forum__post__posts-count">{{ posting_post.poster_post_count|default(posting_user.forumPostCount)|number_format }} posts</div>
+                        <div class="flag flag--{{ posting_post.user.country|default(posting_user.country)|lower }}" title="{{ posting_post.user.country|default(posting_user.country)|country_name }}"></div>
+                        <div class="forum__post__posts-count">{{ posting_post.user.forumPostCount|default(posting_user.forumPostCount)|number_format }} posts</div>
                     </div>
 
                     <div class="forum__post__joined">
-                        joined <time datetime="{{ posting_post.poster_joined|default(posting_user.createdTime)|date('c') }}" title="{{ posting_post.poster_joined|default(posting_user.createdTime)|date('r') }}">{{ posting_post.poster_joined|default(posting_user.createdTime)|time_diff }}</time>
+                        joined <time datetime="{{ posting_post.user.createdTime|default(posting_user.createdTime)|date('c') }}" title="{{ posting_post.user.createdTime|default(posting_user.createdTime)|date('r') }}">{{ posting_post.user.createdTime|default(posting_user.createdTime)|time_diff }}</time>
                     </div>
                 </div>
             </div>
@@ -77,7 +77,7 @@
                     </span>
                 </div>
 
-                <textarea name="post[text]" class="forum__post__text forum__post__text--edit js-forum-posting-text js-ctrl-enter-submit" placeholder="Type your post content here...">{{ posting_defaults.text|default(posting_post.post_text|default('')) }}</textarea>
+                <textarea name="post[text]" class="forum__post__text forum__post__text--edit js-forum-posting-text js-ctrl-enter-submit" placeholder="Type your post content here...">{{ posting_defaults.text|default(posting_post.body|default('')) }}</textarea>
                 <div class="forum__post__text js-forum-posting-preview" hidden></div>
 
                 <div class="forum__post__actions forum__post__actions--bbcode" hidden>
@@ -142,7 +142,7 @@
                         {{ input_select(
                             'post[parser]',
                             constant('\\Misuzu\\Parsers\\Parser::NAMES'),
-                            posting_defaults.parser|default(posting_post.post_parse|default(posting_user.preferredParser)),
+                            posting_defaults.parser|default(posting_post.bodyParser|default(posting_user.preferredParser)),
                             null, null, false, 'forum__post__dropdown js-forum-posting-parser'
                         ) }}
                         {% if is_opening and posting_types|length > 1 %}
@@ -158,8 +158,8 @@
                             'Display Signature',
                             posting_defaults.signature is not null
                                 ? posting_defaults.signature : (
-                                    posting_post.post_display_signature is defined
-                                        ? posting_post.post_display_signature
+                                    posting_post.shouldDisplaySignature is defined
+                                        ? posting_post.shouldDisplaySignature
                                         : true
                                 )
                         ) }}