diff --git a/assets/less/mio/classes/comment.less b/assets/less/mio/classes/comment.less new file mode 100644 index 00000000..7683a7d2 --- /dev/null +++ b/assets/less/mio/classes/comment.less @@ -0,0 +1,145 @@ +.comment { + + &:not(:last-child) { + margin-bottom: 4px; + } + + &--hidden { + //display: none; + } + + &__container { + display: flex; + margin-bottom: 3px; + } + + &__actions { + list-style: none; + display: flex; + font-size: .9em; + align-items: center; + + &:hover { + .comment__action--hide { + opacity: 1; + } + } + } + + &__action { + color: inherit; + text-decoration: none; + vertical-align: middle; + + &:not(:last-child) { + margin-right: 6px; + } + + &--link:hover { + text-decoration: underline; + } + + &--label { + cursor: pointer; + } + + &--post { + margin-left: auto; + } + + &--button { + cursor: pointer; + font: 12px/20px @mio-font-regular; + padding: 0 10px; + } + + &--hide { + opacity: 0; + transition: opacity .2s; + } + + &__checkbox { + vertical-align: text-top; + margin-right: 2px; + } + } + + &__replies { + &--indent { + &-1, &-2, &-3, &-4, &-5 { + margin-left: 50px; + } + } + + .comment__avatar { + width: 40px; + height: 40px; + } + } + + &__avatar { + flex: 0 0 auto; + height: 50px; + width: 50px; + margin-right: 4px; + } + + &__content { + flex: 1 1 auto; + display: flex; + flex-direction: column; + overflow: hidden; + word-wrap: break-word; + } + + &__info { + display: inline-flex; + } + + &__text { + margin-right: 2px; + + &--input { + min-width: 100%; + max-width: 100%; + min-height: 50px; + font: 12px/20px @mio-font-regular; + margin-right: 1px; + } + } + + &__user { + font-weight: 700; + text-decoration: none; + + &--link:hover { + text-decoration: underline; + } + } + + &__date, + &__pin { + color: #666; + font-size: .9em; + margin-left: 8px; + } + + &__link { + color: #666; + display: inline-flex; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + } + + &__pin { + &:before { + content: "-"; + padding-right: 4px; + } + + margin-left: 4px; + } +} diff --git a/assets/less/mio/classes/comments.less b/assets/less/mio/classes/comments.less new file mode 100644 index 00000000..d9ef82a9 --- /dev/null +++ b/assets/less/mio/classes/comments.less @@ -0,0 +1,33 @@ +.comments { + margin: 1px; + overflow: hidden; + word-wrap: break-word; + + &__input, + &__javascript { + border-bottom: 1px solid #9475b2; + padding-bottom: 1px; + margin-bottom: 1px; + } + + &__none, + &__javascript, + &__notice { + padding: 10px; + font-size: 1.2em; + text-align: center; + } + + &__notice__link { + color: #22c; + text-decoration: none; + + &:hover { + text-decoration: underline; + } + + &:active { + color: #c22; + } + } +} diff --git a/assets/less/mio/classes/container.less b/assets/less/mio/classes/container.less index 34661357..15a6aa20 100644 --- a/assets/less/mio/classes/container.less +++ b/assets/less/mio/classes/container.less @@ -8,8 +8,8 @@ background-color: #23172a; } - &--hidden { - .container__content { + &--hidden { // __title should always be the first element of a container + :not(:first-child) { display: none; } } @@ -40,7 +40,7 @@ } } - &__content { + &__content { // only use this for text going forward, just throw your child container in directly after __title margin: 2px 5px; } } diff --git a/assets/less/mio/main.less b/assets/less/mio/main.less index 3eb23a85..c335d15d 100644 --- a/assets/less/mio/main.less +++ b/assets/less/mio/main.less @@ -92,3 +92,7 @@ body { // Member listing @import "classes/members/user"; @import "classes/members/users"; + +// Comments +@import "classes/comment"; // entries +@import "classes/comments"; // listing diff --git a/database/2018_07_25_194106_add_global_comments_stuff.php b/database/2018_07_25_194106_add_global_comments_stuff.php index 07be5214..8aff4023 100644 --- a/database/2018_07_25_194106_add_global_comments_stuff.php +++ b/database/2018_07_25_194106_add_global_comments_stuff.php @@ -24,6 +24,7 @@ function migrate_up(PDO $conn): void `comment_reply_to` INT(10) UNSIGNED NULL DEFAULT NULL, `comment_text` TEXT NOT NULL, `comment_created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + `comment_pinned` TIMESTAMP NULL DEFAULT NULL, `comment_edited` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP, `comment_deleted` TIMESTAMP NULL DEFAULT NULL, PRIMARY KEY (`comment_id`), @@ -32,6 +33,7 @@ function migrate_up(PDO $conn): void INDEX `comments_posts_reply_id` (`comment_reply_to`), INDEX `comments_posts_dates` ( `comment_created`, + `comment_pinned`, `comment_edited`, `comment_deleted` ), @@ -47,10 +49,32 @@ function migrate_up(PDO $conn): void ON DELETE SET NULL ); '); + + $conn->exec(" + CREATE TABLE `msz_comments_votes` ( + `comment_id` INT(10) UNSIGNED NOT NULL, + `user_id` INT(10) UNSIGNED NOT NULL, + `comment_vote` ENUM('Like','Dislike') NULL, + UNIQUE INDEX `comments_vote_unique` (`comment_id`, `user_id`), + INDEX `comments_vote_user_foreign` (`user_id`), + INDEX `comments_vote_index` (`comment_vote`), + CONSTRAINT `comment_vote_id` + FOREIGN KEY (`comment_id`) + REFERENCES `msz_comments_posts` (`comment_id`) + ON UPDATE CASCADE + ON DELETE CASCADE, + CONSTRAINT `comment_vote_user` + FOREIGN KEY (`user_id`) + REFERENCES `msz_users` (`user_id`) + ON UPDATE CASCADE + ON DELETE CASCADE + ); + "); } function migrate_down(PDO $conn): void { + $conn->exec('DROP TABLE `msz_comments_votes`'); $conn->exec('DROP TABLE `msz_comments_posts`'); $conn->exec('DROP TABLE `msz_comments_categories`'); } diff --git a/misuzu.php b/misuzu.php index 1660d75e..9b9fa0db 100644 --- a/misuzu.php +++ b/misuzu.php @@ -103,7 +103,7 @@ if (PHP_SAPI === 'cli') { case 'migrate': $migrationTargets = [ - 'mysql-main' => __DIR__ . '/database', + 'mysql-main' => __DIR__ . '/database', ]; $doRollback = !empty($argv[2]) && $argv[2] === 'rollback'; $targetDb = isset($argv[$doRollback ? 3 : 2]) ? $argv[$doRollback ? 3 : 2] : null; @@ -232,7 +232,7 @@ MIG; $getUserDisplayInfo = Database::prepare(' SELECT u.`user_id`, u.`username`, - COALESCE(u.`user_colour`, r.`role_colour`) as `colour` + 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` diff --git a/public/changelog.php b/public/changelog.php index f4dc2a61..7fb7e6da 100644 --- a/public/changelog.php +++ b/public/changelog.php @@ -13,9 +13,12 @@ $changelogDate = $_GET['d'] ?? ''; $changelogUser = (int)($_GET['u'] ?? 0); $changelogTags = $_GET['t'] ?? ''; +$commentPerms = comments_get_perms($app->getUserId()); + $tpl->vars([ 'changelog_offset' => $changelogOffset, 'changelog_take' => $changelogRange, + 'comments_perms' => $commentPerms, ]); if ($changelogChange > 0) { @@ -55,7 +58,14 @@ if ($changelogChange > 0) { $tpl->var('tags', $tags); } - echo $tpl->render('changelog.change', compact('change')); + echo $tpl->render('changelog.change', [ + 'change' => $change, + 'comments_category' => $commentsCategory = comments_category_info( + "changelog-date-{$change['change_date']}", + true + ), + 'comments' => comments_category_get($commentsCategory['category_id']), + ]); return; } @@ -77,6 +87,13 @@ if (!$changes) { http_response_code(404); } +if (!empty($changelogDate)) { + $tpl->vars([ + 'comments_category' => $commentsCategory = comments_category_info("changelog-date-{$changelogDate}", true), + 'comments' => comments_category_get($commentsCategory['category_id']), + ]); +} + echo $tpl->render('changelog.index', [ 'changes' => $changes, 'changelog_count' => $changesCount, diff --git a/public/comments.php b/public/comments.php new file mode 100644 index 00000000..0a019ccb --- /dev/null +++ b/public/comments.php @@ -0,0 +1,181 @@ +getUserId() < 1) { + echo render_info_or_json($isXHR, 'You must be logged in to manage comments.', 401); + return; +} + +$redirect = !$isXHR && !empty($_SERVER['HTTP_REFERER']) + && is_local_url($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : ''; +$commentPerms = comments_get_perms($app->getUserId()); +$commentId = (int)($_REQUEST['comment_id'] ?? 0); + +if (isset($_POST['vote']) && array_key_exists((int)$_POST['vote'], MSZ_COMMENTS_VOTE_TYPES)) { + echo comments_vote_add( + $commentId, + $app->getUserId(), + MSZ_COMMENTS_VOTE_TYPES[(int)$_POST['vote']] + ); + return; +} + +switch ($_SERVER['REQUEST_METHOD']) { + case 'GET': + if ($commentId < 1) { + echo render_info_or_json(true, 'Missing data.', 400); + break; + } + + switch ($_GET['fetch'] ?? '') { + case 'replies': + echo json_encode(comments_post_replies($commentId)); + break; + + default: + echo json_encode(comments_post_get($commentId)); + } + break; + + case 'POST': + if (!$commentPerms['can_comment']) { + echo render_info_or_json($isXHR, "You're not allowed to post comments.", 403); + break; + } + + if (empty($_POST['comment']) || !is_array($_POST['comment'])) { + echo render_info_or_json($isXHR, 'Missing data.', 400); + break; + } + + $categoryId = (int)($_POST['comment']['category'] ?? 0); + $category = comments_category_info($categoryId); + + if (!$category) { + echo render_info_or_json($isXHR, 'This comment category doesn\'t exist.', 404); + } + + if (!is_null($category['category_locked']) || !$commentPerms['can_lock']) { + echo render_info_or_json($isXHR, 'This comment category has been locked.', 403); + } + + $commentText = $_POST['comment']['text'] ?? ''; + $commentLock = !empty($_POST['comment']['lock']) && $commentPerms['can_lock']; + $commentPin = !empty($_POST['comment']['pin']) && $commentPerms['can_pin']; + $commentReply = (int)($_POST['comment']['reply'] ?? 0); + + if ($commentLock) { + comments_category_lock($categoryId, is_null($category['category_locked'])); + } + + if (strlen($commentText) > 0) { + $commentText = preg_replace("/[\r\n]{2,}/", "\n", $commentText); + } else { + if ($commentPerms['can_lock']) { + echo render_info_or_json($isXHR, 'The action has been processed.'); + } else { + echo render_info_or_json($isXHR, 'Your comment is too short.', 400); + } + break; + } + + if (strlen($commentText) > 5000) { + echo render_info_or_json($isXHR, 'Your comment is too long.', 400); + 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; + } + + $commentId = comments_post_create( + $app->getUserId(), + $categoryId, + $commentText, + $commentPin, + $commentReply + ); + + if ($commentId < 1) { + echo render_info_or_json($isXHR, 'Something went horribly wrong.', 500); + break; + } + + if ($redirect) { + header('Location: ' . $redirect . '#comment-' . $commentId); + break; + } + + echo json_encode(comments_post_get($commentId)); + break; + + case 'PATCH': + method_patch: + if ($commentId < 1) { + echo render_info_or_json($isXHR, 'Missing data.', 400); + break; + } + + if (!$commentPerms['can_edit']) { + echo render_info_or_json($isXHR, "You're not allowed to edit comments.", 403); + break; + } + + if (!$commentPerms['can_edit_any'] + && !comments_post_check_ownership($commentId, $app->getUserId())) { + echo render_info_or_json($isXHR, "You're not allowed to delete comments made by others.", 403); + break; + } + + if ($redirect) { + header('Location: ' . $redirect . '#comment-' . $commentId); + break; + } + + var_dump($_POST); + break; + + case 'DELETE': + method_delete: + if ($commentId < 1) { + echo render_info_or_json($isXHR, 'Missing data.', 400); + break; + } + + if (!$commentPerms['can_delete']) { + echo render_info_or_json($isXHR, "You're not allowed to delete comments.", 403); + break; + } + + if (!$commentPerms['can_delete_any'] + && !comments_post_check_ownership($commentId, $app->getUserId())) { + echo render_info_or_json($isXHR, "You're not allowed to delete comments made by others.", 403); + break; + } + + if (!comments_post_delete($commentId)) { + echo render_info_or_json($isXHR, 'Failed to delete comment.', 500); + break; + } + + if ($redirect) { + header('Location: ' . $redirect); + break; + } + + echo render_info_or_json($isXHR, 'Comment deleted.'); + break; + + default: + echo render_info_or_json($isXHR, 'Invalid request method.', 405); +} diff --git a/src/TemplateEngine.php b/src/TemplateEngine.php index 4d4c1c26..eaeaa1de 100644 --- a/src/TemplateEngine.php +++ b/src/TemplateEngine.php @@ -19,6 +19,8 @@ class TemplateEngine */ private const FILE_EXTENSION = '.twig'; + public const TWIG_DEFAULT = Twig_Loader_Filesystem::MAIN_NAMESPACE; + /** * Instance of the Twig Environment. * @var Twig_Environment diff --git a/src/comments.php b/src/comments.php index 03f944c2..261af497 100644 --- a/src/comments.php +++ b/src/comments.php @@ -8,17 +8,65 @@ define('MSZ_COMMENTS_PERM_DELETE_OWN', 1 << 3); define('MSZ_COMMENTS_PERM_DELETE_ANY', 1 << 4); define('MSZ_COMMENTS_PERM_PIN', 1 << 5); define('MSZ_COMMENTS_PERM_LOCK', 1 << 6); +define('MSZ_COMMENTS_PERM_VOTE', 1 << 7); -function comments_category_create(string $name): int +define('MSZ_COMMENTS_VOTE_INDIFFERENT', null); +define('MSZ_COMMENTS_VOTE_LIKE', 'Like'); +define('MSZ_COMMENTS_VOTE_DISLIKE', 'Dislike'); +define('MSZ_COMMENTS_VOTE_TYPES', [ + 0 => MSZ_COMMENTS_VOTE_INDIFFERENT, + 1 => MSZ_COMMENTS_VOTE_LIKE, + -1 => MSZ_COMMENTS_VOTE_DISLIKE, +]); + +// 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 +{ + $perms = perms_get_user(MSZ_PERMS_COMMENTS, $userId); + return [ + 'can_comment' => perms_check($perms, MSZ_COMMENTS_PERM_CREATE), + 'can_edit' => perms_check($perms, MSZ_COMMENTS_PERM_EDIT_OWN | MSZ_COMMENTS_PERM_EDIT_ANY), + 'can_edit_any' => perms_check($perms, MSZ_COMMENTS_PERM_EDIT_ANY), + 'can_delete' => perms_check($perms, MSZ_COMMENTS_PERM_DELETE_OWN | MSZ_COMMENTS_PERM_DELETE_ANY), + 'can_delete_any' => perms_check($perms, MSZ_COMMENTS_PERM_DELETE_ANY), + 'can_pin' => perms_check($perms, MSZ_COMMENTS_PERM_PIN), + 'can_lock' => perms_check($perms, MSZ_COMMENTS_PERM_LOCK), + 'can_vote' => perms_check($perms, MSZ_COMMENTS_PERM_VOTE), + ]; +} + +function comments_vote_add(int $comment, int $user, ?string $vote): bool +{ + if (!in_array($vote, MSZ_COMMENTS_VOTE_TYPES, true)) { + return false; + } + + $setVote = Database::prepare(' + REPLACE INTO `msz_comments_votes` + (`comment_id`, `user_id`, `comment_vote`) + VALUES + (:comment, :user, :vote) + '); + $setVote->bindValue('comment', $comment); + $setVote->bindValue('user', $user); + $setVote->bindValue('vote', $vote); + return $setVote->execute(); +} + +function comments_category_create(string $name): array { $create = Database::prepare(' INSERT INTO `msz_comments_categories` (`category_name`) VALUES - (:name) + (LOWER(:name)) '); $create->bindValue('name', $name); - return $create->execute() ? Database::lastInsertId() : 0; + return $create->execute() + ? comments_category_info((int)Database::lastInsertId(), false) + : []; } function comments_category_lock(int $category, bool $lock): void @@ -33,22 +81,125 @@ function comments_category_lock(int $category, bool $lock): void $lock->execute(); } -function comments_category_exists(string $name): bool +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 { - $exists = Database::prepare(' - SELECT COUNT(`category_name`) > 0 - FROM `msz_comments_categories` - WHERE `category_name` = :name - '); - $exists->bindValue('name', $name); - return $exists->execute() ? (bool)$exists->fetchColumn() : false; + if (is_int($category)) { + $getCategory = Database::prepare(MSZ_COMMENTS_CATEGORY_INFO_ID); + $createIfNone = false; + } elseif (is_string($category)) { + $getCategory = Database::prepare(MSZ_COMMENTS_CATEGORY_INFO_NAME); + } else { + return []; + } + + $getCategory->bindValue('category', $category); + $categoryInfo = $getCategory->execute() ? $getCategory->fetch(PDO::FETCH_ASSOC) : false; + return $categoryInfo + ? $categoryInfo + : ( + $createIfNone + ? comments_category_create($category) + : [] + ); } -function comments_category_get(int $category): array +define('MSZ_COMMENTS_CATEGORY_QUERY', ' + SELECT + p.`comment_id`, p.`comment_text`, p.`comment_reply_to`, + p.`comment_created`, 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 p.`category_id` = :category + %s + ORDER BY p.`comment_pinned` DESC, p.`comment_id` DESC +'); +define('MSZ_COMMENTS_CATEGORY_QUERY_ROOT', sprintf( + MSZ_COMMENTS_CATEGORY_QUERY, + 'AND p.`comment_reply_to` IS NULL' +)); +define('MSZ_COMMENTS_CATEGORY_QUERY_REPLIES', sprintf( + MSZ_COMMENTS_CATEGORY_QUERY, + 'AND p.`comment_reply_to` = :parent' +)); + +// heavily recursive +function comments_category_get(int $category, ?int $parent = null): array { - $posts = Database::prepare(' + if ($parent !== null) { + $getComments = Database::prepare(MSZ_COMMENTS_CATEGORY_QUERY_REPLIES); + $getComments->bindValue('parent', $parent); + } else { + $getComments = Database::prepare(MSZ_COMMENTS_CATEGORY_QUERY_ROOT); + } + + $getComments->bindValue('category', $category); + $comments = $getComments->execute() ? $getComments->fetchAll(PDO::FETCH_ASSOC) : []; + + $commentsCount = count($comments); + for ($i = 0; $i < $commentsCount; $i++) { + $comments[$i]['comment_replies'] = comments_category_get($category, $comments[$i]['comment_id']); + } + + return $comments; +} + +function comments_post_create(int $user, int $category, string $text, bool $pinned = false, ?int $reply = null): int +{ + $create = Database::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->bindValue('user', $user); + $create->bindValue('category', $category); + $create->bindValue('text', $text); + $create->bindValue('pin', $pinned ? 1 : 0); + $create->bindValue('reply', $reply < 1 ? null : $reply); + return $create->execute() ? Database::lastInsertId() : 0; +} + +function comments_post_delete(int $commentId, bool $delete = true): bool +{ + $deleteComment = Database::prepare(' + UPDATE `msz_comments_posts` + SET `comment_deleted` = IF(:del, NOW(), NULL) + WHERE `comment_id` = :id + '); + $deleteComment->bindValue('id', $commentId); + $deleteComment->bindValue('del', $delete ? 1 : 0); + return $deleteComment->execute(); +} + +function comments_post_get(int $commentId): array +{ + $fetch = Database::prepare(' SELECT - p.`comment_id`, p.`comment_text`, + 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 @@ -56,22 +207,52 @@ function comments_category_get(int $category): array ON u.`user_id` = p.`user_id` LEFT JOIN `msz_roles` as r ON r.`role_id` = u.`display_role` - WHERE c.`category_id` = :category + WHERE `comment_id` = :id '); - $posts->bindValue('category', $category); - return $posts->execute() ? $posts->fetchAll(PDO::FETCH_ASSOC) : []; + $fetch->bindValue('id', $commentId); + return $fetch->execute() ? $fetch->fetch(PDO::FETCH_ASSOC) : []; } -function comments_post_create(int $user, int $category, string $text): int +function comments_post_exists(int $commentId): bool { - $create = Database::prepare(' - INSERT INTO `msz_comments_posts` - (`user_id`, `category_id`, `comment_text`) - VALUES - (:user, :category, :text) + $fetch = Database::prepare(' + SELECT COUNT(`comment_id`) > 0 + FROM `msz_comments_posts` + WHERE `comment_id` = :id '); - $create->bindValue('user', $user); - $create->bindValue('category', $category); - $create->bindValue('text', $text); - return $create->execute() ? Database::lastInsertId() : 0; + $fetch->bindValue('id', $commentId); + return $fetch->execute() ? (bool)$fetch->fetchColumn() : false; +} + +function comments_post_replies(int $commentId): array +{ + $getComments = Database::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->bindValue('id', $commentId); + return $getComments->execute() ? $getComments->fetchAll(PDO::FETCH_ASSOC) : []; +} + +function comments_post_check_ownership(int $commentId, int $userId): bool +{ + $checkUser = Database::prepare(' + SELECT COUNT(`comment_id`) > 0 + FROM `msz_comments_posts` + WHERE `comment_id` = :comment + AND `user_id` = :user + '); + $checkUser->bindValue('comment', $commentId); + $checkUser->bindValue('user', $userId); + return $checkUser->execute() ? (bool)$checkUser->fetchColumn() : false; } diff --git a/src/manage.php b/src/manage.php index bc37d58c..35e9655a 100644 --- a/src/manage.php +++ b/src/manage.php @@ -440,6 +440,16 @@ function manage_perms_list(array $rawPerms): array $rawPerms['comments_perms_deny'] ), ], + [ + 'section' => 'vote', + 'title' => 'Can like or dislike comments.', + 'perm' => MSZ_COMMENTS_PERM_VOTE, + 'value' => manage_perms_value( + MSZ_COMMENTS_PERM_VOTE, + $rawPerms['comments_perms_allow'], + $rawPerms['comments_perms_deny'] + ), + ], ], ], [ diff --git a/utility.php b/utility.php index 7bc00cbf..bcc7e59a 100644 --- a/utility.php +++ b/utility.php @@ -225,6 +225,22 @@ function parse_bbcode(string $text): string return \Misuzu\Parsers\BBCode\BBCodeParser::instance()->parseText($text); } +function is_local_url(string $url): bool +{ + $length = strlen($url); + + if ($length < 1) { + return false; + } + + if ($url[0] === '/' && ($length > 1 ? $url[1] !== '/' : true)) { + return true; + } + + $prefix = 'http' . (empty($_SERVER['HTTPS']) ? '' : 's') . '://' . $_SERVER['HTTP_HOST'] . '/'; + return starts_with($url, $prefix); +} + function parse_text(string $text, string $parser): string { switch (strtolower($parser)) { @@ -259,15 +275,47 @@ function parse_line(string $line, string $parser): string function render_error(int $code, string $template = 'errors.%d'): string { - http_response_code($code); + return render_info(null, $code, $template); +} + +function render_info(?string $message, int $httpCode, string $template = 'errors.%d'): string +{ + http_response_code($httpCode); try { - return \Misuzu\Application::getInstance()->getTemplating()->render(sprintf($template, $code)); + $tpl = \Misuzu\Application::getInstance()->getTemplating(); + + $tpl->var('http_code', $httpCode); + + if (strlen($message)) { + $tpl->var('message', $message); + } + + $template = sprintf($template, $httpCode); + + if (!$tpl->exists($template, \Misuzu\TemplateEngine::TWIG_DEFAULT)) { + $template = 'errors.master'; + } + + return $tpl->render(sprintf($template, $httpCode)); } catch (Exception $ex) { - return ''; + echo $ex->getMessage(); + return $message ?? ''; } } +function render_info_or_json(bool $json, string $message, int $httpCode = 200, string $template = 'errors.%d'): string +{ + $error = $httpCode >= 400; + http_response_code($httpCode); + + if ($json) { + return json_encode([($error ? 'error' : 'message') => $message]); + } + + return render_info($message, $httpCode, $template); +} + function html_link(string $url, ?string $content = null, $attributes = []): string { $content = $content ?? $url; diff --git a/views/manage/master.twig b/views/manage/master.twig index e360b44e..a2924ddf 100644 --- a/views/manage/master.twig +++ b/views/manage/master.twig @@ -40,7 +40,7 @@