i definitely did this today

This commit is contained in:
flash 2018-08-07 00:19:35 +02:00
parent 3760b49359
commit 74f5fd0921
18 changed files with 907 additions and 42 deletions

View file

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

View file

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

View file

@ -8,8 +8,8 @@
background-color: #23172a; background-color: #23172a;
} }
&--hidden { &--hidden { // __title should always be the first element of a container
.container__content { :not(:first-child) {
display: none; 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; margin: 2px 5px;
} }
} }

View file

@ -92,3 +92,7 @@ body {
// Member listing // Member listing
@import "classes/members/user"; @import "classes/members/user";
@import "classes/members/users"; @import "classes/members/users";
// Comments
@import "classes/comment"; // entries
@import "classes/comments"; // listing

View file

@ -24,6 +24,7 @@ function migrate_up(PDO $conn): void
`comment_reply_to` INT(10) UNSIGNED NULL DEFAULT NULL, `comment_reply_to` INT(10) UNSIGNED NULL DEFAULT NULL,
`comment_text` TEXT NOT NULL, `comment_text` TEXT NOT NULL,
`comment_created` TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, `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_edited` TIMESTAMP NULL DEFAULT NULL ON UPDATE CURRENT_TIMESTAMP,
`comment_deleted` TIMESTAMP NULL DEFAULT NULL, `comment_deleted` TIMESTAMP NULL DEFAULT NULL,
PRIMARY KEY (`comment_id`), 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_reply_id` (`comment_reply_to`),
INDEX `comments_posts_dates` ( INDEX `comments_posts_dates` (
`comment_created`, `comment_created`,
`comment_pinned`,
`comment_edited`, `comment_edited`,
`comment_deleted` `comment_deleted`
), ),
@ -47,10 +49,32 @@ function migrate_up(PDO $conn): void
ON DELETE SET NULL 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 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_posts`');
$conn->exec('DROP TABLE `msz_comments_categories`'); $conn->exec('DROP TABLE `msz_comments_categories`');
} }

View file

@ -103,7 +103,7 @@ if (PHP_SAPI === 'cli') {
case 'migrate': case 'migrate':
$migrationTargets = [ $migrationTargets = [
'mysql-main' => __DIR__ . '/database', 'mysql-main' => __DIR__ . '/database',
]; ];
$doRollback = !empty($argv[2]) && $argv[2] === 'rollback'; $doRollback = !empty($argv[2]) && $argv[2] === 'rollback';
$targetDb = isset($argv[$doRollback ? 3 : 2]) ? $argv[$doRollback ? 3 : 2] : null; $targetDb = isset($argv[$doRollback ? 3 : 2]) ? $argv[$doRollback ? 3 : 2] : null;
@ -232,7 +232,7 @@ MIG;
$getUserDisplayInfo = Database::prepare(' $getUserDisplayInfo = Database::prepare('
SELECT SELECT
u.`user_id`, u.`username`, 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 FROM `msz_users` as u
LEFT JOIN `msz_roles` as r LEFT JOIN `msz_roles` as r
ON u.`display_role` = r.`role_id` ON u.`display_role` = r.`role_id`

View file

@ -13,9 +13,12 @@ $changelogDate = $_GET['d'] ?? '';
$changelogUser = (int)($_GET['u'] ?? 0); $changelogUser = (int)($_GET['u'] ?? 0);
$changelogTags = $_GET['t'] ?? ''; $changelogTags = $_GET['t'] ?? '';
$commentPerms = comments_get_perms($app->getUserId());
$tpl->vars([ $tpl->vars([
'changelog_offset' => $changelogOffset, 'changelog_offset' => $changelogOffset,
'changelog_take' => $changelogRange, 'changelog_take' => $changelogRange,
'comments_perms' => $commentPerms,
]); ]);
if ($changelogChange > 0) { if ($changelogChange > 0) {
@ -55,7 +58,14 @@ if ($changelogChange > 0) {
$tpl->var('tags', $tags); $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; return;
} }
@ -77,6 +87,13 @@ if (!$changes) {
http_response_code(404); 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', [ echo $tpl->render('changelog.index', [
'changes' => $changes, 'changes' => $changes,
'changelog_count' => $changesCount, 'changelog_count' => $changesCount,

181
public/comments.php Normal file
View file

@ -0,0 +1,181 @@
<?php
use Misuzu\Database;
require_once __DIR__ . '/../misuzu.php';
// if false, display informational pages instead of outputting json.
$isXHR = !empty($_SERVER['HTTP_MISUZU_XHR_REQUEST']);
if ($isXHR || $_SERVER['REQUEST_METHOD'] === 'GET') {
header('Content-Type: application/json; charset=utf-8');
}
if ($app->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);
}

View file

@ -19,6 +19,8 @@ class TemplateEngine
*/ */
private const FILE_EXTENSION = '.twig'; private const FILE_EXTENSION = '.twig';
public const TWIG_DEFAULT = Twig_Loader_Filesystem::MAIN_NAMESPACE;
/** /**
* Instance of the Twig Environment. * Instance of the Twig Environment.
* @var Twig_Environment * @var Twig_Environment

View file

@ -8,17 +8,65 @@ define('MSZ_COMMENTS_PERM_DELETE_OWN', 1 << 3);
define('MSZ_COMMENTS_PERM_DELETE_ANY', 1 << 4); define('MSZ_COMMENTS_PERM_DELETE_ANY', 1 << 4);
define('MSZ_COMMENTS_PERM_PIN', 1 << 5); define('MSZ_COMMENTS_PERM_PIN', 1 << 5);
define('MSZ_COMMENTS_PERM_LOCK', 1 << 6); 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(' $create = Database::prepare('
INSERT INTO `msz_comments_categories` INSERT INTO `msz_comments_categories`
(`category_name`) (`category_name`)
VALUES VALUES
(:name) (LOWER(:name))
'); ');
$create->bindValue('name', $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 function comments_category_lock(int $category, bool $lock): void
@ -33,22 +81,125 @@ function comments_category_lock(int $category, bool $lock): void
$lock->execute(); $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(' if (is_int($category)) {
SELECT COUNT(`category_name`) > 0 $getCategory = Database::prepare(MSZ_COMMENTS_CATEGORY_INFO_ID);
FROM `msz_comments_categories` $createIfNone = false;
WHERE `category_name` = :name } elseif (is_string($category)) {
'); $getCategory = Database::prepare(MSZ_COMMENTS_CATEGORY_INFO_NAME);
$exists->bindValue('name', $name); } else {
return $exists->execute() ? (bool)$exists->fetchColumn() : false; 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 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`, u.`user_id`, u.`username`,
COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour` COALESCE(u.`user_colour`, r.`role_colour`) as `user_colour`
FROM `msz_comments_posts` as p FROM `msz_comments_posts` as p
@ -56,22 +207,52 @@ function comments_category_get(int $category): array
ON u.`user_id` = p.`user_id` ON u.`user_id` = p.`user_id`
LEFT JOIN `msz_roles` as r LEFT JOIN `msz_roles` as r
ON r.`role_id` = u.`display_role` ON r.`role_id` = u.`display_role`
WHERE c.`category_id` = :category WHERE `comment_id` = :id
'); ');
$posts->bindValue('category', $category); $fetch->bindValue('id', $commentId);
return $posts->execute() ? $posts->fetchAll(PDO::FETCH_ASSOC) : []; 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(' $fetch = Database::prepare('
INSERT INTO `msz_comments_posts` SELECT COUNT(`comment_id`) > 0
(`user_id`, `category_id`, `comment_text`) FROM `msz_comments_posts`
VALUES WHERE `comment_id` = :id
(:user, :category, :text)
'); ');
$create->bindValue('user', $user); $fetch->bindValue('id', $commentId);
$create->bindValue('category', $category); return $fetch->execute() ? (bool)$fetch->fetchColumn() : false;
$create->bindValue('text', $text); }
return $create->execute() ? Database::lastInsertId() : 0;
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;
} }

View file

@ -440,6 +440,16 @@ function manage_perms_list(array $rawPerms): array
$rawPerms['comments_perms_deny'] $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']
),
],
], ],
], ],
[ [

View file

@ -225,6 +225,22 @@ function parse_bbcode(string $text): string
return \Misuzu\Parsers\BBCode\BBCodeParser::instance()->parseText($text); 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 function parse_text(string $text, string $parser): string
{ {
switch (strtolower($parser)) { 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 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 { 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) { } 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 function html_link(string $url, ?string $content = null, $attributes = []): string
{ {
$content = $content ?? $url; $content = $content ?? $url;

View file

@ -40,7 +40,7 @@
<div class="header__user"> <div class="header__user">
<div class="header__menu"> <div class="header__menu">
<input type="checkbox" id="menu-user-state" class="header__menu__state"> <input type="checkbox" id="menu-user-state" class="header__menu__state">
<label for="menu-user-state" class="header__menu__toggle header__menu__toggle--profile" style="background-image:url('/profile.php?u={{ current_user.user_id }}&amp;m=avatar');{{ current_user.colour|html_colour }}">{{ current_user.username }}</label> <label for="menu-user-state" class="header__menu__toggle header__menu__toggle--profile" style="background-image:url('/profile.php?u={{ current_user.user_id }}&amp;m=avatar');{{ current_user.user_colour|html_colour }}">{{ current_user.username }}</label>
<div class="header__menu__options header__menu__options--user"> <div class="header__menu__options header__menu__options--user">
<div class="header__menu__section"> <div class="header__menu__section">
<a class="header__menu__link" href="/profile.php?u={{ current_user.user_id }}">Profile</a> <a class="header__menu__link" href="/profile.php?u={{ current_user.user_id }}">Profile</a>

View file

@ -4,7 +4,7 @@
{% block content %} {% block content %}
<div class="container listing user-listing"> <div class="container listing user-listing">
{% for user in manage_users %} {% for user in manage_users %}
<a href="?v=view&amp;u={{ user.user_id }}" class="listing__entry user-listing__entry"{% if not user.colour|colour_get_inherit %} style="{{ user.colour|html_colour({'border-color':'%s'}) }}"{% endif %}> <a href="?v=view&amp;u={{ user.user_id }}" class="listing__entry user-listing__entry"{% if not user.user_colour|colour_get_inherit %} style="{{ user.user_colour|html_colour({'border-color':'%s'}) }}"{% endif %}>
<div class="listing__entry__content user-listing__entry__content"> <div class="listing__entry__content user-listing__entry__content">
<div class="user-listing__info"> <div class="user-listing__info">
<div class="user-listing__username"> <div class="user-listing__username">

View file

@ -0,0 +1,193 @@
{% macro comments_input(category, user, perms, reply_to) %}
{% set reply_mode = reply_to is not null %}
<form class="comment comment--input{% if reply_mode %} comment--hidden{% endif %}"
method="post" action="/comments.php"
id="comment-{{ reply_mode ? 'reply-' ~ reply_to.comment_id : 'create-' ~ category.category_id }}">
<input type="hidden" name="comment[category]" value="{{ category.category_id }}">
{% if reply_mode %}
<input type="hidden" name="comment[reply]" value="{{ reply_to.comment_id }}">
{% endif %}
<div class="comment__container">
<div class="avatar comment__avatar"
style="background-image:url('/profile.php?m=avatar&amp;u={{ user.user_id }}')">
</div>
<div class="comment__content">
<div class="comment__info">
<div class="comment__user"
style="{{ user.user_colour|html_colour }}">{{ user.username }}</div>
</div>
<textarea
class="comment__text input__textarea comment__text--input"
name="comment[text]" placeholder="Share your extensive insight..."></textarea>
<div class="comment__actions">
{% if not reply_mode %}
{% if perms.can_pin %}
<label class="comment__action comment__action--label">
<input type="checkbox" class="comment__action__checkbox" name="comment[pin]">
Pin this comment
</label>
{% endif %}
{% if perms.can_lock %}
<label class="comment__action comment__action--label">
<input type="checkbox" class="comment__action__checkbox" name="comment[lock]">
Toggle locked status
</label>
{% endif %}
{% endif %}
<button class="comment__action comment__action--button comment__action--post">{{ reply_mode ? 'Reply' : 'Post' }}</button>
</div>
</div>
</div>
</form>
{% endmacro %}
{% macro comments_section(comments, category, user, perms) %}
<div class="comments">
<div class="comments__input">
{% if user|default(null) is null %}
<div class="comments__notice">
Please <a href="/auth.php?m=login" class="comments__notice__link">login</a> to comment.
</div>
{% elseif category|default(null) is null or perms|default(null) is null %}
<div class="comments__notice">
Posting new comments here is disabled.
</div>
{% elseif not perms.can_comment %}
<div class="comments__notice">
You are not allowed to post comments.
</div>
{% else %}
{% from _self import comments_input %}
{{ comments_input(category, user, perms) }}
{% endif %}
</div>
<noscript>
<div class="comments__javascript">
The comments need Javascript to be enabled to function properly, without it functionality is limited.
</div>
</noscript>
{% if comments|length > 0 %}
<div class="comments__listing">
{% from _self import comments_entry %}
{% for comment in comments %}
{{ comments_entry(comment, 1, category, user, perms) }}
{% endfor %}
</div>
{% else %}
<div class="comments__none">
There are no comments yet.
</div>
{% endif %}
</div>
{% if perms.can_comment %}
<script>
var commentPostLock = false;
function commentPost(form)
{
console.log(form);
}
</script>
{% endif %}
{% if perms.can_vote %}
<script>
var commentVoteLock = false;
// this is kinda temporary
// needs to be able to change the state of both the dislike and like buttons
function commentVote(elem, id, vote)
{
if (id < 1 || commentVoteLock)
return;
commentVoteLock = true;
var originalText = elem.textContent;
elem.textContent = '...';
var formData = new FormData();
formData.append('comment_id', id);
formData.append('vote', vote);
formData.append('csrf', '{{ csrf_token() }}');
var xhr = new XMLHttpRequest();
xhr.onreadystatechange = function() {
console.log(this.readyState + ' ' + this.status + ': ' + this.responseText);
if (this.readyState === 4) {
elem.textContent = originalText;
commentVoteLock = false;
}
};
xhr.open('POST', '/comments.php');
xhr.send(formData);
}
</script>
{% endif %}
{% endmacro %}
{% macro comments_entry(comment, indent, category, user, perms) %}
<div class="comment" id="comment-{{ comment.comment_id }}">
<div class="comment__container">
<a class="avatar comment__avatar"
href="/profile.php?u={{ comment.user_id }}"
style="background-image:url('/profile.php?m=avatar&amp;u={{ comment.user_id }}')">
</a>
<div class="comment__content">
<div class="comment__info">
<a class="comment__user comment__user--link"
href="/profile.php?u={{ comment.user_id }}"
style="{{ comment.user_colour|html_colour }}">{{ comment.username }}</a>
<a class="comment__link" href="#comment-{{ comment.comment_id }}">
<time class="comment__date"
title="{{ comment.comment_created|date('r') }}"
datetime="{{ comment.comment_created|date('c') }}">
{{ comment.comment_created|time_diff }}
</time>
</a>
{% if comment.comment_pinned is not null %}
<span class="comment__pin">{% 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 }}
</time>
{% endif %}
{% endspaceless %}</span>
{% endif %}
</div>
<div class="comment__text">
{{ comment.comment_text|nl2br }}
</div>
<div class="comment__actions">
{% if perms.can_vote %}
<a class="comment__action comment__action--link" href="javascript:void(0)" onclick="commentVote(this, {{ comment.comment_id }}, 1)">Like</a>
<a class="comment__action comment__action--link" href="javascript:void(0)" onclick="commentVote(this, {{ comment.comment_id }}, -1)">Dislike</a>
{% endif %}
{% if perms.can_comment %}
<a class="comment__action comment__action--link" href="#">Reply</a>
{% endif %}
{% if user is not null %}
<a class="comment__action comment__action--link comment__action--hide" href="#">Report</a>
{% endif %}
</div>
</div>
</div>
<div class="comment__replies comment__replies--indent-{{ indent }}" id="comment-{{ 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 %}
{{ comments_input(category, user, perms, 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) }}
{% endfor %}
{% endif %}
</div>
</div>
{% endmacro %}

View file

@ -1,5 +1,6 @@
{% extends '@mio/changelog/master.twig' %} {% extends '@mio/changelog/master.twig' %}
{% from '@mio/macros.twig' import navigation %} {% from '@mio/macros.twig' import navigation %}
{% from '@mio/_layout/comments.twig' import comments_section %}
{% set is_valid = change|length > 0 %} {% set is_valid = change|length > 0 %}
{% set title = 'Changelog » ' ~ (is_valid ? 'Change #' ~ change.change_id : 'Unknown Change') %} {% set title = 'Changelog » ' ~ (is_valid ? 'Change #' ~ change.change_id : 'Unknown Change') %}
@ -96,5 +97,14 @@
}); });
</script> </script>
{% if comments is defined %}
<div class="container">
<div class="container__title">
Comments for {{ change.change_date }}
</div>
{{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }}
</div>
{% endif %}
{{ navigation(mio_navigation) }} {{ navigation(mio_navigation) }}
{% endblock %} {% endblock %}

View file

@ -1,6 +1,7 @@
{% extends '@mio/changelog/master.twig' %} {% extends '@mio/changelog/master.twig' %}
{% from '@mio/macros.twig' import navigation, pagination %} {% from '@mio/macros.twig' import navigation, pagination %}
{% from '@mio/changelog/macros.twig' import changelog_listing %} {% from '@mio/changelog/macros.twig' import changelog_listing %}
{% from '@mio/_layout/comments.twig' import comments_section %}
{% set is_valid = changes|length > 0 %} {% set is_valid = changes|length > 0 %}
{% set is_date = changelog_date|length > 0 %} {% set is_date = changelog_date|length > 0 %}
@ -37,5 +38,14 @@
</div> </div>
</div> </div>
{% if comments is defined %}
<div class="container">
<div class="container__title">
Comments
</div>
{{ comments_section(comments, comments_category, current_user|default(null), comments_perms) }}
</div>
{% endif %}
{{ navigation(mio_navigation) }} {{ navigation(mio_navigation) }}
{% endblock %} {% endblock %}

View file

@ -3,11 +3,18 @@
{% block content %} {% block content %}
<div class="container"> <div class="container">
<div class="container__title">Error {{ error_code }}{{ error_text is defined ? ' - ' ~ error_text : '' }}</div> <div class="container__title">
{{ title|default(error_code|default(http_code) >= 400 ? 'Error' : 'Information') }} {{ error_code|default(http_code >= 400 ? http_code : '') }}{{ error_text is defined ? ' - ' ~ error_text : '' }}
</div>
<div class="container__content"> <div class="container__content">
{% block error_message %} {% if message is defined %}
<p>Try again later, perhaps.</p> <p>{{ message }}</p>
{% endblock %} {% else %}
{% block error_message %}
<p>Try again later, perhaps.</p>
{% endblock %}
{% endif %}
</div> </div>
</div> </div>