untangle client and server

Our project was quite confused as to the boundaries between client and
server code.
This false sharing meant that it was quite hard to tell what was actually
sent to the client and what was uniquely scoped to either side.

Further, this meant that our compilation and build pipelines were very
confused and pulled in files they should not have.

This commit series tries to untangle the two. This also entails fixing
quite some typing issues.
It's hard to make this in sane, small, commits that still build at each
step (it's impossible, as fixing one type error / any type immediately lead
to further errors in a game of whack a mole).
So you'll get my actual progress in small commits that can each be reviewed,
however the earlier ones are in fact sometimes wrong and get cleaned up later
once the picture is a bit clearer.
This commit is contained in:
Reto Brunner 2024-04-26 01:42:29 +02:00
commit f7926267d9
101 changed files with 1370 additions and 1128 deletions

View file

@ -59,7 +59,7 @@
<script lang="ts">
import {filter as fuzzyFilter} from "fuzzy";
import {computed, defineComponent, nextTick, PropType, ref} from "vue";
import type {UserInMessage} from "../../server/models/msg";
import type {UserInMessage} from "../../shared/types/msg";
import type {ClientChan, ClientUser} from "../js/types";
import Username from "./Username.vue";
@ -104,7 +104,7 @@ export default defineComponent({
const result = filteredUsers.value;
for (const user of result) {
const mode = user.original.modes[0] || "";
const mode: string = user.original.modes[0] || "";
if (!groups[mode]) {
groups[mode] = [];

View file

@ -41,9 +41,9 @@
<script lang="ts">
import Mousetrap from "mousetrap";
import {computed, defineComponent, ref, watch} from "vue";
import {onBeforeRouteLeave, onBeforeRouteUpdate} from "vue-router";
import eventbus from "../js/eventbus";
import {ClientChan, ClientMessage, ClientLinkPreview} from "../js/types";
import {ClientChan, ClientLinkPreview} from "../js/types";
import {SharedMsg} from "../../shared/types/msg";
export default defineComponent({
name: "ImageViewer",
@ -104,9 +104,9 @@ export default defineComponent({
}
const links = channel.value.messages
.map((msg) => msg.previews)
.map((msg: SharedMsg) => msg.previews)
.flat()
.filter((preview) => preview.thumb);
.filter((preview) => preview && preview.thumb);
const currentIndex = links.indexOf(link.value);

View file

@ -150,10 +150,14 @@ export default defineComponent({
});
const messageComponent = computed(() => {
return "message-" + props.message.type;
return "message-" + (props.message.type || "invalid"); // TODO: force existence of type in sharedmsg
});
const isAction = () => {
if (!props.message.type) {
return false;
}
return typeof MessageTypes["message-" + props.message.type] !== "undefined";
};

View file

@ -79,7 +79,7 @@ import {
} from "vue";
import {useStore} from "../js/store";
import {ClientChan, ClientMessage, ClientNetwork, ClientLinkPreview} from "../js/types";
import Msg from "../../server/models/msg";
import {SharedMsg} from "../../shared/types/msg";
type CondensedMessageContainer = {
type: "condensed";
@ -242,7 +242,7 @@ export default defineComponent({
});
const shouldDisplayDateMarker = (
message: Msg | ClientMessage | CondensedMessageContainer,
message: SharedMsg | ClientMessage | CondensedMessageContainer,
id: number
) => {
const previousMessage = condensedMessages.value[id - 1];
@ -270,7 +270,7 @@ export default defineComponent({
return false;
};
const isPreviousSource = (currentMessage: ClientMessage | Msg, id: number) => {
const isPreviousSource = (currentMessage: ClientMessage | SharedMsg, id: number) => {
const previousMessage = condensedMessages.value[id - 1];
return !!(
previousMessage &&

View file

@ -26,36 +26,43 @@ export default defineComponent({
},
setup(props) {
const errorMessage = computed(() => {
// TODO: enforce chan and nick fields so that we can get rid of that
const chan = props.message.channel || "!UNKNOWN_CHAN";
const nick = props.message.nick || "!UNKNOWN_NICK";
switch (props.message.error) {
case "bad_channel_key":
return `Cannot join ${props.message.channel} - Bad channel key.`;
return `Cannot join ${chan} - Bad channel key.`;
case "banned_from_channel":
return `Cannot join ${props.message.channel} - You have been banned from the channel.`;
return `Cannot join ${chan} - You have been banned from the channel.`;
case "cannot_send_to_channel":
return `Cannot send to channel ${props.message.channel}`;
return `Cannot send to channel ${chan}`;
case "channel_is_full":
return `Cannot join ${props.message.channel} - Channel is full.`;
return `Cannot join ${chan} - Channel is full.`;
case "chanop_privs_needed":
return "Cannot perform action: You're not a channel operator.";
case "invite_only_channel":
return `Cannot join ${props.message.channel} - Channel is invite only.`;
return `Cannot join ${chan} - Channel is invite only.`;
case "no_such_nick":
return `User ${props.message.nick} hasn't logged in or does not exist.`;
return `User ${nick} hasn't logged in or does not exist.`;
case "not_on_channel":
return "Cannot perform action: You're not on the channel.";
case "password_mismatch":
return "Password mismatch.";
case "too_many_channels":
return `Cannot join ${props.message.channel} - You've already reached the maximum number of channels allowed.`;
return `Cannot join ${chan} - You've already reached the maximum number of channels allowed.`;
case "unknown_command":
return `Unknown command: ${props.message.command}`;
// TODO: not having message.command should never happen, so force existence
return `Unknown command: ${props.message.command || "!UNDEFINED_COMMAND_BUG"}`;
case "user_not_in_channel":
return `User ${props.message.nick} is not on the channel.`;
return `User ${nick} is not on the channel.`;
case "user_on_channel":
return `User ${props.message.nick} is already on the channel.`;
return `User ${nick} is already on the channel.`;
default:
if (props.message.reason) {
return `${props.message.reason} (${props.message.error})`;
return `${props.message.reason} (${
props.message.error || "!UNDEFINED_ERR"
})`;
}
return props.message.error;

View file

@ -498,6 +498,7 @@ export default defineComponent({
};
watch(
// eslint-disable-next-line
() => props.defaults?.commands,
() => {
void nextTick(() => {
@ -507,6 +508,7 @@ export default defineComponent({
);
watch(
// eslint-disable-next-line
() => props.defaults?.tls,
(isSecureChecked) => {
const ports = [6667, 6697];

View file

@ -309,8 +309,7 @@ export default defineComponent({
moveItemInArray(store.state.networks, oldIndex, newIndex);
socket.emit("sort", {
type: "networks",
socket.emit("sort:networks", {
order: store.state.networks.map((n) => n.uuid),
});
};
@ -341,9 +340,8 @@ export default defineComponent({
moveItemInArray(netChan.network.channels, oldIndex, newIndex);
socket.emit("sort", {
type: "channels",
target: netChan.network.uuid,
socket.emit("sort:channel", {
network: netChan.network.uuid,
order: netChan.network.channels.map((c) => c.id),
});
};

View file

@ -12,10 +12,10 @@
<script lang="ts">
import {computed, defineComponent, PropType} from "vue";
import {UserInMessage} from "../../server/models/msg";
import {UserInMessage} from "../../shared/types/msg";
import eventbus from "../js/eventbus";
import colorClass from "../js/helpers/colorClass";
import type {ClientChan, ClientNetwork, ClientUser} from "../js/types";
import type {ClientChan, ClientNetwork} from "../js/types";
import {useStore} from "../js/store";
type UsernameUser = Partial<UserInMessage> & {

View file

@ -106,7 +106,7 @@ import type {ClientMessage} from "../../js/types";
import {useStore} from "../../js/store";
import {useRoute, useRouter} from "vue-router";
import {switchToChannel} from "../../js/router";
import {SearchQuery} from "../../../server/plugins/messageStorage/types";
import {SearchQuery} from "../../../shared/types/storage";
export default defineComponent({
name: "SearchResults",

35
client/js/chan.ts Normal file
View file

@ -0,0 +1,35 @@
import {ClientChan, ClientMessage} from "./types";
import {SharedNetworkChan} from "../../shared/types/network";
import {SharedMsg} from "../../shared/types/msg";
export function toClientChan(shared: SharedNetworkChan): ClientChan {
const history: string[] = [""].concat(
shared.messages
.filter((m) => m.self && m.text && m.type === "message")
// TS is too stupid to see the nil guard on filter... so we monkey patch it
.map((m): string => (m.text ? m.text : ""))
.reverse()
.slice(0, 99)
);
// filter the unused vars
const {messages, totalMessages: _, ...props} = shared;
const channel: ClientChan = {
...props,
editTopic: false,
pendingMessage: "",
inputHistoryPosition: 0,
historyLoading: false,
scrolledToBottom: true,
users: [],
usersOutdated: shared.type === "channel" ? true : false,
moreHistoryAvailable: shared.totalMessages > shared.messages.length,
inputHistory: history,
messages: sharedMsgToClientMsg(messages),
};
return channel;
}
function sharedMsgToClientMsg(shared: SharedMsg[]): ClientMessage[] {
// TODO: this is a stub for now, we will want to populate client specific stuff here
return shared;
}

View file

@ -11,7 +11,7 @@ function input() {
for (const message of store.state.activeChannel.channel.messages) {
let toggled = false;
for (const preview of message.previews) {
for (const preview of message.previews || []) {
if (preview.shown) {
preview.shown = false;
toggled = true;

View file

@ -11,7 +11,7 @@ function input() {
for (const message of store.state.activeChannel.channel.messages) {
let toggled = false;
for (const preview of message.previews) {
for (const preview of message.previews || []) {
if (!preview.shown) {
preview.shown = true;
toggled = true;

View file

@ -1,10 +1,11 @@
import {nextTick} from "vue";
import socket from "../socket";
import storage from "../localStorage";
import {toClientChan} from "../chan";
import {router, switchToChannel, navigate} from "../router";
import {store} from "../store";
import parseIrcUri from "../helpers/parseIrcUri";
import {ClientNetwork, InitClientChan} from "../types";
import {ClientNetwork, ClientChan} from "../types";
import {SharedNetwork, SharedNetworkChan} from "../../../shared/types/network";
socket.on("init", async function (data) {
store.commit("networks", mergeNetworkData(data.networks));
@ -31,54 +32,54 @@ socket.on("init", async function (data) {
window.g_TheLoungeRemoveLoading();
}
const handledQuery = await handleQueryParams();
if (await handleQueryParams()) {
// If we handled query parameters like irc:// links or just general
// connect parameters in public mode, then nothing to do here
return;
}
// If we handled query parameters like irc:// links or just general
// connect parameters in public mode, then nothing to do here
if (!handledQuery) {
// If we are on an unknown route or still on SignIn component
// then we can open last known channel on server, or Connect window if none
if (
!router.currentRoute?.value?.name ||
router.currentRoute?.value?.name === "SignIn"
) {
const channel = store.getters.findChannel(data.active);
// If we are on an unknown route or still on SignIn component
// then we can open last known channel on server, or Connect window if none
if (!router.currentRoute?.value?.name || router.currentRoute?.value?.name === "SignIn") {
const channel = store.getters.findChannel(data.active);
if (channel) {
switchToChannel(channel.channel);
} else if (store.state.networks.length > 0) {
// Server is telling us to open a channel that does not exist
// For example, it can be unset if you first open the page after server start
switchToChannel(store.state.networks[0].channels[0]);
} else {
await navigate("Connect");
}
if (channel) {
switchToChannel(channel.channel);
} else if (store.state.networks.length > 0) {
// Server is telling us to open a channel that does not exist
// For example, it can be unset if you first open the page after server start
switchToChannel(store.state.networks[0].channels[0]);
} else {
await navigate("Connect");
}
}
}
});
function mergeNetworkData(newNetworks: ClientNetwork[]) {
function mergeNetworkData(newNetworks: SharedNetwork[]): ClientNetwork[] {
const stored = storage.get("thelounge.networks.collapsed");
const collapsedNetworks = stored ? new Set(JSON.parse(stored)) : new Set();
const result: ReturnType<typeof mergeNetworkData> = [];
for (let n = 0; n < newNetworks.length; n++) {
const network = newNetworks[n];
const currentNetwork = store.getters.findNetwork(network.uuid);
for (const sharedNet of newNetworks) {
const currentNetwork = store.getters.findNetwork(sharedNet.uuid);
// If this network is new, set some default variables and initalize channel variables
if (!currentNetwork) {
network.isJoinChannelShown = false;
network.isCollapsed = collapsedNetworks.has(network.uuid);
network.channels.forEach(store.getters.initChannel);
const newNet: ClientNetwork = {
...sharedNet,
channels: sharedNet.channels.map(toClientChan),
isJoinChannelShown: false,
isCollapsed: collapsedNetworks.has(sharedNet.uuid),
};
result.push(newNet);
continue;
}
// Merge received network object into existing network object on the client
// so the object reference stays the same (e.g. for currentChannel state)
for (const key in network) {
if (!Object.prototype.hasOwnProperty.call(network, key)) {
for (const key in sharedNet) {
if (!Object.prototype.hasOwnProperty.call(sharedNet, key)) {
continue;
}
@ -86,81 +87,82 @@ function mergeNetworkData(newNetworks: ClientNetwork[]) {
if (key === "channels") {
currentNetwork.channels = mergeChannelData(
currentNetwork.channels,
network.channels as InitClientChan[]
sharedNet.channels
);
} else {
currentNetwork[key] = network[key];
currentNetwork[key] = sharedNet[key];
}
}
newNetworks[n] = currentNetwork;
result.push(currentNetwork);
}
return newNetworks;
return result;
}
function mergeChannelData(oldChannels: InitClientChan[], newChannels: InitClientChan[]) {
for (let c = 0; c < newChannels.length; c++) {
const channel = newChannels[c];
const currentChannel = oldChannels.find((chan) => chan.id === channel.id);
function mergeChannelData(
oldChannels: ClientChan[],
newChannels: SharedNetworkChan[]
): ClientChan[] {
const result: ReturnType<typeof mergeChannelData> = [];
for (const newChannel of newChannels) {
const currentChannel = oldChannels.find((chan) => chan.id === newChannel.id);
// This is a new channel that was joined while client was disconnected, initialize it
if (!currentChannel) {
store.getters.initChannel(channel);
// This is a new channel that was joined while client was disconnected, initialize it
const current = toClientChan(newChannel);
result.push(current);
emitNamesOrMarkUsersOudated(current); // TODO: this should not carry logic like that
continue;
}
// Merge received channel object into existing currentChannel
// so the object references are exactly the same (e.g. in store.state.activeChannel)
for (const key in channel) {
if (!Object.prototype.hasOwnProperty.call(channel, key)) {
continue;
}
// Server sends an empty users array, client requests it whenever needed
if (key === "users") {
if (channel.type === "channel") {
if (
store.state.activeChannel &&
store.state.activeChannel.channel === currentChannel
) {
// For currently open channel, request the user list straight away
socket.emit("names", {
target: channel.id,
});
} else {
// For all other channels, mark the user list as outdated
// so an update will be requested whenever user switches to these channels
currentChannel.usersOutdated = true;
}
}
emitNamesOrMarkUsersOudated(currentChannel); // TODO: this should not carry logic like that
continue;
}
// Server sends total count of messages in memory, we compare it to amount of messages
// on the client, and decide whether theres more messages to load from server
if (key === "totalMessages") {
currentChannel.moreHistoryAvailable =
channel.totalMessages! > currentChannel.messages.length;
continue;
}
// Reconnection only sends new messages, so merge it on the client
// Only concat if server sent us less than 100 messages so we don't introduce gaps
if (key === "messages" && currentChannel.messages && channel.messages.length < 100) {
currentChannel.messages = currentChannel.messages.concat(channel.messages);
} else {
currentChannel[key] = channel[key];
}
// Reconnection only sends new messages, so merge it on the client
// Only concat if server sent us less than 100 messages so we don't introduce gaps
if (currentChannel.messages && newChannel.messages.length < 100) {
currentChannel.messages = currentChannel.messages.concat(newChannel.messages);
} else {
currentChannel.messages = newChannel.messages;
}
newChannels[c] = currentChannel;
// TODO: this is copies more than what the compiler knows about
for (const key in newChannel) {
if (!Object.hasOwn(currentChannel, key)) {
continue;
}
if (key === "messages") {
// already handled
continue;
}
currentChannel[key] = newChannel[key];
}
result.push(currentChannel);
}
return newChannels;
return result;
}
function emitNamesOrMarkUsersOudated(chan: ClientChan) {
if (store.state.activeChannel && store.state.activeChannel.channel === chan) {
// For currently open channel, request the user list straight away
socket.emit("names", {
target: chan.id,
});
chan.usersOutdated = false;
return;
}
// For all other channels, mark the user list as outdated
// so an update will be requested whenever user switches to these channels
chan.usersOutdated = true;
}
async function handleQueryParams() {
@ -170,30 +172,28 @@ async function handleQueryParams() {
const params = new URLSearchParams(document.location.search);
const cleanParams = () => {
// Remove query parameters from url without reloading the page
const cleanUri = window.location.origin + window.location.pathname + window.location.hash;
window.history.replaceState({}, document.title, cleanUri);
};
if (params.has("uri")) {
// Set default connection settings from IRC protocol links
const uri = params.get("uri");
const queryParams = parseIrcUri(String(uri));
cleanParams();
removeQueryParams();
await router.push({name: "Connect", query: queryParams});
return true;
} else if (document.body.classList.contains("public") && document.location.search) {
}
if (document.body.classList.contains("public") && document.location.search) {
// Set default connection settings from url params
const queryParams = Object.fromEntries(params.entries());
cleanParams();
removeQueryParams();
await router.push({name: "Connect", query: queryParams});
return true;
}
return false;
}
// Remove query parameters from url without reloading the page
function removeQueryParams() {
const cleanUri = window.location.origin + window.location.pathname + window.location.hash;
window.history.replaceState(null, "", cleanUri);
}

View file

@ -1,17 +1,18 @@
import socket from "../socket";
import {store} from "../store";
import {switchToChannel} from "../router";
import {ClientChan} from "../types";
import {toClientChan} from "../chan";
socket.on("join", function (data) {
store.getters.initChannel(data.chan);
const network = store.getters.findNetwork(data.network);
if (!network) {
return;
}
network.channels.splice(data.index || -1, 0, data.chan);
const clientChan: ClientChan = toClientChan(data.chan);
network.channels.splice(data.index || -1, 0, clientChan);
// Queries do not automatically focus, unless the user did a whois
if (data.chan.type === "query" && !data.shouldOpen) {

View file

@ -1,7 +1,17 @@
import socket from "../socket";
import {store} from "../store";
import {ClientMention} from "../types";
import {SharedMention} from "../../../shared/types/mention";
socket.on("mentions:list", function (data) {
store.commit("mentions", data as ClientMention[]);
store.commit("mentions", data.map(sharedToClientMention));
});
function sharedToClientMention(shared: SharedMention): ClientMention {
const mention: ClientMention = {
...shared,
localetime: "", // TODO: can't be right
channel: null,
};
return mention;
}

View file

@ -2,7 +2,7 @@ import {nextTick} from "vue";
import socket from "../socket";
import {store} from "../store";
import {ClientMessage} from "../types";
import type {ClientChan, ClientMessage} from "../types";
socket.on("more", async (data) => {
const channel = store.getters.findChannel(data.chan)?.channel;
@ -14,13 +14,15 @@ socket.on("more", async (data) => {
channel.inputHistory = channel.inputHistory.concat(
data.messages
.filter((m) => m.self && m.text && m.type === "message")
.map((m) => m.text)
// TS is too stupid to see the guard in .filter(), so we monkey patch it
// to please the compiler
.map((m) => (m.text ? m.text : ""))
.reverse()
.slice(0, 100 - channel.inputHistory.length)
);
channel.moreHistoryAvailable =
data.totalMessages > channel.messages.length + data.messages.length;
channel.messages.unshift(...(data.messages as ClientMessage[]));
channel.messages.unshift(...data.messages);
await nextTick();
channel.historyLoading = false;

View file

@ -3,7 +3,8 @@ import socket from "../socket";
import {cleanIrcMessage} from "../../../shared/irc";
import {store} from "../store";
import {switchToChannel} from "../router";
import {ClientChan, ClientMention, ClientMessage, NetChan} from "../types";
import {ClientChan, NetChan, ClientMessage} from "../types";
import {SharedMsg} from "../../../shared/types/msg";
let pop;
@ -95,6 +96,14 @@ socket.on("msg", function (data) {
}
});
declare global {
// this extends the interface from lib.dom with additional stuff which is not
// exactly standard but implemented in some browsers
interface NotificationOptions {
timestamp?: number; // chrome has it, other browsers ignore it
}
}
function notifyMessage(
targetId: number,
channel: ClientChan,
@ -122,12 +131,14 @@ function notifyMessage(
) {
let title: string;
let body: string;
// TODO: fix msg type and get rid of that conditional
const nick = msg.from && msg.from.nick ? msg.from.nick : "unkonown";
if (msg.type === "invite") {
title = "New channel invite:";
body = msg.from.nick + " invited you to " + msg.channel;
body = nick + " invited you to " + msg.channel;
} else {
title = String(msg.from.nick);
title = nick;
if (channel.type !== "query") {
title += ` (${channel.name})`;
@ -137,7 +148,8 @@ function notifyMessage(
title += " says:";
}
body = cleanIrcMessage(msg.text);
// TODO: fix msg type and get rid of that conditional
body = cleanIrcMessage(msg.text ? msg.text : "");
}
const timestamp = Date.parse(String(msg.time));
@ -184,24 +196,40 @@ function notifyMessage(
}
}
function updateUserList(channel, msg) {
if (msg.type === "message" || msg.type === "action") {
const user = channel.users.find((u) => u.nick === msg.from.nick);
function updateUserList(channel: ClientChan, msg: SharedMsg) {
switch (msg.type) {
case "message": // fallthrough
if (user) {
user.lastMessage = new Date(msg.time).getTime() || Date.now();
case "action": {
const user = channel.users.find((u) => u.nick === msg.from?.nick);
if (user) {
user.lastMessage = new Date(msg.time).getTime() || Date.now();
}
break;
}
} else if (msg.type === "quit" || msg.type === "part") {
const idx = channel.users.findIndex((u) => u.nick === msg.from.nick);
if (idx > -1) {
channel.users.splice(idx, 1);
case "quit": // fallthrough
case "part": {
const idx = channel.users.findIndex((u) => u.nick === msg.from?.nick);
if (idx > -1) {
channel.users.splice(idx, 1);
}
break;
}
} else if (msg.type === "kick") {
const idx = channel.users.findIndex((u) => u.nick === msg.target.nick);
if (idx > -1) {
channel.users.splice(idx, 1);
case "kick": {
const idx = channel.users.findIndex((u) => u.nick === msg.target?.nick);
if (idx > -1) {
channel.users.splice(idx, 1);
}
break;
}
}
}

View file

@ -5,7 +5,7 @@ socket.on("msg:preview", function (data) {
const netChan = store.getters.findChannel(data.chan);
const message = netChan?.channel.messages.find((m) => m.id === data.id);
if (!message) {
if (!message || !message.previews) {
return;
}

View file

@ -1,13 +1,17 @@
import socket from "../socket";
import {store} from "../store";
import {switchToChannel} from "../router";
import {toClientChan} from "../chan";
import {ClientNetwork} from "../types";
import {ChanState} from "../../../shared/types/chan";
socket.on("network", function (data) {
const network = data.networks[0];
network.isJoinChannelShown = false;
network.isCollapsed = false;
network.channels.forEach(store.getters.initChannel);
const network: ClientNetwork = {
...data.network,
channels: data.network.channels.map(toClientChan),
isJoinChannelShown: false,
isCollapsed: false,
};
store.commit("networks", [...store.state.networks, network]);
@ -19,7 +23,7 @@ socket.on("network:options", function (data) {
const network = store.getters.findNetwork(data.network);
if (network) {
network.serverOptions = data.serverOptions as typeof network.serverOptions;
network.serverOptions = data.serverOptions;
}
});
@ -35,8 +39,8 @@ socket.on("network:status", function (data) {
if (!data.connected) {
network.channels.forEach((channel) => {
channel.users = [];
channel.state = 0;
channel.users = []; // TODO: untangle this
channel.state = ChanState.PARTED;
});
}
});

View file

@ -1,30 +1,16 @@
import socket from "../socket";
import {store} from "../store";
socket.on("sync_sort", function (data) {
const order = data.order;
switch (data.type) {
case "networks":
store.commit(
"sortNetworks",
(a, b) => (order as string[]).indexOf(a.uuid) - (order as string[]).indexOf(b.uuid)
);
break;
case "channels": {
const network = store.getters.findNetwork(data.target);
if (!network) {
return;
}
network.channels.sort(
(a, b) => (order as number[]).indexOf(a.id) - (order as number[]).indexOf(b.id)
);
break;
}
}
socket.on("sync_sort:networks", function (data) {
store.commit("sortNetworks", (a, b) => data.order.indexOf(a.uuid) - data.order.indexOf(b.uuid));
});
socket.on("sync_sort:channels", function (data) {
const network = store.getters.findNetwork(data.network);
if (!network) {
return;
}
network.channels.sort((a, b) => data.order.indexOf(a.id) - data.order.indexOf(b.id));
});

View file

@ -1,5 +1,5 @@
import io, {Socket} from "socket.io-client";
import type {ServerToClientEvents, ClientToServerEvents} from "../../server/types/socket-events";
import type {ServerToClientEvents, ClientToServerEvents} from "../../shared/types/socket-events";
const socket: Socket<ServerToClientEvents, ClientToServerEvents> = io({
transports: JSON.parse(document.body.dataset.transports || "['polling', 'websocket']"),

View file

@ -3,19 +3,12 @@
import {ActionContext, createStore, Store, useStore as baseUseStore} from "vuex";
import {createSettingsStore} from "./store-settings";
import storage from "./localStorage";
import type {
ClientChan,
ClientConfiguration,
ClientNetwork,
InitClientChan,
NetChan,
ClientMessage,
ClientMention,
} from "./types";
import type {ClientChan, ClientNetwork, NetChan, ClientMention, ClientMessage} from "./types";
import type {InjectionKey} from "vue";
import {SettingsState} from "./settings";
import {SearchQuery} from "../../server/plugins/messageStorage/types";
import {SearchQuery} from "../../shared/types/storage";
import {SharedConfiguration, LockedSharedConfiguration} from "../../shared/types/config";
const appName = document.title;
@ -59,7 +52,7 @@ export type State = {
mentions: ClientMention[];
hasServiceWorker: boolean;
pushNotificationState: string;
serverConfiguration: ClientConfiguration | null;
serverConfiguration: SharedConfiguration | LockedSharedConfiguration | null;
sessions: ClientSession[];
sidebarOpen: boolean;
sidebarDragging: boolean;
@ -131,7 +124,6 @@ type Getters = {
findNetwork: (state: State) => (uuid: string) => ClientNetwork | null;
highlightCount(state: State): number;
title(state: State, getters: Omit<Getters, "title">): string;
initChannel: () => (channel: InitClientChan) => ClientChan;
};
// getters without the state argument
@ -202,31 +194,6 @@ const getters: Getters = {
return alertEventCount + channelname + appName;
},
initChannel: () => (channel: InitClientChan) => {
// TODO: This should be a mutation
channel.pendingMessage = "";
channel.inputHistoryPosition = 0;
channel.inputHistory = [""].concat(
channel.messages
.filter((m) => m.self && m.text && m.type === "message")
.map((m) => m.text)
.reverse()
.slice(0, 99)
);
channel.historyLoading = false;
channel.scrolledToBottom = true;
channel.editTopic = false;
channel.moreHistoryAvailable = channel.totalMessages! > channel.messages.length;
delete channel.totalMessages;
if (channel.type === "channel") {
channel.usersOutdated = true;
}
return channel as ClientChan;
},
};
type Mutations = {

36
client/js/types.d.ts vendored
View file

@ -1,12 +1,11 @@
import {defineComponent} from "vue";
import Chan from "../../server/models/chan";
import Network from "../../server/models/network";
import User from "../../server/models/user";
import Message from "../../server/models/msg";
import {Mention} from "../../server/client";
import {ClientConfiguration} from "../../server/server";
import {LinkPreview} from "../../server/plugins/irc-events/link";
import {SharedChan} from "../../shared/types/chan";
import {SharedNetwork} from "../../shared/types/network";
import {SharedUser} from "../../shared/types/user";
import {SharedMention} from "../../shared/types/mention";
import {SharedConfiguration, LockedSharedConfiguration} from "../../shared/types/config";
import {LinkPreview, SharedMsg} from "../../shared/types/msg";
interface LoungeWindow extends Window {
g_TheLoungeRemoveLoading?: () => void;
@ -16,19 +15,15 @@ interface LoungeWindow extends Window {
};
}
type ClientUser = User & {
//
};
type ClientUser = SharedUser;
type ClientMessage = Omit<Message, "users"> & {
time: number;
users: string[];
};
// we will eventually need to put client specific fields here
// which are not shared with the server
export type ClientMessage = SharedMsg;
type ClientChan = Omit<Chan, "users" | "messages"> & {
type ClientChan = Omit<SharedChan, "messages"> & {
moreHistoryAvailable: boolean;
editTopic: boolean;
users: ClientUser[];
messages: ClientMessage[];
// these are added in store/initChannel
@ -38,6 +33,8 @@ type ClientChan = Omit<Chan, "users" | "messages"> & {
historyLoading: boolean;
scrolledToBottom: boolean;
usersOutdated: boolean;
users: ClientUser[];
};
type InitClientChan = ClientChan & {
@ -46,7 +43,7 @@ type InitClientChan = ClientChan & {
};
// We omit channels so we can use ClientChan[] instead of Chan[]
type ClientNetwork = Omit<Network, "channels"> & {
type ClientNetwork = Omit<SharedNetwork, "channels"> & {
isJoinChannelShown: boolean;
isCollapsed: boolean;
channels: ClientChan[];
@ -57,9 +54,8 @@ type NetChan = {
network: ClientNetwork;
};
type ClientConfiguration = ClientConfiguration;
type ClientMention = Mention & {
localetime: string;
type ClientMention = SharedMention & {
localetime: string; // TODO: this needs to go the way of the dodo, nothing but a single component uses it
channel: NetChan | null;
};

View file

@ -7,9 +7,9 @@ import App from "../components/App.vue";
import storage from "./localStorage";
import {router} from "./router";
import socket from "./socket";
import "./socket-events"; // this sets up all socket event listeners, do not remove
import eventbus from "./eventbus";
import "./socket-events";
import "./webpush";
import "./keybinds";
import {LoungeWindow} from "./types";

View file

@ -6,40 +6,8 @@
] /* Specifies a list of glob patterns that match files to be included in compilation. If no 'files' or 'include' property is present in a tsconfig.json, the compiler defaults to including all files in the containing directory and subdirectories except those specified by 'exclude'. Requires TypeScript version 2.0 or later. */,
"files": [
"../package.json",
"../server/types/socket-events.d.ts",
"../server/helper.ts",
"../server/log.ts",
"../server/config.ts",
"../server/client.ts",
"../server/storageCleaner.ts",
"../server/clientManager.ts",
"../server/identification.ts",
"../server/plugins/changelog.ts",
"../server/plugins/uploader.ts",
"../server/plugins/storage.ts",
"../server/plugins/inputs/index.ts",
"../server/plugins/messageStorage/sqlite.ts",
"../server/plugins/messageStorage/text.ts",
"../server/plugins/packages/index.ts",
"../server/plugins/packages/publicClient.ts",
"../server/plugins/packages/themes.ts",
"../server/plugins/dev-server.ts",
"../server/plugins/webpush.ts",
"../server/plugins/sts.ts",
"../server/plugins/clientCertificate.ts",
"../server/plugins/auth.ts",
"../server/plugins/auth/local.ts",
"../server/plugins/auth/ldap.ts",
"../server/plugins/irc-events/link.ts",
"../server/command-line/utils.ts",
"../server/models/network.ts",
"../server/models/user.ts",
"../server/models/msg.ts",
"../server/models/prefix.ts",
"./js/helpers/fullnamemap.json",
"./js/helpers/simplemap.json",
"../webpack.config.ts",
"../babel.config.cjs"
"./js/helpers/simplemap.json"
] /* If no 'files' or 'include' property is present in a tsconfig.json, the compiler defaults to including all files in the containing directory and subdirectories except those specified by 'exclude'. When a 'files' property is specified, only those files and those specified by 'include' are included. */,
// "exclude": [],
"compilerOptions": {

View file

@ -6,10 +6,12 @@ import crypto from "crypto";
import colors from "chalk";
import log from "./log";
import Chan, {ChanConfig, Channel, ChanType} from "./models/chan";
import Msg, {MessageType, UserInMessage} from "./models/msg";
import Chan, {ChanConfig} from "./models/chan";
import Msg from "./models/msg";
import Config from "./config";
import {condensedTypes} from "../shared/irc";
import {MessageType} from "../shared/types/msg";
import {SharedMention} from "../shared/types/mention";
import inputs from "./plugins/inputs";
import PublicClient from "./plugins/packages/publicClient";
@ -17,11 +19,12 @@ import SqliteMessageStorage from "./plugins/messageStorage/sqlite";
import TextFileMessageStorage from "./plugins/messageStorage/text";
import Network, {IgnoreListItem, NetworkConfig, NetworkWithIrcFramework} from "./models/network";
import ClientManager from "./clientManager";
import {MessageStorage, SearchQuery, SearchResponse} from "./plugins/messageStorage/types";
import {MessageStorage} from "./plugins/messageStorage/types";
import {StorageCleaner} from "./storageCleaner";
type OrderItem = Chan["id"] | Network["uuid"];
type Order = OrderItem[];
import {SearchQuery, SearchResponse} from "../shared/types/storage";
import {SharedChan, ChanType} from "../shared/types/chan";
import {SharedNetwork} from "../shared/types/network";
import {ServerToClientEvents} from "../shared/types/socket-events";
const events = [
"away",
@ -82,15 +85,6 @@ export type UserConfig = {
networks?: NetworkConfig[];
};
export type Mention = {
chanId: number;
msgId: number;
type: MessageType;
time: Date;
text: string;
from: UserInMessage;
};
class Client {
awayMessage!: string;
lastActiveChannel!: number;
@ -98,12 +92,12 @@ class Client {
[socketId: string]: {token: string; openChannel: number};
};
config!: UserConfig;
id!: number;
id: string;
idMsg!: number;
idChan!: number;
name!: string;
networks!: Network[];
mentions!: Mention[];
mentions!: SharedMention[];
manager!: ClientManager;
messageStorage!: MessageStorage[];
highlightRegex!: RegExp | null;
@ -113,12 +107,12 @@ class Client {
fileHash!: string;
constructor(manager: ClientManager, name?: string, config = {} as UserConfig) {
this.id = uuidv4();
_.merge(this, {
awayMessage: "",
lastActiveChannel: -1,
attachedClients: {},
config: config,
id: uuidv4(),
idChan: 1,
idMsg: 1,
name: name,
@ -229,9 +223,12 @@ class Client {
return chan;
}
emit(event: string, data?: any) {
emit<Ev extends keyof ServerToClientEvents>(
event: Ev,
...args: Parameters<ServerToClientEvents[Ev]>
) {
if (this.manager !== null) {
this.manager.sockets.in(this.id.toString()).emit(event, data);
this.manager.sockets.in(this.id).emit(event, ...args);
}
}
@ -351,7 +348,7 @@ class Client {
client.networks.push(network);
client.emit("network", {
networks: [network.getFilteredClone(this.lastActiveChannel, -1)],
network: network.getFilteredClone(this.lastActiveChannel, -1),
});
if (!network.validate(client)) {
@ -697,56 +694,39 @@ class Client {
this.emit("open", targetNetChan.chan.id);
}
sort(data: {order: Order; type: "networks" | "channels"; target: string}) {
const order = data.order;
sortChannels(netid: SharedNetwork["uuid"], order: SharedChan["id"][]) {
const network = _.find(this.networks, {uuid: netid});
if (!_.isArray(order)) {
if (!network) {
return;
}
switch (data.type) {
case "networks":
this.networks.sort((a, b) => order.indexOf(a.uuid) - order.indexOf(b.uuid));
// Sync order to connected clients
this.emit("sync_sort", {
order: this.networks.map((obj) => obj.uuid),
type: data.type,
});
break;
case "channels": {
const network = _.find(this.networks, {uuid: data.target});
if (!network) {
return;
}
network.channels.sort((a, b) => {
// Always sort lobby to the top regardless of what the client has sent
// Because there's a lot of code that presumes channels[0] is the lobby
if (a.type === ChanType.LOBBY) {
return -1;
} else if (b.type === ChanType.LOBBY) {
return 1;
}
return order.indexOf(a.id) - order.indexOf(b.id);
});
// Sync order to connected clients
this.emit("sync_sort", {
order: network.channels.map((obj) => obj.id),
type: data.type,
target: network.uuid,
});
break;
network.channels.sort((a, b) => {
// Always sort lobby to the top regardless of what the client has sent
// Because there's a lot of code that presumes channels[0] is the lobby
if (a.type === ChanType.LOBBY) {
return -1;
} else if (b.type === ChanType.LOBBY) {
return 1;
}
}
return order.indexOf(a.id) - order.indexOf(b.id);
});
this.save();
// Sync order to connected clients
this.emit("sync_sort:channels", {
network: network.uuid,
order: network.channels.map((obj) => obj.id),
});
}
sortNetworks(order: SharedNetwork["uuid"][]) {
this.networks.sort((a, b) => order.indexOf(a.uuid) - order.indexOf(b.uuid));
this.save();
// Sync order to connected clients
this.emit("sync_sort:networks", {
order: this.networks.map((obj) => obj.uuid),
});
}
names(data: {target: number}) {
@ -776,7 +756,7 @@ class Client {
quit(signOut?: boolean) {
const sockets = this.manager.sockets.sockets;
const room = sockets.adapter.rooms.get(this.id.toString());
const room = sockets.adapter.rooms.get(this.id);
if (room) {
for (const user of room) {
@ -836,12 +816,13 @@ class Client {
}
// TODO: type session to this.attachedClients
registerPushSubscription(session: any, subscription: ClientPushSubscription, noSave = false) {
registerPushSubscription(session: any, subscription: PushSubscriptionJSON, noSave = false) {
if (
!_.isPlainObject(subscription) ||
!_.isPlainObject(subscription.keys) ||
typeof subscription.endpoint !== "string" ||
!/^https?:\/\//.test(subscription.endpoint) ||
!_.isPlainObject(subscription.keys) ||
!subscription.keys || // TS compiler doesn't understand isPlainObject
typeof subscription.keys.p256dh !== "string" ||
typeof subscription.keys.auth !== "string"
) {

View file

@ -10,7 +10,7 @@ import Config from "./config";
import {NetworkConfig} from "./models/network";
import WebPush from "./plugins/webpush";
import log from "./log";
import {Server} from "socket.io";
import {Server} from "./server";
class ClientManager {
clients: Client[];

View file

@ -4,6 +4,7 @@ import fs, {Stats} from "fs";
import os from "os";
import _ from "lodash";
import colors from "chalk";
import {SearchOptions} from "ldapjs";
import log from "./log";
import Helper from "./helper";
@ -44,7 +45,7 @@ export type Defaults = Pick<
| "saslAccount"
| "saslPassword"
> & {
join?: string;
join: string;
};
type Identd = {
@ -57,7 +58,7 @@ type SearchDN = {
rootPassword: string;
filter: string;
base: string;
scope: string;
scope: SearchOptions["scope"];
};
type Ldap = {

View file

@ -2,36 +2,14 @@ import _ from "lodash";
import log from "../log";
import Config from "../config";
import User from "./user";
import Msg, {MessageType} from "./msg";
import Msg from "./msg";
import storage from "../plugins/storage";
import Client from "../client";
import Network from "./network";
import Prefix from "./prefix";
export enum ChanType {
CHANNEL = "channel",
LOBBY = "lobby",
QUERY = "query",
SPECIAL = "special",
}
export enum SpecialChanType {
BANLIST = "list_bans",
INVITELIST = "list_invites",
CHANNELLIST = "list_channels",
IGNORELIST = "list_ignored",
}
export enum ChanState {
PARTED = 0,
JOINED = 1,
}
// eslint-disable-next-line no-use-before-define
export type FilteredChannel = Chan & {
users: [];
totalMessages: number;
};
import {MessageType, SharedMsg} from "../../shared/types/msg";
import {ChanType, SpecialChanType, ChanState} from "../../shared/types/chan";
import {SharedNetworkChan} from "../../shared/types/network";
export type ChanConfig = {
name: string;
@ -60,7 +38,6 @@ class Chan {
data?: any;
closed?: boolean;
num_users?: number;
static optionalProperties = ["userAway", "special", "data", "closed", "num_users"];
constructor(attr?: Partial<Chan>) {
_.defaults(this, attr, {
@ -84,18 +61,11 @@ class Chan {
}
pushMessage(client: Client, msg: Msg, increasesUnread = false) {
const chan = this.id;
const obj = {chan, msg} as {
chan: number;
msg: Msg;
unread?: number;
highlight?: number;
};
const chanId = this.id;
msg.id = client.idMsg++;
// If this channel is open in any of the clients, do not increase unread counter
const isOpen = _.find(client.attachedClients, {openChannel: chan}) !== undefined;
const isOpen = _.find(client.attachedClients, {openChannel: chanId}) !== undefined;
if (msg.self) {
// reset counters/markers when receiving self-/echo-message
@ -108,15 +78,15 @@ class Chan {
}
if (increasesUnread || msg.highlight) {
obj.unread = ++this.unread;
this.unread++;
}
if (msg.highlight) {
obj.highlight = ++this.highlight;
this.highlight++;
}
}
client.emit("msg", obj);
client.emit("msg", {chan: chanId, msg, unread: this.unread, highlight: this.highlight});
// Never store messages in public mode as the session
// is completely destroyed when the page gets closed
@ -144,7 +114,8 @@ class Chan {
}
}
}
dereferencePreviews(messages) {
dereferencePreviews(messages: Msg[]) {
if (!Config.values.prefetch || !Config.values.prefetchStorage) {
return;
}
@ -160,6 +131,7 @@ class Chan {
}
});
}
getSortedUsers(irc?: Network["irc"]) {
const users = Array.from(this.users.values());
@ -182,21 +154,27 @@ class Chan {
return userModeSortPriority[a.mode] - userModeSortPriority[b.mode];
});
}
findMessage(msgId: number) {
return this.messages.find((message) => message.id === msgId);
}
findUser(nick: string) {
return this.users.get(nick.toLowerCase());
}
getUser(nick: string) {
return this.findUser(nick) || new User({nick}, new Prefix([]));
}
setUser(user: User) {
this.users.set(user.nick.toLowerCase(), user);
}
removeUser(user: User) {
this.users.delete(user.nick.toLowerCase());
}
/**
* Get a clean clone of this channel that will be sent to the client.
* This function performs manual cloning of channel object for
@ -206,38 +184,54 @@ class Chan {
* If true, channel is assumed active.
* @param {int} lastMessage - Last message id seen by active client to avoid sending duplicates.
*/
getFilteredClone(lastActiveChannel?: number | boolean, lastMessage?: number): FilteredChannel {
return Object.keys(this).reduce((newChannel, prop) => {
if (Chan.optionalProperties.includes(prop)) {
if (this[prop] !== undefined || (Array.isArray(this[prop]) && this[prop].length)) {
newChannel[prop] = this[prop];
}
} else if (prop === "users") {
// Do not send users, client requests updated user list whenever needed
newChannel[prop] = [];
} else if (prop === "messages") {
// If client is reconnecting, only send new messages that client has not seen yet
if (lastMessage && lastMessage > -1) {
// When reconnecting, always send up to 100 messages to prevent message gaps on the client
// See https://github.com/thelounge/thelounge/issues/1883
newChannel[prop] = this[prop].filter((m) => m.id > lastMessage).slice(-100);
} else {
// If channel is active, send up to 100 last messages, for all others send just 1
// Client will automatically load more messages whenever needed based on last seen messages
const messagesToSend =
lastActiveChannel === true || this.id === lastActiveChannel ? 100 : 1;
getFilteredClone(
lastActiveChannel?: number | boolean,
lastMessage?: number
): SharedNetworkChan {
let msgs: SharedMsg[];
newChannel[prop] = this[prop].slice(-messagesToSend);
}
// If client is reconnecting, only send new messages that client has not seen yet
if (lastMessage && lastMessage > -1) {
// When reconnecting, always send up to 100 messages to prevent message gaps on the client
// See https://github.com/thelounge/thelounge/issues/1883
msgs = this.messages.filter((m) => m.id > lastMessage).slice(-100);
} else {
// If channel is active, send up to 100 last messages, for all others send just 1
// Client will automatically load more messages whenever needed based on last seen messages
const messagesToSend =
lastActiveChannel === true || this.id === lastActiveChannel ? 100 : 1;
msgs = this.messages.slice(-messagesToSend);
}
(newChannel as FilteredChannel).totalMessages = this[prop].length;
} else {
newChannel[prop] = this[prop];
}
return {
id: this.id,
messages: msgs,
totalMessages: this.messages.length,
name: this.name,
key: this.key,
topic: this.topic,
firstUnread: this.firstUnread,
unread: this.unread,
highlight: this.highlight,
muted: this.muted,
type: this.type,
state: this.state,
return newChannel;
}, {}) as FilteredChannel;
special: this.special,
data: this.data,
closed: this.closed,
num_users: this.num_users,
};
// TODO: funny array mutation below might need to be reproduced
// static optionalProperties = ["userAway", "special", "data", "closed", "num_users"];
// return Object.keys(this).reduce((newChannel, prop) => {
// if (Chan.optionalProperties.includes(prop)) {
// if (this[prop] !== undefined || (Array.isArray(this[prop]) && this[prop].length)) {
// newChannel[prop] = this[prop];
// }
// }
}
writeUserLog(client: Client, msg: Msg) {
this.messages.push(msg);
@ -270,6 +264,7 @@ class Chan {
messageStorage.index(target.network, targetChannel, msg).catch((e) => log.error(e));
}
}
loadMessages(client: Client, network: Network) {
if (!this.isLoggable()) {
return;
@ -326,15 +321,23 @@ class Chan {
log.error(`Failed to load messages for ${client.name}: ${err.toString()}`)
);
}
isLoggable() {
return this.type === ChanType.CHANNEL || this.type === ChanType.QUERY;
}
setMuteStatus(muted: boolean) {
this.muted = !!muted;
}
}
function requestZncPlayback(channel, network, from) {
function requestZncPlayback(channel: Chan, network: Network, from: number) {
if (!network.irc) {
throw new Error(
`requestZncPlayback: no irc field on network "${network.name}", this is a bug`
);
}
network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString());
}

View file

@ -1,41 +1,5 @@
import _ from "lodash";
import {LinkPreview} from "../plugins/irc-events/link";
import User from "./user";
export type UserInMessage = Partial<User> & {
mode: string;
};
export enum MessageType {
UNHANDLED = "unhandled",
ACTION = "action",
AWAY = "away",
BACK = "back",
ERROR = "error",
INVITE = "invite",
JOIN = "join",
KICK = "kick",
LOGIN = "login",
LOGOUT = "logout",
MESSAGE = "message",
MODE = "mode",
MODE_CHANNEL = "mode_channel",
MODE_USER = "mode_user", // RPL_UMODEIS
MONOSPACE_BLOCK = "monospace_block",
NICK = "nick",
NOTICE = "notice",
PART = "part",
QUIT = "quit",
CTCP = "ctcp",
CTCP_REQUEST = "ctcp_request",
CHGHOST = "chghost",
TOPIC = "topic",
TOPIC_SET_BY = "topic_set_by",
WHOIS = "whois",
RAW = "raw",
PLUGIN = "plugin",
WALLOPS = "wallops",
}
import {MessageType, LinkPreview, UserInMessage} from "../../shared/types/msg";
class Msg {
from!: UserInMessage;
@ -70,7 +34,7 @@ class Msg {
raw_modes!: any;
when!: Date;
whois!: any;
users!: UserInMessage[] | string[];
users!: string[];
statusmsgGroup!: string;
params!: string[];

View file

@ -1,24 +1,17 @@
import _ from "lodash";
import {v4 as uuidv4} from "uuid";
import IrcFramework, {Client as IRCClient} from "irc-framework";
import Chan, {ChanConfig, Channel, ChanType} from "./chan";
import Msg, {MessageType} from "./msg";
import Chan, {ChanConfig, Channel} from "./chan";
import Msg from "./msg";
import Prefix from "./prefix";
import Helper, {Hostmask} from "../helper";
import Config, {WebIRC} from "../config";
import STSPolicies from "../plugins/sts";
import ClientCertificate, {ClientCertificateType} from "../plugins/clientCertificate";
import Client from "../client";
/**
* List of keys which should be sent to the client by default.
*/
const fieldsForClient = {
uuid: true,
name: true,
nick: true,
serverOptions: true,
};
import {MessageType} from "../../shared/types/msg";
import {ChanType} from "../../shared/types/chan";
import {SharedNetwork} from "../../shared/types/network";
type NetworkIrcOptions = {
host: string;
@ -52,7 +45,7 @@ type NetworkStatus = {
};
export type IgnoreListItem = Hostmask & {
when?: number;
when: number;
};
type IgnoreList = IgnoreListItem[];
@ -505,24 +498,17 @@ class Network {
}
}
getFilteredClone(lastActiveChannel?: number, lastMessage?: number) {
const filteredNetwork = Object.keys(this).reduce((newNetwork, prop) => {
if (prop === "channels") {
// Channels objects perform their own cloning
newNetwork[prop] = this[prop].map((channel) =>
channel.getFilteredClone(lastActiveChannel, lastMessage)
);
} else if (fieldsForClient[prop]) {
// Some properties that are not useful for the client are skipped
newNetwork[prop] = this[prop];
}
return newNetwork;
}, {}) as Network;
filteredNetwork.status = this.getNetworkStatus();
return filteredNetwork;
getFilteredClone(lastActiveChannel?: number, lastMessage?: number): SharedNetwork {
return {
uuid: this.uuid,
name: this.name,
nick: this.nick,
serverOptions: this.serverOptions,
status: this.getNetworkStatus(),
channels: this.channels.map((channel) =>
channel.getFilteredClone(lastActiveChannel, lastMessage)
),
};
}
getNetworkStatus() {

View file

@ -67,11 +67,11 @@ function advancedLdapAuth(user: string, password: string, callback: (success: bo
});
const base = config.ldap.searchDN.base;
const searchOptions = {
const searchOptions: SearchOptions = {
scope: config.ldap.searchDN.scope,
filter: `(&(${config.ldap.primaryKey}=${userDN})${config.ldap.searchDN.filter})`,
attributes: ["dn"],
} as SearchOptions;
};
ldapclient.on("error", function (err: Error) {
log.error(`Unable to connect to LDAP server: ${err.toString()}`);
@ -178,12 +178,12 @@ function advancedLdapLoadUsers(users: string[], callbackLoadUser) {
const remainingUsers = new Set(users);
const searchOptions = {
const searchOptions: SearchOptions = {
scope: config.ldap.searchDN.scope,
filter: `${config.ldap.searchDN.filter}`,
attributes: [config.ldap.primaryKey],
paged: true,
} as SearchOptions;
};
ldapclient.search(base, searchOptions, function (err2, res) {
if (err2) {

View file

@ -4,6 +4,7 @@ import log from "../log";
import pkg from "../../package.json";
import ClientManager from "../clientManager";
import Config from "../config";
import {SharedChangelogData} from "../../shared/types/changelog";
const TIME_TO_LIVE = 15 * 60 * 1000; // 15 minutes, in milliseconds
@ -12,31 +13,17 @@ export default {
fetch,
checkForUpdates,
};
export type ChangelogData = {
current: {
prerelease: boolean;
version: string;
changelog?: string;
url: string;
};
expiresAt: number;
latest?: {
prerelease: boolean;
version: string;
url: string;
};
packages?: boolean;
};
const versions = {
const versions: SharedChangelogData = {
current: {
prerelease: false,
version: `v${pkg.version}`,
changelog: undefined,
url: "", // TODO: properly init
},
expiresAt: -1,
latest: undefined,
packages: undefined,
} as ChangelogData;
};
async function fetch() {
const time = Date.now();

View file

@ -31,7 +31,7 @@ function get(uuid: string): ClientCertificateType | null {
return {
private_key: fs.readFileSync(paths.privateKeyPath, "utf-8"),
certificate: fs.readFileSync(paths.certificatePath, "utf-8"),
} as ClientCertificateType;
};
} catch (e: any) {
log.error("Unable to get certificate", e);
}
@ -122,10 +122,10 @@ function generate() {
// Sign this certificate with a SHA256 signature
cert.sign(keys.privateKey, md.sha256.create());
const pem = {
const pem: ClientCertificateType = {
private_key: pki.privateKeyToPem(keys.privateKey),
certificate: pki.certificateToPem(cert),
} as ClientCertificateType;
};
return pem;
}

View file

@ -1,6 +1,7 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import {ChanType} from "../../models/chan";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["slap", "me"];

View file

@ -1,6 +1,7 @@
import {ChanType} from "../../models/chan";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {PluginInputHandler} from "./index";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["ban", "unban", "banlist", "kickban"];

View file

@ -1,5 +1,6 @@
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {PluginInputHandler} from "./index";
import {MessageType} from "../../../shared/types/msg";
const commands = ["connect", "server"];
const allowDisconnected = true;

View file

@ -1,5 +1,6 @@
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {PluginInputHandler} from "./index";
import {MessageType} from "../../../shared/types/msg";
const commands = ["ctcp"];

View file

@ -1,18 +1,14 @@
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import Helper from "../../helper";
import {PluginInputHandler} from "./index";
import {IgnoreListItem} from "../../models/network";
import {ChanType, SpecialChanType} from "../../models/chan";
import {MessageType} from "../../../shared/types/msg";
const commands = ["ignore", "unignore", "ignorelist"];
const commands = ["ignore", "unignore"];
const input: PluginInputHandler = function (network, chan, cmd, args) {
const client = this;
let target: string;
// let hostmask: cmd === "ignoreList" ? string : undefined;
let hostmask: IgnoreListItem | undefined;
if (cmd !== "ignorelist" && (args.length === 0 || args[0].trim().length === 0)) {
if (args.length === 0 || args[0].trim().length === 0) {
chan.pushMessage(
client,
new Msg({
@ -24,16 +20,13 @@ const input: PluginInputHandler = function (network, chan, cmd, args) {
return;
}
if (cmd !== "ignorelist") {
// Trim to remove any spaces from the hostmask
target = args[0].trim();
hostmask = Helper.parseHostmask(target) as IgnoreListItem;
}
const target = args[0].trim();
const hostmask = Helper.parseHostmask(target);
switch (cmd) {
case "ignore": {
// IRC nicks are case insensitive
if (hostmask!.nick.toLowerCase() === network.nick.toLowerCase()) {
if (hostmask.nick.toLowerCase() === network.nick.toLowerCase()) {
chan.pushMessage(
client,
new Msg({
@ -41,25 +34,14 @@ const input: PluginInputHandler = function (network, chan, cmd, args) {
text: "You can't ignore yourself",
})
);
} else if (
!network.ignoreList.some(function (entry) {
return Helper.compareHostmask(entry, hostmask!);
return;
}
if (
network.ignoreList.some(function (entry) {
return Helper.compareHostmask(entry, hostmask);
})
) {
hostmask!.when = Date.now();
network.ignoreList.push(hostmask!);
client.save();
chan.pushMessage(
client,
new Msg({
type: MessageType.ERROR,
text: `\u0002${hostmask!.nick}!${hostmask!.ident}@${
hostmask!.hostname
}\u000f added to ignorelist`,
})
);
} else {
chan.pushMessage(
client,
new Msg({
@ -67,32 +49,31 @@ const input: PluginInputHandler = function (network, chan, cmd, args) {
text: "The specified user/hostmask is already ignored",
})
);
return;
}
break;
network.ignoreList.push({
...hostmask,
when: Date.now(),
});
client.save();
chan.pushMessage(
client,
new Msg({
type: MessageType.ERROR, // TODO: Successfully added via type.Error 🤔 ?
text: `\u0002${hostmask.nick}!${hostmask.ident}@${hostmask.hostname}\u000f added to ignorelist`,
})
);
return;
}
case "unignore": {
const idx = network.ignoreList.findIndex(function (entry) {
return Helper.compareHostmask(entry, hostmask!);
return Helper.compareHostmask(entry, hostmask);
});
// Check if the entry exists before removing it, otherwise
// let the user know.
if (idx !== -1) {
network.ignoreList.splice(idx, 1);
client.save();
chan.pushMessage(
client,
new Msg({
type: MessageType.ERROR,
text: `Successfully removed \u0002${hostmask!.nick}!${hostmask!.ident}@${
hostmask!.hostname
}\u000f from ignorelist`,
})
);
} else {
if (idx === -1) {
chan.pushMessage(
client,
new Msg({
@ -100,52 +81,20 @@ const input: PluginInputHandler = function (network, chan, cmd, args) {
text: "The specified user/hostmask is not ignored",
})
);
return;
}
break;
network.ignoreList.splice(idx, 1);
client.save();
chan.pushMessage(
client,
new Msg({
type: MessageType.ERROR, // TODO: Successfully removed via type.Error 🤔 ?
text: `Successfully removed \u0002${hostmask.nick}!${hostmask.ident}@${hostmask.hostname}\u000f from ignorelist`,
})
);
}
case "ignorelist":
if (network.ignoreList.length === 0) {
chan.pushMessage(
client,
new Msg({
type: MessageType.ERROR,
text: "Ignorelist is empty",
})
);
} else {
const chanName = "Ignored users";
const ignored = network.ignoreList.map((data) => ({
hostmask: `${data.nick}!${data.ident}@${data.hostname}`,
when: data.when,
}));
let newChan = network.getChannel(chanName);
if (typeof newChan === "undefined") {
newChan = client.createChannel({
type: ChanType.SPECIAL,
special: SpecialChanType.IGNORELIST,
name: chanName,
data: ignored,
});
client.emit("join", {
network: network.uuid,
chan: newChan.getFilteredClone(true),
index: network.addChannel(newChan),
});
} else {
// TODO: add type for this chan/event
newChan.data = ignored;
client.emit("msg:special", {
chan: newChan.id,
data: ignored,
});
}
}
break;
}
};

View file

@ -0,0 +1,57 @@
import {PluginInputHandler} from "./index";
import Msg from "../../models/msg";
import {ChanType, SpecialChanType} from "../../../shared/types/chan";
import {MessageType} from "../../../shared/types/msg";
const commands = ["ignorelist"];
const input: PluginInputHandler = function (network, chan, _cmd, _args) {
const client = this;
if (network.ignoreList.length === 0) {
chan.pushMessage(
client,
new Msg({
type: MessageType.ERROR,
text: "Ignorelist is empty",
})
);
return;
}
const chanName = "Ignored users";
const ignored = network.ignoreList.map((data) => ({
hostmask: `${data.nick}!${data.ident}@${data.hostname}`,
when: data.when,
}));
let newChan = network.getChannel(chanName);
if (typeof newChan === "undefined") {
newChan = client.createChannel({
type: ChanType.SPECIAL,
special: SpecialChanType.IGNORELIST,
name: chanName,
data: ignored,
});
client.emit("join", {
network: network.uuid,
chan: newChan.getFilteredClone(true),
shouldOpen: false,
index: network.addChannel(newChan),
});
return;
}
// TODO: add type for this chan/event
newChan.data = ignored;
client.emit("msg:special", {
chan: newChan.id,
data: ignored,
});
};
export default {
commands,
input,
};

View file

@ -54,6 +54,7 @@ const builtInInputs = [
"ctcp",
"disconnect",
"ignore",
"ignorelist",
"invite",
"kick",
"kill",

View file

@ -1,6 +1,7 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import {ChanType} from "../../models/chan";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["invite", "invitelist"];

View file

@ -1,6 +1,7 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import {ChanType} from "../../models/chan";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["kick"];

View file

@ -1,7 +1,8 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import {ChanType} from "../../models/chan";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["mode", "umode", "op", "deop", "hop", "dehop", "voice", "devoice"];

View file

@ -1,6 +1,8 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import Chan, {ChanType} from "../../models/chan";
import Msg from "../../models/msg";
import Chan from "../../models/chan";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["query", "msg", "say"];
@ -97,10 +99,10 @@ const input: PluginInputHandler = function (network, chan, cmd, args) {
// being sent back to us.
if (!network.irc.network.cap.isEnabled("echo-message")) {
const parsedTarget = network.irc.network.extractTargetGroup(targetName);
let targetGroup;
let targetGroup: string | undefined = undefined;
if (parsedTarget) {
targetName = parsedTarget.target as string;
targetName = parsedTarget.target;
targetGroup = parsedTarget.target_group;
}

View file

@ -2,9 +2,10 @@ import Chan from "../../models/chan";
import Network from "../../models/network";
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import Client from "../../client";
import {MessageType} from "../../../shared/types/msg";
const commands = ["mute", "unmute"];
const allowDisconnected = true;

View file

@ -1,6 +1,7 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
const commands = ["nick"];
const allowDisconnected = true;

View file

@ -1,8 +1,9 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import Config from "../../config";
import {ChanType, ChanState} from "../../models/chan";
import {MessageType} from "../../../shared/types/msg";
import {ChanType, ChanState} from "../../../shared/types/chan";
const commands = ["close", "leave", "part"];
const allowDisconnected = true;

View file

@ -1,7 +1,8 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import {ChanType} from "../../models/chan";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["cycle", "rejoin"];

View file

@ -1,7 +1,8 @@
import {PluginInputHandler} from "./index";
import Msg, {MessageType} from "../../models/msg";
import {ChanType} from "../../models/chan";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
const commands = ["topic"];

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import {ChanType} from "../../models/chan";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -14,7 +14,7 @@ export default <IrcEventHandler>function (irc, network) {
handleSTS(data, false);
});
function handleSTS(data, shouldReconnect) {
function handleSTS(data, shouldReconnect: boolean) {
if (!Object.prototype.hasOwnProperty.call(data.capabilities, "sts")) {
return;
}

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -3,10 +3,11 @@ import _ from "lodash";
import {IrcEventHandler} from "../../client";
import log from "../../log";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import Helper from "../../helper";
import Config from "../../config";
import {ChanType, ChanState} from "../../models/chan";
import {MessageType} from "../../../shared/types/msg";
import {ChanType, ChanState} from "../../../shared/types/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,9 +1,10 @@
import _ from "lodash";
import {IrcEventHandler} from "../../client";
import Helper from "../../helper";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import User from "../../models/user";
import pkg from "../../../package.json";
import {MessageType} from "../../../shared/types/msg";
const ctcpResponses = {
CLIENTINFO: () =>

View file

@ -1,7 +1,8 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import Config from "../../config";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,5 +1,6 @@
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {IrcEventHandler} from "../../client";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,5 +1,6 @@
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {IrcEventHandler} from "../../client";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,7 +1,8 @@
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import User from "../../models/user";
import type {IrcEventHandler} from "../../client";
import {ChanState} from "../../models/chan";
import {MessageType} from "../../../shared/types/msg";
import {ChanState} from "../../../shared/types/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;
@ -18,6 +19,7 @@ export default <IrcEventHandler>function (irc, network) {
client.emit("join", {
network: network.uuid,
chan: chan.getFilteredClone(true),
shouldOpen: false,
index: network.addChannel(chan),
});
client.save();

View file

@ -1,8 +1,8 @@
import {IrcEventHandler} from "../../client";
import {ChanState} from "../../models/chan";
import Msg, {MessageType} from "../../models/msg";
import User from "../../models/user";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanState} from "../../../shared/types/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;
@ -14,11 +14,12 @@ export default <IrcEventHandler>function (irc, network) {
return;
}
const user = chan.getUser(data.kicked!);
const msg = new Msg({
type: MessageType.KICK,
time: data.time,
from: chan.getUser(data.nick),
target: chan.getUser(data.kicked!),
target: user,
text: data.message || "",
highlight: data.kicked === irc.user.nick,
self: data.nick === irc.user.nick,
@ -34,7 +35,7 @@ export default <IrcEventHandler>function (irc, network) {
state: chan.state,
});
} else {
chan.removeUser(msg.target as User);
chan.removeUser(user);
}
});
};

View file

@ -6,6 +6,7 @@ import mime from "mime-types";
import log from "../../log";
import Config from "../../config";
import {findLinksWithSchema} from "../../../shared/linkify";
import {LinkPreview} from "../../../shared/types/msg";
import storage from "../storage";
import Client from "../../client";
import Chan from "../../models/chan";
@ -20,23 +21,6 @@ const currentFetchPromises = new Map<string, Promise<FetchRequest>>();
const imageTypeRegex = /^image\/.+/;
const mediaTypeRegex = /^(audio|video)\/.+/;
export type LinkPreview = {
type: string;
head: string;
body: string;
thumb: string;
size: number;
link: string; // Send original matched link to the client
shown?: boolean | null;
error?: string;
message?: string;
media?: string;
mediaType?: string;
maxSize?: number;
thumbActualUrl?: string;
};
export default function (client: Client, chan: Chan, msg: Msg, cleanText: string) {
if (!Config.values.prefetch) {
return;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Chan, {ChanType, SpecialChanType} from "../../models/chan";
import Chan from "../../models/chan";
import {ChanType, SpecialChanType} from "../../../shared/types/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;
@ -50,6 +51,7 @@ export default <IrcEventHandler>function (irc, network) {
client.emit("join", {
network: network.uuid,
chan: chan.getFilteredClone(true),
shouldOpen: false,
index: network.addChannel(chan),
});
} else {

View file

@ -1,24 +1,38 @@
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import LinkPrefetch from "./link";
import {cleanIrcMessage} from "../../../shared/irc";
import Helper from "../../helper";
import {IrcEventHandler} from "../../client";
import Chan, {ChanType} from "../../models/chan";
import Chan from "../../models/chan";
import User from "../../models/user";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
import {MessageEventArgs} from "irc-framework";
const nickRegExp = /(?:\x03[0-9]{1,2}(?:,[0-9]{1,2})?)?([\w[\]\\`^{|}-]+)/g;
type HandleInput = {
nick: string;
hostname: string;
ident: string;
target: string;
type: MessageType;
time: number;
text?: string;
from_server?: boolean;
message: string;
group?: string;
};
function convertForHandle(type: MessageType, data: MessageEventArgs): HandleInput {
return {...data, time: data.time ? data.time : new Date().getTime(), type: type};
}
export default <IrcEventHandler>function (irc, network) {
const client = this;
irc.on("notice", function (data) {
data.type = MessageType.NOTICE;
type ModifiedData = typeof data & {
type: MessageType.NOTICE;
};
handleMessage(data as ModifiedData);
handleMessage(convertForHandle(MessageType.NOTICE, data));
});
irc.on("action", function (data) {
@ -37,18 +51,7 @@ export default <IrcEventHandler>function (irc, network) {
handleMessage(data);
});
function handleMessage(data: {
nick: string;
hostname: string;
ident: string;
target: string;
type: MessageType;
time: number;
text?: string;
from_server?: boolean;
message: string;
group?: string;
}) {
function handleMessage(data: HandleInput) {
let chan: Chan | undefined;
let from: User;
let highlight = false;
@ -105,6 +108,7 @@ export default <IrcEventHandler>function (irc, network) {
client.emit("join", {
network: network.uuid,
chan: chan.getFilteredClone(true),
shouldOpen: false,
index: network.addChannel(chan),
});
client.save();
@ -125,7 +129,7 @@ export default <IrcEventHandler>function (irc, network) {
// msg is constructed down here because `from` is being copied in the constructor
const msg = new Msg({
type: data.type,
time: data.time as any,
time: new Date(data.time),
text: data.message,
self: self,
from: from,
@ -164,7 +168,6 @@ export default <IrcEventHandler>function (irc, network) {
while ((match = nickRegExp.exec(data.message))) {
if (chan.findUser(match[1])) {
// @ts-expect-error Type 'string' is not assignable to type '{ mode: string; }'.ts(2345)
msg.users.push(match[1]);
}
}

View file

@ -1,7 +1,8 @@
import _ from "lodash";
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,7 +1,8 @@
import {IrcEventHandler} from "../../client";
import {SpecialChanType, ChanType} from "../../models/chan";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {SpecialChanType, ChanType} from "../../../shared/types/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;
@ -68,6 +69,7 @@ export default <IrcEventHandler>function (irc, network) {
client.emit("join", {
network: network.uuid,
chan: chan.getFilteredClone(true),
shouldOpen: false,
index: network.addChannel(chan),
});
} else {

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,6 +1,7 @@
import {IrcEventHandler} from "../../client";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default <IrcEventHandler>function (irc, network) {
const client = this;

View file

@ -1,7 +1,8 @@
import {IrcEventHandler} from "../../client";
import {ChanType} from "../../models/chan";
import Msg, {MessageType} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
import {ChanType} from "../../../shared/types/chan";
export default <IrcEventHandler>function (irc, network) {
const client = this;
@ -28,9 +29,9 @@ export default <IrcEventHandler>function (irc, network) {
});
client.emit("join", {
shouldOpen: true,
network: network.uuid,
chan: chan.getFilteredClone(true),
shouldOpen: true,
index: network.addChannel(chan),
});
chan.loadMessages(client, network);

View file

@ -7,8 +7,9 @@ import Config from "../../config";
import Msg, {Message} from "../../models/msg";
import Chan, {Channel} from "../../models/chan";
import Helper from "../../helper";
import type {SearchResponse, SearchQuery, SearchableMessageStorage, DeletionRequest} from "./types";
import type {SearchableMessageStorage, DeletionRequest} from "./types";
import Network from "../../models/network";
import {SearchQuery, SearchResponse} from "../../../shared/types/storage";
// TODO; type
let sqlite3: any;

View file

@ -6,8 +6,9 @@ import filenamify from "filenamify";
import Config from "../../config";
import {MessageStorage} from "./types";
import Channel from "../../models/chan";
import {Message, MessageType} from "../../models/msg";
import {Message} from "../../models/msg";
import Network from "../../models/network";
import {MessageType} from "../../../shared/types/msg";
class TextFileMessageStorage implements MessageStorage {
isEnabled: boolean;

View file

@ -4,7 +4,8 @@ import {Channel} from "../../models/channel";
import {Message} from "../../models/message";
import {Network} from "../../models/network";
import Client from "../../client";
import type {MessageType} from "../../models/msg";
import {SearchQuery, SearchResponse} from "../../../shared/types/storage";
import type {MessageType} from "../../../shared/types/msg";
export type DeletionRequest = {
olderThanDays: number;
@ -28,17 +29,6 @@ interface MessageStorage {
canProvideMessages(): boolean;
}
export type SearchQuery = {
searchTerm: string;
networkUuid: string;
channelName: string;
offset: number;
};
export type SearchResponse = SearchQuery & {
results: Message[];
};
type SearchFunction = (query: SearchQuery) => Promise<SearchResponse>;
export interface SearchableMessageStorage extends MessageStorage {

View file

@ -1,7 +1,8 @@
import {PackageInfo} from "./index";
import Client from "../../client";
import Chan from "../../models/chan";
import Msg, {MessageType, UserInMessage} from "../../models/msg";
import Msg from "../../models/msg";
import {MessageType} from "../../../shared/types/msg";
export default class PublicClient {
private client: Client;
@ -35,8 +36,11 @@ export default class PublicClient {
* @param {String} event - Name of the event, must be something the browser will recognise
* @param {Object} data - Body of the event, can be anything, but will need to be properly interpreted by the client
*/
sendToBrowser(event: string, data) {
this.client.emit(event, data);
// FIXME: this is utterly bonkers
// This needs to get wrapped into its own, typed plugin event
// Plus it is completely insane to let a plugin inject arbitrary events like that
sendToBrowser(event: string, data: any) {
this.client.emit(event as any, data);
}
/**
@ -61,7 +65,8 @@ export default class PublicClient {
text: text,
from: {
nick: this.packageInfo.name || this.packageInfo.packageName,
} as UserInMessage,
mode: "",
},
})
);
}

View file

@ -3,7 +3,7 @@ import {Server as wsServer} from "ws";
import express, {NextFunction, Request, Response} from "express";
import fs from "fs";
import path from "path";
import {Server, Socket} from "socket.io";
import {Server as ioServer, Socket as ioSocket} from "socket.io";
import dns from "dns";
import colors from "chalk";
import net from "net";
@ -13,25 +13,32 @@ import Client from "./client";
import ClientManager from "./clientManager";
import Uploader from "./plugins/uploader";
import Helper from "./helper";
import Config, {ConfigType, Defaults} from "./config";
import Config, {ConfigType} from "./config";
import Identification from "./identification";
import changelog from "./plugins/changelog";
import inputs from "./plugins/inputs";
import Auth from "./plugins/auth";
import themes, {ThemeForClient} from "./plugins/packages/themes";
import themes from "./plugins/packages/themes";
themes.loadLocalThemes();
import packages from "./plugins/packages/index";
import {NetworkWithIrcFramework} from "./models/network";
import {ChanType} from "./models/chan";
import Utils from "./command-line/utils";
import type {
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData,
} from "./types/socket-events";
AuthPerformData,
} from "../shared/types/socket-events";
import {ChanType} from "../shared/types/chan";
import {
LockedSharedConfiguration,
SharedConfiguration,
ConfigNetDefaults,
LockedConfigNetDefaults,
} from "../shared/types/config";
type ServerOptions = {
dev: boolean;
@ -45,21 +52,13 @@ type IndexTemplateConfiguration = ServerConfiguration & {
cacheBust: string;
};
export type ClientConfiguration = Pick<
ConfigType,
"public" | "lockNetwork" | "useHexIp" | "prefetch" | "defaults"
> & {
fileUpload: boolean;
ldapEnabled: boolean;
isUpdateAvailable: boolean;
applicationServerKey: string;
version: string;
gitCommit: string | null;
defaultTheme: string;
themes: ThemeForClient[];
defaults: Defaults;
fileUploadMaxFileSize?: number;
};
type Socket = ioSocket<ClientToServerEvents, ServerToClientEvents, InterServerEvents, SocketData>;
export type Server = ioServer<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>;
// A random number that will force clients to reload the page if it differs
const serverHash = Math.floor(Date.now() * Math.random());
@ -219,12 +218,7 @@ export default async function (
return;
}
const sockets = new Server<
ClientToServerEvents,
ServerToClientEvents,
InterServerEvents,
SocketData
>(server, {
const sockets: Server = new ioServer(server, {
wsEngine: wsServer,
cookie: false,
serveClient: false,
@ -330,7 +324,7 @@ export default async function (
return server;
}
function getClientLanguage(socket: Socket): string | null {
function getClientLanguage(socket: Socket): string | undefined {
const acceptLanguage = socket.handshake.headers["accept-language"];
if (typeof acceptLanguage === "string" && /^[\x00-\x7F]{1,50}$/.test(acceptLanguage)) {
@ -338,10 +332,10 @@ function getClientLanguage(socket: Socket): string | null {
return acceptLanguage;
}
return null;
return undefined;
}
function getClientIp(socket: Socket) {
function getClientIp(socket: Socket): string {
let ip = socket.handshake.address || "127.0.0.1";
if (Config.values.reverseProxy) {
@ -367,12 +361,12 @@ function getClientSecure(socket: Socket) {
return secure;
}
function allRequests(req: Request, res: Response, next: NextFunction) {
function allRequests(_req: Request, res: Response, next: NextFunction) {
res.setHeader("X-Content-Type-Options", "nosniff");
return next();
}
function addSecurityHeaders(req: Request, res: Response, next: NextFunction) {
function addSecurityHeaders(_req: Request, res: Response, next: NextFunction) {
const policies = [
"default-src 'none'", // default to nothing
"base-uri 'none'", // disallow <base>, has no fallback to default-src
@ -402,32 +396,30 @@ function addSecurityHeaders(req: Request, res: Response, next: NextFunction) {
return next();
}
function forceNoCacheRequest(req: Request, res: Response, next: NextFunction) {
function forceNoCacheRequest(_req: Request, res: Response, next: NextFunction) {
// Intermittent proxies must not cache the following requests,
// browsers must fetch the latest version of these files (service worker, source maps)
res.setHeader("Cache-Control", "no-cache, no-transform");
return next();
}
function indexRequest(req: Request, res: Response) {
function indexRequest(_req: Request, res: Response) {
res.setHeader("Content-Type", "text/html");
return fs.readFile(
Utils.getFileFromRelativeToRoot("client/index.html.tpl"),
"utf-8",
(err, file) => {
if (err) {
throw err;
}
const config: IndexTemplateConfiguration = {
...getServerConfiguration(),
...{cacheBust: Helper.getVersionCacheBust()},
};
res.send(_.template(file)(config));
fs.readFile(Utils.getFileFromRelativeToRoot("client/index.html.tpl"), "utf-8", (err, file) => {
if (err) {
log.error(`failed to server index request: ${err.name}, ${err.message}`);
res.sendStatus(500);
return;
}
);
const config: IndexTemplateConfiguration = {
...getServerConfiguration(),
...{cacheBust: Helper.getVersionCacheBust()},
};
res.send(_.template(file)(config));
});
}
function initializeClient(
@ -552,18 +544,10 @@ function initializeClient(
const hash = Helper.password.hash(p1);
client.setPassword(hash, (success: boolean) => {
const obj = {success: false, error: undefined} as {
success: boolean;
error: string | undefined;
};
if (success) {
obj.success = true;
} else {
obj.error = "update_failed";
}
socket.emit("change-password", obj);
socket.emit("change-password", {
success: success,
error: success ? undefined : "update_failed",
});
});
})
.catch((error: Error) => {
@ -577,10 +561,28 @@ function initializeClient(
client.open(socket.id, data);
});
socket.on("sort", (data) => {
if (_.isPlainObject(data)) {
client.sort(data);
socket.on("sort:networks", (data) => {
if (!_.isPlainObject(data)) {
return;
}
if (!Array.isArray(data.order)) {
return;
}
client.sortNetworks(data.order);
});
socket.on("sort:channels", (data) => {
if (!_.isPlainObject(data)) {
return;
}
if (!Array.isArray(data.order) || typeof data.network !== "string") {
return;
}
client.sortChannels(data.network, data.order);
});
socket.on("names", (data) => {
@ -630,13 +632,13 @@ function initializeClient(
return;
}
const message = networkAndChan.chan.findMessage(data.msgId);
const message = data.msgId ? networkAndChan.chan.findMessage(data.msgId) : null;
if (!message) {
return;
}
const preview = message.findPreview(data.link);
const preview = data.link ? message.findPreview(data.link) : null;
if (preview) {
preview.shown = newState;
@ -828,9 +830,9 @@ function initializeClient(
});
// socket.join is a promise depending on the adapter.
void socket.join(client.id?.toString());
void socket.join(client.id);
const sendInitEvent = (tokenToSend: string | null) => {
const sendInitEvent = (tokenToSend?: string) => {
socket.emit("init", {
active: openChannel,
networks: client.networks.map((network) =>
@ -842,7 +844,7 @@ function initializeClient(
};
if (Config.values.public) {
sendInitEvent(null);
sendInitEvent();
} else if (!token) {
client.generateToken((newToken) => {
token = client.calculateTokenHash(newToken);
@ -853,73 +855,108 @@ function initializeClient(
});
} else {
client.updateSession(token, getClientIp(socket), socket.request);
sendInitEvent(null);
sendInitEvent();
}
}
function getClientConfiguration(): ClientConfiguration {
const config = _.pick(Config.values, [
"public",
"lockNetwork",
"useHexIp",
"prefetch",
]) as ClientConfiguration;
function getClientConfiguration(): SharedConfiguration | LockedSharedConfiguration {
const common = {
fileUpload: Config.values.fileUpload.enable,
ldapEnabled: Config.values.ldap.enable,
isUpdateAvailable: changelog.isUpdateAvailable,
applicationServerKey: manager!.webPush.vapidKeys!.publicKey,
version: Helper.getVersionNumber(),
gitCommit: Helper.getGitCommit(),
themes: themes.getAll(),
defaultTheme: Config.values.theme,
public: Config.values.public,
useHexIp: Config.values.useHexIp,
prefetch: Config.values.prefetch,
fileUploadMaxFileSize: Uploader ? Uploader.getMaxFileSize() : undefined, // TODO can't be undefined?
};
config.fileUpload = Config.values.fileUpload.enable;
config.ldapEnabled = Config.values.ldap.enable;
const defaultsOverride = {
nick: Config.getDefaultNick(), // expand the number part
if (!config.lockNetwork) {
config.defaults = _.clone(Config.values.defaults);
} else {
// Only send defaults that are visible on the client
config.defaults = _.pick(Config.values.defaults, [
"name",
"nick",
"username",
"password",
"realname",
"join",
]) as Defaults;
// TODO: this doesn't seem right, if the client needs this as a buffer
// the client ought to add it on its own
sasl: "",
saslAccount: "",
saslPassword: "",
};
if (!Config.values.lockNetwork) {
const defaults: ConfigNetDefaults = {
..._.clone(Config.values.defaults),
...defaultsOverride,
};
const result: SharedConfiguration = {
...common,
defaults: defaults,
lockNetwork: Config.values.lockNetwork,
};
return result;
}
config.isUpdateAvailable = changelog.isUpdateAvailable;
config.applicationServerKey = manager!.webPush.vapidKeys!.publicKey;
config.version = Helper.getVersionNumber();
config.gitCommit = Helper.getGitCommit();
config.themes = themes.getAll();
config.defaultTheme = Config.values.theme;
config.defaults.nick = Config.getDefaultNick();
config.defaults.sasl = "";
config.defaults.saslAccount = "";
config.defaults.saslPassword = "";
// Only send defaults that are visible on the client
const defaults: LockedConfigNetDefaults = {
..._.pick(Config.values.defaults, ["name", "username", "password", "realname", "join"]),
...defaultsOverride,
};
if (Uploader) {
config.fileUploadMaxFileSize = Uploader.getMaxFileSize();
}
const result: LockedSharedConfiguration = {
...common,
lockNetwork: Config.values.lockNetwork,
defaults: defaults,
};
return config;
return result;
}
function getServerConfiguration(): ServerConfiguration {
return {...Config.values, ...{stylesheets: packages.getStylesheets()}};
}
function performAuthentication(this: Socket, data) {
function performAuthentication(this: Socket, data: AuthPerformData) {
if (!_.isPlainObject(data)) {
return;
}
const socket = this;
let client;
let client: Client | undefined;
let token: string;
const finalInit = () =>
initializeClient(socket, client, token, data.lastMessage || -1, data.openChannel);
const finalInit = () => {
let lastMessage = -1;
if (data && "lastMessage" in data && data.lastMessage) {
lastMessage = data.lastMessage;
}
// TODO: bonkers, but for now good enough until we rewrite the logic properly
// initializeClient will check for if(openChannel) and as 0 is falsey it does the fallback...
let openChannel = 0;
if (data && "openChannel" in data && data.openChannel) {
openChannel = data.openChannel;
}
// TODO: remove this once the logic is cleaned up
if (!client) {
throw new Error("finalInit called with undefined client, this is a bug");
}
initializeClient(socket, client, token, lastMessage, openChannel);
};
const initClient = () => {
if (!client) {
throw new Error("initClient called with undefined client");
}
// Configuration does not change during runtime of TL,
// and the client listens to this event only once
if (!data.hasConfig) {
if (data && (!("hasConfig" in data) || !data.hasConfig)) {
socket.emit("configuration", getClientConfiguration());
socket.emit(
@ -928,8 +965,10 @@ function performAuthentication(this: Socket, data) {
);
}
const clientIP = getClientIp(socket);
client.config.browser = {
ip: getClientIp(socket),
ip: clientIP,
isSecure: getClientSecure(socket),
language: getClientLanguage(socket),
};
@ -939,8 +978,9 @@ function performAuthentication(this: Socket, data) {
return finalInit();
}
reverseDnsLookup(client.config.browser?.ip, (hostname) => {
client.config.browser!.hostname = hostname;
const cb_client = client; // ensure that TS figures out that client can't be nil
reverseDnsLookup(clientIP, (hostname) => {
cb_client.config.browser!.hostname = hostname;
finalInit();
});
@ -951,9 +991,10 @@ function performAuthentication(this: Socket, data) {
client.connect();
manager!.clients.push(client);
const cb_client = client; // ensure TS can see we never have a nil client
socket.on("disconnect", function () {
manager!.clients = _.without(manager!.clients, client);
client.quit();
manager!.clients = _.without(manager!.clients, cb_client);
cb_client.quit();
});
initClient();
@ -965,7 +1006,7 @@ function performAuthentication(this: Socket, data) {
return;
}
const authCallback = (success) => {
const authCallback = (success: boolean) => {
// Authorization failed
if (!success) {
if (!client) {
@ -990,6 +1031,10 @@ function performAuthentication(this: Socket, data) {
// load it and find the user again (this happens with LDAP)
if (!client) {
client = manager!.loadUser(data.user);
if (!client) {
throw new Error(`authCallback: ${data.user} not found after second lookup`);
}
}
initClient();
@ -998,16 +1043,23 @@ function performAuthentication(this: Socket, data) {
client = manager!.findClient(data.user);
// We have found an existing user and client has provided a token
if (client && data.token) {
if (client && "token" in data && data.token) {
const providedToken = client.calculateTokenHash(data.token);
if (Object.prototype.hasOwnProperty.call(client.config.sessions, providedToken)) {
token = providedToken;
return authCallback(true);
authCallback(true);
return;
}
}
if (!("user" in data && "password" in data)) {
log.warn("performAuthentication: callback data has no user or no password");
authCallback(false);
return;
}
Auth.initialize().then(() => {
// Perform password checking
Auth.auth(manager, client, data.user, data.password, authCallback);

View file

@ -1,8 +1,8 @@
import SqliteMessageStorage from "./plugins/messageStorage/sqlite";
import {MessageType} from "./models/msg";
import Config from "./config";
import {DeletionRequest} from "./plugins/messageStorage/types";
import log from "./log";
import {MessageType} from "../shared/types/msg";
const status_types = [
MessageType.AWAY,

View file

@ -1,7 +1,7 @@
{
"extends": "../tsconfig.base.json" /* Path to base configuration file to inherit from. Requires TypeScript version 2.1 or later. */,
"include": [
"**/*",
".",
"../shared/"
] /* Specifies a list of glob patterns that match files to be included in compilation. If no 'files' or 'include' property is present in a tsconfig.json, the compiler defaults to including all files in the containing directory and subdirectories except those specified by 'exclude'. Requires TypeScript version 2.0 or later. */,
"files": [

View file

@ -1,2 +1 @@
import "./modules";
import "./socket-events";

View file

@ -33,8 +33,7 @@ declare module "irc-framework" {
reply: (message: string) => void;
tags: {[key: string]: string};
target: string;
time?: any;
type: "privmsg" | "action" | "notice" | "wallops";
time?: number;
}
export interface JoinEventArgs {
account: boolean;
@ -117,7 +116,11 @@ declare module "irc-framework" {
isEnabled: (cap: string) => boolean;
enabled: string[];
};
extractTargetGroup: (target: string) => any;
extractTargetGroup: (target: string) => {
target: string;
target_group: string;
};
supports(feature: "MODES"): string;
supports(feature: string): boolean;
};

View file

@ -1,224 +0,0 @@
import {ClientMessage, ClientNetwork, InitClientChan} from "../../client/js/types";
import {Mention} from "../client";
import {ChanState} from "../models/chan";
import Msg from "../models/msg";
import Network from "../models/network";
import User from "../models/user";
import {ChangelogData} from "../plugins/changelog";
import {LinkPreview} from "../plugins/irc-events/link";
import {ClientConfiguration} from "../server";
type Session = {
current: boolean;
active: number;
lastUse: number;
ip: string;
agent: string;
token: string;
};
interface ServerToClientEvents {
"auth:failed": () => void;
"auth:start": (serverHash: number) => void;
"auth:success": () => void;
"upload:auth": (token: string) => void;
changelog: (data: ChangelogData) => void;
"changelog:newversion": () => void;
"channel:state": (data: {chan: number; state: ChanState}) => void;
"change-password": ({success, error}: {success: boolean; error?: any}) => void;
commands: (data: string[]) => void;
configuration: (config: ClientConfiguration) => void;
"push:issubscribed": (isSubscribed: boolean) => void;
"push:unregister": () => void;
"sessions:list": (data: Session[]) => void;
"mentions:list": (data: Mention[]) => void;
"setting:new": ({name: string, value: any}) => void;
"setting:all": (settings: {[key: string]: any}) => void;
"history:clear": ({target}: {target: number}) => void;
"mute:changed": (response: {target: number; status: boolean}) => void;
names: (data: {id: number; users: User[]}) => void;
network: (data: {networks: ClientNetwork[]}) => void;
"network:options": (data: {network: string; serverOptions: {[key: string]: any}}) => void;
"network:status": (data: {network: string; connected: boolean; secure: boolean}) => void;
"network:info": (data: {uuid: string}) => void;
"network:name": (data: {uuid: string; name: string}) => void;
nick: (data: {network: string; nick: string}) => void;
open: (id: number) => void;
part: (data: {chan: number}) => void;
"sign-out": () => void;
sync_sort: (
data:
| {
type: "networks";
order: string[];
target: string;
}
| {
type: "channels";
order: number[];
target: string;
}
) => void;
topic: (data: {chan: number; topic: string}) => void;
users: (data: {chan: number}) => void;
more: ({
chan,
messages,
totalMessages,
}: {
chan: number;
messages: Msg[];
totalMessages: number;
}) => void;
"msg:preview": ({id, chan, preview}: {id: number; chan: number; preview: LinkPreview}) => void;
"msg:special": (data: {chan: number; data?: Record<string, any>}) => void;
msg: (data: {msg: ClientMessage; chan: number; highlight?: number; unread?: number}) => void;
init: ({
active,
networks,
token,
}: {
active: number;
networks: ClientNetwork[];
token: string;
}) => void;
"search:results": (response: SearchResponse) => void;
quit: (args: {network: string}) => void;
error: (error: any) => void;
connecting: () => void;
join: (args: {
shouldOpen: boolean;
index: number;
network: string;
chan: InitClientChan;
}) => void;
}
interface ClientToServerEvents {
"auth:perform":
| (({user, password}: {user: string; password: string}) => void)
| (({
user,
token,
lastMessage,
openChannel,
hasConfig,
}: {
user: string;
token: string;
lastMessage: number;
openChannel: number | null;
hasConfig: boolean;
}) => void);
changelog: () => void;
"change-password": ({
old_password: string,
new_password: string,
verify_password: string,
}) => void;
open: (channelId: number) => void;
names: ({target: number}) => void;
input: ({target, text}: {target: number; text: string}) => void;
"upload:auth": () => void;
"upload:ping": (token: string) => void;
"mute:change": (response: {target: number; setMutedTo: boolean}) => void;
"push:register": (subscriptionJson: PushSubscriptionJSON) => void;
"push:unregister": () => void;
"setting:get": () => void;
"setting:set": ({name: string, value: any}) => void;
"sessions:get": () => void;
sort: ({type, order}: {type: string; order: any; target?: string}) => void;
"mentions:dismiss": (msgId: number) => void;
"mentions:dismiss_all": () => void;
"mentions:get": () => void;
more: ({
target,
lastId,
condensed,
}: {
target: number;
lastId: number;
condensed: boolean;
}) => void;
"msg:preview:toggle": ({
target,
messageIds,
msgId,
shown,
link,
}: {
target: number;
messageIds?: number[];
msgId?: number;
shown?: boolean | null;
link?: string;
}) => void;
"network:get": (uuid: string) => void;
"network:edit": (data: Record<string, any>) => void;
"network:new": (data: Record<string, any>) => void;
"sign-out": (token?: string) => void;
"history:clear": ({target}: {target: number}) => void;
search: ({
networkUuid,
channelName,
searchTerm,
offset,
}: {
networkUuid?: string;
channelName?: string;
searchTerm?: string;
offset: number;
}) => void;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface InterServerEvents {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface SocketData {}

42
shared/types/chan.ts Normal file
View file

@ -0,0 +1,42 @@
import {SharedMsg} from "./msg";
import {SharedUser} from "./user";
import {SharedNetworkChan} from "./network";
export enum ChanType {
CHANNEL = "channel",
LOBBY = "lobby",
QUERY = "query",
SPECIAL = "special",
}
export enum SpecialChanType {
BANLIST = "list_bans",
INVITELIST = "list_invites",
CHANNELLIST = "list_channels",
IGNORELIST = "list_ignored",
}
export enum ChanState {
PARTED = 0,
JOINED = 1,
}
export type SharedChan = {
// TODO: don't force existence, figure out how to make TS infer it.
id: number;
messages: SharedMsg[];
name: string;
key: string;
topic: string;
firstUnread: number;
unread: number;
highlight: number;
muted: boolean;
type: ChanType;
state: ChanState;
special?: SpecialChanType;
data?: any;
closed?: boolean;
num_users?: number;
};

15
shared/types/changelog.ts Normal file
View file

@ -0,0 +1,15 @@
export type SharedChangelogData = {
current: {
prerelease: boolean;
version: string;
changelog?: string;
url: string;
};
expiresAt: number;
latest?: {
prerelease: boolean;
version: string;
url: string;
};
packages?: boolean;
};

50
shared/types/config.ts Normal file
View file

@ -0,0 +1,50 @@
export type ConfigTheme = {
displayName: string;
name: string;
themeColor: string | null;
};
type SharedConfigurationBase = {
public: boolean;
useHexIp: boolean;
prefetch: boolean;
fileUpload: boolean;
ldapEnabled: boolean;
isUpdateAvailable: boolean;
applicationServerKey: string;
version: string;
gitCommit: string | null;
themes: ConfigTheme[];
defaultTheme: string;
fileUploadMaxFileSize?: number;
};
export type ConfigNetDefaults = {
name: string;
host: string;
port: number;
password: string;
tls: boolean;
rejectUnauthorized: boolean;
nick: string;
username: string;
realname: string;
join: string;
leaveMessage: string;
sasl: string;
saslAccount: string;
saslPassword: string;
};
export type LockedConfigNetDefaults = Pick<
ConfigNetDefaults,
"name" | "nick" | "username" | "password" | "realname" | "join"
>;
export type LockedSharedConfiguration = SharedConfigurationBase & {
lockNetwork: true;
defaults: LockedConfigNetDefaults;
};
export type SharedConfiguration = SharedConfigurationBase & {
lockNetwork: false;
defaults: ConfigNetDefaults;
};

10
shared/types/mention.ts Normal file
View file

@ -0,0 +1,10 @@
import {MessageType, UserInMessage} from "./msg";
export type SharedMention = {
chanId: number;
msgId: number;
type: MessageType;
time: Date;
text: string;
from: UserInMessage;
};

100
shared/types/msg.ts Normal file
View file

@ -0,0 +1,100 @@
export enum MessageType {
UNHANDLED = "unhandled",
ACTION = "action",
AWAY = "away",
BACK = "back",
ERROR = "error",
INVITE = "invite",
JOIN = "join",
KICK = "kick",
LOGIN = "login",
LOGOUT = "logout",
MESSAGE = "message",
MODE = "mode",
MODE_CHANNEL = "mode_channel",
MODE_USER = "mode_user", // RPL_UMODEIS
MONOSPACE_BLOCK = "monospace_block",
NICK = "nick",
NOTICE = "notice",
PART = "part",
QUIT = "quit",
CTCP = "ctcp",
CTCP_REQUEST = "ctcp_request",
CHGHOST = "chghost",
TOPIC = "topic",
TOPIC_SET_BY = "topic_set_by",
WHOIS = "whois",
RAW = "raw",
PLUGIN = "plugin",
WALLOPS = "wallops",
}
export type SharedUser = {
modes: string[];
// Users in the channel have only one mode assigned
mode: string;
away: string;
nick: string;
lastMessage: number;
};
export type UserInMessage = Partial<SharedUser> & {
mode: string;
};
export type LinkPreview = {
type: string;
head: string;
body: string;
thumb: string;
size: number;
link: string; // Send original matched link to the client
shown?: boolean | null;
error?: string;
message?: string;
media?: string;
mediaType?: string;
maxSize?: number;
thumbActualUrl?: string;
};
export type SharedMsg = {
from?: UserInMessage;
id: number;
previews?: LinkPreview[];
text?: string;
type?: MessageType;
self?: boolean;
time: Date;
hostmask?: string;
target?: UserInMessage;
// TODO: new_nick is only on MessageType.NICK,
// we should probably make Msgs that extend this class and use those
// throughout. I'll leave any similar fields below.
new_nick?: string;
highlight?: boolean;
showInActive?: boolean;
new_ident?: string;
new_host?: string;
ctcpMessage?: string;
command?: string;
invitedYou?: boolean;
gecos?: string;
account?: boolean;
// these are all just for error:
error?: string;
nick?: string;
channel?: string;
reason?: string;
raw_modes?: any;
when?: Date;
whois?: any;
users: string[];
statusmsgGroup?: string;
params?: string[];
};

36
shared/types/network.ts Normal file
View file

@ -0,0 +1,36 @@
import {SharedChan} from "./chan";
export type SharedPrefixObject = {
symbol: string;
mode: string;
};
export type SharedNetworkChan = SharedChan & {
totalMessages: number;
};
export type SharedPrefix = {
prefix: SharedPrefixObject[];
modeToSymbol: {[mode: string]: string};
symbols: string[];
};
export type SharedServerOptions = {
CHANTYPES: string[];
PREFIX: SharedPrefix;
NETWORK: string;
};
export type SharedNetworkStatus = {
connected: boolean;
secure: boolean;
};
export type SharedNetwork = {
uuid: string;
name: string;
nick: string;
serverOptions: SharedServerOptions;
status: SharedNetworkStatus;
channels: SharedNetworkChan[];
};

181
shared/types/socket-events.d.ts vendored Normal file
View file

@ -0,0 +1,181 @@
import {SharedMention} from "./mention";
import {ChanState, SharedChan} from "./chan";
import {SharedNetwork, SharedServerOptions} from "./network";
import {SharedMsg, LinkPreview} from "./msg";
import {SharedUser} from "./user";
import {SharedChangelogData} from "./changelog";
import {SharedConfiguration, LockedSharedConfiguration} from "./config";
import {SearchResponse, SearchQuery} from "./storage";
type Session = {
current: boolean;
active: number;
lastUse: number;
ip: string;
agent: string;
token: string;
};
type EventHandler<T> = (data: T) => void;
type NoPayloadEventHandler = EventHandler<void>;
interface ServerToClientEvents {
"auth:start": (serverHash: number) => void;
"auth:failed": NoPayloadEventHandler;
"auth:success": NoPayloadEventHandler;
"upload:auth": (token: string) => void;
changelog: EventHandler<SharedChangelogData>;
"changelog:newversion": NoPayloadEventHandler;
"channel:state": EventHandler<{chan: number; state: ChanState}>;
"change-password": EventHandler<{success: boolean; error?: any}>;
commands: EventHandler<string[]>;
configuration: EventHandler<SharedConfiguration | LockedSharedConfiguration>;
"push:issubscribed": EventHandler<boolean>;
"push:unregister": NoPayloadEventHandler;
"sessions:list": EventHandler<Session[]>;
"mentions:list": EventHandler<SharedMention[]>;
"setting:new": EventHandler<{name: string; value: any}>;
"setting:all": EventHandler<{[key: string]: any}>;
"history:clear": EventHandler<{target: number}>;
"mute:changed": EventHandler<{target: number; status: boolean}>;
names: EventHandler<{id: number; users: SharedUser[]}>;
network: EventHandler<{network: SharedNetwork}>;
"network:options": EventHandler<{network: string; serverOptions: SharedServerOptions}>;
"network:status": EventHandler<{network: string; connected: boolean; secure: boolean}>;
"network:info": EventHandler<{uuid: string}>;
"network:name": EventHandler<{uuid: string; name: string}>;
nick: EventHandler<{network: string; nick: string}>;
open: (id: number) => void;
part: EventHandler<{chan: number}>;
"sign-out": NoPayloadEventHandler;
"sync_sort:networks": EventHandler<{order: SharedNetwork["uuid"][]}>;
"sync_sort:channels": EventHandler<{
network: SharedNetwork["uuid"];
order: SharedChan["id"][];
}>;
topic: EventHandler<{chan: number; topic: string}>;
users: EventHandler<{chan: number}>;
more: EventHandler<{chan: number; messages: SharedMsg[]; totalMessages: number}>;
"msg:preview": EventHandler<{id: number; chan: number; preview: LinkPreview}>;
"msg:special": EventHandler<{chan: number; data?: Record<string, any>}>;
msg: EventHandler<{msg: SharedMsg; chan: number; highlight?: number; unread?: number}>;
init: EventHandler<{active: number; networks: SharedNetwork[]; token?: string}>;
"search:results": (response: SearchResponse) => void;
quit: EventHandler<{network: string}>;
error: (error: any) => void;
connecting: NoPayloadEventHandler;
join: EventHandler<{
shouldOpen: boolean;
index: number;
network: string;
chan: SharedNetworkChan;
}>;
}
type AuthPerformData =
| Record<string, never> // funny way of saying an empty object
| {user: string; password: string}
| {
user: string;
token: string;
lastMessage: number;
openChannel: number | null;
hasConfig: boolean;
};
interface ClientToServerEvents {
"auth:perform": EventHandler<AuthPerformData>;
changelog: NoPayloadEventHandler;
"change-password": EventHandler<{
old_password: string;
new_password: string;
verify_password: string;
}>;
open: (channelId: number) => void;
names: EventHandler<{target: number}>;
input: EventHandler<{target: number; text: string}>;
"upload:auth": NoPayloadEventHandler;
"upload:ping": (token: string) => void;
"mute:change": EventHandler<{target: number; setMutedTo: boolean}>;
"push:register": EventHandler<PushSubscriptionJSON>;
"push:unregister": NoPayloadEventHandler;
"setting:get": NoPayloadEventHandler;
"setting:set": EventHandler<{name: string; value: any}>;
"sessions:get": NoPayloadEventHandler;
"sort:networks": EventHandler<{order: SharedNetwork["uuid"][]}>;
"sort:channels": EventHandler<{
network: SharedNetwork["uuid"];
order: SharedChan["id"][];
}>;
"mentions:dismiss": (msgId: number) => void;
"mentions:dismiss_all": NoPayloadEventHandler;
"mentions:get": NoPayloadEventHandler;
more: EventHandler<{target: number; lastId: number; condensed: boolean}>;
"msg:preview:toggle": EventHandler<{
target: number;
messageIds?: number[];
msgId?: number;
shown?: boolean | null;
link?: string;
}>;
"network:get": (uuid: string) => void;
// TODO typing
"network:edit": (data: Record<string, any>) => void;
"network:new": (data: Record<string, any>) => void;
"sign-out": (token?: string) => void;
"history:clear": EventHandler<{target: number}>;
search: EventHandler<SearchQuery>;
}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface InterServerEvents {}
// eslint-disable-next-line @typescript-eslint/no-empty-interface
interface SocketData {}

12
shared/types/storage.ts Normal file
View file

@ -0,0 +1,12 @@
import {SharedMsg} from "./msg";
export type SearchQuery = {
searchTerm: string;
networkUuid: string;
channelName: string;
offset: number;
};
export type SearchResponse = SearchQuery & {
results: SharedMsg[];
};

8
shared/types/user.ts Normal file
View file

@ -0,0 +1,8 @@
export type SharedUser = {
modes: string[];
// Users in the channel have only one mode assigned
mode: string;
away: string;
nick: string;
lastMessage: number;
};

View file

@ -1,6 +1,7 @@
import {expect} from "chai";
import {NetworkConfig} from "../server/models/network";
import {ChanConfig, ChanType} from "../server/models/chan";
import {ChanConfig} from "../server/models/chan";
import {ChanType} from "../shared/types/chan";
import ClientManager from "../server/clientManager";
import Client from "../server/client";
import log from "../server/log";

View file

@ -1,8 +1,6 @@
// @ts-nocheck TODO re-enable
import {expect} from "chai";
import Client from "../../server/client";
import Chan, {ChanType} from "../../server/models/chan";
import Chan from "../../server/models/chan";
import {ChanType} from "../../shared/types/chan";
import ModeCommand from "../../server/plugins/inputs/mode";
describe("Commands", function () {
@ -59,12 +57,16 @@ describe("Commands", function () {
},
});
it("should not mess with the given target", function (this: CommandContext) {
function modeCommandInputCall(net, chan, cmd, args) {
ModeCommand.input.call({} as any, net as any, chan, cmd, Array.from(args));
}
it("should not mess with the given target", function () {
const test = function (expected: string, args: string[]) {
ModeCommand.input(testableNetwork, channel, "mode", Array.from(args));
modeCommandInputCall(testableNetwork, channel, "mode", Array.from(args));
expect(testableNetwork.lastCommand).to.equal(expected);
ModeCommand.input(testableNetwork, lobby, "mode", Array.from(args));
modeCommandInputCall(testableNetwork, lobby, "mode", args);
expect(testableNetwork.lastCommand).to.equal(expected);
};
@ -77,51 +79,51 @@ describe("Commands", function () {
});
it("should assume target if none given", function () {
ModeCommand.input(testableNetwork, channel, "mode", []);
modeCommandInputCall(testableNetwork, channel, "mode", []);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge");
ModeCommand.input(testableNetwork, lobby, "mode", []);
modeCommandInputCall(testableNetwork, lobby, "mode", []);
expect(testableNetwork.lastCommand).to.equal("MODE xPaw");
ModeCommand.input(testableNetwork, channel, "mode", ["+b"]);
modeCommandInputCall(testableNetwork, channel, "mode", ["+b"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge +b");
ModeCommand.input(testableNetwork, lobby, "mode", ["+b"]);
modeCommandInputCall(testableNetwork, lobby, "mode", ["+b"]);
expect(testableNetwork.lastCommand).to.equal("MODE xPaw +b");
ModeCommand.input(testableNetwork, channel, "mode", ["-o", "hey"]);
modeCommandInputCall(testableNetwork, channel, "mode", ["-o", "hey"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -o hey");
ModeCommand.input(testableNetwork, lobby, "mode", ["-i", "idk"]);
modeCommandInputCall(testableNetwork, lobby, "mode", ["-i", "idk"]);
expect(testableNetwork.lastCommand).to.equal("MODE xPaw -i idk");
});
it("should support shorthand commands", function () {
ModeCommand.input(testableNetwork, channel, "op", ["xPaw"]);
modeCommandInputCall(testableNetwork, channel, "op", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge +o xPaw");
ModeCommand.input(testableNetwork, channel, "deop", ["xPaw"]);
modeCommandInputCall(testableNetwork, channel, "deop", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -o xPaw");
ModeCommand.input(testableNetwork, channel, "hop", ["xPaw"]);
modeCommandInputCall(testableNetwork, channel, "hop", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge +h xPaw");
ModeCommand.input(testableNetwork, channel, "dehop", ["xPaw"]);
modeCommandInputCall(testableNetwork, channel, "dehop", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -h xPaw");
ModeCommand.input(testableNetwork, channel, "voice", ["xPaw"]);
modeCommandInputCall(testableNetwork, channel, "voice", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge +v xPaw");
ModeCommand.input(testableNetwork, channel, "devoice", ["xPaw"]);
modeCommandInputCall(testableNetwork, channel, "devoice", ["xPaw"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge -v xPaw");
});
it("should use ISUPPORT MODES on shorthand commands", function () {
ModeCommand.input(testableNetwork, channel, "voice", ["xPaw", "Max-P"]);
modeCommandInputCall(testableNetwork, channel, "voice", ["xPaw", "Max-P"]);
expect(testableNetwork.lastCommand).to.equal("MODE #thelounge +vv xPaw Max-P");
// since the limit for modes on tests is 4, it should send two commands
ModeCommand.input(testableNetwork, channel, "devoice", [
modeCommandInputCall(testableNetwork, channel, "devoice", [
"xPaw",
"Max-P",
"hey",
@ -135,10 +137,10 @@ describe("Commands", function () {
});
it("should fallback to all modes at once for shorthand commands", function () {
ModeCommand.input(testableNetworkNoSupports, channel, "voice", ["xPaw"]);
modeCommandInputCall(testableNetworkNoSupports, channel, "voice", ["xPaw"]);
expect(testableNetworkNoSupports.lastCommand).to.equal("MODE #thelounge +v xPaw");
ModeCommand.input(testableNetworkNoSupports, channel, "devoice", ["xPaw", "Max-P"]);
modeCommandInputCall(testableNetworkNoSupports, channel, "devoice", ["xPaw", "Max-P"]);
expect(testableNetworkNoSupports.lastCommand).to.equal(
"MODE #thelounge -vv xPaw Max-P"
);

View file

@ -195,33 +195,28 @@ describe("Chan", function () {
});
describe("#getFilteredClone(lastActiveChannel, lastMessage)", function () {
it("should send empty user list", function () {
const chan = new Chan();
chan.setUser(new User({nick: "test"}));
expect(chan.getFilteredClone().users).to.be.empty;
});
it("should keep necessary properties", function () {
const chan = new Chan();
expect(chan.getFilteredClone())
.to.be.an("object")
.that.has.all.keys(
"firstUnread",
"highlight",
"id",
"key",
"messages",
"muted",
"totalMessages",
"name",
"state",
"topic",
"type",
"unread",
"users"
);
expect(chan.getFilteredClone()).to.be.an("object").that.has.all.keys(
"firstUnread",
"highlight",
"id",
"key",
"messages",
"muted",
"totalMessages",
"name",
"state",
"topic",
"type",
"unread",
// the following are there in special cases, need to fix the types
"num_users",
"special",
"closed",
"data"
);
});
it("should send only last message for non active channel", function () {

View file

@ -2,7 +2,7 @@ import {expect} from "chai";
import Msg from "../../server/models/msg";
import User from "../../server/models/user";
import {LinkPreview} from "../../server/plugins/irc-events/link";
import {LinkPreview} from "../../shared/types/msg";
describe("Msg", function () {
["from", "target"].forEach((prop) => {

View file

@ -1,7 +1,8 @@
/* eslint-disable @typescript-eslint/no-empty-function */
import {expect} from "chai";
import sinon from "ts-sinon";
import Chan, {ChanType} from "../../server/models/chan";
import Chan from "../../server/models/chan";
import {ChanType} from "../../shared/types/chan";
import Msg from "../../server/models/msg";
import User from "../../server/models/user";
import Network from "../../server/models/network";

View file

@ -3,7 +3,8 @@ import path from "path";
import {expect} from "chai";
import util from "../util";
import Config from "../../server/config";
import link, {LinkPreview} from "../../server/plugins/irc-events/link";
import link from "../../server/plugins/irc-events/link";
import {LinkPreview} from "../../shared/types/msg";
describe("Link plugin", function () {
// Increase timeout due to unpredictable I/O on CI services

View file

@ -1,9 +1,9 @@
/* eslint-disable @typescript-eslint/no-unsafe-return */
import fs from "fs";
import path from "path";
import {expect} from "chai";
import util from "../util";
import Msg, {MessageType} from "../../server/models/msg";
import Msg from "../../server/models/msg";
import {MessageType} from "../../shared/types/msg";
import Config from "../../server/config";
import MessageStorage, {
currentSchemaVersion,

Some files were not shown because too many files have changed in this diff Show more