misuzu/src/Messages/MessagesRoutes.php

662 lines
23 KiB
PHP

<?php
namespace Misuzu\Messages;
use stdClass;
use InvalidArgumentException;
use RuntimeException;
use Index\XString;
use Index\Config\Config;
use Index\Colour\Colour;
use Index\Http\{FormHttpContent,HttpRequest,HttpResponseBuilder};
use Index\Http\Content\FormContent;
use Index\Http\Routing\Processors\{After,Before};
use Index\Http\Routing\Routes\{ExactRoute,PatternRoute};
use Index\Urls\{UrlFormat,UrlRegistry,UrlSource,UrlSourceCommon};
use Misuzu\{Misuzu,Pagination,Perm,Template};
use Misuzu\Auth\AuthInfo;
use Misuzu\Parsers\TextFormat;
use Misuzu\Perms\PermissionsData;
use Misuzu\Routing\{HandlerRoles,RouteHandler,RouteHandlerCommon};
use Misuzu\Users\{UsersContext,UserInfo};
#[HandlerRoles('main')]
class MessagesRoutes implements RouteHandler, UrlSource {
use RouteHandlerCommon, UrlSourceCommon;
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 Config $config,
private UrlRegistry $urls,
private AuthInfo $authInfo,
private MessagesContext $msgsCtx,
private UsersContext $usersCtx,
private PermissionsData $perms,
) {}
private bool $canSendMessages {
get {
return $this->authInfo->getPerms('global')->check(Perm::G_MESSAGES_SEND)
&& !$this->usersCtx->hasActiveBan($this->authInfo->userInfo);
}
}
/**
* @return object{
* info: MessageInfo,
* author_info: UserInfo,
* author_colour: Colour,
* recipient_info: UserInfo,
* recipient_colour: Colour
* }
*/
private function populateMessage(MessageInfo $messageInfo): object {
$message = new stdClass;
$message->info = $messageInfo;
$message->author_info = $messageInfo->authorId !== null ? $this->usersCtx->getUserInfo($messageInfo->authorId, 'id') : null;
$message->author_colour = $this->usersCtx->getUserColour($message->author_info);
$message->recipient_info = $messageInfo->recipientId !== null ? $this->usersCtx->getUserInfo($messageInfo->recipientId, 'id') : null;
$message->recipient_colour = $this->usersCtx->getUserColour($message->recipient_info);
return $message;
}
#[ExactRoute('GET', '/messages')]
#[Before('authz:cookie', required: true)]
#[Before('authz:private')]
#[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[UrlFormat('messages-index', '/messages', ['folder' => '<folder>', 'page' => '<page>'])]
public function getIndex(HttpRequest $request, string $folderName = ''): int|string {
$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->userInfo;
$msgsDb = $this->msgsCtx->database;
$authorInfo = !$folderTrash && $folderSent ? $selfInfo : null;
$recipientInfo = !$folderTrash && $folderInbox ? $selfInfo : null;
$sent = $folderTrash ? null : !$folderDrafts;
$deleted = $folderTrash;
$pagination = Pagination::fromRequest($request, $msgsDb->countMessages(
ownerInfo: $selfInfo,
authorInfo: $authorInfo,
recipientInfo: $recipientInfo,
sent: $sent,
deleted: $deleted,
), 50);
$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,
]);
}
/** @return array{unread: int} */
#[ExactRoute('GET', '/messages/stats')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[UrlFormat('messages-stats', '/messages/stats')]
public function getStats(): array {
$selfInfo = $this->authInfo->userInfo;
$msgsDb = $this->msgsCtx->database;
return [
'unread' => $msgsDb->countMessages(
ownerInfo: $selfInfo,
recipientInfo: $selfInfo,
sent: true,
deleted: false,
read: false,
),
];
}
/**
* @return int|array{avatar: string}|array{
* id: string,
* name: string,
* ban: bool,
* avatar: string
* }
*/
#[ExactRoute('POST', '/messages/recipient')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[Before('csrf:header', type: 'json')]
#[Before('input:urlencoded')]
#[UrlFormat('messages-recipient', '/messages/recipient')]
public function postRecipient(FormContent $content): int|array {
if(!$this->canSendMessages)
return 403;
$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->id,
'name' => $userInfo->name,
'ban' => $this->usersCtx->hasActiveBan($userInfo),
'avatar' => $this->urls->format('user-avatar', [
'user' => $userInfo->id,
'res' => 200,
]),
];
}
#[ExactRoute('GET', '/messages/compose')]
#[Before('authz:cookie', required: true)]
#[Before('authz:private')]
#[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[UrlFormat('messages-compose', '/messages/compose', ['recipient' => '<recipient>'])]
public function getEditor(HttpRequest $request): int|string {
if(!$this->canSendMessages)
return 403;
return Template::renderRaw('messages.compose', [
'recipient' => (string)$request->getParam('recipient'),
]);
}
#[PatternRoute('GET', '/messages/([A-Za-z0-9]+)')]
#[Before('authz:cookie', required: true)]
#[Before('authz:private')]
#[Before('authz:perm', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[UrlFormat('messages-view', '/messages/<message>')]
public function getView(string $messageId): int|string {
if(strlen($messageId) !== 8)
return 404;
$selfInfo = $this->authInfo->userInfo;
$msgsDb = $this->msgsCtx->database;
try {
$messageInfo = $msgsDb->getMessageInfo($selfInfo, $messageId);
} catch(RuntimeException $ex) {
return 404;
}
if(!$messageInfo->read)
$msgsDb->updateMessage(
ownerInfo: $selfInfo,
messageInfo: $messageInfo,
readAt: time(),
);
$message = $this->populateMessage($messageInfo);
$replyTo = null;
if($messageInfo->replyToId !== null) {
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->sent && $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,
]);
}
/** @return ?array{error: array{name: string, text: string}} */
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;
}
/** @return ?array{error: array{name: string, text: string, args?: scalar[]}} */
private function checkMessageFields(string $title, string $body, ?TextFormat $format): ?array {
if($format === null)
return [
'error' => [
'name' => 'msgs:invalid_format',
'text' => 'Invalid format 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;
}
/** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */
#[ExactRoute('POST', '/messages/create')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[Before('csrf:header', type: 'json')]
#[Before('input:multipart')]
#[UrlFormat('messages-create', '/messages/create')]
public function postCreate(FormContent $content): int|array {
if(!$this->canSendMessages)
return 403;
$recipient = (string)$content->getParam('recipient');
$replyTo = (string)$content->getParam('reply');
$title = (string)$content->getParam('title');
$body = (string)$content->getParam('body');
$format = TextFormat::tryFrom((string)$content->getParam('format'));
$draft = !empty($content->getParam('draft'));
$error = $this->checkMessageFields($title, $body, $format);
if($error !== null)
return $error;
$selfInfo = $this->authInfo->userInfo;
$msgsDb = $this->msgsCtx->database;
try {
$recipientInfo = $this->usersCtx->getUserInfo($recipient, 'messaging');
} catch(InvalidArgumentException $ex) {
return [
'error' => [
'name' => 'msgs:recipient_invalid',
'text' => 'Name of the recipient was incorrectly formatted.',
],
];
} 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->sent)
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,
bodyFormat: $format,
replyTo: $replyToInfo,
sentAt: $sentAt
);
// recipient copy
if($sentAt !== null && $recipientInfo->id !== $selfInfo->id)
$msgsDb->createMessage(
messageId: $msgId,
ownerInfo: $recipientInfo,
authorInfo: $selfInfo,
recipientInfo: $recipientInfo,
title: $title,
body: $body,
bodyFormat: $format,
replyTo: $replyToInfo,
sentAt: $sentAt
);
return [
'id' => $msgId,
'url' => $this->urls->format('messages-view', ['message' => $msgId]),
];
}
/** @return int|array{error: array{name: string, text: string}}|array{id: string, url: string} */
#[PatternRoute('PATCH', '/messages/([A-Za-z0-9]+)')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[Before('csrf:header', type: 'json')]
#[Before('input:multipart')]
#[UrlFormat('messages-update', '/messages/<message>')]
public function patchUpdate(FormContent $content, string $messageId): int|array {
if(!$this->canSendMessages)
return 403;
$title = (string)$content->getParam('title');
$body = (string)$content->getParam('body');
$format = TextFormat::tryFrom((string)$content->getParam('format'));
$draft = !empty($content->getParam('draft'));
$error = $this->checkMessageFields($title, $body, $format);
if($error !== null)
return $error;
$selfInfo = $this->authInfo->userInfo;
$msgsDb = $this->msgsCtx->database;
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->authorId === null || $messageInfo->authorId !== $selfInfo->id)
return [
'error' => [
'name' => 'msgs:not_author',
'text' => 'You are not the author of this message.',
],
];
if($messageInfo->recipientId === null)
return [
'error' => [
'name' => 'msgs:recipient_gone',
'text' => 'The recipient of this message no longer exists, it cannot be sent or edited.',
],
];
if($messageInfo->sent)
return [
'error' => [
'name' => 'msgs:not_draft',
'text' => 'You cannot edit a message that has already been sent.',
],
];
$error = $this->checkCanReceiveMessages($messageInfo->recipientId);
if($error !== null)
return $error;
$sentAt = $draft ? null : time();
$msgsDb->updateMessage(
ownerInfo: $selfInfo,
messageInfo: $messageInfo,
title: $title,
body: $body,
bodyFormat: $format,
sentAt: $sentAt,
);
// recipient copy
if($sentAt !== null && $messageInfo->recipientId !== $selfInfo->id)
$msgsDb->createMessage(
messageId: $messageId,
ownerInfo: $messageInfo->recipientId,
authorInfo: $selfInfo,
recipientInfo: $messageInfo->recipientId,
title: $title,
body: $body,
bodyFormat: $format,
replyTo: $messageInfo->replyToId,
sentAt: $sentAt
);
return [
'id' => $messageId,
'url' => $this->urls->format('messages-view', ['message' => $messageId]),
];
}
/** @return int|array{error: array{name: string, text: string}}|scalar[] */
#[ExactRoute('POST', '/messages/mark')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[Before('csrf:header', type: 'json')]
#[Before('input:urlencoded')]
#[UrlFormat('messages-mark', '/messages/mark')]
public function postMark(FormContent $content): int|array {
$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->userInfo;
$msgsDb = $this->msgsCtx->database;
foreach($messages as $messageId)
$msgsDb->updateMessage(
ownerInfo: $selfInfo,
messageInfo: $messageId,
readAt: $type === 'read' ? time() : null,
);
return [];
}
/** @return int|array{error: array{name: string, text: string}}|scalar[] */
#[ExactRoute('POST', '/messages/delete')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[Before('csrf:header', type: 'json')]
#[Before('input:urlencoded')]
#[UrlFormat('messages-delete', '/messages/delete')]
public function postDelete(FormContent $content): int|array {
$messages = (string)$content->getParam('messages');
if($messages === '')
return [
'error' => [
'name' => 'msgs:empty',
'text' => 'No messages were supplied.',
],
];
$messages = explode(',', $messages);
$this->msgsCtx->database->deleteMessages(
$this->authInfo->userInfo,
$messages
);
return [];
}
/** @return int|array{error: array{name: string, text: string}}|scalar[] */
#[ExactRoute('POST', '/messages/restore')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[Before('csrf:header', type: 'json')]
#[Before('input:urlencoded')]
#[UrlFormat('messages-restore', '/messages/restore')]
public function postRestore(FormContent $content) {
$messages = (string)$content->getParam('messages');
if($messages === '')
return [
'error' => [
'name' => 'msgs:empty',
'text' => 'No messages were supplied.',
],
];
$messages = explode(',', $messages);
$this->msgsCtx->database->restoreMessages(
$this->authInfo->userInfo,
$messages
);
return [];
}
/** @return int|array{error: array{name: string, text: string}}|scalar[] */
#[ExactRoute('POST', '/messages/nuke')]
#[Before('authz:cookie', type: 'json', required: true)]
#[Before('authz:private', type: 'json')]
#[Before('authz:perm', type: 'json', category: 'global', perm: Perm::G_MESSAGES_VIEW)]
#[Before('csrf:header', type: 'json')]
#[Before('input:urlencoded')]
#[UrlFormat('messages-nuke', '/messages/nuke')]
public function postNuke(FormContent $content) {
$messages = (string)$content->getParam('messages');
if($messages === '')
return [
'error' => [
'name' => 'msgs:empty',
'text' => 'No messages were supplied.',
],
];
$messages = explode(',', $messages);
$this->msgsCtx->database->nukeMessages(
$this->authInfo->userInfo,
$messages
);
return [];
}
}