diff --git a/assets/css/misuzu/changelog/_changelog.css b/assets/css/misuzu/changelog/_changelog.css new file mode 100644 index 00000000..1cd8f095 --- /dev/null +++ b/assets/css/misuzu/changelog/_changelog.css @@ -0,0 +1,6 @@ +.changelog__action--add { --action-colour: #159635 !important; } +.changelog__action--remove { --action-colour: #e33743 !important; } +.changelog__action--update { --action-colour: #297b8a !important; } +.changelog__action--fix { --action-colour: #2d5e96 !important; } +.changelog__action--import { --action-colour: #2b9678 !important; } +.changelog__action--revert { --action-colour: #e38245 !important; } diff --git a/assets/css/misuzu/changelog/entry.css b/assets/css/misuzu/changelog/entry.css index 9d53bcef..21a27bfc 100644 --- a/assets/css/misuzu/changelog/entry.css +++ b/assets/css/misuzu/changelog/entry.css @@ -30,12 +30,6 @@ .changelog__entry__action__text { width: 100%; } -.changelog__action--add { --action-colour: #159635; } -.changelog__action--remove { --action-colour: #e33743; } -.changelog__action--update { --action-colour: #297b8a; } -.changelog__action--fix { --action-colour: #2d5e96; } -.changelog__action--import { --action-colour: #2b9678; } -.changelog__action--revert { --action-colour: #e38245; } .changelog__entry__datetime { min-width: 100px; diff --git a/assets/css/misuzu/changelog/log.css b/assets/css/misuzu/changelog/log.css index 15bc14fe..77da047a 100644 --- a/assets/css/misuzu/changelog/log.css +++ b/assets/css/misuzu/changelog/log.css @@ -2,7 +2,7 @@ --action-colour: var(--accent-colour); border: 1px solid var(--action-colour); - background-color: var(--action-colour); + background-color: var(--background-colour); display: flex; align-items: stretch; flex: 1 0 auto; diff --git a/misuzu.php b/misuzu.php index a55fdd27..22d24ddd 100644 --- a/misuzu.php +++ b/misuzu.php @@ -64,7 +64,6 @@ require_once 'utility.php'; require_once 'src/perms.php'; require_once 'src/audit_log.php'; require_once 'src/changelog.php'; -require_once 'src/comments.php'; require_once 'src/manage.php'; require_once 'src/url.php'; require_once 'src/Forum/perms.php'; diff --git a/public/auth/login.php b/public/auth/login.php index c6fc8eae..9033e49d 100644 --- a/public/auth/login.php +++ b/public/auth/login.php @@ -3,6 +3,7 @@ namespace Misuzu; use Misuzu\Net\IPAddress; use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; require_once '../../misuzu.php'; @@ -44,7 +45,6 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { break; } - $userData = User::findForLogin($_POST['login']['username']); $attemptsRemainingError = sprintf( "%d attempt%s remaining", $remainingAttempts - 1, @@ -52,7 +52,9 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { ); $loginFailedError = "Invalid username or password, {$attemptsRemainingError}."; - if(empty($userData)) { + try { + $userData = User::findForLogin($_POST['login']['username']); + } catch(UserNotFoundException $ex) { user_login_attempt_record(false, null, $ipAddress, $userAgent); $notices[] = $loginFailedError; break; @@ -64,7 +66,7 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { } if($userData->isDeleted() || !$userData->checkPassword($_POST['login']['password'])) { - user_login_attempt_record(false, $userData->user_id, $ipAddress, $userAgent); + user_login_attempt_record(false, $userData->getId(), $ipAddress, $userAgent); $notices[] = $loginFailedError; break; } @@ -73,31 +75,31 @@ while(!empty($_POST['login']) && is_array($_POST['login'])) { $userData->setPassword($_POST['login']['password']); } - if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userData['user_id'], $loginPermVal)) { + if(!empty($loginPermCat) && $loginPermVal > 0 && !perms_check_user($loginPermCat, $userData->getId(), $loginPermVal)) { $notices[] = "Login succeeded, but you're not allowed to browse the site right now."; - user_login_attempt_record(true, $userData->user_id, $ipAddress, $userAgent); + user_login_attempt_record(true, $userData->getId(), $ipAddress, $userAgent); break; } if($userData->hasTOTP()) { url_redirect('auth-two-factor', [ - 'token' => user_auth_tfa_token_create($userData->user_id), + 'token' => user_auth_tfa_token_create($userData->getId()), ]); return; } - user_login_attempt_record(true, $userData->user_id, $ipAddress, $userAgent); - $sessionKey = user_session_create($userData->user_id, $ipAddress, $userAgent); + user_login_attempt_record(true, $userData->getId(), $ipAddress, $userAgent); + $sessionKey = user_session_create($userData->getId(), $ipAddress, $userAgent); if(empty($sessionKey)) { $notices[] = "Something broke while creating a session for you, please tell an administrator or developer about this!"; break; } - user_session_start($userData->user_id, $sessionKey); + user_session_start($userData->getId(), $sessionKey); $cookieLife = strtotime(user_session_current('session_expires')); - $cookieValue = Base64::encode(user_session_cookie_pack($userData->user_id, $sessionKey), true); + $cookieValue = Base64::encode(user_session_cookie_pack($userData->getId(), $sessionKey), true); setcookie('msz_auth', $cookieValue, $cookieLife, '/', '.' . $_SERVER['HTTP_HOST'], !empty($_SERVER['HTTPS']), true); if(!is_local_url($loginRedirect)) { diff --git a/public/auth/register.php b/public/auth/register.php index 04d15b77..c5dfc5b4 100644 --- a/public/auth/register.php +++ b/public/auth/register.php @@ -82,8 +82,8 @@ while(!$restricted && !empty($register)) { break; } - user_role_add($createUser->user_id, MSZ_ROLE_MAIN); - url_redirect('auth-login-welcome', ['username' => $createUser->username]); + user_role_add($createUser->getId(), MSZ_ROLE_MAIN); + url_redirect('auth-login-welcome', ['username' => $createUser->getUsername()]); return; } diff --git a/public/changelog.php b/public/changelog.php index 4f20fd26..ba3f45bd 100644 --- a/public/changelog.php +++ b/public/changelog.php @@ -1,14 +1,17 @@ <?php namespace Misuzu; +use Misuzu\Comments\CommentsCategory; +use Misuzu\Comments\CommentsCategoryNotFoundException; +use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; + require_once '../misuzu.php'; -$changelogChange = !empty($_GET['c']) && is_string($_GET['c']) ? (int)$_GET['c'] : 0; -$changelogDate = !empty($_GET['d']) && is_string($_GET['d']) ? (string)$_GET['d'] : ''; -$changelogUser = !empty($_GET['u']) && is_string($_GET['u']) ? (int)$_GET['u'] : 0; -$changelogTags = !empty($_GET['t']) && is_string($_GET['t']) ? (string)$_GET['t'] : ''; - -Template::set('comments_perms', $commentPerms = comments_get_perms(user_session_current('user_id', 0))); +$changelogChange = !empty($_GET['c']) && is_string($_GET['c']) ? (int)$_GET['c'] : 0; +$changelogDate = !empty($_GET['d']) && is_string($_GET['d']) ? (string)$_GET['d'] : ''; +$changelogUser = !empty($_GET['u']) && is_string($_GET['u']) ? (int)$_GET['u'] : 0; +$changelogTags = !empty($_GET['t']) && is_string($_GET['t']) ? (string)$_GET['t'] : ''; if($changelogChange > 0) { $change = changelog_change_get($changelogChange); @@ -18,14 +21,25 @@ if($changelogChange > 0) { return; } + $commentsCategoryName = "changelog-date-{$change['change_date']}"; + try { + $commentsCategory = CommentsCategory::byName($commentsCategoryName); + } catch(CommentsCategoryNotFoundException $ex) { + $commentsCategory = new CommentsCategory($commentsCategoryName); + $commentsCategory->save(); + } + + try { + $commentsUser = User::byId(user_session_current('user_id', 0)); + } catch(UserNotFoundException $ex) { + $commentsUser = null; + } + Template::render('changelog.change', [ 'change' => $change, 'tags' => changelog_change_tags_get($change['change_id']), - 'comments_category' => $commentsCategory = comments_category_info( - "changelog-date-{$change['change_date']}", - true - ), - 'comments' => comments_category_get($commentsCategory['category_id'], user_session_current('user_id', 0)), + 'comments_category' => $commentsCategory, + 'comments_user' => $commentsUser, ]); return; } @@ -52,9 +66,23 @@ if(!$changes) { } if(!empty($changelogDate) && count($changes) > 0) { + $commentsCategoryName = "changelog-date-{$changelogDate}"; + try { + $commentsCategory = CommentsCategory::byName($commentsCategoryName); + } catch(CommentsCategoryNotFoundException $ex) { + $commentsCategory = new CommentsCategory($commentsCategoryName); + $commentsCategory->save(); + } + + try { + $commentsUser = User::byId(user_session_current('user_id', 0)); + } catch(UserNotFoundException $ex) { + $commentsUser = null; + } + Template::set([ - 'comments_category' => $commentsCategory = comments_category_info("changelog-date-{$changelogDate}", true), - 'comments' => comments_category_get($commentsCategory['category_id'], user_session_current('user_id', 0)), + 'comments_category' => $commentsCategory, + 'comments_user' => $commentsUser, ]); } diff --git a/public/comments.php b/public/comments.php index 84353d60..6cafd119 100644 --- a/public/comments.php +++ b/public/comments.php @@ -1,6 +1,15 @@ <?php namespace Misuzu; +use Misuzu\Comments\CommentsCategory; +use Misuzu\Comments\CommentsCategoryNotFoundException; +use Misuzu\Comments\CommentsPost; +use Misuzu\Comments\CommentsPostNotFoundException; +use Misuzu\Comments\CommentsPostSaveFailedException; +use Misuzu\Comments\CommentsVote; +use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; + require_once '../misuzu.php'; // basing whether or not this is an xhr request on whether a referrer header is present @@ -20,28 +29,36 @@ if(!CSRF::validateRequest()) { return; } -if(!user_session_active()) { +try { + $currentUserInfo = User::byId(user_session_current('user_id', 0)); +} catch(UserNotFoundException $ex) { echo render_info_or_json($isXHR, 'You must be logged in to manage comments.', 401); return; } -$currentUserId = user_session_current('user_id', 0); - -if(user_warning_check_expiration($currentUserId, MSZ_WARN_BAN) > 0) { +if(user_warning_check_expiration($currentUserInfo->getId(), MSZ_WARN_BAN) > 0) { echo render_info_or_json($isXHR, 'You have been banned, check your profile for more information.', 403); return; } -if(user_warning_check_expiration($currentUserId, MSZ_WARN_SILENCE) > 0) { +if(user_warning_check_expiration($currentUserInfo->getId(), MSZ_WARN_SILENCE) > 0) { echo render_info_or_json($isXHR, 'You have been silenced, check your profile for more information.', 403); return; } header(CSRF::header()); -$commentPerms = comments_get_perms($currentUserId); +$commentPerms = $currentUserInfo->commentPerms(); -$commentId = !empty($_GET['c']) && is_string($_GET['c']) ? (int)$_GET['c'] : 0; -$commentMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : ''; -$commentVote = !empty($_GET['v']) && is_string($_GET['v']) ? (int)$_GET['v'] : MSZ_COMMENTS_VOTE_INDIFFERENT; +$commentId = (int)filter_input(INPUT_GET, 'c', FILTER_SANITIZE_NUMBER_INT); +$commentMode = filter_input(INPUT_GET, 'm'); +$commentVote = (int)filter_input(INPUT_GET, 'v', FILTER_SANITIZE_NUMBER_INT); + +if($commentId > 0) + try { + $commentInfo2 = CommentsPost::byId($commentId); + } catch(CommentsPostNotFoundException $ex) { + echo render_info_or_json($isXHR, 'Post not found.', 404); + return; + } switch($commentMode) { case 'pin': @@ -51,38 +68,37 @@ switch($commentMode) { break; } - $commentInfo = comments_post_get($commentId, false); - - if(!$commentInfo || $commentInfo['comment_deleted'] !== null) { + if($commentInfo2->isDeleted()) { echo render_info_or_json($isXHR, "This comment doesn't exist!", 400); break; } - if($commentInfo['comment_reply_to'] !== null) { + if($commentInfo2->hasParent()) { echo render_info_or_json($isXHR, "You can't pin replies!", 400); break; } $isPinning = $commentMode === 'pin'; - if($isPinning && !empty($commentInfo['comment_pinned'])) { + if($isPinning && $commentInfo2->isPinned()) { echo render_info_or_json($isXHR, 'This comment is already pinned.', 400); break; - } elseif(!$isPinning && empty($commentInfo['comment_pinned'])) { + } elseif(!$isPinning && !$commentInfo2->isPinned()) { echo render_info_or_json($isXHR, "This comment isn't pinned yet.", 400); break; } - $commentPinned = comments_pin_status($commentInfo['comment_id'], $isPinning); + $commentInfo2->setPinned($isPinning); + $commentInfo2->save(); if(!$isXHR) { - redirect($redirect . '#comment-' . $commentInfo['comment_id']); + redirect($redirect . '#comment-' . $commentInfo2->getId()); break; } echo json_encode([ - 'comment_id' => $commentInfo['comment_id'], - 'comment_pinned' => $commentPinned, + 'comment_id' => $commentInfo2->getId(), + 'comment_pinned' => ($time = $commentInfo2->getPinnedTime()) < 0 ? null : date('Y-m-d H:i:s', $time), ]); break; @@ -92,30 +108,24 @@ switch($commentMode) { break; } - if(!comments_vote_type_valid($commentVote)) { - echo render_info_or_json($isXHR, 'Invalid vote action.', 400); - break; - } - - $commentInfo = comments_post_get($commentId, false); - - if(!$commentInfo || $commentInfo['comment_deleted'] !== null) { + if($commentInfo2->isDeleted()) { echo render_info_or_json($isXHR, "This comment doesn't exist!", 400); break; } - $voteResult = comments_vote_add( - $commentInfo['comment_id'], - user_session_current('user_id', 0), - $commentVote - ); + if($commentVote > 0) + $commentInfo2->addPositiveVote($currentUserInfo); + elseif($commentVote < 0) + $commentInfo2->addNegativeVote($currentUserInfo); + else + $commentInfo2->removeVote($currentUserInfo); if(!$isXHR) { - redirect($redirect . '#comment-' . $commentInfo['comment_id']); + redirect($redirect . '#comment-' . $commentInfo2->getId()); break; } - echo json_encode(comments_votes_get($commentInfo['comment_id'])); + echo json_encode($commentInfo2->votes()); break; case 'delete': @@ -124,17 +134,7 @@ switch($commentMode) { break; } - $commentInfo = comments_post_get($commentId, false); - - if(!$commentInfo) { - echo render_info_or_json($isXHR, "This comment doesn't exist.", 400); - break; - } - - $isOwnComment = (int)$commentInfo['user_id'] === $currentUserId; - $isModAction = $commentPerms['can_delete_any'] && !$isOwnComment; - - if($commentInfo['comment_deleted'] !== null) { + if($commentInfo2->isDeleted()) { echo render_info_or_json( $isXHR, $commentPerms['can_delete_any'] ? 'This comment is already marked for deletion.' : "This comment doesn't exist.", @@ -143,24 +143,25 @@ switch($commentMode) { break; } + $isOwnComment = $commentInfo2->getUserId() === $currentUserInfo->getId(); + $isModAction = $commentPerms['can_delete_any'] && !$isOwnComment; + if(!$isModAction && !$isOwnComment) { echo render_info_or_json($isXHR, "You're not allowed to delete comments made by others.", 403); break; } - if(!comments_post_delete($commentInfo['comment_id'])) { - echo render_info_or_json($isXHR, 'Failed to delete comment.', 500); - break; - } + $commentInfo2->setDeleted(true); + $commentInfo2->save(); if($isModAction) { - audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE_MOD, $currentUserId, [ - $commentInfo['comment_id'], - (int)($commentInfo['user_id'] ?? 0), - $commentInfo['username'] ?? '(Deleted User)', + audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE_MOD, $currentUserInfo->getId(), [ + $commentInfo2->getId(), + $commentUserId = $commentInfo2->getUserId(), + ($commentUserId < 1 ? '(Deleted User)' : $commentInfo2->getUser()->getUsername()), ]); } else { - audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE, $currentUserId, [$commentInfo['comment_id']]); + audit_log(MSZ_AUDIT_COMMENT_ENTRY_DELETE, $currentUserInfo->getId(), [$commentInfo2->getId()]); } if($redirect) { @@ -169,7 +170,7 @@ switch($commentMode) { } echo json_encode([ - 'id' => $commentInfo['comment_id'], + 'id' => $commentInfo2->getId(), ]); break; @@ -179,36 +180,27 @@ switch($commentMode) { break; } - $commentInfo = comments_post_get($commentId, false); - - if(!$commentInfo) { - echo render_info_or_json($isXHR, "This comment doesn't exist.", 400); - break; - } - - if($commentInfo['comment_deleted'] === null) { + if(!$commentInfo2->isDeleted()) { echo render_info_or_json($isXHR, "This comment isn't in a deleted state.", 400); break; } - if(!comments_post_delete($commentInfo['comment_id'], false)) { - echo render_info_or_json($isXHR, 'Failed to restore comment.', 500); - break; - } + $commentInfo2->setDeleted(false); + $commentInfo2->save(); - audit_log(MSZ_AUDIT_COMMENT_ENTRY_RESTORE, $currentUserId, [ - $commentInfo['comment_id'], - (int)($commentInfo['user_id'] ?? 0), - $commentInfo['username'] ?? '(Deleted User)', + audit_log(MSZ_AUDIT_COMMENT_ENTRY_RESTORE, $currentUserInfo->getId(), [ + $commentInfo2->getId(), + $commentUserId = $commentInfo2->getUserId(), + ($commentUserId < 1 ? '(Deleted User)' : $commentInfo2->getUser()->getUsername()), ]); if($redirect) { - redirect($redirect . '#comment-' . $commentInfo['comment_id']); + redirect($redirect . '#comment-' . $commentInfo2->getId()); break; } echo json_encode([ - 'id' => $commentInfo['comment_id'], + 'id' => $commentInfo2->getId(), ]); break; @@ -223,26 +215,30 @@ switch($commentMode) { break; } - $categoryId = !empty($_POST['comment']['category']) && is_string($_POST['comment']['category']) ? (int)$_POST['comment']['category'] : 0; - $category = comments_category_info($categoryId); - - if(!$category) { + try { + $categoryInfo = CommentsCategory::byId( + isset($_POST['comment']['category']) && is_string($_POST['comment']['category']) + ? (int)$_POST['comment']['category'] + : 0 + ); + } catch(CommentsCategoryNotFoundException $ex) { echo render_info_or_json($isXHR, 'This comment category doesn\'t exist.', 404); break; } - if(!is_null($category['category_locked']) && !$commentPerms['can_lock']) { + if($categoryInfo->isLocked() && !$commentPerms['can_lock']) { echo render_info_or_json($isXHR, 'This comment category has been locked.', 403); break; } - $commentText = !empty($_POST['comment']['text']) && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : ''; - $commentLock = !empty($_POST['comment']['lock']) && $commentPerms['can_lock']; - $commentPin = !empty($_POST['comment']['pin']) && $commentPerms['can_pin']; + $commentText = !empty($_POST['comment']['text']) && is_string($_POST['comment']['text']) ? $_POST['comment']['text'] : ''; $commentReply = !empty($_POST['comment']['reply']) && is_string($_POST['comment']['reply']) ? (int)$_POST['comment']['reply'] : 0; + $commentLock = !empty($_POST['comment']['lock']) && $commentPerms['can_lock']; + $commentPin = !empty($_POST['comment']['pin']) && $commentPerms['can_pin']; if($commentLock) { - comments_category_lock($categoryId, is_null($category['category_locked'])); + $categoryInfo->setLocked(!$categoryInfo->isLocked()); + $categoryInfo->save(); } if(strlen($commentText) > 0) { @@ -261,30 +257,53 @@ switch($commentMode) { break; } - if($commentReply > 0 && !comments_post_exists($commentReply)) { - echo render_info_or_json($isXHR, 'The comment you tried to reply to does not exist.', 404); - break; + if($commentReply > 0) { + try { + $parentCommentInfo = CommentsPost::byId($commentReply); + } catch(CommentsPostNotFoundException $ex) { + unset($parentCommentInfo); + } + + if(!isset($parentCommentInfo) || $parentCommentInfo->isDeleted()) { + echo render_info_or_json($isXHR, 'The comment you tried to reply to does not exist.', 404); + break; + } } - $commentId = comments_post_create( - user_session_current('user_id', 0), - $categoryId, - $commentText, - $commentPin, - $commentReply - ); + $commentInfo2 = (new CommentsPost) + ->setUser($currentUserInfo) + ->setCategory($categoryInfo) + ->setParsedText($commentText) + ->setPinned($commentPin); - if($commentId < 1) { + if(isset($parentCommentInfo)) + $commentInfo2->setParent($parentCommentInfo); + + try { + $commentInfo2->save(); + } catch(CommentsPostSaveFailedException $ex) { echo render_info_or_json($isXHR, 'Something went horribly wrong.', 500); break; } if($redirect) { - redirect($redirect . '#comment-' . $commentId); + redirect($redirect . '#comment-' . $commentInfo2->getId()); break; } - echo json_encode(comments_post_get($commentId)); + echo json_encode([ + 'comment_id' => $commentInfo2->getId(), + 'category_id' => $commentInfo2->getCategoryId(), + 'comment_text' => $commentInfo2->getText(), + 'comment_created' => ($time = $commentInfo2->getCreatedTime()) < 0 ? null : date('Y-m-d H:i:s', $time), + 'comment_edited' => ($time = $commentInfo2->getEditedTime()) < 0 ? null : date('Y-m-d H:i:s', $time), + 'comment_deleted' => ($time = $commentInfo2->getDeletedTime()) < 0 ? null : date('Y-m-d H:i:s', $time), + 'comment_pinned' => ($time = $commentInfo2->getPinnedTime()) < 0 ? null : date('Y-m-d H:i:s', $time), + 'comment_reply_to' => ($parent = $commentInfo2->getParentId()) < 1 ? null : $parent, + 'user_id' => ($commentInfo2->getUserId() < 1 ? null : $commentInfo2->getUser()->getId()), + 'username' => ($commentInfo2->getUserId() < 1 ? null : $commentInfo2->getUser()->getUsername()), + 'user_colour' => ($commentInfo2->getUserId() < 1 ? 0x40000000 : $commentInfo2->getUser()->getColour()->getRaw()), + ]); break; default: diff --git a/public/index.php b/public/index.php index b0f0c35a..e5272b66 100644 --- a/public/index.php +++ b/public/index.php @@ -72,9 +72,13 @@ $responseStatus = $response->getStatusCode(); header('HTTP/' . $response->getProtocolVersion() . ' ' . $responseStatus . ' ' . $response->getReasonPhrase()); -foreach($response->getHeaders() as $headerName => $headerSet) - foreach($headerSet as $headerLine) - header("{$headerName}: {$headerLine}"); +foreach($response->getHeaders() as $name => $lines) { + $firstLine = true; + foreach($lines as $line) { + header("{$name}: {$line}", $firstLine); + $firstLine = false; + } +} $responseBody = $response->getBody(); diff --git a/public/profile.php b/public/profile.php index e77c844d..82b72868 100644 --- a/public/profile.php +++ b/public/profile.php @@ -3,6 +3,7 @@ namespace Misuzu; use Misuzu\Parsers\Parser; use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; require_once '../misuzu.php'; @@ -10,9 +11,9 @@ $userId = !empty($_GET['u']) && is_string($_GET['u']) ? $_GET['u'] : 0; $profileMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : ''; $isEditing = !empty($_GET['edit']) && is_string($_GET['edit']) ? (bool)$_GET['edit'] : !empty($_POST) && is_array($_POST); -$profileUser = User::findForProfile($userId); - -if(empty($profileUser)) { +try { + $profileUser = User::findForProfile($userId); +} catch(UserNotFoundException $ex) { http_response_code(404); Template::render('profile.index'); return; @@ -22,9 +23,9 @@ $notices = []; $currentUserId = user_session_current('user_id', 0); $viewingAsGuest = $currentUserId === 0; -$viewingOwnProfile = $currentUserId === $profileUser->user_id; +$viewingOwnProfile = $currentUserId === $profileUser->getId(); -$isBanned = user_warning_check_restriction($profileUser->user_id); +$isBanned = user_warning_check_restriction($profileUser->getId()); $userPerms = perms_get_user($currentUserId)[MSZ_PERMS_USER]; $canManageWarnings = perms_check($userPerms, MSZ_PERM_USER_MANAGE_WARNINGS); $canEdit = !$isBanned @@ -34,7 +35,7 @@ $canEdit = !$isBanned || user_check_super($currentUserId) || ( perms_check($userPerms, MSZ_PERM_USER_MANAGE_USERS) - && user_check_authority($currentUserId, $profileUser->user_id) + && user_check_authority($currentUserId, $profileUser->getId()) ) ); @@ -87,7 +88,7 @@ if($isEditing) { $notices[] = MSZ_TMP_USER_ERROR_STRINGS['about']['not-allowed']; } else { $setAboutError = user_set_about_page( - $profileUser->user_id, + $profileUser->getId(), $_POST['about']['text'] ?? '', (int)($_POST['about']['parser'] ?? Parser::PLAIN) ); @@ -106,7 +107,7 @@ if($isEditing) { $notices[] = MSZ_TMP_USER_ERROR_STRINGS['signature']['not-allowed']; } else { $setSignatureError = user_set_signature( - $profileUser->user_id, + $profileUser->getId(), $_POST['signature']['text'] ?? '', (int)($_POST['signature']['parser'] ?? Parser::PLAIN) ); @@ -125,7 +126,7 @@ if($isEditing) { $notices[] = "You aren't allow to change your birthdate."; } else { $setBirthdate = user_set_birthdate( - $profileUser->user_id, + $profileUser->getId(), (int)($_POST['birthdate']['day'] ?? 0), (int)($_POST['birthdate']['month'] ?? 0), (int)($_POST['birthdate']['year'] ?? 0) @@ -154,7 +155,7 @@ if($isEditing) { if(!empty($_FILES['avatar'])) { if(!empty($_POST['avatar']['delete'])) { - user_avatar_delete($profileUser->user_id); + user_avatar_delete($profileUser->getId()); } else { if(!$perms['edit_avatar']) { $notices[] = MSZ_TMP_USER_ERROR_STRINGS['avatar']['not-allowed']; @@ -172,7 +173,7 @@ if($isEditing) { ); } else { $setAvatar = user_avatar_set_from_path( - $profileUser->user_id, + $profileUser->getId(), $_FILES['avatar']['tmp_name']['file'], $avatarProps ); @@ -194,8 +195,8 @@ if($isEditing) { if(!empty($_FILES['background'])) { if((int)($_POST['background']['attach'] ?? -1) === 0) { - user_background_delete($profileUser->user_id); - user_background_set_settings($profileUser->user_id, MSZ_USER_BACKGROUND_ATTACHMENT_NONE); + user_background_delete($profileUser->getId()); + user_background_set_settings($profileUser->getId(), MSZ_USER_BACKGROUND_ATTACHMENT_NONE); } else { if(!$perms['edit_background']) { $notices[] = MSZ_TMP_USER_ERROR_STRINGS['background']['not-allowed']; @@ -213,7 +214,7 @@ if($isEditing) { ); } else { $setBackground = user_background_set_from_path( - $profileUser->user_id, + $profileUser->getId(), $_FILES['background']['tmp_name']['file'], $backgroundProps ); @@ -243,7 +244,7 @@ if($isEditing) { $backgroundSettings |= MSZ_USER_BACKGROUND_ATTRIBUTE_SLIDE; } - user_background_set_settings($profileUser->user_id, $backgroundSettings); + user_background_set_settings($profileUser->getId(), $backgroundSettings); } } } @@ -294,23 +295,23 @@ $profileStats = DB::prepare(sprintf(' ) AS `following_count` FROM `msz_users` AS u WHERE `user_id` = :user_id -', MSZ_USER_RELATION_FOLLOW))->bind('user_id', $profileUser->user_id)->fetch(); +', MSZ_USER_RELATION_FOLLOW))->bind('user_id', $profileUser->getId())->fetch(); $relationInfo = user_session_active() - ? user_relation_info($currentUserId, $profileUser->user_id) + ? user_relation_info($currentUserId, $profileUser->getId()) : []; -$backgroundPath = sprintf('%s/backgrounds/original/%d.msz', MSZ_STORAGE, $profileUser->user_id); +$backgroundPath = sprintf('%s/backgrounds/original/%d.msz', MSZ_STORAGE, $profileUser->getId()); if(is_file($backgroundPath)) { $backgroundInfo = getimagesize($backgroundPath); if($backgroundInfo) { Template::set('site_background', [ - 'url' => url('user-background', ['user' => $profileUser->user_id]), + 'url' => url('user-background', ['user' => $profileUser->getId()]), 'width' => $backgroundInfo[0], 'height' => $backgroundInfo[1], - 'settings' => $profileUser->user_background_settings, + 'settings' => $profileUser->getBackgroundSettings(), ]); } } @@ -322,7 +323,7 @@ switch($profileMode) { case 'following': $template = 'profile.relations'; - $followingCount = user_relation_count_from($profileUser->user_id, MSZ_USER_RELATION_FOLLOW); + $followingCount = user_relation_count_from($profileUser->getId(), MSZ_USER_RELATION_FOLLOW); $followingPagination = new Pagination($followingCount, MSZ_USER_RELATION_FOLLOW_PER_PAGE); if(!$followingPagination->hasValidOffset()) { @@ -331,14 +332,14 @@ switch($profileMode) { } $following = user_relation_users_from( - $profileUser->user_id, MSZ_USER_RELATION_FOLLOW, + $profileUser->getId(), MSZ_USER_RELATION_FOLLOW, $followingPagination->getRange(), $followingPagination->getOffset(), $currentUserId ); Template::set([ - 'title' => $profileUser->username . ' / following', - 'canonical_url' => url('user-profile-following', ['user' => $profileUser->user_id]), + 'title' => $profileUser->getUsername() . ' / following', + 'canonical_url' => url('user-profile-following', ['user' => $profileUser->getId()]), 'profile_users' => $following, 'profile_relation_pagination' => $followingPagination, ]); @@ -346,7 +347,7 @@ switch($profileMode) { case 'followers': $template = 'profile.relations'; - $followerCount = user_relation_count_to($profileUser->user_id, MSZ_USER_RELATION_FOLLOW); + $followerCount = user_relation_count_to($profileUser->getId(), MSZ_USER_RELATION_FOLLOW); $followerPagination = new Pagination($followerCount, MSZ_USER_RELATION_FOLLOW_PER_PAGE); if(!$followerPagination->hasValidOffset()) { @@ -355,14 +356,14 @@ switch($profileMode) { } $followers = user_relation_users_to( - $profileUser->user_id, MSZ_USER_RELATION_FOLLOW, + $profileUser->getId(), MSZ_USER_RELATION_FOLLOW, $followerPagination->getRange(), $followerPagination->getOffset(), $currentUserId ); Template::set([ - 'title' => $profileUser->username . ' / followers', - 'canonical_url' => url('user-profile-followers', ['user' => $profileUser->user_id]), + 'title' => $profileUser->getUsername() . ' / followers', + 'canonical_url' => url('user-profile-followers', ['user' => $profileUser->getId()]), 'profile_users' => $followers, 'profile_relation_pagination' => $followerPagination, ]); @@ -370,7 +371,7 @@ switch($profileMode) { case 'forum-topics': $template = 'profile.topics'; - $topicsCount = forum_topic_count_user($profileUser->user_id, $currentUserId); + $topicsCount = forum_topic_count_user($profileUser->getId(), $currentUserId); $topicsPagination = new Pagination($topicsCount, 20); if(!$topicsPagination->hasValidOffset()) { @@ -379,13 +380,13 @@ switch($profileMode) { } $topics = forum_topic_listing_user( - $profileUser->user_id, $currentUserId, + $profileUser->getId(), $currentUserId, $topicsPagination->getOffset(), $topicsPagination->getRange() ); Template::set([ - 'title' => $profileUser->username . ' / topics', - 'canonical_url' => url('user-profile-forum-topics', ['user' => $profileUser->user_id, 'page' => Pagination::param()]), + 'title' => $profileUser->getUsername() . ' / topics', + 'canonical_url' => url('user-profile-forum-topics', ['user' => $profileUser->getId(), 'page' => Pagination::param()]), 'profile_topics' => $topics, 'profile_topics_pagination' => $topicsPagination, ]); @@ -393,7 +394,7 @@ switch($profileMode) { case 'forum-posts': $template = 'profile.posts'; - $postsCount = forum_post_count_user($profileUser->user_id); + $postsCount = forum_post_count_user($profileUser->getId()); $postsPagination = new Pagination($postsCount, 20); if(!$postsPagination->hasValidOffset()) { @@ -402,7 +403,7 @@ switch($profileMode) { } $posts = forum_post_listing( - $profileUser->user_id, + $profileUser->getId(), $postsPagination->getOffset(), $postsPagination->getRange(), false, @@ -410,8 +411,8 @@ switch($profileMode) { ); Template::set([ - 'title' => $profileUser->username . ' / posts', - 'canonical_url' => url('user-profile-forum-posts', ['user' => $profileUser->user_id, 'page' => Pagination::param()]), + 'title' => $profileUser->getUsername() . ' / posts', + 'canonical_url' => url('user-profile-forum-posts', ['user' => $profileUser->getId(), 'page' => Pagination::param()]), 'profile_posts' => $posts, 'profile_posts_pagination' => $postsPagination, ]); @@ -422,7 +423,7 @@ switch($profileMode) { $warnings = $viewingAsGuest ? [] : user_warning_fetch( - $profileUser->user_id, + $profileUser->getId(), 90, $canManageWarnings ? MSZ_WARN_TYPES_VISIBLE_TO_STAFF diff --git a/public/settings/account.php b/public/settings/account.php index 3e4ffade..153a1e85 100644 --- a/public/settings/account.php +++ b/public/settings/account.php @@ -14,7 +14,7 @@ if(!user_session_active()) { $errors = []; $currentUserId = user_session_current('user_id'); -$currentUser = User::get($currentUserId); +$currentUser = User::byId($currentUserId); $currentEmail = user_email_get($currentUserId); $isRestricted = user_warning_check_restriction($currentUserId); $twoFactorInfo = user_totp_info($currentUserId); diff --git a/public/settings/data.php b/public/settings/data.php index 2ebe0833..0bb988b0 100644 --- a/public/settings/data.php +++ b/public/settings/data.php @@ -27,7 +27,7 @@ function db_to_zip(ZipArchive $archive, int $userId, string $filename, string $q $errors = []; $currentUserId = user_session_current('user_id'); -$currentUser = User::get($currentUserId); +$currentUser = User::byId($currentUserId); if(isset($_POST['action']) && is_string($_POST['action'])) { if(isset($_POST['password']) && is_string($_POST['password']) diff --git a/public/user-assets.php b/public/user-assets.php index ce398d35..fc52d4ac 100644 --- a/public/user-assets.php +++ b/public/user-assets.php @@ -3,15 +3,20 @@ namespace Misuzu; use Misuzu\Imaging\Image; use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; $userAssetsMode = !empty($_GET['m']) && is_string($_GET['m']) ? (string)$_GET['m'] : ''; $misuzuBypassLockdown = $userAssetsMode === 'avatar'; require_once '../misuzu.php'; -$userInfo = User::get((int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT)); -$userExists = empty($userExists); -$userId = $userExists ? $userInfo->getUserId() : 0; +try { + $userInfo = User::byId((int)filter_input(INPUT_GET, 'u', FILTER_SANITIZE_NUMBER_INT)); + $userExists = true; +} catch(UserNotFoundException $ex) { + $userExists = false; +} +$userId = $userExists ? $userInfo->getId() : 0; $canViewImages = !$userExists || !user_warning_check_expiration($userId, MSZ_WARN_BAN) diff --git a/src/Comments/CommentsCategory.php b/src/Comments/CommentsCategory.php new file mode 100644 index 00000000..59bc0c2f --- /dev/null +++ b/src/Comments/CommentsCategory.php @@ -0,0 +1,160 @@ +<?php +namespace Misuzu\Comments; + +use JsonSerializable; +use Misuzu\DB; +use Misuzu\Memoizer; +use Misuzu\Pagination; +use Misuzu\Users\User; + +class CommentsCategoryException extends CommentsException {}; +class CommentsCategoryNotFoundException extends CommentsCategoryException {}; + +class CommentsCategory implements JsonSerializable { + // Database fields + private $category_id = -1; + private $category_name = ''; + private $category_created = null; + private $category_locked = null; + + private $postCount = -1; + + public const TABLE = 'comments_categories'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`category_id`, %1$s.`category_name`' + . ', UNIX_TIMESTAMP(%1$s.`category_created`) AS `category_created`' + . ', UNIX_TIMESTAMP(%1$s.`category_locked`) AS `category_locked`'; + + public function __construct(?string $name = null) { + if($name !== null) + $this->setName($name); + } + + public function getId(): int { + return $this->category_id < 1 ? -1 : $this->category_id; + } + + public function getName(): string { + return $this->category_name; + } + public function setName(string $name): self { + $this->category_name = $name; + return $this; + } + + public function getCreatedTime(): int { + return $this->category_created === null ? -1 : $this->category_created; + } + + public function getLockedTime(): int { + return $this->category_locked === null ? -1 : $this->category_locked; + } + public function isLocked(): bool { + return $this->getLockedTime() >= 0; + } + public function setLocked(bool $locked): self { + if($locked !== $this->isLocked()) + $this->category_locked = $locked ? time() : null; + return $this; + } + + // Purely cosmetic, do not use for anything other than displaying + public function getPostCount(): int { + if($this->postCount < 0) + $this->postCount = (int)DB::prepare(' + SELECT COUNT(`comment_id`) + FROM `msz_comments_posts` + WHERE `category_id` = :cat_id + AND `comment_deleted` IS NULL + ')->bind('cat_id', $this->getId())->fetchColumn(); + + return $this->postCount; + } + + public function jsonSerialize() { + return [ + 'id' => $this->getId(), + 'name' => $this->getName(), + 'created' => ($created = $this->getCreatedTime()) < 0 ? null : date('c', $created), + 'locked' => ($locked = $this->getLockedTime()) < 0 ? null : date('c', $locked), + ]; + } + + public function save(): void { + $isInsert = $this->getId() < 1; + if($isInsert) { + $query = 'INSERT INTO `%1$s%2$s` (`category_name`, `category_locked`) VALUES' + . ' (:name, :locked)'; + } else { + $query = 'UPDATE `%1$s%2$s` SET `category_name` = :name, `category_locked` = FROM_UNIXTIME(:locked)' + . ' WHERE `category_id` = :category'; + } + + $saveCategory = DB::prepare(sprintf($query, DB::PREFIX, self::TABLE)) + ->bind('name', $this->category_name) + ->bind('locked', $this->category_locked); + + if($isInsert) { + $this->category_id = $saveCategory->executeGetId(); + $this->category_created = time(); + } else { + $saveCategory->bind('category', $this->getId()) + ->execute(); + } + } + + public function posts(?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $rootOnly = true, bool $includeDeleted = true): array { + return CommentsPost::byCategory($this, $voteUser, $includeVotes, $pagination, $rootOnly, $includeDeleted); + } + public function votes(?User $user = null, bool $rootOnly = true, ?Pagination $pagination = null): array { + return CommentsVote::byCategory($this, $user, $rootOnly, $pagination); + } + + private static function getMemoizer() { + 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 { + return self::getMemoizer()->find($categoryId, function() use ($categoryId) { + $cat = DB::prepare(self::byQueryBase() . ' WHERE `category_id` = :cat_id') + ->bind('cat_id', $categoryId) + ->fetchObject(self::class); + if(!$cat) + throw new CommentsCategoryNotFoundException; + return $cat; + }); + } + public static function byName(string $categoryName): self { + return self::getMemoizer()->find(function($category) use ($categoryName) { + return $category->getName() === $categoryName; + }, function() use ($categoryName) { + $cat = DB::prepare(self::byQueryBase() . ' WHERE `category_name` = :name') + ->bind('name', $categoryName) + ->fetchObject(self::class); + if(!$cat) + throw new CommentsCategoryNotFoundException; + return $cat; + }); + } + public static function all(?Pagination $pagination = null): array { + $catsQuery = self::byQueryBase() + . ' ORDER BY `category_id` ASC'; + + if($pagination !== null) + $catsQuery .= ' LIMIT :range OFFSET :offset'; + + $getCats = DB::prepare($catsQuery); + + if($pagination !== null) + $getCats->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getCats->fetchObjects(self::class); + } +} \ No newline at end of file diff --git a/src/Comments/CommentsException.php b/src/Comments/CommentsException.php new file mode 100644 index 00000000..cd7f45a0 --- /dev/null +++ b/src/Comments/CommentsException.php @@ -0,0 +1,6 @@ +<?php +namespace Misuzu\Comments; + +use Exception; + +class CommentsException extends Exception {} diff --git a/src/Comments/CommentsParser.php b/src/Comments/CommentsParser.php new file mode 100644 index 00000000..34b9e3fd --- /dev/null +++ b/src/Comments/CommentsParser.php @@ -0,0 +1,59 @@ +<?php +namespace Misuzu\Comments; + +use Misuzu\DB; +use Misuzu\Users\User; + +class CommentsParser { + private const MARKUP_USERNAME = '#\B(?:@{1}(' . MSZ_USERNAME_REGEX . '))#u'; + private const MARKUP_USERID = '#\B(?:@{2}([0-9]+))#u'; + + public static function parseForStorage(string $text): string { + return preg_replace_callback(self::MARKUP_USERNAME, function ($matches) { + return ($userId = user_id_from_username($matches[1])) < 1 + ? $matches[0] : "@@{$userId}"; + }, $text); + } + + public static function parseForDisplay(string $text): string { + $text = htmlentities($text); + + $text = preg_replace_callback( + '/(^|[\n ])([\w]*?)([\w]*?:\/\/[\w]+[^ \,\"\n\r\t<]*)/is', + function ($matches) { + $matches[0] = trim($matches[0]); + $url = parse_url($matches[0]); + if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true)) + return $matches[0]; + return sprintf(' <a href="%1$s" class="link" target="_blank" rel="noreferrer noopener">%1$s</a>', $matches[0]); + }, + $text + ); + + $text = preg_replace_callback(self::MARKUP_USERID, function ($matches) { + $getInfo = DB::prepare(' + SELECT + u.`user_id`, u.`username`, + COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` + FROM `msz_users` as u + LEFT JOIN `msz_roles` as r + ON u.`display_role` = r.`role_id` + WHERE `user_id` = :user_id + '); + $getInfo->bind('user_id', $matches[1]); + $info = $getInfo->fetch(); + + if(empty($info)) + return $matches[0]; + + return sprintf( + '<a href="%s" class="comment__mention", style="%s">@%s</a>', + url('user-profile', ['user' => $info['user_id']]), + html_colour($info['user_colour']), + $info['username'] + ); + }, $text); + + return nl2br($text); + } +} diff --git a/src/Comments/CommentsPost.php b/src/Comments/CommentsPost.php new file mode 100644 index 00000000..ad8f3235 --- /dev/null +++ b/src/Comments/CommentsPost.php @@ -0,0 +1,349 @@ +<?php +namespace Misuzu\Comments; + +use JsonSerializable; +use Misuzu\DB; +use Misuzu\Pagination; +use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; + +class CommentsPostException extends CommentsException {} +class CommentsPostNotFoundException extends CommentsPostException {} +class CommentsPostHasNoParentException extends CommentsPostException {} +class CommentsPostSaveFailedException extends CommentsPostException {} + +class CommentsPost implements JsonSerializable { + // Database fields + private $comment_id = -1; + private $category_id = -1; + private $user_id = null; + private $comment_reply_to = null; + private $comment_text = ''; + private $comment_created = null; + private $comment_pinned = null; + private $comment_edited = null; + private $comment_deleted = null; + + // Virtual fields + private $comment_likes = -1; + private $comment_dislikes = -1; + private $user_vote = null; + + private $category = null; + private $user = null; + private $userLookedUp = false; + private $parentPost = null; + + public const TABLE = 'comments_posts'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`comment_id`, %1$s.`category_id`, %1$s.`user_id`, %1$s.`comment_reply_to`, %1$s.`comment_text`' + . ', UNIX_TIMESTAMP(%1$s.`comment_created`) AS `comment_created`' + . ', UNIX_TIMESTAMP(%1$s.`comment_pinned`) AS `comment_pinned`' + . ', UNIX_TIMESTAMP(%1$s.`comment_edited`) AS `comment_edited`' + . ', UNIX_TIMESTAMP(%1$s.`comment_deleted`) AS `comment_deleted`'; + private const LIKE_VOTE_SELECT = '(SELECT COUNT(`comment_id`) FROM `' . DB::PREFIX . CommentsVote::TABLE . '` WHERE `comment_id` = %1$s.`comment_id` AND `comment_vote` = ' . CommentsVote::LIKE . ') AS `comment_likes`'; + private const DISLIKE_VOTE_SELECT = '(SELECT COUNT(`comment_id`) FROM `' . DB::PREFIX . CommentsVote::TABLE . '` WHERE `comment_id` = %1$s.`comment_id` AND `comment_vote` = ' . CommentsVote::DISLIKE . ') AS `comment_dislikes`'; + private const USER_VOTE_SELECT = '(SELECT `comment_vote` FROM `' . DB::PREFIX . CommentsVote::TABLE . '` WHERE `comment_id` = %1$s.`comment_id` AND `user_id` = :user) AS `user_vote`'; + + public function getId(): int { + return $this->comment_id < 1 ? -1 : $this->comment_id; + } + + public function getCategoryId(): int { + return $this->category_id < 1 ? -1 : $this->category_id; + } + public function setCategoryId(int $categoryId): self { + $this->category_id = $categoryId; + $this->category = null; + return $this; + } + public function getCategory(): CommentsCategory { + if($this->category === null) + $this->category = CommentsCategory::byId($this->getCategoryId()); + return $this->category; + } + public function setCategory(CommentsCategory $category): self { + $this->category_id = $category->getId(); + $this->category = null; + 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->userLookedUp && ($userId = $this->getUserId()) > 0) { + $this->userLookedUp = true; + try { + $this->user = User::byId($userId); + } catch(UserNotFoundException $ex) {} + } + return $this->user; + } + public function setUser(?User $user): self { + $this->user_id = $user === null ? null : $user->getId(); + $this->user = $user; + return $this; + } + + public function getParentId(): int { + return $this->comment_reply_to < 1 ? -1 : $this->comment_reply_to; + } + public function setParentId(int $parentId): self { + $this->comment_reply_to = $parentId < 1 ? null : $parentId; + $this->parentPost = null; + return $this; + } + public function hasParent(): bool { + return $this->getParentId() > 0; + } + public function getParent(): CommentsPost { + if(!$this->hasParent()) + throw new CommentsPostHasNoParentException; + if($this->parentPost === null) + $this->parentPost = CommentsPost::byId($this->getParentId()); + return $this->parentPost; + } + public function setParent(?CommentsPost $parent): self { + $this->comment_reply_to = $parent === null ? null : $parent->getId(); + $this->parentPost = $parent; + return $this; + } + + public function getText(): string { + return $this->comment_text; + } + public function setText(string $text): self { + $this->comment_text = $text; + return $this; + } + public function getParsedText(): string { + return CommentsParser::parseForDisplay($this->getText()); + } + public function setParsedText(string $text): self { + return $this->setText(CommentsParser::parseForStorage($text)); + } + + public function getCreatedTime(): int { + return $this->comment_created === null ? -1 : $this->comment_created; + } + + public function getPinnedTime(): int { + return $this->comment_pinned === null ? -1 : $this->comment_pinned; + } + public function isPinned(): bool { + return $this->getPinnedTime() >= 0; + } + public function setPinned(bool $pinned): self { + if($this->isPinned() !== $pinned) + $this->comment_pinned = $pinned ? time() : null; + return $this; + } + + public function getEditedTime(): int { + return $this->comment_edited === null ? -1 : $this->comment_edited; + } + public function isEdited(): bool { + return $this->getEditedTime() >= 0; + } + + public function getDeletedTime(): int { + return $this->comment_deleted === null ? -1 : $this->comment_deleted; + } + public function isDeleted(): bool { + return $this->getDeletedTime() >= 0; + } + public function setDeleted(bool $deleted): self { + if($this->isDeleted() !== $deleted) + $this->comment_deleted = $deleted ? time() : null; + return $this; + } + + public function getLikes(): int { + return $this->comment_likes; + } + public function getDislikes(): int { + return $this->comment_dislikes; + } + + public function hasUserVote(): bool { + return $this->user_vote !== null; + } + public function getUserVote(): int { + return $this->user_vote ?? 0; + } + + public function jsonSerialize() { + $json = [ + 'id' => $this->getId(), + 'category' => $this->getCategoryId(), + 'user' => $this->getUserId(), + 'parent' => ($parent = $this->getParentId()) < 1 ? null : $parent, + 'text' => $this->getText(), + 'created' => ($created = $this->getCreatedTime()) < 0 ? null : date('c', $created), + 'pinned' => ($pinned = $this->getPinnedTime()) < 0 ? null : date('c', $pinned), + 'edited' => ($edited = $this->getEditedTime()) < 0 ? null : date('c', $edited), + 'deleted' => ($deleted = $this->getDeletedTime()) < 0 ? null : date('c', $deleted), + ]; + + if(($likes = $this->getLikes()) >= 0) + $json['likes'] = $likes; + if(($dislikes = $this->getDislikes()) >= 0) + $json['dislikes'] = $dislikes; + + if($this->hasUserVote()) + $json['user_vote'] = $this->getUserVote(); + + return $json; + } + + public function save(): void { + $isInsert = $this->getId() < 1; + if($isInsert) { + $query = 'INSERT INTO `%1$s%2$s` (`category_id`, `user_id`, `comment_reply_to`, `comment_text`' + . ', `comment_pinned`, `comment_deleted`) VALUES' + . ' (:category, :user, :parent, :text, FROM_UNIXTIME(:pinned), FROM_UNIXTIME(:deleted))'; + } else { + $query = 'UPDATE `%1$s%2$s` SET `category_id` = :category, `user_id` = :user, `comment_reply_to` = :parent' + . ', `comment_text` = :text, `comment_pinned` = FROM_UNIXTIME(:pinned), `comment_deleted` = FROM_UNIXTIME(:deleted)' + . ' WHERE `comment_id` = :post'; + } + + $savePost = DB::prepare(sprintf($query, DB::PREFIX, self::TABLE)) + ->bind('category', $this->category_id) + ->bind('user', $this->user_id) + ->bind('parent', $this->comment_reply_to) + ->bind('text', $this->comment_text) + ->bind('pinned', $this->comment_pinned) + ->bind('deleted', $this->comment_deleted); + + if($isInsert) { + $this->comment_id = $savePost->executeGetId(); + if($this->comment_id < 1) + throw new CommentsPostSaveFailedException; + $this->comment_created = time(); + } else { + $this->comment_edited = time(); + $savePost->bind('post', $this->getId()); + if(!$savePost->execute()) + throw new CommentsPostSaveFailedException; + } + } + + public function nuke(): void { + $replies = $this->replies(null, true); + foreach($replies as $reply) + $reply->nuke(); + DB::prepare('DELETE FROM `' . DB::PREFIX . self::TABLE . '` WHERE `comment_id` = :comment') + ->bind('comment_id', $this->getId()) + ->execute(); + } + + public function replies(?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $includeDeleted = true): array { + return CommentsPost::byParent($this, $voteUser, $includeVotes, $pagination, $includeDeleted); + } + public function votes(): CommentsVoteCount { + return CommentsVote::countByPost($this); + } + public function childVotes(?User $user = null, ?Pagination $pagination = null): array { + return CommentsVote::byParent($this, $user, $pagination); + } + + public function addPositiveVote(User $user): void { + CommentsVote::create($this, $user, CommentsVote::LIKE); + } + public function addNegativeVote(User $user): void { + CommentsVote::create($this, $user, CommentsVote::DISLIKE); + } + public function removeVote(User $user): void { + CommentsVote::delete($this, $user); + } + + public function getVoteFromUser(User $user): CommentsVote { + return CommentsVote::byExact($this, $user); + } + + private static function byQueryBase(bool $includeVotes = true, bool $includeUserVote = false): string { + $select = self::SELECT; + if($includeVotes) + $select .= ', ' . self::LIKE_VOTE_SELECT + . ', ' . self::DISLIKE_VOTE_SELECT; + if($includeUserVote) + $select .= ', ' . self::USER_VOTE_SELECT; + return sprintf(self::QUERY_SELECT, sprintf($select, self::TABLE)); + } + public static function byId(int $postId): self { + $getPost = DB::prepare(self::byQueryBase() . ' WHERE `comment_id` = :post_id'); + $getPost->bind('post_id', $postId); + $post = $getPost->fetchObject(self::class); + if(!$post) + throw new CommentsPostNotFoundException; + return $post; + } + public static function byCategory(CommentsCategory $category, ?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $rootOnly = true, bool $includeDeleted = true): array { + $postsQuery = self::byQueryBase($includeVotes, $voteUser !== null) + . ' WHERE `category_id` = :category' + . (!$rootOnly ? '' : ' AND `comment_reply_to` IS NULL') + . ($includeDeleted ? '' : ' AND `comment_deleted` IS NULL') + . ' ORDER BY `comment_deleted` ASC, `comment_pinned` DESC, `comment_id` DESC'; + + if($pagination !== null) + $postsQuery .= ' LIMIT :range OFFSET :offset'; + + $getPosts = DB::prepare($postsQuery) + ->bind('category', $category->getId()); + + if($voteUser !== null) + $getPosts->bind('user', $voteUser->getId()); + + if($pagination !== null) + $getPosts->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getPosts->fetchObjects(self::class); + } + public static function byParent(CommentsPost $parent, ?User $voteUser = null, bool $includeVotes = true, ?Pagination $pagination = null, bool $includeDeleted = true): array { + $postsQuery = self::byQueryBase($includeVotes, $voteUser !== null) + . ' WHERE `comment_reply_to` = :parent' + . ($includeDeleted ? '' : ' AND `comment_deleted` IS NULL') + . ' ORDER BY `comment_deleted` ASC, `comment_pinned` DESC, `comment_id` ASC'; + + if($pagination !== null) + $postsQuery .= ' LIMIT :range OFFSET :offset'; + + $getPosts = DB::prepare($postsQuery) + ->bind('parent', $parent->getId()); + + if($voteUser !== null) + $getPosts->bind('user', $voteUser->getId()); + + if($pagination !== null) + $getPosts->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getPosts->fetchObjects(self::class); + } + public static function all(?Pagination $pagination = null, bool $rootOnly = true, bool $includeDeleted = false): array { + $postsQuery = self::byQueryBase() + . ' WHERE 1' // this is disgusting + . (!$rootOnly ? '' : ' AND `comment_reply_to` IS NULL') + . ($includeDeleted ? '' : ' AND `comment_deleted` IS NULL') + . ' ORDER BY `comment_id` DESC'; + + if($pagination !== null) + $postsQuery .= ' LIMIT :range OFFSET :offset'; + + $getPosts = DB::prepare($postsQuery); + + if($pagination !== null) + $getPosts->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getPosts->fetchObjects(self::class); + } +} diff --git a/src/Comments/CommentsVote.php b/src/Comments/CommentsVote.php new file mode 100644 index 00000000..0347febc --- /dev/null +++ b/src/Comments/CommentsVote.php @@ -0,0 +1,246 @@ +<?php +namespace Misuzu\Comments; + +use JsonSerializable; +use Misuzu\DB; +use Misuzu\Pagination; +use Misuzu\Users\User; + +class CommentsVoteException extends CommentsException {} +class CommentsVoteCountFailedException extends CommentsVoteException {} +class CommentsVoteCreateFailedException extends CommentsVoteException {} + +class CommentsVoteCount implements JsonSerializable { + private $comment_id = -1; + private $likes = 0; + private $dislikes = 0; + private $total = 0; + + public function getPostId(): int { + return $this->comment_id < 1 ? -1 : $this->comment_id; + } + public function getLikes(): int { + return $this->likes; + } + public function getDislikes(): int { + return $this->dislikes; + } + public function getTotal(): int { + return $this->total; + } + + public function jsonSerialize() { + return [ + 'id' => $this->getPostId(), + 'likes' => $this->getLikes(), + 'dislikes' => $this->getDislikes(), + 'total' => $this->getTotal(), + ]; + } +} + +class CommentsVote implements JsonSerializable { + // Database fields + private $comment_id = -1; + private $user_id = -1; + private $comment_vote = 0; + + private $comment = null; + private $user = null; + + public const LIKE = 1; + public const NONE = 0; + public const DISLIKE = -1; + + public const TABLE = 'comments_votes'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; + private const SELECT = '%1$s.`comment_id`, %1$s.`user_id`, %1$s.`comment_vote`'; + + private const QUERY_COUNT = 'SELECT %3$d AS `comment_id`' + . ', (SELECT COUNT(`comment_id`) FROM `%1$s%2$s` WHERE %6$s) AS `total`' + . ', (SELECT COUNT(`comment_id`) FROM `%1$s%2$s` WHERE %6$s AND `comment_vote` = %4$d) AS `likes`' + . ', (SELECT COUNT(`comment_id`) FROM `%1$s%2$s` WHERE %6$s AND `comment_vote` = %5$d) AS `dislikes`'; + + public function getPostId(): int { + return $this->comment_id < 1 ? -1 : $this->comment_id; + } + public function getPost(): CommentsPost { + if($this->comment === null) + $this->comment = CommentsPost::byId($this->comment_id); + return $this->comment; + } + + 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->user_id); + return $this->user; + } + + public function getVote(): int { + return $this->comment_vote; + } + + public function jsonSerialize() { + return [ + 'post' => $this->getPostId(), + 'user' => $this->getUserId(), + 'vote' => $this->getVote(), + ]; + } + + public static function create(CommentsPost $post, User $user, int $vote, bool $return = false): ?self { + $createVote = DB::prepare(' + REPLACE INTO `msz_comments_votes` + (`comment_id`, `user_id`, `comment_vote`) + VALUES + (:post, :user, :vote) + ') ->bind('post', $post->getId()) + ->bind('user', $user->getId()) + ->bind('vote', $vote); + + if(!$createVote->execute()) + throw new CommentsVoteCreateFailedException; + if(!$return) + return null; + + return CommentsVote::byExact($post, $user); + } + + public static function delete(CommentsPost $post, User $user): void { + DB::prepare('DELETE FROM `msz_comments_votes` WHERE `comment_id` = :post AND `user_id` = :user') + ->bind('post', $post->getId()) + ->bind('user', $user->getId()) + ->execute(); + } + + private static function countQueryBase(int $id, string $condition = '1'): string { + return sprintf(self::QUERY_COUNT, DB::PREFIX, self::TABLE, $id, self::LIKE, self::DISLIKE, $condition); + } + public static function countByPost(CommentsPost $post): CommentsVoteCount { + $count = DB::prepare(self::countQueryBase($post->getId(), sprintf('`comment_id` = %d', $post->getId()))) + ->fetchObject(CommentsVoteCount::class); + if(!$count) + throw new CommentsVoteCountFailedException; + return $count; + } + + private static function fake(CommentsPost $post, User $user, int $vote): CommentsVote { + $fake = new static; + $fake->comment_id = $post->getId(); + $fake->comment = $post; + $fake->user_id = $user->getId(); + $fake->user = $user; + $fake->comment_vote = $vote; + return $fake; + } + + private static function byQueryBase(): string { + return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE)); + } + public static function byExact(CommentsPost $post, User $user): self { + $vote = DB::prepare(self::byQueryBase() . ' WHERE `comment_id` = :post_id AND `user_id` = :user_id') + ->bind('post_id', $post->getId()) + ->bind('user_id', $user->getId()) + ->fetchObject(self::class); + if(!$vote) + return self::fake($post, $user, self::NONE); + return $vote; + } + public static function byPost(CommentsPost $post, ?User $user = null, ?Pagination $pagination = null): array { + $votesQuery = self::byQueryBase() + . ' WHERE `comment_id` = :post' + . ($user === null ? '' : ' AND `user_id` = :user'); + + if($pagination !== null) + $votesQuery .= ' LIMIT :range OFFSET :offset'; + + $getVotes = DB::prepare($votesQuery) + ->bind('post', $post->getId()); + + if($user !== null) + $getVotes->bind('user', $user->getId()); + + if($pagination !== null) + $getVotes->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getVotes->fetchObjects(self::class); + } + public static function byUser(User $user, ?Pagination $pagination = null): array { + $votesQuery = self::byQueryBase() + . ' WHERE `user_id` = :user'; + + if($pagination !== null) + $votesQuery .= ' LIMIT :range OFFSET :offset'; + + $getVotes = DB::prepare($votesQuery) + ->bind('user', $user->getId()); + + if($pagination !== null) + $getVotes->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getVotes->fetchObjects(self::class); + } + public static function byCategory(CommentsCategory $category, ?User $user = null, bool $rootOnly = true, ?Pagination $pagination = null): array { + $votesQuery = self::byQueryBase() + . ' WHERE `comment_id` IN' + . ' (SELECT `comment_id` FROM `' . DB::PREFIX . CommentsPost::TABLE . '` WHERE `category_id` = :category' + . (!$rootOnly ? '' : ' AND `comment_reply_to` IS NULL') + . ')' + . ($user === null ? '' : ' AND `user_id` = :user'); + + if($pagination !== null) + $votesQuery .= ' LIMIT :range OFFSET :offset'; + + $getVotes = DB::prepare($votesQuery) + ->bind('category', $category->getId()); + + if($user !== null) + $getVotes->bind('user', $user->getId()); + + if($pagination !== null) + $getVotes->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getVotes->fetchObjects(self::class); + } + public static function byParent(CommentsPost $parent, ?User $user = null, ?Pagination $pagination = null): array { + $votesQuery = self::byQueryBase() + . ' WHERE `comment_id` IN' + . ' (SELECT `comment_id` FROM `' . DB::PREFIX . CommentsPost::TABLE . '` WHERE `comment_reply_to` = :parent)' + . ($user === null ? '' : ' AND `user_id` = :user'); + + if($pagination !== null) + $votesQuery .= ' LIMIT :range OFFSET :offset'; + + $getVotes = DB::prepare($votesQuery) + ->bind('parent', $parent->getId()); + + if($user !== null) + $getVotes->bind('user', $user->getId()); + + if($pagination !== null) + $getVotes->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getVotes->fetchObjects(self::class); + } + public static function all(?Pagination $pagination = null): array { + $votesQuery = self::byQueryBase(); + + if($pagination !== null) + $votesQuery .= ' LIMIT :range OFFSET :offset'; + + $getVotes = DB::prepare($votesQuery); + + if($pagination !== null) + $getVotes->bind('range', $pagination->getRange()) + ->bind('offset', $pagination->getOffset()); + + return $getVotes->fetchObjects(self::class); + } +} diff --git a/src/DB.php b/src/DB.php index 982ee603..a07cb1f4 100644 --- a/src/DB.php +++ b/src/DB.php @@ -8,7 +8,6 @@ final class DB { private static $instance; public const PREFIX = 'msz_'; - public const QUERY_SELECT = 'SELECT %2$s FROM `' . self::PREFIX . '%1$s` AS %1$s'; public const ATTRS = [ PDO::ATTR_CASE => PDO::CASE_NATURAL, @@ -16,11 +15,8 @@ final class DB { PDO::ATTR_ORACLE_NULLS => PDO::NULL_NATURAL, PDO::ATTR_STRINGIFY_FETCHES => false, PDO::ATTR_EMULATE_PREPARES => false, - PDO::MYSQL_ATTR_INIT_COMMAND => " - SET SESSION - sql_mode = 'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION', - time_zone = '+00:00'; - ", + PDO::MYSQL_ATTR_INIT_COMMAND => 'SET SESSION time_zone = \'+00:00\'' + . ', sql_mode = \'STRICT_TRANS_TABLES,NO_ZERO_IN_DATE,NO_ZERO_DATE,ERROR_FOR_DIVISION_BY_ZERO,NO_ENGINE_SUBSTITUTION\'', ]; public static function init(...$args) { diff --git a/src/Debug/Stopwatch.php b/src/Debug/Stopwatch.php index 22580101..29a9d669 100644 --- a/src/Debug/Stopwatch.php +++ b/src/Debug/Stopwatch.php @@ -1,54 +1,63 @@ <?php namespace Misuzu\Debug; -class Stopwatch { +final class Stopwatch { private $startTime = 0; private $stopTime = 0; private $laps = []; private static $instance = null; - public static function __callStatic(string $name, array $args) { - if(self::$instance === null) - self::$instance = new static; - return self::$instance->{substr($name, 1)}(...$args); + public function __call(string $name, array $args) { + if($name[0] === '_') + return null; + return $this->{'_' . $name}(...$args); } - public function __construct() {} + public static function __callStatic(string $name, array $args) { + if($name[0] === '_') + return null; + if(self::$instance === null) + self::$instance = new static; + return self::$instance->{'_' . $name}(...$args); + } private static function time() { return microtime(true); } - public function start(): void { + public function _start(): void { $this->startTime = self::time(); } - public function lap(string $text): void { + public function _lap(string $text): void { $this->laps[$text] = self::time(); } - public function stop(): void { + public function _stop(): void { $this->stopTime = self::time(); } - public function reset(): void { + public function _reset(): void { $this->laps = []; $this->startTime = 0; $this->stopTime = 0; } - public function elapsed(): float { + public function _elapsed(): float { return $this->stopTime - $this->startTime; } - public function laps(): array { + public function _laps(): array { $laps = []; - - foreach($this->laps as $name => $time) { + foreach($this->laps as $name => $time) $laps[$name] = $time - $this->startTime; - } - return $laps; } + + public function _dump(bool $trimmed = false): void { + header('X-Misuzu-Elapsed: ' . $this->_elapsed()); + foreach($this->_laps() as $text => $time) + header('X-Misuzu-Lap: ' . ($trimmed ? number_format($time, 6) : $time) . ' ' . $text, false); + } } diff --git a/src/Http/Handlers/NewsHandler.php b/src/Http/Handlers/NewsHandler.php index a0a24699..1d3b58d9 100644 --- a/src/Http/Handlers/NewsHandler.php +++ b/src/Http/Handlers/NewsHandler.php @@ -15,6 +15,8 @@ use Misuzu\News\NewsPost; use Misuzu\News\NewsCategoryNotFoundException; use Misuzu\News\NewsPostNotException; use Misuzu\Parsers\Parser; +use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; final class NewsHandler extends Handler { public function index(HttpResponse $response, HttpRequest $request) { @@ -42,11 +44,9 @@ final class NewsHandler extends Handler { if(!$categoryPagination->hasValidOffset()) return 404; - $posts = NewsPost::byCategory($categoryInfo, $categoryPagination); - $response->setTemplate('news.category', [ 'category_info' => $categoryInfo, - 'posts' => $posts, + 'posts' => $categoryInfo->posts($categoryPagination), 'news_pagination' => $categoryPagination, ]); } @@ -63,12 +63,16 @@ final class NewsHandler extends Handler { $postInfo->ensureCommentsSection(); $commentsInfo = $postInfo->getCommentSection(); + try { + $commentsUser = User::byId(user_session_current('user_id', 0)); + } catch(UserNotFoundException $ex) { + $commentsUser = null; + } $response->setTemplate('news.post', [ 'post_info' => $postInfo, - 'comments_perms' => comments_get_perms(user_session_current('user_id', 0)), - 'comments_category' => $commentsInfo, - 'comments' => comments_category_get($commentsInfo['category_id'], user_session_current('user_id', 0)), + 'comments_info' => $commentsInfo, + 'comments_user' => $commentsUser, ]); } @@ -76,7 +80,7 @@ final class NewsHandler extends Handler { private function createFeed(string $feedMode, ?NewsCategory $categoryInfo, array $posts): Feed { $hasCategory = !empty($categoryInfo); $pagination = new Pagination(10); - $posts = $hasCategory ? NewsPost::byCategory($categoryInfo, $pagination) : NewsPost::all($pagination, true); + $posts = $hasCategory ? $categoryInfo->posts($pagination) : NewsPost::all($pagination, true); $feed = (new Feed) ->setTitle(Config::get('site.name', Config::TYPE_STR, 'Misuzu') . ' ยป ' . ($hasCategory ? $categoryInfo->getName() : 'Featured News')) @@ -132,7 +136,7 @@ final class NewsHandler extends Handler { $response->setContentType('application/atom+xml; charset=utf-8'); return (new AtomFeedSerializer)->serializeFeed( - self::createFeed('atom', $categoryInfo, NewsPost::byCategory($categoryInfo, new Pagination(10))) + self::createFeed('atom', $categoryInfo, $categoryInfo->posts(new Pagination(10))) ); } @@ -145,7 +149,7 @@ final class NewsHandler extends Handler { $response->setContentType('application/rss+xml; charset=utf-8'); return (new RssFeedSerializer)->serializeFeed( - self::createFeed('rss', $categoryInfo, NewsPost::byCategory($categoryInfo, new Pagination(10))) + self::createFeed('rss', $categoryInfo, $categoryInfo->posts(new Pagination(10))) ); } diff --git a/src/Http/Handlers/SockChatHandler.php b/src/Http/Handlers/SockChatHandler.php index 01414f78..437ed714 100644 --- a/src/Http/Handlers/SockChatHandler.php +++ b/src/Http/Handlers/SockChatHandler.php @@ -246,29 +246,29 @@ final class SockChatHandler extends Handler { if(!isset($userId) || $userId < 1) return ['success' => false, 'reason' => 'unknown']; - $userInfo = User::get($userId); + $userInfo = User::byId($userId); - if($userInfo === null || !$userInfo->hasUserId()) + if($userInfo === null) return ['success' => false, 'reason' => 'user']; $perms = self::PERMS_DEFAULT; - if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_MANAGE_USERS)) + if(perms_check_user(MSZ_PERMS_USER, $userInfo->getId(), MSZ_PERM_USER_MANAGE_USERS)) $perms |= self::PERMS_MANAGE_USERS; - if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_MANAGE_WARNINGS)) + if(perms_check_user(MSZ_PERMS_USER, $userInfo->getId(), MSZ_PERM_USER_MANAGE_WARNINGS)) $perms |= self::PERMS_MANAGE_WARNS; - if(perms_check_user(MSZ_PERMS_USER, $userInfo->user_id, MSZ_PERM_USER_CHANGE_BACKGROUND)) + if(perms_check_user(MSZ_PERMS_USER, $userInfo->getId(), MSZ_PERM_USER_CHANGE_BACKGROUND)) $perms |= self::PERMS_CHANGE_BACKG; - if(perms_check_user(MSZ_PERMS_FORUM, $userInfo->user_id, MSZ_PERM_FORUM_MANAGE_FORUMS)) + if(perms_check_user(MSZ_PERMS_FORUM, $userInfo->getId(), MSZ_PERM_FORUM_MANAGE_FORUMS)) $perms |= self::PERMS_MANAGE_FORUM; return [ 'success' => true, - 'user_id' => $userInfo->getUserId(), + 'user_id' => $userInfo->getId(), 'username' => $userInfo->getUsername(), 'colour_raw' => $userInfo->getColourRaw(), 'hierarchy' => $userInfo->getHierarchy(), - 'is_silenced' => date('c', user_warning_check_expiration($userInfo->getUserId(), MSZ_WARN_SILENCE)), + 'is_silenced' => date('c', user_warning_check_expiration($userInfo->getId(), MSZ_WARN_SILENCE)), 'perms' => $perms, ]; } diff --git a/src/Memoizer.php b/src/Memoizer.php new file mode 100644 index 00000000..ffcb3ad7 --- /dev/null +++ b/src/Memoizer.php @@ -0,0 +1,27 @@ +<?php +namespace Misuzu; + +use InvalidArgumentException; + +class Memoizer { + private $collection = []; + + public function find($find, callable $create) { + if(is_int($find) || is_string($find)) { + if(!isset($this->collection[$find])) + $this->collection[$find] = $create(); + return $this->collection[$find]; + } + + 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; + return $item; + } + + throw new InvalidArgumentException('Wasn\'t able to figure out your $find argument.'); + } +} diff --git a/src/News/NewsCategory.php b/src/News/NewsCategory.php index 31aa4bc7..e9e2551e 100644 --- a/src/News/NewsCategory.php +++ b/src/News/NewsCategory.php @@ -18,7 +18,8 @@ class NewsCategory implements ArrayAccess { private $postCount = -1; - private const TABLE = 'news_categories'; + public const TABLE = 'news_categories'; + private const QUERY_SELECT = 'SELECT %1$s FROM `' . DB::PREFIX . self::TABLE . '` AS '. self::TABLE; private const SELECT = '%1$s.`category_id`, %1$s.`category_name`, %1$s.`category_description`, %1$s.`category_is_hidden`' . ', UNIX_TIMESTAMP(%1$s.`category_created`) AS `category_created`'; @@ -94,8 +95,12 @@ class NewsCategory implements ArrayAccess { } } + public function posts(?Pagination $pagination = null, bool $includeScheduled = false, bool $includeDeleted = false): array { + return NewsPost::byCategory($this, $pagination, $includeScheduled, $includeDeleted); + } + private static function countQueryBase(): string { - return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf('COUNT(%s.`category_id`)', self::TABLE)); + return sprintf(self::QUERY_SELECT, sprintf('COUNT(%s.`category_id`)', self::TABLE)); } public static function countAll(bool $showHidden = false): int { return (int)DB::prepare(self::countQueryBase() @@ -104,7 +109,7 @@ class NewsCategory implements ArrayAccess { } private static function byQueryBase(): string { - return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf(self::SELECT, self::TABLE)); + return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE)); } public static function byId(int $categoryId): self { $getCat = DB::prepare(self::byQueryBase() . ' WHERE `category_id` = :cat_id'); diff --git a/src/News/NewsPost.php b/src/News/NewsPost.php index eff4c6ef..c8a9be14 100644 --- a/src/News/NewsPost.php +++ b/src/News/NewsPost.php @@ -3,7 +3,10 @@ namespace Misuzu\News; use Misuzu\DB; use Misuzu\Pagination; +use Misuzu\Comments\CommentsCategory; +use Misuzu\Comments\CommentsCategoryNotFoundException; use Misuzu\Users\User; +use Misuzu\Users\UserNotFoundException; class NewsPostException extends NewsException {}; class NewsPostNotFoundException extends NewsPostException {}; @@ -24,10 +27,11 @@ class NewsPost { private $category = null; private $user = null; + private $userLookedUp = false; private $comments = null; - private $commentCount = -1; - private const TABLE = 'news_posts'; + public const TABLE = 'news_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.`category_id`, %1$s.`user_id`, %1$s.`comment_section_id`' . ', %1$s.`post_is_featured`, %1$s.`post_title`, %1$s.`post_text`' . ', UNIX_TIMESTAMP(%1$s.`post_scheduled`) AS `post_scheduled`' @@ -46,15 +50,17 @@ class NewsPost { } public function setCategoryId(int $categoryId): self { $this->category_id = max(1, $categoryId); + $this->category = null; return $this; } public function getCategory(): NewsCategory { - if($this->category === null && ($catId = $this->getCategoryId()) > 0) - $this->category = NewsCategory::byId($catId); + if($this->category === null) + $this->category = NewsCategory::byId($this->getCategoryId()); return $this->category; } public function setCategory(NewsCategory $category): self { $this->category_id = $category->getId(); + $this->category = $category; return $this; } @@ -63,41 +69,35 @@ class NewsPost { } 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); + if(!$this->userLookedUp && ($userId = $this->getUserId()) > 0) { + $this->userLookedUp = true; + try { + $this->user = User::byId($userId); + } catch(UserNotFoundException $ex) {} + } return $this->user; } public function setUser(?User $user): self { $this->user_id = $user === null ? null : $user->getId(); + $this->user = $user; return $this; } public function getCommentSectionId(): int { return $this->comment_section_id < 1 ? -1 : $this->comment_section_id; } - public function hasCommentsSection(): bool { + public function hasCommentSection(): bool { return $this->getCommentSectionId() > 0; } - public function getCommentSection() { - if($this->comments === null && ($sectionId = $this->getCommentSectionId()) > 0) - $this->comments = comments_category_info($sectionId); + public function getCommentSection(): CommentsCategory { + if($this->comments === null) + $this->comments = CommentsCategory::byId($this->getCommentSectionId()); return $this->comments; } - // Temporary solution, should be a method of whatever getCommentSection returns - public function getCommentCount(): int { - if($this->commentCount < 0) - $this->commentCount = (int)DB::prepare(' - SELECT COUNT(`comment_id`) - FROM `msz_comments_posts` - WHERE `category_id` = :cat_id - AND `comment_deleted` IS NULL - ')->bind('cat_id', $this->getCommentSectionId())->fetchColumn(); - - return $this->commentCount; - } public function isFeatured(): bool { return $this->post_is_featured !== 0; @@ -153,24 +153,25 @@ class NewsPost { return $this->getDeletedTime() >= 0; } public function setDeleted(bool $isDeleted): self { - $this->post_deleted = $isDeleted ? time() : null; + if($this->isDeleted() !== $isDeleted) + $this->post_deleted = $isDeleted ? time() : null; return $this; } public function ensureCommentsSection(): void { - if($this->hasCommentsSection()) + if($this->hasCommentSection()) return; - $this->comments = comments_category_create("news-{$this->getId()}"); + $this->comments = (new CommentsCategory) + ->setName("news-{$this->getId()}"); + $this->comments->save(); - if($this->comments !== null) { - $this->comment_section_id = (int)$this->comments['category_id']; - DB::prepare('UPDATE `msz_news_posts` SET `comment_section_id` = :comment_section_id WHERE `post_id` = :post_id') - ->execute([ - 'comment_section_id' => $this->getCommentSectionId(), - 'post_id' => $this->getId(), - ]); - } + $this->comment_section_id = $this->comments->getId(); + DB::prepare('UPDATE `msz_news_posts` SET `comment_section_id` = :comment_section_id WHERE `post_id` = :post_id') + ->execute([ + 'comment_section_id' => $this->getCommentSectionId(), + 'post_id' => $this->getId(), + ]); } public function save(): void { @@ -206,7 +207,7 @@ class NewsPost { } private static function countQueryBase(): string { - return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf('COUNT(%s.`post_id`)', self::TABLE)); + return sprintf(self::QUERY_SELECT, sprintf('COUNT(%s.`post_id`)', self::TABLE)); } public static function countAll(bool $onlyFeatured = false, bool $includeScheduled = false, bool $includeDeleted = false): int { return (int)DB::prepare(self::countQueryBase() @@ -226,7 +227,7 @@ class NewsPost { } private static function byQueryBase(): string { - return sprintf(DB::QUERY_SELECT, self::TABLE, sprintf(self::SELECT, self::TABLE)); + return sprintf(self::QUERY_SELECT, sprintf(self::SELECT, self::TABLE)); } public static function byId(int $postId): self { $post = DB::prepare(self::byQueryBase() . ' WHERE `post_id` = :post_id') diff --git a/src/Users/User.php b/src/Users/User.php index ab5cc0a5..70f86f06 100644 --- a/src/Users/User.php +++ b/src/Users/User.php @@ -3,9 +3,37 @@ namespace Misuzu\Users; use Misuzu\Colour; use Misuzu\DB; +use Misuzu\Memoizer; use Misuzu\Net\IPAddress; +class UserException extends UsersException {} // this naming definitely won't lead to confusion down the line! +class UserNotFoundException extends UserException {} + class User { + // Database fields + // TODO: update all references to use getters and setters and mark all of these as private + public $user_id = -1; + public $username = ''; + public $password = ''; + public $email = ''; + public $register_ip = '::1'; + public $last_ip = '::1'; + public $user_super = 0; + public $user_country = 'XX'; + public $user_colour = null; + public $user_created = null; + public $user_active = null; + public $user_deleted = null; + public $display_role = 1; + public $user_totp_key = null; + public $user_about_content = null; + public $user_about_parser = 0; + public $user_signature_content = null; + public $user_signature_parser = 0; + public $user_birthdate = ''; + public $user_background_settings = 0; + public $user_title = null; + private const USER_SELECT = ' SELECT u.`user_id`, u.`username`, u.`password`, u.`email`, u.`user_super`, u.`user_title`, u.`user_country`, u.`user_colour`, u.`display_role`, u.`user_totp_key`, @@ -50,48 +78,17 @@ class User { if($createUser < 1) return null; - return static::get($createUser); + return static::byId($createUser); } - public static function get(int $userId): ?User { return self::byId($userId); } - public static function byId(int $userId): ?User { - return DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id') - ->bind('user_id', $userId) - ->fetchObject(User::class); - } - - public static function findForLogin(string $usernameOrEmail): ?User { - return DB::prepare(self::USER_SELECT . 'WHERE LOWER(`email`) = LOWER(:email) OR LOWER(`username`) = LOWER(:username)') - ->bind('email', $usernameOrEmail) - ->bind('username', $usernameOrEmail) - ->fetchObject(User::class); - } - public static function findForProfile($userId): ?User { - return DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id OR LOWER(`username`) = LOWER(:username)') - ->bind('user_id', (int)$userId) - ->bind('username', (string)$userId) - ->fetchObject(User::class); - } - - public function hasUserId(): bool { return $this->hasId(); } - public function getUserId(): int { return $this->getId(); } - public function hasId(): bool { - return isset($this->user_id) && $this->user_id > 0; - } public function getId(): int { - return $this->user_id ?? 0; + return $this->user_id < 1 ? -1 : $this->user_id; } - public function hasUsername(): bool { - return isset($this->username); - } public function getUsername(): string { - return $this->username ?? ''; + return $this->username; } - public function hasColour(): bool { - return isset($this->user_colour); - } public function getColour(): Colour { return new Colour($this->getColourRaw()); } @@ -99,8 +96,12 @@ class User { return $this->user_colour ?? 0x40000000; } + public function getEmailAddress(): string { + return $this->email; + } + public function getHierarchy(): int { - return $this->hasUserId() ? user_get_hierarchy($this->getUserId()) : 0; + return ($userId = $this->getId()) < 1 ? 0 : user_get_hierarchy($userId); } public function hasPassword(): bool { @@ -113,12 +114,12 @@ class User { return password_needs_rehash($this->password, MSZ_USERS_PASSWORD_HASH_ALGO); } public function setPassword(string $password): void { - if(!$this->hasUserId()) + if(($userId = $this->getId()) < 1) return; DB::prepare('UPDATE `msz_users` SET `password` = :password WHERE `user_id` = :user_id') ->bind('password', password_hash($password, MSZ_USERS_PASSWORD_HASH_ALGO)) - ->bind('user_id', $this->user_id) + ->bind('user_id', $userId) ->execute(); } @@ -130,6 +131,9 @@ class User { return !empty($this->user_totp_key); } + public function getBackgroundSettings(): int { // Use the below methods instead + return $this->user_background_settings; + } public function getBackgroundAttachment(): int { return $this->user_background_settings & 0x0F; } @@ -141,9 +145,70 @@ class User { } public function profileFields(bool $filterEmpty = true): array { - if(!$this->hasUserId()) + if(($userId = $this->getId()) < 1) return []; + return ProfileField::user($userId, $filterEmpty); + } - return ProfileField::user($this->user_id, $filterEmpty); + // TODO: Is this the proper location/implementation for this? (no) + private $commentPermsArray = null; + public function commentPerms(): array { + if($this->commentPermsArray === null) + $this->commentPermsArray = perms_check_user_bulk(MSZ_PERMS_COMMENTS, $this->getId(), [ + 'can_comment' => MSZ_PERM_COMMENTS_CREATE, + 'can_delete' => MSZ_PERM_COMMENTS_DELETE_OWN | MSZ_PERM_COMMENTS_DELETE_ANY, + 'can_delete_any' => MSZ_PERM_COMMENTS_DELETE_ANY, + 'can_pin' => MSZ_PERM_COMMENTS_PIN, + 'can_lock' => MSZ_PERM_COMMENTS_LOCK, + 'can_vote' => MSZ_PERM_COMMENTS_VOTE, + ]); + return $this->commentPermsArray; + } + + private static function getMemoizer() { + static $memoizer = null; + if($memoizer === null) + $memoizer = new Memoizer; + return $memoizer; + } + + public static function byId(int $userId): ?User { + return self::getMemoizer()->find($userId, function() use ($userId) { + $user = DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id') + ->bind('user_id', $userId) + ->fetchObject(User::class); + if(!$user) + throw new UserNotFoundException; + return $user; + }); + } + public static function findForLogin(string $usernameOrEmail): ?User { + $usernameOrEmailLower = mb_strtolower($usernameOrEmail); + return self::getMemoizer()->find(function() use ($usernameOrEmailLower) { + return mb_strtolower($user->getUsername()) === $usernameOrEmailLower + || mb_strtolower($user->getEmailAddress()) === $usernameOrEmailLower; + }, function() use ($usernameOrEmail) { + $user = DB::prepare(self::USER_SELECT . 'WHERE LOWER(`email`) = LOWER(:email) OR LOWER(`username`) = LOWER(:username)') + ->bind('email', $usernameOrEmail) + ->bind('username', $usernameOrEmail) + ->fetchObject(User::class); + if(!$user) + throw new UserNotFoundException; + return $user; + }); + } + public static function findForProfile($userIdOrName): ?User { + $userIdOrNameLower = mb_strtolower($userIdOrName); + return self::getMemoizer()->find(function() use ($userIdOrNameLower) { + return $user->getId() == $userIdOrNameLower || mb_strtolower($user->getUsername()) === $userIdOrNameLower; + }, function() use ($userIdOrName) { + $user = DB::prepare(self::USER_SELECT . 'WHERE `user_id` = :user_id OR LOWER(`username`) = LOWER(:username)') + ->bind('user_id', (int)$userIdOrName) + ->bind('username', (string)$userIdOrName) + ->fetchObject(User::class); + if(!$user) + throw new UserNotFoundException; + return $user; + }); } } diff --git a/src/Users/UsersException.php b/src/Users/UsersException.php new file mode 100644 index 00000000..15a6a2c9 --- /dev/null +++ b/src/Users/UsersException.php @@ -0,0 +1,6 @@ +<?php +namespace Misuzu\Users; + +use Exception; + +class UsersException extends Exception {} diff --git a/src/comments.php b/src/comments.php deleted file mode 100644 index 5c7220a0..00000000 --- a/src/comments.php +++ /dev/null @@ -1,364 +0,0 @@ -<?php -require_once 'Users/validation.php'; - -define('MSZ_COMMENTS_VOTE_INDIFFERENT', 0); -define('MSZ_COMMENTS_VOTE_LIKE', 1); -define('MSZ_COMMENTS_VOTE_DISLIKE', -1); -define('MSZ_COMMENTS_VOTE_TYPES', [ - MSZ_COMMENTS_VOTE_INDIFFERENT, - MSZ_COMMENTS_VOTE_LIKE, - MSZ_COMMENTS_VOTE_DISLIKE, -]); - -// gets parsed on post -define('MSZ_COMMENTS_MARKUP_USERNAME', '#\B(?:@{1}(' . MSZ_USERNAME_REGEX . '))#u'); - -// gets parsed on fetch -define('MSZ_COMMENTS_MARKUP_USER_ID', '#\B(?:@{2}([0-9]+))#u'); - -function comments_vote_type_valid(int $voteType): bool { - return in_array($voteType, MSZ_COMMENTS_VOTE_TYPES, true); -} - -function comments_parse_for_store(string $text): string { - return preg_replace_callback(MSZ_COMMENTS_MARKUP_USERNAME, function ($matches) { - return ($userId = user_id_from_username($matches[1])) < 1 - ? $matches[0] - : "@@{$userId}"; - }, $text); -} - -function comments_parse_for_display(string $text): string { - $text = preg_replace_callback( - '/(^|[\n ])([\w]*?)([\w]*?:\/\/[\w]+[^ \,\"\n\r\t<]*)/is', - function ($matches) { - $matches[0] = trim($matches[0]); - $url = parse_url($matches[0]); - - if(empty($url['scheme']) || !in_array(mb_strtolower($url['scheme']), ['http', 'https'], true)) { - return $matches[0]; - } - - return sprintf(' <a href="%1$s" class="link" target="_blank" rel="noreferrer noopener">%1$s</a>', $matches[0]); - }, - $text - ); - - $text = preg_replace_callback(MSZ_COMMENTS_MARKUP_USER_ID, function ($matches) { - $getInfo = \Misuzu\DB::prepare(' - SELECT - u.`user_id`, u.`username`, - COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` - FROM `msz_users` as u - LEFT JOIN `msz_roles` as r - ON u.`display_role` = r.`role_id` - WHERE `user_id` = :user_id - '); - $getInfo->bind('user_id', $matches[1]); - $info = $getInfo->fetch(); - - if(empty($info)) { - return $matches[0]; - } - - return sprintf( - '<a href="%s" class="comment__mention", style="%s">@%s</a>', - url('user-profile', ['user' => $info['user_id']]), - html_colour($info['user_colour']), - $info['username'] - ); - }, $text); - - return $text; -} - -// usually this is not how you're suppose to handle permission checking, -// but in the context of comments this is fine since the same shit is used -// for every comment section. -function comments_get_perms(int $userId): array { - return perms_check_user_bulk(MSZ_PERMS_COMMENTS, $userId, [ - 'can_comment' => MSZ_PERM_COMMENTS_CREATE, - 'can_delete' => MSZ_PERM_COMMENTS_DELETE_OWN | MSZ_PERM_COMMENTS_DELETE_ANY, - 'can_delete_any' => MSZ_PERM_COMMENTS_DELETE_ANY, - 'can_pin' => MSZ_PERM_COMMENTS_PIN, - 'can_lock' => MSZ_PERM_COMMENTS_LOCK, - 'can_vote' => MSZ_PERM_COMMENTS_VOTE, - ]); -} - -function comments_pin_status(int $comment, bool $mode): ?string { - if($comment < 1) { - return false; - } - - $status = $mode ? date('Y-m-d H:i:s') : null; - - $setPinStatus = \Misuzu\DB::prepare(' - UPDATE `msz_comments_posts` - SET `comment_pinned` = :status - WHERE `comment_id` = :comment - AND `comment_reply_to` IS NULL - '); - $setPinStatus->bind('comment', $comment); - $setPinStatus->bind('status', $status); - - return $setPinStatus->execute() ? $status : null; -} - -function comments_vote_add(int $comment, int $user, int $vote = MSZ_COMMENTS_VOTE_INDIFFERENT): bool { - if(!comments_vote_type_valid($vote)) { - return false; - } - - $setVote = \Misuzu\DB::prepare(' - REPLACE INTO `msz_comments_votes` - (`comment_id`, `user_id`, `comment_vote`) - VALUES - (:comment, :user, :vote) - '); - $setVote->bind('comment', $comment); - $setVote->bind('user', $user); - $setVote->bind('vote', $vote); - return $setVote->execute(); -} - -function comments_votes_get(int $commentId): array { - $getVotes = \Misuzu\DB::prepare(sprintf( - ' - SELECT :id as `id`, - ( - SELECT COUNT(`user_id`) - FROM `msz_comments_votes` - WHERE `comment_id` = `id` - AND `comment_vote` = %1$d - ) as `likes`, - ( - SELECT COUNT(`user_id`) - FROM `msz_comments_votes` - WHERE `comment_id` = `id` - AND `comment_vote` = %2$d - ) as `dislikes` - ', - MSZ_COMMENTS_VOTE_LIKE, - MSZ_COMMENTS_VOTE_DISLIKE - )); - $getVotes->bind('id', $commentId); - return $getVotes->fetch(); -} - -function comments_category_create(string $name): array { - $create = \Misuzu\DB::prepare(' - INSERT INTO `msz_comments_categories` - (`category_name`) - VALUES - (LOWER(:name)) - '); - $create->bind('name', $name); - return $create->execute() - ? comments_category_info(\Misuzu\DB::lastId(), false) - : []; -} - -function comments_category_lock(int $category, bool $lock): void { - $setLock = \Misuzu\DB::prepare(' - UPDATE `msz_comments_categories` - SET `category_locked` = IF(:lock, NOW(), NULL) - WHERE `category_id` = :category - '); - $setLock->bind('category', $category); - $setLock->bind('lock', $lock); - $setLock->execute(); -} - -define('MSZ_COMMENTS_CATEGORY_INFO_QUERY', ' - SELECT - `category_id`, `category_locked` - FROM `msz_comments_categories` - WHERE `%s` = %s -'); -define('MSZ_COMMENTS_CATEGORY_INFO_ID', sprintf( - MSZ_COMMENTS_CATEGORY_INFO_QUERY, - 'category_id', - ':category' -)); -define('MSZ_COMMENTS_CATEGORY_INFO_NAME', sprintf( - MSZ_COMMENTS_CATEGORY_INFO_QUERY, - 'category_name', - 'LOWER(:category)' -)); - -function comments_category_info($category, bool $createIfNone = false): array { - if(is_int($category)) { - $getCategory = \Misuzu\DB::prepare(MSZ_COMMENTS_CATEGORY_INFO_ID); - $createIfNone = false; - } elseif(is_string($category)) { - $getCategory = \Misuzu\DB::prepare(MSZ_COMMENTS_CATEGORY_INFO_NAME); - } else { - return []; - } - - $getCategory->bind('category', $category); - $categoryInfo = $getCategory->fetch(); - return $categoryInfo - ? $categoryInfo - : ( - $createIfNone - ? comments_category_create($category) - : [] - ); -} - -define('MSZ_COMMENTS_CATEGORY_QUERY', sprintf( - ' - SELECT - p.`comment_id`, p.`comment_text`, p.`comment_reply_to`, - p.`comment_created`, p.`comment_pinned`, p.`comment_deleted`, - u.`user_id`, u.`username`, - COALESCE(u.`user_colour`, r.`role_colour`) AS `user_colour`, - ( - SELECT COUNT(`comment_id`) - FROM `msz_comments_votes` - WHERE `comment_id` = p.`comment_id` - AND `comment_vote` = %1$d - ) AS `comment_likes`, - ( - SELECT COUNT(`comment_id`) - FROM `msz_comments_votes` - WHERE `comment_id` = p.`comment_id` - AND `comment_vote` = %2$d - ) AS `comment_dislikes`, - ( - SELECT `comment_vote` - FROM `msz_comments_votes` - WHERE `comment_id` = p.`comment_id` - AND `user_id` = :user - ) AS `comment_user_vote` - FROM `msz_comments_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 p.`category_id` = :category - %%1$s - ORDER BY p.`comment_deleted` ASC, p.`comment_pinned` DESC, p.`comment_id` %%2$s - ', - MSZ_COMMENTS_VOTE_LIKE, - MSZ_COMMENTS_VOTE_DISLIKE -)); - -// The $parent param should never be used outside of this function itself and should always remain the last of the list. -function comments_category_get(int $category, int $user, ?int $parent = null): array { - $isParent = $parent === null; - $getComments = \Misuzu\DB::prepare(sprintf( - MSZ_COMMENTS_CATEGORY_QUERY, - $isParent ? 'AND p.`comment_reply_to` IS NULL' : 'AND p.`comment_reply_to` = :parent', - $isParent ? 'DESC' : 'ASC' - )); - - if(!$isParent) { - $getComments->bind('parent', $parent); - } - - $getComments->bind('user', $user); - $getComments->bind('category', $category); - $comments = $getComments->fetchAll(); - - $commentsCount = count($comments); - for($i = 0; $i < $commentsCount; $i++) { - $comments[$i]['comment_html'] = nl2br(comments_parse_for_display(htmlentities($comments[$i]['comment_text']))); - $comments[$i]['comment_replies'] = comments_category_get($category, $user, $comments[$i]['comment_id']); - } - - return $comments; -} - -function comments_post_create( - int $user, - int $category, - string $text, - bool $pinned = false, - ?int $reply = null, - bool $parse = true -): int { - if($parse) { - $text = comments_parse_for_store($text); - } - - $create = \Misuzu\DB::prepare(' - INSERT INTO `msz_comments_posts` - (`user_id`, `category_id`, `comment_text`, `comment_pinned`, `comment_reply_to`) - VALUES - (:user, :category, :text, IF(:pin, NOW(), NULL), :reply) - '); - $create->bind('user', $user); - $create->bind('category', $category); - $create->bind('text', $text); - $create->bind('pin', $pinned ? 1 : 0); - $create->bind('reply', $reply < 1 ? null : $reply); - return $create->execute() ? \Misuzu\DB::lastId() : 0; -} - -function comments_post_delete(int $commentId, bool $delete = true): bool { - $deleteComment = \Misuzu\DB::prepare(' - UPDATE `msz_comments_posts` - SET `comment_deleted` = IF(:del, NOW(), NULL) - WHERE `comment_id` = :id - '); - $deleteComment->bind('id', $commentId); - $deleteComment->bind('del', $delete ? 1 : 0); - return $deleteComment->execute(); -} - -function comments_post_get(int $commentId, bool $parse = true): array { - $fetch = \Misuzu\DB::prepare(' - SELECT - p.`comment_id`, p.`category_id`, p.`comment_text`, - p.`comment_created`, p.`comment_edited`, p.`comment_deleted`, - p.`comment_reply_to`, p.`comment_pinned`, - u.`user_id`, u.`username`, - COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` - FROM `msz_comments_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 `comment_id` = :id - '); - $fetch->bind('id', $commentId); - $comment = $fetch->fetch(); - - if($comment && $parse) { - $comment['comment_html'] = nl2br(comments_parse_for_display(htmlentities($comment['comment_text']))); - } - - return $comment; -} - -function comments_post_exists(int $commentId): bool { - $fetch = \Misuzu\DB::prepare(' - SELECT COUNT(`comment_id`) > 0 - FROM `msz_comments_posts` - WHERE `comment_id` = :id - '); - $fetch->bind('id', $commentId); - return (bool)$fetch->fetchColumn(); -} - -function comments_post_replies(int $commentId): array { - $getComments = \Misuzu\DB::prepare(' - SELECT - p.`comment_id`, p.`category_id`, p.`comment_text`, - p.`comment_created`, p.`comment_edited`, p.`comment_deleted`, - p.`comment_reply_to`, p.`comment_pinned`, - u.`user_id`, u.`username`, - COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` - FROM `msz_comments_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 `comment_reply_to` = :id - '); - $getComments->bind('id', $commentId); - return $getComments->fetchAll(); -} diff --git a/templates/_layout/comments.twig b/templates/_layout/comments.twig index 8651dc5f..960e210a 100644 --- a/templates/_layout/comments.twig +++ b/templates/_layout/comments.twig @@ -1,4 +1,4 @@ -{% macro comments_input(category, user, perms, reply_to) %} +{% macro comments_input(category, user, reply_to) %} {% set reply_mode = reply_to is not null %} {% from 'macros.twig' import avatar %} @@ -6,17 +6,17 @@ <form class="comment comment--input{% if reply_mode %} comment--reply{% endif %}" method="post" action="{{ url('comment-create') }}" - id="comment-{{ reply_mode ? 'reply-' ~ reply_to.comment_id : 'create-' ~ category.category_id }}"> - {{ input_hidden('comment[category]', category.category_id) }} + id="comment-{{ reply_mode ? 'reply-' ~ reply_to.id : 'create-' ~ category.id }}"> + {{ input_hidden('comment[category]', category.id) }} {{ input_csrf() }} {% if reply_mode %} - {{ input_hidden('comment[reply]', reply_to.comment_id) }} + {{ input_hidden('comment[reply]', reply_to.id) }} {% endif %} <div class="comment__container"> <div class="avatar comment__avatar"> - {{ avatar(user.user_id, reply_mode ? 40 : 50, user.username) }} + {{ avatar(user.id, reply_mode ? 40 : 50, user.username) }} </div> <div class="comment__content"> <textarea @@ -24,10 +24,10 @@ name="comment[text]" placeholder="Share your extensive insights..."></textarea> <div class="comment__actions"> {% if not reply_mode %} - {% if perms.can_pin %} + {% if user.commentPerms.can_pin|default(false) %} {{ input_checkbox('comment[pin]', 'Pin this comment', false, 'comment__action') }} {% endif %} - {% if perms.can_lock %} + {% if user.commentPerms.can_lock|default(false) %} {{ input_checkbox('comment[lock]', 'Toggle locked status', false, 'comment__action') }} {% endif %} {% endif %} @@ -40,110 +40,101 @@ </form> {% endmacro %} -{% macro comments_entry(comment, indent, category, user, perms) %} +{% macro comments_entry(comment, indent, category, user) %} {% from 'macros.twig' import avatar %} {% from '_layout/input.twig' import input_checkbox_raw %} - {% set is_deleted = comment.comment_deleted is not null %} - {% set hide_details = is_deleted and not perms.can_delete_any %} + {% set hide_details = comment.userId < 1 or comment.deleted and not user.commentPerms.can_delete_any|default(false) %} - {% if perms.can_delete_any or (not is_deleted or comment.comment_replies|length > 0) %} - {% set is_pinned = comment.comment_pinned is not null %} - - <div class="comment{% if is_deleted %} comment--deleted{% endif %}" id="comment-{{ comment.comment_id }}"> + {% if user.commentPerms.can_delete_any|default(false) or (not comment.deleted or comment.replies(user)|length > 0) %} + <div class="comment{% if comment.deleted %} comment--deleted{% endif %}" id="comment-{{ comment.id }}"> <div class="comment__container"> {% if hide_details %} <div class="comment__avatar"> {{ avatar(0, indent > 1 ? 40 : 50) }} </div> {% else %} - <a class="comment__avatar" href="{{ url('user-profile', {'user':comment.user_id}) }}"> - {{ avatar(comment.user_id, indent > 1 ? 40 : 50, comment.username) }} + <a class="comment__avatar" href="{{ url('user-profile', {'user':comment.user.id}) }}"> + {{ avatar(comment.user.id, indent > 1 ? 40 : 50, comment.user.username) }} </a> {% endif %} <div class="comment__content"> <div class="comment__info"> {% if not hide_details %} <a class="comment__user comment__user--link" - href="{{ url('user-profile', {'user':comment.user_id}) }}" - style="{{ comment.user_colour|html_colour }}">{{ comment.username }}</a> + href="{{ url('user-profile', {'user':comment.user.id}) }}" + style="--user-colour: {{ comment.user.colour}}">{{ comment.user.username }}</a> {% endif %} - <a class="comment__link" href="#comment-{{ comment.comment_id }}"> + <a class="comment__link" href="#comment-{{ comment.id }}"> <time class="comment__date" - title="{{ comment.comment_created|date('r') }}" - datetime="{{ comment.comment_created|date('c') }}"> - {{ comment.comment_created|time_diff }} + title="{{ comment.createdTime|date('r') }}" + datetime="{{ comment.createdTime|date('c') }}"> + {{ comment.createdTime|time_diff }} </time> </a> - {% if is_pinned %} + {% if comment.pinned %} <span class="comment__pin">{% apply spaceless %} Pinned - {% if comment.comment_pinned != comment.comment_created %} - <time title="{{ comment.comment_pinned|date('r') }}" - datetime="{{ comment.comment_pinned|date('c') }}"> - {{ comment.comment_pinned|time_diff }} + {% if comment.pinnedTime != comment.createdTime %} + <time title="{{ comment.pinnedTime|date('r') }}" + datetime="{{ comment.pinnedTime|date('c') }}"> + {{ comment.pinnedTime|time_diff }} </time> {% endif %} {% endapply %}</span> {% endif %} </div> <div class="comment__text"> - {{ hide_details ? '(deleted)' : (comment.comment_html is defined ? comment.comment_html|raw : comment.comment_text|nl2br) }} + {{ hide_details ? '(deleted)' : comment.parsedText|raw }} </div> <div class="comment__actions"> - {% if not is_deleted and user is not null %} - {% if perms.can_vote %} - {% set like_vote_state = comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_LIKE') - ? constant('MSZ_COMMENTS_VOTE_INDIFFERENT') - : constant('MSZ_COMMENTS_VOTE_LIKE') %} - {% set dislike_vote_state = comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_DISLIKE') - ? constant('MSZ_COMMENTS_VOTE_INDIFFERENT') - : constant('MSZ_COMMENTS_VOTE_DISLIKE') %} + {% if not comment.deleted and user is not null %} + {% if user.commentPerms.can_vote|default(false) %} + {% set like_vote_state = comment.userVote > 0 ? 0 : 1 %} + {% set dislike_vote_state = comment.userVote < 0 ? 0 : -1 %} - <a class="comment__action comment__action--link comment__action--vote comment__action--like{% if comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_LIKE') %} comment__action--voted{% endif %}" data-comment-id="{{ comment.comment_id }}" data-comment-vote="{{ like_vote_state }}" - href="{{ url('comment-vote', {'comment':comment.comment_id,'vote':like_vote_state}) }}"> - <!--i class="fas fa-thumbs-up"></i--> + <a class="comment__action comment__action--link comment__action--vote comment__action--like{% if comment.userVote > 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ like_vote_state }}" + href="{{ url('comment-vote', {'comment':comment.id,'vote':like_vote_state}) }}"> Like - {% if comment.comment_likes > 0 %} - ({{ comment.comment_likes|number_format }}) + {% if comment.likes > 0 %} + ({{ comment.likes|number_format }}) {% endif %} </a> - <a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if comment.comment_user_vote == constant('MSZ_COMMENTS_VOTE_DISLIKE') %} comment__action--voted{% endif %}" data-comment-id="{{ comment.comment_id }}" data-comment-vote="{{ dislike_vote_state }}" - href="{{ url('comment-vote', {'comment':comment.comment_id,'vote':dislike_vote_state}) }}"> - <!--i class="fas fa-thumbs-down"></i--> + <a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if comment.userVote < 0 %} comment__action--voted{% endif %}" data-comment-id="{{ comment.id }}" data-comment-vote="{{ dislike_vote_state }}" + href="{{ url('comment-vote', {'comment':comment.id,'vote':dislike_vote_state}) }}"> Dislike - {% if comment.comment_dislikes > 0 %} - ({{ comment.comment_dislikes|number_format }}) + {% if comment.dislikes > 0 %} + ({{ comment.dislikes|number_format }}) {% endif %} </a> {% endif %} - {% if perms.can_comment %} - <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.comment_id }}">Reply</label> + {% if user.commentPerms.can_comment|default(false) %} + <label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.id }}">Reply</label> {% endif %} - {% if perms.can_delete_any or (comment.user_id == user.user_id and perms.can_delete) %} - <a class="comment__action comment__action--link comment__action--hide comment__action--delete" data-comment-id="{{ comment.comment_id }}" href="{{ url('comment-delete', {'comment':comment.comment_id}) }}">Delete</a> + {% if user.commentPerms.can_delete_any|default(false) or (comment.user.id == user.id and user.commentPerms.can_delete|default(false)) %} + <a class="comment__action comment__action--link comment__action--hide comment__action--delete" data-comment-id="{{ comment.id }}" href="{{ url('comment-delete', {'comment':comment.id}) }}">Delete</a> {% endif %} {# if user is not null %} <a class="comment__action comment__action--link comment__action--hide" href="#">Report</a> {% endif #} - {% if comment.comment_reply_to is null and perms.can_pin %} - <a class="comment__action comment__action--link comment__action--hide comment__action--pin" data-comment-id="{{ comment.comment_id }}" data-comment-pinned="{{ is_pinned ? '1' : '0' }}" href="{{ url('comment-' ~ (is_pinned ? 'unpin' : 'pin'), {'comment':comment.comment_id}) }}">{{ is_pinned ? 'Unpin' : 'Pin' }}</a> + {% if not comment.hasParent and user.commentPerms.can_pin|default(false) %} + <a class="comment__action comment__action--link comment__action--hide comment__action--pin" data-comment-id="{{ comment.id }}" data-comment-pinned="{{ comment.pinned ? '1' : '0' }}" href="{{ url('comment-' ~ (comment.pinned ? 'unpin' : 'pin'), {'comment':comment.id}) }}">{{ comment.pinned ? 'Unpin' : 'Pin' }}</a> {% endif %} - {% elseif perms.can_delete_any %} - <a class="comment__action comment__action--link comment__action--restore" data-comment-id="{{ comment.comment_id }}" href="{{ url('comment-restore', {'comment':comment.comment_id}) }}">Restore</a> + {% elseif user.commentPerms.can_delete_any|default(false) %} + <a class="comment__action comment__action--link comment__action--restore" data-comment-id="{{ comment.id }}" href="{{ url('comment-restore', {'comment':comment.id}) }}">Restore</a> {% endif %} </div> </div> </div> - <div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ comment.comment_id }}-replies"> + <div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ comment.id }}-replies"> {% from _self import comments_entry, comments_input %} - {% if user|default(null) is not null and category|default(null) is not null and perms|default(null) is not null and perms.can_comment %} - {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.comment_id}) }} - {{ comments_input(category, user, perms, comment) }} + {% if user|default(null) is not null and category|default(null) is not null and user.commentPerms.can_comment|default(false) %} + {{ input_checkbox_raw('', false, 'comment__reply-toggle', '', false, {'id':'comment-reply-toggle-' ~ comment.id}) }} + {{ comments_input(category, user, comment) }} {% endif %} - {% if comment.comment_replies is defined and comment.comment_replies|length > 0 %} - {% for reply in comment.comment_replies %} - {{ comments_entry(reply, indent + 1, category, user, perms) }} + {% if comment.replies|length > 0 %} + {% for reply in comment.replies %} + {{ comments_entry(reply, indent + 1, category, user) }} {% endfor %} {% endif %} </div> @@ -151,34 +142,34 @@ {% endif %} {% endmacro %} -{% macro comments_section(comments, category, user, perms) %} +{% macro comments_section(category, user) %} <div class="comments" id="comments"> <div class="comments__input"> {% if user|default(null) is null %} <div class="comments__notice"> Please <a href="{{ url('auth-login') }}" class="comments__notice__link">login</a> to comment. </div> - {% elseif category|default(null) is null or perms|default(null) is null %} + {% elseif category|default(null) is null %} <div class="comments__notice"> Posting new comments here is disabled. </div> - {% elseif not perms.can_lock and category.category_locked is not null %} + {% elseif not user.commentPerms.can_lock|default(false) and category.locked %} <div class="comments__notice"> - This comment section was locked, <time datetime="{{ category.category_locked|date('c') }}" title="{{ category.category_locked|date('r') }}">{{ category.category_locked|time_diff }}</time>. + This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_diff }}</time>. </div> - {% elseif not perms.can_comment %} + {% elseif not user.commentPerms.can_comment|default(false) %} <div class="comments__notice"> You are not allowed to post comments. </div> {% else %} {% from _self import comments_input %} - {{ comments_input(category, user, perms) }} + {{ comments_input(category, user) }} {% endif %} </div> - {% if perms.can_lock and category.category_locked is not null %} + {% if user.commentPerms.can_lock|default(false) and category.locked %} <div class="comments__notice comments__notice--staff"> - This comment section was locked, <time datetime="{{ category.category_locked|date('c') }}" title="{{ category.category_locked|date('r') }}">{{ category.category_locked|time_diff }}</time>. + This comment section was locked, <time datetime="{{ category.lockedTime|date('c') }}" title="{{ category.lockedTime|date('r') }}">{{ category.lockedTime|time_diff }}</time>. </div> {% endif %} @@ -189,13 +180,13 @@ </noscript> <div class="comments__listing"> - {% if comments|length > 0 %} + {% if category.posts|length > 0 %} {% from _self import comments_entry %} - {% for comment in comments %} - {{ comments_entry(comment, 1, category, user, perms) }} + {% for comment in category.posts(user) %} + {{ comments_entry(comment, 1, category, user) }} {% endfor %} {% else %} - <div class="comments__none" id="_no_comments_notice_{{ category.category_id }}"> + <div class="comments__none" id="_no_comments_notice_{{ category.id }}"> There are no comments yet. </div> {% endif %} diff --git a/templates/changelog/change.twig b/templates/changelog/change.twig index 85e73e4f..9ffa8525 100644 --- a/templates/changelog/change.twig +++ b/templates/changelog/change.twig @@ -83,10 +83,10 @@ </div> </div> - {% if comments is defined %} + {% if comments_category is defined %} <div class="container"> {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments for ' ~ change.change_date) }} - {{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }} + {{ comments_section(comments_category, comments_user) }} </div> {% endif %} {% endblock %} diff --git a/templates/changelog/index.twig b/templates/changelog/index.twig index f82d77f6..000fcca6 100644 --- a/templates/changelog/index.twig +++ b/templates/changelog/index.twig @@ -34,10 +34,10 @@ {% endif %} </div> - {% if comments is defined %} + {% if comments_category is defined %} <div class="container"> {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }} - {{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }} + {{ comments_section(comments_category, comments_user) }} </div> {% endif %} {% endblock %} diff --git a/templates/news/macros.twig b/templates/news/macros.twig index 8623c46c..d890f5fe 100644 --- a/templates/news/macros.twig +++ b/templates/news/macros.twig @@ -38,7 +38,7 @@ <div class="news__preview__links"> <a href="{{ url('news-post', {'post': post.id}) }}" class="news__preview__link">Continue reading</a> <a href="{{ url('news-post-comments', {'post': post.id}) }}" class="news__preview__link"> - {{ post.commentCount < 1 ? 'No' : post.commentCount|number_format }} comment{{ post.commentCount != 1 ? 's' : '' }} + {{ not post.hasCommentSection or post.commentSection.postCount < 1 ? 'No' : post.commentSection.postCount|number_format }} comment{{ not post.hasCommentSection or post.commentSection.postCount != 1 ? 's' : '' }} </a> </div> </div> diff --git a/templates/news/post.twig b/templates/news/post.twig index b410484a..1e2991f9 100644 --- a/templates/news/post.twig +++ b/templates/news/post.twig @@ -10,10 +10,10 @@ {% block content %} {{ news_post(post_info) }} - {% if comments is defined %} + {% if comments_info is defined %} <div class="container"> {{ container_title('<i class="fas fa-comments fa-fw"></i> Comments') }} - {{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }} + {{ comments_section(comments_info, comments_user) }} </div> {% endif %} {% endblock %} diff --git a/templates/profile/index.twig b/templates/profile/index.twig index 807e6374..35999c5f 100644 --- a/templates/profile/index.twig +++ b/templates/profile/index.twig @@ -4,7 +4,7 @@ {% from '_layout/input.twig' import input_hidden, input_csrf, input_text, input_checkbox, input_file, input_file_raw, input_select %} {% if profile_user is defined %} - {% set canonical_url = url('user-profile', {'user': profile_user.user_id}) %} + {% set canonical_url = url('user-profile', {'user': profile_user.id}) %} {% set title = profile_user.username %} {% else %} {% set title = 'User not found!' %} @@ -12,7 +12,7 @@ {% block content %} {% if profile_is_editing %} - <form class="profile" method="post" action="{{ url('user-profile', {'user': profile_user.user_id}) }}" enctype="multipart/form-data"> + <form class="profile" method="post" action="{{ url('user-profile', {'user': profile_user.id}) }}" enctype="multipart/form-data"> {{ input_csrf('profile') }} {% if perms.edit_avatar %} @@ -20,7 +20,7 @@ <script> function updateAvatarPreview(name, url, preview) { - url = url || "{{ url('user-avatar', {'user': profile_user.user_id, 'res': 240})|raw }}"; + url = url || "{{ url('user-avatar', {'user': profile_user.id, 'res': 240})|raw }}"; preview = preview || document.getElementById('avatar-preview'); preview.src = url; preview.title = name; @@ -211,7 +211,7 @@ {% if profile_warnings|length > 0 or profile_warnings_can_manage %} <div class="container profile__container profile__warning__container" id="account-standing"> - {{ container_title('Account Standing', false, profile_warnings_can_manage ? url('manage-users-warnings', {'user': profile_user.user_id}) : '') }} + {{ container_title('Account Standing', false, profile_warnings_can_manage ? url('manage-users-warnings', {'user': profile_user.id}) : '') }} <div class="profile__warning"> <div class="profile__warning__background"></div> diff --git a/templates/profile/master.twig b/templates/profile/master.twig index d0632b7a..e0ed31e5 100644 --- a/templates/profile/master.twig +++ b/templates/profile/master.twig @@ -1,8 +1,8 @@ {% extends 'master.twig' %} {% if profile_user is defined %} - {% set image = url('user-avatar', {'user': profile_user.user_id, 'res': 200}) %} - {% set manage_link = url('manage-user', {'user': profile_user.user_id}) %} + {% set image = url('user-avatar', {'user': profile_user.id, 'res': 200}) %} + {% set manage_link = url('manage-user', {'user': profile_user.id}) %} {% set stats = [ { 'title': 'Joined', @@ -17,25 +17,25 @@ { 'title': 'Following', 'value': profile_stats.following_count, - 'url': url('user-profile-following', {'user': profile_user.user_id}), + 'url': url('user-profile-following', {'user': profile_user.id}), 'active': profile_mode == 'following', }, { 'title': 'Followers', 'value': profile_stats.followers_count, - 'url': url('user-profile-followers', {'user': profile_user.user_id}), + 'url': url('user-profile-followers', {'user': profile_user.id}), 'active': profile_mode == 'followers', }, { 'title': 'Topics', 'value': profile_stats.forum_topic_count, - 'url': url('user-profile-forum-topics', {'user': profile_user.user_id}), + 'url': url('user-profile-forum-topics', {'user': profile_user.id}), 'active': profile_mode == 'forum-topics', }, { 'title': 'Posts', 'value': profile_stats.forum_post_count, - 'url': url('user-profile-forum-posts', {'user': profile_user.user_id}), + 'url': url('user-profile-forum-posts', {'user': profile_user.id}), 'active': profile_mode == 'forum-posts', }, { diff --git a/utility.php b/utility.php index e2af3b5f..83b16dc8 100644 --- a/utility.php +++ b/utility.php @@ -1,32 +1,32 @@ <?php function array_test(array $array, callable $func): bool { - foreach($array as $value) { - if(!$func($value)) { + foreach($array as $value) + if(!$func($value)) return false; - } - } - return true; } function array_apply(array $array, callable $func): array { - for($i = 0; $i < count($array); $i++) { + for($i = 0; $i < count($array); ++$i) $array[$i] = $func($array[$i]); - } - return $array; } function array_bit_or(array $array1, array $array2): array { - foreach($array1 as $key => $value) { + foreach($array1 as $key => $value) $array1[$key] |= $array2[$key] ?? 0; - } - return $array1; } function array_rand_value(array $array) { - return $array[array_rand($array)]; + return $array[mt_rand(0, count($array) - 1)]; +} + +function array_find(array $array, callable $callback) { + foreach($array as $item) + if($callback($item)) + return $item; + return null; } function clamp($num, int $min, int $max): int { @@ -76,72 +76,35 @@ function unique_chars(string $input, bool $multibyte = true): int { } function byte_symbol(int $bytes, bool $decimal = false, array $symbols = ['', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y']): string { - if($bytes < 1) { + if($bytes < 1) return '0 B'; - } $divider = $decimal ? 1000 : 1024; $exp = floor(log($bytes) / log($divider)); - $bytes = $bytes / pow($divider, floor($exp)); + $bytes = $bytes / pow($divider, $exp); $symbol = $symbols[$exp]; return sprintf("%.2f %s%sB", $bytes, $symbol, $symbol !== '' && !$decimal ? 'i' : ''); } -// For chat emote list, nuke this when Sharp Chat comms are in this project -function emotes_list(int $hierarchy = PHP_INT_MAX, bool $unique = false, bool $order = true): array { - $getEmotes = \Misuzu\DB::prepare(' - SELECT e.`emote_id`, e.`emote_order`, e.`emote_hierarchy`, e.`emote_url`, - s.`emote_string_order`, s.`emote_string` - FROM `msz_emoticons_strings` AS s - LEFT JOIN `msz_emoticons` AS e - ON e.`emote_id` = s.`emote_id` - WHERE `emote_hierarchy` <= :hierarchy - ORDER BY IF(:order, e.`emote_order`, e.`emote_id`), s.`emote_string_order` - '); - $getEmotes->bind('hierarchy', $hierarchy); - $getEmotes->bind('order', $order); - $emotes = $getEmotes->fetchAll(); - - // Removes aliases, emote with lowest ordering is considered the main - if($unique) { - $existing = []; - - for($i = 0; $i < count($emotes); $i++) { - if(in_array($emotes[$i]['emote_url'], $existing)) { - unset($emotes[$i]); - } else { - $existing[] = $emotes[$i]['emote_url']; - } - } - } - - return $emotes; -} - function safe_delete(string $path): void { $path = realpath($path); - - if(empty($path)) { + if(empty($path)) return; - } if(is_dir($path)) { rmdir($path); return; } - if(is_file($path)) { + if(is_file($path)) unlink($path); - } } // mkdir but it fails silently function mkdirs(string $path, bool $recursive = false, int $mode = 0777): bool { - if(file_exists($path)) { + if(file_exists($path)) return true; - } - return mkdir($path, $mode, $recursive); } @@ -270,8 +233,8 @@ 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, 'res' => $resolution * 2]); +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'] ?? ''));