diff --git a/assets/css/misuzu/navigation.css b/assets/css/misuzu/navigation.css index 2dcaf7c1..5f8937ed 100644 --- a/assets/css/misuzu/navigation.css +++ b/assets/css/misuzu/navigation.css @@ -3,7 +3,7 @@ width: 100%; display: flex; border-width: 0; - border-color: var(--text-colour); + border-color: #000; border-style: solid; border-top-width: 1px; align-items: flex-start; @@ -23,7 +23,7 @@ .navigation__option { list-style: none; background-color: #c9bbcc; - border: 1px solid var(--text-colour); + border: 1px solid #000; border-top-width: 0; flex-grow: 0; } @@ -31,6 +31,7 @@ .navigation__option--selected { background-color: var(--accent-colour); top: -1px; + padding-bottom: 2px; } .navigation__option--selected:not(:first-child) { margin-left: -1px; @@ -40,7 +41,7 @@ .navigation__link { display: block; padding: 2px 1em; - color: var(--text-colour); + color: #000; text-decoration: none; } .navigation__link:hover, .navigation__link:focus { color: #609; } diff --git a/database/2020_06_20_194341_forum_updates.php b/database/2020_06_20_194341_forum_updates.php new file mode 100644 index 00000000..7795c556 --- /dev/null +++ b/database/2020_06_20_194341_forum_updates.php @@ -0,0 +1,66 @@ +<?php +namespace Misuzu\DatabaseMigrations\ForumUpdates; + +use PDO; + +function migrate_up(PDO $conn): void { + $conn->exec(" + ALTER TABLE `msz_forum_topics` + ADD COLUMN `topic_count_posts` INT(10) UNSIGNED NOT NULL DEFAULT '0' AFTER `topic_title`, + ADD COLUMN `topic_post_first` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `topic_count_views`, + ADD COLUMN `topic_post_last` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `topic_post_first`, + DROP COLUMN `poll_id`, + DROP INDEX `posts_poll_id_foreign`, + DROP FOREIGN KEY `posts_poll_id_foreign`, + ADD INDEX `topics_post_first_foreign` (`topic_post_first`), + ADD INDEX `topics_post_last_foreign` (`topic_post_last`), + ADD CONSTRAINT `topics_post_first_foreign` + FOREIGN KEY (`topic_post_first`) + REFERENCES `msz_forum_posts` (`post_id`) + ON UPDATE CASCADE + ON DELETE SET NULL, + ADD CONSTRAINT `topics_post_last_foreign` + FOREIGN KEY (`topic_post_last`) + REFERENCES `msz_forum_posts` (`post_id`) + ON UPDATE CASCADE + ON DELETE SET NULL; + "); + + $conn->exec(" + ALTER TABLE `msz_forum_polls` + ADD COLUMN `topic_id` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `poll_id`, + ADD INDEX `forum_poll_topic_foreign` (`topic_id`), + ADD CONSTRAINT `forum_poll_topic_foreign` + FOREIGN KEY (`topic_id`) + REFERENCES `msz_forum_topics` (`topic_id`) + ON UPDATE CASCADE + ON DELETE CASCADE; + "); +} + +function migrate_down(PDO $conn): void { + $conn->exec(" + ALTER TABLE `msz_forum_polls` + DROP COLUMN `topic_id`, + DROP INDEX `forum_poll_topic_foreign`, + DROP FOREIGN KEY `forum_poll_topic_foreign`; + "); + + $conn->exec(" + ALTER TABLE `msz_forum_topics` + ADD COLUMN `poll_id` INT(10) UNSIGNED NULL DEFAULT NULL AFTER `user_id`, + DROP COLUMN `topic_count_posts`, + DROP COLUMN `topic_post_first`, + DROP COLUMN `topic_post_last`, + DROP INDEX `topics_post_first_foreign`, + DROP INDEX `topics_post_last_foreign`, + DROP FOREIGN KEY `topics_post_first_foreign`, + DROP FOREIGN KEY `topics_post_last_foreign`, + ADD INDEX `posts_poll_id_foreign` (`poll_id`), + ADD CONSTRAINT `posts_poll_id_foreign` + FOREIGN KEY (`poll_id`) + REFERENCES `msz_users` (`poll_id`) + ON UPDATE CASCADE + ON DELETE SET NULL; + "); +} diff --git a/misuzu.php b/misuzu.php index ab574389..b8f30afd 100644 --- a/misuzu.php +++ b/misuzu.php @@ -76,12 +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/leaderboard.php'; -require_once 'src/Forum/poll.php'; -require_once 'src/Forum/post.php'; -require_once 'src/Forum/topic.php'; -require_once 'src/Forum/validate.php'; $dbConfig = parse_ini_file(MSZ_CONFIG . '/config.ini', true, INI_SCANNER_TYPED); @@ -123,12 +117,6 @@ if(MSZ_CLI) { // Temporary backwards compatibility measure, remove this later // Everything below here should eventually be moved to index.php, probably only initialised when required. // Serving things like the css/js doesn't need to initialise sessions. -if(!mb_check_encoding()) { - http_response_code(415); - echo 'Invalid request encoding.'; - exit; -} - ob_start(); if(file_exists(MSZ_ROOT . '/.migrating')) { diff --git a/public/forum/forum.php b/public/forum/forum.php index 78b67aae..6b2e29b3 100644 --- a/public/forum/forum.php +++ b/public/forum/forum.php @@ -1,81 +1,2 @@ <?php -namespace Misuzu; - -use Misuzu\Users\User; - -require_once '../../misuzu.php'; - -$forumId = !empty($_GET['f']) && is_string($_GET['f']) ? (int)$_GET['f'] : 0; -$forumId = max($forumId, 0); - -if($forumId === 0) { - url_redirect('forum-index'); - exit; -} - -$forum = forum_get($forumId); -$forumUser = User::getCurrent(); -$forumUserId = $forumUser === null ? 0 : $forumUser->getId(); - -if(empty($forum) || ($forum['forum_type'] == MSZ_FORUM_TYPE_LINK && empty($forum['forum_link']))) { - echo render_error(404); - return; -} - -$perms = forum_perms_get_user($forum['forum_id'], $forumUserId)[MSZ_FORUM_PERMS_GENERAL]; - -if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) { - echo render_error(403); - return; -} - -if(isset($forumUser) && $forumUser->hasActiveWarning()) - $perms &= ~MSZ_FORUM_PERM_SET_WRITE; - -Template::set('forum_perms', $perms); - -if($forum['forum_type'] == MSZ_FORUM_TYPE_LINK) { - forum_increment_clicks($forum['forum_id']); - redirect($forum['forum_link']); - return; -} - -$forumPagination = new Pagination($forum['forum_topic_count'], 20); - -if(!$forumPagination->hasValidOffset() && $forum['forum_topic_count'] > 0) { - echo render_error(404); - return; -} - -$forumMayHaveTopics = forum_may_have_topics($forum['forum_type']); -$topics = $forumMayHaveTopics - ? forum_topic_listing( - $forum['forum_id'], - $forumUserId, - $forumPagination->getOffset(), - $forumPagination->getRange(), - perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST), - forum_has_priority_voting($forum['forum_type']) - ) - : []; - -$forumMayHaveChildren = forum_may_have_children($forum['forum_type']); - -if($forumMayHaveChildren) { - $forum['forum_subforums'] = forum_get_children($forum['forum_id'], $forumUserId); - - foreach($forum['forum_subforums'] as $skey => $subforum) { - $forum['forum_subforums'][$skey]['forum_subforums'] - = forum_get_children($subforum['forum_id'], $forumUserId); - } -} - -Template::render('forum.forum', [ - 'forum_breadcrumbs' => forum_get_breadcrumbs($forum['forum_id']), - 'global_accent_colour' => forum_get_colour($forum['forum_id']), - 'forum_may_have_topics' => $forumMayHaveTopics, - 'forum_may_have_children' => $forumMayHaveChildren, - 'forum_info' => $forum, - 'forum_topics' => $topics, - 'forum_pagination' => $forumPagination, -]); +require_once __DIR__ . '/../index.php'; diff --git a/public/forum/index.php b/public/forum/index.php index e168c337..6b2e29b3 100644 --- a/public/forum/index.php +++ b/public/forum/index.php @@ -1,41 +1,2 @@ <?php -namespace Misuzu; - -use Misuzu\Users\User; - -require_once '../../misuzu.php'; - -$indexMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : ''; -$forumId = !empty($_GET['f']) && is_string($_GET['f']) ? (int)$_GET['f'] : 0; - -$currentUser = User::getCurrent(); -$currentUserId = $currentUser === null ? 0 : $currentUser->getId(); - -switch($indexMode) { - case 'mark': - url_redirect($forumId < 1 ? 'forum-mark-global' : 'forum-mark-single', ['forum' => $forumId]); - break; - - default: - $categories = forum_get_root_categories($currentUserId); - $blankForum = count($categories) < 1; - - foreach($categories as $key => $category) { - $categories[$key]['forum_subforums'] = forum_get_children($category['forum_id'], $currentUserId); - - foreach($categories[$key]['forum_subforums'] as $skey => $sub) { - if(!forum_may_have_children($sub['forum_type'])) { - continue; - } - - $categories[$key]['forum_subforums'][$skey]['forum_subforums'] - = forum_get_children($sub['forum_id'], $currentUserId); - } - } - - Template::render('forum.index', [ - 'forum_categories' => $categories, - 'forum_empty' => $blankForum, - ]); - break; -} +require_once __DIR__ . '/../index.php'; diff --git a/public/forum/leaderboard.php b/public/forum/leaderboard.php index 97eda9b9..24a0cecd 100644 --- a/public/forum/leaderboard.php +++ b/public/forum/leaderboard.php @@ -1,6 +1,7 @@ <?php namespace Misuzu; +use Misuzu\Forum\ForumLeaderboard; use Misuzu\Users\User; require_once '../../misuzu.php'; @@ -14,7 +15,7 @@ $leaderboardMode = !empty($_GET['mode']) && is_string($_GET['mode']) && ctype_lo $leaderboardId = !empty($_GET['id']) && is_string($_GET['id']) && ctype_digit($_GET['id']) ? $_GET['id'] - : MSZ_FORUM_LEADERBOARD_CATEGORY_ALL; + : ForumLeaderboard::CATEGORY_ALL; $leaderboardIdLength = strlen($leaderboardId); $leaderboardYear = $leaderboardIdLength === 4 || $leaderboardIdLength === 6 ? substr($leaderboardId, 0, 4) : null; @@ -22,8 +23,8 @@ $leaderboardMonth = $leaderboardIdLength === 6 ? substr($leaderboardId, 4, 2) : $unrankedForums = !empty($_GET['allow_unranked']) ? [] : Config::get('forum_leader.unranked.forum', Config::TYPE_ARR); $unrankedTopics = !empty($_GET['allow_unranked']) ? [] : Config::get('forum_leader.unranked.topic', Config::TYPE_ARR); -$leaderboards = forum_leaderboard_categories(); -$leaderboard = forum_leaderboard_listing($leaderboardYear, $leaderboardMonth, $unrankedForums, $unrankedTopics); +$leaderboards = ForumLeaderboard::categories(); +$leaderboard = ForumLeaderboard::listing($leaderboardYear, $leaderboardMonth, $unrankedForums, $unrankedTopics); $leaderboardName = 'All Time'; diff --git a/public/forum/poll.php b/public/forum/poll.php deleted file mode 100644 index db26181f..00000000 --- a/public/forum/poll.php +++ /dev/null @@ -1,102 +0,0 @@ -<?php -namespace Misuzu; - -use Misuzu\Users\User; - -require_once '../../misuzu.php'; - -$redirect = !empty($_SERVER['HTTP_REFERER']) && empty($_SERVER['HTTP_X_MISUZU_XHR']) ? $_SERVER['HTTP_REFERER'] : ''; -$isXHR = !$redirect; - -if($isXHR) { - header('Content-Type: application/json; charset=utf-8'); -} elseif(!is_local_url($redirect)) { - echo render_info('Possible request forgery detected.', 403); - return; -} - -if(!CSRF::validateRequest()) { - echo render_info_or_json($isXHR, "Couldn't verify this request, please refresh the page and try again.", 403); - return; -} - -$currentUser = User::getCurrent(); - -if($currentUser === null) { - echo render_info_or_json($isXHR, 'You must be logged in to vote on polls.', 401); - return; -} - -$currentUserId = $currentUser->getId(); - -if($currentUser->isBanned()) { - echo render_info_or_json($isXHR, 'You have been banned, check your profile for more information.', 403); - return; -} -if($currentUser->isSilenced()) { - echo render_info_or_json($isXHR, 'You have been silenced, check your profile for more information.', 403); - return; -} - -header(CSRF::header()); - -if(empty($_POST['poll']['id']) || !ctype_digit($_POST['poll']['id'])) { - echo render_info_or_json($isXHR, "Invalid request.", 400); - return; -} - -$poll = forum_poll_get($_POST['poll']['id']); - -if(empty($poll)) { - echo "Poll {$poll['poll_id']} doesn't exist.<br>"; - return; -} - -$topicInfo = forum_poll_get_topic($poll['poll_id']); - -if(!is_null($topicInfo['topic_locked'])) { - echo "The topic associated with this poll has been locked.<br>"; - return; -} - -if(!forum_perms_check_user( - MSZ_FORUM_PERMS_GENERAL, $topicInfo['forum_id'], - $currentUserId, MSZ_FORUM_PERM_SET_READ -)) { - echo "You aren't allowed to vote on this poll.<br>"; - return; -} - -if($poll['poll_expired']) { - echo "Voting for poll {$poll['poll_id']} has closed.<br>"; - return; -} - -if(!$poll['poll_change_vote'] && forum_poll_has_voted($currentUserId, $poll['poll_id'])) { - echo "Can't change vote for {$poll['poll_id']}<br>"; - return; -} - -$answers = !empty($_POST['poll']['answers']) - && is_array($_POST['poll']['answers']) - ? $_POST['poll']['answers'] - : []; - -if(count($answers) > $poll['poll_max_votes']) { - echo "Too many votes for poll {$poll['poll_id']}<br>"; - return; -} - -forum_poll_vote_remove($currentUserId, $poll['poll_id']); - -foreach($answers as $answerId) { - if(!is_string($answerId) || !ctype_digit($answerId) - || !forum_poll_validate_option($poll['poll_id'], (int)$answerId)) { - echo "Vote {$answerId} was invalid for {$poll['poll_id']}<br>"; - continue; - } - - forum_poll_vote_cast($currentUserId, $poll['poll_id'], (int)$answerId); -} - -url_redirect('forum-topic', ['topic' => $topicInfo['topic_id']]); diff --git a/public/forum/post.php b/public/forum/post.php index 4bef354c..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; @@ -30,7 +32,7 @@ if(!empty($postMode) && !UserSession::hasCurrent()) { return; } -$currentUser = User::getCurrent(): +$currentUser = User::getCurrent(); $currentUserId = $currentUser === null ? 0 : $currentUser->getId(); if(isset($currentUser) && $currentUser->isBanned()) { @@ -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'] / MSZ_FORUM_POSTS_PER_PAGE) + 1, - ]); } diff --git a/public/forum/posting.php b/public/forum/posting.php index 0538d0e7..70326428 100644 --- a/public/forum/posting.php +++ b/public/forum/posting.php @@ -1,6 +1,16 @@ <?php namespace Misuzu; +use Misuzu\Forum\ForumCategory; +use Misuzu\Forum\ForumCategoryNotFoundException; +use Misuzu\Forum\ForumTopic; +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; use Misuzu\Users\User; @@ -63,42 +73,37 @@ if(empty($postId) && empty($topicId) && empty($forumId)) { return; } -if(!empty($postId)) { - $post = forum_post_get($postId); +if(!empty($postId)) + try { + $postInfo = ForumPost::byId($postId); + $topicId = $postInfo->getTopicId(); + } catch(ForumPostNotFoundException $ex) {} - 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($topicId)) + try { + $topicInfo = ForumTopic::byId($topicId); + $forumId = $topicInfo->getCategoryId(); + } catch(ForumTopicNotFoundException $ex) {} -if(!empty($topicId)) { - $topic = forum_topic_get($topicId); - if(isset($topic['forum_id'])) { - $forumId = (int)$topic['forum_id']; - } -} - -if(!empty($forumId)) { - $forum = forum_get($forumId); -} - -if(empty($forum)) { +try { + $forumInfo = ForumCategory::byId($forumId); +} catch(ForumCategoryNotFoundException $ex) { echo render_error(404); return; } -$perms = forum_perms_get_user($forum['forum_id'], $currentUserId)[MSZ_FORUM_PERMS_GENERAL]; +$perms = forum_perms_get_user($forumInfo->getId(), $currentUserId)[MSZ_FORUM_PERMS_GENERAL]; -if($forum['forum_archived'] - || (!empty($topic['topic_locked']) && !perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC)) +if($forumInfo->isArchived() + || (!empty($topicInfo) && $topicInfo->isLocked() && !perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC)) || !perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM | MSZ_FORUM_PERM_CREATE_POST) - || (empty($topic) && !perms_check($perms, MSZ_FORUM_PERM_CREATE_TOPIC))) { + || (empty($topicInfo) && !perms_check($perms, MSZ_FORUM_PERM_CREATE_TOPIC))) { echo render_error(403); return; } -if(!forum_may_have_topics($forum['forum_type'])) { +if(!$forumInfo->canHaveTopics()) { echo render_error(400); return; } @@ -106,48 +111,45 @@ if(!forum_may_have_topics($forum['forum_type'])) { $topicTypes = []; if($mode === 'create' || $mode === 'edit') { - $topicTypes[MSZ_TOPIC_TYPE_DISCUSSION] = 'Normal discussion'; - - if(perms_check($perms, MSZ_FORUM_PERM_STICKY_TOPIC)) { - $topicTypes[MSZ_TOPIC_TYPE_STICKY] = 'Sticky topic'; - } - if(perms_check($perms, MSZ_FORUM_PERM_ANNOUNCE_TOPIC)) { - $topicTypes[MSZ_TOPIC_TYPE_ANNOUNCEMENT] = 'Announcement'; - } - if(perms_check($perms, MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC)) { - $topicTypes[MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT] = 'Global Announcement'; - } + $topicTypes[ForumTopic::TYPE_DISCUSSION] = 'Normal discussion'; + if(perms_check($perms, MSZ_FORUM_PERM_STICKY_TOPIC)) + $topicTypes[ForumTopic::TYPE_STICKY] = 'Sticky topic'; + if(perms_check($perms, MSZ_FORUM_PERM_ANNOUNCE_TOPIC)) + $topicTypes[ForumTopic::TYPE_ANNOUNCEMENT] = 'Announcement'; + if(perms_check($perms, MSZ_FORUM_PERM_GLOBAL_ANNOUNCE_TOPIC)) + $topicTypes[ForumTopic::TYPE_GLOBAL_ANNOUNCEMENT] = 'Global Announcement'; } // 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; } } $notices = []; +$isNewTopic = false; 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 = empty($topic) || ($mode === 'edit' && $post['is_opening_post']); + $isEditingTopic = $isNewTopic || ($mode === 'edit' && $postInfo->isOpeningPost()); if($mode === 'create') { - $timeoutCheck = max(1, forum_timeout($forumId, $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)); @@ -156,20 +158,14 @@ if(!empty($_POST)) { } if($isEditingTopic) { - $originalTopicTitle = $topic['topic_title'] ?? null; + $originalTopicTitle = $isNewTopic ? null : $topicInfo->getTitle(); $topicTitleChanged = $topicTitle !== $originalTopicTitle; - $originalTopicType = (int)($topic['topic_type'] ?? MSZ_TOPIC_TYPE_DISCUSSION); + $originalTopicType = $isNewTopic ? ForumTopic::TYPE_DISCUSSION : $topicInfo->getType(); $topicTypeChanged = $topicType !== null && $topicType !== $originalTopicType; - switch(forum_validate_title($topicTitle)) { - case 'too-short': - $notices[] = 'Topic title was too short.'; - break; - - case 'too-long': - $notices[] = 'Topic title was too long.'; - break; - } + $validateTopicTitle = ForumTopic::validateTitle($topicTitle); + if(!empty($validateTopicTitle)) + $notices[] = ForumTopic::titleValidationErrorString($validateTopicTitle); if($mode === 'create' && $topicType === null) { $topicType = array_key_first($topicTypes); @@ -178,54 +174,52 @@ if(!empty($_POST)) { } } - if(!Parser::isValid($postParser)) { + if(!Parser::isValid($postParser)) $notices[] = 'Invalid parser selected.'; - } - switch(forum_validate_post($postText)) { - case 'too-short': - $notices[] = 'Post content was too short.'; - break; - - case 'too-long': - $notices[] = 'Post content was too long.'; - break; - } + $postBodyValidation = ForumPost::validateBody($postText); + if(!empty($postBodyValidation)) + $notices[] = ForumPost::bodyValidationErrorString($postBodyValidation); if(empty($notices)) { switch($mode) { case 'create': - if(!empty($topic)) { - forum_topic_bump($topic['topic_id']); + if(!empty($topicInfo)) { + $topicInfo->bumpTopic(); } else { - $topicId = forum_topic_create( - $forum['forum_id'], - $currentUserId, - $topicTitle, - $topicType - ); + $isNewTopic = true; + $topicInfo = ForumTopic::create($forumInfo, $currentUser, $topicTitle, $topicType); + $topicId = $topicInfo->getId(); } - $postId = forum_post_create( - $topicId, - $forum['forum_id'], - $currentUserId, - IPAddress::remote(), - $postText, - $postParser, - $postSignature - ); - forum_topic_mark_read($currentUserId, $topicId, $forum['forum_id']); - forum_count_increase($forum['forum_id'], empty($topic)); + $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.'; } if($isEditingTopic && ($topicTitleChanged || $topicTypeChanged)) { - if(!forum_topic_update($topicId, $topicTitle, $topicType)) { + $topicInfo->setTitle($topicTitle)->setType($topicType); + + try { + $topicInfo->update(); + } catch(ForumTopicUpdateFailedException $ex) { $notices[] = 'Topic update failed.'; } } @@ -233,7 +227,7 @@ if(!empty($_POST)) { } if(empty($notices)) { - $redirect = url(empty($topic) ? 'forum-topic' : 'forum-post', [ + $redirect = url($isNewTopic ? 'forum-topic' : 'forum-post', [ 'topic' => $topicId ?? 0, 'post' => $postId ?? 0, 'post_fragment' => 'p' . ($postId ?? 0), @@ -245,21 +239,18 @@ if(!empty($_POST)) { } } -if(!empty($topic)) { - Template::set('posting_topic', $topic); +if(!$isNewTopic && !empty($topicInfo)) { + Template::set('posting_topic', $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); } -$displayInfo = forum_posting_info($currentUserId); - Template::render('forum.posting', [ - 'posting_breadcrumbs' => forum_get_breadcrumbs($forumId), - 'global_accent_colour' => forum_get_colour($forumId), - 'posting_forum' => $forum, - 'posting_info' => $displayInfo, + 'global_accent_colour' => $forumInfo->getColour(), + 'posting_forum' => $forumInfo, + 'posting_user' => $currentUser, 'posting_notices' => $notices, 'posting_mode' => $mode, 'posting_types' => $topicTypes, diff --git a/public/forum/topic-priority.php b/public/forum/topic-priority.php deleted file mode 100644 index 3a92e014..00000000 --- a/public/forum/topic-priority.php +++ /dev/null @@ -1,55 +0,0 @@ -<?php -namespace Misuzu; - -use Misuzu\Users\User; -use Misuzu\Users\UserNotFoundException; - -require_once '../../misuzu.php'; - -if(!MSZ_DEBUG) - return; - -$topicId = !empty($_GET['t']) && is_string($_GET['t']) ? (int)$_GET['t'] : 0; -$topicUser = User::getCurrent(); -$topicUserId = $topicUser === null ? 0 : $topicUser->getId(); - -if($topicUserId < 1) { - echo render_error(403); - return; -} - -$topic = forum_topic_get($topicId, true); -$perms = $topic - ? forum_perms_get_user($topic['forum_id'], $topicUserId)[MSZ_FORUM_PERMS_GENERAL] - : 0; - -if(isset($topicUser) && $topicUser->hasActiveWarning()) - $perms &= ~MSZ_FORUM_PERM_SET_WRITE; - -$topicIsDeleted = !empty($topic['topic_deleted']); -$canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST); - -if(!$topic || ($topicIsDeleted && !$canDeleteAny)) { - echo render_error(404); - return; -} - -if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM, true) // | MSZ_FORUM_PERM_PRIORITY_VOTE - || !$canDeleteAny - && ( - !empty($topic['topic_locked']) - || !empty($topic['topic_archived']) - ) -) { - echo render_error(403); - return; -} - -if(!forum_has_priority_voting($topic['forum_type'])) { - echo render_error(400); - return; -} - -forum_topic_priority_increase($topicId, $topicUserId); - -url_redirect('forum-topic', ['topic' => $topicId]); diff --git a/public/forum/topic.php b/public/forum/topic.php index 690cd47a..cee90e80 100644 --- a/public/forum/topic.php +++ b/public/forum/topic.php @@ -2,39 +2,47 @@ namespace Misuzu; use Misuzu\AuditLog; +use Misuzu\Forum\ForumTopic; +use Misuzu\Forum\ForumTopicNotFoundException; +use Misuzu\Forum\ForumPost; +use Misuzu\Forum\ForumPostNotFoundException; use Misuzu\Users\User; use Misuzu\Users\UserSession; require_once '../../misuzu.php'; -$postId = !empty($_GET['p']) && is_string($_GET['p']) ? (int)$_GET['p'] : 0; -$topicId = !empty($_GET['t']) && is_string($_GET['t']) ? (int)$_GET['t'] : 0; -$moderationMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : ''; -$submissionConfirmed = !empty($_GET['confirm']) && is_string($_GET['confirm']) && $_GET['confirm'] === '1'; +$postId = (int)filter_input(INPUT_GET, 'p', FILTER_SANITIZE_NUMBER_INT); +$topicId = (int)filter_input(INPUT_GET, 't', FILTER_SANITIZE_NUMBER_INT); +$moderationMode = (string)filter_input(INPUT_GET, 'm', FILTER_SANITIZE_STRING); +$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); +} catch(ForumTopicNotFoundException $ex) { + echo render_error(404); + return; } -$topic = forum_topic_get($topicId, true); -$perms = $topic - ? forum_perms_get_user($topic['forum_id'], $topicUserId)[MSZ_FORUM_PERMS_GENERAL] - : 0; +$perms = forum_perms_get_user($topicInfo->getCategory()->getId(), $topicUserId)[MSZ_FORUM_PERMS_GENERAL]; if(isset($topicUser) && $topicUser->hasActiveWarning()) $perms &= ~MSZ_FORUM_PERM_SET_WRITE; -$topicIsDeleted = !empty($topic['topic_deleted']); $canDeleteAny = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST); -if(!$topic || ($topicIsDeleted && !$canDeleteAny)) { +if($topicInfo->isDeleted() && !$canDeleteAny) { echo render_error(404); return; } @@ -44,29 +52,18 @@ if(!perms_check($perms, MSZ_FORUM_PERM_VIEW_FORUM)) { return; } -if(!empty($topic['poll_id'])) { - $pollOptions = forum_poll_get_options($topic['poll_id']); - $pollUserAnswers = forum_poll_get_user_answers($topic['poll_id'], $topicUserId); -} - -if(forum_has_priority_voting($topic['forum_type'])) { - $topicPriority = forum_topic_priority($topic['topic_id']); -} - -$topicIsLocked = !empty($topic['topic_locked']); -$topicIsArchived = !empty($topic['topic_archived']); -$topicPostsTotal = (int)($topic['topic_count_posts'] + $topic['topic_count_posts_deleted']); -$topicIsFrozen = $topicIsArchived || $topicIsDeleted; -$canDeleteOwn = !$topicIsFrozen && !$topicIsLocked && perms_check($perms, MSZ_FORUM_PERM_DELETE_POST); +$topicPostsTotal = $topicInfo->getActualPostCount(true); +$topicIsFrozen = $topicInfo->isArchived() || $topicInfo->isDeleted(); +$canDeleteOwn = !$topicIsFrozen && !$topicInfo->isLocked() && perms_check($perms, MSZ_FORUM_PERM_DELETE_POST); $canBumpTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_BUMP_TOPIC); $canLockTopic = !$topicIsFrozen && perms_check($perms, MSZ_FORUM_PERM_LOCK_TOPIC); -$canNukeOrRestore = $canDeleteAny && $topicIsDeleted; -$canDelete = !$topicIsDeleted && ( +$canNukeOrRestore = $canDeleteAny && $topicInfo->isDeleted(); +$canDelete = !$topicInfo->isDeleted() && ( $canDeleteAny || ( $topicPostsTotal > 0 - && $topicPostsTotal <= MSZ_FORUM_TOPIC_DELETE_POST_LIMIT + && $topicPostsTotal <= ForumTopic::DELETE_POST_LIMIT && $canDeleteOwn - && $topic['author_user_id'] === $topicUserId + && $topicInfo->getUserId() === $topicUserId ) ); @@ -109,52 +106,25 @@ if(in_array($moderationMode, $validModerationModes, true)) { switch($moderationMode) { case 'delete': - $canDeleteCode = forum_topic_can_delete($topic, $topicUserId); - $canDeleteMsg = ''; - $responseCode = 200; + $canDeleteCodes = [ + 'view' => 404, + 'deleted' => 404, + 'owner' => 403, + 'age' => 403, + 'permission' => 403, + 'posts' => 403, + '' => 200, + ]; + $canDelete = $topicInfo->canBeDeleted($topicUser); + $canDeleteMsg = ForumTopic::canBeDeletedErrorString($canDelete); + $responseCode = $canDeleteCodes[$canDelete] ?? 500; - switch($canDeleteCode) { - case MSZ_E_FORUM_TOPIC_DELETE_USER: - $responseCode = 401; - $canDeleteMsg = 'You must be logged in to delete topics.'; - break; - case MSZ_E_FORUM_TOPIC_DELETE_TOPIC: - $responseCode = 404; - $canDeleteMsg = "This topic doesn't exist."; - break; - case MSZ_E_FORUM_TOPIC_DELETE_DELETED: - $responseCode = 404; - $canDeleteMsg = 'This topic has already been marked as deleted.'; - break; - case MSZ_E_FORUM_TOPIC_DELETE_OWNER: - $responseCode = 403; - $canDeleteMsg = 'You can only delete your own topics.'; - break; - case MSZ_E_FORUM_TOPIC_DELETE_OLD: - $responseCode = 401; - $canDeleteMsg = 'This topic has existed for too long. Ask a moderator to remove if it absolutely necessary.'; - break; - case MSZ_E_FORUM_TOPIC_DELETE_PERM: - $responseCode = 401; - $canDeleteMsg = 'You are not allowed to delete topics.'; - break; - case MSZ_E_FORUM_TOPIC_DELETE_POSTS: - $responseCode = 403; - $canDeleteMsg = 'This topic already has replies, you may no longer delete it. Ask a moderator to remove if it absolutely necessary.'; - break; - case MSZ_E_FORUM_TOPIC_DELETE_OK: - break; - default: - $responseCode = 500; - $canDeleteMsg = sprintf('Unknown error \'%d\'', $canDelete); - } - - if($canDeleteCode !== MSZ_E_FORUM_TOPIC_DELETE_OK) { + if($canDelete !== '') { if($isXHR) { http_response_code($responseCode); echo json_encode([ 'success' => false, - 'topic_id' => $topic['topic_id'], + 'topic_id' => $topicInfo->getId(), 'code' => $canDeleteCode, 'message' => $canDeleteMsg, ]); @@ -170,9 +140,9 @@ if(in_array($moderationMode, $validModerationModes, true)) { Template::render('forum.confirm', [ 'title' => 'Confirm topic deletion', 'class' => 'far fa-trash-alt', - 'message' => sprintf('You are about to delete topic #%d. Are you sure about that?', $topic['topic_id']), + 'message' => sprintf('You are about to delete topic #%d. Are you sure about that?', $topicInfo->getId()), 'params' => [ - 't' => $topic['topic_id'], + 't' => $topicInfo->getId(), 'm' => 'delete', ], ]); @@ -180,34 +150,26 @@ if(in_array($moderationMode, $validModerationModes, true)) { } elseif(!$submissionConfirmed) { url_redirect( 'forum-topic', - ['topic' => $topic['topic_id']] + ['topic' => $topicInfo->getId()] ); break; } } - $deleteTopic = forum_topic_delete($topic['topic_id']); - - if($deleteTopic) { - AuditLog::create(AuditLog::FORUM_TOPIC_DELETE, [$topic['topic_id']]); - } + $topicInfo->delete(); + AuditLog::create(AuditLog::FORUM_TOPIC_DELETE, [$topicInfo->getId()]); if($isXHR) { echo json_encode([ - 'success' => $deleteTopic, - 'topic_id' => $topic['topic_id'], - 'message' => $deleteTopic ? 'Topic deleted!' : 'Failed to delete topic.', + 'success' => true, + 'topic_id' => $topicInfo->getId(), + 'message' => 'Topic deleted!', ]); break; } - if(!$deleteTopic) { - echo render_error(500); - break; - } - url_redirect('forum-category', [ - 'forum' => $topic['forum_id'], + 'forum' => $topicInfo->getCategoryId(), ]); break; @@ -222,34 +184,28 @@ if(in_array($moderationMode, $validModerationModes, true)) { Template::render('forum.confirm', [ 'title' => 'Confirm topic restore', 'class' => 'fas fa-magic', - 'message' => sprintf('You are about to restore topic #%d. Are you sure about that?', $topic['topic_id']), + 'message' => sprintf('You are about to restore topic #%d. Are you sure about that?', $topicInfo->getId()), 'params' => [ - 't' => $topic['topic_id'], + 't' => $topicInfo->getId(), 'm' => 'restore', ], ]); break; } elseif(!$submissionConfirmed) { url_redirect('forum-topic', [ - 'topic' => $topic['topic_id'], + 'topic' => $topicInfo->getId(), ]); break; } } - $restoreTopic = forum_topic_restore($topic['topic_id']); - - if(!$restoreTopic) { - echo render_error(500); - break; - } - - AuditLog::create(AuditLog::FORUM_TOPIC_RESTORE, [$topic['topic_id']]); + $topicInfo->restore(); + AuditLog::create(AuditLog::FORUM_TOPIC_RESTORE, [$topicInfo->getId()]); http_response_code(204); if(!$isXHR) { url_redirect('forum-category', [ - 'forum' => $topic['forum_id'], + 'forum' => $topicInfo->getCategoryId(), ]); } break; @@ -265,124 +221,90 @@ if(in_array($moderationMode, $validModerationModes, true)) { Template::render('forum.confirm', [ 'title' => 'Confirm topic nuke', 'class' => 'fas fa-radiation', - 'message' => sprintf('You are about to PERMANENTLY DELETE topic #%d. Are you sure about that?', $topic['topic_id']), + 'message' => sprintf('You are about to PERMANENTLY DELETE topic #%d. Are you sure about that?', $topicInfo->getId()), 'params' => [ - 't' => $topic['topic_id'], + 't' => $topicInfo->getId(), 'm' => 'nuke', ], ]); break; } elseif(!$submissionConfirmed) { url_redirect('forum-topic', [ - 'topic' => $topic['topic_id'], + 'topic' => $topicInfo->getId(), ]); break; } } - $nukeTopic = forum_topic_nuke($topic['topic_id']); - - if(!$nukeTopic) { - echo render_error(500); - break; - } - - AuditLog::create(AuditLog::FORUM_TOPIC_NUKE, [$topic['topic_id']]); + $topicInfo->nuke(); + AuditLog::create(AuditLog::FORUM_TOPIC_NUKE, [$topicInfo->getId()]); http_response_code(204); if(!$isXHR) { url_redirect('forum-category', [ - 'forum' => $topic['forum_id'], + 'forum' => $topicInfo->getCategoryId(), ]); } break; case 'bump': - if($canBumpTopic && forum_topic_bump($topic['topic_id'])) { - AuditLog::create(AuditLog::FORUM_TOPIC_BUMP, [$topic['topic_id']]); + if($canBumpTopic) { + $topicInfo->bumpTopic(); + AuditLog::create(AuditLog::FORUM_TOPIC_BUMP, [$topicInfo->getId()]); } url_redirect('forum-topic', [ - 'topic' => $topic['topic_id'], + 'topic' => $topicInfo->getId(), ]); break; case 'lock': - if($canLockTopic && !$topicIsLocked && forum_topic_lock($topic['topic_id'])) { - AuditLog::create(AuditLog::FORUM_TOPIC_LOCK, [$topic['topic_id']]); + if($canLockTopic && !$topicInfo->isLocked()) { + $topicInfo->setLocked(true); + AuditLog::create(AuditLog::FORUM_TOPIC_LOCK, [$topicInfo->getId()]); } url_redirect('forum-topic', [ - 'topic' => $topic['topic_id'], + 'topic' => $topicInfo->getId(), ]); break; case 'unlock': - if($canLockTopic && $topicIsLocked && forum_topic_unlock($topic['topic_id'])) { - AuditLog::create(AuditLog::FORUM_TOPIC_UNLOCK, [$topic['topic_id']]); + if($canLockTopic && $topicInfo->isLocked()) { + $topicInfo->setLocked(false); + AuditLog::create(AuditLog::FORUM_TOPIC_UNLOCK, [$topicInfo->getId()]); } url_redirect('forum-topic', [ - 'topic' => $topic['topic_id'], + 'topic' => $topicInfo->getId(), ]); break; } return; } -$topicPosts = $topic['topic_count_posts']; +$topicPagination = new Pagination($topicInfo->getActualPostCount($canDeleteAny), \Misuzu\Forum\ForumPost::PER_PAGE, 'page'); -if($canDeleteAny) { - $topicPosts += $topic['topic_count_posts_deleted']; -} - -$topicPagination = new Pagination($topicPosts, MSZ_FORUM_POSTS_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); return; } -Template::set('topic_perms', $perms); +$canReply = !$topicInfo->isArchived() && !$topicInfo->isLocked() && !$topicInfo->isDeleted() && perms_check($perms, MSZ_FORUM_PERM_CREATE_POST); -$posts = forum_post_listing( - $topic['topic_id'], - $topicPagination->getOffset(), - $topicPagination->getRange(), - perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST) -); - -if(!$posts) { - echo render_error(404); - return; -} - -$canReply = !$topicIsArchived && !$topicIsLocked && !$topicIsDeleted && perms_check($perms, MSZ_FORUM_PERM_CREATE_POST); - -forum_topic_mark_read($topicUserId, $topic['topic_id'], $topic['forum_id']); +$topicInfo->markRead($topicUser); Template::render('forum.topic', [ - 'topic_breadcrumbs' => forum_get_breadcrumbs($topic['forum_id']), - 'global_accent_colour' => forum_get_colour($topic['forum_id']), - 'topic_info' => $topic, - 'topic_posts' => $posts, + 'topic_perms' => $perms, + 'topic_info' => $topicInfo, 'can_reply' => $canReply, 'topic_pagination' => $topicPagination, 'topic_can_delete' => $canDelete, + 'topic_can_view_deleted' => $canDeleteAny, 'topic_can_nuke_or_restore' => $canNukeOrRestore, 'topic_can_bump' => $canBumpTopic, 'topic_can_lock' => $canLockTopic, - 'topic_poll_options' => $pollOptions ?? [], - 'topic_poll_user_answers' => $pollUserAnswers ?? [], - 'topic_priority_votes' => $topicPriority ?? [], ]); diff --git a/public/index.php b/public/index.php index 96cf3d7c..1e42fd26 100644 --- a/public/index.php +++ b/public/index.php @@ -11,72 +11,106 @@ $request = HttpRequestMessage::fromGlobals(); Router::setHandlerFormat('\Misuzu\Http\Handlers\%sHandler'); Router::setFilterFormat('\Misuzu\Http\Filters\%sFilter'); -Router::addRoutes( - // Home - Route::get('/', 'index', 'Home'), - // Assets - Route::group('/assets', 'Assets')->addChildren( - Route::get('/([a-zA-Z0-9\-]+)\.(css|js)', 'serveComponent'), - Route::get('/avatar/([0-9]+)(?:\.png)?', 'serveAvatar'), - Route::get('/profile-background/([0-9]+)(?:\.png)?', 'serveProfileBackground'), - ), +if(strpos($request->getUri()->getPath(), '.php') === false) { + Router::addRoutes( + // Home + Route::get('/', 'index', 'Home'), - // Info - Route::get('/info', 'index', 'Info'), - Route::get('/info/([A-Za-z0-9_/]+)', 'page', 'Info'), + // Assets + Route::group('/assets', 'Assets')->addChildren( + Route::get('/([a-zA-Z0-9\-]+)\.(css|js)', 'serveComponent'), + Route::get('/avatar/([0-9]+)(?:\.png)?', 'serveAvatar'), + Route::get('/profile-background/([0-9]+)(?:\.png)?', 'serveProfileBackground'), + ), - // Changelog - Route::get('/changelog', 'index', 'Changelog')->addChildren( - Route::get('.atom', 'feedAtom'), - Route::get('.rss', 'feedRss'), - Route::get('/change/([0-9]+)', 'change'), - ), + // Info + Route::get('/info', 'index', 'Info'), + Route::get('/info/([A-Za-z0-9_\-/]+)', 'page', 'Info'), - // News - Route::get('/news', 'index', 'News')->addChildren( - Route::get('.atom', 'feedIndexAtom'), - Route::get('.rss', 'feedIndexRss'), - Route::get('/([0-9]+)', 'viewCategory'), - Route::get('/([0-9]+).atom', 'feedCategoryAtom'), - Route::get('/([0-9]+).rss', 'feedCategoryRss'), - Route::get('/post/([0-9]+)', 'viewPost') - ), + // Changelog + Route::get('/changelog', 'index', 'Changelog')->addChildren( + Route::get('.atom', 'feedAtom'), + Route::get('.rss', 'feedRss'), + Route::get('/change/([0-9]+)', 'change'), + ), - // Forum - Route::group('/forum', 'Forum')->addChildren( - Route::get('/mark-as-read', 'markAsReadGET')->addFilters('EnforceLogIn'), - Route::post('/mark-as-read', 'markAsReadPOST')->addFilters('EnforceLogIn', 'ValidateCsrf'), - ), + // News + Route::get('/news', 'index', 'News')->addChildren( + Route::get('.atom', 'feedIndexAtom'), + Route::get('.rss', 'feedIndexRss'), + Route::get('/([0-9]+)', 'viewCategory'), + Route::get('/([0-9]+).atom', 'feedCategoryAtom'), + Route::get('/([0-9]+).rss', 'feedCategoryRss'), + Route::get('/post/([0-9]+)', 'viewPost') + ), - // Sock Chat - Route::create(['GET', 'POST'], '/_sockchat.php', 'phpFile', 'SockChat'), - Route::group('/_sockchat', 'SockChat')->addChildren( - Route::get('/emotes', 'emotes'), - Route::get('/bans', 'bans'), - Route::get('/login', 'login'), - Route::post('/bump', 'bump'), - Route::post('/verify', 'verify'), - ), + // Forum + Route::get('/forum', 'index', 'Forum.ForumIndex')->addChildren( + Route::get('/mark-as-read', 'markAsRead')->addFilters('EnforceLogIn'), + Route::post('/mark-as-read', 'markAsRead')->addFilters('EnforceLogIn', 'ValidateCsrf'), - // Redirects - Route::get('/index.php', url('index')), - Route::get('/info.php', url('info')), - Route::get('/settings.php', url('settings-index')), - Route::get('/changelog.php', 'legacy', 'Changelog'), - Route::get('/info.php/([A-Za-z0-9_/]+)', 'redir', 'Info'), - Route::get('/auth.php', 'legacy', 'Auth'), - Route::get('/news.php', 'legacy', 'News'), - Route::get('/news.php/rss', 'legacy', 'News'), - Route::get('/news.php/atom', 'legacy', 'News'), - Route::get('/news/index.php', 'legacy', 'News'), - Route::get('/news/category.php', 'legacy', 'News'), - Route::get('/news/post.php', 'legacy', 'News'), - Route::get('/news/feed.php', 'legacy', 'News'), - Route::get('/news/feed.php/rss', 'legacy', 'News'), - Route::get('/news/feed.php/atom', 'legacy', 'News'), - Route::get('/user-assets.php', 'serveLegacy', 'Assets'), -); + Route::get('/([0-9]+)', 'category', 'Forum.ForumCategory')->addChildren( + Route::get('/create-topic', 'createView')->addFilters('EnforceLogIn'), + Route::post('/create-topic', 'createAction')->addFilters('EnforceLogIn', 'ValidateCsrf'), + ), + + Route::get('/topic/([0-9]+)', 'topic', 'Forum.ForumTopic')->addChildren( + Route::get('/live', 'live')->addFilters('EnforceLogIn'), + Route::post('/reply', 'reply')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/delete', 'delete')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/restore', 'restore')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/nuke', 'nuke')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/bump', 'bump')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/lock', 'lock')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/unlock', 'unlock')->addFilters('EnforceLogIn', 'ValidateCsrf'), + ), + + Route::get('/post/([0-9]+)', 'post', 'Forum.ForumPost')->addChildren( + Route::post('/', 'edit')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/delete', 'delete')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/restore', 'restore')->addFilters('EnforceLogIn', 'ValidateCsrf'), + Route::post('/nuke', 'nuke')->addFilters('EnforceLogIn', 'ValidateCsrf'), + ), + + Route::post('/poll/([0-9]+)', 'vote', 'Forum.ForumPoll')->addFilters('EnforceLogIn', 'ValidateCsrf'), + ), + + // Sock Chat + Route::create(['GET', 'POST'], '/_sockchat.php', 'phpFile', 'SockChat'), + Route::group('/_sockchat', 'SockChat')->addChildren( + Route::get('/emotes', 'emotes'), + Route::get('/bans', 'bans'), + Route::get('/login', 'login'), + Route::post('/bump', 'bump'), + Route::post('/verify', 'verify'), + ), + ); +} else { + Router::addRoutes( + // Redirects + Route::get('/index.php', url('index')), + Route::get('/info.php', url('info')), + Route::get('/settings.php', url('settings-index')), + Route::get('/changelog.php', 'legacy', 'Changelog'), + Route::get('/info.php/([A-Za-z0-9_\-/]+)', 'redir', 'Info'), + Route::get('/auth.php', 'legacy', 'Auth'), + Route::get('/news.php', 'legacy', 'News'), + Route::get('/news.php/rss', 'legacy', 'News'), + Route::get('/news.php/atom', 'legacy', 'News'), + Route::get('/news/index.php', 'legacy', 'News'), + Route::get('/news/category.php', 'legacy', 'News'), + Route::get('/news/post.php', 'legacy', 'News'), + Route::get('/news/feed.php', 'legacy', 'News'), + Route::get('/news/feed.php/rss', 'legacy', 'News'), + Route::get('/news/feed.php/atom', 'legacy', 'News'), + Route::get('/user-assets.php', 'serveLegacy', 'Assets'), + Route::create(['GET', 'POST'], '/forum/index.php', 'legacy', 'Forum.ForumIndex'), + Route::get('/forum/forum.php', 'legacy', 'Forum.ForumCategory'), + Route::get('/forum/topic.php', 'legacy', 'Forum.ForumTopic'), + Route::get('/forum/post.php', 'legacy', 'Forum.ForumPost'), + ); +} $response = Router::handle($request); $response->setHeader('X-Powered-By', 'Misuzu'); diff --git a/public/profile.php b/public/profile.php index 149f3cc8..5fd9b4b5 100644 --- a/public/profile.php +++ b/public/profile.php @@ -344,50 +344,19 @@ switch($profileMode) { case 'forum-topics': $template = 'profile.topics'; - $topicsCount = forum_topic_count_user($profileUser->getId(), $currentUserId); - $topicsPagination = new Pagination($topicsCount, 20); - - if(!$topicsPagination->hasValidOffset()) { - echo render_error(404); - return; - } - - $topics = forum_topic_listing_user( - $profileUser->getId(), $currentUserId, - $topicsPagination->getOffset(), $topicsPagination->getRange() - ); Template::set([ 'title' => $profileUser->getUsername() . ' / topics', 'canonical_url' => url('user-profile-forum-topics', ['user' => $profileUser->getId(), 'page' => Pagination::param()]), - 'profile_topics' => $topics, - 'profile_topics_pagination' => $topicsPagination, ]); break; case 'forum-posts': $template = 'profile.posts'; - $postsCount = forum_post_count_user($profileUser->getId()); - $postsPagination = new Pagination($postsCount, 20); - - if(!$postsPagination->hasValidOffset()) { - echo render_error(404); - return; - } - - $posts = forum_post_listing( - $profileUser->getId(), - $postsPagination->getOffset(), - $postsPagination->getRange(), - false, - true - ); Template::set([ 'title' => $profileUser->getUsername() . ' / posts', 'canonical_url' => url('user-profile-forum-posts', ['user' => $profileUser->getId(), 'page' => Pagination::param()]), - 'profile_posts' => $posts, - 'profile_posts_pagination' => $postsPagination, ]); break; diff --git a/public/search.php b/public/search.php index 3582f014..2f834003 100644 --- a/public/search.php +++ b/public/search.php @@ -1,6 +1,8 @@ <?php namespace Misuzu; +use Misuzu\Forum\ForumTopic; +use Misuzu\Forum\ForumPost; use Misuzu\News\NewsPost; use Misuzu\Users\User; @@ -9,8 +11,8 @@ require_once '../misuzu.php'; $searchQuery = !empty($_GET['q']) && is_string($_GET['q']) ? $_GET['q'] : ''; if(!empty($searchQuery)) { - $forumTopics = forum_topic_listing_search($searchQuery, User::hasCurrent() ? User::getCurrent()->getId() : 0); - $forumPosts = forum_post_search($searchQuery); + $forumTopics = ForumTopic::bySearchQuery($searchQuery); + $forumPosts = ForumPost::bySearchQuery($searchQuery); $newsPosts = NewsPost::bySearchQuery($searchQuery); $findUsers = DB::prepare(sprintf( diff --git a/src/Colour.php b/src/Colour.php index 832dbab2..fe012802 100644 --- a/src/Colour.php +++ b/src/Colour.php @@ -38,9 +38,10 @@ class Colour { return $this; } - public function getInherit(): bool { + public function isInherit(): bool { return ($this->getRaw() & self::FLAG_INHERIT) > 0; } + public function getInherit(): bool { return $this->isInherit(); } public function setInherit(bool $inherit): self { $raw = $this->getRaw(); 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/Database/DatabaseStatement.php b/src/Database/DatabaseStatement.php index 39c32e6e..93ea8a5a 100644 --- a/src/Database/DatabaseStatement.php +++ b/src/Database/DatabaseStatement.php @@ -43,13 +43,12 @@ class DatabaseStatement { return $out ? $out : $default; } + private $hasExecuted = false; public function fetchObject(string $className = 'stdClass', ?array $args = null, $default = null) { - $out = false; - - if($this->isQuery || $this->execute()) { - $out = $args === null ? $this->stmt->fetchObject($className) : $this->stmt->fetchObject($className, $args); - } + if(!$this->hasExecuted) + $this->hasExecuted = $this->isQuery || $this->execute(); + $out = $args === null ? $this->stmt->fetchObject($className) : $this->stmt->fetchObject($className, $args); return $out !== false ? $out : $default; } diff --git a/src/Forum/ForumCategory.php b/src/Forum/ForumCategory.php new file mode 100644 index 00000000..ff8fe6a8 --- /dev/null +++ b/src/Forum/ForumCategory.php @@ -0,0 +1,587 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\Colour; +use Misuzu\DB; +use Misuzu\Memoizer; +use Misuzu\Pagination; +use Misuzu\Users\User; + +class ForumCategoryException extends ForumException {} +class ForumCategoryNotFoundException extends ForumCategoryException {} + +class ForumCategory { + public const TYPE_DISCUSSION = 0; + public const TYPE_CATEGORY = 1; + public const TYPE_LINK = 2; + public const TYPE_FEATURE = 3; + + public const TYPES = [ + self::TYPE_DISCUSSION, self::TYPE_CATEGORY, self::TYPE_LINK, self::TYPE_FEATURE, + ]; + public const HAS_CHILDREN = [ + self::TYPE_DISCUSSION, self::TYPE_CATEGORY, self::TYPE_FEATURE, + ]; + public const HAS_TOPICS = [ + self::TYPE_DISCUSSION, self::TYPE_FEATURE, + ]; + public const HAS_PRIORITY_VOTES = [ + self::TYPE_FEATURE, + ]; + + public const ROOT_ID = 0; + + // Database fields + private $forum_id = -1; + private $forum_order = 0; + private $forum_parent = 0; + private $forum_name = ''; + private $forum_type = self::TYPE_DISCUSSION; + private $forum_description = null; + private $forum_icon = null; + private $forum_colour = null; + private $forum_link = null; + private $forum_link_clicks = null; + private $forum_created = null; + private $forum_archived = 0; + private $forum_hidden = 0; + private $forum_count_topics = 0; + private $forum_count_posts = 0; + + public const TABLE = 'forum_categories'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`forum_id`, %1$s.`forum_order`, %1$s.`forum_parent`, %1$s.`forum_name`, %1$s.`forum_type`, %1$s.`forum_description`' + . ', %1$s.`forum_icon`, %1$s.`forum_colour`, %1$s.`forum_link`, %1$s.`forum_link_clicks`' + . ', %1$s.`forum_archived`, %1$s.`forum_hidden`, %1$s.`forum_count_topics`, %1$s.`forum_count_posts`' + . ', UNIX_TIMESTAMP(%1$s.`forum_created`) AS `forum_created`'; + + private $categoryColour = null; + private $realColour = null; + private $parentCategory = null; + private $children = null; + + public function getId(): int { + return $this->forum_id < 0 ? -1 : $this->forum_id; + } + public function isRoot(): bool { + return $this->forum_id === self::ROOT_ID; + } + + public function getOrder(): int { + return $this->forum_order; + } + public function setOrder(int $order): self { + $this->forum_order = $order; + return $this; + } + public function moveBelow(self $other): self { + $this->setOrder($other->getOrder() + 1); + return $this; + } + public function moveAbove(self $other): self { + $this->setOrder($other->getOrder() - 1); + return $this; + } + + public function getParentId(): int { + return $this->forum_parent < 0 ? -1 : $this->forum_parent; + } + public function setParentId(int $otherId): self { + $this->forum_parent = $otherId; + $this->parentCategory = null; + return $this; + } + public function hasParent(): bool { + return $this->getParentId() > 0; + } + public function getParent(): self { + if($this->parentCategory === null) + $this->parentCategory = $this->hasParent() ? self::byId($this->getParentId()) : self::root(); + return $this->parentCategory; + } + public function setParent(?self $other): self { + $this->forum_parent = $other === null ? 0 : $other->getId(); + $this->parentCategory = $other; + return $this; + } + public function getParentTree(): array { + $current = $this; + $parents = []; + while(!$current->isRoot()) + $parents[] = $current = $current->getParent(); + return array_reverse($parents); + } + + public function getUrl(): string { + if($this->isRoot()) + return url('forum-index'); + return url('forum-category', ['forum' => $this->getId()]); + } + + public function getName(): string { + return $this->forum_name; + } + public function setName(string $name): self { + $this->forum_name = $name; + return $this; + } + + public function getType(): int { + return $this->forum_type; + } + public function setType(int $type): self { + $this->forum_type = $type; + return $this; + } + public function isDiscussionForum(): bool { return $this->getType() === self::TYPE_DISCUSSION; } + public function isCategoryForum(): bool { return $this->getType() === self::TYPE_CATEGORY; } + public function isFeatureForum(): bool { return $this->getType() === self::TYPE_FEATURE; } + public function isLink(): bool { return $this->getType() === self::TYPE_LINK; } + public function canHaveChildren(): bool { + return in_array($this->getType(), self::HAS_CHILDREN); + } + public function canHaveTopics(): bool { + return in_array($this->getType(), self::HAS_TOPICS); + } + public function canHavePriorityVotes(): bool { + return in_array($this->getType(), self::HAS_PRIORITY_VOTES); + } + + public function getDescription(): string { + return $this->forum_description ?? ''; + } + public function hasDescription(): bool { + return !empty($this->forum_description); + } + public function setDescription(string $description): self { + $this->forum_description = empty($description) ? null : $description; + return $this; + } + public function getParsedDescription(): string { + return nl2br($this->getDescription()); + } + + public function getIcon(): string { + $icon = $this->getRealIcon(); + if(!empty($icon)) + return $icon; + + if($this->isArchived()) + return 'fas fa-archive fa-fw'; + + switch($this->getType()) { + case self::TYPE_FEATURE: + return 'fas fa-star fa-fw'; + case self::TYPE_LINK: + return 'fas fa-link fa-fw'; + case self::TYPE_CATEGORY: + return 'fas fa-folder fa-fw'; + } + + return 'fas fa-comments fa-fw'; + } + public function getRealIcon(): string { + return $this->forum_icon ?? ''; + } + public function hasIcon(): bool { + return !empty($this->forum_icon); + } + public function setIcon(string $icon): self { + $this->forum_icon = empty($icon) ? null : $icon; + return $this; + } + + public function getColourRaw(): int { + return $this->forum_colour ?? 0x40000000; + } + public function setColourRaw(?int $raw): self { + $this->forum_colour = $raw; + $this->realColour = null; + $this->categoryColour = null; + return $this; + } + public function getColour(): Colour { // Swaps parent colour in if no category colour is present + if($this->realColour === null) { + $this->realColour = $this->getCategoryColour(); + if($this->realColour->getInherit() && $this->hasParent()) + $this->realColour = $this->getParent()->getColour(); + } + return $this->realColour; + } + public function getCategoryColour(): Colour { + if($this->categoryColour === null) + $this->categoryColour = new Colour($this->getColourRaw()); + return $this->categoryColour; + } + public function setColour(Colour $colour): self { + return $this->setColourRaw($colour === null ? null : $colour->getRaw()); + } + + public function getLink(): string { + return $this->forum_link ?? ''; + } + public function hasLink(): bool { + return !empty($this->forum_link); + } + public function setLink(string $link): self { + $this->forum_link = empty($link) ? null : $link; + } + + public function getLinkClicks(): int { + return $this->forum_link_clicks ?? -1; + } + public function shouldCountLinkClicks(): bool { + return $this->isLink() && $this->getLinkClicks() >= 0; + } + public function setCountLinkClicks(bool $state): self { + if($this->isLink() && $this->shouldCountLinkClicks() !== $state) { + $this->forum_link_clicks = $state ? 0 : null; + + // forum_link_clicks is not affected by the save method so we must save + DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `forum_link_clicks` = :clicks' + . ' WHERE `forum_id` = :category' + ) ->bind('category', $this->getId()) + ->bind('clicks', $this->forum_link_clicks) + ->execute(); + } + return $this; + } + public function increaseLinkClicks(): void { + if($this->shouldCountLinkClicks()) { + $this->forum_link_clicks = $this->getLinkClicks() + 1; + DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `forum_link_clicks` = `forum_link_clicks` + 1' + . ' WHERE `forum_id` = :category AND `forum_type` = ' . self::TYPE_LINK + )->bind('category', $this->getId())->execute(); + } + } + + public function getCreatedTime(): int { + return $this->forum_created === null ? -1 : $this->forum_created; + } + + public function isArchived(): bool { + return boolval($this->forum_archived); + } + public function setArchived(bool $archived): self { + $this->forum_archived = $archived ? 1 : 0; + return $this; + } + + public function isHidden(): bool { + return boolval($this->forum_hidden); + } + public function setHidden(bool $hidden): self { + $this->forum_hidden = $hidden ? 1 : 0; + return $this; + } + + public function getTopicCount(): int { + return $this->forum_count_topics ?? 0; + } + public function getPostCount(): int { + return $this->forum_count_posts ?? 0; + } + public function increaseTopicPostCount(bool $hasTopic): void { + if($this->isLink() || $this->isRoot()) + return; + if($this->hasParent()) + $this->getParent()->increaseTopicPostCount($hasTopic); + + if($hasTopic) + $this->forum_count_topics = $this->getTopicCount() + 1; + $this->forum_count_posts = $this->getPostCount() + 1; + + DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `forum_count_posts` = `forum_count_posts` + 1' + . ($hasTopic ? ', `forum_count_topics` = `forum_count_topics` + 1' : '') + . ' WHERE `forum_id` = :category' + )->bind('category', $this->getId())->execute(); + } + + // Param is fucking hackjob + // -1 = no check + // null = guest + // User = user + public function getChildren(/* ?User */ $viewer = -1): array { + if(!$this->canHaveChildren()) + return []; + if($this->children === null) + $this->children = self::all($this); + if($viewer === null || $viewer instanceof User) { + $children = []; + foreach($this->children as $child) + if($child->canView($viewer)) + $children[] = $child; + return $children; + } + return $this->children; + } + + public function getActualTopicCount(bool $includeDeleted = false): int { + if(!$this->canHaveTopics()) + return 0; + return ForumTopic::countByCategory($this, $includeDeleted); + } + public function getActualPostCount(bool $includeDeleted = false): int { + if(!$this->canHaveTopics()) + return 0; + return ForumPost::countByCategory($this, $includeDeleted); + } + + public function getTopics(bool $includeDeleted = false, ?Pagination $pagination = null): array { + if(!$this->canHaveTopics()) + return []; + return ForumTopic::byCategory($this, $includeDeleted, $pagination); + } + + public function checkLegacyPermission(?User $user, int $perm, bool $strict = false): bool { + return forum_perms_check_user( + MSZ_FORUM_PERMS_GENERAL, + $this->isRoot() ? null : $this->getId(), + $user === null ? 0 : $user->getId(), + $perm, $strict + ); + } + public function canView(?User $user): bool { + return $this->checkLegacyPermission($user, MSZ_FORUM_PERM_SET_READ); + } + + 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 { + 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 { + $lastTopic = ForumTopic::byCategoryLast($this); + $children = $this->getChildren($viewer); + + foreach($children as $child) { + $topic = $child->getLatestTopic($viewer); + if($topic !== null && ($lastTopic === null || $topic->getBumpedTime() > $lastTopic->getBumpedTime())) + $lastTopic = $topic; + } + + return $lastTopic; + } + + // This function is really fucking expensive and should only be called by cron + // Optimise this as much as possible at some point + public function synchronise(bool $save = true): array { + $topics = 0; $posts = 0; $topicStats = []; + + $children = $this->getChildren(); + foreach($children as $child) { + $stats = $child->synchronise($save); + $topics += $stats['topics']; + $posts += $stats['posts']; + if(empty($topicStats) || (!empty($stats['topic_stats']) && $stats['topic_stats']['last_post_time'] > $topicStats['last_post_time'])) + $topicStats = $stats['topic_stats']; + } + + $getCounts = DB::prepare( + 'SELECT :forum as `target_forum_id`, (' + . ' SELECT COUNT(`topic_id`)' + . ' FROM `msz_forum_topics`' + . ' WHERE `forum_id` = `target_forum_id`' + . ' AND `topic_deleted` IS NULL' + . ') AS `topics`, (' + . ' SELECT COUNT(`post_id`)' + . ' FROM `msz_forum_posts`' + . ' WHERE `forum_id` = `target_forum_id`' + . ' AND `post_deleted` IS NULL' + . ') AS `posts`' + ); + $getCounts->bind('forum', $this->getId()); + $counts = $getCounts->fetch(); + $topics += $counts['topics']; + $posts += $counts['posts']; + + foreach($this->getTopics() as $topic) { + $stats = $topic->synchronise($save); + if(empty($topicStats) || $stats['last_post_time'] > $topicStats['last_post_time']) + $topicStats = $stats; + } + + if($save && !$this->isRoot()) { + $setCounts = DB::prepare( + 'UPDATE `msz_forum_categories`' + . ' SET `forum_count_topics` = :topics, `forum_count_posts` = :posts' + . ' WHERE `forum_id` = :forum' + ); + $setCounts->bind('forum', $this->getId()); + $setCounts->bind('topics', $topics); + $setCounts->bind('posts', $posts); + $setCounts->execute(); + } + + return [ + 'topics' => $topics, + 'posts' => $posts, + 'topic_stats' => $topicStats, + ]; + } + + public static function root(): self { + static $root = null; + if($root === null) { + $root = new static; + $root->forum_id = self::ROOT_ID; + $root->forum_name = 'Forums'; + $root->forum_type = self::TYPE_CATEGORY; + $root->forum_created = 1359324884; + } + return $root; + } + + public function save(): void { + if($this->isRoot()) + return; + $isInsert = $this->getId() < 0; + + if($isInsert) { + $save = DB::prepare( + 'INSERT INTO `' . DB::PREFIX . self::TABLE . '` (' + . '`forum_order`, `forum_parent`, `forum_name`, `forum_type`, `forum_description`, `forum_icon`' + . ', `forum_colour`, `forum_link`, `forum_archived`, `forum_hidden`' + . ') VALUES (:order, :parent, :name, :type, :desc, :icon, :colour, :link, :archived, :hidden)' + ); + } else { + $save = DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `forum_order` = :order, `forum_parent` = :parent, `forum_name` = :name, `forum_type` = :type' + . ', `forum_description` = :desc, `forum_icon` = :icon, `forum_colour` = :colour, `forum_link` = :link' + . ', `forum_archived` = :archived, `forum_hidden` = :hidden' + . ' WHERE `forum_id` = :category' + )->bind('category', $this->getId()); + } + + $save->bind('order', $this->forum_order) + ->bind('parent', $this->forum_parent) + ->bind('name', $this->forum_name) + ->bind('type', $this->forum_type) + ->bind('desc', $this->forum_description) + ->bind('icon', $this->forum_icon) + ->bind('colour', $this->forum_colour) + ->bind('link', $this->forum_link) + ->bind('archived', $this->forum_archived) + ->bind('hidden', $this->forum_hidden); + + if($isInsert) { + $this->forum_id = $save->executeGetId(); + $this->forum_created = time(); + } else $save->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 byId(int $categoryId): self { + if($categoryId === self::ROOT_ID) + return self::root(); + + return self::memoizer()->find($categoryId, function() use ($categoryId) { + $object = DB::prepare(self::byQueryBase() . ' WHERE `forum_id` = :category') + ->bind('category', $categoryId) + ->fetchObject(self::class); + if(!$object) + throw new ForumCategoryNotFoundException; + return $object; + }); + } + public static function all(?self $parent = null): array { + $getObjects = DB::prepare( + self::byQueryBase() + . ($parent === null ? '' : ' WHERE `forum_parent` = :parent') + . ' ORDER BY `forum_order`' + ); + + if($parent !== null) + $getObjects->bind('parent', $parent->getId()); + + $objects = []; + $memoizer = self::memoizer(); + while($object = $getObjects->fetchObject(self::class)) + $memoizer->insert($objects[] = $object); + return $objects; + } +} diff --git a/src/Forum/ForumException.php b/src/Forum/ForumException.php new file mode 100644 index 00000000..009adc0e --- /dev/null +++ b/src/Forum/ForumException.php @@ -0,0 +1,6 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\MisuzuException; + +class ForumException extends MisuzuException {} diff --git a/src/Forum/ForumLeaderboard.php b/src/Forum/ForumLeaderboard.php new file mode 100644 index 00000000..93478c07 --- /dev/null +++ b/src/Forum/ForumLeaderboard.php @@ -0,0 +1,103 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\DB; + +final class ForumLeaderboard { + public const START_YEAR = 2018; + public const START_MONTH = 12; + public const CATEGORY_ALL = 0; + + public static function isValidYear(?int $year): bool { + return !is_null($year) && $year >= self::START_YEAR && $year <= date('Y'); + } + + public static function isValidMonth(?int $year, ?int $month): bool { + if(is_null($month) || !self::isValidYear($year) || $month < 1 || $month > 12) + return false; + + $combo = sprintf('%04d%02d', $year, $month); + $start = sprintf('%04d%02d', self::START_YEAR, self::START_MONTH); + $current = date('Ym'); + + return $combo >= $start && $combo <= $current; + } + + public static function categories(): array { + $categories = [ + self::CATEGORY_ALL => 'All Time', + ]; + + $currentYear = date('Y'); + $currentMonth = date('m'); + + for($i = $currentYear; $i >= self::START_YEAR; $i--) { + $categories[$i] = sprintf('Leaderboard %d', $i); + } + + for($i = $currentYear, $j = $currentMonth;;) { + $categories[sprintf('%d%02d', $i, $j)] = sprintf('Leaderboard %d-%02d', $i, $j); + + if($j <= 1) { + $i--; $j = 12; + } else $j--; + + if($i <= self::START_YEAR && $j < self::START_MONTH) + break; + } + + return $categories; + } + + public static function listing( + ?int $year = null, + ?int $month = null, + array $unrankedForums = [], + array $unrankedTopics = [] + ): array { + $hasYear = self::isValidYear($year); + $hasMonth = $hasYear && self::isValidMonth($year, $month); + $unrankedForums = implode(',', $unrankedForums); + $unrankedTopics = implode(',', $unrankedTopics); + + $rawLeaderboard = DB::query(sprintf( + ' + SELECT + u.`user_id`, u.`username`, + COUNT(fp.`post_id`) as `posts` + FROM `msz_users` AS u + INNER JOIN `msz_forum_posts` AS fp + ON fp.`user_id` = u.`user_id` + WHERE fp.`post_deleted` IS NULL + %s %s %s + GROUP BY u.`user_id` + HAVING `posts` > 0 + ORDER BY `posts` DESC + ', + $unrankedForums ? sprintf('AND fp.`forum_id` NOT IN (%s)', $unrankedForums) : '', + $unrankedTopics ? sprintf('AND fp.`topic_id` NOT IN (%s)', $unrankedTopics) : '', + !$hasYear ? '' : sprintf( + 'AND DATE(fp.`post_created`) BETWEEN \'%1$04d-%2$02d-01\' AND \'%1$04d-%3$02d-31\'', + $year, + $hasMonth ? $month : 1, + $hasMonth ? $month : 12 + ) + ))->fetchAll(); + + $leaderboard = []; + $ranking = 0; + $lastPosts = null; + + foreach($rawLeaderboard as $entry) { + if(is_null($lastPosts) || $lastPosts > $entry['posts']) { + $ranking++; + $lastPosts = $entry['posts']; + } + + $entry['rank'] = $ranking; + $leaderboard[] = $entry; + } + + return $leaderboard; + } +} diff --git a/src/Forum/ForumPoll.php b/src/Forum/ForumPoll.php new file mode 100644 index 00000000..9c999aee --- /dev/null +++ b/src/Forum/ForumPoll.php @@ -0,0 +1,166 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\DB; +use Misuzu\Memoizer; +use Misuzu\Users\User; + +class ForumPollException extends ForumException {} +class ForumPollNotFoundException extends ForumPollException {} + +class ForumPoll { + // Database fields + private $poll_id = -1; + private $topic_id = null; + private $poll_max_votes = 0; + private $poll_expires = null; + private $poll_preview_results = 0; + private $poll_change_vote = 0; + + public const TABLE = 'forum_polls'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`poll_id`, %1$s.`topic_id`, %1$s.`poll_max_votes`, %1$s.`poll_preview_results`, %1$s.`poll_change_vote`' + . ', UNIX_TIMESTAMP(%1$s.`poll_expires`) AS `poll_expires`'; + + private $topic = null; + private $options = null; + private $answers = []; + private $voteCount = -1; + + public function getId(): int { + return $this->poll_id < 1 ? -1 : $this->poll_id; + } + + public function getTopicId(): int { + return $this->topic_id < 1 ? -1 : $this->topic_id; + } + public function setTopicId(?int $topicId): self { + $this->topic_id = $topicId < 1 ? null : $topicId; + $this->topic = null; + return $this; + } + public function hasTopic(): bool { + return $this->getTopicId() > 0; + } + public function getTopic(): ForumTopic { + if($this->topic === null) + $this->topic = ForumTopic::byId($this->getTopicId()); + return $this->topic; + } + public function setTopic(?ForumTopic $topic): self { + $this->topic_id = $topic === null ? null : $topic->getId(); + $this->topic = $topic; + return $this; + } + + public function getMaxVotes(): int { + return max(0, $this->poll_max_votes); + } + public function setMaxVotes(int $maxVotes): self { + $this->poll_max_votes = max(0, $maxVotes); + return $this; + } + + public function getExpiresTime(): int { + return $this->poll_expires === null ? -1 : $this->poll_expires; + } + public function hasExpired(): bool { + return $this->getExpiresTime() >= time(); + } + public function canExpire(): bool { + return $this->getExpiresTime() >= 0; + } + public function setExpiresTime(int $expires): self { + $this->poll_expires = $expires < 0 ? null : $expires; + return $this; + } + + public function canPreviewResults(): bool { + return boolval($this->poll_preview_results); + } + public function setPreviewResults(bool $canPreview): self { + $this->poll_preview_results = $canPreview ? 1 : 0; + return $this; + } + + public function canChangeVote(): bool { + return boolval($this->poll_change_vote); + } + public function setChangeVote(bool $canChange): self { + $this->poll_change_vote = $canChange ? 1 : 0; + return $this; + } + + public function getVotes(): int { + if($this->voteCount < 0) + $this->voteCount = ForumPollAnswer::countByPoll($this); + return $this->voteCount; + } + + public function getOptions(): array { + if($this->options === null) + $this->options = ForumPollOption::byPoll($this); + return $this->options; + } + + public function getAnswers(?User $user): array { + if($user === null) + return []; + $userId = $user->getId(); + if(!isset($this->answers[$userId])) + $this->answers[$userId] = ForumPollAnswer::byExact($user, $this); + return $this->answers[$userId]; + } + public function hasVoted(?User $user): bool { + if($user === null) + return false; + $userId = $user->getId(); + if(!isset($this->answers[$userId])) + return !empty($this->getAnswers($user)); + return !empty($this->answers[$userId]); + } + + public function canVoteOnPoll(?User $user): bool { + if($user === null) + return false; + if(!$this->hasTopic()) // introduce generic poll vote permission? + return true; + return forum_perms_check_user( + MSZ_FORUM_PERMS_GENERAL, + $this->getTopic()->getCategory()->getId(), + $user->getId(), + MSZ_FORUM_PERM_SET_READ + ); + } + + 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 byId(int $pollId): self { + return self::memoizer()->find($pollId, function() use ($pollId) { + $object = DB::prepare(self::byQueryBase() . ' WHERE `poll_id` = :poll') + ->bind('poll', $pollId) + ->fetchObject(self::class); + if(!$object) + throw new ForumPollNotFoundException; + return $object; + }); + } + public static function byTopic(ForumTopic $topic): array { + $getObjects = DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic') + ->bind('topic', $topic->getId()); + + $objects = []; + $memoizer = self::memoizer(); + while($object = $getObjects->fetchObject(self::class)) + $memoizer->insert($objects[] = $object); + return $objects; + } +} diff --git a/src/Forum/ForumPollAnswer.php b/src/Forum/ForumPollAnswer.php new file mode 100644 index 00000000..fd4c25b8 --- /dev/null +++ b/src/Forum/ForumPollAnswer.php @@ -0,0 +1,75 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\DB; +use Misuzu\Users\User; + +class ForumPollAnswer { + // Database fields + private $user_id = -1; + private $poll_id = -1; + private $option_id = -1; + + public const TABLE = 'forum_polls_answers'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`user_id`, %1$s.`poll_id`, %1$s.`option_id`'; + + private $user = null; + private $poll = null; + private $option = null; + + public function getUserId(): int { + return $this->user_id < 1 ? -1 : $this->user_id; + } + public function getUser(): User { + if($this->user === null) + $this->user = User::byId($this->getUserId()); + return $this->user; + } + + public function getPollId(): int { + return $this->poll_id < 1 ? -1 : $this->poll_id; + } + public function getPoll(): ForumPoll { + if($this->poll === null) + $this->poll = ForumPoll::byId($this->getPollId()); + return $this->poll; + } + + public function getOptionId(): int { + return $this->option_id < 1 ? -1 : $this->option_id; + } + public function getOption(): ForumPollOption { + if($this->option === null) + $this->option = ForumPollOption::byId($this->getOptionId()); + return $this->option; + } + + public function hasAnswer(): bool { + return $this->getOptionId() > 0; + } + + private static function countQueryBase(): string { + return sprintf(self::QUERY_SELECT, sprintf('COUNT(*)', self::TABLE)); + } + public static function countByPoll(ForumPoll $poll): int { + return (int)DB::prepare( + self::countQueryBase() . ' WHERE `poll_id` = :poll' + )->bind('poll', $poll->getId())->fetchColumn(); + } + public static function countByOption(ForumPollOption $option): int { + return (int)DB::prepare( + self::countQueryBase() . ' WHERE `option_id` = :option' + )->bind('option', $option->getId())->fetchColumn(); + } + + private static function byQueryBase(): string { + return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE)); + } + public static function byExact(User $user, ForumPoll $poll): array { + return DB::prepare(self::byQueryBase() . ' WHERE `poll_id` = :poll AND `user_id` = :user') + ->bind('user', $user->getId()) + ->bind('poll', $poll->getId()) + ->fetchObjects(self::class); + } +} diff --git a/src/Forum/ForumPollOption.php b/src/Forum/ForumPollOption.php new file mode 100644 index 00000000..39d24642 --- /dev/null +++ b/src/Forum/ForumPollOption.php @@ -0,0 +1,63 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\DB; +use Misuzu\Users\User; + +class ForumPollOption { + // Database fields + private $option_id = -1; + private $poll_id = -1; + private $option_text = null; + + public const TABLE = 'forum_polls_options'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`option_id`, %1$s.`poll_id`, %1$s.`option_text`'; + + private $poll = null; + private $voteCount = -1; + + public function getId(): int { + return $this->option_id < 1 ? -1 : $this->option_id; + } + + public function getPollId(): int { + return $this->poll_id < 1 ? -1 : $this->poll_id; + } + public function getPoll(): ForumPoll { + if($this->poll === null) + $this->poll = ForumPoll::byId($this->getPollId()); + return $this->poll; + } + + public function getText(): string { + return $this->option_text ?? ''; + } + + public function getPercentage(): float { + return $this->getVotes() / $this->getPoll()->getVotes(); + } + + public function getVotes(): int { + if($this->voteCount < 0) + $this->voteCount = ForumPollAnswer::countByOption($this); + return $this->voteCount; + } + + public function hasVotedFor(?User $user): bool { + if($user === null) + return false; + return array_find($this->getPoll()->getAnswers($user), function($answer) { + return $answer->getOptionId() === $this->getId(); + }) !== null; + } + + private static function byQueryBase(): string { + return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE)); + } + public static function byPoll(ForumPoll $poll): array { + return DB::prepare(self::byQueryBase() . ' WHERE `poll_id` = :poll') + ->bind('poll', $poll->getId()) + ->fetchObjects(self::class); + } +} diff --git a/src/Forum/ForumPost.php b/src/Forum/ForumPost.php new file mode 100644 index 00000000..86b9b480 --- /dev/null +++ b/src/Forum/ForumPost.php @@ -0,0 +1,488 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\DB; +use Misuzu\Memoizer; +use Misuzu\Pagination; +use Misuzu\Parsers\Parser; +use Misuzu\Users\User; +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; + + 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; + private $forum_id = -1; + private $user_id = null; + private $post_ip = '::1'; + private $post_text = ''; + private $post_parse = Parser::BBCODE; + private $post_display_signature = 1; + private $post_created = null; + private $post_edited = null; + private $post_deleted = null; + + public const TABLE = 'forum_posts'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`post_id`, %1$s.`topic_id`, %1$s.`forum_id`, %1$s.`user_id`, %1$s.`post_text`, %1$s.`post_parse`, %1$s.`post_display_signature`' + . ', INET6_NTOA(%1$s.`post_ip`) AS `post_ip`' + . ', UNIX_TIMESTAMP(%1$s.`post_created`) AS `post_created`' + . ', UNIX_TIMESTAMP(%1$s.`post_edited`) AS `post_edited`' + . ', UNIX_TIMESTAMP(%1$s.`post_deleted`) AS `post_deleted`'; + + private $topic = null; + private $category = null; + private $user = null; + private $userLookedUp = false; + + public function getId(): int { + return $this->post_id < 1 ? -1 : $this->post_id; + } + + public function getTopicId(): int { + return $this->topic_id < 1 ? -1 : $this->topic_id; + } + public function setTopicId(int $topicId): self { + $this->topic_id = $topicId; + $this->topic = null; + return $this; + } + public function getTopic(): ForumTopic { + if($this->topic === null) + $this->topic = ForumTopic::byId($this->getTopicId()); + return $this->topic; + } + public function setTopic(ForumTopic $topic): self { + $this->topic_id = $topic->getId(); + $this->topic = $topic; + return $this; + } + + public function getCategoryId(): int { + return $this->forum_id < 1 ? -1 : $this->forum_id; + } + public function setCategoryId(int $categoryId): self { + $this->forum_id = $categoryId; + $this->category = null; + return $this; + } + public function getCategory(): ForumCategory { + if($this->category === null) + $this->category = ForumCategory::byId($this->getCategoryId()); + return $this->category; + } + public function setCategory(ForumCategory $category): self { + $this->forum_id = $category->getId(); + $this->category = $category; + return $this; + } + + public function getUserId(): int { + return $this->user_id < 1 ? -1 : $this->user_id; + } + public function setUserId(?int $userId): self { + $this->user_id = $userId < 1 ? null : $userId; + $this->user = null; + $this->userLookedUp = false; + return $this; + } + public function hasUser(): bool { + return $this->getUserId() > 0; + } + public function getUser(): ?User { + if(!$this->userLookedUp) { + $this->userLookedUp = true; + try { + $this->user = User::byId($this->getUserId()); + } catch(UserNotFoundException $ex) {} + } + return $this->user; + } + public function setUser(?User $user): self { + $this->user_id = $user === null ? null : $user->getId(); + $this->user = $user; + $this->userLookedUp = true; + return $this; + } + + 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; + } + public function getParsedBody(): string { + return Parser::instance($this->getBodyParser())->parseText(htmlspecialchars($this->getBody())); + } + public function getFirstBodyParagraph(): string { + return htmlspecialchars(first_paragraph($this->getBody())); + } + public function setBody(string $body): self { + $this->post_text = empty($body) ? null : $body; + return $this; + } + + public function getBodyParser(): int { + return $this->post_parse; + } + public function setBodyParser(int $parser): self { + $this->post_parse = $parser; + return $this; + } + + public function getBodyClasses(): string { + if($this->getBodyParser() === Parser::MARKDOWN) + return 'markdown'; + return ''; + } + + public function shouldDisplaySignature(): bool { + return boolval($this->post_display_signature); + } + public function setDisplaySignature(bool $display): self { + $this->post_display_signature = $display ? 1 : 0; + return $this; + } + + 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; + } + public function isEdited(): bool { + return $this->getEditedTime() >= 0; + } + public function bumpEdited(): self { + $this->post_edited = time(); + return $this; + } + public function stripEdited(): self { + $this->post_edited = null; + return $this; + } + + public function getDeletedTime(): int { + return $this->post_deleted === null ? -1 : $this->post_deleted; + } + public function isDeleted(): bool { + return $this->getDeletedTime() >= 0; + } + + public function isOpeningPost(): bool { + return $this->getTopic()->isOpeningPost($this); + } + + public function isTopicAuthor(): bool { + 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; + // check if user can view deleted posts + return true; + } + + // complete this implementation + public function canBeEdited(?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) + return 'short'; + if($length > self::BODY_MAX_LENGTH) + return 'long'; + return ''; + } + public static function bodyValidationErrorString(string $error): string { + switch($error) { + case 'short': + return sprintf('Post body was too short, it has to be at least %d characters!', self::BODY_MIN_LENGTH); + case 'long': + return sprintf("Post body was too long, it can't be longer than %d characters!", self::BODY_MAX_LENGTH); + case '': + return 'Post body is correctly formatted!'; + default: + return 'Post body is incorrectly formatted.'; + } + } + + 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()) + return; + DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `post_deleted` = NOW()' + . ' WHERE `topic_id` = :topic' + . ' AND `post_deleted` IS NULL' + )->bind('topic', $topic->getId())->execute(); + } + public static function restoreTopic(ForumTopic $topic): void { + // This looks like an error but it's not, run this before restoring the topic + if(!$topic->isDeleted()) + return; + DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `post_deleted` = NULL' + . ' WHERE `topic_id` = :topic' + . ' AND `post_deleted` = FROM_UNIXTIME(:deleted)' + )->bind('topic', $topic->getId())->bind('deleted', $topic->getDeletedTime())->execute(); + } + public static function nukeTopic(ForumTopic $topic): void { // Does this need to exist? Happens implicitly through foreign keys. + // Hard deleting should only be allowed if the topic is already soft deleted + if(!$topic->isDeleted()) + return; + DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `topic_id` = :topic') + ->bind('topic', $topic->getId()) + ->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)); + } + public static function countByCategory(ForumCategory $category, bool $includeDeleted = false): int { + return (int)DB::prepare( + self::countQueryBase() + . ' WHERE `forum_id` = :category' + . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL') + )->bind('category', $category->getId())->fetchColumn(); + } + public static function countByTopic(ForumTopic $topic, bool $includeDeleted = false): int { + return (int)DB::prepare( + self::countQueryBase() + . ' WHERE `topic_id` = :topic' + . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL') + )->bind('topic', $topic->getId())->fetchColumn(); + } + + 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 byId(int $postId): self { + return self::memoizer()->find($postId, function() use ($postId) { + $object = DB::prepare(self::byQueryBase() . ' WHERE `post_id` = :post') + ->bind('post', $postId) + ->fetchObject(self::class); + if(!$object) + throw new ForumPostNotFoundException; + return $object; + }); + } + public static function byTopic(ForumTopic $topic, bool $includeDeleted = false, ?Pagination $pagination = null): array { + $query = self::byQueryBase() + . ' WHERE `topic_id` = :topic' + . ($includeDeleted ? '' : ' AND `post_deleted` IS NULL') + . ' ORDER BY `post_id`'; + + if($pagination !== null) + $query .= ' LIMIT :range OFFSET :offset'; + + $getObjects = DB::prepare($query) + ->bind('topic', $topic->getId()); + + 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; + } + 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 new file mode 100644 index 00000000..b38dd2b9 --- /dev/null +++ b/src/Forum/ForumTopic.php @@ -0,0 +1,588 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\DB; +use Misuzu\Memoizer; +use Misuzu\Pagination; +use Misuzu\Users\User; + +class ForumTopicException extends ForumException {} +class ForumTopicNotFoundException extends ForumTopicException {} +class ForumTopicCreationFailedException extends ForumTopicException {} +class ForumTopicUpdateFailedException extends ForumTopicException {} + +class ForumTopic { + public const TYPE_DISCUSSION = 0; + public const TYPE_STICKY = 1; + public const TYPE_ANNOUNCEMENT = 2; + public const TYPE_GLOBAL_ANNOUNCEMENT = 3; + + public const TYPES = [ + self::TYPE_DISCUSSION, + self::TYPE_STICKY, + self::TYPE_ANNOUNCEMENT, + self::TYPE_GLOBAL_ANNOUNCEMENT, + ]; + + public const TYPE_ORDER = [ + self::TYPE_GLOBAL_ANNOUNCEMENT, + self::TYPE_ANNOUNCEMENT, + self::TYPE_STICKY, + self::TYPE_DISCUSSION, + ]; + + public const TYPE_IMPORTANT = [ + self::TYPE_STICKY, + self::TYPE_ANNOUNCEMENT, + self::TYPE_GLOBAL_ANNOUNCEMENT, + ]; + + public const TITLE_MIN_LENGTH = 3; + public const TITLE_MAX_LENGTH = 100; + + 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; + private $user_id = null; + private $topic_type = self::TYPE_DISCUSSION; + private $topic_title = ''; + private $topic_priority = 0; + private $topic_count_posts = 0; + private $topic_count_views = 0; + private $topic_post_first = null; + private $topic_post_last = null; + private $topic_created = null; + private $topic_bumped = null; + private $topic_deleted = null; + private $topic_locked = null; + + public const TABLE = 'forum_topics'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`topic_id`, %1$s.`forum_id`, %1$s.`user_id`, %1$s.`topic_type`, %1$s.`topic_title`' + . ', %1$s.`topic_count_posts`, %1$s.`topic_count_views`, %1$s.`topic_post_first`, %1$s.`topic_post_last`' + . ', UNIX_TIMESTAMP(%1$s.`topic_created`) AS `topic_created`' + . ', UNIX_TIMESTAMP(%1$s.`topic_bumped`) AS `topic_bumped`' + . ', UNIX_TIMESTAMP(%1$s.`topic_deleted`) AS `topic_deleted`' + . ', UNIX_TIMESTAMP(%1$s.`topic_locked`) AS `topic_locked`'; + + private $category = null; + private $user = null; + private $firstPost = -1; + private $lastPost = -1; + private $priorityVotes = null; + private $polls = []; + + public function getId(): int { + return $this->topic_id < 1 ? -1 : $this->topic_id; + } + + public function getCategoryId(): int { + return $this->forum_id < 1 ? -1 : $this->forum_id; + } + public function setCategoryId(int $categoryId): self { + $this->forum_id = $categoryId; + $this->category = null; + return $this; + } + public function getCategory(): ForumCategory { + if($this->category === null) + $this->category = ForumCategory::byId($this->getCategoryId()); + return $this->category; + } + public function setCategory(ForumCategory $category): self { + $this->forum_id = $category->getId(); + $this->category = $category; + return $this; + } + + public function getUserId(): int { + return $this->user_id < 1 ? -1 : $this->user_id; + } + public function setUserId(?int $userId): self { + $this->user_id = $userId < 1 ? null : $userId; + $this->user = null; + return $this; + } + public function getUser(): ?User { + if($this->user === null && ($userId = $this->getUserId()) > 0) + $this->user = User::byId($userId); + return $this->user; + } + public function hasUser(): bool { + return $this->getUserId() > 0; + } + public function setUser(?User $user): self { + $this->user_id = $user === null ? null : $user->getId(); + $this->user = $user; + return $this; + } + + public function getType(): int { + return $this->topic_type; + } + public function setType(int $type): self { + $this->topic_type = $type; + return $this; + } + public function isNormal(): bool { return $this->getType() === self::TYPE_DISCUSSION; } + public function isSticky(): bool { return $this->getType() === self::TYPE_STICKY; } + public function isAnnouncement(): bool { return $this->getType() === self::TYPE_ANNOUNCEMENT; } + public function isGlobalAnnouncement(): bool { return $this->getType() === self::TYPE_GLOBAL_ANNOUNCEMENT; } + + public function isImportant(): bool { + return in_array($this->getType(), self::TYPE_IMPORTANT); + } + + public function hasPriorityVoting(): bool { + return $this->getCategory()->canHavePriorityVotes(); + } + + public function getIcon(?User $viewer = null): string { + if($this->isDeleted()) + return 'fas fa-trash-alt fa-fw'; + + if($this->isGlobalAnnouncement() || $this->isAnnouncement()) + return 'fas fa-bullhorn fa-fw'; + if($this->isSticky()) + return 'fas fa-thumbtack fa-fw'; + + if($this->isLocked()) + return 'fas fa-lock fa-fw'; + + if($this->hasPriorityVoting()) + return 'far fa-star fa-fw'; + + return ($viewer === null || $this->hasRead($viewer) ? 'far' : 'fas') . ' fa-comment fa-fw'; + } + + public function getTitle(): string { + return $this->topic_title ?? ''; + } + public function setTitle(string $title): self { + $this->topic_title = $title; + return $this; + } + + public function getPriority(): int { + return $this->topic_priority < 1 ? 0 : $this->topic_priority; + } + + public function getPostCount(): int { + return $this->topic_count_posts; + } + public function getPageCount(int $postsPerPage = 10): int { + return ceil($this->getPostCount() / $postsPerPage); + } + + 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; + } + public function hasFirstPost(): bool { + return $this->getFirstPostId() > 0; + } + public function getFirstPost(): ?ForumPost { + if($this->firstPost === -1) { + if(!$this->hasFirstPost()) + return null; + try { + $this->firstPost = ForumPost::byId($this->getFirstPostId()); + } catch(ForumPostNotFoundException $ex) { + $this->firstPost = null; + } + } + return $this->firstPost; + } + + public function getLastPostId(): int { + return $this->topic_post_last < 1 ? -1 : $this->topic_post_last; + } + public function hasLastPost(): bool { + return $this->getLastPostId() > 0; + } + public function getLastPost(): ?ForumPost { + if($this->lastPost === -1) { + if(!$this->hasLastPost()) + return null; + try { + $this->lastPost = ForumPost::byId($this->getLastPostId()); + } catch(ForumPostNotFoundException $ex) { + $this->lastPost = null; + } + } + return $this->lastPost; + } + + public function getCreatedTime(): int { + return $this->topic_created === null ? -1 : $this->topic_created; + } + + public function getBumpedTime(): int { + return $this->topic_bumped === null ? -1 : $this->topic_bumped; + } + public function bumpTopic(): void { + if($this->isDeleted()) + return; + $this->topic_bumped = time(); + DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `topic_bumped` = NOW()' + . ' WHERE `topic_id` = :topic' + . ' AND `topic_deleted` IS NULL' + )->bind('topic', $this->getId())->execute(); + } + + public function getDeletedTime(): int { + return $this->topic_deleted === null ? -1 : $this->topic_deleted; + } + public function isDeleted(): bool { + return $this->getDeletedTime() >= 0; + } + + public function getLockedTime(): int { + return $this->topic_locked === null ? -1 : $this->topic_locked; + } + public function isLocked(): bool { + return $this->getLockedTime() >= 0; + } + public function setLocked(bool $locked): self { + if($this->isLocked() !== $locked) + $this->topic_locked = $locked ? time() : null; + return $this; + } + + public function isArchived(): bool { + return $this->getCategory()->isArchived(); + } + + public function getActualPostCount(bool $includeDeleted = false): int { + return ForumPost::countByTopic($this, $includeDeleted); + } + public function getPosts(bool $includeDeleted = false, ?Pagination $pagination = null): array { + return ForumPost::byTopic($this, $includeDeleted, $pagination); + } + + public function getPolls(): array { + if($this->polls === null) + $this->polls = ForumPoll::byTopic($this); + return $this->polls; + } + + 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; + } + } + public function markRead(User $user): void { + if(!$this->hasRead($user)) + $this->incrementViewCount(); + ForumTopicTrack::bump($this, $user); + } + + public function hasParticipated(?User $user): bool { + return $user !== null; + } + + public function isOpeningPost(ForumPost $post): bool { + $firstPost = $this->getFirstPost(); + return $firstPost !== null && $firstPost->getId() === $post->getId(); + } + public function isTopicAuthor(?User $user): bool { + if($user === null) + return false; + return $user->getId() === $this->getUser()->getId(); + } + + public function getPriorityVotes(): array { + if($this->priorityVotes === null) + $this->priorityVotes = ForumTopicPriority::byTopic($this); + return $this->priorityVotes; + } + + public function canVoteOnPriority(?User $user): bool { + if($user === null || !$this->hasPriorityVoting()) + return false; + // shouldn't there be an actual permission for this? + return $this->getCategory()->canView($user); + } + + public function canBeDeleted(User $user): string { + if(false) // check if viewable + return 'view'; + + // 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'; + if($this->getActualPostCount(true) >= self::DELETE_POST_LIMIT) + return 'posts'; + } + + return ''; + } + public static function canBeDeletedErrorString(string $error): string { + switch($error) { + case 'view': + return 'This topic doesn\'t exist.'; + case 'deleted': + return 'This topic has already been marked as deleted.'; + case 'permission': + return 'You aren\'t allowed to this topic.'; + case 'owner': + return 'You can only delete your own topics.'; + case 'age': + return 'This topic is too old to be deleted. Ask a moderator to remove it if you deem it absolutely necessary.'; + case 'posts': + return 'This topic has too many replies to be deleted. Ask a moderator to remove it if you deem it absolutely necessary.'; + case '': + return 'Topic can be deleted!'; + default: + return 'Topic cannot be deleted.'; + } + } + + public function delete(): void { + if($this->isDeleted()) + return; + $this->topic_deleted = time(); + DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `topic_deleted` = NOW() WHERE `topic_id` = :topic') + ->bind('topic', $this->getId()) + ->execute(); + ForumPost::deleteTopic($this); + } + public function restore(): void { + if(!$this->isDeleted()) + return; + ForumPost::restoreTopic($this); + DB::prepare('UPDATE `' . DB::PREFIX . self::TABLE . '` SET `topic_deleted` = NULL WHERE `topic_id` = :topic') + ->bind('topic', $this->getId()) + ->execute(); + $this->topic_deleted = null; + } + public function nuke(): void { + if(!$this->isDeleted()) + return; + DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `topic_id` = :topic') + ->bind('topic', $this->getId()) + ->execute(); + //ForumPost::nukeTopic($this); + } + + public static function create(ForumCategory $category, User $user, string $title, int $type = self::TYPE_DISCUSSION): ForumTopic { + $create = DB::prepare( + 'INSERT INTO `msz_forum_topics` (`forum_id`, `user_id`, `topic_title`, `topic_type`) VALUES (:forum, :user, :title, :type)' + )->bind('forum', $category->getId())->bind('user', $user->getId()) + ->bind('title', $title)->bind('type', $type) + ->execute(); + if(!$create) + throw new ForumTopicCreationFailedException; + $topicId = DB::lastId(); + if($topicId < 1) + throw new ForumTopicCreationFailedException; + + try { + return self::byId($topicId); + } catch(ForumTopicNotFoundException $ex) { + throw new ForumTopicCreationFailedException; + } + } + + public function update(): void { + if($this->getId() < 1) + throw new ForumTopicUpdateFailedException; + + if(!DB::prepare( + 'UPDATE `' . DB::PREFIX . self::TABLE . '`' + . ' SET `topic_title` = :title,' + . ' `topic_type` = :type' + . ' WHERE `topic_id` = :topic' + )->bind('topic', $this->getId()) + ->bind('title', $this->getTitle()) + ->bind('type', $this->getType()) + ->execute()) + throw new ForumTopicUpdateFailedException; + } + + public function synchronise(bool $save = true): array { + $stats = DB::prepare( + 'SELECT :topic AS `topic`, (' + . 'SELECT MIN(`post_id`) FROM `msz_forum_posts` WHERE `topic_id` = `topic`' // this shouldn't be deleteable without nuking the topic + . ') AS `first_post`, (' + . 'SELECT MAX(`post_id`) FROM `msz_forum_posts` WHERE `topic_id` = `topic` AND `post_deleted` IS NULL' + . ') AS `last_post`, (' + . 'SELECT COUNT(*) FROM `msz_forum_posts` WHERE `topic_id` = `topic` AND `post_deleted` IS NULL' + . ') AS `posts`, (' + . 'SELECT UNIX_TIMESTAMP(`post_created`) FROM `msz_forum_posts` WHERE `post_id` = `last_post`' + . ') AS `last_post_time`' + )->bind('topic', $this->getId())->fetch(); + + if($save) { + $this->topic_post_first = $stats['first_post']; + $this->topic_post_last = $stats['last_post']; + $this->topic_count_posts = $stats['posts']; + DB::prepare( + 'UPDATE `msz_forum_topics`' + . ' SET `topic_post_first` = :first' + . ', `topic_post_last` = :last' + . ', `topic_count_posts` = :posts' + . ' WHERE `topic_id` = :topic' + ) ->bind('first', $this->topic_post_first) + ->bind('last', $this->topic_post_last) + ->bind('posts', $this->topic_count_posts) + ->bind('topic', $this->getId()) + ->execute(); + } + + return $stats; + } + + public static function validateTitle(string $title): string { + $length = mb_strlen(trim($title)); + if($length < self::TITLE_MIN_LENGTH) + return 'short'; + if($length > self::TITLE_MAX_LENGTH) + return 'long'; + return ''; + } + public static function titleValidationErrorString(string $error): string { + switch($error) { + case 'short': + return sprintf('Topic title was too short, it has to be at least %d characters!', self::TITLE_MIN_LENGTH); + case 'long': + return sprintf("Topic title was too long, it can't be longer than %d characters!", self::TITLE_MAX_LENGTH); + case '': + return 'Topic title is correctly formatted!'; + default: + return 'Topic title is incorrectly formatted.'; + } + } + + private static function countQueryBase(): string { + return sprintf(self::QUERY_SELECT, sprintf('COUNT(*)', self::TABLE)); + } + public static function countByCategory(ForumCategory $category, bool $includeDeleted = false): int { + return (int)DB::prepare( + self::countQueryBase() + . ' WHERE `forum_id` = :category' + . ($includeDeleted ? '' : ' AND `topic_deleted` IS NULL') + )->bind('category', $category->getId())->fetchColumn(); + } + + 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 byId(int $topicId): self { + return self::memoizer()->find($topicId, function() use ($topicId) { + $object = DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic') + ->bind('topic', $topicId) + ->fetchObject(self::class); + if(!$object) + throw new ForumTopicNotFoundException; + return $object; + }); + } + public static function byCategoryLast(ForumCategory $category): ?self { + return self::memoizer()->find(function($topic) use ($category) { + // This doesn't actually do what is advertised, but should be fine for the time being. + return $topic->getCategory()->getId() === $category->getId() && !$topic->isDeleted(); + }, function() use ($category) { + return DB::prepare( + self::byQueryBase() + . ' WHERE `forum_id` = :category AND `topic_deleted` IS NULL' + . ' ORDER BY `topic_bumped` DESC' + . ' LIMIT 1' + )->bind('category', $category->getId())->fetchObject(self::class); + }); + } + public static function byCategory(ForumCategory $category, bool $includeDeleted = false, ?Pagination $pagination = null): array { + if(!$category->canHaveTopics()) + return []; + + $query = self::byQueryBase() + . ' WHERE `forum_id` = :category' + . ($includeDeleted ? '' : ' AND `topic_deleted` IS NULL') + . ' ORDER BY FIELD(`topic_type`, ' . implode(',', self::TYPE_ORDER) . ')'; + + //if($category->canHavePriorityVotes()) + // $query .= ', `topic_priority` DESC'; + + $query .= ', `topic_bumped` DESC'; + + if($pagination !== null) + $query .= ' LIMIT :range OFFSET :offset'; + + $getObjects = DB::prepare($query) + ->bind('category', $category->getId()); + + 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; + } + public static function bySearchQuery(string $search, bool $includeDeleted = false, ?Pagination $pagination = null): array { + $query = self::byQueryBase() + . ' WHERE MATCH(`topic_title`) AGAINST (:search IN NATURAL LANGUAGE MODE)' + . ($includeDeleted ? '' : ' AND `topic_deleted` IS NULL') + . ' ORDER BY FIELD(`topic_type`, ' . implode(',', self::TYPE_ORDER) . '), `topic_bumped` DESC'; + + 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/ForumTopicPriority.php b/src/Forum/ForumTopicPriority.php new file mode 100644 index 00000000..6d41a784 --- /dev/null +++ b/src/Forum/ForumTopicPriority.php @@ -0,0 +1,50 @@ +<?php +namespace Misuzu\Forum; + +use Misuzu\DB; +use Misuzu\Users\User; + +class ForumTopicPriority { + // Database fields + private $topic_id = -1; + private $user_id = -1; + private $topic_priority = 0; + + public const TABLE = 'forum_topics_priority'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`topic_id`, %1$s.`user_id`, %1$s.`topic_priority`'; + + private $topic = null; + private $user = null; + + public function getTopicId(): int { + return $this->topic_id < 1 ? -1 : $this->topic_id; + } + public function getTopic(): ForumTopic { + if($this->topic === null) + $this->topic = ForumTopic::byId($this->getTopicId()); + return $this->topic; + } + + public function getUserId(): int { + return $this->user_id < 1 ? -1 : $this->user_id; + } + public function getUser(): User { + if($this->user === null) + $this->user = User::byId($this->getUserId()); + return $this->user; + } + + public function getPriority(): int { + return $this->topic_priority < 1 ? -1 : $this->topic_priority; + } + + private static function byQueryBase(): string { + return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE)); + } + public static function byTopic(ForumTopic $topic): array { + return DB::prepare(self::byQueryBase() . ' WHERE `topic_id` = :topic') + ->bind('topic', $topic->getId()) + ->fetchObjects(self::class); + } +} diff --git a/src/Forum/ForumTopicTrack.php b/src/Forum/ForumTopicTrack.php new file mode 100644 index 00000000..045f3264 --- /dev/null +++ b/src/Forum/ForumTopicTrack.php @@ -0,0 +1,105 @@ +<?php +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; + private $topic_id = -1; + private $forum_id = -1; + private $track_last_read = null; + + public const TABLE = 'forum_topics_track'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`user_id`, %1$s.`topic_id`, %1$s.`forum_id`' + . ', UNIX_TIMESTAMP(%1$s.`track_last_read`) AS `track_last_read`'; + + private $user = null; + private $topic = null; + private $category = null; + + public function getUserId(): int { + return $this->user_id < 1 ? -1 : $this->user_id; + } + public function getUser(): User { + if($this->user === null) + $this->user = User::byId($this->getUserId()); + return $this->user; + } + + public function getTopicId(): int { + return $this->topic_id < 1 ? -1 : $this->topic_id; + } + public function getTopic(): ForumTopic { + if($this->topic === null) + $this->topic = ForumTopic::byId($this->getTopicId()); + return $this->topic; + } + + public function getCategoryId(): int { + return $this->forum_id < 1 ? -1 : $this->forum_id; + } + public function getCategory(): ForumCategory { + if($this->category === null) + $this->category = ForumCategory::byId($this->getCategoryId()); + return $this->category; + } + + 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/ForumTrack.php b/src/Forum/ForumTrack.php new file mode 100644 index 00000000..e69de29b diff --git a/src/Forum/forum.php b/src/Forum/forum.php deleted file mode 100644 index 04e5296d..00000000 --- a/src/Forum/forum.php +++ /dev/null @@ -1,537 +0,0 @@ -<?php -define('MSZ_FORUM_TYPE_DISCUSSION', 0); -define('MSZ_FORUM_TYPE_CATEGORY', 1); -define('MSZ_FORUM_TYPE_LINK', 2); -define('MSZ_FORUM_TYPE_FEATURE', 3); -define('MSZ_FORUM_TYPES', [ - MSZ_FORUM_TYPE_DISCUSSION, - MSZ_FORUM_TYPE_CATEGORY, - MSZ_FORUM_TYPE_LINK, - MSZ_FORUM_TYPE_FEATURE, -]); - -define('MSZ_FORUM_MAY_HAVE_CHILDREN', [ - MSZ_FORUM_TYPE_DISCUSSION, - MSZ_FORUM_TYPE_CATEGORY, - MSZ_FORUM_TYPE_FEATURE, -]); - -define('MSZ_FORUM_MAY_HAVE_TOPICS', [ - MSZ_FORUM_TYPE_DISCUSSION, - MSZ_FORUM_TYPE_FEATURE, -]); - -define('MSZ_FORUM_HAS_PRIORITY_VOTING', [ - MSZ_FORUM_TYPE_FEATURE, -]); - -define('MSZ_FORUM_ROOT', 0); -define('MSZ_FORUM_ROOT_DATA', [ // should be compatible with the data fetched in forum_get_root_categories - 'forum_id' => MSZ_FORUM_ROOT, - 'forum_name' => 'Forums', - 'forum_children' => 0, - 'forum_type' => MSZ_FORUM_TYPE_CATEGORY, - 'forum_colour' => null, - 'forum_permissions' => MSZ_FORUM_PERM_SET_READ, -]); - -function forum_is_valid_type(int $type): bool { - return in_array($type, MSZ_FORUM_TYPES, true); -} - -function forum_may_have_children(int $forumType): bool { - return in_array($forumType, MSZ_FORUM_MAY_HAVE_CHILDREN); -} - -function forum_may_have_topics(int $forumType): bool { - return in_array($forumType, MSZ_FORUM_MAY_HAVE_TOPICS); -} - -function forum_has_priority_voting(int $forumType): bool { - return in_array($forumType, MSZ_FORUM_HAS_PRIORITY_VOTING); -} - -function forum_get(int $forumId, bool $showDeleted = false): array { - $getForum = \Misuzu\DB::prepare(sprintf( - ' - SELECT - `forum_id`, `forum_name`, `forum_type`, `forum_link`, `forum_archived`, - `forum_link_clicks`, `forum_parent`, `forum_colour`, `forum_icon`, - ( - SELECT COUNT(`topic_id`) - FROM `msz_forum_topics` - WHERE `forum_id` = f.`forum_id` - %1$s - ) as `forum_topic_count` - FROM `msz_forum_categories` as f - WHERE `forum_id` = :forum_id - ', - $showDeleted ? '' : 'AND `topic_deleted` IS NULL' - )); - $getForum->bind('forum_id', $forumId); - return $getForum->fetch(); -} - -function forum_get_root_categories(int $userId): array { - $getCategories = \Misuzu\DB::prepare(sprintf( - ' - SELECT - f.`forum_id`, f.`forum_name`, f.`forum_type`, f.`forum_colour`, f.`forum_icon`, - ( - SELECT COUNT(`forum_id`) - FROM `msz_forum_categories` AS sf - WHERE sf.`forum_parent` = f.`forum_id` - ) AS `forum_children` - FROM `msz_forum_categories` AS f - WHERE f.`forum_parent` = 0 - AND f.`forum_type` = %1$d - AND f.`forum_hidden` = 0 - GROUP BY f.`forum_id` - ORDER BY f.`forum_order` - ', - MSZ_FORUM_TYPE_CATEGORY - )); - $categories = array_merge([MSZ_FORUM_ROOT_DATA], $getCategories->fetchAll()); - - $getRootForumCount = \Misuzu\DB::prepare(sprintf( - " - SELECT COUNT(`forum_id`) - FROM `msz_forum_categories` - WHERE `forum_parent` = %d - AND `forum_type` != %d - ", - MSZ_FORUM_ROOT, - MSZ_FORUM_TYPE_CATEGORY - )); - $categories[0]['forum_children'] = (int)$getRootForumCount->fetchColumn(); - - foreach($categories as $key => $category) { - $categories[$key]['forum_permissions'] = $perms = forum_perms_get_user($category['forum_id'], $userId)[MSZ_FORUM_PERMS_GENERAL]; - - if(!perms_check($perms, MSZ_FORUM_PERM_SET_READ)) { - unset($categories[$key]); - continue; - } - - $categories[$key] = array_merge( - $category, - ['forum_unread' => forum_topics_unread($category['forum_id'], $userId)], - forum_latest_post($category['forum_id'], $userId) - ); - } - - return $categories; -} - -function forum_get_breadcrumbs( - int $forumId, - string $linkFormat = '/forum/forum.php?f=%d', - string $rootFormat = '/forum/#f%d', - array $indexLink = ['Forums' => '/forum/'] -): array { - $breadcrumbs = []; - $getBreadcrumb = \Misuzu\DB::prepare(' - SELECT `forum_id`, `forum_name`, `forum_type`, `forum_parent` - FROM `msz_forum_categories` - WHERE `forum_id` = :forum_id - '); - - while($forumId > 0) { - $getBreadcrumb->bind('forum_id', $forumId); - $breadcrumb = $getBreadcrumb->fetch(); - - if(empty($breadcrumb)) { - break; - } - - $breadcrumbs[$breadcrumb['forum_name']] = sprintf( - $breadcrumb['forum_parent'] === MSZ_FORUM_ROOT - && $breadcrumb['forum_type'] === MSZ_FORUM_TYPE_CATEGORY - ? $rootFormat - : $linkFormat, - $breadcrumb['forum_id'] - ); - $forumId = $breadcrumb['forum_parent']; - } - - return array_reverse($breadcrumbs + $indexLink); -} - -function forum_get_colour(int $forumId): int { - $getColours = \Misuzu\DB::prepare(' - SELECT `forum_id`, `forum_parent`, `forum_colour` - FROM `msz_forum_categories` - WHERE `forum_id` = :forum_id - '); - - while($forumId > 0) { - $getColours->bind('forum_id', $forumId); - $colourInfo = $getColours->fetch(); - - if(empty($colourInfo)) { - break; - } - - if(!empty($colourInfo['forum_colour'])) { - return $colourInfo['forum_colour']; - } - - $forumId = $colourInfo['forum_parent']; - } - - return 0x40000000; -} - -function forum_increment_clicks(int $forumId): void { - $incrementLinkClicks = \Misuzu\DB::prepare(sprintf(' - UPDATE `msz_forum_categories` - SET `forum_link_clicks` = `forum_link_clicks` + 1 - WHERE `forum_id` = :forum_id - AND `forum_type` = %d - AND `forum_link_clicks` IS NOT NULL - ', MSZ_FORUM_TYPE_LINK)); - $incrementLinkClicks->bind('forum_id', $forumId); - $incrementLinkClicks->execute(); -} - -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_latest_post(int $forumId, int $userId): array { - if($forumId < 1) { - return []; - } - - static $memoized = []; - $memoId = "{$forumId}-{$userId}"; - - if(array_key_exists($memoId, $memoized)) { - return $memoized[$memoId]; - } - - if(!forum_perms_check_user(MSZ_FORUM_PERMS_GENERAL, $forumId, $userId, MSZ_FORUM_PERM_SET_READ)) { - return $memoized[$memoId] = []; - } - - $getLastPost = \Misuzu\DB::prepare(' - SELECT - p.`post_id` AS `recent_post_id`, t.`topic_id` AS `recent_topic_id`, - t.`topic_title` AS `recent_topic_title`, t.`topic_bumped` AS `recent_topic_bumped`, - p.`post_created` AS `recent_post_created`, - u.`user_id` AS `recent_post_user_id`, - u.`username` AS `recent_post_username`, - COALESCE(u.`user_colour`, r.`role_colour`) AS `recent_post_user_colour`, - UNIX_TIMESTAMP(p.`post_created`) AS `post_created_unix` - FROM `msz_forum_posts` AS p - LEFT JOIN `msz_forum_topics` AS t - ON t.`topic_id` = p.`topic_id` - 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 p.`forum_id` = :forum_id - AND p.`post_deleted` IS NULL - ORDER BY p.`post_id` DESC - '); - $getLastPost->bind('forum_id', $forumId); - $currentLast = $getLastPost->fetch(); - - $children = forum_get_child_ids($forumId); - - foreach($children as $child) { - $lastPost = forum_latest_post($child, $userId); - - if(($currentLast['post_created_unix'] ?? 0) < ($lastPost['post_created_unix'] ?? 0)) { - $currentLast = $lastPost; - } - } - - return $memoized[$memoId] = $currentLast; -} - -function forum_get_children(int $parentId, int $userId): array { - $getListing = \Misuzu\DB::prepare(sprintf( - ' - SELECT - :user_id AS `target_user_id`, - f.`forum_id`, f.`forum_name`, f.`forum_description`, f.`forum_type`, f.`forum_icon`, - f.`forum_link`, f.`forum_link_clicks`, f.`forum_archived`, f.`forum_colour`, - f.`forum_count_topics`, f.`forum_count_posts` - FROM `msz_forum_categories` AS f - WHERE f.`forum_parent` = :parent_id - AND f.`forum_hidden` = 0 - AND ( - (f.`forum_parent` = %1$d AND f.`forum_type` != %2$d) - OR f.`forum_parent` != %1$d - ) - GROUP BY f.`forum_id` - ORDER BY f.`forum_order` - ', - MSZ_FORUM_ROOT, - MSZ_FORUM_TYPE_CATEGORY - )); - - $getListing->bind('user_id', $userId); - $getListing->bind('parent_id', $parentId); - - $listing = $getListing->fetchAll(); - - foreach($listing as $key => $forum) { - $listing[$key]['forum_permissions'] = $perms = forum_perms_get_user($forum['forum_id'], $userId)[MSZ_FORUM_PERMS_GENERAL]; - - if(!perms_check($perms, MSZ_FORUM_PERM_SET_READ)) { - unset($listing[$key]); - continue; - } - - $listing[$key] = array_merge( - $forum, - ['forum_unread' => forum_topics_unread($forum['forum_id'], $userId)], - forum_latest_post($forum['forum_id'], $userId) - ); - } - - return $listing; -} - -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_posting_info(int $userId): array { - $getPostingInfo = \Misuzu\DB::prepare(' - SELECT - u.`user_country`, u.`user_created`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `user_id` = u.`user_id` - AND `post_deleted` IS NULL - ) AS `user_forum_posts`, - ( - SELECT `post_parse` - FROM `msz_forum_posts` - WHERE `user_id` = u.`user_id` - AND `post_deleted` IS NULL - ORDER BY `post_id` DESC - LIMIT 1 - ) AS `user_post_parse` - FROM `msz_users` as u - WHERE `user_id` = :user_id - '); - $getPostingInfo->bind('user_id', $userId); - return $getPostingInfo->fetch(); -} - -function forum_count_increase(int $forumId, bool $topic = false): void { - $increaseCount = \Misuzu\DB::prepare(sprintf( - ' - UPDATE `msz_forum_categories` - SET `forum_count_posts` = `forum_count_posts` + 1 - %s - WHERE `forum_id` = :forum - ', - $topic ? ',`forum_count_topics` = `forum_count_topics` + 1' : '' - )); - $increaseCount->bind('forum', $forumId); - $increaseCount->execute(); -} - -function forum_count_synchronise(int $forumId = MSZ_FORUM_ROOT, bool $save = true): array { - static $getChildren = null; - static $getCounts = null; - static $setCounts = null; - - if(is_null($getChildren)) { - $getChildren = \Misuzu\DB::prepare(' - SELECT `forum_id`, `forum_parent` - FROM `msz_forum_categories` - WHERE `forum_parent` = :parent - '); - } - - if(is_null($getCounts)) { - $getCounts = \Misuzu\DB::prepare(' - SELECT :forum as `target_forum_id`, - ( - SELECT COUNT(`topic_id`) - FROM `msz_forum_topics` - WHERE `forum_id` = `target_forum_id` - AND `topic_deleted` IS NULL - ) AS `count_topics`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `forum_id` = `target_forum_id` - AND `post_deleted` IS NULL - ) AS `count_posts` - '); - } - - if($save && is_null($setCounts)) { - $setCounts = \Misuzu\DB::prepare(' - UPDATE `msz_forum_categories` - SET `forum_count_topics` = :topics, - `forum_count_posts` = :posts - WHERE `forum_id` = :forum_id - '); - } - - $getChildren->bind('parent', $forumId); - $children = $getChildren->fetchAll(); - - $topics = 0; - $posts = 0; - - foreach($children as $child) { - $childCount = forum_count_synchronise($child['forum_id'], $save); - $topics += $childCount['topics']; - $posts += $childCount['posts']; - } - - $getCounts->bind('forum', $forumId); - $counts = $getCounts->fetch(); - $topics += $counts['count_topics']; - $posts += $counts['count_posts']; - - if($forumId > 0 && $save) { - $setCounts->bind('forum_id', $forumId); - $setCounts->bind('topics', $topics); - $setCounts->bind('posts', $posts); - $setCounts->execute(); - } - - return compact('topics', 'posts'); -} diff --git a/src/Forum/leaderboard.php b/src/Forum/leaderboard.php deleted file mode 100644 index 7d528f50..00000000 --- a/src/Forum/leaderboard.php +++ /dev/null @@ -1,98 +0,0 @@ -<?php -define('MSZ_FORUM_LEADERBOARD_START_YEAR', 2018); -define('MSZ_FORUM_LEADERBOARD_START_MONTH', 12); -define('MSZ_FORUM_LEADERBOARD_CATEGORY_ALL', 0); - -function forum_leaderboard_year_valid(?int $year): bool { - return !is_null($year) && $year >= MSZ_FORUM_LEADERBOARD_START_YEAR && $year <= date('Y'); -} - -function forum_leaderboard_month_valid(?int $year, ?int $month): bool { - if(is_null($month) || !forum_leaderboard_year_valid($year) || $month < 1 || $month > 12) { - return false; - } - - $combo = sprintf('%04d%02d', $year, $month); - $start = sprintf('%04d%02d', MSZ_FORUM_LEADERBOARD_START_YEAR, MSZ_FORUM_LEADERBOARD_START_MONTH); - $current = date('Ym'); - - return $combo >= $start && $combo <= $current; -} - -function forum_leaderboard_categories(): array { - $categories = [ - MSZ_FORUM_LEADERBOARD_CATEGORY_ALL => 'All Time', - ]; - - $currentYear = date('Y'); - $currentMonth = date('m'); - - for($i = $currentYear; $i >= MSZ_FORUM_LEADERBOARD_START_YEAR; $i--) { - $categories[$i] = sprintf('Leaderboard %d', $i); - } - - for($i = $currentYear, $j = $currentMonth;;) { - $categories[sprintf('%d%02d', $i, $j)] = sprintf('Leaderboard %d-%02d', $i, $j); - - if($j <= 1) { - $i--; $j = 12; - } else $j--; - - if($i <= MSZ_FORUM_LEADERBOARD_START_YEAR && $j < MSZ_FORUM_LEADERBOARD_START_MONTH) - break; - } - - return $categories; -} - -function forum_leaderboard_listing( - ?int $year = null, - ?int $month = null, - array $unrankedForums = [], - array $unrankedTopics = [] -): array { - $hasYear = forum_leaderboard_year_valid($year); - $hasMonth = $hasYear && forum_leaderboard_month_valid($year, $month); - $unrankedForums = implode(',', $unrankedForums); - $unrankedTopics = implode(',', $unrankedTopics); - - $rawLeaderboard = \Misuzu\DB::query(sprintf( - ' - SELECT - u.`user_id`, u.`username`, - COUNT(fp.`post_id`) as `posts` - FROM `msz_users` AS u - INNER JOIN `msz_forum_posts` AS fp - ON fp.`user_id` = u.`user_id` - WHERE fp.`post_deleted` IS NULL - %s %s %s - GROUP BY u.`user_id` - HAVING `posts` > 0 - ORDER BY `posts` DESC - ', - $unrankedForums ? sprintf('AND fp.`forum_id` NOT IN (%s)', $unrankedForums) : '', - $unrankedTopics ? sprintf('AND fp.`topic_id` NOT IN (%s)', $unrankedTopics) : '', - !$hasYear ? '' : sprintf( - 'AND DATE(fp.`post_created`) BETWEEN \'%1$04d-%2$02d-01\' AND \'%1$04d-%3$02d-31\'', - $year, - $hasMonth ? $month : 1, - $hasMonth ? $month : 12 - ) - ))->fetchAll(); - - $leaderboard = []; - $ranking = 0; - $lastPosts = null; - - foreach($rawLeaderboard as $entry) { - if(is_null($lastPosts) || $lastPosts > $entry['posts']) { - $ranking++; - $lastPosts = $entry['posts']; - } - - $entry['rank'] = $ranking; - $leaderboard[] = $entry; - } - - return $leaderboard; -} 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/poll.php b/src/Forum/poll.php deleted file mode 100644 index b2a56dc5..00000000 --- a/src/Forum/poll.php +++ /dev/null @@ -1,200 +0,0 @@ -<?php -function forum_poll_get(int $poll): array { - if($poll < 1) { - return []; - } - - $getPoll = \Misuzu\DB::prepare(" - SELECT fp.`poll_id`, fp.`poll_max_votes`, fp.`poll_expires`, fp.`poll_preview_results`, fp.`poll_change_vote`, - (fp.`poll_expires` < CURRENT_TIMESTAMP) AS `poll_expired`, - ( - SELECT COUNT(*) - FROM `msz_forum_polls_answers` - WHERE `poll_id` = fp.`poll_id` - ) AS `poll_votes` - FROM `msz_forum_polls` AS fp - WHERE fp.`poll_id` = :poll - "); - $getPoll->bind('poll', $poll); - return $getPoll->fetch(); -} - -function forum_poll_create(int $maxVotes = 1): int { - if($maxVotes < 1) { - return -1; - } - - $createPoll = \Misuzu\DB::prepare(" - INSERT INTO `msz_forum_polls` - (`poll_max_votes`) - VALUES - (:max_votes) - "); - $createPoll->bind('max_votes', $maxVotes); - return $createPoll->execute() ? \Misuzu\DB::lastId() : -1; -} - -function forum_poll_get_options(int $poll): array { - if($poll < 1) { - return []; - } - - static $polls = []; - - if(array_key_exists($poll, $polls)) { - return $polls[$poll]; - } - - $getOptions = \Misuzu\DB::prepare(' - SELECT `option_id`, `option_text`, - ( - SELECT COUNT(*) - FROM `msz_forum_polls_answers` - WHERE `option_id` = fpo.`option_id` - ) AS `option_votes` - FROM `msz_forum_polls_options` AS fpo - WHERE `poll_id` = :poll - '); - $getOptions->bind('poll', $poll); - - return $polls[$poll] = $getOptions->fetchAll(); -} - -function forum_poll_get_user_answers(int $poll, int $user): array { - if($poll < 1 || $user < 1) { - return []; - } - - $getAnswers = \Misuzu\DB::prepare(" - SELECT `option_id` - FROM `msz_forum_polls_answers` - WHERE `poll_id` = :poll - AND `user_id` = :user - "); - $getAnswers->bind('poll', $poll); - $getAnswers->bind('user', $user); - return array_column($getAnswers->fetchAll(), 'option_id'); -} - -function forum_poll_reset_answers(int $poll): void { - if($poll < 1) { - return; - } - - $resetAnswers = \Misuzu\DB::prepare(" - DELETE FROM `msz_forum_polls_answers` - WHERE `poll_id` = :poll - "); - $resetAnswers->bind('poll', $poll); - $resetAnswers->execute(); -} - -function forum_poll_option_add(int $poll, string $text): int { - if($poll < 1 || empty($text) || strlen($text) > 0xFF) { - return -1; - } - - $addOption = \Misuzu\DB::prepare(" - INSERT INTO `msz_forum_polls_options` - (`poll_id`, `option_text`) - VALUES - (:poll, :text) - "); - $addOption->bind('poll', $poll); - $addOption->bind('text', $text); - return $addOption->execute() ? \Misuzu\DB::lastId() : -1; -} - -function forum_poll_option_remove(int $option): void { - if($option < 1) { - return; - } - - $removeOption = \Misuzu\DB::prepare(" - DELETE FROM `msz_forum_polls_options` - WHERE `option_id` = :option - "); - $removeOption->bind('option', $option); - $removeOption->execute(); -} - -function forum_poll_vote_remove(int $user, int $poll): void { - if($user < 1 || $poll < 1) { - return; - } - - $purgeVote = \Misuzu\DB::prepare(" - DELETE FROM `msz_forum_polls_answers` - WHERE `user_id` = :user - AND `poll_id` = :poll - "); - $purgeVote->bind('user', $user); - $purgeVote->bind('poll', $poll); - $purgeVote->execute(); -} - -function forum_poll_vote_cast(int $user, int $poll, int $option): void { - if($user < 1 || $poll < 1 || $option < 1) { - return; - } - - $castVote = \Misuzu\DB::prepare(" - INSERT INTO `msz_forum_polls_answers` - (`user_id`, `poll_id`, `option_id`) - VALUES - (:user, :poll, :option) - "); - $castVote->bind('user', $user); - $castVote->bind('poll', $poll); - $castVote->bind('option', $option); - $castVote->execute(); -} - -function forum_poll_validate_option(int $poll, int $option): bool { - if($poll < 1 || $option < 1) { - return false; - } - - $checkVote = \Misuzu\DB::prepare(" - SELECT COUNT(`option_id`) > 0 - FROM `msz_forum_polls_options` - WHERE `poll_id` = :poll - AND `option_id` = :option - "); - $checkVote->bind('poll', $poll); - $checkVote->bind('option', $option); - - return (bool)$checkVote->fetchColumn(); -} - -function forum_poll_has_voted(int $user, int $poll): bool { - if($user < 1 || $poll < 1) { - return false; - } - - $getAnswers = \Misuzu\DB::prepare(" - SELECT COUNT(`user_id`) > 0 - FROM `msz_forum_polls_answers` - WHERE `poll_id` = :poll - AND `user_id` = :user - "); - $getAnswers->bind('poll', $poll); - $getAnswers->bind('user', $user); - - return (bool)$getAnswers->fetchColumn(); -} - -function forum_poll_get_topic(int $poll): array { - if($poll < 1) { - return []; - } - - $getTopic = \Misuzu\DB::prepare(" - SELECT `forum_id`, `topic_id`, `topic_locked` - FROM `msz_forum_topics` - WHERE `poll_id` = :poll - "); - $getTopic->bind('poll', $poll); - - return $getTopic->fetch(); -} diff --git a/src/Forum/post.php b/src/Forum/post.php deleted file mode 100644 index c569eb50..00000000 --- a/src/Forum/post.php +++ /dev/null @@ -1,360 +0,0 @@ -<?php -define('MSZ_FORUM_POSTS_PER_PAGE', 10); - -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 6dd5c42d..00000000 --- a/src/Forum/topic.php +++ /dev/null @@ -1,709 +0,0 @@ -<?php -define('MSZ_TOPIC_TYPE_DISCUSSION', 0); -define('MSZ_TOPIC_TYPE_STICKY', 1); -define('MSZ_TOPIC_TYPE_ANNOUNCEMENT', 2); -define('MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT', 3); -define('MSZ_TOPIC_TYPES', [ - MSZ_TOPIC_TYPE_DISCUSSION, - MSZ_TOPIC_TYPE_STICKY, - MSZ_TOPIC_TYPE_ANNOUNCEMENT, - MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT, -]); - -define('MSZ_TOPIC_TYPE_ORDER', [ // in which order to display topics, only add types here that should appear above others - MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT, - MSZ_TOPIC_TYPE_ANNOUNCEMENT, - MSZ_TOPIC_TYPE_STICKY, -]); - -function forum_topic_is_valid_type(int $type): bool { - return in_array($type, MSZ_TOPIC_TYPES, true); -} - -function forum_topic_create( - int $forumId, - int $userId, - string $title, - int $type = MSZ_TOPIC_TYPE_DISCUSSION -): int { - if(empty($title) || !forum_topic_is_valid_type($type)) { - return 0; - } - - $createTopic = \Misuzu\DB::prepare(' - INSERT INTO `msz_forum_topics` - (`forum_id`, `user_id`, `topic_title`, `topic_type`) - VALUES - (:forum_id, :user_id, :topic_title, :topic_type) - '); - $createTopic->bind('forum_id', $forumId); - $createTopic->bind('user_id', $userId); - $createTopic->bind('topic_title', $title); - $createTopic->bind('topic_type', $type); - - return $createTopic->execute() ? \Misuzu\DB::lastId() : 0; -} - -function forum_topic_update(int $topicId, ?string $title, ?int $type = null): bool { - if($topicId < 1) { - return false; - } - - // make sure it's null and not some other kinda empty - if(empty($title)) { - $title = null; - } - - if($type !== null && !forum_topic_is_valid_type($type)) { - return false; - } - - $updateTopic = \Misuzu\DB::prepare(' - UPDATE `msz_forum_topics` - SET `topic_title` = COALESCE(:topic_title, `topic_title`), - `topic_type` = COALESCE(:topic_type, `topic_type`) - WHERE `topic_id` = :topic_id - '); - $updateTopic->bind('topic_id', $topicId); - $updateTopic->bind('topic_title', $title); - $updateTopic->bind('topic_type', $type); - - return $updateTopic->execute(); -} - -function forum_topic_get(int $topicId, bool $allowDeleted = false): array { - $getTopic = \Misuzu\DB::prepare(sprintf( - ' - SELECT - t.`topic_id`, t.`forum_id`, t.`topic_title`, t.`topic_type`, t.`topic_locked`, t.`topic_created`, - f.`forum_archived` AS `topic_archived`, t.`topic_deleted`, t.`topic_bumped`, f.`forum_type`, - tp.`poll_id`, tp.`poll_max_votes`, tp.`poll_expires`, tp.`poll_preview_results`, tp.`poll_change_vote`, - (tp.`poll_expires` < CURRENT_TIMESTAMP) AS `poll_expired`, - fp.`topic_id` AS `author_post_id`, fp.`user_id` AS `author_user_id`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `post_deleted` IS NULL - ) AS `topic_count_posts`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `post_deleted` IS NOT NULL - ) AS `topic_count_posts_deleted`, - ( - SELECT COUNT(*) - FROM `msz_forum_polls_answers` - WHERE `poll_id` = tp.`poll_id` - ) AS `poll_votes` - FROM `msz_forum_topics` AS t - LEFT JOIN `msz_forum_categories` AS f - ON f.`forum_id` = t.`forum_id` - LEFT JOIN `msz_forum_posts` AS fp - ON fp.`post_id` = ( - SELECT MIN(`post_id`) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - ) - LEFT JOIN `msz_forum_polls` AS tp - ON tp.`poll_id` = t.`poll_id` - WHERE t.`topic_id` = :topic_id - %s - ', - $allowDeleted ? '' : 'AND t.`topic_deleted` IS NULL' - )); - $getTopic->bind('topic_id', $topicId); - return $getTopic->fetch(); -} - -function forum_topic_bump(int $topicId): bool { - $bumpTopic = \Misuzu\DB::prepare(' - UPDATE `msz_forum_topics` - SET `topic_bumped` = NOW() - WHERE `topic_id` = :topic_id - AND `topic_deleted` IS NULL - '); - $bumpTopic->bind('topic_id', $topicId); - return $bumpTopic->execute(); -} - -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 - 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(); - } -} - -function forum_topic_listing( - int $forumId, int $userId, - int $offset = 0, int $take = 0, - bool $showDeleted = false, bool $sortByPriority = false -): array { - $hasPagination = $offset >= 0 && $take > 0; - $getTopics = \Misuzu\DB::prepare(sprintf( - ' - SELECT - :user_id AS `target_user_id`, - t.`topic_id`, t.`topic_title`, t.`topic_locked`, t.`topic_type`, t.`topic_created`, - t.`topic_bumped`, t.`topic_deleted`, t.`topic_count_views`, f.`forum_type`, - COALESCE(SUM(tp.`topic_priority`), 0) AS `topic_priority`, - au.`user_id` AS `author_id`, au.`username` AS `author_name`, - COALESCE(au.`user_colour`, ar.`role_colour`) AS `author_colour`, - lp.`post_id` AS `response_id`, - lp.`post_created` AS `response_created`, - lu.`user_id` AS `respondent_id`, - lu.`username` AS `respondent_name`, - COALESCE(lu.`user_colour`, lr.`role_colour`) AS `respondent_colour`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - %5$s - ) AS `topic_count_posts`, - ( - SELECT CEIL(COUNT(`post_id`) / %6$d) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - %5$s - ) AS `topic_pages`, - ( - SELECT - `target_user_id` > 0 - AND - t.`topic_bumped` > NOW() - INTERVAL 1 MONTH - AND ( - SELECT COUNT(ti.`topic_id`) < 1 - FROM `msz_forum_topics_track` AS tt - RIGHT JOIN `msz_forum_topics` AS ti - ON ti.`topic_id` = tt.`topic_id` - WHERE ti.`topic_id` = t.`topic_id` - AND tt.`user_id` = `target_user_id` - AND `track_last_read` >= `topic_bumped` - ) - ) AS `topic_unread`, - ( - SELECT COUNT(`post_id`) > 0 - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `user_id` = `target_user_id` - LIMIT 1 - ) AS `topic_participated` - FROM `msz_forum_topics` AS t - LEFT JOIN `msz_forum_topics_priority` AS tp - ON tp.`topic_id` = t.`topic_id` - LEFT JOIN `msz_forum_categories` AS f - ON f.`forum_id` = t.`forum_id` - LEFT JOIN `msz_users` AS au - ON t.`user_id` = au.`user_id` - LEFT JOIN `msz_roles` AS ar - ON ar.`role_id` = au.`display_role` - LEFT JOIN `msz_forum_posts` AS lp - ON lp.`post_id` = ( - SELECT `post_id` - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - %5$s - ORDER BY `post_id` DESC - LIMIT 1 - ) - LEFT JOIN `msz_users` AS lu - ON lu.`user_id` = lp.`user_id` - LEFT JOIN `msz_roles` AS lr - ON lr.`role_id` = lu.`display_role` - WHERE ( - t.`forum_id` = :forum_id - OR t.`topic_type` = %3$d - ) - %1$s - GROUP BY t.`topic_id` - ORDER BY FIELD(t.`topic_type`, %4$s) DESC, %7$s t.`topic_bumped` DESC - %2$s - ', - $showDeleted ? '' : 'AND t.`topic_deleted` IS NULL', - $hasPagination ? 'LIMIT :offset, :take' : '', - MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT, - implode(',', array_reverse(MSZ_TOPIC_TYPE_ORDER)), - $showDeleted ? '' : 'AND `post_deleted` IS NULL', - MSZ_FORUM_POSTS_PER_PAGE, - $sortByPriority ? '`topic_priority` DESC,' : '' - )); - $getTopics->bind('forum_id', $forumId); - $getTopics->bind('user_id', $userId); - - if($hasPagination) { - $getTopics->bind('offset', $offset); - $getTopics->bind('take', $take); - } - - return $getTopics->fetchAll(); -} - -function forum_topic_count_user(int $authorId, int $userId, bool $showDeleted = false): int { - $getTopics = \Misuzu\DB::prepare(sprintf( - ' - SELECT COUNT(`topic_id`) - FROM `msz_forum_topics` AS t - WHERE t.`user_id` = :author_id - %1$s - ', - $showDeleted ? '' : 'AND t.`topic_deleted` IS NULL' - )); - $getTopics->bind('author_id', $authorId); - //$getTopics->bind('user_id', $userId); - - return (int)$getTopics->fetchColumn(); -} - -// Remove unneccesary stuff from the sql stmt -function forum_topic_listing_user( - int $authorId, - int $userId, - int $offset = 0, - int $take = 0, - bool $showDeleted = false -): array { - $hasPagination = $offset >= 0 && $take > 0; - $getTopics = \Misuzu\DB::prepare(sprintf( - ' - SELECT - :user_id AS `target_user_id`, - t.`topic_id`, t.`topic_title`, t.`topic_locked`, t.`topic_type`, t.`topic_created`, - t.`topic_bumped`, t.`topic_deleted`, t.`topic_count_views`, f.`forum_type`, - au.`user_id` AS `author_id`, au.`username` AS `author_name`, - COALESCE(au.`user_colour`, ar.`role_colour`) AS `author_colour`, - lp.`post_id` AS `response_id`, - lp.`post_created` AS `response_created`, - lu.`user_id` AS `respondent_id`, - lu.`username` AS `respondent_name`, - COALESCE(lu.`user_colour`, lr.`role_colour`) AS `respondent_colour`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - %5$s - ) AS `topic_count_posts`, - ( - SELECT CEIL(COUNT(`post_id`) / %6$d) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - %5$s - ) AS `topic_pages`, - ( - SELECT - `target_user_id` > 0 - AND - t.`topic_bumped` > NOW() - INTERVAL 1 MONTH - AND ( - SELECT COUNT(ti.`topic_id`) < 1 - FROM `msz_forum_topics_track` AS tt - RIGHT JOIN `msz_forum_topics` AS ti - ON ti.`topic_id` = tt.`topic_id` - WHERE ti.`topic_id` = t.`topic_id` - AND tt.`user_id` = `target_user_id` - AND `track_last_read` >= `topic_bumped` - ) - ) AS `topic_unread`, - ( - SELECT COUNT(`post_id`) > 0 - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `user_id` = `target_user_id` - LIMIT 1 - ) AS `topic_participated` - FROM `msz_forum_topics` AS t - LEFT JOIN `msz_forum_categories` AS f - ON f.`forum_id` = t.`forum_id` - LEFT JOIN `msz_users` AS au - ON t.`user_id` = au.`user_id` - LEFT JOIN `msz_roles` AS ar - ON ar.`role_id` = au.`display_role` - LEFT JOIN `msz_forum_posts` AS lp - ON lp.`post_id` = ( - SELECT `post_id` - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - %5$s - ORDER BY `post_id` DESC - LIMIT 1 - ) - LEFT JOIN `msz_users` AS lu - ON lu.`user_id` = lp.`user_id` - LEFT JOIN `msz_roles` AS lr - ON lr.`role_id` = lu.`display_role` - WHERE au.`user_id` = :author_id - %1$s - ORDER BY FIELD(t.`topic_type`, %4$s) DESC, t.`topic_bumped` DESC - %2$s - ', - $showDeleted ? '' : 'AND t.`topic_deleted` IS NULL', - $hasPagination ? 'LIMIT :offset, :take' : '', - MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT, - implode(',', array_reverse(MSZ_TOPIC_TYPE_ORDER)), - $showDeleted ? '' : 'AND `post_deleted` IS NULL', - MSZ_FORUM_POSTS_PER_PAGE - )); - $getTopics->bind('author_id', $authorId); - $getTopics->bind('user_id', $userId); - - if($hasPagination) { - $getTopics->bind('offset', $offset); - $getTopics->bind('take', $take); - } - - return $getTopics->fetchAll(); -} - -function forum_topic_listing_search(string $query, int $userId): array { - $getTopics = \Misuzu\DB::prepare(sprintf( - ' - SELECT - :user_id AS `target_user_id`, - t.`topic_id`, t.`topic_title`, t.`topic_locked`, t.`topic_type`, t.`topic_created`, - t.`topic_bumped`, t.`topic_deleted`, t.`topic_count_views`, f.`forum_type`, - au.`user_id` AS `author_id`, au.`username` AS `author_name`, - COALESCE(au.`user_colour`, ar.`role_colour`) AS `author_colour`, - lp.`post_id` AS `response_id`, - lp.`post_created` AS `response_created`, - lu.`user_id` AS `respondent_id`, - lu.`username` AS `respondent_name`, - COALESCE(lu.`user_colour`, lr.`role_colour`) AS `respondent_colour`, - ( - SELECT COUNT(`post_id`) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `post_deleted` IS NULL - ) AS `topic_count_posts`, - ( - SELECT CEIL(COUNT(`post_id`) / %2$d) - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `post_deleted` IS NULL - ) AS `topic_pages`, - ( - SELECT - `target_user_id` > 0 - AND - t.`topic_bumped` > NOW() - INTERVAL 1 MONTH - AND ( - SELECT COUNT(ti.`topic_id`) < 1 - FROM `msz_forum_topics_track` AS tt - RIGHT JOIN `msz_forum_topics` AS ti - ON ti.`topic_id` = tt.`topic_id` - WHERE ti.`topic_id` = t.`topic_id` - AND tt.`user_id` = `target_user_id` - AND `track_last_read` >= `topic_bumped` - ) - ) AS `topic_unread`, - ( - SELECT COUNT(`post_id`) > 0 - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `user_id` = `target_user_id` - LIMIT 1 - ) AS `topic_participated` - FROM `msz_forum_topics` AS t - LEFT JOIN `msz_forum_categories` AS f - ON f.`forum_id` = t.`forum_id` - LEFT JOIN `msz_users` AS au - ON t.`user_id` = au.`user_id` - LEFT JOIN `msz_roles` AS ar - ON ar.`role_id` = au.`display_role` - LEFT JOIN `msz_forum_posts` AS lp - ON lp.`post_id` = ( - SELECT `post_id` - FROM `msz_forum_posts` - WHERE `topic_id` = t.`topic_id` - AND `post_deleted` IS NULL - ORDER BY `post_id` DESC - LIMIT 1 - ) - LEFT JOIN `msz_users` AS lu - ON lu.`user_id` = lp.`user_id` - LEFT JOIN `msz_roles` AS lr - ON lr.`role_id` = lu.`display_role` - WHERE MATCH(`topic_title`) - AGAINST (:query IN NATURAL LANGUAGE MODE) - AND t.`topic_deleted` IS NULL - ORDER BY FIELD(t.`topic_type`, %1$s) DESC, t.`topic_bumped` DESC - ', - implode(',', array_reverse(MSZ_TOPIC_TYPE_ORDER)), - MSZ_FORUM_POSTS_PER_PAGE - )); - $getTopics->bind('query', $query); - $getTopics->bind('user_id', $userId); - - return $getTopics->fetchAll(); -} - -function forum_topic_lock(int $topicId): bool { - if($topicId < 1) { - return false; - } - - $markLocked = \Misuzu\DB::prepare(' - UPDATE `msz_forum_topics` - SET `topic_locked` = NOW() - WHERE `topic_id` = :topic - AND `topic_locked` IS NULL - '); - $markLocked->bind('topic', $topicId); - - return $markLocked->execute(); -} - -function forum_topic_unlock(int $topicId): bool { - if($topicId < 1) { - return false; - } - - $markUnlocked = \Misuzu\DB::prepare(' - UPDATE `msz_forum_topics` - SET `topic_locked` = NULL - WHERE `topic_id` = :topic - AND `topic_locked` IS NOT NULL - '); - $markUnlocked->bind('topic', $topicId); - - return $markUnlocked->execute(); -} - -define('MSZ_E_FORUM_TOPIC_DELETE_OK', 0); // deleting is fine -define('MSZ_E_FORUM_TOPIC_DELETE_USER', 1); // invalid user -define('MSZ_E_FORUM_TOPIC_DELETE_TOPIC', 2); // topic doesn't exist -define('MSZ_E_FORUM_TOPIC_DELETE_DELETED', 3); // topic is already marked as deleted -define('MSZ_E_FORUM_TOPIC_DELETE_OWNER', 4); // you may only delete your own topics -define('MSZ_E_FORUM_TOPIC_DELETE_OLD', 5); // topic has existed for too long to be deleted -define('MSZ_E_FORUM_TOPIC_DELETE_PERM', 6); // you aren't allowed to delete topics -define('MSZ_E_FORUM_TOPIC_DELETE_POSTS', 7); // the topic already has replies - -// only allow topics made within a day of posting to be deleted by normal users -define('MSZ_FORUM_TOPIC_DELETE_TIME_LIMIT', 60 * 60 * 24); - -// only allow topics with a single post to be deleted, includes soft deleted posts -define('MSZ_FORUM_TOPIC_DELETE_POST_LIMIT', 1); - -// set $userId to null for system request, make sure this is NEVER EVER null on user request -// $topicId can also be a the return value of forum_topic_get if you already grabbed it once before -function forum_topic_can_delete($topicId, ?int $userId = null): int { - if($userId !== null && $userId < 1) { - return MSZ_E_FORUM_TOPIC_DELETE_USER; - } - - if(is_array($topicId)) { - $topic = $topicId; - } else { - $topic = forum_topic_get((int)$topicId, true); - } - - if(empty($topic)) { - return MSZ_E_FORUM_TOPIC_DELETE_TOPIC; - } - - $isSystemReq = $userId === null; - $perms = $isSystemReq ? 0 : forum_perms_get_user($topic['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($topic['topic_deleted']); - - if(!$canViewPost) { - return MSZ_E_FORUM_TOPIC_DELETE_TOPIC; - } - - if($postIsDeleted) { - return $canDeleteAny ? MSZ_E_FORUM_TOPIC_DELETE_DELETED : MSZ_E_FORUM_TOPIC_DELETE_TOPIC; - } - - if($isSystemReq) { - return MSZ_E_FORUM_TOPIC_DELETE_OK; - } - - if(!$canDeleteAny) { - if(!perms_check($perms, MSZ_FORUM_PERM_DELETE_POST)) { - return MSZ_E_FORUM_TOPIC_DELETE_PERM; - } - - if($topic['author_user_id'] !== $userId) { - return MSZ_E_FORUM_TOPIC_DELETE_OWNER; - } - - if(strtotime($topic['topic_created']) <= time() - MSZ_FORUM_TOPIC_DELETE_TIME_LIMIT) { - return MSZ_E_FORUM_TOPIC_DELETE_OLD; - } - - $totalReplies = $topic['topic_count_posts'] + $topic['topic_count_posts_deleted']; - - if($totalReplies > MSZ_E_FORUM_TOPIC_DELETE_POSTS) { - return MSZ_E_FORUM_TOPIC_DELETE_POSTS; - } - } - - return MSZ_E_FORUM_TOPIC_DELETE_OK; -} - -function forum_topic_delete(int $topicId): bool { - if($topicId < 1) { - return false; - } - - $markTopicDeleted = \Misuzu\DB::prepare(' - UPDATE `msz_forum_topics` - SET `topic_deleted` = NOW() - WHERE `topic_id` = :topic - AND `topic_deleted` IS NULL - '); - $markTopicDeleted->bind('topic', $topicId); - - if(!$markTopicDeleted->execute()) { - return false; - } - - $markPostsDeleted = \Misuzu\DB::prepare(' - UPDATE `msz_forum_posts` as p - SET p.`post_deleted` = ( - SELECT `topic_deleted` - FROM `msz_forum_topics` - WHERE `topic_id` = p.`topic_id` - ) - WHERE p.`topic_id` = :topic - AND p.`post_deleted` IS NULL - '); - $markPostsDeleted->bind('topic', $topicId); - - return $markPostsDeleted->execute(); -} - -function forum_topic_restore(int $topicId): bool { - if($topicId < 1) { - return false; - } - - $markPostsRestored = \Misuzu\DB::prepare(' - UPDATE `msz_forum_posts` as p - SET p.`post_deleted` = NULL - WHERE p.`topic_id` = :topic - AND p.`post_deleted` = ( - SELECT `topic_deleted` - FROM `msz_forum_topics` - WHERE `topic_id` = p.`topic_id` - ) - '); - $markPostsRestored->bind('topic', $topicId); - - if(!$markPostsRestored->execute()) { - return false; - } - - $markTopicRestored = \Misuzu\DB::prepare(' - UPDATE `msz_forum_topics` - SET `topic_deleted` = NULL - WHERE `topic_id` = :topic - AND `topic_deleted` IS NOT NULL - '); - $markTopicRestored->bind('topic', $topicId); - - return $markTopicRestored->execute(); -} - -function forum_topic_nuke(int $topicId): bool { - if($topicId < 1) { - return false; - } - - $nukeTopic = \Misuzu\DB::prepare(' - DELETE FROM `msz_forum_topics` - WHERE `topic_id` = :topic - '); - $nukeTopic->bind('topic', $topicId); - return $nukeTopic->execute(); -} - -function forum_topic_priority(int $topic): array { - if($topic < 1) { - return []; - } - - $getPriority = \Misuzu\DB::prepare(' - SELECT - tp.`topic_id`, tp.`topic_priority`, - u.`user_id`, u.`username`, - COALESCE(u.`user_colour`, r.`role_colour`) AS `user_colour` - FROM `msz_forum_topics_priority` AS tp - LEFT JOIN `msz_users` AS u - ON u.`user_id` = tp.`user_id` - LEFT JOIN `msz_roles` AS r - ON u.`display_role` = r.`role_id` - WHERE `topic_id` = :topic - '); - $getPriority->bind('topic', $topic); - - return $getPriority->fetchAll(); -} - -function forum_topic_priority_increase(int $topic, int $user, int $bump = 1): void { - if($topic < 1 || $user < 1 || $bump === 0) { - return; - } - - $bumpPriority = \Misuzu\DB::prepare(' - INSERT INTO `msz_forum_topics_priority` - (`topic_id`, `user_id`, `topic_priority`) - VALUES - (:topic, :user, :bump1) - ON DUPLICATE KEY UPDATE - `topic_priority` = `topic_priority` + :bump2 - '); - $bumpPriority->bind('topic', $topic); - $bumpPriority->bind('user', $user); - $bumpPriority->bind('bump1', $bump); - $bumpPriority->bind('bump2', $bump); - $bumpPriority->execute(); -} diff --git a/src/Forum/validate.php b/src/Forum/validate.php deleted file mode 100644 index 5c0a6ba9..00000000 --- a/src/Forum/validate.php +++ /dev/null @@ -1,33 +0,0 @@ -<?php -define('MSZ_TOPIC_TITLE_LENGTH_MIN', 3); -define('MSZ_TOPIC_TITLE_LENGTH_MAX', 100); -define('MSZ_POST_TEXT_LENGTH_MIN', 1); -define('MSZ_POST_TEXT_LENGTH_MAX', 60000); - -function forum_validate_title(string $title): string { - $length = mb_strlen(trim($title)); - - if($length < MSZ_TOPIC_TITLE_LENGTH_MIN) { - return 'too-short'; - } - - if($length > MSZ_TOPIC_TITLE_LENGTH_MAX) { - return 'too-long'; - } - - return ''; -} - -function forum_validate_post(string $text): string { - $length = mb_strlen(trim($text)); - - if($length < MSZ_POST_TEXT_LENGTH_MIN) { - return 'too-short'; - } - - if($length > MSZ_POST_TEXT_LENGTH_MAX) { - return 'too-long'; - } - - return ''; -} diff --git a/src/Http/Handlers/Forum/ForumCategoryHandler.php b/src/Http/Handlers/Forum/ForumCategoryHandler.php new file mode 100644 index 00000000..587883c9 --- /dev/null +++ b/src/Http/Handlers/Forum/ForumCategoryHandler.php @@ -0,0 +1,91 @@ +<?php +namespace Misuzu\Http\Handlers\Forum; + +use HttpResponse; +use HttpRequest; +use Misuzu\Pagination; +use Misuzu\Forum\ForumCategory; +use Misuzu\Forum\ForumCategoryNotFoundException; +use Misuzu\Users\User; + +class ForumCategoryHandler extends ForumHandler { + public function category(HttpResponse $response, HttpRequest $request, int $categoryId) { + if($categoryId === 0) { + $response->redirect(url('forum-index')); + return; + } + + try { + $categoryInfo = ForumCategory::byId($categoryId); + } catch(ForumCategoryNotFoundException $ex) {} + + if(empty($categoryInfo) || ($categoryInfo->isLink() && !$categoryInfo->hasLink())) + return 404; + + $currentUser = User::getCurrent(); + + if(!$categoryInfo->canView($currentUser)) + return 403; + + $perms = forum_perms_get_user($categoryInfo->getId(), $currentUser === null ? 0 : $currentUser->getId())[MSZ_FORUM_PERMS_GENERAL]; + + if(isset($currentUser) && $currentUser->hasActiveWarning()) + $perms &= ~MSZ_FORUM_PERM_SET_WRITE; + + if($categoryInfo->isLink()) { + $categoryInfo->increaseLinkClicks(); + $response->redirect($categoryInfo->getLink()); + return; + } + + $canViewDeleted = perms_check($perms, MSZ_FORUM_PERM_DELETE_ANY_POST); + $pagination = new Pagination($categoryInfo->getActualTopicCount($canViewDeleted), 20); + + if(!$pagination->hasValidOffset() && $pagination->getCount() > 0) + return 404; + + $response->setTemplate('forum.forum', [ + 'forum_perms' => $perms, + 'forum_info' => $categoryInfo, + 'forum_pagination' => $pagination, + 'can_view_deleted' => $canViewDeleted, + ]); + } + + public function createView(HttpResponse $response, HttpRequest $request, int $categoryId) { + try { + $categoryInfo = ForumCategory::byId($categoryId); + } catch(ForumCategoryNotFoundException $ex) { + return 404; + } + + var_dump($categoryInfo->getId()); + } + + public function createAction(HttpResponse $response, HttpRequest $request, int $categoryId) { + try { + $categoryInfo = ForumCategory::byId($categoryId); + } catch(ForumCategoryNotFoundException $ex) { + return 404; + } + + var_dump($categoryInfo->getId()); + } + + public function legacy(HttpResponse $response, HttpRequest $request) { + $categoryId = (int)$request->getQueryParam('f', FILTER_SANITIZE_NUMBER_INT); + + if($categoryId < 0) + return 404; + + if($categoryId === 0) { + $response->redirect(url('forum-index')); + return; + } + + $response->redirect(url('forum-category', [ + 'forum' => $categoryId, + 'page' => (int)$request->getQueryParam('p', FILTER_SANITIZE_NUMBER_INT), + ])); + } +} diff --git a/src/Http/Handlers/Forum/ForumHandler.php b/src/Http/Handlers/Forum/ForumHandler.php new file mode 100644 index 00000000..3fec15d0 --- /dev/null +++ b/src/Http/Handlers/Forum/ForumHandler.php @@ -0,0 +1,6 @@ +<?php +namespace Misuzu\Http\Handlers\Forum; + +use Misuzu\Http\Handlers\Handler; + +abstract class ForumHandler extends Handler {} diff --git a/src/Http/Handlers/Forum/ForumIndexHandler.php b/src/Http/Handlers/Forum/ForumIndexHandler.php new file mode 100644 index 00000000..a1242020 --- /dev/null +++ b/src/Http/Handlers/Forum/ForumIndexHandler.php @@ -0,0 +1,56 @@ +<?php +namespace Misuzu\Http\Handlers\Forum; + +use HttpResponse; +use HttpRequest; +use Misuzu\Forum\ForumCategory; +use Misuzu\Forum\ForumCategoryNotFoundException; +use Misuzu\Users\User; + +class ForumIndexHandler extends ForumHandler { + public function index(HttpResponse $response): void { + $response->setTemplate('forum.index', [ + 'forum_root' => ForumCategory::root(), + ]); + } + + public function markAsRead(HttpResponse $response, HttpRequest $request) { + try { + $categoryInfo = ForumCategory::byId( + (int)($request->getBodyParam('forum', FILTER_SANITIZE_NUMBER_INT) ?? $request->getQueryParam('forum', FILTER_SANITIZE_NUMBER_INT)) + ); + } catch(ForumCategoryNotFoundException $ex) { + return 404; + } + + if($request->getMethod() === 'GET') { + $response->setTemplate('confirm', [ + 'title' => 'Mark forum as read', + 'message' => 'Are you sure you want to mark ' . ($categoryInfo->isRoot() ? 'the entire' : 'this') . ' forum as read?', + 'return' => url($categoryInfo->isRoot() ? 'forum-index' : 'forum-category', ['forum' => $categoryInfo->getId()]), + 'params' => [ + 'forum' => $categoryInfo->getId(), + ] + ]); + return; + } + + $categoryInfo->markAsRead(User::getCurrent()); + + $response->redirect( + url($categoryInfo->isRoot() ? 'forum-index' : 'forum-category', ['forum' => $categoryInfo->getId()]), + false, + $request->hasHeader('X-Misuzu-XHR') + ); + } + + public function legacy(HttpResponse $response, HttpRequest $request): void { + if($request->getQueryParam('m') === 'mark') { + $forumId = (int)$request->getQueryParam('f', FILTER_SANITIZE_NUMBER_INT); + $response->redirect(url($forumId < 1 ? 'forum-mark-global' : 'forum-mark-single', ['forum' => $forumId])); + return; + } + + $response->redirect(url('forum-index')); + } +} diff --git a/src/Http/Handlers/Forum/ForumPollHandler.php b/src/Http/Handlers/Forum/ForumPollHandler.php new file mode 100644 index 00000000..7f6e1371 --- /dev/null +++ b/src/Http/Handlers/Forum/ForumPollHandler.php @@ -0,0 +1,32 @@ +<?php +namespace Misuzu\Http\Handlers\Forum; + +use HttpResponse; +use HttpRequest; +use Misuzu\Forum\ForumPoll; +use Misuzu\Forum\ForumPollNotFoundException; +use Misuzu\Users\User; + +class ForumPollHandler extends ForumHandler { + public function vote(HttpResponse $response, HttpRequest $request, int $postId) { + try { + $pollInfo = ForumPoll::byId($pollId); + } catch(ForumPollNotFoundException $ex) { + return 404; + } + + // check perms lol + + $results = []; + + foreach($pollInfo->getOptions() as $optionInfo) + $results[] = [ + 'id' => $optionInfo->getId(), + 'text' => $optionInfo->getText(), + 'vote_count' => $optionInfo->getVotes(), + 'vote_percent' => $optionInfo->getPercentage(), + ]; + + return $results; + } +} diff --git a/src/Http/Handlers/Forum/ForumPostHandler.php b/src/Http/Handlers/Forum/ForumPostHandler.php new file mode 100644 index 00000000..51da1ac3 --- /dev/null +++ b/src/Http/Handlers/Forum/ForumPostHandler.php @@ -0,0 +1,43 @@ +<?php +namespace Misuzu\Http\Handlers\Forum; + +use HttpResponse; +use HttpRequest; +use Misuzu\Pagination; +use Misuzu\Forum\ForumPost; +use Misuzu\Forum\ForumPostNotFoundException; +use Misuzu\Users\User; + +class ForumPostHandler extends ForumHandler { + public function post(HttpResponse $response, HttpRequest $request, int $postId) { + try { + $postInfo = ForumPost::byId($postId); + } catch(ForumPostNotFoundException $ex) { + return 404; + } + + var_dump($postInfo->getId()); + } + + public function edit(HttpResponse $response, HttpRequest $request, int $postId) { + } + + public function delete(HttpResponse $response, HttpRequest $request, int $postId) { + } + + public function restore(HttpResponse $response, HttpRequest $request, int $postId) { + } + + public function nuke(HttpResponse $response, HttpRequest $request, int $postId) { + } + + public function legacy(HttpResponse $response, HttpRequest $request) { + $postId = (int)$request->getQueryParam('p', FILTER_SANITIZE_NUMBER_INT); + if($postId > 0) { + $response->redirect(url('forum-post', ['post' => $postId])); + return; + } + + return 404; + } +} diff --git a/src/Http/Handlers/Forum/ForumTopicHandler.php b/src/Http/Handlers/Forum/ForumTopicHandler.php new file mode 100644 index 00000000..0e3ff1e7 --- /dev/null +++ b/src/Http/Handlers/Forum/ForumTopicHandler.php @@ -0,0 +1,132 @@ +<?php +namespace Misuzu\Http\Handlers\Forum; + +use HttpResponse; +use HttpRequest; +use Misuzu\Pagination; +use Misuzu\Forum\ForumTopic; +use Misuzu\Forum\ForumTopicNotFoundException; +use Misuzu\Users\User; + +class ForumTopicHandler extends ForumHandler { + public function topic(HttpResponse $response, HttpRequest $request, int $topicId) { + try { + $topicInfo = ForumTopic::byId($topicId); + } catch(ForumTopicNotFoundException $ex) { + return 404; + } + + var_dump($topicInfo->getId()); + } + + public function reply(HttpResponse $response, HttpRequest $request, int $topicId) { + } + + // Should support a since param to fetch a number of points after a point in time/post id + public function live(HttpResponse $response, HttpRequest $request, int $topicId) { + try { + $topicInfo = ForumTopic::byId($topicId); + } catch(ForumTopicNotFoundException $ex) { + return 404; + } + + if(!$topicInfo->getCategory()->canView(User::getCurrent())) + return 403; + + $sincePostId = (int)($request->getQueryParam('since', FILTER_SANITIZE_NUMBER_INT) ?? -1); + + $ajaxInfo = [ + 'id' => $topicInfo->getId(), + 'title' => $topicInfo->getTitle(), + ]; + + $categoryInfo = $topicInfo->getCategory(); + $ajaxInfo['category'] = [ + 'id' => $categoryInfo->getId(), + 'name' => $categoryInfo->getName(), + 'tree' => [], + ]; + + $parentTree = $categoryInfo->getParentTree(); + foreach($parentTree as $parentInfo) + $ajaxInfo['category']['tree'][] = [ + 'id' => $parentInfo->getId(), + 'name' => $parentInfo->getName(), + ]; + + if($topicInfo->hasPriorityVoting()) { + $ajaxInfo['priority'] = [ + 'total' => $topicInfo->getPriority(), + 'votes' => [], + ]; + $topicPriority = $topicInfo->getPriorityVotes(); + foreach($topicPriority as $priorityInfo) { + $priorityUserInfo = $priorityInfo->getUser(); + $ajaxInfo['priority']['votes'][] = [ + 'count' => $priorityInfo->getPriority(), + 'user' => [ + 'id' => $priorityUserInfo->getId(), + 'name' => $priorityUserInfo->getUsername(), + 'colour' => $priorityUserInfo->getColour()->getRaw(), + ], + ]; + } + } + + if($topicInfo->hasPoll()) { + $pollInfo = $topicInfo->getPoll(); + $ajaxInfo['poll'] = [ + 'id' => $pollInfo->getId(), + 'options' => [], + ]; + + $pollOptions = $pollInfo->getOptions(); + foreach($pollOptions as $optionInfo) + $ajaxInfo['poll']['options'][] = [ + 'id' => $optionInfo->getId(), + 'text' => $optionInfo->getText(), + 'vote_count' => $optionInfo->getVotes(), + 'vote_percent' => $optionInfo->getPercentage(), + ]; + } + + if($sincePostId >= 0) { + // Should contain all info necessary to build said posts + // Maybe just serialised HTML a la YTKNS? + $ajaxInfo['posts'] = []; + } + + return $ajaxInfo; + } + + public function delete(HttpResponse $response, HttpRequest $request, int $topicId) { + } + + public function restore(HttpResponse $response, HttpRequest $request, int $topicId) { + } + + public function nuke(HttpResponse $response, HttpRequest $request, int $topicId) { + } + + public function bump(HttpResponse $response, HttpRequest $request, int $topicId) { + } + + public function lock(HttpResponse $response, HttpRequest $request, int $topicId) { + } + + public function legacy(HttpResponse $response, HttpRequest $request) { + $postId = (int)$request->getQueryParam('p', FILTER_SANITIZE_NUMBER_INT); + if($postId > 0) { + $response->redirect(url('forum-post', ['post' => $postId])); + return; + } + + $topicId = (int)$request->getQueryParam('t', FILTER_SANITIZE_NUMBER_INT); + if($topicId > 0) { + $response->redirect(url('forum-topic', ['topic' => $topicId])); + return; + } + + return 404; + } +} diff --git a/src/Http/Handlers/ForumHandler.php b/src/Http/Handlers/ForumHandler.php deleted file mode 100644 index 9afb40b1..00000000 --- a/src/Http/Handlers/ForumHandler.php +++ /dev/null @@ -1,32 +0,0 @@ -<?php -namespace Misuzu\Http\Handlers; - -use HttpResponse; -use HttpRequest; -use Misuzu\CSRF; -use Misuzu\Users\User; - -final class ForumHandler extends Handler { - public function markAsReadGET(HttpResponse $response, HttpRequest $request): void { - $forumId = (int)$request->getQueryParam('forum', FILTER_SANITIZE_NUMBER_INT); - $response->setTemplate('confirm', [ - 'title' => 'Mark forum as read', - 'message' => 'Are you sure you want to mark ' . ($forumId === null ? 'the entire' : 'this') . ' forum as read?', - 'return' => url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]), - 'params' => [ - 'forum' => $forumId, - ] - ]); - } - - public function markAsReadPOST(HttpResponse $response, HttpRequest $request) { - $forumId = (int)$request->getBodyParam('forum', FILTER_SANITIZE_NUMBER_INT); - forum_mark_read($forumId, User::getCurrent()->getId()); - - $response->redirect( - url($forumId ? 'forum-category' : 'forum-index', ['forum' => $forumId]), - false, - $request->hasHeader('X-Misuzu-XHR') - ); - } -} diff --git a/src/Http/Routing/Route.php b/src/Http/Routing/Route.php index ccccb784..6444d52e 100644 --- a/src/Http/Routing/Route.php +++ b/src/Http/Routing/Route.php @@ -19,7 +19,8 @@ class Route implements Serializable { public function __construct(array $methods, string $path, ?string $method = null, ?string $class = null) { $this->methods = array_map('strtoupper', $methods); $this->path = $path; - $this->handlerClass = $class; + if($class !== null) + $this->handlerClass = str_replace('.', '\\', $class); $this->handlerMethod = $method; } diff --git a/src/Memoizer.php b/src/Memoizer.php index ffcb3ad7..f6faa2ee 100644 --- a/src/Memoizer.php +++ b/src/Memoizer.php @@ -15,13 +15,20 @@ class Memoizer { if(is_callable($find)) { $item = array_find($this->collection, $find) ?? $create(); - if(method_exists($item, 'getId')) - $this->collection[$item->getId()] = $item; - else - $this->collection[] = $item; + if($item !== null) + $this->insert($item); return $item; } throw new InvalidArgumentException('Wasn\'t able to figure out your $find argument.'); } + + public function insert($item): void { + if($item === null) + throw new InvalidArgumentException('null'); + if(method_exists($item, 'getId')) + $this->collection[$item->getId()] = $item; + else + $this->collection[] = $item; + } } diff --git a/src/Pagination.php b/src/Pagination.php index 213d44a4..10042704 100644 --- a/src/Pagination.php +++ b/src/Pagination.php @@ -74,4 +74,60 @@ final class Pagination { return $default; } + + private const PAGE_RANGE = 5; + + public function render(string $pathOrName, array $params = [], string $pageParam = self::DEFAULT_PARAM, string $urlFragment = ''): string { + if($this->getPages() <= 1) + return ''; + + if($pathOrName[0] !== '/') + $pathOrName = url($pathOrName); + + $getUrl = function(int $page) use ($pathOrName, $params, $pageParam, $urlFragment) { + if($page <= 1) + unset($params[$pageParam]); + else + $params[$pageParam] = $page; + + $url = $pathOrName; + if(!empty($params)) + $url .= '?' . http_build_query($params); + if(!empty($urlFragment)) + $url .= '#' . rawurldecode($urlFragment); + + return $url; + }; + + $html = '<div class="pagination">'; + + $html .= '<div class="pagination__section pagination__section--first">'; + if($this->getPage() <= 1) { + $html .= '<div class="pagination__link pagination__link--first pagination__link--disabled"><i class="fas fa-angle-double-left"></i></div>'; + $html .= '<div class="pagination__link pagination__link--prev pagination__link--disabled"><i class="fas fa-angle-left"></i></div>'; + } else { + $html .= '<a href="' . $getUrl(1) . '" class="pagination__link pagination__link--first" rel="first"><i class="fas fa-angle-double-left"></i></a>'; + $html .= '<a href="' . $getUrl($this->getPage() - 1) . '" class="pagination__link pagination__link--prev" rel="prev"><i class="fas fa-angle-left"></i></a>'; + } + $html .= '</div>'; + + $html .= '<div class="pagination__section pagination__section--pages">'; + $start = max($this->getPage() - self::PAGE_RANGE, 1); + $stop = min($this->getPage() + self::PAGE_RANGE, $this->getPages()); + for($i = $start; $i <= $stop; ++$i) + $html .= '<a href="' . $getUrl($i) . '" class="pagination__link' . ($i === $this->getPage() ? ' pagination__link--current' : '') . '">' . number_format($i) . '</a>'; + $html .= '</div>'; + + $html .= '<div class="pagination__section pagination__section--last">'; + if($this->getPage() >= $this->getPages()) { + $html .= '<div class="pagination__link pagination__link--next pagination__link--disabled"><i class="fas fa-angle-right"></i></div>'; + $html .= '<div class="pagination__link pagination__link--last pagination__link--disabled"><i class="fas fa-angle-double-right"></i></div>'; + } else { + $html .= '<a href="' . $getUrl($this->getPage() + 1) . '" class="pagination__link pagination__link--next" rel="next"><i class="fas fa-angle-right"></i></a>'; + $html .= '<a href="' . $getUrl($this->getPages()) . '" class="pagination__link pagination__link--last" rel="last"><i class="fas fa-angle-double-right"></i></a>'; + } + $html .= '</div>'; + + return $html . '</div>'; + } } diff --git a/src/TwigMisuzu.php b/src/TwigMisuzu.php index 6dc30fde..fbdf2501 100644 --- a/src/TwigMisuzu.php +++ b/src/TwigMisuzu.php @@ -23,10 +23,6 @@ final class TwigMisuzu extends Twig_Extension { new Twig_Function('url_construct', 'url_construct'), new Twig_Function('url', 'url'), new Twig_Function('url_list', 'url_list'), - new Twig_Function('html_avatar', 'html_avatar'), - new Twig_Function('forum_may_have_children', 'forum_may_have_children'), - new Twig_Function('forum_may_have_topics', 'forum_may_have_topics'), - new Twig_Function('forum_has_priority_voting', 'forum_has_priority_voting'), new Twig_Function('csrf_token', fn() => CSRF::token()), new Twig_Function('git_commit_hash', fn(bool $long = false) => GitInfo::hash($long)), new Twig_Function('git_tag', fn() => GitInfo::tag()), diff --git a/src/Users/User.php b/src/Users/User.php index 419ff0b0..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; @@ -303,6 +304,11 @@ class User implements HasRankInterface, JsonSerializable { return Parser::instance($this->getForumSignatureParser()) ->parseText(htmlspecialchars($this->getForumSignatureText())); } + public function getForumSignatureClasses(): string { + if($this->getForumSignatureParser() === Parser::MARKDOWN) + return 'markdown'; + return ''; + } // Address these through getBackgroundInfo() public function getBackgroundSettings(): int { @@ -343,6 +349,17 @@ class User implements HasRankInterface, JsonSerializable { return intval($this->getBirthdate()->diff(new DateTime('now', new DateTimeZone('UTC')))->format('%y')); } + private $preferredParser = null; + public function getPreferredParser(): int { + if($this->preferredParser === null) + $this->preferredParser = DB::prepare( + 'SELECT `post_parse` FROM `msz_forum_posts`' + . ' WHERE `user_id` = :user AND `post_deleted` IS NULL' + . ' ORDER BY `post_id` DESC LIMIT 1' + )->bind('user', $this->getId())->fetchColumn() ?? Parser::BBCODE; + return $this->preferredParser; + } + public function profileFields(bool $filterEmpty = true): array { if(($userId = $this->getId()) < 1) return []; @@ -883,7 +900,7 @@ class User implements HasRankInterface, JsonSerializable { return $user; }); } - public static function byUsername(string $username): ?self { + public static function byUsername(string $username): self { $username = mb_strtolower($username); return self::memoizer()->find(function($user) use ($username) { return mb_strtolower($user->getUsername()) === $username; @@ -896,7 +913,7 @@ class User implements HasRankInterface, JsonSerializable { return $user; }); } - public static function byEMailAddress(string $address): ?self { + public static function byEMailAddress(string $address): self { $address = mb_strtolower($address); return self::memoizer()->find(function($user) use ($address) { return mb_strtolower($user->getEmailAddress()) === $address; @@ -928,7 +945,7 @@ class User implements HasRankInterface, JsonSerializable { return DB::prepare(self::byQueryBase() . ' WHERE `user_deleted` IS NULL ORDER BY `user_id` DESC LIMIT 1') ->fetchObject(self::class); } - public static function findForProfile($userIdOrName): ?self { + public static function findForProfile($userIdOrName): self { $userIdOrNameLower = mb_strtolower($userIdOrName); return self::memoizer()->find(function($user) use ($userIdOrNameLower) { return $user->getId() == $userIdOrNameLower || mb_strtolower($user->getUsername()) === $userIdOrNameLower; diff --git a/src/url.php b/src/url.php index 7a2fdfaf..9def9439 100644 --- a/src/url.php +++ b/src/url.php @@ -45,7 +45,7 @@ define('MSZ_URLS', [ 'forum-mark-single' => ['/forum/mark-as-read', ['forum' => '<forum>']], 'forum-topic-new' => ['/forum/posting.php', ['f' => '<forum>']], 'forum-reply-new' => ['/forum/posting.php', ['t' => '<topic>']], - 'forum-category' => ['/forum/forum.php', ['f' => '<forum>', 'p' => '<page>']], + 'forum-category' => ['/forum/<forum>', ['p' => '<page>']], 'forum-topic' => ['/forum/topic.php', ['t' => '<topic>', 'page' => '<page>']], 'forum-topic-create' => ['/forum/posting.php', ['f' => '<forum>']], 'forum-topic-bump' => ['/forum/topic.php', ['t' => '<topic>', 'm' => 'bump', 'csrf' => '{csrf}']], @@ -55,6 +55,7 @@ define('MSZ_URLS', [ 'forum-topic-restore' => ['/forum/topic.php', ['t' => '<topic>', 'm' => 'restore', 'csrf' => '{csrf}']], 'forum-topic-nuke' => ['/forum/topic.php', ['t' => '<topic>', 'm' => 'nuke', 'csrf' => '{csrf}']], 'forum-topic-priority' => ['/forum/topic-priority.php', ['t' => '<topic>', 'b' => '<bump>']], + 'forum-topic-live' => ['/forum/topic/<topic>/live', ['since' => '<post>']], 'forum-post' => ['/forum/topic.php', ['p' => '<post>'], '<post_fragment>'], 'forum-post-create' => ['/forum/posting.php', ['t' => '<topic>']], 'forum-post-delete' => ['/forum/post.php', ['p' => '<post>', 'm' => 'delete']], diff --git a/templates/changelog/index.twig b/templates/changelog/index.twig index fb766f8c..75cddfc0 100644 --- a/templates/changelog/index.twig +++ b/templates/changelog/index.twig @@ -1,5 +1,5 @@ {% extends 'changelog/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} {% from 'changelog/macros.twig' import changelog_listing %} {% from '_layout/comments.twig' import comments_section %} @@ -39,7 +39,7 @@ {% if not is_date %} <div class="changelog__pagination"> - {{ pagination(changelog_pagination, url('changelog-index'), null, {'date':changelog_date_fmt, 'user':changelog_user.id|default(0)})}} + {{ changelog_pagination.render('changelog-index', {'date':changelog_date_fmt, 'user':changelog_user.id|default(0)})|raw }} </div> {% endif %} </div> diff --git a/templates/forum/forum.twig b/templates/forum/forum.twig index 86186c95..09c0bd34 100644 --- a/templates/forum/forum.twig +++ b/templates/forum/forum.twig @@ -1,32 +1,30 @@ {% extends 'forum/master.twig' %} {% from 'forum/macros.twig' import forum_category_listing, forum_topic_listing, forum_category_buttons, forum_header, forum_category_tools %} -{% set title = forum_info.forum_name %} +{% set title = forum_info.name %} {% set canonical_url = url('forum-category', { - 'forum': forum_info.forum_id, + 'forum': forum_info.id, 'page': forum_pagination.page|default(0) > 1 ? forum_pagination.page : 0, }) %} {% block content %} - {{ forum_header(forum_info.forum_name, forum_breadcrumbs, true, canonical_url, [ + {{ forum_header(forum_info, true, canonical_url, [ { 'html': '<i class="far fa-check-circle"></i> Mark as Read', - 'url': url('forum-mark-single', {'forum': forum_info.forum_id}), + 'url': url('forum-mark-single', {'forum': forum_info.id}), 'display': current_user is defined, 'method': 'POST', } ]) }} - {% if forum_may_have_children and forum_info.forum_subforums|length > 0 %} - {{ forum_category_listing(forum_info.forum_subforums, 'Forums') }} - {% endif %} + {{ forum_category_listing(forum_info, current_user, 'Forums') }} - {% if forum_may_have_topics %} + {% if forum_info.canHaveTopics %} {% set category_tools = forum_category_tools(forum_info, forum_perms, forum_pagination) %} {{ category_tools }} - {{ forum_topic_listing(forum_topics) }} + {{ forum_topic_listing(forum_info.topics(can_view_deleted, forum_pagination), null, current_user) }} {{ category_tools }} {% endif %} - {{ forum_header('', forum_breadcrumbs) }} + {{ forum_header(forum_info, false) }} {% endblock %} diff --git a/templates/forum/index.twig b/templates/forum/index.twig index e80ebcc8..638ba70b 100644 --- a/templates/forum/index.twig +++ b/templates/forum/index.twig @@ -3,21 +3,21 @@ {% from 'forum/macros.twig' import forum_category_listing %} {% set title = 'Forum Listing' %} -{% set canonical_url = '/forum/' %} +{% set canonical_url = url('forum-index') %} {% block content %} - {% if not forum_empty %} - {% for category in forum_categories %} - {% if category.forum_children > 0 %} - {{ forum_category_listing( - category.forum_subforums, - category.forum_name, - category.forum_colour, - category.forum_id == constant('MSZ_FORUM_ROOT') - ? '' - : 'f' ~ category.forum_id, - category.forum_icon|default('') - ) }} + {% if forum_root.children|length is empty %} + <div class="container"> + {{ container_title('<i class="fas fa-comment-slash fa-fw"></i> Forums') }} + + <div class="container__content"> + <p>There are currently no visible forums.</p> + </div> + </div> + {% else %} + {% for category in forum_root.children %} + {% if category.children is not empty %} + {{ forum_category_listing(category, current_user|default(null)) }} {% endif %} {% endfor %} @@ -26,13 +26,5 @@ <a href="{{ url('forum-mark-global') }}" class="input__button forum__actions__button" data-msz-method="POST">Mark All Read</a> </div> {% endif %} - {% else %} - <div class="container"> - {{ container_title('<i class="fas fa-comment-slash fa-fw"></i> Forums') }} - - <div class="container__content"> - <p>There are currently no visible forums.</p> - </div> - </div> {% endif %} {% endblock %} diff --git a/templates/forum/leaderboard.twig b/templates/forum/leaderboard.twig index 2a7642aa..4c73f75e 100644 --- a/templates/forum/leaderboard.twig +++ b/templates/forum/leaderboard.twig @@ -1,6 +1,5 @@ {% extends 'forum/master.twig' %} {% from 'macros.twig' import avatar %} -{% from 'forum/macros.twig' import forum_header %} {% set title = 'Forum Leaderboard » ' ~ leaderboard_name %} {% set canonical_url = url('forum-leaderboard', { @@ -9,18 +8,23 @@ }) %} {% block content %} - {{ forum_header(title, [], false, canonical_url, [ - { - 'html': '<i class="fab fa-markdown fa-fw"></i> Markdown', - 'url': url('forum-leaderboard', {'id': leaderboard_id, 'mode': 'markdown'}), - 'display': leaderboard_mode != 'markdown', - }, - { - 'html': '<i class="fas fa-table fa-fw"></i> Table', - 'url': url('forum-leaderboard', {'id': leaderboard_id}), - 'display': leaderboard_mode == 'markdown', - }, - ]) }} + <div class="container forum__header"> + <a class="forum__header__title" href="{{ canonical_url }}"> + {{ title }} + </a> + + <div class="forum__header__actions"> + {% if leaderboard_mode == 'markdown' %} + <a class="forum__header__action" href="{{ url('forum-leaderboard', {'id': leaderboard_id}) }}"> + <i class="fas fa-table fa-fw"></i> Table + </a> + {% else %} + <a class="forum__header__action" href="{{ url('forum-leaderboard', {'id': leaderboard_id, 'mode': 'markdown'}) }}"> + <i class="fas fa-markdown fa-fw"></i> Markdown + </a> + {% endif %} + </div> + </div> <div class="container forum__leaderboard__categories"> {% for id, name in leaderboard_categories %} diff --git a/templates/forum/macros.twig b/templates/forum/macros.twig index adca2fb9..25d05cdf 100644 --- a/templates/forum/macros.twig +++ b/templates/forum/macros.twig @@ -1,4 +1,44 @@ {% macro forum_category_listing(forums, title, colour, id, icon) %} + {% if forums is iterable %} + {% from _self import forum_category_listing_old %} + {{ forum_category_listing_old(forums, title, colour, id, icon) }} + {% else %} + {% from _self import forum_category_listing_new %} + {{ forum_category_listing_new(forums, title, colour) }} + {% endif %} +{% endmacro %} + +{% macro forum_category_listing_new(category, user, title) %} + {% from _self import forum_category_entry %} + {% from 'macros.twig' import container_title %} + + {% if category.canView(user) %} + {% set children = category.children(user) %} + + {% if children is not empty or category.isCategoryForum %} + {% set icon = title is not empty ? 'fas fa-folder fa-fw' : category.icon %} + <div class="container forum__categories" + {% if not category.colour.inherit %}style="--accent-colour: {{ category.colour }}"{% endif %} + {% if not category.isRoot %}id="{{ category.id }}"{% endif %}> + {{ container_title('<span class="' ~ icon ~ '"></span> ' ~ title|default(category.name)) }} + + {% if children is empty %} + <div class="forum__categories__empty"> + This category is empty. + </div> + {% else %} + <div class="forum__categories__list"> + {% for category in children %} + {{ forum_category_entry(category, user) }} + {% endfor %} + </div> + {% endif %} + </div> + {% endif %} + {% endif %} +{% endmacro %} + +{% macro forum_category_listing_old(forums, title, colour, id, icon) %} {% from _self import forum_category_entry %} {% from 'macros.twig' import container_title %} @@ -21,32 +61,29 @@ </div> {% endmacro %} -{% macro forum_header(title, breadcrumbs, omit_last_breadcrumb, title_url, actions) %} +{% macro forum_header(info, is_top, title_url, actions, is_topic, title) %} <div class="container forum__header"> - {% if breadcrumbs is iterable and breadcrumbs|length > 0 %} - <div class="forum__header__breadcrumbs"> - {% for name, url in breadcrumbs %} - {% if url != breadcrumbs|first %} - <div class="forum__header__breadcrumb__separator"> - <i class="fas fa-chevron-right"></i> - </div> - {% endif %} + {% set parents = info.parentTree %} + <div class="forum__header__breadcrumbs"> + {% for parent in parents %} + <a href="{{ parent.url }}" class="forum__header__breadcrumb">{{ parent.name }}</a> + <div class="forum__header__breadcrumb__separator"> + <i class="fas fa-chevron-right"></i> + </div> + {% endfor %} + {% if not is_top or is_topic %} + <a href="{{ info.url }}" class="forum__header__breadcrumb">{{ info.name }}</a> + {% endif %} + </div> - {% if not (omit_last_breadcrumb|default(false) and url == breadcrumbs|last) %} - <a href="{{ url }}" class="forum__header__breadcrumb">{{ name }}</a> - {% endif %} - {% endfor %} - </div> - {% endif %} - - {% if title|length > 0 %} + {% if is_top %} {% if title_url|length > 0 %} <a class="forum__header__title" href="{{ title_url }}"> - {{ title }} + {{ title|default(info.name) }} </a> {% else %} <div class="forum__header__title forum__header__title--fill"> - {{ title }} + {{ title|default(info.name) }} </div> {% endif %} {% endif %} @@ -65,49 +102,145 @@ </div> {% endmacro %} -{% macro forum_category_tools(info, perms, pagination_info) %} - {% from 'macros.twig' import pagination %} - - {% set is_locked = info.forum_archived != 0 %} - {% set can_topic = not is_locked and perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_TOPIC')) %} - {% set pag = pagination(pagination_info, url('forum-category'), null, {'f': info.forum_id}) %} +{% macro forum_category_tools(category, perms, pagination_info) %} + {% set can_topic = not category.isArchived and perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_TOPIC')) %} + {% set pag = pagination_info.render(url('forum-category', {'forum': category.id})) %} {% if can_topic or pag|trim|length > 0 %} <div class="container forum__actions"> <div class="forum__actions__buttons"> {% if can_topic %} - <a href="{{ url('forum-topic-new', {'forum': info.forum_id}) }}" class="input__button forum__actions__button">{{ info.forum_type == constant('MSZ_FORUM_TYPE_FEATURE') ? 'New Request' : 'New Topic' }}</a> + <a href="{{ url('forum-topic-new', {'forum': category.id}) }}" class="input__button forum__actions__button">{{ category.canHavePriorityVotes ? 'New Request' : 'New Topic' }}</a> {% endif %} </div> <div class="forum__actions__pagination"> - {{ pag }} + {{ pag|raw }} </div> </div> {% endif %} {% endmacro %} {% macro forum_topic_tools(info, pagination_info, can_reply) %} - {% from 'macros.twig' import pagination %} + {% set pag = pagination_info.render('forum-topic', {'t': info.topic_id|default(info.id)}, 'page') %} - {% set pag = pagination(pagination_info, url('forum-topic'), null, {'t': info.topic_id}, 'page') %} - - {% if can_reply or pag|trim|length > 0 %} + {% if can_reply or pag|length > 0 %} <div class="container forum__actions"> <div class="forum__actions__buttons"> {% if can_reply %} - <a href="{{ url('forum-reply-new', {'topic': info.topic_id}) }}" class="input__button">Reply</a> + <a href="{{ url('forum-reply-new', {'topic': info.topic_id|default(info.id)}) }}" class="input__button">Reply</a> {% endif %} </div> <div class="forum__actions__pagination"> - {{ pag }} + {{ pag|raw }} </div> </div> {% endif %} {% endmacro %} {% macro forum_category_entry(forum, forum_unread, forum_icon) %} + {% if forum.forum_id is defined %} + {% from _self import forum_category_entry_old %} + {{ forum_category_entry_old(forum, forum_unread, forum_icon) }} + {% else %} + {% from _self import forum_category_entry_new %} + {{ forum_category_entry_new(forum, forum_unread) }} + {% endif %} +{% endmacro %} + +{% macro forum_category_entry_new(category, user) %} + {% from 'macros.twig' import avatar %} + + <div class="forum__category"> + <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--{{ user is null or category.hasRead(user) ? '' : 'un' }}read"> + <span class="{{ category.icon }}"></span> + </div> + + <div class="forum__category__details"> + <div class="forum__category__title"> + {{ category.name }} + </div> + + {% if category.hasDescription %} + <div class="forum__category__description"> + {{ category.parsedDescription|raw }} + </div> + {% endif %} + + {% if category.children is not empty %} + <div class="forum__category__subforums"> + {% for child in category.children %} + {% if child.canView(user) %} + <a href="{{ url('forum-category', {'forum': child.id}) }}" + class="forum__category__subforum{% if user is not null and not child.hasRead(user) %} forum__category__subforum--unread{% endif %}"> + {{ child.name }} + </a> + {% endif %} + {% endfor %} + </div> + {% endif %} + </div> + + {% if category.isLink %} + {% if category.shouldCountLinkClicks %} + <div class="forum__category__stats"> + <div class="forum__category__stat" title="Clicks">{{ category.linkClicks|number_format }}</div> + </div> + {% endif %} + {% elseif category.canHaveChildren %} + <div class="forum__category__stats"> + <div class="forum__category__stat" title="Topics">{{ category.topicCount|number_format }}</div> + <div class="forum__category__stat" title="Posts">{{ category.postCount|number_format }}</div> + </div> + {% endif %} + + {% if category.canHaveTopics or category.shouldCountLinkClicks %} + <div class="forum__category__activity{% if category.shouldCountLinkClicks %} forum__category__activity--empty{% endif %}"> + {% if not category.isLink %} + {% set topic = category.latestTopic(user) %} + {% set post = topic.lastPost|default(null) %} + {% if topic is empty or topic.lastPost is empty %} + <div class="forum__category__activity__none"> + There are no posts in this forum yet. + </div> + {% else %} + <div class="forum__category__activity__details"> + <a class="forum__category__activity__post" + href="{{ url('forum-post', {'post': post.id, 'post_fragment': 'p' ~ post.id}) }}"> + {{ topic.title }} + </a> + + <div class="forum__category__activity__info"> + <time datetime="{{ post.createdTime|date('c') }}" + title="{{ post.createdTime|date('r') }}">{{ post.createdTime|time_diff }}</time> + {% if post.hasUser %} + by + <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="forum__category__username" + style="--user-colour: {{ post.user.colour }}"> + {{ post.user.username }} + </a> + {% endif %} + </div> + </div> + + {% if post.hasUser %} + <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="avatar forum__category__avatar"> + {{ avatar(post.user.id, 40, post.user.username) }} + </a> + {% endif %} + {% endif %} + {% endif %} + </div> + {% endif %} + </div> + </div> +{% endmacro %} + +{% macro forum_category_entry_old(forum, forum_unread, forum_icon) %} {% from 'macros.twig' import avatar %} {% set forum_unread = forum_unread|default(forum.forum_unread|default(false)) ? 'unread' : 'read' %} @@ -116,12 +249,12 @@ {% set forum_icon = forum.forum_icon %} {% elseif forum.forum_archived is defined and forum.forum_archived %} {% set forum_icon = 'fas fa-archive fa-fw' %} - {% elseif forum.forum_type is defined and forum.forum_type != constant('MSZ_FORUM_TYPE_DISCUSSION') %} - {% if forum.forum_type == constant('MSZ_FORUM_TYPE_FEATURE') %} + {% elseif forum.forum_type is defined and forum.forum_type != constant('\\Misuzu\\Forum\\ForumCategory::TYPE_DISCUSSION') %} + {% if forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_FEATURE') %} {% set forum_icon = 'fas fa-star fa-fw' %} - {% elseif forum.forum_type == constant('MSZ_FORUM_TYPE_LINK') %} + {% elseif forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_LINK') %} {% set forum_icon = 'fas fa-link fa-fw' %} - {% elseif forum.forum_type == constant('MSZ_FORUM_TYPE_CATEGORY') %} + {% elseif forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_CATEGORY') %} {% set forum_icon = 'fas fa-folder fa-fw' %} {% endif %} {% else %} @@ -158,22 +291,22 @@ {% endif %} </div> - {% if forum.forum_type == constant('MSZ_FORUM_TYPE_LINK') %} + {% if forum.forum_type == constant('\\Misuzu\\Forum\\ForumCategory::TYPE_LINK') %} {% if forum.forum_link_clicks is not null %} <div class="forum__category__stats"> <div class="forum__category__stat" title="Clicks">{{ forum.forum_link_clicks|number_format }}</div> </div> {% endif %} - {% elseif forum_may_have_children(forum.forum_type) %} + {% elseif forum.forum_type in constant('\\Misuzu\\Forum\\ForumCategory::HAS_CHILDREN') %} <div class="forum__category__stats"> <div class="forum__category__stat" title="Topics">{{ forum.forum_count_topics|number_format }}</div> <div class="forum__category__stat" title="Posts">{{ forum.forum_count_posts|number_format }}</div> </div> {% endif %} - {% if forum_may_have_topics(forum.forum_type) or forum.forum_link_clicks is not null %} + {% if forum.forum_type in constant('\\Misuzu\\Forum\\ForumCategory::HAS_TOPICS') or forum.forum_link_clicks is not null %} <div class="forum__category__activity{% if forum.forum_link_clicks is not null %} forum__category__activity--empty{% endif %}"> - {% if forum.forum_type != constant('MSZ_FORUM_TYPE_LINK') %} + {% if forum.forum_type != constant('\\Misuzu\\Forum\\ForumCategory::TYPE_LINK') %} {% if forum.recent_topic_id is not defined %} <div class="forum__category__activity__none"> There are no posts in this forum yet. @@ -212,7 +345,7 @@ {% endmacro %} {% macro forum_topic_locked(locked, archived) %} - {% if locked is not null or archived %} + {% if locked|default(0) > 0 is not null or archived %} <div class="container forum__status"> <div class="forum__status__icon"> <div class="forum__status__icon__background"></div> @@ -223,16 +356,14 @@ This topic has been <span class="forum__status__emphasis">archived</span>. {% else %} This topic was locked - <time class="forum__status__emphasis" - datetime="{{ locked|date('c') }}" - title="{{ locked|date('r') }}">{{ locked|time_diff }}</time>. + <time class="forum__status__emphasis" datetime="{{ locked|date('c') }}" title="{{ locked|date('r') }}">{{ locked|time_diff }}</time>. {% endif %} </div> </div> {% endif %} {% endmacro %} -{% macro forum_topic_listing(topics, title) %} +{% macro forum_topic_listing(topics, title, user) %} {% from _self import forum_topic_entry %} {% from 'macros.twig' import container_title %} @@ -242,7 +373,7 @@ <div class="forum__topics__list"> {% if topics|length > 0 %} {% for topic in topics %} - {{ forum_topic_entry(topic) }} + {{ forum_topic_entry(topic, user) }} {% endfor %} {% else %} <div class="forum__topics__empty"> @@ -254,18 +385,122 @@ {% endmacro %} {% macro forum_topic_entry(topic, topic_icon, topic_unread) %} + {% if topic.getId is defined %} + {% from _self import forum_topic_entry_new %} + {{ forum_topic_entry_new(topic, topic_icon) }} + {% else %} + {% from _self import forum_topic_entry_old %} + {{ forum_topic_entry_old(topic, topic_icon, topic_unread) }} + {% endif %} +{% endmacro %} + +{% macro forum_topic_entry_new(topic, user) %} + {% from 'macros.twig' import avatar %} + + <div class="forum__topic{% if topic.deleted %} forum__topic--deleted{% elseif topic.locked and not topic.important %} forum__topic--locked{% endif %}"> + <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--{{ 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 %} + <div class="forum__topic__icon__priority">{{ topic.priority|number_format }}</div> + {% endif %} + + {% if topic.hasParticipated(user) %} + <div class="forum__topic__icon__participated" title="You have posted in this topic"></div> + {% endif %} + </div> + + <div class="forum__topic__details"> + <div class="forum__topic__title"> + <span class="forum__topic__title__inner"> + {{ topic.title }} + </span> + </div> + + <div class="forum__topic__info"> + {% if topic.user is not empty %} + by <a href="{{ url('user-profile', {'user': topic.user.id}) }}" + class="forum__topic__username" + style="--user-colour: {{ topic.user.colour }}">{{ topic.user.username }}</a>, + + {% endif %} + <time datetime="{{ topic.createdTime|date('c') }}" title="{{ topic.createdTime|date('r') }}">{{ topic.createdTime|time_diff }}</time> + </div> + + {% set pages = topic.pageCount %} + + {% if pages > 1 %} + <div class="forum__topic__pagination"> + {% for i in 1..pages|clamp(0, 3) %} + <a href="{{ url('forum-topic', {'topic': topic.id, 'page': i}) }}" class="forum__topic__pagination__item"> + {{ i }} + </a> + {% endfor %} + {% if pages > 3 %} + {% if pages > 6 %} + <div class="forum__topic__pagination__separator"> + <i class="fas fa-ellipsis-h"></i> + </div> + {% endif %} + + {% for i in (pages - 2)|clamp(4, pages)..pages %} + <a href="{{ url('forum-topic', {'topic': topic.id, 'page': i}) }}" class="forum__topic__pagination__item"> + {{ i }} + </a> + {% endfor %} + {% endif %} + </div> + {% endif %} + </div> + + <div class="forum__topic__stats"> + <div class="forum__topic__stat" title="Posts">{{ topic.postCount|number_format }}</div> + <div class="forum__topic__stat" title="Views">{{ topic.viewCount|number_format }}</div> + </div> + + <div class="forum__topic__activity"> + {% set post = topic.lastPost %} + {% if post is not null %} + <div class="forum__topic__activity__details"> + {% if post.user is not empty %} + <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="forum__topic__username" + style="--user-colour: {{ post.user.colour }}">{{ post.user.username }}</a> + {% endif %} + + <a class="forum__topic__activity__post" + href="{{ url('forum-post', {'post': post.id, 'post_fragment': 'p' ~ post.id}) }}"> + <time datetime="{{ post.createdTime|date('c') }}" + title="{{ post.createdTime|date('r') }}">{{ post.createdTime|time_diff }}</time> + </a> + </div> + + {% if post.user is not empty %} + <a href="{{ url('user-profile', {'user': post.user.id}) }}" class="forum__topic__avatar"> + {{ avatar(post.user.id, 30, post.user.username) }} + </a> + {% endif %} + {% endif %} + </div> + </div> + </div> +{% endmacro %} + +{% macro forum_topic_entry_old(topic, topic_icon, topic_unread) %} {% from 'macros.twig' import avatar %} {% set topic_unread = topic_unread|default(topic.topic_unread|default(false)) %} - {% set topic_important = topic.topic_type == constant('MSZ_TOPIC_TYPE_STICKY') or topic.topic_type == constant('MSZ_TOPIC_TYPE_ANNOUNCEMENT') or topic.topic_type == constant('MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT') %} - {% set has_priority_voting = forum_has_priority_voting(topic.forum_type) %} + {% set topic_important = topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_STICKY') or topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_ANNOUNCEMENT') or topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_GLOBAL_ANNOUNCEMENT') %} + {% set has_priority_voting = topic.forum_type in constant('\\Misuzu\\Forum\\ForumCategory::HAS_PRIORITY_VOTES') %} {% if topic_icon is null %} {% if topic.topic_deleted is defined and topic.topic_deleted is not null %} {% set topic_icon = 'fas fa-trash-alt' %} - {% elseif topic.topic_type is defined and topic.topic_type != constant('MSZ_TOPIC_TYPE_DISCUSSION') %} - {% if topic.topic_type == constant('MSZ_TOPIC_TYPE_ANNOUNCEMENT') or topic.topic_type == constant('MSZ_TOPIC_TYPE_GLOBAL_ANNOUNCEMENT') %} + {% elseif topic.topic_type is defined and topic.topic_type != constant('\\Misuzu\\Forum\\ForumTopic::DISCUSSION') %} + {% if topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::TYPE_ANNOUNCEMENT') or topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::GLOBAL_ANNOUNCEMENT') %} {% set topic_icon = 'fas fa-bullhorn' %} - {% elseif topic.topic_type == constant('MSZ_TOPIC_TYPE_STICKY') %} + {% elseif topic.topic_type == constant('\\Misuzu\\Forum\\ForumTopic::STICKY') %} {% set topic_icon = 'fas fa-thumbtack' %} {% endif %} {% elseif topic.topic_locked is defined and topic.topic_locked is not null %} @@ -373,6 +608,114 @@ {% endmacro %} {% macro forum_post_entry(post, user_id, perms) %} + {% if post.getId is defined %} + {% from _self import forum_post_entry_new %} + {{ forum_post_entry_new(post, user_id, perms) }} + {% else %} + {% from _self import forum_post_entry_old %} + {{ forum_post_entry_old(post, user_id, perms) }} + {% endif %} +{% endmacro %} + +{% macro forum_post_entry_new(post, user, perms) %} + {% from 'macros.twig' import avatar %} + {% set can_post = perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_POST')) %} + {% set can_edit = perms|perms_check(constant('MSZ_FORUM_PERM_EDIT_ANY_POST')) or ( + perms|perms_check(constant('MSZ_FORUM_PERM_EDIT_POST')) + and post.canBeEdited(user) + ) %} + {% set can_delete = not post.isOpeningPost and ( + perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_ANY_POST')) or ( + perms|perms_check(constant('MSZ_FORUM_PERM_DELETE_POST')) + and post.canBeDeleted(user) + ) + ) %} + + <div class="container forum__post{% if post.deleted %} forum__post--deleted{% endif %}" id="p{{ post.id }}" style="{% if post.hasUser %}--accent-colour: {{ post.user.colour }}{% endif %}"> + <div class="forum__post__info"> + <div class="forum__post__info__background"></div> + <div class="forum__post__info__content"> + {% if post.hasUser %} + <a class="forum__post__avatar" href="{{ url('user-profile', {'user': post.user.id}) }}"> + {{ avatar(post.user.id, 120, post.user.username) }} + </a> + + <a class="forum__post__username" href="{{ url('user-profile', {'user': post.user.id}) }}">{{ post.user.username }}</a> + + {% if post.user.hasTitle %} + <div class="forum__post__usertitle">{{ post.user.title }}</div> + {% endif %} + + <div class="forum__post__icons"> + <div class="flag flag--{{ post.user.country|lower }}" title="{{ post.user.countryName }}"></div> + <div class="forum__post__posts-count">{{ post.user.forumPostCount|number_format }} posts</div> + </div> + + {% if post.isTopicAuthor %} + <div class="forum__post__badge forum__post__badge--original-poster"> + <div class="forum__post__badge__desktop">Original Poster</div> + <div class="forum__post__badge__mobile">OP</div> + </div> + {% endif %} + + <div class="forum__post__joined"> + joined <time datetime="{{ post.user.createdTime|date('c') }}" title="{{ post.user.createdTime|date('r') }}">{{ post.user.createdTime|time_diff }}</time> + </div> + {% else %} + <div class="forum__post__username">Deleted User</div> + {% endif %} + </div> + </div> + + <div class="forum__post__content"> + {% set post_link = url(post.isOpeningPost ? 'forum-topic' : 'forum-post', {'topic': post.topic.id, 'post': post.id, 'post_fragment': 'p%d'|format(post.id)}) %} + + <div class="forum__post__details"> + <a class="forum__post__datetime" href="{{ post_link }}"> + <time datetime="{{ post.createdTime|date('c') }}" title="{{ post.createdTime|date('r') }}">{{ post.createdTime|time_diff }}</time> + {% if post.isEdited %} + (edited <time datetime="{{ post.editedTime|date('c') }}" title="{{ post.editedTime|date('r') }}">{{ post.editedTime|time_diff }}</time>) + {% endif %} + </a> + + <a class="forum__post__id" href="{{ post_link }}"> + #{{ post.id }} + </a> + </div> + + <div class="forum__post__text {{ post.bodyClasses }}"> + {{ post.parsedBody|raw }} + </div> + + {% if can_post or can_edit or can_delete %} + <div class="forum__post__actions"> + {% if post.deleted %} + <a href="{{ url('forum-post-restore', {'post': post.id}) }}" class="forum__post__action forum__post__action--restore"><i class="fas fa-magic fa-fw"></i> Restore</a> + <a href="{{ url('forum-post-nuke', {'post': post.id}) }}" class="forum__post__action forum__post__action--nuke"><i class="fas fa-radiation-alt fa-fw"></i> Permanently Delete</a> + {% else %} + {# if can_post %} + <a href="{{ url('forum-post-quote', {'post': post.id}) }}" class="forum__post__action forum__post__action--quote"><i class="fas fa-quote-left fa-fw"></i> Quote</a> + {% endif #} + {% if can_edit %} + <a href="{{ url('forum-post-edit', {'post': post.id}) }}" class="forum__post__action forum__post__action--edit"><i class="fas fa-edit fa-fw"></i> Edit</a> + {% endif %} + {% if can_delete %} + <a href="{{ url('forum-post-delete', {'post': post.id}) }}" class="forum__post__action forum__post__action--delete"><i class="far fa-trash-alt fa-fw"></i> Delete</a> + {% endif %} + {% endif %} + </div> + {% endif %} + + {% if post.shouldDisplaySignature and post.user.hasForumSignature %} + <div class="forum__post__signature {{ post.user.forumSignatureClasses }}"> + {{ post.user.forumSignatureParsed|raw }} + </div> + {% endif %} + </div> + </div> +{% endmacro %} + +{% macro forum_post_entry_old(post, user_id, perms) %} {% from 'macros.twig' import avatar %} {% set is_deleted = post.post_deleted is not null %} {% set can_post = perms|perms_check(constant('MSZ_FORUM_PERM_CREATE_POST')) %} @@ -384,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') ) ) %} @@ -472,116 +815,111 @@ </div> {% endmacro %} -{% macro forum_poll(poll, options, user_answers, topic_id, can_vote, preview_results) %} +{% macro forum_poll(poll, user) %} {% from '_layout/input.twig' import input_csrf, input_hidden, input_checkbox, input_checkbox_raw %} - {% set user_answers = user_answers is empty or user_answers is not iterable ? [] : user_answers %} - {% set user_answered = user_answers|length > 0 %} - {% set results_available = preview_results or user_answered or poll.poll_expired or poll.poll_preview_results %} - {% set options_available = not poll.poll_expired and (poll.poll_change_vote or not user_answered) %} - {% set display_results = user_answered or poll.poll_expired %} - {% if options is iterable and options|length > 0 %} - <div class="forum__poll"> - {% if results_available %} - {% if options_available %} - {{ input_checkbox_raw('', display_results, 'forum__poll__toggle', '', false, {'id':'forum-poll-toggle'}) }} + {% set can_vote = poll.canVoteOnPoll(user) %} + {% set results_available = poll.hasVoted(user) or poll.hasExpired or poll.canPreviewResults %} + {% set options_available = not poll.hasExpired and (poll.canChangeVote or not poll.hasVoted(user)) %} + {% set display_results = poll.hasVoted(user) or poll.hasExpired %} + + <div class="forum__poll"> + {% if results_available %} + {% if options_available %} + {{ input_checkbox_raw('', display_results, 'forum__poll__toggle', '', false, {'id':'forum-poll-toggle'}) }} + {% endif %} + + <div class="container forum__poll__container forum__poll__container--results"> + <div class="forum__poll__results"> + {% for option in poll.options %} + <div class="forum__poll__result{% if option.hasVotedFor(user) %} forum__poll__result--voted{% endif %}"> + <div class="forum__poll__result__background" style="width: {{ option.percentage * 100 }}%"></div> + <div class="forum__poll__result__container"> + <div class="forum__poll__result__text">{{ option.text }}</div> + <div class="forum__poll__result__votes">{{ option.votes|number_format }}</div> + <div class="forum__poll__result__percent">{{ (option.percentage * 100)|number_format(2) }}%</div> + </div> + </div> + {% endfor %} + </div> + + <div class="forum__poll__remaining"> + This poll got <span class="forum__poll__remaining__num">{{ poll.votes|number_format }} vote{{ poll.votes == 1 ? '' : 's' }}</span> + </div> + + {% if poll.canExpire %} + <div class="forum__poll__expires"> + Polling {{ poll.hasExpired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.expiresTime|date('c') }}" title="{{ poll.expiresTime|date('r') }}">{{ poll.expiresTime|time_diff }}</time>. + </div> {% endif %} - <div class="container forum__poll__container forum__poll__container--results"> - <div class="forum__poll__results"> - {% for option in options %} - {% set percent = poll.poll_votes < 1 ? 0 : (option.option_votes / poll.poll_votes) * 100 %} - - <div class="forum__poll__result{% if option.option_id in user_answers %} forum__poll__result--voted{% endif %}"> - <div class="forum__poll__result__background" style="width: {{ percent }}%"> - </div> - <div class="forum__poll__result__container"> - <div class="forum__poll__result__text">{{ option.option_text }}</div> - <div class="forum__poll__result__votes">{{ option.option_votes|number_format }}</div> - <div class="forum__poll__result__percent">{{ percent|number_format(2) }}%</div> - </div> - </div> - {% endfor %} + {% if options_available %} + <div class="forum__poll__buttons"> + <label class="input__button forum__poll__button" for="forum-poll-toggle">Vote</label> </div> + {% endif %} + </div> + {% endif %} - <div class="forum__poll__remaining"> - This poll got <span class="forum__poll__remaining__num">{{ poll.poll_votes|number_format }} vote{{ poll.poll_votes == 1 ? '' : 's' }}</span> + {% if options_available %} + <form method="post" action="{{ url('forum-poll-vote') }}" class="container forum__poll__container forum__poll__container--poll js-forum-poll" + data-poll-id="{{ poll.id }}" data-poll-max-votes="{{ poll.maxVotes }}"> + {{ input_csrf() }} + {{ input_hidden('poll[id]', poll.id) }} + + <div class="forum__poll__options"> + {% for option in poll.options %} + {{ input_checkbox( + 'poll[answers][]', + option.text, option.hasVotedFor(user), 'forum__poll__option', + option.id, poll.maxVotes <= 1, + null, not can_vote + ) }} + {% endfor %} + </div> + + {% if can_vote and poll.maxVotes > 1 %} + <div class="forum__poll__remaining js-forum-poll-remaining"> + You have <span class="forum__poll__remaining__num"> + <span class="js-forum-poll-remaining-count">{{ poll.maxVotes }}</span> vote<span class="js-forum-poll-remaining-plural">s</span> + </span> remaining. </div> + {% endif %} - {% if poll.poll_expires is not null %} - <div class="forum__poll__expires"> - Polling {{ poll.poll_expired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.poll_expires|date('c') }}" title="{{ poll.poll_expires|date('r') }}">{{ poll.poll_expires|time_diff }}</time>. - </div> + {% if poll.canExpire %} + <div class="forum__poll__expires"> + Polling {{ poll.hasExpired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.expiresTime|date('c') }}" title="{{ poll.expiresTime|date('r') }}">{{ poll.expiresTime|time_diff }}</time>. + </div> + {% endif %} + + <div class="forum__poll__buttons"> + {% if can_vote %} + <button class="input__button forum__poll__button">Vote</button> {% endif %} - - {% if options_available %} - <div class="forum__poll__buttons"> - <label class="input__button forum__poll__button" for="forum-poll-toggle">Vote</label> - </div> + {% if results_available %} + <label class="input__button forum__poll__button" for="forum-poll-toggle">Results</label> {% endif %} </div> - {% endif %} - - {% if options_available %} - <form method="post" action="{{ url('forum-poll-vote') }}" class="container forum__poll__container forum__poll__container--poll js-forum-poll" - data-poll-id="{{ poll.poll_id }}" data-poll-max-votes="{{ poll.poll_max_votes }}"> - {{ input_csrf() }} - {{ input_hidden('poll[id]', poll.poll_id) }} - - <div class="forum__poll__options"> - {% for option in options %} - {{ input_checkbox( - 'poll[answers][]', - option.option_text, option.option_id in user_answers, 'forum__poll__option', - option.option_id, poll.poll_max_votes <= 1, - null, not can_vote - ) }} - {% endfor %} - </div> - - {% if can_vote and poll.poll_max_votes > 1 %} - <div class="forum__poll__remaining js-forum-poll-remaining"> - You have <span class="forum__poll__remaining__num"> - <span class="js-forum-poll-remaining-count">{{ poll.poll_max_votes }}</span> vote<span class="js-forum-poll-remaining-plural">s</span> - </span> remaining. - </div> - {% endif %} - - {% if poll.poll_expires is not null %} - <div class="forum__poll__expires"> - Polling {{ poll.poll_expired ? 'closed' : 'will close' }} <time class="forum__poll__expires__datetime" datetime="{{ poll.poll_expires|date('c') }}" title="{{ poll.poll_expires|date('r') }}">{{ poll.poll_expires|time_diff }}</time>. - </div> - {% endif %} - - <div class="forum__poll__buttons"> - {% if can_vote %} - <button class="input__button forum__poll__button">Vote</button> - {% endif %} - {% if results_available %} - <label class="input__button forum__poll__button" for="forum-poll-toggle">Results</label> - {% endif %} - </div> - </form> - {% endif %} - </div> - {% endif %} + </form> + {% endif %} + </div> {% endmacro %} -{% macro forum_priority_votes(topic, votes, can_vote) %} +{% macro forum_priority_votes(topic, user) %} <div class="container forum__priority"> <div class="forum__priority__votes"> - {% for vote in votes %} - <div title="{{ vote.username }} ({{ vote.topic_priority|number_format }})" class="forum__priority__vote" style="{{ vote.user_colour|html_colour }}"> - {% for i in 1..vote.topic_priority %} + {% for vote in topic.priorityVotes %} + <div title="{{ vote.user.username }} ({{ vote.priority|number_format }})" class="forum__priority__vote" style="--user-colour: {{ vote.user.colour }}"> + {% for i in 1..vote.priority %} <span class="forum__priority__star fas fa-star fa-fw"></span> {% endfor %} </div> {% endfor %} </div> - {% if can_vote %} + {% if topic.canVoteOnPriority(user) %} <div class="forum__priority__input"> - <a class="input__button" href="{{ url('forum-topic-priority', {'topic':topic.topic_id}) }}"> + <a class="input__button" href="{{ url('forum-topic-priority', {'topic': topic.id}) }}"> Vote for this feature </a> </div> diff --git a/templates/forum/master.twig b/templates/forum/master.twig index fc530f21..a3f41f59 100644 --- a/templates/forum/master.twig +++ b/templates/forum/master.twig @@ -1 +1,9 @@ {% extends 'master.twig' %} + +{% if forum_info is not defined and topic_info.category is defined %} + {% set forum_info = topic_info.category %} +{% endif %} + +{% if forum_info.colour.raw is defined %} + {% set global_accent_colour = forum_info.colour %} +{% endif %} diff --git a/templates/forum/posting.twig b/templates/forum/posting.twig index 3de2cec4..7a1b0f70 100644 --- a/templates/forum/posting.twig +++ b/templates/forum/posting.twig @@ -5,32 +5,34 @@ {% 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') }}"> - {{ input_hidden('post[' ~ (is_reply ? 'topic' : 'forum') ~ ']', is_reply ? posting_topic.topic_id : posting_forum.forum_id) }} + {{ input_hidden('post[' ~ (is_reply ? 'topic' : 'forum') ~ ']', is_reply ? posting_topic.id : posting_forum.id) }} {{ input_hidden('post[mode]', posting_mode) }} {{ input_csrf() }} {{ forum_header( + posting_forum, + true, is_reply and not is_opening - ? posting_topic.topic_title + ? url('forum-topic', {'topic': posting_topic.id}) + : '', + [], + true, + is_reply and not is_opening + ? posting_topic.title : input_text( 'post[title]', 'forum__header__input', - posting_defaults.title|default(posting_topic.topic_title|default('')), + posting_defaults.title|default(posting_topic.title|default('')), 'text', 'Enter your title here...' - ), - posting_breadcrumbs, - false, - is_reply and not is_opening - ? url('forum-topic', {'topic': posting_topic.topic_id}) - : '' + ) ) }} {% 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 %} @@ -43,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_info.user_country)|lower }}" title="{{ posting_post.poster_country|default(posting_info.user_country)|country_name }}"></div> - <div class="forum__post__posts-count">{{ posting_post.poster_post_count|default(posting_info.user_forum_posts)|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_info.user_created)|date('c') }}" title="{{ posting_post.poster_joined|default(posting_info.user_created)|date('r') }}">{{ posting_post.poster_joined|default(posting_info.user_created)|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> @@ -75,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> @@ -140,14 +142,14 @@ {{ input_select( 'post[parser]', constant('\\Misuzu\\Parsers\\Parser::NAMES'), - posting_defaults.parser|default(posting_post.post_parse|default(posting_info.user_post_parse|default(constant('\\Misuzu\\Parsers\\Parser::BBCODE')))), + 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 %} {{ input_select( 'post[type]', posting_types, - posting_defaults.type|default(posting_topic.topic_type|default(posting_types|keys|first)), + posting_defaults.type|default(posting_topic.type|default(posting_types|keys|first)), null, null, null, 'forum__post__dropdown' ) }} {% endif %} @@ -156,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 ) ) }} diff --git a/templates/forum/topic.twig b/templates/forum/topic.twig index fdeba47c..1ef34c23 100644 --- a/templates/forum/topic.twig +++ b/templates/forum/topic.twig @@ -12,58 +12,60 @@ forum_priority_votes %} -{% set title = topic_info.topic_title %} +{% set title = topic_info.title %} {% set canonical_url = url('forum-topic', { - 'topic': topic_info.topic_id, + 'topic': topic_info.id, 'page': topic_pagination.page > 1 ? topic_pagination.page : 0, }) %} {% set forum_post_csrf = csrf_token() %} {% set topic_tools = forum_topic_tools(topic_info, topic_pagination, can_reply) %} -{% set topic_notice = forum_topic_locked(topic_info.topic_locked, topic_info.topic_archived) %} +{% set topic_notice = forum_topic_locked(topic_info.lockedTime, topic_info.archived) %} {% set topic_actions = [ { 'html': '<i class="far fa-trash-alt fa-fw"></i> Delete', - 'url': url('forum-topic-delete', {'topic': topic_info.topic_id}), + 'url': url('forum-topic-delete', {'topic': topic_info.id}), 'display': topic_can_delete, }, { 'html': '<i class="fas fa-magic fa-fw"></i> Restore', - 'url': url('forum-topic-restore', {'topic': topic_info.topic_id}), + 'url': url('forum-topic-restore', {'topic': topic_info.id}), 'display': topic_can_nuke_or_restore, }, { 'html': '<i class="fas fa-radiation-alt fa-fw"></i> Permanently Delete', - 'url': url('forum-topic-nuke', {'topic': topic_info.topic_id}), + 'url': url('forum-topic-nuke', {'topic': topic_info.id}), 'display': topic_can_nuke_or_restore, }, { 'html': '<i class="fas fa-plus-circle fa-fw"></i> Bump', - 'url': url('forum-topic-bump', {'topic': topic_info.topic_id}), + 'url': url('forum-topic-bump', {'topic': topic_info.id}), 'display': topic_can_bump, }, { 'html': '<i class="fas fa-lock fa-fw"></i> Lock', - 'url': url('forum-topic-lock', {'topic': topic_info.topic_id}), - 'display': topic_can_lock and topic_info.topic_locked is null, + 'url': url('forum-topic-lock', {'topic': topic_info.id}), + 'display': topic_can_lock and not topic_info.locked, }, { 'html': '<i class="fas fa-lock-open fa-fw"></i> Unlock', - 'url': url('forum-topic-unlock', {'topic': topic_info.topic_id}), - 'display': topic_can_lock and topic_info.topic_locked is not null, + 'url': url('forum-topic-unlock', {'topic': topic_info.id}), + 'display': topic_can_lock and topic_info.locked, }, ] %} {% block content %} - {{ forum_header(topic_info.topic_title, topic_breadcrumbs, false, canonical_url, topic_actions) }} + {{ forum_header(topic_info.category, true, canonical_url, topic_actions, true, topic_info.title) }} {{ topic_notice }} - {% if forum_has_priority_voting(topic_info.forum_type) %} - {{ forum_priority_votes(topic_info, topic_priority_votes, true) }} {# replace true this with perms check #} + {% if topic_info.hasPriorityVoting %} + {{ forum_priority_votes(topic_info, current_user|default(null)) }} {% endif %} - {{ forum_poll(topic_info, topic_poll_options, topic_poll_user_answers, topic_info.topic_id, current_user.id|default(0) > 0, topic_info.author_user_id == current_user.id|default(0)) }} + {% for poll in topic_info.polls %} + {{ forum_poll(poll, current_user|default(null)) }} + {% endfor %} {{ topic_tools }} - {{ forum_post_listing(topic_posts, current_user.id|default(0), topic_perms) }} + {{ forum_post_listing(topic_info.posts(topic_can_view_deleted, topic_pagination), current_user|default(null), topic_perms) }} {{ topic_tools }} {{ topic_notice }} - {{ forum_header('', topic_breadcrumbs) }} + {{ forum_header(topic_info.category, false) }} {% endblock %} diff --git a/templates/macros.twig b/templates/macros.twig index 98b4988b..37378a21 100644 --- a/templates/macros.twig +++ b/templates/macros.twig @@ -11,63 +11,6 @@ </ul> {% endmacro %} -{% macro pagination(info, path, page_range, params, page_param, url_fragment) %} - {% if info.page is defined and info.pages > 1 %} - {% set params = params is iterable ? params : [] %} - {% set page_param = page_param|default('p') %} - {% set page_range = page_range|default(5) %} - - <div class="pagination"> - <div class="pagination__section pagination__section--first"> - {% if info.page <= 1 %} - <div class="pagination__link pagination__link--first pagination__link--disabled"> - <i class="fas fa-angle-double-left"></i> - </div> - <div class="pagination__link pagination__link--prev pagination__link--disabled"> - <i class="fas fa-angle-left"></i> - </div> - {% else %} - <a href="{{ url_construct(path, params, url_fragment) }}" class="pagination__link pagination__link--first" rel="first"> - <i class="fas fa-angle-double-left"></i> - </a> - <a href="{{ url_construct(path, info.page <= 2 ? params : params|merge({(page_param): info.page - 1}), url_fragment) }}" class="pagination__link pagination__link--prev" rel="prev"> - <i class="fas fa-angle-left"></i> - </a> - {% endif %} - </div> - - <div class="pagination__section pagination__section--pages"> - {% set p_start = max(info.page - page_range, 1) %} - {% set p_stop = min(info.page + page_range, info.pages) %} - - {% for i in p_start..p_stop %} - <a href="{{ url_construct(path, i <= 1 ? params : params|merge({(page_param): i}), url_fragment) }}" class="pagination__link{{ info.page == i ? ' pagination__link--current' : '' }}"> - {{ i }} - </a> - {% endfor %} - </div> - - <div class="pagination__section pagination__section--last"> - {% if info.page >= info.pages %} - <div class="pagination__link pagination__link--next pagination__link--disabled"> - <i class="fas fa-angle-right"></i> - </div> - <div class="pagination__link pagination__link--last pagination__link--disabled"> - <i class="fas fa-angle-double-right"></i> - </div> - {% else %} - <a href="{{ url_construct(path, params|merge({(page_param): info.page + 1}), url_fragment) }}" class="pagination__link pagination__link--next" rel="next"> - <i class="fas fa-angle-right"></i> - </a> - <a href="{{ url_construct(path, params|merge({(page_param): info.pages}), url_fragment) }}" class="pagination__link pagination__link--last" rel="last"> - <i class="fas fa-angle-double-right"></i> - </a> - {% endif %} - </div> - </div> - {% endif %} -{% endmacro %} - {% macro container_title(title, unsafe, url) %} {% set has_url = url is not null and url|length > 0 %} @@ -86,5 +29,5 @@ {% endmacro %} {% macro avatar(user_id, resolution, alt_text, attributes) %} -{{ html_avatar(user_id, resolution, alt_text|default(''), attributes|default([]))|raw }} + <img src="{{ url('user-avatar', {'user': user_id|default(0), 'res': resolution|default(0) * 2}) }}" alt="{{ alt_text|default('') }}" class="{{ ('avatar ' ~ attributes.class|default(''))|trim }}"{% if resolution > 0 %} width="{{ resolution }}" height="{{ resolution }}"{% endif %}/> {% endmacro %} diff --git a/templates/manage/changelog/changes.twig b/templates/manage/changelog/changes.twig index 40099b02..3a4541ee 100644 --- a/templates/manage/changelog/changes.twig +++ b/templates/manage/changelog/changes.twig @@ -1,9 +1,9 @@ {% extends 'manage/changelog/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} {% from 'changelog/macros.twig' import changelog_listing %} {% block manage_content %} - {% set changelog_pagination = pagination(changelog_pagination, url('manage-changelog-changes')) %} + {% set changelog_pagination = changelog_pagination.render('manage-changelog-changes') %} <div class="container"> @@ -12,13 +12,13 @@ <div class="changelog__content"> <div class="changelog__pagination"> <a href="{{ url('manage-changelog-change') }}" class="input__button">Create new change</a> - {{ changelog_pagination }} + {{ changelog_pagination|raw }} </div> {{ changelog_listing(changelog_changes, false, false, true) }} <div class="changelog__pagination"> - {{ changelog_pagination }} + {{ changelog_pagination|raw }} </div> </div> </div> diff --git a/templates/manage/general/logs.twig b/templates/manage/general/logs.twig index d2e3bcd1..97111f76 100644 --- a/templates/manage/general/logs.twig +++ b/templates/manage/general/logs.twig @@ -1,15 +1,15 @@ {% extends 'manage/general/master.twig' %} -{% from 'macros.twig' import container_title, pagination %} +{% from 'macros.twig' import container_title, %} {% from 'user/macros.twig' import user_account_log %} {% block manage_content %} <div class="container settings__container"> {{ container_title('<i class="fas fa-file-alt fa-fw"></i> Global Log') }} - {% set glp = pagination(global_logs_pagination, url('manage-general-logs'), null, {'v': 'logs'}) %} + {% set glp = global_logs_pagination.render('manage-general-logs', {'v': 'logs'}) %} <div class="settings__account-logs"> <div class="settings__account-logs__pagination"> - {{ glp }} + {{ glp|raw }} </div> {% for log in global_logs %} @@ -17,7 +17,7 @@ {% endfor %} <div class="settings__account-logs__pagination"> - {{ glp }} + {{ glp|raw }} </div> </div> </div> diff --git a/templates/manage/news/categories.twig b/templates/manage/news/categories.twig index 995c502b..006ab878 100644 --- a/templates/manage/news/categories.twig +++ b/templates/manage/news/categories.twig @@ -1,5 +1,5 @@ {% extends 'manage/news/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} {% block manage_content %} <div class="container"> @@ -16,6 +16,6 @@ </p> {% endfor %} - {{ pagination(categories_pagination, url('manage-news-categories')) }} + {{ categories_pagination.render('manage-news-categories')|raw }} </div> {% endblock %} diff --git a/templates/manage/news/posts.twig b/templates/manage/news/posts.twig index d7abc802..9bfc0c8b 100644 --- a/templates/manage/news/posts.twig +++ b/templates/manage/news/posts.twig @@ -1,5 +1,5 @@ {% extends 'manage/news/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} {% block manage_content %} <div class="container"> @@ -22,6 +22,6 @@ </p> {% endfor %} - {{ pagination(posts_pagination, url('manage-news-posts')) }} + {{ posts_pagination.render('manage-news-posts') }} </div> {% endblock %} diff --git a/templates/manage/users/roles.twig b/templates/manage/users/roles.twig index 06a41216..e9ecbdf1 100644 --- a/templates/manage/users/roles.twig +++ b/templates/manage/users/roles.twig @@ -1,7 +1,7 @@ {% extends 'manage/users/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} -{% set roles_pagination = pagination(manage_roles_pagination, url('manage-roles')) %} +{% set roles_pagination = manage_roles_pagination.render('manage-roles') %} {% block manage_content %} <div class="container manage__roles"> @@ -9,7 +9,7 @@ {% if roles_pagination|trim|length > 0 %} <div class="manage__roles__pagination"> - {{ roles_pagination }} + {{ roles_pagination|raw }} </div> {% endif %} @@ -71,7 +71,7 @@ {% if roles_pagination|trim|length > 0 %} <div class="manage__roles__pagination"> - {{ roles_pagination }} + {{ roles_pagination|raw }} </div> {% endif %} </div> diff --git a/templates/manage/users/users.twig b/templates/manage/users/users.twig index b2156c8e..f268665a 100644 --- a/templates/manage/users/users.twig +++ b/templates/manage/users/users.twig @@ -1,7 +1,7 @@ {% extends 'manage/users/master.twig' %} -{% from 'macros.twig' import pagination, container_title, avatar %} +{% from 'macros.twig' import container_title, avatar %} -{% set users_pagination = pagination(manage_users_pagination, url('manage-users')) %} +{% set users_pagination = manage_users_pagination.render('manage-users') %} {% block manage_content %} <div class="container manage__users"> @@ -9,7 +9,7 @@ {% if users_pagination|trim|length > 0 %} <div class="manage__users__pagination"> - {{ users_pagination }} + {{ users_pagination|raw }} </div> {% endif %} @@ -55,7 +55,7 @@ {% if users_pagination|trim|length > 0 %} <div class="manage__users__pagination"> - {{ users_pagination }} + {{ users_pagination|raw }} </div> {% endif %} </div> diff --git a/templates/manage/users/warnings.twig b/templates/manage/users/warnings.twig index 776d0722..987d96e0 100644 --- a/templates/manage/users/warnings.twig +++ b/templates/manage/users/warnings.twig @@ -1,5 +1,5 @@ {% extends 'manage/users/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} {% from 'user/macros.twig' import user_profile_warning %} {% from '_layout/input.twig' import input_text, input_csrf, input_select, input_hidden %} @@ -38,9 +38,9 @@ <div class="container container--lazy"> {{ container_title('<i class="fas fa-exclamation-circle fa-fw"></i> Warnings') }} - {% set warnpag = pagination(warnings.pagination, url('manage-users-warnings', {'user': warnings.user.id|default(0)})) %} + {% set warnpag = warnings.pagination.render('manage-users-warnings', {'user': warnings.user.id|default(0)}) %} - {{ warnpag }} + {{ warnpag|raw }} <div class="profile__warnings__container"> <div class="profile__warning profile__warning--extendo"> @@ -90,6 +90,6 @@ {% endfor %} </div> - {{ warnpag }} + {{ warnpag|raw }} </div> {% endblock %} diff --git a/templates/master.twig b/templates/master.twig index 5d02e1e8..9129a089 100644 --- a/templates/master.twig +++ b/templates/master.twig @@ -26,7 +26,7 @@ {% endif %} </head> <body class="main{% if site_background is defined %} {{ site_background.classNames('main--bg-%s')|join(' ') }}{% endif %}" - style="{% if global_accent_colour is defined %}{{ global_accent_colour|html_colour('--accent-colour') }}{% endif %}" id="container"> + style="{% if global_accent_colour.raw is defined %}--accent-colour: {{ global_accent_colour }}{% elseif global_accent_colour is defined %}{{ global_accent_colour|html_colour('--accent-colour') }}{% endif %}" id="container"> {% include '_layout/header.twig' %} <div class="main__wrapper"> diff --git a/templates/news/category.twig b/templates/news/category.twig index afe804bf..31b73be3 100644 --- a/templates/news/category.twig +++ b/templates/news/category.twig @@ -1,5 +1,5 @@ {% extends 'news/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} {% from 'news/macros.twig' import news_preview %} {% set title = category_info.name ~ ' :: News' %} @@ -30,7 +30,7 @@ {% endfor %} <div class="container" style="padding: 4px; display: {{ news_pagination.pages > 1 ? 'block' : 'none' }}"> - {{ pagination(news_pagination, url('news-category', {'category':category_info.id})) }} + {{ news_pagination.render(url('news-category', {'category':category_info.id}))|raw }} </div> </div> diff --git a/templates/news/index.twig b/templates/news/index.twig index ec7747c0..2d4dee13 100644 --- a/templates/news/index.twig +++ b/templates/news/index.twig @@ -1,5 +1,5 @@ {% extends 'news/master.twig' %} -{% from 'macros.twig' import pagination, container_title %} +{% from 'macros.twig' import container_title %} {% from 'news/macros.twig' import news_preview %} {% set title = 'News' %} @@ -29,7 +29,7 @@ {% endfor %} <div class="container" style="padding: 4px; display: {{ news_pagination.pages > 1 ? 'block' : 'none' }}"> - {{ pagination(news_pagination, url('news-index')) }} + {{ news_pagination.render('news-index')|raw }} </div> </div> diff --git a/templates/profile/posts.twig b/templates/profile/posts.twig index bbb02b66..005376ee 100644 --- a/templates/profile/posts.twig +++ b/templates/profile/posts.twig @@ -1,23 +1,13 @@ {% extends 'profile/master.twig' %} -{% from 'macros.twig' import pagination %} -{% from 'forum/macros.twig' import forum_post_listing %} {% block content %} <div class="profile"> {% include 'profile/_layout/header.twig' %} - {% set sp = profile_posts_pagination.pages > 1 - ? '<div class="container profile__pagination">' ~ pagination(profile_posts_pagination, canonical_url) ~ '</div>' - : '' %} - - {% if sp is not empty %} - {{ sp|raw }} - {% endif %} - - {{ forum_post_listing(profile_posts) }} - - {% if sp is not empty %} - {{ sp|raw }} - {% endif %} + <div class="warning"> + <div class="warning__content"> + <p>User post listing is gone for a while, it will be back someday but with less bad.</p> + </div> + </div> </div> {% endblock %} diff --git a/templates/profile/relations.twig b/templates/profile/relations.twig index 3fadaa72..a8d462f0 100644 --- a/templates/profile/relations.twig +++ b/templates/profile/relations.twig @@ -1,5 +1,4 @@ {% extends 'profile/master.twig' %} -{% from 'macros.twig' import pagination %} {% from 'user/macros.twig' import user_card %} {% block content %} @@ -16,7 +15,7 @@ {% if profile_relation_pagination.pages > 1 %} <div class="container profile__pagination"> - {{ pagination(profile_relation_pagination, canonical_url) }} + {{ profile_relation_pagination.render(canonical_url)|raw }} </div> {% endif %} </div> diff --git a/templates/profile/topics.twig b/templates/profile/topics.twig index 12773026..e4bbc5d0 100644 --- a/templates/profile/topics.twig +++ b/templates/profile/topics.twig @@ -1,23 +1,13 @@ {% extends 'profile/master.twig' %} -{% from 'macros.twig' import pagination %} -{% from 'forum/macros.twig' import forum_topic_listing %} {% block content %} <div class="profile"> {% include 'profile/_layout/header.twig' %} - {% set sp = profile_topics_pagination.pages > 1 - ? '<div class="container profile__pagination">' ~ pagination(profile_topics_pagination, canonical_url) ~ '</div>' - : '' %} - - {% if sp is not empty %} - {{ sp|raw }} - {% endif %} - - {{ forum_topic_listing(profile_topics) }} - - {% if sp is not empty %} - {{ sp|raw }} - {% endif %} + <div class="warning"> + <div class="warning__content"> + <p>User topic listing is gone for a while, it will be back someday but with less bad.</p> + </div> + </div> </div> {% endblock %} diff --git a/templates/settings/logs.twig b/templates/settings/logs.twig index e2c02136..92935192 100644 --- a/templates/settings/logs.twig +++ b/templates/settings/logs.twig @@ -1,5 +1,5 @@ {% extends 'settings/master.twig' %} -{% from 'macros.twig' import container_title, pagination %} +{% from 'macros.twig' import container_title %} {% from 'user/macros.twig' import user_login_attempt, user_account_log %} {% set title = 'Settings / Logs' %} @@ -7,7 +7,7 @@ {% block settings_content %} <div class="container settings__container" id="login-history"> {{ container_title('<i class="fas fa-user-lock fa-fw"></i> Login History') }} - {% set lhpagination = pagination(login_history_pagination, url('settings-logs'), null, { + {% set lhpagination = login_history_pagination.render('settings-logs', { 'ap': account_log_pagination.page > 1 ? account_log_pagination.page : 0, }, 'hp', 'login-history') %} @@ -17,7 +17,7 @@ <div class="settings__login-attempts"> <div class="settings__login-attempts__pagination"> - {{ lhpagination }} + {{ lhpagination|raw }} </div> {% if login_history_list|length < 1 %} @@ -31,14 +31,14 @@ {% endif %} <div class="settings__login-attempts__pagination"> - {{ lhpagination }} + {{ lhpagination|raw }} </div> </div> </div> <div class="container settings__container" id="account-log"> {{ container_title('<i class="fas fa-file-alt fa-fw"></i> Account Log') }} - {% set alpagination = pagination(account_log_pagination, url('settings-logs'), null, { + {% set alpagination = account_log_pagination.render('settings-logs', { 'hp': login_history_pagination.page > 1 ? login_history_pagination.page : 0, }, 'ap', 'account-log') %} @@ -48,7 +48,7 @@ <div class="settings__account-logs"> <div class="settings__account-logs__pagination"> - {{ alpagination }} + {{ alpagination|raw }} </div> {% for log in account_log_list %} @@ -56,7 +56,7 @@ {% endfor %} <div class="settings__account-logs__pagination"> - {{ alpagination }} + {{ alpagination|raw }} </div> </div> </div> diff --git a/templates/settings/sessions.twig b/templates/settings/sessions.twig index 5c13bbb2..dfaea636 100644 --- a/templates/settings/sessions.twig +++ b/templates/settings/sessions.twig @@ -1,5 +1,5 @@ {% extends 'settings/master.twig' %} -{% from 'macros.twig' import container_title, pagination %} +{% from 'macros.twig' import container_title %} {% from 'user/macros.twig' import user_session %} {% from '_layout/input.twig' import input_hidden, input_csrf %} @@ -9,7 +9,7 @@ <div class="container settings__container"> {{ container_title('<i class="fas fa-key fa-fw"></i> Sessions') }} - {% set spagination = pagination(session_pagination, url('settings-sessions')) %} + {% set spagination = session_pagination.render('settings-sessions') %} <div class="settings__description"> <p>These are the active logins to your account, clicking the Kill button will force a logout on that session. Your current login is highlighted with a different colour so you don't accidentally force yourself to logout.</p> @@ -26,7 +26,7 @@ </form> <div class="settings__sessions__pagination"> - {{ spagination }} + {{ spagination|raw }} </div> <div class="settings__sessions__list"> @@ -36,7 +36,7 @@ </div> <div class="settings__sessions__pagination"> - {{ spagination }} + {{ spagination|raw }} </div> </div> </div> diff --git a/templates/user/listing.twig b/templates/user/listing.twig index 64b3b3fd..b8f6d3fe 100644 --- a/templates/user/listing.twig +++ b/templates/user/listing.twig @@ -15,7 +15,6 @@ {% set manage_link = url('manage-users') %} {% macro member_nav(roles, role_id, orders, order, directions, direction, users_pagination, url_role, url_sort, url_direction) %} - {% from 'macros.twig' import pagination %} {% from '_layout/input.twig' import input_select %} <div class="userlist__navigation"> @@ -30,7 +29,7 @@ </form> <div class="userlist__pagination"> - {{ pagination(users_pagination, url('user-list'), null, {'r': url_role, 'ss': url_sort, 'sd': url_direction}) }} + {{ users_pagination.render('user-list', {'r': url_role, 'ss': url_sort, 'sd': url_direction})|raw }} </div> </div> {% endmacro %} diff --git a/utility.php b/utility.php index ebed408f..32960a4b 100644 --- a/utility.php +++ b/utility.php @@ -33,16 +33,21 @@ function clamp($num, int $min, int $max): int { return max($min, min($max, intval($num))); } -function starts_with(string $string, string $text, bool $multibyte = true): bool { +function starts_with(string $haystack, string $needle, bool $multibyte = true): bool { $strlen = $multibyte ? 'mb_strlen' : 'strlen'; $substr = $multibyte ? 'mb_substr' : 'substr'; - return $substr($string, 0, $strlen($text)) === $text; + return $substr($haystack, 0, $strlen($needle)) === $needle; } -function ends_with(string $string, string $text, bool $multibyte = true): bool { +function ends_with(string $haystack, string $needle, bool $multibyte = true): bool { $strlen = $multibyte ? 'mb_strlen' : 'strlen'; $substr = $multibyte ? 'mb_substr' : 'substr'; - return $substr($string, 0 - $strlen($text)) === $text; + return $substr($haystack, 0 - $strlen($needle)) === $needle; +} + +if(!function_exists('str_starts_with')) { + function str_starts_with(string $haystack, string $needle): bool { return starts_with($haystack, $needle, false); } + function str_ends_with(string $haystack, string $needle): bool { return ends_with($haystack, $needle, false); } } function first_paragraph(string $text, string $delimiter = "\n"): string { @@ -79,22 +84,14 @@ function byte_symbol(int $bytes, bool $decimal = false, array $symbols = ['', 'K return sprintf("%.2f %s%sB", $bytes, $symbol, $symbol !== '' && !$decimal ? 'i' : ''); } -function get_country_name(string $code): string { - switch(strtolower($code)) { - case 'xx': - return 'Unknown'; - - case 'a1': - return 'Anonymous Proxy'; - - case 'a2': - return 'Satellite Provider'; - - default: - return locale_get_display_region("-{$code}", 'en'); - } +function get_country_name(string $code, string $locale = 'en'): string { + if($code === 'xx') + return 'Unknown'; + return \Locale::getDisplayRegion("-{$code}", $locale); } +/*** THE DEPRECATION NATION (LINE) ***/ + // render_error, render_info and render_info_or_json should be redone a bit better // following a uniform format so there can be a global handler for em @@ -161,37 +158,3 @@ function html_colour(?int $colour, $attribs = '--user-colour'): string { return $css; } - -function html_avatar(?int $userId, int $resolution, string $altText = '', array $attributes = []): string { - $attributes['src'] = url('user-avatar', ['user' => $userId ?? 0, 'res' => $resolution * 2]); - $attributes['alt'] = $altText; - $attributes['class'] = trim('avatar ' . ($attributes['class'] ?? '')); - - if(!isset($attributes['width'])) - $attributes['width'] = $resolution; - if(!isset($attributes['height'])) - $attributes['height'] = $resolution; - - return html_tag('img', $attributes); -} - -function html_tag(string $name, array $atrributes = [], ?bool $close = null, string $content = ''): string { - $html = '<' . $name; - - foreach($atrributes as $key => $value) { - $html .= ' ' . $key; - - if(!empty($value)) - $html .= '="' . $value . '"'; - } - - if($close === false) - $html .= '/'; - - $html .= '>'; - - if($close === true) - $html .= $content . '</' . $name . '>'; - - return $html; -}