Compare commits
No commits in common. "e60ecce1bbc0d0b40cdc1caa5d3eceb02db4eddd" and "97847288fcbecfba20ecc11c4aaa69ecc5c22ff0" have entirely different histories.
e60ecce1bb
...
97847288fc
33 changed files with 850 additions and 1528 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
@ -1,5 +1,5 @@
|
||||||
node_modules/
|
node_modules/
|
||||||
dist/
|
dist/
|
||||||
frontend-new/
|
frontend-new/
|
||||||
uploads/
|
uploads/avatar/*.webp
|
||||||
.env
|
.env
|
||||||
|
|
|
@ -1,18 +1,8 @@
|
||||||
<script>
|
<script>
|
||||||
import { avatarUrl } from "../storage";
|
import { avatarUrl } from "../storage";
|
||||||
import { overlayStore, OverlayType, setMessageInputEvent } from "../stores";
|
import { overlayStore, OverlayType, setMessageInputEvent } from "../stores";
|
||||||
import MessageAttachment from "./MessageAttachment.svelte";
|
|
||||||
|
|
||||||
export let message;
|
export let message;
|
||||||
let waitingForAttachments = 0;
|
|
||||||
$: {
|
|
||||||
const attachmentCount = message.attachments ? message.attachments.length : 0;
|
|
||||||
const expectedCount = message.pending_attachments || 0;
|
|
||||||
let delta = expectedCount - attachmentCount;
|
|
||||||
if (delta < 0) delta = 0;
|
|
||||||
waitingForAttachments = delta;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
const reply = () => {
|
const reply = () => {
|
||||||
let replyString = "";
|
let replyString = "";
|
||||||
|
@ -122,20 +112,6 @@
|
||||||
.message:hover .message-actions {
|
.message:hover .message-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.attachments-container {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
margin-top: var(--space-xxs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hint-text {
|
|
||||||
display: block;
|
|
||||||
margin-top: 6px;
|
|
||||||
color: var(--foreground-color-3);
|
|
||||||
font-size: var(--h6);
|
|
||||||
user-select: none;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="message" class:clumped={ message._clumped } class:pinged={ message._mentions }>
|
<div class="message" class:clumped={ message._clumped } class:pinged={ message._mentions }>
|
||||||
|
@ -161,18 +137,6 @@
|
||||||
{/if}
|
{/if}
|
||||||
<span class="message-content" class:pending={ message._isPending }>{ message.content }</span>
|
<span class="message-content" class:pending={ message._isPending }>{ message.content }</span>
|
||||||
|
|
||||||
{#if message.attachments && message.attachments.length}
|
|
||||||
<div class="attachments-container">
|
|
||||||
{#each message.attachments as attachment(attachment.id)}
|
|
||||||
<MessageAttachment attachment={attachment} />
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if waitingForAttachments}
|
|
||||||
<span class="hint-text">Waiting for {waitingForAttachments} more {`attachment${waitingForAttachments !== 1 ? 's' : ''}`}</span>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="message-actions">
|
<div class="message-actions">
|
||||||
<button class="icon-button material-icons-outlined" on:click="{ reply }" aria-label="Reply to Message">
|
<button class="icon-button material-icons-outlined" on:click="{ reply }" aria-label="Reply to Message">
|
||||||
reply
|
reply
|
||||||
|
|
|
@ -1,93 +0,0 @@
|
||||||
<script>
|
|
||||||
import { attachmentUrl } from "../storage";
|
|
||||||
|
|
||||||
const AttachmentRenderAs = {
|
|
||||||
Invalid: 0,
|
|
||||||
Image: 1,
|
|
||||||
Video: 2,
|
|
||||||
Audio: 3,
|
|
||||||
DownloadableFile: 4,
|
|
||||||
};
|
|
||||||
|
|
||||||
export let attachment;
|
|
||||||
let renderAs;
|
|
||||||
$: {
|
|
||||||
renderAs = AttachmentRenderAs.Invalid;
|
|
||||||
if (attachment && attachment.type === "file" && attachment.file_mime && attachment.file) {
|
|
||||||
const mimeParts = attachment.file_mime.split("/");
|
|
||||||
const type = mimeParts[0];
|
|
||||||
if (type) {
|
|
||||||
switch (type) {
|
|
||||||
case "audio": {
|
|
||||||
renderAs = AttachmentRenderAs.Audio;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "video": {
|
|
||||||
renderAs = AttachmentRenderAs.Video;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
case "image": {
|
|
||||||
renderAs = AttachmentRenderAs.Image;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
default: {
|
|
||||||
renderAs = AttachmentRenderAs.DownloadableFile
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.attachment-card {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
padding: var(--space-norm);
|
|
||||||
border-radius: 9999px;
|
|
||||||
background-color: var(--background-color-0);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment {
|
|
||||||
margin-bottom: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.media {
|
|
||||||
max-width: 90%;
|
|
||||||
}
|
|
||||||
|
|
||||||
img {
|
|
||||||
background-color: var(--background-color-2);
|
|
||||||
border-radius: 6px;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-filename {
|
|
||||||
color: var(--foreground-color-3);
|
|
||||||
margin-right: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-card .icon-button {
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
||||||
|
|
||||||
{#if renderAs === AttachmentRenderAs.Image}
|
|
||||||
<img loading="lazy" decoding="async" width="{ attachment.width }" height="{ attachment.height }" class="attachment media" alt="Attachment" src="{ attachmentUrl(attachment.file) }">
|
|
||||||
{:else if renderAs === AttachmentRenderAs.Video}
|
|
||||||
<!-- svelte-ignore a11y-media-has-caption -->
|
|
||||||
<video controls="controls" class="attachment media" src="{ attachmentUrl(attachment.file) }"></video>
|
|
||||||
{:else if renderAs === AttachmentRenderAs.DownloadableFile}
|
|
||||||
<div class="attachment attachment-card">
|
|
||||||
<div class="attachment-filename">{ attachment.file_name }</div>
|
|
||||||
<a class="icon-button material-icons-outlined" href="{ attachmentUrl(attachment.file) }" target="_blank">download</a>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="attachment attachment-card">Couldn't render attachment</div>
|
|
||||||
{/if}
|
|
|
@ -1,24 +1,14 @@
|
||||||
<script>
|
<script>
|
||||||
import { onDestroy, onMount } from "svelte";
|
import { onDestroy, onMount } from "svelte";
|
||||||
import { getItem } from "../storage";
|
import { getItem } from "../storage";
|
||||||
import { messagesStoreProvider, overlayStore, setMessageInputEvent, smallViewport, typingStore, userInfoStore, usesKeyboardNavigation } from "../stores";
|
import { overlayStore, selectedChannel, sendMessageAction, setMessageInputEvent, smallViewport, typingStore, userInfoStore, usesKeyboardNavigation } from "../stores";
|
||||||
import { getErrorFromResponse, methods, remoteBlobUpload, remoteCall, responseOk } from "../request";
|
|
||||||
|
|
||||||
export let channel;
|
export let channel;
|
||||||
let messageInput = "";
|
let messageInput = "";
|
||||||
|
let messageTextarea;
|
||||||
let typingList = "?no one?";
|
let typingList = "?no one?";
|
||||||
let typingMessage = "is typing...";
|
let typingMessage = "is typing...";
|
||||||
|
|
||||||
let messageTextarea;
|
|
||||||
let fileInput;
|
|
||||||
|
|
||||||
let files = [];
|
|
||||||
let loadingMessage = null;
|
|
||||||
let showFileListStrip = false;
|
|
||||||
$: {
|
|
||||||
showFileListStrip = !!files.length || !!loadingMessage;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
const typing = $typingStore.filter(a => a.channelId === channel.id);
|
const typing = $typingStore.filter(a => a.channelId === channel.id);
|
||||||
const ownIndex = typing.findIndex(a => a.user.id === $userInfoStore.id);
|
const ownIndex = typing.findIndex(a => a.user.id === $userInfoStore.id);
|
||||||
|
@ -47,88 +37,13 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
const removeFile = (id) => {
|
|
||||||
const index = files.findIndex(f => f.id === id);
|
|
||||||
if (index !== -1) {
|
|
||||||
files.splice(index, 1);
|
|
||||||
files = files;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectFiles = async () => {
|
|
||||||
if (!fileInput || !fileInput.files) return;
|
|
||||||
|
|
||||||
for (let i = 0; i < fileInput.files.length; i++) {
|
|
||||||
const file = fileInput.files.item(i);
|
|
||||||
const fileObject = {
|
|
||||||
file,
|
|
||||||
id: Math.random()
|
|
||||||
};
|
|
||||||
files.push(fileObject);
|
|
||||||
files = files;
|
|
||||||
}
|
|
||||||
|
|
||||||
return;
|
|
||||||
|
|
||||||
};
|
|
||||||
|
|
||||||
const uploadFiles = async (messageId) => {
|
|
||||||
const filesToUpload = [ ...files ];
|
|
||||||
files.length = 0;
|
|
||||||
files = files;
|
|
||||||
if (!filesToUpload.length) return;
|
|
||||||
|
|
||||||
loadingMessage = `Uploading ${filesToUpload.length} attachment${filesToUpload.length !== 1 ? 's' : ''}`;
|
|
||||||
|
|
||||||
for (let i = 0; i < filesToUpload.length; i++) {
|
|
||||||
const file = filesToUpload[i].file;
|
|
||||||
|
|
||||||
const res = await remoteBlobUpload(methods.createMessageAttachment, file, [messageId, file.name]);
|
|
||||||
if (!responseOk(res)) {
|
|
||||||
const error = getErrorFromResponse(res);
|
|
||||||
const message = error.validationErrors && error.validationErrors.length ? error.validationErrors[0].msg : error.message;
|
|
||||||
overlayStore.toast(`Failed to upload file ${file.name}: ${message}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loadingMessage = null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const sendMessage = async () => {
|
const sendMessage = async () => {
|
||||||
messageTextarea.focus();
|
messageTextarea.focus();
|
||||||
const content = messageInput;
|
sendMessageAction.emit({
|
||||||
|
channelId: channel.id,
|
||||||
if (content.trim() === "" || !userInfoStore.value)
|
content: messageInput
|
||||||
return;
|
});
|
||||||
|
|
||||||
// optimistically add message to store
|
|
||||||
const optimisticMessageId = Math.floor(Math.random() * 9999999);
|
|
||||||
const optimisticMessage = {
|
|
||||||
id: optimisticMessageId,
|
|
||||||
content: content,
|
|
||||||
channel_id: channel.id,
|
|
||||||
author_id: userInfoStore.value.id,
|
|
||||||
author_username: userInfoStore.value.username,
|
|
||||||
created_at: Date.now().toString(),
|
|
||||||
_isPending: true
|
|
||||||
};
|
|
||||||
const messagesStoreForChannel = messagesStoreProvider.getStore(channel.id);
|
|
||||||
messagesStoreForChannel.addMessage(optimisticMessage);
|
|
||||||
|
|
||||||
const res = await remoteCall(methods.createChannelMessage, channel.id, content, optimisticMessageId, null, files.length);
|
|
||||||
|
|
||||||
if (!responseOk(res)) {
|
|
||||||
messagesStoreForChannel.deleteMessage({
|
|
||||||
id: optimisticMessageId
|
|
||||||
});
|
|
||||||
overlayStore.toast(`Couldn't send message: ${getMessageFromResponse(res)}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
messageInput = "";
|
messageInput = "";
|
||||||
|
|
||||||
await uploadFiles(res.data.id);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const onKeydown = async (e) => {
|
const onKeydown = async (e) => {
|
||||||
|
@ -194,31 +109,23 @@
|
||||||
.message-input-container.small {
|
.message-input-container.small {
|
||||||
padding-top: var(--space-sm);
|
padding-top: var(--space-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.input-row {
|
.message-input.small {
|
||||||
display: flex;
|
padding: var(--space-xsplus);
|
||||||
flex-direction: row;
|
padding-left: var(--space-sm);
|
||||||
background-color: var(--background-color-3);
|
border-radius: 1.4em;
|
||||||
border-radius: var(--radius-md);
|
|
||||||
contain: content;
|
|
||||||
width: 100%;
|
|
||||||
padding: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-row.small {
|
|
||||||
border-radius: 9999px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.message-input {
|
.message-input {
|
||||||
margin-left: calc(0.67 * var(--space-unit));
|
|
||||||
flex-grow: 1;
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background-color: transparent;
|
background-color: var(--background-color-3);
|
||||||
border: none;
|
border: none;
|
||||||
color: currentColor;
|
color: currentColor;
|
||||||
border-radius: 0;
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-sm);
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
resize: none;
|
resize: none;
|
||||||
|
contain: strict;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* TODO: is this good? */
|
/* TODO: is this good? */
|
||||||
|
@ -269,79 +176,22 @@
|
||||||
.typing-message {
|
.typing-message {
|
||||||
color: var(--foreground-color-2);
|
color: var(--foreground-color-2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.strip {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
align-items: center;
|
|
||||||
border-top-left-radius: var(--radius-md);
|
|
||||||
border-top-right-radius: var(--radius-md);
|
|
||||||
background-color: var(--background-color-1);
|
|
||||||
color: var(--foreground-color-2);
|
|
||||||
padding: var(--space-sm);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-strip-file {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: row;
|
|
||||||
background-color: var(--background-color-2);
|
|
||||||
padding: var(--space-xs);
|
|
||||||
border-radius: var(--radius-sm);
|
|
||||||
margin-right: var(--space-xs);
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-strip-file span {
|
|
||||||
margin-right: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-strip-file button {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.file-strip-file:hover button {
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.loading-message {
|
|
||||||
color: var(--foreground-color-3);
|
|
||||||
margin-left: 8px;
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<div class="message-input-container" class:small={ $smallViewport }>
|
<div class="message-input-container" class:small={ $smallViewport }>
|
||||||
<input type="file" style="display: none;" accept="*" name="attachment-upload" multiple={false} bind:this={fileInput} on:change={selectFiles}>
|
|
||||||
{#if showFileListStrip}
|
|
||||||
<div class="strip">
|
|
||||||
{#if loadingMessage}
|
|
||||||
<div class="spinner ui"></div>
|
|
||||||
<div class="loading-message">{loadingMessage}</div>
|
|
||||||
{:else}
|
|
||||||
{#each files as fileObject(fileObject.id)}
|
|
||||||
<div class="file-strip-file">
|
|
||||||
<span>{fileObject.file.name}</span>
|
|
||||||
<button class="icon-button material-icons-outlined" on:click="{ () => removeFile(fileObject.id) }" aria-label="Remove Attachment">close</button>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<div class="inner-input-container">
|
<div class="inner-input-container">
|
||||||
<div class="input-row" class:small={ $smallViewport }>
|
<textarea
|
||||||
<button class="icon-button material-icons-outlined" on:click="{ () => fileInput.click() }" aria-label="Add Attachment">add</button>
|
placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`}
|
||||||
<textarea
|
type="text"
|
||||||
placeholder={$smallViewport ? `Message #${channel.name}` : `Send something interesting to #${channel.name}`}
|
class="message-input"
|
||||||
type="text"
|
rows="1"
|
||||||
class="message-input"
|
on:keydown={ onKeydown }
|
||||||
rows="1"
|
on:input={ onInput }
|
||||||
on:keydown={ onKeydown }
|
bind:value={ messageInput }
|
||||||
on:input={ onInput }
|
bind:this={ messageTextarea }
|
||||||
bind:value={ messageInput }
|
class:small={ $smallViewport || getItem("ui:alwaysUseMobileChatBar") }
|
||||||
bind:this={ messageTextarea }
|
class:keyboard-nav={ $usesKeyboardNavigation }
|
||||||
class:small={ $smallViewport || getItem("ui:alwaysUseMobileChatBar") }
|
/>
|
||||||
class:keyboard-nav={ $usesKeyboardNavigation }
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{#if $smallViewport || getItem("ui:alwaysUseMobileChatBar")}
|
{#if $smallViewport || getItem("ui:alwaysUseMobileChatBar")}
|
||||||
<button class="icon-button send-button material-icons-outlined" on:click="{ sendMessage }">
|
<button class="icon-button send-button material-icons-outlined" on:click="{ sendMessage }">
|
||||||
arrow_upward
|
arrow_upward
|
||||||
|
|
|
@ -2,7 +2,9 @@
|
||||||
import { quadInOut } from "svelte/easing";
|
import { quadInOut } from "svelte/easing";
|
||||||
import { maybeFly, maybeFlyIf } from "../animations";
|
import { maybeFly, maybeFlyIf } from "../animations";
|
||||||
import { avatarUrl } from "../storage";
|
import { avatarUrl } from "../storage";
|
||||||
import { gatewayStatus, overlayStore, selectedChannel, showSidebar, smallViewport, userInfoStore, unreadStore, OverlayType, communities, selectedCommunity, filteredChannelsStore } from "../stores";
|
import { channels, gatewayStatus, overlayStore, selectedChannel, showSidebar, smallViewport, userInfoStore, unreadStore, OverlayType, communities, selectedCommunity, filteredChannelsStore } from "../stores";
|
||||||
|
import AddCommunity from "./overlays/AddCommunity.svelte";
|
||||||
|
import UserView from "./UserView.svelte";
|
||||||
|
|
||||||
const selectChannel = (channel) => {
|
const selectChannel = (channel) => {
|
||||||
if ($smallViewport) {
|
if ($smallViewport) {
|
||||||
|
|
|
@ -8,7 +8,7 @@
|
||||||
width: 52px;
|
width: 52px;
|
||||||
height: 26px;
|
height: 26px;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
margin: 2px;
|
margin: 4px;
|
||||||
background-color: var(--background-color-3);
|
background-color: var(--background-color-3);
|
||||||
transition: 0.15s;
|
transition: 0.15s;
|
||||||
contain: strict;
|
contain: strict;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
<script>
|
<script>
|
||||||
import { overlayStore, userInfoStore, smallViewport, theme, doAnimations, OverlayType, sendTypingUpdatesItemStore } from "../../stores";
|
import { overlayStore, userInfoStore, smallViewport, theme, doAnimations, OverlayType, sendTypingUpdatesItemStore } from "../../stores";
|
||||||
import { logOut } from "../../auth";
|
import { logOut } from "../../auth";
|
||||||
|
import { maybeModalFade, maybeModalScale } from "../../animations";
|
||||||
import request, { getErrorFromResponse, methods, remoteBlobUpload, responseOk } from "../../request";
|
import request, { getErrorFromResponse, methods, remoteBlobUpload, responseOk } from "../../request";
|
||||||
import { apiRoute, getItem } from "../../storage";
|
import { apiRoute, getItem } from "../../storage";
|
||||||
import UserView from "../UserView.svelte";
|
import UserView from "../UserView.svelte";
|
||||||
|
|
|
@ -194,10 +194,10 @@ export default {
|
||||||
},
|
},
|
||||||
subscribe(event, handler) {
|
subscribe(event, handler) {
|
||||||
if (!this.handlers.get(event)) {
|
if (!this.handlers.get(event)) {
|
||||||
this.handlers.set(event, []);
|
this.handlers.set(event, new Set());
|
||||||
}
|
}
|
||||||
|
|
||||||
this.handlers.get(event).push(handler);
|
this.handlers.get(event).add(handler);
|
||||||
return handler; // can later be used for unsubscribe()
|
return handler; // can later be used for unsubscribe()
|
||||||
},
|
},
|
||||||
unsubscribe(event, handler) {
|
unsubscribe(event, handler) {
|
||||||
|
@ -205,12 +205,9 @@ export default {
|
||||||
if (!eventHandlers)
|
if (!eventHandlers)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
const index = eventHandlers.indexOf(handler);
|
eventHandlers.delete(handler);
|
||||||
if (index !== -1) {
|
|
||||||
eventHandlers.splice(index, 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (eventHandlers.length < 1) {
|
if (eventHandlers.size < 1) {
|
||||||
this.handlers.delete(event);
|
this.handlers.delete(event);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -27,7 +27,6 @@ export const methods = {
|
||||||
getCommunity: withCacheable(method(403, true)),
|
getCommunity: withCacheable(method(403, true)),
|
||||||
getCommunities: withCacheable(method(404, true)),
|
getCommunities: withCacheable(method(404, true)),
|
||||||
getCommunityChannels: withCacheable(method(405, true)),
|
getCommunityChannels: withCacheable(method(405, true)),
|
||||||
createMessageAttachment: method(500, true),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const RPCError = {
|
export const RPCError = {
|
||||||
|
@ -65,7 +64,7 @@ export const RequestStatusToMessage = {
|
||||||
export function getErrorFromResponse(response) {
|
export function getErrorFromResponse(response) {
|
||||||
if (!response) return;
|
if (!response) return;
|
||||||
if (response.status === RequestStatus.OK) return;
|
if (response.status === RequestStatus.OK) return;
|
||||||
console.error("Got error response", response);
|
console.log(response);
|
||||||
|
|
||||||
let message = RequestStatusToMessage[response.status];
|
let message = RequestStatusToMessage[response.status];
|
||||||
if (!message) message = "Something went wrong (unknown request error)";
|
if (!message) message = "Something went wrong (unknown request error)";
|
||||||
|
@ -193,8 +192,8 @@ export async function remoteSignal(method, ...args) {
|
||||||
}, ...args);
|
}, ...args);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function remoteBlobUpload({methodId, requiresAuthentication, _isSignal=false}, blob, args=[]) {
|
export async function remoteBlobUpload({methodId, requiresAuthentication, _isSignal=false}, blob) {
|
||||||
const calls = [[methodId, ...args, [0, blob.size]]];
|
const calls = [[methodId, [0, blob.size]]];
|
||||||
|
|
||||||
if (requiresAuthentication && gateway.authenticated) {
|
if (requiresAuthentication && gateway.authenticated) {
|
||||||
const replies = await gateway.sendRPCRequest(calls, _isSignal, blob);
|
const replies = await gateway.sendRPCRequest(calls, _isSignal, blob);
|
||||||
|
|
|
@ -2,7 +2,6 @@ const defaults = {
|
||||||
"server:apiBase": `${window.location.origin || ""}/api/v1`,
|
"server:apiBase": `${window.location.origin || ""}/api/v1`,
|
||||||
"server:gatewayBase": `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/gateway`,
|
"server:gatewayBase": `${location.protocol === "https:" ? "wss" : "ws"}://${location.host}/gateway`,
|
||||||
"server:avatarsBase": `${window.location.origin || ""}/uploads/avatar`,
|
"server:avatarsBase": `${window.location.origin || ""}/uploads/avatar`,
|
||||||
"server:attachmentsBase": `${window.location.origin || ""}/uploads/attachment`,
|
|
||||||
"auth:token": "",
|
"auth:token": "",
|
||||||
"ui:doAnimations": true,
|
"ui:doAnimations": true,
|
||||||
"ui:theme": "dark",
|
"ui:theme": "dark",
|
||||||
|
@ -96,8 +95,3 @@ export function apiRoute(fragment) {
|
||||||
export function avatarUrl(avatarId, size) {
|
export function avatarUrl(avatarId, size) {
|
||||||
return `${getItem("server:avatarsBase")}/${avatarId}_${size}.webp`;
|
return `${getItem("server:avatarsBase")}/${avatarId}_${size}.webp`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function attachmentUrl(name) {
|
|
||||||
return `${getItem("server:attachmentsBase")}/${name}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
|
@ -102,6 +102,8 @@ class Store {
|
||||||
|
|
||||||
this.value = this._applyPipes(this.value);
|
this.value = this._applyPipes(this.value);
|
||||||
|
|
||||||
|
if (!this._handlers.size) return;
|
||||||
|
|
||||||
storeLog(`[Update] (${this.name}) Will queue ${this._handlers.size} handlers`, "value:", this.value, "handlers:", this._handlers);
|
storeLog(`[Update] (${this.name}) Will queue ${this._handlers.size} handlers`, "value:", this.value, "handlers:", this._handlers);
|
||||||
|
|
||||||
const isRootNode = storeCallbackQueue.length === 0;
|
const isRootNode = storeCallbackQueue.length === 0;
|
||||||
|
@ -703,24 +705,7 @@ class SelectedChannelStore extends Store {
|
||||||
|
|
||||||
this.communityIdToSelectedChannel = new Map();
|
this.communityIdToSelectedChannel = new Map();
|
||||||
|
|
||||||
const applySavedMap = () => {
|
|
||||||
if (!gateway.channels) return;
|
|
||||||
|
|
||||||
this.communityIdToSelectedChannel.clear();
|
|
||||||
|
|
||||||
const savedMap = getItem("state:selectedChannels");
|
|
||||||
for (const [communityId, channelId] of Object.entries(savedMap)) {
|
|
||||||
const channel = gateway.channels.find(c => c.id === channelId);
|
|
||||||
if (channel) {
|
|
||||||
this.communityIdToSelectedChannel.set(parseInt(communityId), channel);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.updateSavedMap();
|
|
||||||
}
|
|
||||||
|
|
||||||
filteredChannelsStore.subscribe((channels) => {
|
filteredChannelsStore.subscribe((channels) => {
|
||||||
applySavedMap();
|
|
||||||
let channel = this.communityIdToSelectedChannel.get(selectedCommunity.value.id);
|
let channel = this.communityIdToSelectedChannel.get(selectedCommunity.value.id);
|
||||||
if (!channel && channels.length) {
|
if (!channel && channels.length) {
|
||||||
channel = channels[0];
|
channel = channels[0];
|
||||||
|
@ -729,8 +714,17 @@ class SelectedChannelStore extends Store {
|
||||||
this.updated();
|
this.updated();
|
||||||
});
|
});
|
||||||
|
|
||||||
gateway.subscribe(GatewayEventType.Ready, () => applySavedMap());
|
gateway.subscribe(GatewayEventType.Ready, ({ channels }) => {
|
||||||
|
this.communityIdToSelectedChannel.clear();
|
||||||
|
const savedMap = getItem("state:selectedChannels");
|
||||||
|
for (const [communityId, channelId] of Object.entries(savedMap)) {
|
||||||
|
const channel = channels.find(c => c.id === channelId);
|
||||||
|
if (channel) {
|
||||||
|
this.communityIdToSelectedChannel.set(parseInt(communityId), channel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.updateSavedMap();
|
||||||
|
});
|
||||||
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
|
gateway.subscribe(GatewayEventType.CommunityDelete, ({ id }) => {
|
||||||
if (this.communityIdToSelectedChannel.delete(id) && this.value && this.value.id === id) {
|
if (this.communityIdToSelectedChannel.delete(id) && this.value && this.value.id === id) {
|
||||||
this.value = noneChannel;
|
this.value = noneChannel;
|
||||||
|
@ -827,16 +821,16 @@ class SelectedCommunityStore extends Store {
|
||||||
class FilteredChannelsStore extends Store {
|
class FilteredChannelsStore extends Store {
|
||||||
constructor() {
|
constructor() {
|
||||||
super([], "FilteredChannelsStore");
|
super([], "FilteredChannelsStore");
|
||||||
channelsStore.on(() => this.update());
|
channels.on(() => this.update());
|
||||||
selectedCommunity.on(() => this.update());
|
selectedCommunity.on(() => this.update());
|
||||||
this.update();
|
this.update();
|
||||||
}
|
}
|
||||||
|
|
||||||
update() {
|
update() {
|
||||||
if (selectedCommunity.value.id === -1) {
|
if (selectedCommunity.value.id === -1) {
|
||||||
this.value = channelsStore.value.filter(n => n.community_id === null);
|
this.value = channels.value.filter(n => n.community_id === null);
|
||||||
} else {
|
} else {
|
||||||
this.value = channelsStore.value.filter(n => n.community_id === selectedCommunity.value.id);
|
this.value = channels.value.filter(n => n.community_id === selectedCommunity.value.id);
|
||||||
}
|
}
|
||||||
this.updated();
|
this.updated();
|
||||||
}
|
}
|
||||||
|
@ -844,7 +838,7 @@ class FilteredChannelsStore extends Store {
|
||||||
|
|
||||||
|
|
||||||
export const selectedCommunity = new SelectedCommunityStore();
|
export const selectedCommunity = new SelectedCommunityStore();
|
||||||
export const channelsStore = new ChannelsStore();
|
export const channels = new ChannelsStore();
|
||||||
export const filteredChannelsStore = new FilteredChannelsStore();
|
export const filteredChannelsStore = new FilteredChannelsStore();
|
||||||
export const selectedChannel = new SelectedChannelStore();
|
export const selectedChannel = new SelectedChannelStore();
|
||||||
export const showSidebar = new Store(true, "showSidebar");
|
export const showSidebar = new Store(true, "showSidebar");
|
||||||
|
@ -868,6 +862,33 @@ export const totalUnreadsStore = new Store(0, "TotalUnreadsStore");
|
||||||
export const statusBarStore = new Store(null, "statusBarStore");
|
export const statusBarStore = new Store(null, "statusBarStore");
|
||||||
|
|
||||||
export const setMessageInputEvent = new Store(null, "event:setMessageInput");
|
export const setMessageInputEvent = new Store(null, "event:setMessageInput");
|
||||||
|
export const sendMessageAction = createAction("sendMessageAction", async ({channelId, content}) => {
|
||||||
|
if (content.trim() === "" || !userInfoStore.value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
// optimistically add message to store
|
||||||
|
const optimisticMessageId = Math.floor(Math.random() * 999999);
|
||||||
|
const optimisticMessage = {
|
||||||
|
id: optimisticMessageId,
|
||||||
|
content: content,
|
||||||
|
channel_id: channelId,
|
||||||
|
author_id: userInfoStore.value.id,
|
||||||
|
author_username: userInfoStore.value.username,
|
||||||
|
created_at: Date.now().toString(),
|
||||||
|
_isPending: true
|
||||||
|
};
|
||||||
|
const messagesStoreForChannel = messagesStoreProvider.getStore(channelId);
|
||||||
|
messagesStoreForChannel.addMessage(optimisticMessage);
|
||||||
|
|
||||||
|
const res = await remoteSignal(methods.createChannelMessage, channelId, content, optimisticMessageId, null);
|
||||||
|
|
||||||
|
if (!responseOk(res)) {
|
||||||
|
messagesStoreForChannel.deleteMessage({
|
||||||
|
id: optimisticMessageId
|
||||||
|
});
|
||||||
|
overlayStore.toast(`Couldn't send message: ${getMessageFromResponse(res)}`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
export const allStores = {
|
export const allStores = {
|
||||||
selectedChannel,
|
selectedChannel,
|
||||||
|
@ -877,7 +898,7 @@ export const allStores = {
|
||||||
showChannelView,
|
showChannelView,
|
||||||
theme,
|
theme,
|
||||||
doAnimations,
|
doAnimations,
|
||||||
channels: channelsStore,
|
channels,
|
||||||
gatewayStatus,
|
gatewayStatus,
|
||||||
messagesStoreProvider,
|
messagesStoreProvider,
|
||||||
userInfoStore,
|
userInfoStore,
|
||||||
|
@ -887,6 +908,7 @@ export const allStores = {
|
||||||
unreadStore,
|
unreadStore,
|
||||||
pluginStore,
|
pluginStore,
|
||||||
setMessageInputEvent,
|
setMessageInputEvent,
|
||||||
|
sendMessageAction,
|
||||||
};
|
};
|
||||||
|
|
||||||
unreadStore.subscribe(() => {
|
unreadStore.subscribe(() => {
|
||||||
|
|
|
@ -383,7 +383,6 @@ body {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
text-decoration: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.icon-button .material-icons,
|
.icon-button .material-icons,
|
||||||
|
|
|
@ -17,7 +17,6 @@
|
||||||
"dotenv": "^16.0.1",
|
"dotenv": "^16.0.1",
|
||||||
"express": "^4.18.1",
|
"express": "^4.18.1",
|
||||||
"express-validator": "^6.14.2",
|
"express-validator": "^6.14.2",
|
||||||
"file-type": "^18.5.0",
|
|
||||||
"jsonwebtoken": "^8.5.1",
|
"jsonwebtoken": "^8.5.1",
|
||||||
"pg": "^8.8.0",
|
"pg": "^8.8.0",
|
||||||
"sharp": "^0.31.3",
|
"sharp": "^0.31.3",
|
||||||
|
|
10
src/auth.ts
10
src/auth.ts
|
@ -2,13 +2,21 @@ import e, { NextFunction, Request, Response } from "express";
|
||||||
import { sign, verify } from "jsonwebtoken";
|
import { sign, verify } from "jsonwebtoken";
|
||||||
import { query } from "./database";
|
import { query } from "./database";
|
||||||
import { errors } from "./errors";
|
import { errors } from "./errors";
|
||||||
import serverConfig, { jwtSecret } from "./serverconfig";
|
import serverConfig from "./serverconfig";
|
||||||
import { compare } from "bcrypt";
|
import { compare } from "bcrypt";
|
||||||
|
|
||||||
|
const jwtSecret = process.env.JWT_SECRET || "[generic token]";
|
||||||
|
|
||||||
const tokenTypes = {
|
const tokenTypes = {
|
||||||
BEARER: 1
|
BEARER: 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (jwtSecret === "[generic token]") {
|
||||||
|
console.error("ERROR: No JWT_SECRET environment variable was specified.");
|
||||||
|
console.error("ERROR: exiting...");
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
export function getUserPermissions(user: User) {
|
export function getUserPermissions(user: User) {
|
||||||
return {
|
return {
|
||||||
create_channel: serverConfig.superuserRequirement.createChannel ? user.is_superuser : true
|
create_channel: serverConfig.superuserRequirement.createChannel ? user.is_superuser : true
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import { query } from ".";
|
import { query, withClient } from ".";
|
||||||
|
|
||||||
export default async function databaseInit() {
|
export default async function databaseInit() {
|
||||||
const migrations = [
|
const migrations = [
|
||||||
|
@ -43,32 +43,7 @@ export default async function databaseInit() {
|
||||||
`,
|
`,
|
||||||
`
|
`
|
||||||
ALTER TABLE channels ADD COLUMN IF NOT EXISTS community_id INT NULL REFERENCES communities(id) ON DELETE CASCADE;
|
ALTER TABLE channels ADD COLUMN IF NOT EXISTS community_id INT NULL REFERENCES communities(id) ON DELETE CASCADE;
|
||||||
`,
|
|
||||||
`
|
`
|
||||||
CREATE TABLE IF NOT EXISTS message_attachments(
|
|
||||||
id SERIAL PRIMARY KEY,
|
|
||||||
type VARCHAR(64) NOT NULL,
|
|
||||||
owner_id SERIAL REFERENCES users ON DELETE CASCADE,
|
|
||||||
message_id SERIAL REFERENCES messages ON DELETE CASCADE,
|
|
||||||
created_at BIGINT,
|
|
||||||
|
|
||||||
file VARCHAR(256) DEFAULT NULL,
|
|
||||||
file_mime VARCHAR(256) DEFAULT NULL,
|
|
||||||
file_size_bytes BIGINT DEFAULT NULL
|
|
||||||
);
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS pending_attachments INT DEFAULT NULL;
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE message_attachments ADD COLUMN IF NOT EXISTS width INT DEFAULT NULL;
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE message_attachments ADD COLUMN IF NOT EXISTS height INT DEFAULT NULL;
|
|
||||||
`,
|
|
||||||
`
|
|
||||||
ALTER TABLE message_attachments ADD COLUMN IF NOT EXISTS file_name VARCHAR(256) DEFAULT NULL;
|
|
||||||
`,
|
|
||||||
];
|
];
|
||||||
|
|
||||||
for (let i = 0; i < migrations.length; i++) {
|
for (let i = 0; i < migrations.length; i++) {
|
||||||
|
|
|
@ -1,41 +1,4 @@
|
||||||
export const getMessageById = `
|
export const getMessageById = "SELECT messages.id, messages.content, messages.channel_id, messages.created_at, messages.author_id, messages.nick_username, users.avatar AS author_avatar, users.username AS author_username FROM messages JOIN users ON messages.author_id = users.id WHERE messages.id = $1";
|
||||||
SELECT m.id, m.content, m.channel_id, m.created_at, m.author_id, m.nick_username, m.pending_attachments, u.avatar AS author_avatar, u.username AS author_username,
|
export const getMessagesByChannelFirstPage = (limit: number) => `SELECT messages.id, messages.content, messages.created_at, messages.author_id, messages.nick_username, users.avatar AS author_avatar, users.username AS author_username FROM messages JOIN users ON messages.author_id = users.id WHERE messages.channel_id = $1 ORDER BY id DESC LIMIT ${limit}`;
|
||||||
(SELECT jsonb_agg(to_jsonb(ma)) FROM message_attachments ma WHERE ma.message_id = m.id) AS attachments
|
export const getMessagesByChannelPage = (limit: number) => `SELECT messages.id, messages.content, messages.created_at, messages.author_id, messages.nick_username, users.avatar AS author_avatar, users.username AS author_username FROM messages JOIN users ON messages.author_id = users.id WHERE messages.id < $1 AND messages.channel_id = $2 ORDER BY id DESC LIMIT ${limit}`;
|
||||||
FROM messages m
|
export const getMessagesByChannelAfterPage = (limit: number) => `SELECT messages.id, messages.content, messages.created_at, messages.author_id, messages.nick_username, users.avatar AS author_avatar, users.username AS author_username FROM messages JOIN users ON messages.author_id = users.id WHERE messages.id > $1 AND messages.channel_id = $2 ORDER BY id DESC LIMIT ${limit}`;
|
||||||
JOIN users u ON m.author_id = u.id
|
|
||||||
WHERE m.id = $1
|
|
||||||
GROUP BY m.id, u.avatar, u.username;
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const getMessagesByChannelFirstPage = (limit: number) => `
|
|
||||||
SELECT m.id, m.content, m.channel_id, m.created_at, m.author_id, m.nick_username, m.pending_attachments, u.avatar AS author_avatar, u.username AS author_username,
|
|
||||||
(SELECT jsonb_agg(to_jsonb(ma)) FROM message_attachments ma WHERE ma.message_id = m.id) AS attachments
|
|
||||||
FROM messages m
|
|
||||||
JOIN users u ON m.author_id = u.id
|
|
||||||
WHERE m.channel_id = $1
|
|
||||||
GROUP BY m.id, u.avatar, u.username
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT ${limit};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const getMessagesByChannelPage = (limit: number) => `
|
|
||||||
SELECT m.id, m.content, m.channel_id, m.created_at, m.author_id, m.nick_username, m.pending_attachments, u.avatar AS author_avatar, u.username AS author_username,
|
|
||||||
(SELECT jsonb_agg(to_jsonb(ma)) FROM message_attachments ma WHERE ma.message_id = m.id) AS attachments
|
|
||||||
FROM messages m
|
|
||||||
JOIN users u ON m.author_id = u.id
|
|
||||||
WHERE m.id < $1 AND m.channel_id = $2
|
|
||||||
GROUP BY m.id, u.avatar, u.username
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT ${limit};
|
|
||||||
`;
|
|
||||||
|
|
||||||
export const getMessagesByChannelAfterPage = (limit: number) => `
|
|
||||||
SELECT m.id, m.content, m.channel_id, m.created_at, m.author_id, m.nick_username, m.pending_attachments, u.avatar AS author_avatar, u.username AS author_username,
|
|
||||||
(SELECT jsonb_agg(to_jsonb(ma)) FROM message_attachments ma WHERE ma.message_id = m.id) AS attachments
|
|
||||||
FROM messages m
|
|
||||||
JOIN users u ON m.author_id = u.id
|
|
||||||
WHERE m.id > $1 AND m.channel_id = $2
|
|
||||||
GROUP BY m.id, u.avatar, u.username
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT ${limit};
|
|
||||||
`;
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Server } from "node:http";
|
import { Server } from "node:http";
|
||||||
import { performance } from "node:perf_hooks";
|
import { performance } from "node:perf_hooks";
|
||||||
import WebSocket, { WebSocketServer } from "ws";
|
import { WebSocketServer, WebSocket } from "ws";
|
||||||
import { decodeTokenOrNull, getPublicUserObject } from "../auth";
|
import { decodeTokenOrNull, getPublicUserObject } from "../auth";
|
||||||
import { query } from "../database";
|
import { query } from "../database";
|
||||||
import { gatewayErrors } from "../errors";
|
import { gatewayErrors } from "../errors";
|
||||||
|
@ -15,21 +15,16 @@ const GATEWAY_PING_INTERVAL = 40000;
|
||||||
const MAX_CLIENT_MESSAGES_PER_BATCH = 30; // TODO: how well does this work for weak connections?
|
const MAX_CLIENT_MESSAGES_PER_BATCH = 30; // TODO: how well does this work for weak connections?
|
||||||
const MAX_GATEWAY_SESSIONS_PER_USER = 5;
|
const MAX_GATEWAY_SESSIONS_PER_USER = 5;
|
||||||
|
|
||||||
|
|
||||||
// mapping between a dispatch id and a websocket client
|
// mapping between a dispatch id and a websocket client
|
||||||
const dispatchChannels = new Map<string, Set<GatewayClient>>();
|
const dispatchChannels = new Map<string, Set<WebSocket>>();
|
||||||
|
|
||||||
// mapping between a user id and the websocket sessions it has
|
// mapping between a user id and the websocket sessions it has
|
||||||
const sessionsByUserId = new Map<number, GatewayClient[]>();
|
const sessionsByUserId = new Map<number, WebSocket[]>();
|
||||||
|
|
||||||
// mapping between a dispatch id and a temporary handler
|
// mapping between a dispatch id and a temporary handler
|
||||||
const dispatchTemporary = new Map<string, Set<(payload: GatewayPayload) => void>>();
|
const dispatchTemporary = new Map<string, Set<(payload: GatewayPayload) => void>>();
|
||||||
|
|
||||||
// all clients
|
export function handle(channels: string[], handler: (payload: GatewayPayload) => void): (() => any) {
|
||||||
const gatewayClients = new Set<GatewayClient>();
|
|
||||||
|
|
||||||
|
|
||||||
function handle(channels: string[], handler: (payload: GatewayPayload) => void): (() => any) {
|
|
||||||
channels.forEach(c => {
|
channels.forEach(c => {
|
||||||
if (!dispatchTemporary.get(c)) {
|
if (!dispatchTemporary.get(c)) {
|
||||||
dispatchTemporary.set(c, new Set());
|
dispatchTemporary.set(c, new Set());
|
||||||
|
@ -69,17 +64,65 @@ export function waitForEvent(channels: string[], timeout: number) {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatch(channel: string, message: GatewayPayload | ((ws: GatewayClient | null) => GatewayPayload)) {
|
function clientSubscribe(ws: WebSocket, dispatchChannel: string) {
|
||||||
|
ws.state.dispatchChannels.add(dispatchChannel);
|
||||||
|
if (!dispatchChannels.get(dispatchChannel)) {
|
||||||
|
dispatchChannels.set(dispatchChannel, new Set());
|
||||||
|
}
|
||||||
|
|
||||||
|
dispatchChannels.get(dispatchChannel)?.add(ws);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientUnsubscribe(ws: WebSocket, dispatchChannel: string) {
|
||||||
|
if (!ws.state) return;
|
||||||
|
|
||||||
|
ws.state.dispatchChannels.delete(dispatchChannel);
|
||||||
|
|
||||||
|
const set = dispatchChannels.get(dispatchChannel);
|
||||||
|
if (!set) return;
|
||||||
|
|
||||||
|
set.delete(ws);
|
||||||
|
if (set.size < 1) {
|
||||||
|
dispatchChannels.delete(dispatchChannel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatchChannelSubscribe(target: string, dispatchChannel: string) {
|
||||||
|
const set = dispatchChannels.get(target);
|
||||||
|
if (!set) return;
|
||||||
|
|
||||||
|
set.forEach(c => {
|
||||||
|
clientSubscribe(c, dispatchChannel);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clientUnsubscribeAll(ws: WebSocket) {
|
||||||
|
if (!ws.state) return;
|
||||||
|
|
||||||
|
ws.state.dispatchChannels.forEach(e => {
|
||||||
|
const set = dispatchChannels.get(e);
|
||||||
|
if (!set) return;
|
||||||
|
|
||||||
|
set.delete(ws);
|
||||||
|
if (set && set.size < 1) {
|
||||||
|
dispatchChannels.delete(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.state.dispatchChannels = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dispatch(channel: string, message: GatewayPayload | ((ws: WebSocket | null) => GatewayPayload)) {
|
||||||
const members = dispatchChannels.get(channel);
|
const members = dispatchChannels.get(channel);
|
||||||
if (!members) return;
|
if (!members) return;
|
||||||
|
|
||||||
members.forEach(e => {
|
members.forEach(e => {
|
||||||
if (e.ready) {
|
if (e.state.ready) {
|
||||||
let data = message;
|
let data = message;
|
||||||
if (typeof message === "function") {
|
if (typeof message === "function") {
|
||||||
data = message(e);
|
data = message(e);
|
||||||
}
|
}
|
||||||
e.send(data);
|
e.send(JSON.stringify(data));
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -96,34 +139,14 @@ export function dispatch(channel: string, message: GatewayPayload | ((ws: Gatewa
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function dispatchChannelSubscribe(target: string, dispatchChannel: string) {
|
function closeWithError(ws: WebSocket, { code, message }: { code: number, message: string }) {
|
||||||
const set = dispatchChannels.get(target);
|
return ws.close(code, message);
|
||||||
set?.forEach(c => c.subscribe(dispatchChannel));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function closeWithBadPayload(ws: WebSocket, hint: string) {
|
||||||
function getInitialPresenceEntries(): GatewayPresenceEntry[] {
|
return ws.close(gatewayErrors.BAD_PAYLOAD.code, `${gatewayErrors.BAD_PAYLOAD.message}: ${hint}`);
|
||||||
// The initial presence entries are sent right when the user connects.
|
|
||||||
// In the future, each user will have their own list of channels that they can join and leave.
|
|
||||||
// In that case, we will send the presence entries to a certain user only for the channels they're in.
|
|
||||||
|
|
||||||
const entries: GatewayPresenceEntry[] = [];
|
|
||||||
|
|
||||||
sessionsByUserId.forEach((clients: GatewayClient[], userId: number) => {
|
|
||||||
if (clients.length < 1)
|
|
||||||
return;
|
|
||||||
|
|
||||||
const firstClient = clients[0];
|
|
||||||
if (firstClient.ready && firstClient.user) {
|
|
||||||
const entry = firstClient.getPresenceEntry(GatewayPresenceStatus.Online);
|
|
||||||
if (entry) {
|
|
||||||
entries.push(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return entries;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseJsonOrNull(payload: string): any {
|
function parseJsonOrNull(payload: string): any {
|
||||||
try {
|
try {
|
||||||
return JSON.parse(payload);
|
return JSON.parse(payload);
|
||||||
|
@ -132,6 +155,8 @@ function parseJsonOrNull(payload: string): any {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The function below ensures `payload` is of the GatewayPayload
|
||||||
|
// interface payload. If it does not match, null is returned.
|
||||||
function ensureFormattedGatewayPayload(payload: any): GatewayPayload | null {
|
function ensureFormattedGatewayPayload(payload: any): GatewayPayload | null {
|
||||||
if (!payload) {
|
if (!payload) {
|
||||||
return null;
|
return null;
|
||||||
|
@ -158,305 +183,288 @@ function ensureFormattedGatewayPayload(payload: any): GatewayPayload | null {
|
||||||
return asPayload;
|
return asPayload;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function sendPayload(ws: WebSocket, payload: GatewayPayload) {
|
||||||
|
ws.send(JSON.stringify(payload));
|
||||||
|
}
|
||||||
|
|
||||||
class GatewayClient {
|
function getPresenceEntryForConnection(ws: WebSocket, status: GatewayPresenceStatus): GatewayPresenceEntry | null {
|
||||||
ws: WebSocket;
|
if (!ws.state || !ws.state.user) {
|
||||||
user?: User;
|
return null;
|
||||||
ready: boolean;
|
|
||||||
alive: boolean;
|
|
||||||
lastAliveCheck: number;
|
|
||||||
clientDispatchChannels: Set<string>;
|
|
||||||
messagesSinceLastCheck: number;
|
|
||||||
bridgesTo?: string;
|
|
||||||
privacy?: string;
|
|
||||||
terms?: string;
|
|
||||||
|
|
||||||
constructor(ws: WebSocket) {
|
|
||||||
this.ws = ws;
|
|
||||||
this.user = undefined;
|
|
||||||
this.ready = false;
|
|
||||||
this.alive = false;
|
|
||||||
this.lastAliveCheck = performance.now();
|
|
||||||
this.clientDispatchChannels = new Set();
|
|
||||||
this.messagesSinceLastCheck = 0;
|
|
||||||
this.bridgesTo = undefined;
|
|
||||||
this.privacy = undefined;
|
|
||||||
this.terms = undefined;
|
|
||||||
|
|
||||||
gatewayClients.add(this);
|
|
||||||
this.ws.on("close", this.handleClose.bind(this));
|
|
||||||
this.ws.on("message", this.handleMessage.bind(this));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
greet() {
|
const entry: GatewayPresenceEntry = {
|
||||||
this.send({
|
user: {
|
||||||
t: GatewayPayloadType.Hello,
|
id: ws.state.user.id,
|
||||||
d: {
|
username: ws.state.user.username,
|
||||||
pingInterval: GATEWAY_PING_INTERVAL
|
avatar: ws.state.user.avatar
|
||||||
}
|
},
|
||||||
});
|
status
|
||||||
|
};
|
||||||
|
if (typeof ws.state.bridgesTo === "string") {
|
||||||
|
entry.bridgesTo = ws.state.bridgesTo;
|
||||||
|
}
|
||||||
|
if (typeof ws.state.privacy === "string") {
|
||||||
|
entry.privacy = ws.state.privacy;
|
||||||
|
}
|
||||||
|
if (typeof ws.state.terms === "string") {
|
||||||
|
entry.terms = ws.state.terms;
|
||||||
}
|
}
|
||||||
|
|
||||||
subscribe(channel: string) {
|
return entry;
|
||||||
this.clientDispatchChannels.add(channel);
|
}
|
||||||
if (!dispatchChannels.get(channel)) {
|
|
||||||
dispatchChannels.set(channel, new Set());
|
|
||||||
}
|
|
||||||
|
|
||||||
dispatchChannels.get(channel)?.add(this);
|
|
||||||
}
|
|
||||||
|
|
||||||
unsubscribeAll() {
|
// The initial presence entries are sent right when the user connects.
|
||||||
this.clientDispatchChannels.forEach((channel) => {
|
// In the future, each user will have their own list of channels that they can join and leave.
|
||||||
const set = dispatchChannels.get(channel);
|
// In that case, we will send the presence entries to a certain user only for the channels they're in.
|
||||||
if (!set) return;
|
function getInitialPresenceEntries(): GatewayPresenceEntry[] {
|
||||||
|
const entries: GatewayPresenceEntry[] = [];
|
||||||
set.delete(this);
|
|
||||||
if (set && set.size < 1) {
|
|
||||||
dispatchChannels.delete(channel);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
this.clientDispatchChannels.clear();
|
|
||||||
}
|
|
||||||
|
|
||||||
send(payload: object) {
|
sessionsByUserId.forEach((wsList: WebSocket[], userId: number) => {
|
||||||
this.ws.send(JSON.stringify(payload));
|
if (wsList.length < 1)
|
||||||
}
|
return;
|
||||||
|
|
||||||
closeWithError({ code, message }: { code: number, message: string }) {
|
const firstWs = wsList[0];
|
||||||
this.ws.close(code, message);
|
if (firstWs.state.ready && firstWs.state.user) {
|
||||||
}
|
const entry = getPresenceEntryForConnection(firstWs, GatewayPresenceStatus.Online);
|
||||||
|
if (entry) {
|
||||||
closeWithBadPayload(hint: string) {
|
entries.push(entry);
|
||||||
this.ws.close(gatewayErrors.BAD_PAYLOAD.code, `${gatewayErrors.BAD_PAYLOAD.message}: ${hint}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
getPresenceEntry(status: GatewayPresenceStatus): GatewayPresenceEntry | null {
|
|
||||||
if (!this.user || !this.ready) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
id: this.user.id,
|
|
||||||
username: this.user.username,
|
|
||||||
avatar: this.user.avatar
|
|
||||||
},
|
|
||||||
status,
|
|
||||||
bridgesTo: this.bridgesTo,
|
|
||||||
privacy: this.privacy,
|
|
||||||
terms: this.terms,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
handleClose() {
|
|
||||||
gatewayClients.delete(this);
|
|
||||||
this.unsubscribeAll();
|
|
||||||
this.ready = false;
|
|
||||||
|
|
||||||
if (this.user) {
|
|
||||||
const sessions = sessionsByUserId.get(this.user.id);
|
|
||||||
if (sessions) {
|
|
||||||
const index = sessions.indexOf(this);
|
|
||||||
sessions.splice(index, 1);
|
|
||||||
if (!sessions.length) {
|
|
||||||
sessionsByUserId.delete(this.user.id);
|
|
||||||
|
|
||||||
// user no longer has any sessions, update presence
|
|
||||||
dispatch("*", {
|
|
||||||
t: GatewayPayloadType.PresenceUpdate,
|
|
||||||
d: [
|
|
||||||
this.getPresenceEntry(GatewayPresenceStatus.Offline)
|
|
||||||
]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
});
|
||||||
|
|
||||||
batchTick() {
|
return entries;
|
||||||
const now = performance.now();
|
|
||||||
if ((now - this.lastAliveCheck) >= GATEWAY_PING_INTERVAL) {
|
|
||||||
if (!this.ready) {
|
|
||||||
return this.closeWithError(gatewayErrors.AUTHENTICATION_TIMEOUT);
|
|
||||||
}
|
|
||||||
if (!this.alive) {
|
|
||||||
return this.closeWithError(gatewayErrors.NO_PING);
|
|
||||||
}
|
|
||||||
this.messagesSinceLastCheck = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async handleMessage(rawData: Buffer, isBinary: boolean) {
|
|
||||||
this.messagesSinceLastCheck++;
|
|
||||||
|
|
||||||
if (rawData.byteLength >= maxGatewayPayloadByteLength) {
|
|
||||||
return this.closeWithError(gatewayErrors.PAYLOAD_TOO_LARGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.messagesSinceLastCheck > MAX_CLIENT_MESSAGES_PER_BATCH) {
|
|
||||||
return this.closeWithError(gatewayErrors.FLOODING);
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
let stringData: string;
|
|
||||||
let binaryStream: Buffer | null = null;
|
|
||||||
if (isBinary) {
|
|
||||||
// Binary frames are used in order combine our text data (JSON) with binary data.
|
|
||||||
// This is especially useful for calling RPC methods that, for example, upload files.
|
|
||||||
// The format is: [json payload]\n[begin binary stream]
|
|
||||||
|
|
||||||
let jsonSlice;
|
|
||||||
let jsonOffset = -1;
|
|
||||||
for (let i = 0; i < maxGatewayJsonStringByteLength; i++) {
|
|
||||||
if (rawData.readUInt8(i) === 0x0A) {
|
|
||||||
// hit newline
|
|
||||||
jsonSlice = rawData.subarray(0, i);
|
|
||||||
jsonOffset = i + 1;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!jsonSlice) {
|
|
||||||
return this.closeWithBadPayload("Did not find newline to delimit JSON from binary stream. JSON payload may be too large, or newline may be missing.");
|
|
||||||
}
|
|
||||||
|
|
||||||
binaryStream = rawData.subarray(jsonOffset, rawData.byteLength);
|
|
||||||
stringData = jsonSlice.toString();
|
|
||||||
} else {
|
|
||||||
stringData = rawData.toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (stringData.length > maxGatewayJsonStringLength) {
|
|
||||||
return this.closeWithError(gatewayErrors.PAYLOAD_TOO_LARGE);
|
|
||||||
}
|
|
||||||
|
|
||||||
const payload = ensureFormattedGatewayPayload(parseJsonOrNull(stringData));
|
|
||||||
if (!payload) {
|
|
||||||
return this.closeWithBadPayload("Invalid JSON or message does not match schema");
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (payload.t) {
|
|
||||||
case GatewayPayloadType.Authenticate: {
|
|
||||||
if (this.ready) {
|
|
||||||
return this.closeWithError(gatewayErrors.ALREADY_AUTHENTICATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
const authData = payload.d;
|
|
||||||
if (typeof authData !== "object") {
|
|
||||||
return this.closeWithBadPayload("d: expected object");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof authData.token !== "string") {
|
|
||||||
return this.closeWithBadPayload("d: invalid field 'token'");
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await decodeTokenOrNull(authData.token);
|
|
||||||
if (!user) {
|
|
||||||
return this.closeWithError(gatewayErrors.BAD_AUTH);
|
|
||||||
}
|
|
||||||
|
|
||||||
let sessions = sessionsByUserId.get(user.id);
|
|
||||||
if (sessions) {
|
|
||||||
if ((sessions.length + 1) > MAX_GATEWAY_SESSIONS_PER_USER) {
|
|
||||||
return this.closeWithError(gatewayErrors.TOO_MANY_SESSIONS);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO: each user should have their own list of channels that they join
|
|
||||||
const [channels, communities] = await Promise.all([
|
|
||||||
query("SELECT id, name, owner_id, community_id FROM channels ORDER BY id ASC"),
|
|
||||||
query("SELECT id, name, owner_id, avatar, created_at FROM communities ORDER BY id ASC"),
|
|
||||||
]);
|
|
||||||
if (!channels || !communities) {
|
|
||||||
return this.closeWithError(gatewayErrors.GOT_NO_DATABASE_DATA);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!sessions) {
|
|
||||||
sessions = [];
|
|
||||||
sessionsByUserId.set(user.id, sessions);
|
|
||||||
}
|
|
||||||
sessions.push(this);
|
|
||||||
|
|
||||||
this.subscribe("*");
|
|
||||||
for (let i = 0; i < channels.rows.length; i++) {
|
|
||||||
this.subscribe(`channel:${channels.rows[i].id}`);
|
|
||||||
}
|
|
||||||
for (let i = 0; i < communities.rows.length; i++) {
|
|
||||||
this.subscribe(`community:${communities.rows[i].id}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.user = user;
|
|
||||||
|
|
||||||
// first session, notify others that we are online
|
|
||||||
if (sessions.length === 1) {
|
|
||||||
dispatch("*", {
|
|
||||||
t: GatewayPayloadType.PresenceUpdate,
|
|
||||||
d: [this.getPresenceEntry(GatewayPresenceStatus.Online)]
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
this.ready = true;
|
|
||||||
|
|
||||||
this.send({
|
|
||||||
t: GatewayPayloadType.Ready,
|
|
||||||
d: {
|
|
||||||
user: getPublicUserObject(this.user),
|
|
||||||
channels: channels.rows,
|
|
||||||
communities: communities.rows,
|
|
||||||
presence: getInitialPresenceEntries()
|
|
||||||
}
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case GatewayPayloadType.Ping: {
|
|
||||||
if (payload.d !== 0) {
|
|
||||||
return this.closeWithBadPayload("d: expected numeric '0'");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.ready) {
|
|
||||||
return this.closeWithError(gatewayErrors.NOT_AUTHENTICATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
this.alive = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case GatewayPayloadType.RPCSignal: /* through */
|
|
||||||
case GatewayPayloadType.RPCRequest: {
|
|
||||||
if (!this.ready || !this.user) {
|
|
||||||
return this.closeWithError(gatewayErrors.NOT_AUTHENTICATED);
|
|
||||||
}
|
|
||||||
|
|
||||||
// RPCSignal is like RPCRequest however it does not send RPC method output unless there is an error
|
|
||||||
processMethodBatch(this.user, payload.d, (payload.t === GatewayPayloadType.RPCSignal ? true : false), binaryStream).then((results) => {
|
|
||||||
this.send({
|
|
||||||
t: GatewayPayloadType.RPCResponse,
|
|
||||||
d: results,
|
|
||||||
s: payload.s
|
|
||||||
});
|
|
||||||
});
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return this.closeWithBadPayload("t: unknown type");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function(server: Server) {
|
export default function(server: Server) {
|
||||||
const wss = new WebSocketServer({ server });
|
const wss = new WebSocketServer({ server });
|
||||||
|
|
||||||
const batchInterval = setInterval(() => {
|
const batchInterval = setInterval(() => {
|
||||||
gatewayClients.forEach(client => client.batchTick());
|
wss.clients.forEach((e) => {
|
||||||
|
const now = performance.now();
|
||||||
|
if (e.state && (now - e.state.lastAliveCheck) >= GATEWAY_PING_INTERVAL) {
|
||||||
|
if (!e.state.ready) {
|
||||||
|
return closeWithError(e, gatewayErrors.AUTHENTICATION_TIMEOUT);
|
||||||
|
}
|
||||||
|
if (!e.state.alive) {
|
||||||
|
return closeWithError(e, gatewayErrors.NO_PING);
|
||||||
|
}
|
||||||
|
e.state.messagesSinceLastCheck = 0;
|
||||||
|
}
|
||||||
|
});
|
||||||
}, GATEWAY_BATCH_INTERVAL);
|
}, GATEWAY_BATCH_INTERVAL);
|
||||||
|
|
||||||
wss.on("close", () => {
|
wss.on("close", () => {
|
||||||
console.error("gateway: websocket server closed");
|
console.warn("gateway: websocket server closed");
|
||||||
|
console.warn("gateway: clearing batch interval due to websocket server close");
|
||||||
clearInterval(batchInterval);
|
clearInterval(batchInterval);
|
||||||
});
|
});
|
||||||
|
|
||||||
wss.on("connection", (ws) => {
|
wss.on("connection", (ws) => {
|
||||||
const client = new GatewayClient(ws);
|
ws.state = {
|
||||||
client.greet();
|
user: undefined,
|
||||||
|
alive: false,
|
||||||
|
ready: false,
|
||||||
|
lastAliveCheck: performance.now(),
|
||||||
|
dispatchChannels: new Set(),
|
||||||
|
messagesSinceLastCheck: 0,
|
||||||
|
bridgesTo: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
sendPayload(ws, {
|
||||||
|
t: GatewayPayloadType.Hello,
|
||||||
|
d: {
|
||||||
|
pingInterval: GATEWAY_PING_INTERVAL
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("close", () => {
|
||||||
|
clientUnsubscribeAll(ws);
|
||||||
|
ws.state.ready = false;
|
||||||
|
if (ws.state.user && ws.state.user.id) {
|
||||||
|
const sessions = sessionsByUserId.get(ws.state.user.id);
|
||||||
|
if (sessions) {
|
||||||
|
const index = sessions.indexOf(ws);
|
||||||
|
sessions.splice(index, 1);
|
||||||
|
if (sessions.length < 1) {
|
||||||
|
sessionsByUserId.delete(ws.state.user.id);
|
||||||
|
|
||||||
|
// user no longer has any sessions, update presence
|
||||||
|
dispatch("*", {
|
||||||
|
t: GatewayPayloadType.PresenceUpdate,
|
||||||
|
d: [getPresenceEntryForConnection(ws, GatewayPresenceStatus.Offline)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.on("message", async (rawData: Buffer, isBinary) => {
|
||||||
|
ws.state.messagesSinceLastCheck++;
|
||||||
|
if (ws.state.messagesSinceLastCheck > MAX_CLIENT_MESSAGES_PER_BATCH) {
|
||||||
|
return closeWithError(ws, gatewayErrors.FLOODING);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (rawData.byteLength >= maxGatewayPayloadByteLength) {
|
||||||
|
return closeWithError(ws, gatewayErrors.PAYLOAD_TOO_LARGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
let stringData: string;
|
||||||
|
let binaryStream: Buffer | null = null;
|
||||||
|
if (isBinary) {
|
||||||
|
// Binary frames are used in order combine our text data (JSON) with binary data.
|
||||||
|
// This is especially useful for calling RPC methods that, for example, upload files.
|
||||||
|
// The format is: [json payload]\n[begin binary stream]
|
||||||
|
|
||||||
|
let jsonSlice;
|
||||||
|
let jsonOffset = -1;
|
||||||
|
for (let i = 0; i < maxGatewayJsonStringByteLength; i++) {
|
||||||
|
if (rawData.readUInt8(i) === 0x0A) {
|
||||||
|
// hit newline
|
||||||
|
jsonSlice = rawData.subarray(0, i);
|
||||||
|
jsonOffset = i + 1;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!jsonSlice) {
|
||||||
|
return closeWithBadPayload(ws, "Did not find newline to delimit JSON from binary stream. JSON payload may be too large, or newline may be missing.");
|
||||||
|
}
|
||||||
|
|
||||||
|
binaryStream = rawData.subarray(jsonOffset, rawData.byteLength);
|
||||||
|
stringData = jsonSlice.toString();
|
||||||
|
} else {
|
||||||
|
stringData = rawData.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (stringData.length > maxGatewayJsonStringLength) {
|
||||||
|
return closeWithError(ws, gatewayErrors.PAYLOAD_TOO_LARGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = ensureFormattedGatewayPayload(parseJsonOrNull(stringData));
|
||||||
|
if (!payload) {
|
||||||
|
return closeWithBadPayload(ws, "Invalid JSON or message does not match schema");
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (payload.t) {
|
||||||
|
case GatewayPayloadType.Authenticate: {
|
||||||
|
if (ws.state.ready) {
|
||||||
|
return closeWithError(ws, gatewayErrors.ALREADY_AUTHENTICATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
const authData = payload.d;
|
||||||
|
if (typeof authData !== "object") {
|
||||||
|
return closeWithBadPayload(ws, "d: expected object");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof authData.token !== "string") {
|
||||||
|
return closeWithBadPayload(ws, "d: invalid field 'token'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof authData.bridgesTo !== "undefined" && typeof authData.bridgesTo !== "string" && authData.bridgesTo.length > 40) {
|
||||||
|
return closeWithBadPayload(ws, "d: invalid field 'bridgesTo'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof authData.privacy !== "undefined" && typeof authData.privacy !== "string" && authData.privacy.length > 200) {
|
||||||
|
return closeWithBadPayload(ws, "d: invalid field 'privacy'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof authData.terms !== "undefined" && typeof authData.terms !== "string" && authData.terms.length > 200) {
|
||||||
|
return closeWithBadPayload(ws, "d: invalid field 'terms'");
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await decodeTokenOrNull(authData.token);
|
||||||
|
if (!user) {
|
||||||
|
return closeWithError(ws, gatewayErrors.BAD_AUTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessions = sessionsByUserId.get(user.id);
|
||||||
|
if (sessions) {
|
||||||
|
if ((sessions.length + 1) > MAX_GATEWAY_SESSIONS_PER_USER) {
|
||||||
|
return closeWithError(ws, gatewayErrors.TOO_MANY_SESSIONS);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: each user should have their own list of channels that they join
|
||||||
|
const [channels, communities] = await Promise.all([
|
||||||
|
query("SELECT id, name, owner_id, community_id FROM channels ORDER BY id ASC"),
|
||||||
|
query("SELECT id, name, owner_id, avatar, created_at FROM communities ORDER BY id ASC"),
|
||||||
|
]);
|
||||||
|
if (!channels || !communities) {
|
||||||
|
return closeWithError(ws, gatewayErrors.GOT_NO_DATABASE_DATA);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sessions) {
|
||||||
|
sessions = [];
|
||||||
|
sessionsByUserId.set(user.id, sessions);
|
||||||
|
}
|
||||||
|
sessions.push(ws);
|
||||||
|
|
||||||
|
clientSubscribe(ws, "*");
|
||||||
|
for (let i = 0; i < channels.rows.length; i++) {
|
||||||
|
clientSubscribe(ws, `channel:${channels.rows[i].id}`);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < communities.rows.length; i++) {
|
||||||
|
clientSubscribe(ws, `community:${communities.rows[i].id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.state.user = user;
|
||||||
|
ws.state.bridgesTo = authData.bridgesTo;
|
||||||
|
ws.state.privacy = authData.privacy;
|
||||||
|
ws.state.terms = authData.terms;
|
||||||
|
|
||||||
|
// first session, notify others that we are online
|
||||||
|
if (sessions.length === 1) {
|
||||||
|
dispatch("*", {
|
||||||
|
t: GatewayPayloadType.PresenceUpdate,
|
||||||
|
d: [getPresenceEntryForConnection(ws, GatewayPresenceStatus.Online)]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ws.state.ready = true;
|
||||||
|
|
||||||
|
sendPayload(ws, {
|
||||||
|
t: GatewayPayloadType.Ready,
|
||||||
|
d: {
|
||||||
|
user: getPublicUserObject(ws.state.user),
|
||||||
|
channels: channels.rows,
|
||||||
|
communities: communities.rows,
|
||||||
|
presence: getInitialPresenceEntries()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GatewayPayloadType.Ping: {
|
||||||
|
if (payload.d !== 0) {
|
||||||
|
return closeWithBadPayload(ws, "d: expected numeric '0'");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ws.state.ready) {
|
||||||
|
return closeWithError(ws, gatewayErrors.NOT_AUTHENTICATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: also check session here
|
||||||
|
ws.state.alive = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case GatewayPayloadType.RPCSignal: /* through */
|
||||||
|
case GatewayPayloadType.RPCRequest: {
|
||||||
|
if (!ws.state.ready || !ws.state.user) {
|
||||||
|
return closeWithError(ws, gatewayErrors.NOT_AUTHENTICATED);
|
||||||
|
}
|
||||||
|
|
||||||
|
// RPCSignal is like RPCRequest however it does not send RPC method output unless there is an error
|
||||||
|
processMethodBatch(ws.state.user, payload.d, (payload.t === GatewayPayloadType.RPCSignal ? true : false), binaryStream).then((results) => {
|
||||||
|
sendPayload(ws, {
|
||||||
|
t: GatewayPayloadType.RPCResponse,
|
||||||
|
d: results,
|
||||||
|
s: payload.s
|
||||||
|
});
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
default: {
|
||||||
|
return closeWithBadPayload(ws, "t: unknown type");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
12
src/impl.ts
12
src/impl.ts
|
@ -2,12 +2,12 @@ import { query } from "./database";
|
||||||
import { dispatch } from "./gateway";
|
import { dispatch } from "./gateway";
|
||||||
import { GatewayPayloadType } from "./gateway/gatewaypayloadtype";
|
import { GatewayPayloadType } from "./gateway/gatewaypayloadtype";
|
||||||
|
|
||||||
export default async function sendMessage(user: User, channelId: number, optimisticId: number | null, content: string, nickUsername: string | null, pendingAttachments: number) {
|
export default async function sendMessage(user: User, channelId: number, optimisticId: number | null, content: string, nickUsername: string | null) {
|
||||||
const authorId = user.id;
|
const authorId = user.id;
|
||||||
const createdAt = Date.now().toString();
|
const createdAt = Date.now().toString();
|
||||||
|
|
||||||
const result = await query("INSERT INTO messages(content, channel_id, author_id, created_at, nick_username, pending_attachments) VALUES ($1, $2, $3, $4, $5, $6) RETURNING id", [content, channelId, authorId, createdAt, nickUsername, pendingAttachments]);
|
const result = await query("INSERT INTO messages(content, channel_id, author_id, created_at, nick_username) VALUES ($1, $2, $3, $4, $5) RETURNING id", [content, channelId, authorId, createdAt, nickUsername]);
|
||||||
if (!result || result.rowCount !== 1) {
|
if (!result || result.rowCount < 1) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -19,14 +19,12 @@ export default async function sendMessage(user: User, channelId: number, optimis
|
||||||
author_username: user.username,
|
author_username: user.username,
|
||||||
author_avatar: user.avatar,
|
author_avatar: user.avatar,
|
||||||
created_at: createdAt,
|
created_at: createdAt,
|
||||||
nick_username: nickUsername,
|
nick_username: nickUsername
|
||||||
pending_attachments: pendingAttachments,
|
|
||||||
attachments: null
|
|
||||||
};
|
};
|
||||||
|
|
||||||
dispatch(`channel:${channelId}`, (ws) => {
|
dispatch(`channel:${channelId}`, (ws) => {
|
||||||
let payload: any = returnObject;
|
let payload: any = returnObject;
|
||||||
if (ws && ws.user && ws.user.id === user.id && optimisticId) {
|
if (ws && ws.state && ws.state.user && ws.state.user.id === user.id && optimisticId) {
|
||||||
payload = {
|
payload = {
|
||||||
...payload,
|
...payload,
|
||||||
optimistic_id: optimisticId
|
optimistic_id: optimisticId
|
||||||
|
|
|
@ -4,8 +4,8 @@ import { createServer } from "node:http";
|
||||||
import databaseInit from "./database/init";
|
import databaseInit from "./database/init";
|
||||||
import gateway from "./gateway";
|
import gateway from "./gateway";
|
||||||
import server from "./server";
|
import server from "./server";
|
||||||
import { port } from "./serverconfig";
|
|
||||||
|
|
||||||
|
const port = process.env.PORT || 3000;
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
server(app);
|
server(app);
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
import express from "express";
|
import express from "express";
|
||||||
|
import { body, validationResult } from "express-validator";
|
||||||
|
import { PoolClient } from "pg";
|
||||||
import { authenticateRoute, loginAttempt } from "../../auth";
|
import { authenticateRoute, loginAttempt } from "../../auth";
|
||||||
import { query } from "../../database";
|
import { query, withClient } from "../../database";
|
||||||
import { getMessagesByChannelAfterPage, getMessagesByChannelFirstPage } from "../../database/templates";
|
import { getMessagesByChannelAfterPage, getMessagesByChannelFirstPage } from "../../database/templates";
|
||||||
import { waitForEvent } from "../../gateway";
|
import { handle, waitForEvent } from "../../gateway";
|
||||||
import cors from "cors";
|
import cors from "cors";
|
||||||
import sendMessage from "../../impl";
|
import sendMessage from "../../impl";
|
||||||
|
|
||||||
|
@ -334,7 +336,7 @@ router.put(
|
||||||
error: "Message body must be a string between 1 and 2000 characters"
|
error: "Message body must be a string between 1 and 2000 characters"
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
const message = await sendMessage(req.user, channelId, null, req.body.body, null, 0);
|
const message = await sendMessage(req.user, channelId, null, req.body.body, null);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return res.status(500).json({
|
return res.status(500).json({
|
||||||
errcode: "M_UNKNOWN",
|
errcode: "M_UNKNOWN",
|
||||||
|
|
|
@ -1,14 +0,0 @@
|
||||||
import express from "express";
|
|
||||||
import { attachmentUploadDirectory } from "../serverconfig";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.use("/avatar", express.static("uploads/avatar"));
|
|
||||||
|
|
||||||
router.get("/attachment/:id", (req, res) => {
|
|
||||||
res.download(req.params.id, req.params.id, {
|
|
||||||
root: attachmentUploadDirectory
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
|
@ -1,122 +0,0 @@
|
||||||
import { bufferSlice, method, string, uint } from "../rpc";
|
|
||||||
import { query } from "../../database";
|
|
||||||
import { errors } from "../../errors";
|
|
||||||
import { UploadTarget, getSafeUploadPath, sanitizeFilename, supportedImageMime } from "../../uploading";
|
|
||||||
import sharp from "sharp";
|
|
||||||
import { randomBytes } from "node:crypto";
|
|
||||||
import path from "node:path";
|
|
||||||
import { promises as fsPromises } from "node:fs";
|
|
||||||
import { getMessageById } from "../../database/templates";
|
|
||||||
import { uploadsMode } from "../../serverconfig";
|
|
||||||
import { dispatch } from "../../gateway";
|
|
||||||
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
|
|
||||||
|
|
||||||
const fileType = eval("import('file-type')");
|
|
||||||
|
|
||||||
method(
|
|
||||||
"createMessageAttachment",
|
|
||||||
[uint(), string(2, 128), bufferSlice()],
|
|
||||||
async (user: User, messageId: number, filenameUnsafe: string, inputBuffer: Buffer) => {
|
|
||||||
if (inputBuffer.byteLength >= 16777220) {
|
|
||||||
return { ...errors.BAD_REQUEST, detail: "Uploaded file exceeds 16MiB limit." };
|
|
||||||
}
|
|
||||||
|
|
||||||
const messageCheckResult = await query(getMessageById, [messageId]);
|
|
||||||
if (!messageCheckResult || messageCheckResult.rowCount < 1) {
|
|
||||||
return errors.NOT_FOUND;
|
|
||||||
}
|
|
||||||
if (messageCheckResult.rows[0].author_id !== user.id && !user.is_superuser) {
|
|
||||||
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
|
|
||||||
}
|
|
||||||
|
|
||||||
const randomId = randomBytes(60).toString("hex");
|
|
||||||
|
|
||||||
const safeFilename = sanitizeFilename(filenameUnsafe);
|
|
||||||
const filePath = getSafeUploadPath(UploadTarget.Attachment, `${randomId}.${safeFilename}`);
|
|
||||||
if (!filePath) {
|
|
||||||
return { ...errors.BAD_REQUEST, detail: "Invalid filename." };
|
|
||||||
}
|
|
||||||
const actualFilename = path.basename(filePath);
|
|
||||||
|
|
||||||
const filetype = await (await fileType).fileTypeFromBuffer(inputBuffer);
|
|
||||||
|
|
||||||
let bufferToUpload = inputBuffer;
|
|
||||||
let width = null;
|
|
||||||
let height = null;
|
|
||||||
|
|
||||||
if (filetype && supportedImageMime.includes(filetype.mime)) {
|
|
||||||
try {
|
|
||||||
const { data, info } = await sharp(inputBuffer, { limitInputPixels: 8000 * 8000 })
|
|
||||||
.timeout({ seconds: 5 })
|
|
||||||
.webp({ quality: 80 })
|
|
||||||
.toBuffer({ resolveWithObject: true });
|
|
||||||
|
|
||||||
bufferToUpload = data;
|
|
||||||
width = info.width;
|
|
||||||
height = info.height;
|
|
||||||
} catch (O_o) {
|
|
||||||
console.error("failed to process image attachment", O_o);
|
|
||||||
return { ...errors.INTERNAL_ERROR, detail: "An unknown error occurred while processing your image attachment." };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let fd;
|
|
||||||
try {
|
|
||||||
fd = await fsPromises.open(filePath, "wx", uploadsMode);
|
|
||||||
await fd.writeFile(bufferToUpload);
|
|
||||||
} catch (O_o) {
|
|
||||||
console.error("failed to write non-image buffer attachment", O_o);
|
|
||||||
return { ...errors.INTERNAL_ERROR, detail: "Failed to save attachment." };
|
|
||||||
} finally {
|
|
||||||
fd?.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
const attachmentObject = {
|
|
||||||
id: -1,
|
|
||||||
type: "file",
|
|
||||||
owner_id: user.id,
|
|
||||||
message_id: messageId,
|
|
||||||
created_at: Date.now().toString(),
|
|
||||||
file: actualFilename,
|
|
||||||
file_mime: filetype && filetype.mime ? filetype.mime : "application/octet-stream",
|
|
||||||
file_size_bytes: bufferToUpload.byteLength,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
file_name: safeFilename
|
|
||||||
};
|
|
||||||
|
|
||||||
const createAttachmentResult = await query(
|
|
||||||
"INSERT INTO message_attachments(type, owner_id, message_id, created_at, file, file_mime, file_size_bytes, width, height, file_name) VALUES($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) RETURNING id",
|
|
||||||
[
|
|
||||||
attachmentObject.type,
|
|
||||||
attachmentObject.owner_id,
|
|
||||||
attachmentObject.message_id,
|
|
||||||
attachmentObject.created_at,
|
|
||||||
attachmentObject.file,
|
|
||||||
attachmentObject.file_mime,
|
|
||||||
attachmentObject.file_size_bytes,
|
|
||||||
attachmentObject.width,
|
|
||||||
attachmentObject.height,
|
|
||||||
attachmentObject.file_name
|
|
||||||
]
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!createAttachmentResult || createAttachmentResult.rowCount !== 1) {
|
|
||||||
return errors.GOT_NO_DATABASE_DATA;
|
|
||||||
}
|
|
||||||
|
|
||||||
attachmentObject.id = createAttachmentResult.rows[0].id;
|
|
||||||
|
|
||||||
messageCheckResult.rows[0].attachments = [
|
|
||||||
...(messageCheckResult.rows[0].attachments ?? []),
|
|
||||||
attachmentObject
|
|
||||||
];
|
|
||||||
|
|
||||||
dispatch(`channel:${messageCheckResult.rows[0].channel_id}`, {
|
|
||||||
t: GatewayPayloadType.MessageUpdate,
|
|
||||||
d: messageCheckResult.rows[0]
|
|
||||||
});
|
|
||||||
|
|
||||||
return messageCheckResult.rows[0];
|
|
||||||
}
|
|
||||||
);
|
|
|
@ -1,3 +1,4 @@
|
||||||
|
import express from "express";
|
||||||
import { channelNameRegex, method, int, string, uint, withOptional, withRegexp } from "../rpc";
|
import { channelNameRegex, method, int, string, uint, withOptional, withRegexp } from "../rpc";
|
||||||
import { query } from "../../database";
|
import { query } from "../../database";
|
||||||
import { getMessagesByChannelFirstPage, getMessagesByChannelPage } from "../../database/templates";
|
import { getMessagesByChannelFirstPage, getMessagesByChannelPage } from "../../database/templates";
|
||||||
|
@ -110,9 +111,9 @@ method(
|
||||||
|
|
||||||
method(
|
method(
|
||||||
"createChannelMessage",
|
"createChannelMessage",
|
||||||
[uint(), string(1, 4000), withOptional(uint()), withOptional(string(1, 64)), withOptional(uint())],
|
[uint(), string(1, 4000), withOptional(uint()), withOptional(string(1, 64))],
|
||||||
async (user: User, id: number, content: string, optimistic_id: number | null, nick_username: string | null, pending_attachments: number | null) => {
|
async (user: User, id: number, content: string, optimistic_id: number | null, nick_username: string | null) => {
|
||||||
return await sendMessage(user, id, optimistic_id, content, nick_username, pending_attachments ?? 0);
|
return await sendMessage(user, id, optimistic_id, content, nick_username);
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
|
@ -4,53 +4,29 @@ import { getMessageById } from "../../database/templates";
|
||||||
import { errors } from "../../errors";
|
import { errors } from "../../errors";
|
||||||
import { dispatch } from "../../gateway";
|
import { dispatch } from "../../gateway";
|
||||||
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
|
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
|
||||||
import { unlink } from "node:fs/promises";
|
|
||||||
import path from "node:path";
|
|
||||||
import { UploadTarget, getSafeUploadPath } from "../../uploading";
|
|
||||||
|
|
||||||
method(
|
method(
|
||||||
"deleteMessage",
|
"deleteMessage",
|
||||||
[uint()],
|
[uint()],
|
||||||
async (user: User, id: number) => {
|
async (user: User, id: number) => {
|
||||||
const messageCheckResult = await query(getMessageById, [id]);
|
const permissionCheckResult = await query("SELECT author_id, channel_id FROM messages WHERE id = $1", [id]);
|
||||||
if (!messageCheckResult || messageCheckResult.rowCount < 1) {
|
if (!permissionCheckResult || permissionCheckResult.rowCount < 1) {
|
||||||
return errors.NOT_FOUND;
|
return errors.NOT_FOUND;
|
||||||
}
|
}
|
||||||
if (messageCheckResult.rows[0].author_id !== user.id && !user.is_superuser) {
|
if (permissionCheckResult.rows[0].author_id !== user.id && !user.is_superuser) {
|
||||||
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
|
return errors.FORBIDDEN_DUE_TO_MISSING_PERMISSIONS;
|
||||||
}
|
}
|
||||||
|
|
||||||
const message = messageCheckResult.rows[0];
|
|
||||||
|
|
||||||
if (message.attachments) {
|
|
||||||
for (let i = 0; i < message.attachments.length; i++) {
|
|
||||||
const attachment = message.attachments[i];
|
|
||||||
if (attachment.type === "file" && attachment.file) {
|
|
||||||
const fileInfo = getSafeUploadPath(UploadTarget.Attachment, attachment.file);
|
|
||||||
if (fileInfo) {
|
|
||||||
const resolved = path.resolve(fileInfo);
|
|
||||||
try {
|
|
||||||
await unlink(resolved);
|
|
||||||
} catch(o_O) {
|
|
||||||
console.error("Failed to unlink message attachment upon message deletion", o_O);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
console.error("Failed to unlink message attachment upon message deletion: got null from getSafeUploadPath. This should not happen.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await query("DELETE FROM messages WHERE id = $1", [id]);
|
const result = await query("DELETE FROM messages WHERE id = $1", [id]);
|
||||||
if (!result || result.rowCount < 1) {
|
if (!result || result.rowCount < 1) {
|
||||||
return errors.GOT_NO_DATABASE_DATA;
|
return errors.GOT_NO_DATABASE_DATA;
|
||||||
}
|
}
|
||||||
|
|
||||||
dispatch(`channel:${message.channel_id}`, {
|
dispatch(`channel:${permissionCheckResult.rows[0].channel_id}`, {
|
||||||
t: GatewayPayloadType.MessageDelete,
|
t: GatewayPayloadType.MessageDelete,
|
||||||
d: {
|
d: {
|
||||||
id,
|
id,
|
||||||
channel_id: message.channel_id
|
channel_id: permissionCheckResult.rows[0].channel_id
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -9,17 +9,15 @@ import { randomBytes } from "crypto";
|
||||||
import { unlink } from "fs/promises";
|
import { unlink } from "fs/promises";
|
||||||
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
|
import { GatewayPayloadType } from "../../gateway/gatewaypayloadtype";
|
||||||
import { dispatch } from "../../gateway";
|
import { dispatch } from "../../gateway";
|
||||||
import { supportedImageMime } from "../../uploading";
|
|
||||||
import { avatarUploadDirectory, disableAccountCreation, superuserKey } from "../../serverconfig";
|
|
||||||
|
|
||||||
const fileType = eval("import('file-type')");
|
|
||||||
|
|
||||||
|
const superuserKey = process.env.SUPERUSER_KEY ? hashSync(process.env.SUPERUSER_KEY, 10) : null;
|
||||||
|
const avatarUploadDirectory = process.env.AVATAR_UPLOADS_DIR ?? "./uploads/avatar";
|
||||||
|
|
||||||
methodButWarningDoesNotAuthenticate(
|
methodButWarningDoesNotAuthenticate(
|
||||||
"createUser",
|
"createUser",
|
||||||
[withRegexp(usernameRegex, string(3, 32)), string(8, 1000)],
|
[withRegexp(usernameRegex, string(3, 32)), string(8, 1000)],
|
||||||
async (username: string, password: string) => {
|
async (username: string, password: string) => {
|
||||||
if (disableAccountCreation) {
|
if (process.env.DISABLE_ACCOUNT_CREATION === "true") {
|
||||||
return errors.FEATURE_DISABLED;
|
return errors.FEATURE_DISABLED;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -85,6 +83,20 @@ method(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
const checkMagic = (buffer: Buffer, magic: number[]) => {
|
||||||
|
for (let i = 0; i < magic.length; i++) {
|
||||||
|
try {
|
||||||
|
if (buffer.readUint8(i) !== magic[i]) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
} catch(O_o) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
const profilePictureSizes = [
|
const profilePictureSizes = [
|
||||||
16, 28, 32, 64, 80, 128, 256
|
16, 28, 32, 64, 80, 128, 256
|
||||||
];
|
];
|
||||||
|
@ -98,9 +110,22 @@ method(
|
||||||
return { ...errors.BAD_REQUEST, detail: "Uploaded file exceeds 3MiB limit." };
|
return { ...errors.BAD_REQUEST, detail: "Uploaded file exceeds 3MiB limit." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const filetype = await (await fileType).fileTypeFromBuffer(buffer);
|
// TODO: maybe get rid of this entirely and give buffer directly to `sharp`?
|
||||||
if (!filetype || !supportedImageMime.includes(filetype.mime)) {
|
const supportedFormatMagic = [
|
||||||
return { ...errors.BAD_REQUEST, detail: "Unsupported file format. Supported file formats are: png, jpeg, webp, avif." };
|
[0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A], // PNG
|
||||||
|
[0xFF, 0xD8, 0xFF], // JPEG
|
||||||
|
[0x52, 0x49, 0x46, 0x46] // WebP
|
||||||
|
];
|
||||||
|
let isSupported = false;
|
||||||
|
for (let i = 0; i < supportedFormatMagic.length; i++) {
|
||||||
|
if (checkMagic(buffer, supportedFormatMagic[i])) {
|
||||||
|
isSupported = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSupported) {
|
||||||
|
return { ...errors.BAD_REQUEST, detail: "Unsupported file format. Supported file formats are: png, jpeg, webp." };
|
||||||
}
|
}
|
||||||
|
|
||||||
const avatarId = randomBytes(8).toString("hex");
|
const avatarId = randomBytes(8).toString("hex");
|
||||||
|
@ -124,7 +149,7 @@ method(
|
||||||
try {
|
try {
|
||||||
await unlink(path.resolve(path.join(avatarUploadDirectory, filenames[i])));
|
await unlink(path.resolve(path.join(avatarUploadDirectory, filenames[i])));
|
||||||
} catch(o_0) {
|
} catch(o_0) {
|
||||||
//console.error("rpc: putUserAvatar: error while removing files (upon error)", o_0);
|
console.error("rpc: putUserAvatar: error while removing files (upon error)", o_0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return errors.INTERNAL_ERROR;
|
return errors.INTERNAL_ERROR;
|
||||||
|
|
|
@ -8,8 +8,6 @@ methodGroup(300);
|
||||||
import "./apis/messages";
|
import "./apis/messages";
|
||||||
methodGroup(400);
|
methodGroup(400);
|
||||||
import "./apis/communities";
|
import "./apis/communities";
|
||||||
methodGroup(500);
|
|
||||||
import "./apis/attachments";
|
|
||||||
|
|
||||||
|
|
||||||
console.log("--- begin rpc method map ---")
|
console.log("--- begin rpc method map ---")
|
||||||
|
|
|
@ -1,16 +1,15 @@
|
||||||
import express, { Application, ErrorRequestHandler, json } from "express";
|
import express, { Application, ErrorRequestHandler, json } from "express";
|
||||||
import "./rpc";
|
import "./rpc";
|
||||||
import uploadsRouter from "./routes/uploads";
|
|
||||||
import rpcRouter from "./routes/api/v1/rpc";
|
import rpcRouter from "./routes/api/v1/rpc";
|
||||||
import matrixRouter from "./routes/matrix";
|
import matrixRouter from "./routes/matrix";
|
||||||
import { errors } from "./errors";
|
import { errors } from "./errors";
|
||||||
|
|
||||||
const ENABLE_MATRIX_LAYER = false;
|
const ENABLE_MATRIX_LAYER = true;
|
||||||
|
|
||||||
export default function(app: Application) {
|
export default function(app: Application) {
|
||||||
app.use(json());
|
app.use(json());
|
||||||
app.use("/api/v1/rpc", rpcRouter);
|
app.use("/api/v1/rpc", rpcRouter);
|
||||||
app.use("/uploads", uploadsRouter);
|
app.use("/uploads", express.static("uploads"));
|
||||||
app.use("/", express.static("frontend/public"));
|
app.use("/", express.static("frontend/public"));
|
||||||
if (ENABLE_MATRIX_LAYER) {
|
if (ENABLE_MATRIX_LAYER) {
|
||||||
app.use("/", matrixRouter);
|
app.use("/", matrixRouter);
|
||||||
|
|
|
@ -1,54 +1,10 @@
|
||||||
import { hashSync } from "bcrypt";
|
|
||||||
import { existsSync, mkdirSync, statSync } from "node:fs";
|
|
||||||
import path from "node:path";
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
superuserRequirement: {
|
superuserRequirement: {
|
||||||
createChannel: false
|
createChannel: false
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export let port = process.env.PORT ?? 3000;
|
|
||||||
if (typeof port === "string") {
|
|
||||||
port = parseInt(port);
|
|
||||||
}
|
|
||||||
if (isNaN(port) || !isFinite(port) || port <= 0) {
|
|
||||||
console.error(`provided PORT in process environment is not valid, exiting... (got '${port}')`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const jwtSecret = process.env.JWT_SECRET ?? "";
|
|
||||||
if (!jwtSecret || jwtSecret === "") {
|
|
||||||
console.error("JWT_SECRET not found in process environment, exiting...");
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const maxBufferByteLength = 8388608; // 8MiB
|
export const maxBufferByteLength = 8388608; // 8MiB
|
||||||
export const maxGatewayJsonStringLength = 4500;
|
export const maxGatewayJsonStringLength = 4500;
|
||||||
export const maxGatewayJsonStringByteLength = maxGatewayJsonStringLength * 2; // https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encodeInto#buffer_sizing
|
export const maxGatewayJsonStringByteLength = maxGatewayJsonStringLength * 2; // https://developer.mozilla.org/en-US/docs/Web/API/TextEncoder/encodeInto#buffer_sizing
|
||||||
export const maxGatewayPayloadByteLength = maxBufferByteLength + maxGatewayJsonStringByteLength + 4;
|
export const maxGatewayPayloadByteLength = maxBufferByteLength + maxGatewayJsonStringByteLength + 4;
|
||||||
|
|
||||||
export const superuserKey = process.env.SUPERUSER_KEY ? hashSync(process.env.SUPERUSER_KEY, 10) : null;
|
|
||||||
|
|
||||||
export const disableAccountCreation = (process.env.DISABLE_ACCOUNT_CREATION === "true");
|
|
||||||
|
|
||||||
export const uploadsMode = 0o600;
|
|
||||||
export const uploadsDirectoryMode = 0o700;
|
|
||||||
export const avatarUploadDirectory = path.resolve(process.env.AVATAR_UPLOADS_DIR ?? "./uploads/avatar");
|
|
||||||
export const attachmentUploadDirectory = path.resolve(process.env.ATTACHMENT_UPLOADS_DIR ?? "./uploads/attachment");
|
|
||||||
const ensureDirectory = (dir: string) => {
|
|
||||||
const stats = statSync(dir, { throwIfNoEntry: false });
|
|
||||||
if (!stats) {
|
|
||||||
mkdirSync(dir, { mode: uploadsDirectoryMode });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!stats.isDirectory()) {
|
|
||||||
throw new Error(`Expected path ${dir} to point to a directory`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const ensureDirectories = () => {
|
|
||||||
ensureDirectory(avatarUploadDirectory);
|
|
||||||
ensureDirectory(attachmentUploadDirectory);
|
|
||||||
};
|
|
||||||
ensureDirectories();
|
|
||||||
|
|
11
src/types/gatewayclientstate.d.ts
vendored
Normal file
11
src/types/gatewayclientstate.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
||||||
|
interface GatewayClientState {
|
||||||
|
user?: User;
|
||||||
|
ready: boolean,
|
||||||
|
alive: boolean,
|
||||||
|
lastAliveCheck: number,
|
||||||
|
dispatchChannels: Set<string>,
|
||||||
|
messagesSinceLastCheck: number,
|
||||||
|
bridgesTo?: string
|
||||||
|
privacy?: string,
|
||||||
|
terms?: string,
|
||||||
|
}
|
7
src/types/ws.d.ts
vendored
Normal file
7
src/types/ws.d.ts
vendored
Normal file
|
@ -0,0 +1,7 @@
|
||||||
|
import ws from 'ws';
|
||||||
|
|
||||||
|
declare module 'ws' {
|
||||||
|
export interface WebSocket extends ws {
|
||||||
|
state: GatewayClientState;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,75 +0,0 @@
|
||||||
import path from "node:path";
|
|
||||||
import { attachmentUploadDirectory, avatarUploadDirectory } from "./serverconfig";
|
|
||||||
|
|
||||||
|
|
||||||
export const supportedImageMime = [
|
|
||||||
"image/jpeg",
|
|
||||||
"image/png",
|
|
||||||
"image/webp",
|
|
||||||
"image/avif",
|
|
||||||
];
|
|
||||||
|
|
||||||
|
|
||||||
// Thanks: https://github.com/parshap/node-sanitize-filename/blob/209c39b914c8eb48ee27bcbde64b2c7822fdf3de/index.js
|
|
||||||
const replacements = [
|
|
||||||
/[\/\?<>\\:\*\|"]/g,
|
|
||||||
/[\x00-\x1f\x80-\x9f]/g,
|
|
||||||
/^\.+$/,
|
|
||||||
/^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$/i,
|
|
||||||
/[\. ]+$/,
|
|
||||||
];
|
|
||||||
export function sanitizeFilename(filename: string): string | null {
|
|
||||||
if (filename.length > 256) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
for (let i = 0; i < replacements.length; i++) {
|
|
||||||
filename.replace(replacements[i], "");
|
|
||||||
}
|
|
||||||
if (!filename.length) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum UploadTarget {
|
|
||||||
Avatar,
|
|
||||||
Attachment
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getSafeUploadPath(target: UploadTarget, filenameUnsafe: string) {
|
|
||||||
let base;
|
|
||||||
switch (target) {
|
|
||||||
case UploadTarget.Avatar: {
|
|
||||||
base = avatarUploadDirectory;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case UploadTarget.Attachment: {
|
|
||||||
base = attachmentUploadDirectory;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
default: {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (typeof base !== "string" || typeof filenameUnsafe !== "string") {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sanitized = sanitizeFilename(filenameUnsafe);
|
|
||||||
if (!sanitized) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (sanitized.indexOf('\0') !== -1 || sanitized.indexOf('%') !== -1 || sanitized.indexOf('/') !== -1 || sanitized.indexOf('..') !== -1) {
|
|
||||||
console.error("getSafeUploadPath: attempted path traversal or illegal path, this should not be possible.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const joined = path.join(base, sanitized);
|
|
||||||
if (joined.indexOf(base) !== 0) {
|
|
||||||
console.error("getSafeUploadPath: attempted path traversal or illegal path, this should not be possible.");
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return path.resolve(joined);
|
|
||||||
}
|
|
|
@ -7,7 +7,6 @@
|
||||||
"module": "commonjs",
|
"module": "commonjs",
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
"skipLibCheck": true,
|
|
||||||
"outDir": "./dist",
|
"outDir": "./dist",
|
||||||
}
|
}
|
||||||
}
|
}
|
Loading…
Reference in a new issue