misuzu/src/Messages/MessagesRoutes.php

627 lines
21 KiB
PHP

<?php
namespace Misuzu\Messages;
use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Routing\{Route,RouteHandler};
use Syokuhou\IConfig;
use Misuzu\{CSRF,Pagination,Perm,Template};
use Misuzu\Auth\AuthInfo;
use Misuzu\Parsers\Parser;
use Misuzu\Perms\Permissions;
use Misuzu\URLs\{URLInfo,URLRegistry};
use Misuzu\Users\{UsersContext,UserInfo};
class MessagesRoutes extends RouteHandler {
public const FOLDER_META = [
'inbox' => [ 'title' => 'Inbox', 'icon' => 'fas fa-inbox fa-fw' ],
'drafts' => [ 'title' => 'Drafts', 'icon' => 'fas fa-pencil-alt fa-fw' ],
'sent' => [ 'title' => 'Sent', 'icon' => 'fas fa-paper-plane fa-fw' ],
'trash' => [ 'title' => 'Trash', 'icon' => 'fas fa-trash-alt fa-fw' ],
];
public function __construct(
private IConfig $config,
private URLRegistry $urls,
private AuthInfo $authInfo,
private MessagesContext $msgsCtx,
private UsersContext $usersCtx,
private Permissions $perms
) {}
private bool $canSendMessages;
#[Route('/messages')]
public function checkAccess($response, $request) {
// should probably be a permission or something too
if(!$this->authInfo->isLoggedIn())
return 401;
$globalPerms = $this->authInfo->getPerms('global');
if(!$globalPerms->check(Perm::G_MESSAGES_VIEW))
return 403;
$this->canSendMessages = $globalPerms->check(Perm::G_MESSAGES_SEND)
&& !$this->usersCtx->hasActiveBan($this->authInfo->getUserInfo());
if($request->getMethod() === 'POST' && $request->isFormContent()) {
$content = $request->getContent();
if(!$content->hasParam('_csrfp') || !CSRF::validate((string)$content->getParam('_csrfp')))
return [
'error' => [
'name' => 'msgs:verify',
'text' => 'Request verification failed! Refresh the page and try again.',
],
];
$response->setHeader('X-CSRFP-Token', CSRF::token());
}
}
private function populateMessage(MessageInfo $messageInfo): object {
$message = new stdClass;
$message->info = $messageInfo;
$message->author_info = $messageInfo->hasAuthorId() ? $this->usersCtx->getUserInfo($messageInfo->getAuthorId(), 'id') : null;
$message->author_colour = $this->usersCtx->getUserColour($message->author_info);
$message->recipient_info = $messageInfo->hasRecipientId() ? $this->usersCtx->getUserInfo($messageInfo->getRecipientId(), 'id') : null;
$message->recipient_colour = $this->usersCtx->getUserColour($message->recipient_info);
return $message;
}
#[Route('GET', '/messages')]
#[URLInfo('messages-index', '/messages', ['folder' => '<folder>', 'page' => '<page>'])]
public function getIndex($response, $request, string $folderName = '') {
$folderName = (string)$request->getParam('folder');
if($folderName === '')
$folderName = 'inbox';
if(!array_key_exists($folderName, self::FOLDER_META))
return 404;
$folderInbox = $folderName === 'inbox';
$folderDrafts = $folderName === 'drafts';
$folderSent = $folderName === 'sent';
$folderTrash = $folderName === 'trash';
$selfInfo = $this->authInfo->getUserInfo();
$msgsDb = $this->msgsCtx->getDatabase();
$authorInfo = !$folderTrash && $folderSent ? $selfInfo : null;
$recipientInfo = !$folderTrash && $folderInbox ? $selfInfo : null;
$sent = $folderTrash ? null : !$folderDrafts;
$deleted = $folderTrash;
$pagination = new Pagination($msgsDb->countMessages(
ownerInfo: $selfInfo,
authorInfo: $authorInfo,
recipientInfo: $recipientInfo,
sent: $sent,
deleted: $deleted,
), 50, 'page');
$messageInfos = $msgsDb->getMessages(
ownerInfo: $selfInfo,
authorInfo: $authorInfo,
recipientInfo: $recipientInfo,
sent: $sent,
deleted: $deleted,
pagination: $pagination,
);
$messages = [];
foreach($messageInfos as $messageInfo)
$messages[] = $this->populateMessage($messageInfo);
return Template::renderRaw('messages.index', [
'can_send_messages' => $this->canSendMessages,
'folder_name' => $folderName,
'folder_meta' => self::FOLDER_META,
'folder_messages' => $messages,
'folder_pagination' => $pagination,
]);
}
#[Route('GET', '/messages/stats')]
#[URLInfo('messages-stats', '/messages/stats')]
public function getStats() {
$selfInfo = $this->authInfo->getUserInfo();
$msgsDb = $this->msgsCtx->getDatabase();
return [
'unread' => $msgsDb->countMessages(
ownerInfo: $selfInfo,
recipientInfo: $selfInfo,
sent: true,
deleted: false,
read: false,
),
];
}
#[Route('POST', '/messages/recipient')]
#[URLInfo('messages-recipient', '/messages/recipient')]
public function postRecipient($response, $request) {
if(!$request->isFormContent())
return 400;
if(!$this->canSendMessages)
return 403;
$content = $request->getContent();
$name = trim((string)$content->getParam('name'));
// flappy hacks
if(str_starts_with(mb_strtolower($name), 'flappyzor'))
$name = 14;
$userInfo = null;
if(!empty($name))
try {
$userInfo = $this->usersCtx->getUserInfo($name, 'messaging');
} catch(InvalidArgumentException $ex) {
} catch(RuntimeException $ex) {}
if($userInfo === null)
return [
'avatar' => $this->urls->format('user-avatar', [
'res' => 200,
]),
];
return [
'id' => $userInfo->getId(),
'name' => $userInfo->getName(),
'ban' => $this->usersCtx->hasActiveBan($userInfo),
'avatar' => $this->urls->format('user-avatar', [
'user' => $userInfo->getId(),
'res' => 200,
]),
];
}
#[Route('GET', '/messages/compose')]
#[URLInfo('messages-compose', '/messages/compose', ['recipient' => '<recipient>'])]
public function getEditor($response, $request) {
if(!$this->canSendMessages)
return 403;
return Template::renderRaw('messages.compose', [
'recipient' => (string)$request->getParam('recipient'),
]);
}
#[Route('GET', '/messages/:message')]
#[URLInfo('messages-view', '/messages/<message>')]
public function getView($response, $request, string $messageId) {
if(strlen($messageId) !== 8)
return 404;
$selfInfo = $this->authInfo->getUserInfo();
$msgsDb = $this->msgsCtx->getDatabase();
try {
$messageInfo = $msgsDb->getMessageInfo($selfInfo, $messageId);
} catch(RuntimeException $ex) {
return 404;
}
if(!$messageInfo->isRead())
$msgsDb->updateMessage(
ownerInfo: $selfInfo,
messageInfo: $messageInfo,
readAt: time(),
);
$message = $this->populateMessage($messageInfo);
$replyTo = null;
if($messageInfo->hasReplyToId()) {
try {
$replyTo = $this->populateMessage(
$msgsDb->getMessageInfo($selfInfo, $messageInfo, true)
);
} catch(RuntimeException $ex) {}
}
$repliesForInfos = $msgsDb->getMessages(
ownerInfo: $selfInfo,
repliesFor: $messageInfo,
deleted: false,
);
$draftInfo = null;
$repliesFor = [];
foreach($repliesForInfos as $repliesForInfo) {
$repliesFor[] = $this->populateMessage($repliesForInfo);
if(!$repliesForInfo->isSent() && $draftInfo === null)
$draftInfo = $repliesForInfo;
}
return Template::renderRaw('messages.thread', [
'can_send_messages' => $this->canSendMessages,
'self_info' => $selfInfo,
'reply_to' => $replyTo,
'message' => $message,
'draft_info' => $draftInfo,
'replies_for' => $repliesFor,
]);
}
private function checkCanReceiveMessages(UserInfo|string $userInfo): ?array {
$globalPerms = $this->perms->getPermissions('global', $userInfo);
if(!$globalPerms->check(Perm::G_MESSAGES_VIEW))
return [
'error' => [
'name' => 'msgs:recipient_cannot_recv',
'text' => 'This person is not allowed to receive messages.',
],
];
return null;
}
private function checkMessageFields(string $title, string $body, int $parser): ?array {
if(!Parser::isValid($parser))
return [
'error' => [
'name' => 'msgs:invalid_parser',
'text' => 'Invalid parser selected.',
],
];
$lengths = $this->config->getValues([
['title.minLength:i', 1],
['title.maxLength:i', 200],
['body.minLength:i', 1],
['body.maxLength:i', 60000],
]);
$titleLength = mb_strlen(trim($title));
if($titleLength < $lengths['title.minLength'])
return [
'error' => [
'name' => 'msgs:title_too_short',
'args' => [$lengths['title.minLength'], $titleLength],
'text' => sprintf('Title may not be shorter than %d characters. You entered %d characters.', $lengths['title.minLength'], $titleLength),
],
];
if($titleLength > $lengths['title.maxLength'])
return [
'error' => [
'name' => 'msgs:title_too_long',
'args' => [$lengths['title.maxLength'], $titleLength],
'text' => sprintf('Title may not be longer than %d characters. You entered %d characters.', $lengths['title.maxLength'], $titleLength),
],
];
$bodyLength = mb_strlen(trim($body));
if($bodyLength < $lengths['body.minLength'])
return [
'error' => [
'name' => 'msgs:body_too_short',
'args' => [$lengths['body.minLength'], $bodyLength],
'text' => sprintf('Message may not be shorter than %d characters. You entered %d characters.', $lengths['body.minLength'], $bodyLength),
],
];
if($bodyLength > $lengths['body.maxLength'])
return [
'error' => [
'name' => 'msgs:body_too_long',
'args' => [$lengths['body.maxLength'], $bodyLength],
'text' => sprintf('Message may not be longer than %d characters. You entered %d characters.', $lengths['body.maxLength'], $bodyLength),
],
];
return null;
}
#[Route('POST', '/messages/create')]
#[URLInfo('messages-create', '/messages/create')]
public function postCreate($response, $request) {
if(!$request->isFormContent())
return 400;
if(!$this->canSendMessages)
return 403;
$content = $request->getContent();
$recipient = (string)$content->getParam('recipient');
$replyTo = (string)$content->getParam('reply');
$title = (string)$content->getParam('title');
$body = (string)$content->getParam('body');
$parser = (int)$content->getParam('parser', FILTER_SANITIZE_NUMBER_INT);
$draft = !empty($content->getParam('draft'));
$error = $this->checkMessageFields($title, $body, $parser);
if($error !== null)
return $error;
$selfInfo = $this->authInfo->getUserInfo();
$msgsDb = $this->msgsCtx->getDatabase();
try {
$recipientInfo = $this->usersCtx->getUserInfo($recipient, 'messaging');
} catch(InvalidArgumentException $ex) {
return [
'error' => [
'name' => 'msgs:recipient_invalid',
'text' => 'Name of the recipient was incorrectly formatted.',
'jeff' => $recipient,
],
];
} catch(RuntimeException $ex) {
return [
'error' => [
'name' => 'msgs:recipient_not_found',
'text' => 'Recipient does not exist.',
],
];
}
$error = $this->checkCanReceiveMessages($recipientInfo);
if($error !== null)
return $error;
$replyToInfo = null;
if(!empty($replyTo)) {
try {
$replyToInfo = $msgsDb->getMessageInfo($selfInfo, $replyTo);
} catch(RuntimeException $ex) {
return [
'error' => [
'name' => 'msgs:reply_not_found',
'text' => 'The message you are trying to reply to does not exist.',
],
];
}
if(!$replyToInfo->isSent())
return [
'error' => [
'name' => 'msgs:draft_reply',
'text' => 'You cannot reply to a draft.',
],
];
}
$msgId = XString::random(8);
$sentAt = $draft ? null : time();
// own copy
$msgsDb->createMessage(
messageId: $msgId,
ownerInfo: $selfInfo,
authorInfo: $selfInfo,
recipientInfo: $recipientInfo,
title: $title,
body: $body,
parser: $parser,
replyTo: $replyToInfo,
sentAt: $sentAt
);
// recipient copy
if($sentAt !== null && $recipientInfo->getId() !== $selfInfo->getId())
$msgsDb->createMessage(
messageId: $msgId,
ownerInfo: $recipientInfo,
authorInfo: $selfInfo,
recipientInfo: $recipientInfo,
title: $title,
body: $body,
parser: $parser,
replyTo: $replyToInfo,
sentAt: $sentAt
);
return [
'id' => $msgId,
'url' => $this->urls->format('messages-view', ['message' => $msgId]),
];
}
#[Route('POST', '/messages/:message')]
#[URLInfo('messages-update', '/messages/<message>')]
public function postUpdate($response, $request, string $messageId) {
if(!$request->isFormContent())
return 400;
if(!$this->canSendMessages)
return 403;
$content = $request->getContent();
$title = (string)$content->getParam('title');
$body = (string)$content->getParam('body');
$parser = (int)$content->getParam('parser', FILTER_SANITIZE_NUMBER_INT);
$draft = !empty($content->getParam('draft'));
$error = $this->checkMessageFields($title, $body, $parser);
if($error !== null)
return $error;
$selfInfo = $this->authInfo->getUserInfo();
$msgsDb = $this->msgsCtx->getDatabase();
try {
$messageInfo = $msgsDb->getMessageInfo($selfInfo, $messageId);
} catch(RuntimeException $ex) {
return [
'error' => [
'name' => 'msgs:edit_not_found',
'text' => 'The message you are trying to edit does not exist.',
],
];
}
if(!$messageInfo->hasAuthorId() || $messageInfo->getAuthorId() !== $selfInfo->getId())
return [
'error' => [
'name' => 'msgs:not_author',
'text' => 'You are not the author of this message.',
],
];
if(!$messageInfo->hasRecipientId())
return [
'error' => [
'name' => 'msgs:recipient_gone',
'text' => 'The recipient of this message no longer exists, it cannot be sent or edited.',
],
];
if($messageInfo->isSent())
return [
'error' => [
'name' => 'msgs:not_draft',
'text' => 'You cannot edit a message that has already been sent.',
],
];
$error = $this->checkCanReceiveMessages($messageInfo->getRecipientId());
if($error !== null)
return $error;
$sentAt = $draft ? null : time();
$msgsDb->updateMessage(
ownerInfo: $selfInfo,
messageInfo: $messageInfo,
title: $title,
body: $body,
parser: $parser,
sentAt: $sentAt,
);
// recipient copy
if($sentAt !== null && $messageInfo->getRecipientId() !== $selfInfo->getId())
$msgsDb->createMessage(
messageId: $messageId,
ownerInfo: $messageInfo->getRecipientId(),
authorInfo: $selfInfo,
recipientInfo: $messageInfo->getRecipientId(),
title: $title,
body: $body,
parser: $parser,
replyTo: $messageInfo->getReplyToId(),
sentAt: $sentAt
);
return [
'id' => $messageId,
'url' => $this->urls->format('messages-view', ['message' => $messageId]),
];
}
#[Route('POST', '/messages/mark')]
#[URLInfo('messages-mark', '/messages/mark')]
public function postMark($response, $request) {
if(!$request->isFormContent())
return 400;
$content = $request->getContent();
$type = (string)$content->getParam('type');
$messages = explode(',', (string)$content->getParam('messages'));
if($type !== 'read' && $type !== 'unread')
return [
'error' => [
'name' => 'msgs:unsupported_mark',
'text' => 'Attempting to mark message with an unsupported state.',
],
];
$selfInfo = $this->authInfo->getUserInfo();
$msgsDb = $this->msgsCtx->getDatabase();
foreach($messages as $messageId)
$msgsDb->updateMessage(
ownerInfo: $selfInfo,
messageInfo: $messageId,
readAt: $type === 'read' ? time() : null,
);
return [];
}
#[Route('POST', '/messages/delete')]
#[URLInfo('messages-delete', '/messages/delete')]
public function postDelete($response, $request) {
if(!$request->isFormContent())
return 400;
$content = $request->getContent();
$messages = explode(',', (string)$content->getParam('messages'));
if(empty($messages))
return [
'error' => [
'name' => 'msgs:empty',
'text' => 'No messages were supplied.',
],
];
$this->msgsCtx->getDatabase()->deleteMessages(
$this->authInfo->getUserInfo(),
$messages
);
return [];
}
#[Route('POST', '/messages/restore')]
#[URLInfo('messages-restore', '/messages/restore')]
public function postRestore($response, $request) {
if(!$request->isFormContent())
return 400;
$content = $request->getContent();
$messages = explode(',', (string)$content->getParam('messages'));
if(empty($messages))
return [
'error' => [
'name' => 'msgs:empty',
'text' => 'No messages were supplied.',
],
];
$this->msgsCtx->getDatabase()->restoreMessages(
$this->authInfo->getUserInfo(),
$messages
);
return [];
}
#[Route('POST', '/messages/nuke')]
#[URLInfo('messages-nuke', '/messages/nuke')]
public function postNuke($response, $request) {
if(!$request->isFormContent())
return 400;
$content = $request->getContent();
$messages = explode(',', (string)$content->getParam('messages'));
if(empty($messages))
return [
'error' => [
'name' => 'msgs:empty',
'text' => 'No messages were supplied.',
],
];
$this->msgsCtx->getDatabase()->nukeMessages(
$this->authInfo->getUserInfo(),
$messages
);
return [];
}
}