Rewrote client side code for the comment system.

This commit is contained in:
flash 2018-12-11 00:43:50 +01:00
parent d3531fc2c1
commit fa9059a9d9
6 changed files with 218 additions and 168 deletions

View file

@ -1,26 +1,5 @@
/// <reference path="FormUtilities.ts" />
let globalCommentLock = false;
function commentsLocked(): boolean
{
return globalCommentLock;
}
function commentsRequestLock(): boolean
{
if (commentsLocked())
return false;
return globalCommentLock = true;
}
function commentsFreeLock(): void
{
globalCommentLock = false;
}
enum CommentVoteType {
Indifferent = 0,
Like = 1,
@ -33,7 +12,7 @@ interface CommentNotice {
}
interface CommentDeletionInfo extends CommentNotice {
comment_id: number;
id: number; // minor inconsistency, deal with it
}
interface CommentPostInfo extends CommentNotice {
@ -57,32 +36,49 @@ interface CommentVotesInfo extends CommentNotice {
dislikes: number;
}
function commentDelete(ev: Event): void
{
if (!checkUserPerm('comments', CommentPermission.Delete) || !commentsRequestLock())
return;
function commentDeleteEventHandler(ev: Event): void {
const target: HTMLAnchorElement = ev.target as HTMLAnchorElement,
commentId: number = parseInt(target.dataset.commentId);
const xhr: XMLHttpRequest = new XMLHttpRequest(),
target: HTMLAnchorElement = ev.target as HTMLAnchorElement;
commentDelete(
commentId,
info => {
let elem = document.getElementById('comment-' + info.id);
if (elem)
elem.parentNode.removeChild(elem);
},
message => {
alert(message);
}
);
}
function commentDelete(commentId: number, onSuccess: (info: CommentDeletionInfo) => void = null, onFail: (message: string) => void = null): void
{
if (!checkUserPerm('comments', CommentPermission.Delete)) {
if (onFail)
onFail("You aren't allowed to delete comments.");
return;
}
const xhr: XMLHttpRequest = new XMLHttpRequest;
xhr.addEventListener('readystatechange', () => {
if (xhr.readyState !== 4)
return;
commentsFreeLock();
updateCSRF(xhr.getResponseHeader('X-Misuzu-CSRF'));
let json: CommentDeletionInfo = JSON.parse(xhr.responseText) as CommentDeletionInfo,
message = json.error || json.message;
if (message)
alert(message);
else {
let elem = document.getElementById('comment-' + json.comment_id);
if (elem)
elem.parentNode.removeChild(elem);
}
if (message && onFail)
onFail(message);
else if (!message && onSuccess)
onSuccess(json);
});
xhr.open('GET', target.dataset.href);
xhr.open('GET', `/comments.php?m=delete&c=${commentId}&csrf=${getCSRFToken('comments')}`);
xhr.setRequestHeader('X-Misuzu-XHR', 'comments');
xhr.send();
}
@ -91,17 +87,25 @@ function commentPostEventHandler(ev: Event): void
{
const form: HTMLFormElement = ev.target as HTMLFormElement;
if (form.dataset.disabled)
return;
form.dataset.disabled = '1';
form.style.opacity = '0.5';
commentPost(
ExtractFormData(form, true),
extractFormData(form, true),
info => commentPostSuccess(form, info),
commentPostFail
message => commentPostFail(form, message)
);
}
function commentPost(formData: FormData, onSuccess: (comment: CommentPostInfo) => void = null, onFail: (message: string) => void = null): void
{
if (!checkUserPerm('comments', CommentPermission.Create) || !commentsRequestLock())
if (!checkUserPerm('comments', CommentPermission.Create)) {
if (onFail)
onFail("You aren't allowed to post comments.");
return;
}
const xhr = new XMLHttpRequest();
@ -109,9 +113,7 @@ function commentPost(formData: FormData, onSuccess: (comment: CommentPostInfo) =
if (xhr.readyState !== 4)
return;
commentsFreeLock();
console.log(xhr.getResponseHeader('X-Misuzu-CSRF'));
updateCSRF(xhr.getResponseHeader('X-Misuzu-CSRF'));
const json: CommentPostInfo = JSON.parse(xhr.responseText) as CommentPostInfo,
message: string = json.error || json.message;
@ -132,17 +134,21 @@ function commentPostSuccess(form: HTMLFormElement, comment: CommentPostInfo): vo
(form.parentNode.parentNode.querySelector('label.comment__action') as HTMLLabelElement).click();
commentInsert(comment, form);
form.style.opacity = '1';
form.dataset.disabled = '';
}
function commentPostFail(message: string): void {
function commentPostFail(form: HTMLFormElement, message: string): void {
alert(message);
form.style.opacity = '1';
form.dataset.disabled = '';
}
function commentsInit(): void {
const commentDeletes: HTMLCollectionOf<HTMLAnchorElement> = document.getElementsByClassName('comment__action--delete') as HTMLCollectionOf<HTMLAnchorElement>;
for (let i = 0; i < commentDeletes.length; i++) {
commentDeletes[i].addEventListener('click', commentDelete);
commentDeletes[i].addEventListener('click', commentDeleteEventHandler);
commentDeletes[i].dataset.href = commentDeletes[i].href;
commentDeletes[i].href = 'javascript:void(0);';
}
@ -152,16 +158,26 @@ function commentsInit(): void {
for (let i = 0; i < commentInputs.length; i++) {
commentInputs[i].form.action = 'javascript:void(0);';
commentInputs[i].form.addEventListener('submit', commentPostEventHandler);
commentInputs[i].addEventListener('keydown', ev => {
if (ev.keyCode === 13 && ev.ctrlKey && !ev.altKey && !ev.shiftKey) {
let form = commentInputs[i].form;
commentPost(
ExtractFormData(form, true),
info => commentPostSuccess(form, info),
message => commentPostFail
);
}
});
commentInputs[i].addEventListener('keydown', commentInputEventHandler);
}
const voteButtons: HTMLCollectionOf<HTMLAnchorElement> = document.getElementsByClassName('comment__action--vote') as HTMLCollectionOf<HTMLAnchorElement>;
for (var i = 0; i < voteButtons.length; i++)
{
voteButtons[i].href = 'javascript:void(0);';
voteButtons[i].addEventListener('click', commentVoteEventHandler);
}
}
function commentInputEventHandler(ev: KeyboardEvent): void {
if (ev.keyCode === 13 && ev.ctrlKey && !ev.altKey && !ev.shiftKey) {
const form: HTMLFormElement = (ev.target as HTMLTextAreaElement).form;
commentPost(
extractFormData(form, true),
info => commentPostSuccess(form, info),
message => commentPostFail
);
}
}
@ -226,16 +242,20 @@ function commentConstruct(comment: CommentPostInfo, layer: number = 0): HTMLElem
// actions
if (checkUserPerm('comments', CommentPermission.Vote)) {
const commentLike: HTMLAnchorElement = commentActions.appendChild(document.createElement('a'));
commentLike.className = 'comment__action comment__action--link comment__action--like';
commentLike.dataset['commentId'] = comment.comment_id.toString();
commentLike.dataset['commentVote'] = CommentVoteType.Like.toString();
commentLike.className = 'comment__action comment__action--link comment__action--vote comment__action--like';
commentLike.href = 'javascript:void(0);';
commentLike.textContent = 'Like';
commentLike.addEventListener('click', commentVoteEventHandler);
const commentDislike: HTMLAnchorElement = commentActions.appendChild(document.createElement('a'));
commentDislike.className = 'comment__action comment__action--link comment__action--dislike';
commentDislike.dataset['commentId'] = comment.comment_id.toString();
commentDislike.dataset['commentVote'] = CommentVoteType.Dislike.toString();
commentDislike.className = 'comment__action comment__action--link comment__action--vote comment__action--dislike';
commentDislike.href = 'javascript:void(0);';
commentDislike.textContent = 'Dislike';
commentLike.addEventListener('click', commentVoteEventHandler);
commentDislike.addEventListener('click', commentVoteEventHandler);
}
// if we're executing this it's fairly obvious that we can reply,
@ -265,8 +285,8 @@ function commentConstruct(comment: CommentPostInfo, layer: number = 0): HTMLElem
replyCategory.type = 'hidden';
const replyCsrf: HTMLInputElement = commentReplyInput.appendChild(document.createElement('input'));
replyCsrf.name = 'csrf';
replyCsrf.value = '{{ csrf_token("comments") }}';
replyCsrf.name = 'csrf[comments]';
replyCsrf.value = getCSRFToken('comments');
replyCsrf.type = 'hidden';
const replyId: HTMLInputElement = commentReplyInput.appendChild(document.createElement('input'));
@ -301,6 +321,7 @@ function commentConstruct(comment: CommentPostInfo, layer: number = 0): HTMLElem
replyText.className = 'comment__text input__textarea comment__text--input';
replyText.name = 'comment[text]';
replyText.placeholder = 'Share your extensive insights...';
replyText.addEventListener('keydown', commentInputEventHandler);
const replyActions: HTMLDivElement = replyContent.appendChild(document.createElement('div'));
replyActions.className = 'comment__actions';
@ -335,24 +356,70 @@ function commentInsert(comment: CommentPostInfo, form: HTMLFormElement): void
}
function commentVoteEventHandler(ev: Event): void {
//
const target: HTMLAnchorElement = ev.target as HTMLAnchorElement,
commentId: number = parseInt(target.dataset.commentId),
voteType: CommentVoteType = parseInt(target.dataset.commentVote),
buttons: NodeListOf<HTMLAnchorElement> = document.querySelectorAll(`.comment__action--vote[data-comment-id="${commentId}"]`),
likeButton: HTMLAnchorElement = document.querySelector(`.comment__action--like[data-comment-id="${commentId}"]`),
dislikeButton: HTMLAnchorElement = document.querySelector(`.comment__action--dislike[data-comment-id="${commentId}"]`),
classVoted: string = 'comment__action--voted';
for (let i = 0; i < buttons.length; i++) {
let button: HTMLAnchorElement = buttons[i];
button.textContent = button === target ? '...' : '';
button.classList.remove(classVoted);
if (button === likeButton) {
button.dataset.commentVote = (voteType === CommentVoteType.Like ? CommentVoteType.Indifferent : CommentVoteType.Like).toString();
} else if (button === dislikeButton) {
button.dataset.commentVote = (voteType === CommentVoteType.Dislike ? CommentVoteType.Indifferent : CommentVoteType.Dislike).toString();
}
}
commentVote(
commentId,
voteType,
(info) => {
switch (voteType) {
case CommentVoteType.Like:
likeButton.classList.add(classVoted);
break;
case CommentVoteType.Dislike:
dislikeButton.classList.add(classVoted);
break;
}
likeButton.textContent = info.likes > 0 ? `Like (${info.likes.toLocaleString()})` : 'Like';
dislikeButton.textContent = info.dislikes > 0 ? `Dislike (${info.dislikes.toLocaleString()})` : 'Dislike';
},
(message) => {
likeButton.textContent = 'Like';
dislikeButton.textContent = 'Dislike';
alert(message);
}
);
}
function commentVoteV2(
function commentVote(
commentId: number,
vote: CommentVoteType,
onSuccess: (voteInfo: CommentVotesInfo, userVote: CommentVoteType) => void,
onFail: (message: string) => void
onSuccess: (voteInfo: CommentVotesInfo) => void = null,
onFail: (message: string) => void = null
): void {
if (!checkUserPerm('comments', CommentPermission.Vote) || !commentsRequestLock())
if (!checkUserPerm('comments', CommentPermission.Vote)) {
if (onFail)
onFail("You aren't allowed to vote on comments.");
return;
}
const xhr: XMLHttpRequest = new XMLHttpRequest;
xhr.onreadystatechange = () => {
if (xhr.readyState !== 4)
return;
commentsFreeLock();
updateCSRF(xhr.getResponseHeader('X-Misuzu-CSRF'));
const json: CommentVotesInfo = JSON.parse(xhr.responseText),
message: string = json.error || json.message;
@ -360,9 +427,9 @@ function commentVoteV2(
if (message && onFail)
onFail(message);
else if (!message && onSuccess)
onSuccess(json, vote);
onSuccess(json);
};
xhr.open('GET', `/comments.php?m=vote&c=${commentId}&v=${vote}&csrf={{ csrf_token("comments") }}`);
xhr.open('GET', `/comments.php?m=vote&c=${commentId}&v=${vote}&csrf=${getCSRFToken('comments')}`);
xhr.setRequestHeader('X-Misuzu-XHR', 'comments');
xhr.send();
}

View file

@ -1,4 +1,4 @@
function ExtractFormData(form: HTMLFormElement, resetSource: boolean = false): FormData
function extractFormData(form: HTMLFormElement, resetSource: boolean = false): FormData
{
const formData: FormData = new FormData;
@ -14,7 +14,7 @@ function ExtractFormData(form: HTMLFormElement, resetSource: boolean = false): F
}
if (resetSource)
ResetForm(form);
resetForm(form);
return formData;
}
@ -24,7 +24,7 @@ interface FormHiddenDefault {
Value: string;
}
function ResetForm(form: HTMLFormElement, defaults: FormHiddenDefault[] = []): void
function resetForm(form: HTMLFormElement, defaults: FormHiddenDefault[] = []): void
{
for (let i = 0; i < form.length; i++) {
let input: HTMLInputElement = form[i] as HTMLInputElement;
@ -46,3 +46,67 @@ function ResetForm(form: HTMLFormElement, defaults: FormHiddenDefault[] = []): v
}
}
}
class CSRFToken {
realm: string;
token: string;
}
const CSRFTokenStore: CSRFToken[] = [];
function initCSRF(): void {
const csrfTokens: NodeListOf<HTMLInputElement> = document.querySelectorAll('[name^="csrf["]'),
regex = /\[([a-z]+)\]/iu,
handled: string[] = [];
for (let i = 0; i < csrfTokens.length; i++) {
let csrfToken: HTMLInputElement = csrfTokens[i],
realm: string = csrfToken.name.match(regex)[1] || '';
if (handled.indexOf(realm) >= 0)
continue;
handled.push(realm);
setCSRF(realm, csrfToken.value);
}
}
function getCSRF(realm: string): CSRFToken {
return CSRFTokenStore.find(i => i.realm.toLowerCase() === realm.toLowerCase());
}
function getCSRFToken(realm: string): string {
return getCSRF(realm).token || '';
}
function setCSRF(realm: string, token: string): void {
let csrf: CSRFToken = getCSRF(realm);
if (csrf) {
csrf.token = token;
} else {
csrf = new CSRFToken;
csrf.realm = realm;
csrf.token = token;
CSRFTokenStore.push(csrf);
}
}
function updateCSRF(token: string, realm: string = null, name: string = 'csrf'): void
{
const tokenSplit: string[] = token.split(';');
if (tokenSplit.length > 1) {
token = tokenSplit[1];
if (!realm) {
realm = tokenSplit[0];
}
}
const elements: NodeListOf<HTMLInputElement> = document.getElementsByName(`${name}[${realm}]`) as NodeListOf<HTMLInputElement>;
for (let i = 0; i < elements.length; i++) {
elements[i].value = token;
}
}

View file

@ -4,6 +4,7 @@
/// <reference path="Permissions.ts" />
/// <reference path="Comments.ts" />
/// <reference path="Common.ts" />
/// <reference path="FormUtilities.ts" />
declare const timeago: any;
declare const hljs: any;
@ -15,6 +16,7 @@ window.addEventListener('load', () => {
timeago().render(document.querySelectorAll('time'));
hljs.initHighlighting();
initCSRF();
userInit();
const changelogChangeAction: HTMLDivElement = document.querySelector('.changelog__change__action') as HTMLDivElement;

View file

@ -101,17 +101,17 @@ function comments_vote_add(int $comment, int $user, ?string $vote): bool
function comments_votes_get(int $commentId): array
{
$getVotes = db_prepare('
SELECT :id as `comment_id`,
SELECT :id as `id`,
(
SELECT COUNT(`user_id`)
FROM `msz_comments_votes`
WHERE `comment_id` = `comment_id`
WHERE `comment_id` = `id`
AND `comment_vote` = \'Like\'
) as `likes`,
(
SELECT COUNT(`user_id`)
FROM `msz_comments_votes`
WHERE `comment_id` = `comment_id`
WHERE `comment_id` = `id`
AND `comment_vote` = \'Dislike\'
) as `dislikes`
');

View file

@ -1,6 +1,6 @@
<?php
define('MSZ_CSRF_TOLERANCE', 15 * 60); // DO NOT EXCEED 16-BIT INTEGER SIZES, SHIT _WILL_ BREAK
define('MSZ_CSRF_HTML', '<input type="hidden" name="%1$s" value="%2$s">');
define('MSZ_CSRF_HTML', '<input type="hidden" name="%1$s[%3$s]" value="%2$s">');
define('MSZ_CSRF_SECRET_STORE', '_msz_csrf_secret');
define('MSZ_CSRF_IDENTITY_STORE', '_msz_csrf_identity');
define('MSZ_CSRF_TOKEN_STORE', '_msz_csrf_tokens');
@ -98,8 +98,10 @@ function csrf_token(string $realm): string
);
}
function csrf_verify(string $realm, string $token): bool
function csrf_verify(string $realm, $token): bool
{
$token = (string)(is_array($token) && !empty($token[$realm]) ? $token[$realm] : $token);
return csrf_token_verify(
$realm,
$token,
@ -110,7 +112,7 @@ function csrf_verify(string $realm, string $token): bool
function csrf_html(string $realm, string $name = 'csrf'): string
{
return sprintf(MSZ_CSRF_HTML, $name, csrf_token($realm));
return sprintf(MSZ_CSRF_HTML, $name, csrf_token($realm), $realm);
}
function csrf_http_header(string $realm, string $name = 'X-Misuzu-CSRF'): string

View file

@ -83,15 +83,15 @@
{% if comment.comment_deleted is null and user is not null %}
<div class="comment__actions">
{% if perms.can_vote %}
<a class="comment__action comment__action--link comment__action--like{% if comment.comment_user_vote == 'Like' %} comment__action--voted{% endif %}"
href="/comments.php?m=vote&amp;c={{ comment.comment_id }}&amp;v={{ comment.comment_user_vote == 'Like' ? '0' : '1' }}&amp;csrf={{ csrf_token('comments') }}">
<a class="comment__action comment__action--link comment__action--vote comment__action--like{% if comment.comment_user_vote == 'Like' %} comment__action--voted{% endif %}" data-comment-id="{{ comment.comment_id }}" data-comment-vote="{{ comment.comment_user_vote == 'Like' ? '0' : '1' }}"
href="/comments.php?m=vote&amp;c={{ comment.comment_id }}&amp;v={{ comment.comment_user_vote == 'Like' ? '0' : '1' }}&amp;csrf={{ csrf_token('comments') }}">
Like
{% if comment.comment_likes > 0 %}
({{ comment.comment_likes|number_format }})
{% endif %}
</a>
<a class="comment__action comment__action--link comment__action--dislike{% if comment.comment_user_vote == 'Dislike' %} comment__action--voted{% endif %}"
href="/comments.php?m=vote&amp;c={{ comment.comment_id }}&amp;v={{ comment.comment_user_vote == 'Dislike' ? '0' : '-1' }}&amp;csrf={{ csrf_token('comments') }}">
<a class="comment__action comment__action--link comment__action--vote comment__action--dislike{% if comment.comment_user_vote == 'Dislike' %} comment__action--voted{% endif %}" data-comment-id="{{ comment.comment_id }}" data-comment-vote="{{ comment.comment_user_vote == 'Dislike' ? '0' : '-1' }}"
href="/comments.php?m=vote&amp;c={{ comment.comment_id }}&amp;v={{ comment.comment_user_vote == 'Dislike' ? '0' : '-1' }}&amp;csrf={{ csrf_token('comments') }}">
Dislike
{% if comment.comment_dislikes > 0 %}
({{ comment.comment_dislikes|number_format }})
@ -102,7 +102,7 @@
<label class="comment__action comment__action--link" for="comment-reply-toggle-{{ comment.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"
<a class="comment__action comment__action--link comment__action--hide comment__action--delete" data-comment-id="{{ comment.comment_id }}"
href="/comments.php?m=delete&amp;c={{ comment.comment_id }}&amp;csrf={{ csrf_token('comments') }}">Delete</a>
{% endif %}
{# if user is not null %}
@ -179,89 +179,4 @@
{% endif %}
</div>
</div>
<script>
window.addEventListener('load', function () {
var likeButtons = document.getElementsByClassName('comment__action--like'),
dislikeButtons = document.getElementsByClassName('comment__action--dislike');
for (var i = 0; i < likeButtons.length; i++) // there's gonna be an equal amount of like and dislike buttons
{
likeButtons[i].href = 'javascript:void(0);';
likeButtons[i].onclick = commentVote;
dislikeButtons[i].href = 'javascript:void(0);';
dislikeButtons[i].onclick = commentVote;
}
});
var commentVoteLock = false,
commentLikeClass = 'comment__action--like',
commentDislikeClass = 'comment__action--dislike',
commentVotedClass = 'comment__action--voted',
commentLikeText = 'Like',
commentDislikeText = 'Dislike',
commentVoteCountSuffix = ' ({0})';
// DEBUG THIS IF YOU MAKE MAJOR DOM CHANGES TO COMMENTS
function commentVote(ev)
{
var elem = ev.target,
id = elem.parentNode.parentNode.parentNode.parentNode.id.substr(8); // STACK UP
// the moment we find the id we engage vote lock
if (id < 1 || commentVoteLock)
return;
commentVoteLock = true;
elem.textContent = '.';
var isLike = elem.classList.contains(commentLikeClass),
isDislike = elem.classList.contains(commentDislikeClass),
isIndifferent = elem.classList.contains(commentVotedClass),
vote = isIndifferent ? 0 : (isLike ? 1 : -1);
elem.textContent += '.';
// find friendo (the other vote button), this'll fuck up if the parent element is fucked with
for (var i = 0; i < elem.parentNode.childNodes.length; i++) {
var current = elem.parentNode.childNodes[i];
if (current.nodeName.toLowerCase() === 'a' && current !== elem) {
var friend = current;
break;
}
}
if (typeof friend !== 'object') {
console.error('something happened');
return;
}
friend.classList.remove(commentVotedClass);
friend.textContent = '';
elem.textContent += '.';
commentVoteV2(
id, vote,
(vInfo, uVote) => {
if (vote)
elem.classList.add(commentVotedClass);
else
elem.classList.remove(commentVotedClass);
var likes = vInfo.likes || 0,
dislikes = vInfo.dislikes || 0;
if (isLike) { // somewhat implicitly defined, like will always come before dislike
elem.textContent = commentLikeText + (likes > 0 ? commentVoteCountSuffix.replace('{0}', likes.toLocaleString()) : '');
friend.textContent = commentDislikeText + (dislikes > 0 ? commentVoteCountSuffix.replace('{0}', dislikes.toLocaleString()) : '');
} else {
elem.textContent = commentDislikeText + (dislikes > 0 ? commentVoteCountSuffix.replace('{0}', dislikes.toLocaleString()) : '');
friend.textContent = commentLikeText + (likes > 0 ? commentVoteCountSuffix.replace('{0}', likes.toLocaleString()) : '');
}
},
alert
);
}
</script>
{% endmacro %}