[ '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; #[HttpMiddleware('/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; } #[HttpGet('/messages')] #[URLInfo('messages-index', '/messages', ['folder' => '', '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, ]); } #[HttpGet('/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, ), ]; } #[HttpPost('/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, ]), ]; } #[HttpGet('/messages/compose')] #[URLInfo('messages-compose', '/messages/compose', ['recipient' => ''])] public function getEditor($response, $request) { if(!$this->canSendMessages) return 403; return Template::renderRaw('messages.compose', [ 'recipient' => (string)$request->getParam('recipient'), ]); } #[HttpGet('/messages/([A-Za-z0-9]+)')] #[URLInfo('messages-view', '/messages/')] 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; } #[HttpPost('/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]), ]; } #[HttpPost('/messages/([A-Za-z0-9]+)')] #[URLInfo('messages-update', '/messages/')] 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]), ]; } #[HttpPost('/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 []; } #[HttpPost('/messages/delete')] #[URLInfo('messages-delete', '/messages/delete')] public function postDelete($response, $request) { if(!$request->isFormContent()) return 400; $content = $request->getContent(); $messages = (string)$content->getParam('messages'); if($messages === '') return [ 'error' => [ 'name' => 'msgs:empty', 'text' => 'No messages were supplied.', ], ]; $messages = explode(',', $messages); $this->msgsCtx->getDatabase()->deleteMessages( $this->authInfo->getUserInfo(), $messages ); return []; } #[HttpPost('/messages/restore')] #[URLInfo('messages-restore', '/messages/restore')] public function postRestore($response, $request) { if(!$request->isFormContent()) return 400; $content = $request->getContent(); $messages = (string)$content->getParam('messages'); if($messages === '') return [ 'error' => [ 'name' => 'msgs:empty', 'text' => 'No messages were supplied.', ], ]; $messages = explode(',', $messages); $this->msgsCtx->getDatabase()->restoreMessages( $this->authInfo->getUserInfo(), $messages ); return []; } #[HttpPost('/messages/nuke')] #[URLInfo('messages-nuke', '/messages/nuke')] public function postNuke($response, $request) { if(!$request->isFormContent()) return 400; $content = $request->getContent(); $messages = (string)$content->getParam('messages'); if($messages === '') return [ 'error' => [ 'name' => 'msgs:empty', 'text' => 'No messages were supplied.', ], ]; $messages = explode(',', $messages); $this->msgsCtx->getDatabase()->nukeMessages( $this->authInfo->getUserInfo(), $messages ); return []; } }