diff --git a/assets/common.js/html.js b/assets/common.js/html.js index 09aef2cf..4b7d3e4a 100644 --- a/assets/common.js/html.js +++ b/assets/common.js/html.js @@ -72,7 +72,7 @@ const $create = function(info, attrs, child, created) { let setFunc = null; if(elem[key] instanceof DOMTokenList) setFunc = (ak, av) => { if(av) elem[key].add(ak); }; - else if(elem[key] instanceof CSS2Properties) + else if(elem[key] instanceof CSSStyleDeclaration) setFunc = (ak, av) => { elem[key].setProperty(ak, av); } else setFunc = (ak, av) => { elem[key][ak] = av; }; diff --git a/assets/misuzu.css/comments/entry.css b/assets/misuzu.css/comments/entry.css index 1e9bf535..b75d287b 100644 --- a/assets/misuzu.css/comments/entry.css +++ b/assets/misuzu.css/comments/entry.css @@ -12,16 +12,6 @@ border-top: 1px solid var(--accent-colour); } -.comments-entry-deleted { - opacity: .5; - transition: opacity .1s; -} -.comments-entry-deleted:hover, -.comments-entry-deleted:focus, -.comments-entry-deleted:focus-within { - opacity: .8; -} - .comments-entry-replies { margin-left: 25px; } diff --git a/assets/misuzu.js/comments/api.js b/assets/misuzu.js/comments/api.js index 9ffa4d98..107b6200 100644 --- a/assets/misuzu.js/comments/api.js +++ b/assets/misuzu.js/comments/api.js @@ -84,10 +84,48 @@ const MszCommentsApi = (() => { return status; }, updatePost: async (post, args) => { - // + if(typeof post !== 'string') + throw 'post id must be a string'; + if(post.trim() === '') + throw 'post id may not be empty'; + if(typeof args !== 'object' || args === null) + throw 'args must be a non-null object'; + + const { status, body } = await $xhr.post( + `/comments/posts/${post}`, + { csrf: true, type: 'json' }, + args + ); + if(status === 400) + throw 'your update is not acceptable'; + if(status === 401) + throw 'you must be logged in to do that'; + if(status === 403) + throw 'you are not allowed to edit that part of the post'; + if(status === 404) + throw 'that post does not exist'; + if(status === 410) + throw 'that post disappeared while attempting to edit it'; + if(status !== 200) + throw 'something went wrong'; + + return body; }, deletePost: async post => { - // + if(typeof post !== 'string') + throw 'post id must be a string'; + if(post.trim() === '') + throw 'post id may not be empty'; + + const { status } = await $xhr.delete(`/comments/posts/${post}`, { csrf: true }); + if(status === 401) + throw 'you must be logged in to do that'; + if(status === 403) + throw 'you are not allowed to delete that post'; + if(status === 404) + throw 'that post does not exist'; + if(status !== 204) + throw 'something went wrong'; }, restorePost: async post => { if(typeof post !== 'string') @@ -98,6 +136,8 @@ const MszCommentsApi = (() => { const { status } = await $xhr.post(`/comments/posts/${post}/restore`, { csrf: true }); if(status === 400) throw 'that post is not deleted'; + if(status === 401) + throw 'you must be logged in to do that'; if(status === 403) throw 'you are not allowed to restore posts'; if(status === 404) @@ -105,6 +145,24 @@ const MszCommentsApi = (() => { if(status !== 200) throw 'something went wrong'; }, + nukePost: async post => { + if(typeof post !== 'string') + throw 'post id must be a string'; + if(post.trim() === '') + throw 'post id may not be empty'; + + const { status } = await $xhr.post(`/comments/posts/${post}/nuke`, { csrf: true }); + if(status === 400) + throw 'that post is not deleted'; + if(status === 401) + throw 'you must be logged in to do that'; + if(status === 403) + throw 'you are not allowed to nuke posts'; + if(status === 404) + throw 'that post does not exist'; + if(status !== 200) + throw 'something went wrong'; + }, createVote: async (post, vote) => { if(typeof post !== 'string') throw 'post id must be a string'; @@ -122,6 +180,8 @@ const MszCommentsApi = (() => { ); if(status === 400) throw 'your vote is not acceptable'; + if(status === 401) + throw 'you must be logged in to do that'; if(status === 403) throw 'you are not allowed to like or dislike comments'; if(status === 404) @@ -141,6 +201,8 @@ const MszCommentsApi = (() => { `/comments/posts/${post}/vote`, { csrf: true, type: 'json' } ); + if(status === 401) + throw 'you must be logged in to do that'; if(status === 403) throw 'you are not allowed to like or dislike comments'; if(status === 404) diff --git a/assets/misuzu.js/comments/listing.jsx b/assets/misuzu.js/comments/listing.jsx index 300af049..5919d2f3 100644 --- a/assets/misuzu.js/comments/listing.jsx +++ b/assets/misuzu.js/comments/listing.jsx @@ -1,15 +1,15 @@ #include comments/api.js #include comments/form.jsx -const MszCommentsEntry = function(userInfo, postInfo, root) { +const MszCommentsEntry = function(userInfo, postInfo, listing, root) { userInfo ??= {}; const actions = <div class="comments-entry-actions" />; - const likeAction = <button class="comments-entry-action comments-entry-action-vote-like" disabled={!userInfo.can_vote}> + const likeAction = <button class="comments-entry-action comments-entry-action-vote-like" disabled={!userInfo.can_vote} title="Like"> <i class="fas fa-chevron-up" /> </button>; - const dislikeAction = <button class="comments-entry-action comments-entry-action-vote-dislike" disabled={!userInfo.can_vote}> + const dislikeAction = <button class="comments-entry-action comments-entry-action-vote-dislike" disabled={!userInfo.can_vote} title="Dislike"> <i class="fas fa-chevron-down" /> </button>; const voteActions = <div class="comments-entry-actions-group comments-entry-actions-group-votes"> @@ -42,6 +42,9 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { updateVotes(postInfo); const castVote = async vote => { + if(postInfo.deleted) + return; + voteActions.classList.add('comments-entry-actions-group-disabled'); likeAction.disabled = dislikeAction.disabled = true; try { @@ -56,29 +59,27 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { } }; - if(postInfo.deleted && userInfo.can_vote) { - likeAction.onclick = () => { castVote(likeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : 1); }; - dislikeAction.onclick = () => { castVote(dislikeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : -1); }; - } + likeAction.onclick = () => { castVote(likeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : 1); }; + dislikeAction.onclick = () => { castVote(dislikeAction.classList.contains('comments-entry-action-vote-cast') ? 0 : -1); }; const repliesIsArray = Array.isArray(postInfo.replies); - const listing = new MszCommentsListing({ hidden: !repliesIsArray }); + const replies = new MszCommentsListing({ hidden: !repliesIsArray }); if(repliesIsArray) - listing.addPosts(userInfo, postInfo.replies); + replies.addPosts(userInfo, postInfo.replies); let form = null; const repliesElem = <div class="comments-entry-replies"> - {listing} + {replies} </div>; - const replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0); + let replyCount = repliesIsArray ? postInfo.replies.length : (postInfo.replies ?? 0); const replyActionsGroup = <div class="comments-entry-actions-group comments-entry-actions-group-replies" />; actions.appendChild(replyActionsGroup); const replyToggleOpenElem = <span class="hidden"><i class="fas fa-minus" /></span>; const replyToggleClosedElem = <span class="hidden"><i class="fas fa-plus" /></span>; const replyCountElem = <span />; - const replyToggleElem = <button class="comments-entry-action"> + const replyToggleElem = <button class="comments-entry-action" title="Replies"> {replyToggleOpenElem} {replyToggleClosedElem} {replyCountElem} @@ -89,10 +90,10 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { replyToggleOpenElem.classList.toggle('hidden', !visible); replyToggleClosedElem.classList.toggle('hidden', visible); }; - setReplyToggleState(listing.visible); + setReplyToggleState(replies.visible); const setReplyCount = count => { - count ??= 0; + replyCount = count ??= 0; if(count > 0) { replyCountElem.textContent = count.toLocaleString(); replyToggleElem.classList.remove('hidden'); @@ -104,13 +105,13 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { } }; - let replyLoaded = listing.loaded; + let replyLoaded = replies.loaded; replyToggleElem.onclick = async () => { - setReplyToggleState(listing.visible = !listing.visible); + setReplyToggleState(replies.visible = !replies.visible); if(!replyLoaded) { replyLoaded = true; try { - listing.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id)); + replies.addPosts(userInfo, await MszCommentsApi.getPostReplies(postInfo.id)); } catch(ex) { console.error(ex); setReplyToggleState(false); @@ -124,7 +125,7 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { }; if(userInfo.can_create) { - const replyElem = <button class="comments-entry-action"> + const replyElem = <button class="comments-entry-action" title="Reply"> <span><i class="fas fa-reply" /></span> <span>Reply</span> </button>; @@ -134,7 +135,7 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { if(form === null) { replyElem.classList.add('comments-entry-action-reply-active'); form = new MszCommentsForm(userInfo); - $insertBefore(listing.element, form.element); + $insertBefore(replies.element, form.element); } else { replyElem.classList.remove('comments-entry-action-reply-active'); repliesElem.removeChild(form.element); @@ -146,63 +147,95 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { // this has to be called no earlier cus if there's less than 2 elements in the group it gets hidden on 0 setReplyCount(replyCount); - if(postInfo.can_delete || userInfo.can_pin) { - const misc = <div class="comments-entry-actions-group" />; - if(postInfo.can_delete) { - if(postInfo.deleted) { - misc.appendChild(<button class="comments-entry-action"> - <i class="fas fa-trash-restore" /> - </button>); - } else { - misc.appendChild(<button class="comments-entry-action"> - <i class="fas fa-trash" /> - </button>); - } + const deleteButton = postInfo.can_delete + ? <button class="comments-entry-action" title="Delete"><i class="fas fa-trash" /></button> + : null; + const restoreButton = postInfo.can_delete_any + ? <button class="comments-entry-action" title="Restore"><i class="fas fa-trash-restore" /></button> + : null; + const nukeButton = postInfo.can_delete_any + ? <button class="comments-entry-action" title="Permanently delete"><i class="fas fa-radiation-alt" /></button> + : null; + const pinButton = root && userInfo.can_pin + ? <button class="comments-entry-action" title="Pin"><i class="fas fa-thumbtack" /></button> + : null; + const unpinButton = root && userInfo.can_pin + ? <button class="comments-entry-action" title="Unpin"> + {/*<i class="fas fa-crow" /> + <i class="fas fa-bars" />*/} + <img src="https://mikoto.misaka.nl/u/1I0KnhRO/crowbar.png" width="11" height="12" alt="crowbar" /> + </button> : null; + + const miscActions = <div class="comments-entry-actions-group hidden"> + {deleteButton} + {restoreButton} + {nukeButton} + {pinButton} + {unpinButton} + </div>; + actions.appendChild(miscActions); + + const setMiscVisible = (deleted=null, pinned=null) => { + if(deleted !== null) { + if(deleteButton) + deleteButton.classList.toggle('hidden', deleted); + if(restoreButton) + restoreButton.classList.toggle('hidden', !deleted); + if(nukeButton) + nukeButton.classList.toggle('hidden', !deleted); + } + if(pinned !== null) { + if(pinButton) + pinButton.classList.toggle('hidden', pinned); + if(unpinButton) + unpinButton.classList.toggle('hidden', !pinned); } - if(userInfo.can_pin) - misc.appendChild(<button class="comments-entry-action" disabled={!userInfo.can_pin}> - <i class="fas fa-thumbtack" /> - </button>); - actions.appendChild(misc); - } - const created = typeof postInfo.created === 'string' ? new Date(postInfo.created) : null; - const edited = typeof postInfo.edited === 'string' ? new Date(postInfo.edited) : null; - const deleted = typeof postInfo.deleted === 'string' ? new Date(postInfo.deleted) : null; - const pinned = typeof postInfo.pinned === 'string' ? new Date(postInfo.pinned) : null; + miscActions.classList.toggle('hidden', miscActions.querySelectorAll('.hidden').length === miscActions.childElementCount); + }; + const setMiscDisabled = state => { + miscActions.classList.toggle('comments-entry-actions-group-disabled', state); + for(const elem of miscActions.querySelectorAll('button')) + elem.disabled = state; + }; - const element = <div id={`comment-${postInfo.id}`} data-comment={postInfo.id} class={{ 'comments-entry': true, 'comments-entry-root': root, 'comments-entry-deleted': postInfo.deleted }} style={{ '--user-colour': postInfo.user?.colour }}> + setMiscVisible(!!postInfo.deleted, !!postInfo.pinned); + + const userAvatarElem = <img alt="" width="40" height="40" class="avatar" />; + const userNameElem = <div class="comments-entry-user" />; + + const createdTimeElem = <a href={`#comment-${postInfo.id}`} class="comments-entry-time-link" />; + const editedElem = <div class="comments-entry-time comments-entry-time-edited"> + <div class="comments-entry-time-icon"><i class="fas fa-pencil-alt" /></div> + </div>; + const pinnedElem = <div class="comments-entry-time comments-entry-time-pinned"> + <div class="comments-entry-time-icon"><i class="fas fa-thumbtack" /></div> + </div>; + const deletedElem = <div class="comments-entry-time comments-entry-time-deleted"> + <div class="comments-entry-time-icon"><i class="fas fa-trash" /></div> + </div>; + + const bodyElem = <div class="comments-entry-body" />; + const setBody = body => { bodyElem.textContent = body ?? '[deleted]'; }; + setBody(postInfo?.body); + + const element = <div id={`comment-${postInfo.id}`} data-comment={postInfo.id} class={{ 'comments-entry': true, 'comments-entry-root': root }}> <div class="comments-entry-main"> <div class="comments-entry-avatar"> - <img src={postInfo.user?.avatar ?? '/images/no-avatar.png'} alt="" width="40" height="40" class="avatar" /> + {userAvatarElem} </div> <div class="comments-entry-wrap"> <div class="comments-entry-meta"> <div class="comments-entry-user"> - {postInfo.user - ? <a class="comments-entry-user-link" href={postInfo.user.profile} style="color: var(--user-colour);">{postInfo.user.name}</a> - : <span class="comments-entry-user-dead">Deleted user</span>} + {userNameElem} </div> <div class="comments-entry-time"> <div class="comments-entry-time-icon">—</div> - <a href={`#comment-${postInfo.id}`} class="comments-entry-time-link"> - {created !== null - ? <time class="comments-entry-time-text" datetime={created.toISOString()} title={created.toString()}>{MszSakuya.formatTimeAgo(created)}</time> - : 'deleted'} - </a> + {createdTimeElem} </div> - {edited !== null ? <div class="comments-entry-time comments-entry-time-edited"> - <div class="comments-entry-time-icon"><i class="fas fa-pencil-alt" /></div> - <time class="comments-entry-time-text" datetime={edited.toISOString()} title={edited.toString()}>{MszSakuya.formatTimeAgo(edited)}</time> - </div> : null} - {pinned !== null ? <div class="comments-entry-time comments-entry-time-pinned"> - <div class="comments-entry-time-icon"><i class="fas fa-thumbtack" /></div> - <time class="comments-entry-time-text" datetime={pinned.toISOString()} title={pinned.toString()}>{MszSakuya.formatTimeAgo(pinned)}</time> - </div> : null} - {deleted !== null ? <div class="comments-entry-time comments-entry-time-deleted"> - <div class="comments-entry-time-icon"><i class="fas fa-trash" /></div> - <time class="comments-entry-time-text" datetime={deleted.toISOString()} title={deleted.toString()}>{MszSakuya.formatTimeAgo(deleted)}</time> - </div> : null} + {editedElem} + {pinnedElem} + {deletedElem} </div> <div class="comments-entry-body">{postInfo?.body ?? '[deleted]'}</div> {actions.childElementCount > 0 ? actions : null} @@ -211,7 +244,168 @@ const MszCommentsEntry = function(userInfo, postInfo, root) { {repliesElem} </div>; - MszSakuya.trackElements(element.querySelectorAll('time')); + const setUserInfo = userInfo => { + $removeChildren(userNameElem); + if(userInfo) { + if(typeof userInfo.colour === 'string') + element.style.setProperty('--user-colour', userInfo.colour); + userAvatarElem.src = userInfo.avatar; + userNameElem.appendChild(<a class="comments-entry-user-link" href={userInfo.profile} style="color: var(--user-colour);">{userInfo.name}</a>); + } else { + element.style.removeProperty('--user-colour'); + userAvatarElem.src = '/images/no-avatar.png'; + userNameElem.appendChild(<span class="comments-entry-user-dead">Deleted user</span>); + } + }; + setUserInfo(postInfo.user); + + const setCreatedTime = date => { + if(typeof date === 'string') + date = new Date(date); + + $removeChildren(createdTimeElem); + element.dataset.commentCreated = date.getTime(); + const time = <time class="comments-entry-time-text" datetime={date.toISOString()} title={date.toString()}>{MszSakuya.formatTimeAgo(date)}</time>; + createdTimeElem.appendChild(time); + MszSakuya.trackElement(time); + }; + setCreatedTime(postInfo.created); + + const updateOrderValue = () => { + let order = parseInt(element.dataset.commentCreated ?? 0); + + if(element.dataset.commentDeleted !== undefined) + order -= parseInt(element.dataset.commentDeleted); + else if(element.dataset.commentPinned !== undefined) + order += parseInt(element.dataset.commentPinned); + + element.dataset.commentOrder = order; + }; + + const setOptionalTime = (elem, date, name, reorder=true, textIfTrue=null) => { + if(typeof date === 'string') + date = new Date(date); + + while(!(elem.lastChild instanceof HTMLDivElement)) + elem.removeChild(elem.lastChild); + + if(date) { + if(date instanceof Date) { + if(name) + element.dataset[name] = date.getTime(); + + const timeElem = <time class="comments-entry-time-text" datetime={date.toISOString()} title={date.toString()}>{MszSakuya.formatTimeAgo(date)}</time> + elem.appendChild(timeElem); + MszSakuya.trackElement(timeElem); + } else { + // this is kiiiind of a hack but commentCreated isn't updated through this function so who cares lol ! + if(name) + element.dataset[name] = element.dataset.commentCreated; + + if(typeof textIfTrue === 'string') + elem.appendChild(<span>{textIfTrue}</span>); + } + + elem.classList.remove('hidden'); + } else { + if(name) + delete element.dataset[name]; + elem.classList.add('hidden'); + } + + if(reorder) + updateOrderValue(); + }; + + setOptionalTime(editedElem, postInfo.edited, 'commentEdited', false); + setOptionalTime(pinnedElem, postInfo.pinned, 'commentPinned', false); + setOptionalTime(deletedElem, postInfo.deleted, 'commentDeleted', false, 'deleted'); + updateOrderValue(); + + const nukeThePost = () => { + if(replies.count < 1 && replyCount < 1) + listing.element.removeChild(element); + else { + miscActions.classList.add('hidden'); + setMiscDisabled(true); + setUserInfo(null); + setBody(null); + } + }; + + if(deleteButton) + deleteButton.onclick = async () => { + setMiscDisabled(true); + try { + await MszCommentsApi.deletePost(postInfo.id); + if(restoreButton) { + setOptionalTime(deletedElem, new Date, 'commentDeleted'); + listing.reorder(); + deleteButton.classList.add('hidden'); + restoreButton.classList.remove('hidden'); + nukeButton.classList.remove('hidden'); + } else + nukeThePost(); + } catch(ex) { + console.error(ex); + } finally { + setMiscDisabled(false); + } + }; + if(restoreButton) + restoreButton.onclick = async () => { + setMiscDisabled(true); + try { + await MszCommentsApi.restorePost(postInfo.id); + setMiscVisible(false, null); + setOptionalTime(deletedElem, null, 'commentDeleted'); + listing.reorder(); + } catch(ex) { + console.error(ex); + } finally { + setMiscDisabled(false); + } + }; + if(nukeButton) + nukeButton.onclick = async () => { + setMiscDisabled(true); + try { + await MszCommentsApi.nukePost(postInfo.id); + nukeThePost(); + } catch(ex) { + console.error(ex); + } finally { + setMiscDisabled(false); + } + }; + if(pinButton) + pinButton.onclick = async () => { + setMiscDisabled(true); + try { + const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '1' }); + setMiscVisible(null, !!result.pinned); + setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); + listing.reorder(); + } catch(ex) { + console.error(ex); + } finally { + setMiscDisabled(false); + } + }; + if(unpinButton) + unpinButton.onclick = async () => { + setMiscDisabled(true); + try { + const result = await MszCommentsApi.updatePost(postInfo.id, { pin: '0' }); + setMiscVisible(null, !!result.pinned); + setOptionalTime(pinnedElem, result.pinned, 'commentPinned'); + listing.reorder(); + } catch(ex) { + console.error(ex); + } finally { + setMiscDisabled(false); + } + }; return { get element() { @@ -229,32 +423,40 @@ const MszCommentsListing = function(options) { {loading} </div>; - const addPost = function(userInfo, postInfo, parentId=null) { - const entry = new MszCommentsEntry(userInfo ?? {}, postInfo, root); - entries.set(postInfo.id, entry); - element.appendChild(entry.element); - }; - - return { + const listing = { get element() { return element; }, + get count() { return element.childElementCount; }, get visible() { return !element.classList.contains('hidden'); }, set visible(state) { element.classList.toggle('hidden', !state); }, get loaded() { return loading === null; }, - addPost: addPost, - addPosts: function(userInfo, posts) { + reorder: () => { + // this feels yucky but it works + const items = Array.from(element.children).sort((a, b) => parseInt(b.dataset.commentOrder - a.dataset.commentOrder)); + for(const item of items) + element.appendChild(item); + }, + + addPost: (userInfo, postInfo, parentId=null) => { + const entry = new MszCommentsEntry(userInfo ?? {}, postInfo, listing, root); + entries.set(postInfo.id, entry); + element.appendChild(entry.element); + }, + addPosts: (userInfo, posts) => { try { if(!Array.isArray(posts)) throw 'posts must be an array'; userInfo ??= {}; for(const postInfo of posts) - addPost(userInfo, postInfo); + listing.addPost(userInfo, postInfo); } finally { loading.element.remove(); loading = null; } }, }; + + return listing; }; diff --git a/src/Comments/CommentsPostsData.php b/src/Comments/CommentsPostsData.php index 2c4e60e2..dfd1edae 100644 --- a/src/Comments/CommentsPostsData.php +++ b/src/Comments/CommentsPostsData.php @@ -235,46 +235,38 @@ class CommentsPostsData { $stmt->execute(); } - public function editPost(CommentsPostInfo|string $infoOrId, string $body): void { + public function updatePost( + CommentsPostInfo|string $infoOrId, + ?string $body, + ?bool $pinned, + bool $edited = false + ): void { if($infoOrId instanceof CommentsPostInfo) $infoOrId = $infoOrId->id; - if(empty(trim($body))) - throw new InvalidArgumentException('$body may not be empty.'); + $fields = []; + $values = []; - $stmt = $this->cache->get(<<<SQL - UPDATE msz_comments_posts - SET comment_text = ?, - comment_edited = NOW() - WHERE comment_id = ? - SQL); - $stmt->nextParameter($body); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } + if($body !== null) { + if(trim($body) === '') + throw new InvalidArgumentException('$body must be null or a non-empty string.'); - public function pinPost(CommentsPostInfo|string $infoOrId): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; + $fields[] = 'comment_text = ?'; + $values[] = $body; + } - $stmt = $this->cache->get(<<<SQL - UPDATE msz_comments_posts - SET comment_pinned = COALESCE(comment_pinned, NOW()) - WHERE comment_id = ? - SQL); - $stmt->nextParameter($infoOrId); - $stmt->execute(); - } + if($pinned !== null) + $fields[] = $pinned ? 'comment_pinned = COALESCE(comment_pinned, NOW())' : 'comment_pinned = NULL'; - public function unpinPost(CommentsPostInfo|string $infoOrId): void { - if($infoOrId instanceof CommentsPostInfo) - $infoOrId = $infoOrId->id; + if($edited) + $fields[] = 'comment_edited = NOW()'; - $stmt = $this->cache->get(<<<SQL - UPDATE msz_comments_posts - SET comment_pinned = NULL - WHERE comment_id = ? - SQL); + if(empty($fields)) + return; + + $stmt = $this->cache->get(sprintf('UPDATE msz_comments_posts SET %s WHERE comment_id = ?', implode(', ', $fields))); + foreach($values as $value) + $stmt->nextParameter($value); $stmt->nextParameter($infoOrId); $stmt->execute(); } diff --git a/src/Comments/CommentsRoutes.php b/src/Comments/CommentsRoutes.php index 753390f3..5b6a805e 100644 --- a/src/Comments/CommentsRoutes.php +++ b/src/Comments/CommentsRoutes.php @@ -42,6 +42,28 @@ class CommentsRoutes implements RouteHandler, UrlSource { return $user; } + private function convertPosts( + IPermissionResult $perms, + iterable $postInfos, + bool $loadReplies = false + ): array { + $posts = []; + + foreach($postInfos as $postInfo) { + $post = $this->convertPost( + $perms, + $postInfo, + $loadReplies ? $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) : null + ); + if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies'])) + continue; + + $posts[] = $post; + } + + return $posts; + } + private function convertPost( IPermissionResult $perms, CommentsPostInfo $postInfo, @@ -50,10 +72,12 @@ class CommentsRoutes implements RouteHandler, UrlSource { $canViewDeleted = $perms->check(Perm::G_COMMENTS_DELETE_ANY); $isDeleted = $postInfo->deleted && !$canViewDeleted; - $post = ['id' => $postInfo->id]; + $post = [ + 'id' => $postInfo->id, + 'created' => $postInfo->createdAt->toIso8601ZuluString(), + ]; if(!$isDeleted) { $post['body'] = $postInfo->body; - $post['created'] = $postInfo->createdAt->toIso8601ZuluString(); if($postInfo->pinned) $post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString(); if($postInfo->edited) @@ -89,7 +113,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { if($perms->check(Perm::G_COMMENTS_EDIT_ANY)) $post['can_edit'] = true; if($perms->check(Perm::G_COMMENTS_DELETE_ANY)) - $post['can_delete'] = true; + $post['can_delete'] = $post['can_delete_any'] = true; } if($replyInfos === null) { @@ -97,9 +121,7 @@ class CommentsRoutes implements RouteHandler, UrlSource { if($replies > 0) $post['replies'] = $replies; } else { - $replies = []; - foreach($replyInfos as $replyInfo) - $replies[] = $this->convertPost($perms, $replyInfo); + $replies = $this->convertPosts($perms, $replyInfos); if(!empty($replies)) $post['replies'] = $replies; } @@ -111,12 +133,8 @@ class CommentsRoutes implements RouteHandler, UrlSource { #[HttpMiddleware('/comments')] public function checkCsrf(HttpResponseBuilder $response, HttpRequest $request) { if(in_array($request->method, ['DELETE', 'PATCH', 'POST'])) { - if($request->method !== 'DELETE' && !($request->content instanceof FormHttpContent)) - return 400; - if(!$this->authInfo->loggedIn) return 401; - if(!CSRF::validate($request->getHeaderLine('x-csrf-token'))) return 403; } @@ -166,17 +184,14 @@ class CommentsRoutes implements RouteHandler, UrlSource { $result['user'] = $user; } - $posts = []; try { - $postInfos = $this->commentsCtx->posts->getPosts( + $posts = $this->convertPosts($perms, $this->commentsCtx->posts->getPosts( categoryInfo: $categoryInfo, replies: false, - ); - foreach($postInfos as $postInfo) { - $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo); - $posts[] = $this->convertPost($perms, $postInfo, $replyInfos); - } - } catch(RuntimeException $ex) {} + ), true); + } catch(RuntimeException $ex) { + $posts = []; + } $result['posts'] = $posts; @@ -208,9 +223,11 @@ class CommentsRoutes implements RouteHandler, UrlSource { } $perms = $this->getGlobalPerms(); - $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo); + $post = $this->convertPost($perms, $postInfo, $this->commentsCtx->posts->getPosts(parentInfo: $postInfo)); + if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies'])) + return 404; - return $this->convertPost($perms, $postInfo, $replyInfos); + return $post; } #[HttpGet('/comments/posts/([0-9]+)/replies')] @@ -221,26 +238,73 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 404; } - $perms = $this->getGlobalPerms(); - $replyInfos = $this->commentsCtx->posts->getPosts(parentInfo: $postInfo); - - $replies = []; - foreach($replyInfos as $replyInfo) - $replies[] = $this->convertPost($perms, $replyInfo); - - return $replies; + return $this->convertPosts( + $this->getGlobalPerms(), + $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) + ); } - #[HttpPatch('/comments/posts/([0-9]+)')] + // this should be HttpPatch but PHP doesn't parse into $_POST for PATCH... + // fix this in the v3 router for index by just ignoring PHP's parsing altogether + #[HttpPost('/comments/posts/([0-9]+)')] public function patchPost(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { - $perms = $this->getGlobalPerms(); - $canEditAny = $perms->check(Perm::G_COMMENTS_EDIT_ANY); - $canEditOwn = $perms->check(Perm::G_COMMENTS_EDIT_OWN); - $canPin = $perms->check(Perm::G_COMMENTS_PIN); - if(!$canEditAny && !$canEditOwn && !$canPin) - return 403; + if(!($request->content instanceof FormHttpContent)) + return 400; - return 501; + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + + $perms = $this->getGlobalPerms(); + + if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && $postInfo->deleted) + return 404; + + $body = null; + $pinned = null; + $edited = false; + + if($request->content->hasParam('pin')) { + if(!$perms->check(Perm::G_COMMENTS_PIN)) + return 403; + + $pinned = !empty($request->content->getParam('pin')); + } + + if($request->content->hasParam('body')) { + if(!$perms->check(Perm::G_COMMENTS_EDIT_ANY) && !($perms->check(Perm::G_COMMENTS_EDIT_OWN) && $this->authInfo->userId === $postInfo->userId)) + return 403; + + $body = (string)$request->content->getParam('body'); + $edited = $body !== $postInfo->body; + if(!$edited) + $body = null; + } + + $this->commentsCtx->posts->updatePost( + $postInfo, + body: $body, + pinned: $pinned, + edited: $edited, + ); + + try { + $postInfo = $this->commentsCtx->posts->getPost($postInfo->id); + } catch(RuntimeException $ex) { + return 410; + } + + $result = ['id' => $postInfo->id]; + if($body !== null) + $result['body'] = $postInfo->body; + if($pinned !== null) + $result['pinned'] = $postInfo->pinned ? $postInfo->pinnedAt->toIso8601ZuluString() : false; + if($edited) + $result['edited'] = $postInfo->editedAt->toIso8601ZuluString(); + + return $result; } #[HttpDelete('/comments/posts/([0-9]+)')] @@ -251,12 +315,17 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 404; } + if($postInfo->deleted) + return 404; + $perms = $this->getGlobalPerms(); - $canDeleteAny = $perms->check(Perm::G_COMMENTS_DELETE_ANY); - if(!$canDeleteAny && !$perms->check(Perm::G_COMMENTS_DELETE_OWN)) + if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) + && !($postInfo->userId === $this->authInfo->userId && $perms->check(Perm::G_COMMENTS_DELETE_OWN))) return 403; - return 501; + $this->commentsCtx->posts->deletePost($postInfo); + + return 204; } #[HttpPost('/comments/posts/([0-9]+)/restore')] @@ -278,8 +347,30 @@ class CommentsRoutes implements RouteHandler, UrlSource { return 200; } + #[HttpPost('/comments/posts/([0-9]+)/nuke')] + public function postPostNuke(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int { + if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY)) + return 403; + + try { + $postInfo = $this->commentsCtx->posts->getPost($commentId); + } catch(RuntimeException $ex) { + return 404; + } + + if(!$postInfo->deleted) + return 400; + + $this->commentsCtx->posts->nukePost($postInfo); + + return 200; + } + #[HttpPost('/comments/posts/([0-9]+)/vote')] public function postPostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): int|array { + if(!($request->content instanceof FormHttpContent)) + return 400; + $vote = (int)$request->content->getParam('vote', FILTER_SANITIZE_NUMBER_INT); if($vote === 0) return 400; @@ -302,7 +393,6 @@ class CommentsRoutes implements RouteHandler, UrlSource { $voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo); $votes = $this->commentsCtx->votes->getVotesAggregate($postInfo); - $response->statusCode = 200; return [ 'vote' => $voteInfo->weight, 'positive' => $votes->positive,