Compare commits

...
Sign in to create a new pull request.

5 commits

74 changed files with 3743 additions and 3156 deletions

View file

@ -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; }

View file

@ -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;
");
}

View file

@ -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')) {

View file

@ -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';

View file

@ -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';

View file

@ -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';

View file

@ -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']]);

View file

@ -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,
]);
}

View file

@ -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,

View file

@ -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]);

View file

@ -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 ?? [],
]);

View file

@ -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');

View file

@ -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;

View file

@ -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(

View file

@ -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();

View file

@ -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.',

View file

@ -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;
}

587
src/Forum/ForumCategory.php Normal file
View file

@ -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;
}
}

View file

@ -0,0 +1,6 @@
<?php
namespace Misuzu\Forum;
use Misuzu\MisuzuException;
class ForumException extends MisuzuException {}

View file

@ -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;
}
}

166
src/Forum/ForumPoll.php Normal file
View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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);
}
}

488
src/Forum/ForumPost.php Normal file
View file

@ -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;
}
}

588
src/Forum/ForumTopic.php Normal file
View file

@ -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;
}
}

View file

@ -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);
}
}

View file

@ -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;
});
}
}

0
src/Forum/ForumTrack.php Normal file
View file

View file

@ -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');
}

View file

@ -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;
}

View file

@ -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);

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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();
}

View file

@ -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 '';
}

View file

@ -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),
]));
}
}

View file

@ -0,0 +1,6 @@
<?php
namespace Misuzu\Http\Handlers\Forum;
use Misuzu\Http\Handlers\Handler;
abstract class ForumHandler extends Handler {}

View file

@ -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'));
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -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')
);
}
}

View file

@ -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;
}

View file

@ -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;
}
}

View file

@ -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>';
}
}

View file

@ -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()),

View file

@ -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;

View file

@ -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']],

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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 %}

View file

@ -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
)
) }}

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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">

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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>

View file

@ -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 %}

View file

@ -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>

View file

@ -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>

View file

@ -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 %}

View file

@ -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;
}