misuzu/src/Comments/CommentsRoutes.php
2025-03-31 15:35:24 +00:00

688 lines
26 KiB
PHP

<?php
namespace Misuzu\Comments;
use RuntimeException;
use Index\XArray;
use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
use Index\Http\Content\FormContent;
use Index\Http\Routing\{RouteHandler,RouteHandlerCommon};
use Index\Http\Routing\Processors\Before;
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
use Misuzu\Perm;
use Misuzu\Auth\AuthInfo;
use Misuzu\Perms\{PermissionResult,IPermissionResult};
use Misuzu\Users\{UserInfo,UsersContext,UsersData};
class CommentsRoutes implements RouteHandler, UrlSource {
use RouteHandlerCommon, UrlSourceCommon;
public function __construct(
private CommentsContext $commentsCtx,
private UsersContext $usersCtx,
private UrlRegistry $urls,
private AuthInfo $authInfo,
) {}
private function getGlobalPerms(): IPermissionResult {
return $this->authInfo->loggedIn && !$this->usersCtx->hasActiveBan($this->authInfo->userInfo)
? $this->authInfo->getPerms('global')
: new PermissionResult(0);
}
/**
* @return array{
* id: string,
* name: string,
* profile: string,
* avatar: string,
* colour?: string,
* }
*/
private function convertUser(UserInfo $userInfo, int $avatarRes = 80): array {
$user = [
'id' => $userInfo->id,
'name' => $userInfo->name,
'profile' => $this->urls->format('user-profile', ['user' => $userInfo->id]),
'avatar' => $this->urls->format('user-avatar', ['user' => $userInfo->id, 'res' => $avatarRes]),
];
$userColour = $this->usersCtx->getUserColour($userInfo);
if(!$userColour->inherits)
$user['colour'] = (string)$userColour;
return $user;
}
/**
* @param iterable<CommentsPostInfo> $postInfos
* @return mixed[]
*/
private function convertPosts(
IPermissionResult $perms,
CommentsCategoryInfo $catInfo,
iterable $postInfos,
bool $loadReplies = false
): array {
$posts = [];
foreach($postInfos as $postInfo) {
$post = $this->convertPost(
$perms,
$catInfo,
$postInfo,
$loadReplies ? $this->commentsCtx->posts->getPosts(parentInfo: $postInfo) : null
);
if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies']))
continue;
$posts[] = $post;
}
return $posts;
}
/**
* @param ?iterable<CommentsPostInfo> $replyInfos
* @return array{
* id: string,
* body?: string,
* created: string,
* pinned?: string,
* edited?: string,
* deleted?: string|true,
* user?: array{
* id: string,
* name: string,
* profile: string,
* avatar: string,
* colour?: string,
* },
* positive?: int,
* negative?: int,
* vote?: int,
* can_edit?: true,
* can_delete?: true,
* can_delete_any?: true,
* replies?: int|mixed[],
* }
*/
private function convertPost(
IPermissionResult $perms,
CommentsCategoryInfo $catInfo,
CommentsPostInfo $postInfo,
?iterable $replyInfos = null
): array {
$canViewDeleted = $perms->check(Perm::G_COMMENTS_DELETE_ANY);
$isDeleted = $postInfo->deleted && !$canViewDeleted;
$post = [
'id' => $postInfo->id,
'created' => $postInfo->createdAt->toIso8601ZuluString(),
];
if(!$isDeleted) {
$post['body'] = $postInfo->body;
if($postInfo->pinned)
$post['pinned'] = $postInfo->pinnedAt->toIso8601ZuluString();
if($postInfo->edited)
$post['edited'] = $postInfo->editedAt->toIso8601ZuluString();
if($postInfo->userId !== null)
try {
$post['user'] = $this->convertUser(
$this->usersCtx->getUserInfo($postInfo->userId)
);
} catch(RuntimeException $ex) {}
$votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
$post['positive'] = $votes->positive;
$post['negative'] = $votes->negative;
if($this->authInfo->loggedIn) {
$voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
if($voteInfo->weight !== 0)
$post['vote'] = $voteInfo->weight;
$isAuthor = $this->authInfo->userId === $postInfo->userId;
if($isAuthor && $perms->check(Perm::G_COMMENTS_EDIT_OWN))
$post['can_edit'] = true;
if(($isAuthor || $catInfo->ownerId === $this->authInfo->userId) && $perms->check(Perm::G_COMMENTS_DELETE_OWN))
$post['can_delete'] = true;
}
}
if($postInfo->deleted)
$post['deleted'] = $canViewDeleted ? $postInfo->deletedAt->toIso8601ZuluString() : true;
if($this->authInfo->loggedIn) {
if($perms->check(Perm::G_COMMENTS_EDIT_ANY))
$post['can_edit'] = true;
if($perms->check(Perm::G_COMMENTS_DELETE_ANY))
$post['can_delete'] = $post['can_delete_any'] = true;
}
if($replyInfos === null) {
$replies = $this->commentsCtx->posts->countPosts(parentInfo: $postInfo);
if($replies > 0)
$post['replies'] = $replies;
} else {
$replies = $this->convertPosts($perms, $catInfo, $replyInfos);
if(!empty($replies))
$post['replies'] = $replies;
}
return $post;
}
/**
* @param array<string, mixed> $extra
* @return array{error: array{name: string, text: string}}
*/
private static function error(HttpResponseBuilder $response, int $code, string $name, string $text, array $extra = []): array {
$response->statusCode = $code;
return [
'error' => array_merge($extra, [
'name' => $name,
'text' => $text,
]),
];
}
/**
* @return array{
* category: array{
* name: string,
* created: string,
* locked?: string,
* owner?: array{
* id: string,
* name: string,
* profile: string,
* avatar: string,
* colour?: string,
* },
* },
* user?: array{
* id: string,
* name: string,
* profile: string,
* avatar: string,
* colour?: string,
* can_create?: true,
* can_pin?: true,
* can_vote?: true,
* can_lock?: true,
* },
* posts: mixed[]
* }|array{error: array{name: string, text: string}}
*/
#[PatternRoute('GET', '/comments/categories/([A-Za-z0-9-]+)')]
#[Before('authz:cookie')]
public function getCategory(HttpResponseBuilder $response, string $categoryName): array {
try {
$catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName);
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
}
$perms = $this->getGlobalPerms();
$result = [];
$category = [
'name' => $catInfo->name,
'created' => $catInfo->createdAt->toIso8601ZuluString(),
];
if($catInfo->locked)
$category['locked'] = $catInfo->lockedAt->toIso8601ZuluString();
if($catInfo->ownerId !== null)
try {
$category['owner'] = $this->convertUser(
$this->usersCtx->getUserInfo($catInfo->ownerId)
);
} catch(RuntimeException $ex) {}
$result['category'] = $category;
if($this->authInfo->loggedIn) {
$user = $this->convertUser($this->authInfo->userInfo, 100);
if($perms->check(Perm::G_COMMENTS_CREATE))
$user['can_create'] = true;
if($perms->check(Perm::G_COMMENTS_PIN) || $catInfo->ownerId === $this->authInfo->userId)
$user['can_pin'] = true;
if($perms->check(Perm::G_COMMENTS_VOTE))
$user['can_vote'] = true;
if($perms->check(Perm::G_COMMENTS_LOCK))
$user['can_lock'] = true;
$result['user'] = $user;
}
try {
$posts = $this->convertPosts($perms, $catInfo, $this->commentsCtx->posts->getPosts(
categoryInfo: $catInfo,
replies: false,
), true);
} catch(RuntimeException $ex) {
$posts = [];
}
$result['posts'] = $posts;
return $result;
}
/**
* @return array{
* name: string,
* locked?: string|false,
* }|array{error: array{name: string, text: string}}
*/
#[PatternRoute('POST', '/comments/categories/([A-Za-z0-9-]+)')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
#[Before('input:urlencoded')]
public function patchCategory(HttpResponseBuilder $response, FormContent $content, string $categoryName): array {
try {
$catInfo = $this->commentsCtx->categories->getCategory(name: $categoryName);
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
}
$perms = $this->getGlobalPerms();
$locked = null;
if($content->hasParam('lock')) {
if(!$perms->check(Perm::G_COMMENTS_LOCK))
return self::error($response, 403, 'comments:lock-not-allowed', 'You are not allowed to lock this comment section.');
$locked = !empty($content->getParam('lock'));
}
$this->commentsCtx->categories->updateCategory(
$catInfo,
locked: $locked,
);
try {
$catInfo = $this->commentsCtx->categories->getCategory(categoryId: $catInfo->id);
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
}
$result = ['name' => $catInfo->name];
if($locked !== null)
$result['locked'] = $catInfo->locked ? $catInfo->lockedAt->toIso8601ZuluString() : false;
return $result;
}
/**
* @return mixed[]|array{error: array{name: string, text: string}}
*/
#[ExactRoute('POST', '/comments/posts')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
#[Before('input:multipart')]
public function postPost(HttpResponseBuilder $response, FormContent $content): array {
$perms = $this->getGlobalPerms();
if(!$perms->check(Perm::G_COMMENTS_CREATE))
return self::error($response, 403, 'comments:create-not-allowed', 'You are not allowed to post comments.');
if(!$content->hasParam('category') || !$content->hasParam('body'))
return self::error($response, 400, 'comments:missing-fields', 'Required fields are not specified.');
$pinned = false;
$body = preg_replace("/[\r\n]{2,}/", "\n", (string)$content->getParam('body'));
if(mb_strlen(mb_trim($body)) < 1)
return self::error($response, 400, 'comments:body-too-short', 'Your comment must be longer.');
if(mb_strlen($body) > 5000)
return self::error($response, 400, 'comments:body-too-long', 'Your comment is too long.');
try {
$catInfo = $this->commentsCtx->categories->getCategory(name: (string)$content->getParam('category'));
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:category-not-found', 'No comment section with that name exists.');
}
if($content->hasParam('reply_to')) {
try {
$replyToInfo = $this->commentsCtx->posts->getPost((string)$content->getParam('reply_to'));
if($replyToInfo->deleted)
return self::error($response, 404, 'comments:parent-not-found', 'The comment you are trying to reply to does not exist.');
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:parent-not-found', 'The comment you are trying to reply to does not exist.');
}
} else
$replyToInfo = null;
if($content->hasParam('pin')) {
if(!$perms->check(Perm::G_COMMENTS_PIN) && $catInfo->ownerId !== $this->authInfo->userId)
return self::error($response, 403, 'comments:pin-not-allowed', 'You are not allowed to pin comments.');
if($replyToInfo !== null)
return self::error($response, 400, 'comments:post-not-root', 'Replies cannot be pinned.');
$pinned = !empty($content->getParam('pin'));
}
try {
$postInfo = $this->commentsCtx->posts->createPost(
$catInfo,
$replyToInfo,
$this->authInfo->userInfo,
$body,
$pinned
);
} catch(RuntimeException $ex) {
return self::error($response, 500, 'comments:create-failed', 'Failed to create your comment. Please report this as a bug if it persists.');
}
$response->statusCode = 201;
return $this->convertPost($perms, $catInfo, $postInfo);
}
/**
* @return mixed[]|array{error: array{name: string, text: string}}
*/
#[PatternRoute('GET', '/comments/posts/([0-9]+)')]
#[Before('authz:cookie')]
public function getPost(HttpResponseBuilder $response, string $commentId): array {
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$perms = $this->getGlobalPerms();
$post = $this->convertPost(
$perms,
$catInfo,
$postInfo,
$this->commentsCtx->posts->getPosts(parentInfo: $postInfo)
);
if(isset($post['deleted']) && $post['deleted'] === true && empty($post['replies']))
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
return $post;
}
/**
* @return mixed[]|array{error: array{name: string, text: string}}
*/
#[PatternRoute('GET', '/comments/posts/([0-9]+)/replies')]
#[Before('authz:cookie')]
public function getPostReplies(HttpResponseBuilder $response, string $commentId): array {
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
return $this->convertPosts(
$this->getGlobalPerms(),
$catInfo,
$this->commentsCtx->posts->getPosts(parentInfo: $postInfo)
);
}
/**
* @return array{
* id: string,
* body?: string,
* pinned?: string|false,
* edited?: string,
* }|array{error: array{name: string, text: string}}
*/
#[PatternRoute('PATCH', '/comments/posts/([0-9]+)')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
#[Before('input:multipart')]
public function patchPost(HttpResponseBuilder $response, FormContent $content, string $commentId): array {
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$perms = $this->getGlobalPerms();
if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && ($catInfo->locked || $postInfo->deleted))
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
$body = null;
$pinned = null;
$edited = false;
if($content->hasParam('pin')) {
if(!$perms->check(Perm::G_COMMENTS_PIN) && $catInfo->ownerId !== $this->authInfo->userId)
return self::error($response, 403, 'comments:pin-not-allowed', 'You are not allowed to pin comments.');
if($postInfo->reply)
return self::error($response, 400, 'comments:post-not-root', 'Replies cannot be pinned.');
$pinned = !empty($content->getParam('pin'));
}
if($content->hasParam('body')) {
if(!$perms->check(Perm::G_COMMENTS_EDIT_ANY) && !($perms->check(Perm::G_COMMENTS_EDIT_OWN) && $this->authInfo->userId === $postInfo->userId))
return self::error($response, 403, 'comments:edit-not-allowed', 'You are not allowed to edit comments.');
$body = preg_replace("/[\r\n]{2,}/", "\n", (string)$content->getParam('body'));
if(mb_strlen(mb_trim($body)) < 1)
return self::error($response, 400, 'comments:body-too-short', 'Your comment must be longer.');
if(mb_strlen($body) > 5000)
return self::error($response, 400, 'comments:body-too-long', 'Your comment is too long.');
$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 self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$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;
}
/**
* @return string|array{error: array{name: string, text: string}}
*/
#[PatternRoute('DELETE', '/comments/posts/([0-9]+)')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
public function deletePost(HttpResponseBuilder $response, string $commentId): array|string {
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
if($postInfo->deleted)
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
if($catInfo->locked)
return self::error($response, 403, 'comments:category-locked-delete', 'The comment section this comment is in is locked, it cannot be deleted.');
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$perms = $this->getGlobalPerms();
if(!$perms->check(Perm::G_COMMENTS_DELETE_ANY) && !(
($postInfo->userId === $this->authInfo->userId || $catInfo->ownerId === $this->authInfo->userId)
&& $perms->check(Perm::G_COMMENTS_DELETE_OWN)
)) return self::error($response, 403, 'comments:delete-not-allowed', 'You are not allowed to delete this comment.');
$this->commentsCtx->posts->deletePost($postInfo);
$response->statusCode = 204;
return '';
}
/**
* @return mixed[]|array{error: array{name: string, text: string}}
*/
#[PatternRoute('POST', '/comments/posts/([0-9]+)/restore')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
public function postPostRestore(HttpResponseBuilder $response, string $commentId): array {
if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
return self::error($response, 403, 'comments:restore-not-allowed', 'You are not allowed to restore comments.');
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
if(!$postInfo->deleted)
return self::error($response, 400, 'comments:post-not-deleted', 'This comment is not currently deleted.');
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
if($catInfo->locked)
return self::error($response, 403, 'comments:category-locked-restore', 'The comment section this comment is in is locked, it cannot be restored.');
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$this->commentsCtx->posts->restorePost($postInfo);
return [];
}
/**
* @return mixed[]|array{error: array{name: string, text: string}}
*/
#[PatternRoute('POST' ,'/comments/posts/([0-9]+)/nuke')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
public function postPostNuke(HttpResponseBuilder $response, string $commentId): array {
if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_DELETE_ANY))
return self::error($response, 403, 'comments:nuke-not-allowed', 'You are not allowed to permanently delete comments.');
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
if(!$postInfo->deleted)
return self::error($response, 400, 'comments:post-not-deleted', 'This comment is not currently (soft-)deleted.');
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
if($catInfo->locked)
return self::error($response, 403, 'comments:category-locked-nuke', 'The comment section this comment is in is locked, it cannot be permanently deleted.');
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$this->commentsCtx->posts->nukePost($postInfo);
return [];
}
/**
* @return array{
* vote: int,
* positive: int,
* negative: int,
* }|array{error: array{name: string, text: string}}
*/
#[PatternRoute('POST', '/comments/posts/([0-9]+)/vote')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
#[Before('input:urlencoded')]
public function postPostVote(HttpResponseBuilder $response, FormContent $content, string $commentId): array {
$vote = (int)$content->getFilteredParam('vote', FILTER_SANITIZE_NUMBER_INT);
if($vote === 0)
return self::error($response, 400, 'comments:vote', 'Could not process vote.');
if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE))
return self::error($response, 403, 'comments:vote-not-allowed', 'You are not allowed to vote on comments.');
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
if($postInfo->deleted)
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
if($catInfo->locked)
return self::error($response, 403, 'comments:category-locked-vote', 'The comment section this comment is in is locked, you cannot vote on it.');
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$this->commentsCtx->votes->addVote(
$postInfo,
$this->authInfo->userInfo,
max(-1, min(1, $vote))
);
$voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
$votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
$response->statusCode = 201;
return [
'vote' => $voteInfo->weight,
'positive' => $votes->positive,
'negative' => $votes->negative,
];
}
/**
* @return array{
* vote: int,
* positive: int,
* negative: int,
* }|array{error: array{name: string, text: string}}
*/
#[PatternRoute('DELETE', '/comments/posts/([0-9]+)/vote')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('csrf:header', type: 'json')]
#[Before('authz:banned', type: 'json')]
public function deletePostVote(HttpResponseBuilder $response, HttpRequest $request, string $commentId): array {
if(!$this->getGlobalPerms()->check(Perm::G_COMMENTS_VOTE))
return self::error($response, 403, 'comments:vote-not-allowed', 'You are not allowed to vote on comments.');
try {
$postInfo = $this->commentsCtx->posts->getPost($commentId);
if($postInfo->deleted)
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
$catInfo = $this->commentsCtx->categories->getCategory(postInfo: $postInfo);
if($catInfo->locked)
return self::error($response, 403, 'comments:category-locked-vote', 'The comment section this comment is in is locked, you cannot vote on it.');
} catch(RuntimeException $ex) {
return self::error($response, 404, 'comments:post-not-found', 'Comment not found.');
}
$this->commentsCtx->votes->removeVote(
$postInfo,
$this->authInfo->userInfo
);
$voteInfo = $this->commentsCtx->votes->getVote($postInfo, $this->authInfo->userInfo);
$votes = $this->commentsCtx->votes->getVotesAggregate($postInfo);
return [
'vote' => $voteInfo->weight,
'positive' => $votes->positive,
'negative' => $votes->negative,
];
}
}