mirror of
https://github.com/thelounge/thelounge.git
synced 2026-03-14 14:35:50 +01:00
TypeScript and Vue 3 (#4559)
Co-authored-by: Eric Nemchik <eric@nemchik.com> Co-authored-by: Pavel Djundik <xPaw@users.noreply.github.com>
This commit is contained in:
parent
2e3d9a6265
commit
dd05ee3a65
349 changed files with 13388 additions and 8803 deletions
864
server/client.ts
Normal file
864
server/client.ts
Normal file
|
|
@ -0,0 +1,864 @@
|
|||
import _ from "lodash";
|
||||
import UAParser from "ua-parser-js";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import escapeRegExp from "lodash/escapeRegExp";
|
||||
import crypto from "crypto";
|
||||
import colors from "chalk";
|
||||
|
||||
import log from "./log";
|
||||
import Chan, {Channel, ChanType} from "./models/chan";
|
||||
import Msg, {MessageType, UserInMessage} from "./models/msg";
|
||||
import Config from "./config";
|
||||
import constants from "../client/js/constants";
|
||||
|
||||
import inputs from "./plugins/inputs";
|
||||
import PublicClient from "./plugins/packages/publicClient";
|
||||
import SqliteMessageStorage from "./plugins/messageStorage/sqlite";
|
||||
import TextFileMessageStorage from "./plugins/messageStorage/text";
|
||||
import Network, {IgnoreListItem, NetworkWithIrcFramework} from "./models/network";
|
||||
import ClientManager from "./clientManager";
|
||||
import {MessageStorage, SearchQuery} from "./plugins/messageStorage/types";
|
||||
|
||||
type OrderItem = Chan["id"] | Network["uuid"];
|
||||
type Order = OrderItem[];
|
||||
|
||||
const events = [
|
||||
"away",
|
||||
"cap",
|
||||
"connection",
|
||||
"unhandled",
|
||||
"ctcp",
|
||||
"chghost",
|
||||
"error",
|
||||
"help",
|
||||
"info",
|
||||
"invite",
|
||||
"join",
|
||||
"kick",
|
||||
"list",
|
||||
"mode",
|
||||
"modelist",
|
||||
"motd",
|
||||
"message",
|
||||
"names",
|
||||
"nick",
|
||||
"part",
|
||||
"quit",
|
||||
"sasl",
|
||||
"topic",
|
||||
"welcome",
|
||||
"whois",
|
||||
];
|
||||
|
||||
type ClientPushSubscription = {
|
||||
endpoint: string;
|
||||
keys: {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type UserConfig = {
|
||||
log: boolean;
|
||||
password: string;
|
||||
sessions: {
|
||||
[token: string]: {
|
||||
lastUse: number;
|
||||
ip: string;
|
||||
agent: string;
|
||||
pushSubscription?: ClientPushSubscription;
|
||||
};
|
||||
};
|
||||
clientSettings: {
|
||||
[key: string]: any;
|
||||
};
|
||||
browser?: {
|
||||
language?: string;
|
||||
ip?: string;
|
||||
hostname?: string;
|
||||
isSecure?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type Mention = {
|
||||
chanId: number;
|
||||
msgId: number;
|
||||
type: MessageType;
|
||||
time: Date;
|
||||
text: string;
|
||||
from: UserInMessage;
|
||||
};
|
||||
|
||||
class Client {
|
||||
awayMessage!: string;
|
||||
lastActiveChannel!: number;
|
||||
attachedClients!: {
|
||||
[socketId: string]: {token: string; openChannel: number};
|
||||
};
|
||||
config!: UserConfig & {
|
||||
networks?: Network[];
|
||||
};
|
||||
id!: number;
|
||||
idMsg!: number;
|
||||
idChan!: number;
|
||||
name!: string;
|
||||
networks!: Network[];
|
||||
mentions!: Mention[];
|
||||
manager!: ClientManager;
|
||||
messageStorage!: MessageStorage[];
|
||||
highlightRegex!: RegExp | null;
|
||||
highlightExceptionRegex!: RegExp | null;
|
||||
messageProvider?: SqliteMessageStorage;
|
||||
|
||||
fileHash!: string;
|
||||
|
||||
constructor(manager: ClientManager, name?: string, config = {} as UserConfig) {
|
||||
_.merge(this, {
|
||||
awayMessage: "",
|
||||
lastActiveChannel: -1,
|
||||
attachedClients: {},
|
||||
config: config,
|
||||
id: uuidv4(),
|
||||
idChan: 1,
|
||||
idMsg: 1,
|
||||
name: name,
|
||||
networks: [],
|
||||
mentions: [],
|
||||
manager: manager,
|
||||
messageStorage: [],
|
||||
highlightRegex: null,
|
||||
highlightExceptionRegex: null,
|
||||
messageProvider: undefined,
|
||||
});
|
||||
|
||||
const client = this;
|
||||
|
||||
client.config.log = Boolean(client.config.log);
|
||||
client.config.password = String(client.config.password);
|
||||
|
||||
if (!Config.values.public && client.config.log) {
|
||||
if (Config.values.messageStorage.includes("sqlite")) {
|
||||
client.messageProvider = new SqliteMessageStorage(client);
|
||||
client.messageStorage.push(client.messageProvider);
|
||||
}
|
||||
|
||||
if (Config.values.messageStorage.includes("text")) {
|
||||
client.messageStorage.push(new TextFileMessageStorage(client));
|
||||
}
|
||||
|
||||
for (const messageStorage of client.messageStorage) {
|
||||
messageStorage.enable();
|
||||
}
|
||||
}
|
||||
|
||||
if (!_.isPlainObject(client.config.sessions)) {
|
||||
client.config.sessions = {};
|
||||
}
|
||||
|
||||
if (!_.isPlainObject(client.config.clientSettings)) {
|
||||
client.config.clientSettings = {};
|
||||
}
|
||||
|
||||
if (!_.isPlainObject(client.config.browser)) {
|
||||
client.config.browser = {};
|
||||
}
|
||||
|
||||
if (client.config.clientSettings.awayMessage) {
|
||||
client.awayMessage = client.config.clientSettings.awayMessage;
|
||||
}
|
||||
|
||||
client.config.clientSettings.searchEnabled = client.messageProvider !== undefined;
|
||||
|
||||
client.compileCustomHighlights();
|
||||
|
||||
_.forOwn(client.config.sessions, (session) => {
|
||||
if (session.pushSubscription) {
|
||||
this.registerPushSubscription(session, session.pushSubscription, true);
|
||||
}
|
||||
});
|
||||
|
||||
(client.config.networks || []).forEach((network) => client.connect(network, true));
|
||||
|
||||
// Networks are stored directly in the client object
|
||||
// We don't need to keep it in the config object
|
||||
delete client.config.networks;
|
||||
|
||||
if (client.name) {
|
||||
log.info(`User ${colors.bold(client.name)} loaded`);
|
||||
|
||||
// Networks are created instantly, but to reduce server load on startup
|
||||
// We randomize the IRC connections and channel log loading
|
||||
let delay = manager.clients.length * 500;
|
||||
client.networks.forEach((network) => {
|
||||
setTimeout(() => {
|
||||
network.channels.forEach((channel) => channel.loadMessages(client, network));
|
||||
|
||||
if (!network.userDisconnected && network.irc) {
|
||||
network.irc.connect();
|
||||
}
|
||||
}, delay);
|
||||
|
||||
delay += 1000 + Math.floor(Math.random() * 1000);
|
||||
});
|
||||
|
||||
client.fileHash = manager.getDataToSave(client).newHash;
|
||||
}
|
||||
}
|
||||
|
||||
createChannel(attr: Partial<Chan>) {
|
||||
const chan = new Chan(attr);
|
||||
chan.id = this.idChan++;
|
||||
|
||||
return chan;
|
||||
}
|
||||
|
||||
emit(event: string, data?: any) {
|
||||
if (this.manager !== null) {
|
||||
this.manager.sockets.in(this.id.toString()).emit(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
find(channelId: number) {
|
||||
let network: Network | null = null;
|
||||
let chan: Chan | null | undefined = null;
|
||||
|
||||
for (const n of this.networks) {
|
||||
chan = _.find(n.channels, {id: channelId});
|
||||
|
||||
if (chan) {
|
||||
network = n;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (network && chan) {
|
||||
return {network, chan};
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
connect(args: Record<string, any>, isStartup = false) {
|
||||
const client = this;
|
||||
let channels: Chan[] = [];
|
||||
|
||||
// Get channel id for lobby before creating other channels for nicer ids
|
||||
const lobbyChannelId = client.idChan++;
|
||||
|
||||
if (Array.isArray(args.channels)) {
|
||||
let badName = false;
|
||||
|
||||
args.channels.forEach((chan: Chan) => {
|
||||
if (!chan.name) {
|
||||
badName = true;
|
||||
return;
|
||||
}
|
||||
|
||||
channels.push(
|
||||
client.createChannel({
|
||||
name: chan.name,
|
||||
key: chan.key || "",
|
||||
type: chan.type,
|
||||
muted: chan.muted,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
if (badName && client.name) {
|
||||
log.warn(
|
||||
"User '" +
|
||||
client.name +
|
||||
"' on network '" +
|
||||
String(args.name) +
|
||||
"' has an invalid channel which has been ignored"
|
||||
);
|
||||
}
|
||||
// `join` is kept for backwards compatibility when updating from versions <2.0
|
||||
// also used by the "connect" window
|
||||
} else if (args.join) {
|
||||
channels = args.join
|
||||
.replace(/,/g, " ")
|
||||
.split(/\s+/g)
|
||||
.map((chan: string) => {
|
||||
if (!chan.match(/^[#&!+]/)) {
|
||||
chan = `#${chan}`;
|
||||
}
|
||||
|
||||
return client.createChannel({
|
||||
name: chan,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// TODO; better typing for args
|
||||
const network = new Network({
|
||||
uuid: String(args.uuid),
|
||||
name: String(
|
||||
args.name || (Config.values.lockNetwork ? Config.values.defaults.name : "") || ""
|
||||
),
|
||||
host: String(args.host || ""),
|
||||
port: parseInt(String(args.port), 10),
|
||||
tls: !!args.tls,
|
||||
userDisconnected: !!args.userDisconnected,
|
||||
rejectUnauthorized: !!args.rejectUnauthorized,
|
||||
password: String(args.password || ""),
|
||||
nick: String(args.nick || ""),
|
||||
username: String(args.username || ""),
|
||||
realname: String(args.realname || ""),
|
||||
leaveMessage: String(args.leaveMessage || ""),
|
||||
sasl: String(args.sasl || ""),
|
||||
saslAccount: String(args.saslAccount || ""),
|
||||
saslPassword: String(args.saslPassword || ""),
|
||||
commands: (args.commands as string[]) || [],
|
||||
channels: channels,
|
||||
ignoreList: args.ignoreList ? (args.ignoreList as IgnoreListItem[]) : [],
|
||||
|
||||
proxyEnabled: !!args.proxyEnabled,
|
||||
proxyHost: String(args.proxyHost || ""),
|
||||
proxyPort: parseInt(args.proxyPort, 10),
|
||||
proxyUsername: String(args.proxyUsername || ""),
|
||||
proxyPassword: String(args.proxyPassword || ""),
|
||||
});
|
||||
|
||||
// Set network lobby channel id
|
||||
network.channels[0].id = lobbyChannelId;
|
||||
|
||||
client.networks.push(network);
|
||||
client.emit("network", {
|
||||
networks: [network.getFilteredClone(this.lastActiveChannel, -1)],
|
||||
});
|
||||
|
||||
if (!network.validate(client)) {
|
||||
return;
|
||||
}
|
||||
|
||||
(network as NetworkWithIrcFramework).createIrcFramework(client);
|
||||
|
||||
// TODO
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
events.forEach(async (plugin) => {
|
||||
(await import(`./plugins/irc-events/${plugin}`)).default.apply(client, [
|
||||
network.irc,
|
||||
network,
|
||||
]);
|
||||
});
|
||||
|
||||
if (network.userDisconnected) {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: "You have manually disconnected from this network before, use the /connect command to connect again.",
|
||||
}),
|
||||
true
|
||||
);
|
||||
} else if (!isStartup) {
|
||||
// irc is created in createIrcFramework
|
||||
// TODO; fix type
|
||||
network.irc!.connect();
|
||||
}
|
||||
|
||||
if (!isStartup) {
|
||||
client.save();
|
||||
channels.forEach((channel) => channel.loadMessages(client, network));
|
||||
}
|
||||
}
|
||||
|
||||
generateToken(callback: (token: string) => void) {
|
||||
crypto.randomBytes(64, (err, buf) => {
|
||||
if (err) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
callback(buf.toString("hex"));
|
||||
});
|
||||
}
|
||||
|
||||
calculateTokenHash(token: string) {
|
||||
return crypto.createHash("sha512").update(token).digest("hex");
|
||||
}
|
||||
|
||||
updateSession(token: string, ip: string, request: any) {
|
||||
const client = this;
|
||||
const agent = UAParser(request.headers["user-agent"] || "");
|
||||
let friendlyAgent = "";
|
||||
|
||||
if (agent.browser.name) {
|
||||
friendlyAgent = `${agent.browser.name} ${agent.browser.major || ""}`;
|
||||
} else {
|
||||
friendlyAgent = "Unknown browser";
|
||||
}
|
||||
|
||||
if (agent.os.name) {
|
||||
friendlyAgent += ` on ${agent.os.name}`;
|
||||
|
||||
if (agent.os.version) {
|
||||
friendlyAgent += ` ${agent.os.version}`;
|
||||
}
|
||||
}
|
||||
|
||||
client.config.sessions[token] = _.assign(client.config.sessions[token], {
|
||||
lastUse: Date.now(),
|
||||
ip: ip,
|
||||
agent: friendlyAgent,
|
||||
});
|
||||
|
||||
client.save();
|
||||
}
|
||||
|
||||
setPassword(hash: string, callback: (success: boolean) => void) {
|
||||
const client = this;
|
||||
|
||||
const oldHash = client.config.password;
|
||||
client.config.password = hash;
|
||||
client.manager.saveUser(client, function (err) {
|
||||
if (err) {
|
||||
// If user file fails to write, reset it back
|
||||
client.config.password = oldHash;
|
||||
return callback(false);
|
||||
}
|
||||
|
||||
return callback(true);
|
||||
});
|
||||
}
|
||||
|
||||
input(data) {
|
||||
const client = this;
|
||||
data.text.split("\n").forEach((line) => {
|
||||
data.text = line;
|
||||
client.inputLine(data);
|
||||
});
|
||||
}
|
||||
|
||||
inputLine(data) {
|
||||
const client = this;
|
||||
const target = client.find(data.target);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Sending a message to a channel is higher priority than merely opening one
|
||||
// so that reloading the page will open this channel
|
||||
this.lastActiveChannel = target.chan.id;
|
||||
|
||||
let text: string = data.text;
|
||||
|
||||
// This is either a normal message or a command escaped with a leading '/'
|
||||
if (text.charAt(0) !== "/" || text.charAt(1) === "/") {
|
||||
if (target.chan.type === ChanType.LOBBY) {
|
||||
target.chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "Messages can not be sent to lobbies.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
text = "say " + text.replace(/^\//, "");
|
||||
} else {
|
||||
text = text.substring(1);
|
||||
}
|
||||
|
||||
const args = text.split(" ");
|
||||
const cmd = args?.shift()?.toLowerCase() || "";
|
||||
|
||||
const irc = target.network.irc;
|
||||
let connected = irc && irc.connection && irc.connection.connected;
|
||||
|
||||
if (inputs.userInputs.has(cmd)) {
|
||||
const plugin = inputs.userInputs.get(cmd);
|
||||
|
||||
if (!plugin) {
|
||||
// should be a no-op
|
||||
throw new Error(`Plugin ${cmd} not found`);
|
||||
}
|
||||
|
||||
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
|
||||
connected = true;
|
||||
plugin.input.apply(client, [target.network, target.chan, cmd, args]);
|
||||
}
|
||||
} else if (inputs.pluginCommands.has(cmd)) {
|
||||
const plugin = inputs.pluginCommands.get(cmd);
|
||||
|
||||
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
|
||||
connected = true;
|
||||
plugin.input(
|
||||
new PublicClient(client, plugin.packageInfo),
|
||||
{network: target.network, chan: target.chan},
|
||||
cmd,
|
||||
args
|
||||
);
|
||||
}
|
||||
} else if (connected) {
|
||||
// TODO: fix
|
||||
irc!.raw(text);
|
||||
}
|
||||
|
||||
if (!connected) {
|
||||
target.chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You are not connected to the IRC network, unable to send your command.",
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
compileCustomHighlights() {
|
||||
function compileHighlightRegex(customHighlightString: string) {
|
||||
if (typeof customHighlightString !== "string") {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Ensure we don't have empty strings in the list of highlights
|
||||
const highlightsTokens = customHighlightString
|
||||
.split(",")
|
||||
.map((highlight) => escapeRegExp(highlight.trim()))
|
||||
.filter((highlight) => highlight.length > 0);
|
||||
|
||||
if (highlightsTokens.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new RegExp(
|
||||
`(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join(
|
||||
"|"
|
||||
)})(?:$|[ .,+!?|/:<>(){}'"-])`,
|
||||
"i"
|
||||
);
|
||||
}
|
||||
|
||||
this.highlightRegex = compileHighlightRegex(this.config.clientSettings.highlights);
|
||||
this.highlightExceptionRegex = compileHighlightRegex(
|
||||
this.config.clientSettings.highlightExceptions
|
||||
);
|
||||
}
|
||||
|
||||
more(data) {
|
||||
const client = this;
|
||||
const target = client.find(data.target);
|
||||
|
||||
if (!target) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chan = target.chan;
|
||||
let messages: Msg[] = [];
|
||||
let index = 0;
|
||||
|
||||
// If client requests -1, send last 100 messages
|
||||
if (data.lastId < 0) {
|
||||
index = chan.messages.length;
|
||||
} else {
|
||||
index = chan.messages.findIndex((val) => val.id === data.lastId);
|
||||
}
|
||||
|
||||
// If requested id is not found, an empty array will be sent
|
||||
if (index > 0) {
|
||||
let startIndex = index;
|
||||
|
||||
if (data.condensed) {
|
||||
// Limit to 1000 messages (that's 10x normal limit)
|
||||
const indexToStop = Math.max(0, index - 1000);
|
||||
let realMessagesLeft = 100;
|
||||
|
||||
for (let i = index - 1; i >= indexToStop; i--) {
|
||||
startIndex--;
|
||||
|
||||
// Do not count condensed messages towards the 100 messages
|
||||
if (constants.condensedTypes.has(chan.messages[i].type)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Count up actual 100 visible messages
|
||||
if (--realMessagesLeft === 0) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
startIndex = Math.max(0, index - 100);
|
||||
}
|
||||
|
||||
messages = chan.messages.slice(startIndex, index);
|
||||
}
|
||||
|
||||
return {
|
||||
chan: chan.id,
|
||||
messages: messages,
|
||||
totalMessages: chan.messages.length,
|
||||
};
|
||||
}
|
||||
|
||||
clearHistory(data) {
|
||||
const client = this;
|
||||
const target = client.find(data.target);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
target.chan.messages = [];
|
||||
target.chan.unread = 0;
|
||||
target.chan.highlight = 0;
|
||||
target.chan.firstUnread = 0;
|
||||
|
||||
client.emit("history:clear", {
|
||||
target: target.chan.id,
|
||||
});
|
||||
|
||||
if (!target.chan.isLoggable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const messageStorage of this.messageStorage) {
|
||||
messageStorage.deleteChannel(target.network, target.chan);
|
||||
}
|
||||
}
|
||||
|
||||
search(query: SearchQuery) {
|
||||
if (this.messageProvider === undefined) {
|
||||
return Promise.resolve({
|
||||
results: [],
|
||||
target: "",
|
||||
networkUuid: "",
|
||||
offset: 0,
|
||||
searchTerm: query?.searchTerm,
|
||||
});
|
||||
}
|
||||
|
||||
return this.messageProvider.search(query);
|
||||
}
|
||||
|
||||
open(socketId: string, target: number) {
|
||||
// Due to how socket.io works internally, normal events may arrive later than
|
||||
// the disconnect event, and because we can't control this timing precisely,
|
||||
// process this event normally even if there is no attached client anymore.
|
||||
const attachedClient =
|
||||
this.attachedClients[socketId] ||
|
||||
({} as Record<string, typeof this.attachedClients[0]>);
|
||||
|
||||
// Opening a window like settings
|
||||
if (target === null) {
|
||||
attachedClient.openChannel = -1;
|
||||
return;
|
||||
}
|
||||
|
||||
const targetNetChan = this.find(target);
|
||||
|
||||
if (!targetNetChan) {
|
||||
return;
|
||||
}
|
||||
|
||||
targetNetChan.chan.unread = 0;
|
||||
targetNetChan.chan.highlight = 0;
|
||||
|
||||
if (targetNetChan.chan.messages.length > 0) {
|
||||
targetNetChan.chan.firstUnread =
|
||||
targetNetChan.chan.messages[targetNetChan.chan.messages.length - 1].id;
|
||||
}
|
||||
|
||||
attachedClient.openChannel = targetNetChan.chan.id;
|
||||
this.lastActiveChannel = targetNetChan.chan.id;
|
||||
|
||||
this.emit("open", targetNetChan.chan.id);
|
||||
}
|
||||
|
||||
sort(data: {order: Order; type: "networks" | "channels"; target: string}) {
|
||||
const order = data.order;
|
||||
|
||||
if (!_.isArray(order)) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
this.save();
|
||||
}
|
||||
|
||||
names(data: {target: number}) {
|
||||
const client = this;
|
||||
const target = client.find(data.target);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
client.emit("names", {
|
||||
id: target.chan.id,
|
||||
users: target.chan.getSortedUsers(target.network.irc),
|
||||
});
|
||||
}
|
||||
|
||||
part(network: Network, chan: Chan) {
|
||||
const client = this;
|
||||
network.channels = _.without(network.channels, chan);
|
||||
client.mentions = client.mentions.filter((msg) => !(msg.chanId === chan.id));
|
||||
chan.destroy();
|
||||
client.save();
|
||||
client.emit("part", {
|
||||
chan: chan.id,
|
||||
});
|
||||
}
|
||||
|
||||
quit(signOut?: boolean) {
|
||||
const sockets = this.manager.sockets.sockets;
|
||||
const room = sockets.adapter.rooms.get(this.id.toString());
|
||||
|
||||
if (room) {
|
||||
for (const user of room) {
|
||||
const socket = sockets.sockets.get(user);
|
||||
|
||||
if (socket) {
|
||||
if (signOut) {
|
||||
socket.emit("sign-out");
|
||||
}
|
||||
|
||||
socket.disconnect();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.networks.forEach((network) => {
|
||||
network.quit();
|
||||
network.destroy();
|
||||
});
|
||||
|
||||
for (const messageStorage of this.messageStorage) {
|
||||
messageStorage.close();
|
||||
}
|
||||
}
|
||||
|
||||
clientAttach(socketId: string, token: string) {
|
||||
const client = this;
|
||||
|
||||
if (client.awayMessage && _.size(client.attachedClients) === 0) {
|
||||
client.networks.forEach(function (network) {
|
||||
// Only remove away on client attachment if
|
||||
// there is no away message on this network
|
||||
if (network.irc && !network.awayMessage) {
|
||||
network.irc.raw("AWAY");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const openChannel = client.lastActiveChannel;
|
||||
client.attachedClients[socketId] = {token, openChannel};
|
||||
}
|
||||
|
||||
clientDetach(socketId: string) {
|
||||
const client = this;
|
||||
|
||||
delete this.attachedClients[socketId];
|
||||
|
||||
if (client.awayMessage && _.size(client.attachedClients) === 0) {
|
||||
client.networks.forEach(function (network) {
|
||||
// Only set away on client deattachment if
|
||||
// there is no away message on this network
|
||||
if (network.irc && !network.awayMessage) {
|
||||
network.irc.raw("AWAY", client.awayMessage);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: type session to this.attachedClients
|
||||
registerPushSubscription(session: any, subscription: ClientPushSubscription, noSave = false) {
|
||||
if (
|
||||
!_.isPlainObject(subscription) ||
|
||||
!_.isPlainObject(subscription.keys) ||
|
||||
typeof subscription.endpoint !== "string" ||
|
||||
!/^https?:\/\//.test(subscription.endpoint) ||
|
||||
typeof subscription.keys.p256dh !== "string" ||
|
||||
typeof subscription.keys.auth !== "string"
|
||||
) {
|
||||
session.pushSubscription = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const data = {
|
||||
endpoint: subscription.endpoint,
|
||||
keys: {
|
||||
p256dh: subscription.keys.p256dh,
|
||||
auth: subscription.keys.auth,
|
||||
},
|
||||
};
|
||||
|
||||
session.pushSubscription = data;
|
||||
|
||||
if (!noSave) {
|
||||
this.save();
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
unregisterPushSubscription(token: string) {
|
||||
this.config.sessions[token].pushSubscription = undefined;
|
||||
this.save();
|
||||
}
|
||||
|
||||
save = _.debounce(
|
||||
function SaveClient(this: Client) {
|
||||
if (Config.values.public) {
|
||||
return;
|
||||
}
|
||||
|
||||
const client = this;
|
||||
client.manager.saveUser(client);
|
||||
},
|
||||
5000,
|
||||
{maxWait: 20000}
|
||||
);
|
||||
}
|
||||
|
||||
export default Client;
|
||||
|
||||
// TODO: this should exist elsewhere?
|
||||
export type IrcEventHandler = (
|
||||
this: Client,
|
||||
irc: NetworkWithIrcFramework["irc"],
|
||||
network: NetworkWithIrcFramework
|
||||
) => void;
|
||||
296
server/clientManager.ts
Normal file
296
server/clientManager.ts
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
import _ from "lodash";
|
||||
import colors from "chalk";
|
||||
import crypto from "crypto";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
|
||||
import Auth from "./plugins/auth";
|
||||
import Client, {UserConfig} from "./client";
|
||||
import Config from "./config";
|
||||
import WebPush from "./plugins/webpush";
|
||||
import log from "./log";
|
||||
import {Server} from "socket.io";
|
||||
|
||||
class ClientManager {
|
||||
clients: Client[];
|
||||
sockets!: Server;
|
||||
identHandler: any;
|
||||
webPush!: WebPush;
|
||||
|
||||
constructor() {
|
||||
this.clients = [];
|
||||
}
|
||||
|
||||
init(identHandler, sockets: Server) {
|
||||
this.sockets = sockets;
|
||||
this.identHandler = identHandler;
|
||||
this.webPush = new WebPush();
|
||||
|
||||
if (!Config.values.public) {
|
||||
this.loadUsers();
|
||||
|
||||
// LDAP does not have user commands, and users are dynamically
|
||||
// created upon logon, so we don't need to watch for new files
|
||||
if (!Config.values.ldap.enable) {
|
||||
this.autoloadUsers();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
findClient(name: string) {
|
||||
name = name.toLowerCase();
|
||||
return this.clients.find((u) => u.name.toLowerCase() === name);
|
||||
}
|
||||
|
||||
loadUsers() {
|
||||
let users = this.getUsers();
|
||||
|
||||
if (users.length === 0) {
|
||||
log.info(
|
||||
`There are currently no users. Create one with ${colors.bold(
|
||||
"thelounge add <name>"
|
||||
)}.`
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const alreadySeenUsers = new Set();
|
||||
users = users.filter((user) => {
|
||||
user = user.toLowerCase();
|
||||
|
||||
if (alreadySeenUsers.has(user)) {
|
||||
log.error(
|
||||
`There is more than one user named "${colors.bold(
|
||||
user
|
||||
)}". Usernames are now case insensitive, duplicate users will not load.`
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
alreadySeenUsers.add(user);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// This callback is used by Auth plugins to load users they deem acceptable
|
||||
const callbackLoadUser = (user) => {
|
||||
this.loadUser(user);
|
||||
};
|
||||
|
||||
if (!Auth.loadUsers(users, callbackLoadUser)) {
|
||||
// Fallback to loading all users
|
||||
users.forEach((name) => this.loadUser(name));
|
||||
}
|
||||
}
|
||||
|
||||
autoloadUsers() {
|
||||
fs.watch(
|
||||
Config.getUsersPath(),
|
||||
_.debounce(
|
||||
() => {
|
||||
const loaded = this.clients.map((c) => c.name);
|
||||
const updatedUsers = this.getUsers();
|
||||
|
||||
if (updatedUsers.length === 0) {
|
||||
log.info(
|
||||
`There are currently no users. Create one with ${colors.bold(
|
||||
"thelounge add <name>"
|
||||
)}.`
|
||||
);
|
||||
}
|
||||
|
||||
// Reload all users. Existing users will only have their passwords reloaded.
|
||||
updatedUsers.forEach((name) => this.loadUser(name));
|
||||
|
||||
// Existing users removed since last time users were loaded
|
||||
_.difference(loaded, updatedUsers).forEach((name) => {
|
||||
const client = _.find(this.clients, {name});
|
||||
|
||||
if (client) {
|
||||
client.quit(true);
|
||||
this.clients = _.without(this.clients, client);
|
||||
log.info(`User ${colors.bold(name)} disconnected and removed.`);
|
||||
}
|
||||
});
|
||||
},
|
||||
1000,
|
||||
{maxWait: 10000}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
loadUser(name: string) {
|
||||
const userConfig = this.readUserConfig(name);
|
||||
|
||||
if (!userConfig) {
|
||||
return;
|
||||
}
|
||||
|
||||
let client = this.findClient(name);
|
||||
|
||||
if (client) {
|
||||
if (userConfig.password !== client.config.password) {
|
||||
/**
|
||||
* If we happen to reload an existing client, make super duper sure we
|
||||
* have their latest password. We're not replacing the entire config
|
||||
* object, because that could have undesired consequences.
|
||||
*
|
||||
* @see https://github.com/thelounge/thelounge/issues/598
|
||||
*/
|
||||
client.config.password = userConfig.password;
|
||||
log.info(`Password for user ${colors.bold(name)} was reset.`);
|
||||
}
|
||||
} else {
|
||||
client = new Client(this, name, userConfig);
|
||||
this.clients.push(client);
|
||||
}
|
||||
|
||||
return client;
|
||||
}
|
||||
|
||||
getUsers = function () {
|
||||
if (!fs.existsSync(Config.getUsersPath())) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(Config.getUsersPath())
|
||||
.filter((file) => file.endsWith(".json"))
|
||||
.map((file) => file.slice(0, -5));
|
||||
};
|
||||
|
||||
addUser(name: string, password: string | null, enableLog?: boolean) {
|
||||
if (path.basename(name) !== name) {
|
||||
throw new Error(`${name} is an invalid username.`);
|
||||
}
|
||||
|
||||
const userPath = Config.getUserConfigPath(name);
|
||||
|
||||
if (fs.existsSync(userPath)) {
|
||||
log.error(`User ${colors.green(name)} already exists.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const user = {
|
||||
password: password || "",
|
||||
log: enableLog,
|
||||
};
|
||||
|
||||
try {
|
||||
fs.writeFileSync(userPath, JSON.stringify(user, null, "\t"), {
|
||||
mode: 0o600,
|
||||
});
|
||||
} catch (e: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.error(`Failed to create user ${colors.green(name)} (${e})`);
|
||||
throw e;
|
||||
}
|
||||
|
||||
try {
|
||||
const userFolderStat = fs.statSync(Config.getUsersPath());
|
||||
const userFileStat = fs.statSync(userPath);
|
||||
|
||||
if (
|
||||
userFolderStat &&
|
||||
userFileStat &&
|
||||
(userFolderStat.uid !== userFileStat.uid || userFolderStat.gid !== userFileStat.gid)
|
||||
) {
|
||||
log.warn(
|
||||
`User ${colors.green(
|
||||
name
|
||||
)} has been created, but with a different uid (or gid) than expected.`
|
||||
);
|
||||
log.warn(
|
||||
"The file owner has been changed to the expected user. " +
|
||||
"To prevent any issues, please run thelounge commands " +
|
||||
"as the correct user that owns the config folder."
|
||||
);
|
||||
log.warn(
|
||||
"See https://thelounge.chat/docs/usage#using-the-correct-system-user for more information."
|
||||
);
|
||||
fs.chownSync(userPath, userFolderStat.uid, userFolderStat.gid);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// We're simply verifying file owner as a safe guard for users
|
||||
// that run `thelounge add` as root, so we don't care if it fails
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
getDataToSave(client: Client) {
|
||||
const json = Object.assign({}, client.config, {
|
||||
networks: client.networks.map((n) => n.export()),
|
||||
});
|
||||
const newUser = JSON.stringify(json, null, "\t");
|
||||
const newHash = crypto.createHash("sha256").update(newUser).digest("hex");
|
||||
|
||||
return {newUser, newHash};
|
||||
}
|
||||
|
||||
saveUser(client: Client, callback?: (err?: any) => void) {
|
||||
const {newUser, newHash} = this.getDataToSave(client);
|
||||
|
||||
// Do not write to disk if the exported data hasn't actually changed
|
||||
if (client.fileHash === newHash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pathReal = Config.getUserConfigPath(client.name);
|
||||
const pathTemp = pathReal + ".tmp";
|
||||
|
||||
try {
|
||||
// Write to a temp file first, in case the write fails
|
||||
// we do not lose the original file (for example when disk is full)
|
||||
fs.writeFileSync(pathTemp, newUser, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(pathTemp, pathReal);
|
||||
|
||||
return callback ? callback() : true;
|
||||
} catch (e: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.error(`Failed to update user ${colors.green(client.name)} (${e})`);
|
||||
|
||||
if (callback) {
|
||||
callback(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
removeUser(name) {
|
||||
const userPath = Config.getUserConfigPath(name);
|
||||
|
||||
if (!fs.existsSync(userPath)) {
|
||||
log.error(`Tried to remove non-existing user ${colors.green(name)}.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
fs.unlinkSync(userPath);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private readUserConfig(name: string) {
|
||||
const userPath = Config.getUserConfigPath(name);
|
||||
|
||||
if (!fs.existsSync(userPath)) {
|
||||
log.error(`Tried to read non-existing user ${colors.green(name)}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const data = fs.readFileSync(userPath, "utf-8");
|
||||
return JSON.parse(data) as UserConfig;
|
||||
} catch (e: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.error(`Failed to read user ${colors.bold(name)}: ${e}`);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export default ClientManager;
|
||||
118
server/command-line/index.ts
Normal file
118
server/command-line/index.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import log from "../log";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import colors from "chalk";
|
||||
import {Command} from "commander";
|
||||
import Helper from "../helper";
|
||||
import Config from "../config";
|
||||
import Utils from "./utils";
|
||||
|
||||
const program = new Command("thelounge");
|
||||
program
|
||||
.version(Helper.getVersion(), "-v, --version")
|
||||
.option(
|
||||
"-c, --config <key=value>",
|
||||
"override entries of the configuration file, must be specified for each entry that needs to be overriden",
|
||||
Utils.parseConfigOptions
|
||||
)
|
||||
.on("--help", Utils.extraHelp);
|
||||
|
||||
// Parse options from `argv` returning `argv` void of these options.
|
||||
const argvWithoutOptions = program.parseOptions(process.argv);
|
||||
|
||||
Config.setHome(process.env.THELOUNGE_HOME || Utils.defaultHome());
|
||||
|
||||
// Check config file owner and warn if we're running under a different user
|
||||
try {
|
||||
verifyFileOwner();
|
||||
} catch (e: any) {
|
||||
// We do not care about failures of these checks
|
||||
// fs.statSync will throw if config.js does not exist (e.g. first run)
|
||||
}
|
||||
|
||||
// Create packages/package.json
|
||||
createPackagesFolder();
|
||||
|
||||
// Merge config key-values passed as CLI options into the main config
|
||||
Config.merge(program.opts().config);
|
||||
|
||||
program.addCommand(require("./start").default);
|
||||
program.addCommand(require("./install").default);
|
||||
program.addCommand(require("./uninstall").default);
|
||||
program.addCommand(require("./upgrade").default);
|
||||
program.addCommand(require("./outdated").default);
|
||||
|
||||
if (!Config.values.public) {
|
||||
require("./users").default.forEach((command: Command) => {
|
||||
if (command) {
|
||||
program.addCommand(command);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// `parse` expects to be passed `process.argv`, but we need to remove to give it
|
||||
// a version of `argv` that does not contain options already parsed by
|
||||
// `parseOptions` above.
|
||||
// This is done by giving it the updated `argv` that `parseOptions` returned,
|
||||
// except it returns an object with `operands`/`unknown`, so we need to concat them.
|
||||
// See https://github.com/tj/commander.js/blob/fefda77f463292/index.js#L686-L763
|
||||
program.parse(argvWithoutOptions.operands.concat(argvWithoutOptions.unknown));
|
||||
|
||||
function createPackagesFolder() {
|
||||
const packagesPath = Config.getPackagesPath();
|
||||
const packagesConfig = path.join(packagesPath, "package.json");
|
||||
|
||||
// Create node_modules folder, otherwise yarn will start walking upwards to find one
|
||||
fs.mkdirSync(path.join(packagesPath, "node_modules"), {recursive: true});
|
||||
|
||||
// Create package.json with private set to true, if it doesn't exist already
|
||||
if (!fs.existsSync(packagesConfig)) {
|
||||
fs.writeFileSync(
|
||||
packagesConfig,
|
||||
JSON.stringify(
|
||||
{
|
||||
private: true,
|
||||
description:
|
||||
"Packages for The Lounge. Use `thelounge install <package>` command to add a package.",
|
||||
dependencies: {},
|
||||
},
|
||||
null,
|
||||
"\t"
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function verifyFileOwner() {
|
||||
if (!process.getuid) {
|
||||
return;
|
||||
}
|
||||
|
||||
const uid = process.getuid();
|
||||
|
||||
if (uid === 0) {
|
||||
log.warn(
|
||||
`You are currently running The Lounge as root. ${colors.bold.red(
|
||||
"We highly discourage running as root!"
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
const configStat = fs.statSync(path.join(Config.getHomePath(), "config.js"));
|
||||
|
||||
if (configStat && configStat.uid !== uid) {
|
||||
log.warn(
|
||||
"Config file owner does not match the user you are currently running The Lounge as."
|
||||
);
|
||||
log.warn(
|
||||
"To prevent any issues, please run thelounge commands " +
|
||||
"as the correct user that owns the config folder."
|
||||
);
|
||||
log.warn(
|
||||
"See https://thelounge.chat/docs/usage#using-the-correct-system-user for more information."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default program;
|
||||
109
server/command-line/install.ts
Normal file
109
server/command-line/install.ts
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
import log from "../log";
|
||||
import colors from "chalk";
|
||||
import semver from "semver";
|
||||
import Helper from "../helper";
|
||||
import Config from "../config";
|
||||
import Utils from "./utils";
|
||||
import {Command} from "commander";
|
||||
import {FullMetadata} from "package-json";
|
||||
|
||||
type CustomMetadata = FullMetadata & {
|
||||
thelounge: {
|
||||
supports: string;
|
||||
};
|
||||
};
|
||||
|
||||
const program = new Command("install");
|
||||
program
|
||||
.argument("<package>", "package to install")
|
||||
.description("Install a theme or a package")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.action(async function (packageName: string) {
|
||||
const fs = await import("fs");
|
||||
const fspromises = fs.promises;
|
||||
const path = await import("path");
|
||||
const packageJson = await import("package-json");
|
||||
|
||||
if (!fs.existsSync(Config.getConfigPath())) {
|
||||
log.error(`${Config.getConfigPath()} does not exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Retrieving information about the package...");
|
||||
// TODO: type
|
||||
let readFile: any = null;
|
||||
let isLocalFile = false;
|
||||
|
||||
if (packageName.startsWith("file:")) {
|
||||
isLocalFile = true;
|
||||
readFile = fspromises
|
||||
.readFile(path.join(packageName.substr("file:".length), "package.json"), "utf-8")
|
||||
.then((data) => JSON.parse(data) as typeof packageJson);
|
||||
} else {
|
||||
const split = packageName.split("@");
|
||||
packageName = split[0];
|
||||
const packageVersion = split[1] || "latest";
|
||||
|
||||
readFile = packageJson.default(packageName, {
|
||||
fullMetadata: true,
|
||||
version: packageVersion,
|
||||
});
|
||||
}
|
||||
|
||||
if (!readFile) {
|
||||
// no-op, error should've been thrown before this point
|
||||
return;
|
||||
}
|
||||
|
||||
readFile
|
||||
.then((json: CustomMetadata) => {
|
||||
const humanVersion = isLocalFile ? packageName : `${json.name} v${json.version}`;
|
||||
|
||||
if (!("thelounge" in json)) {
|
||||
log.error(`${colors.red(humanVersion)} does not have The Lounge metadata.`);
|
||||
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (
|
||||
json.thelounge.supports &&
|
||||
!semver.satisfies(Helper.getVersionNumber(), json.thelounge.supports)
|
||||
) {
|
||||
log.error(
|
||||
`${colors.red(
|
||||
humanVersion
|
||||
)} does not support The Lounge v${Helper.getVersionNumber()}. Supported version(s): ${
|
||||
json.thelounge.supports
|
||||
}`
|
||||
);
|
||||
|
||||
process.exit(2);
|
||||
}
|
||||
|
||||
log.info(`Installing ${colors.green(humanVersion)}...`);
|
||||
const yarnVersion = isLocalFile ? packageName : `${json.name}@${json.version}`;
|
||||
return Utils.executeYarnCommand("add", "--exact", yarnVersion)
|
||||
.then(() => {
|
||||
log.info(`${colors.green(humanVersion)} has been successfully installed.`);
|
||||
|
||||
if (isLocalFile) {
|
||||
// yarn v1 is buggy if a local filepath is used and doesn't update
|
||||
// the lockfile properly. We need to run an install in that case
|
||||
// even though that's supposed to be done by the add subcommand
|
||||
return Utils.executeYarnCommand("install").catch((err) => {
|
||||
throw `Failed to update lockfile after package install ${err}`;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((code) => {
|
||||
throw `Failed to install ${colors.red(humanVersion)}. Exit code: ${code}`;
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
log.error(`${e}`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
export default program;
|
||||
27
server/command-line/outdated.ts
Normal file
27
server/command-line/outdated.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import {Command} from "commander";
|
||||
import Utils from "./utils";
|
||||
import packageManager from "../plugins/packages";
|
||||
import log from "../log";
|
||||
|
||||
const program = new Command("outdated");
|
||||
program
|
||||
.description("Check for any outdated packages")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.action(async () => {
|
||||
log.info("Checking for outdated packages");
|
||||
|
||||
await packageManager
|
||||
.outdated(0)
|
||||
.then((outdated) => {
|
||||
if (outdated) {
|
||||
log.info("There are outdated packages");
|
||||
} else {
|
||||
log.info("No outdated packages");
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
log.error("Error finding outdated packages.");
|
||||
});
|
||||
});
|
||||
|
||||
export default program;
|
||||
37
server/command-line/start.ts
Normal file
37
server/command-line/start.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import log from "../log";
|
||||
import colors from "chalk";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import {Command} from "commander";
|
||||
import Config from "../config";
|
||||
import Utils from "./utils";
|
||||
|
||||
const program = new Command("start");
|
||||
program
|
||||
.description("Start the server")
|
||||
.option("--dev", "Development mode with hot module reloading")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.action(function (options) {
|
||||
initalizeConfig();
|
||||
|
||||
const newLocal = "../server";
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const server = require(newLocal);
|
||||
server.default(options);
|
||||
});
|
||||
|
||||
function initalizeConfig() {
|
||||
if (!fs.existsSync(Config.getConfigPath())) {
|
||||
fs.mkdirSync(Config.getHomePath(), {recursive: true});
|
||||
fs.chmodSync(Config.getHomePath(), "0700");
|
||||
fs.copyFileSync(
|
||||
path.resolve(path.join(__dirname, "..", "..", "defaults", "config.js")),
|
||||
Config.getConfigPath()
|
||||
);
|
||||
log.info(`Configuration file created at ${colors.green(Config.getConfigPath())}.`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(Config.getUsersPath(), {recursive: true, mode: 0o700});
|
||||
}
|
||||
|
||||
export default program;
|
||||
42
server/command-line/uninstall.ts
Normal file
42
server/command-line/uninstall.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import log from "../log";
|
||||
import colors from "chalk";
|
||||
import {Command} from "commander";
|
||||
import Config from "../config";
|
||||
import Utils from "./utils";
|
||||
|
||||
const program = new Command("uninstall");
|
||||
program
|
||||
.argument("<package>", "The package to uninstall")
|
||||
.description("Uninstall a theme or a package")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.action(async function (packageName: string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require("fs").promises;
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const path = require("path");
|
||||
|
||||
const packagesConfig = path.join(Config.getPackagesPath(), "package.json");
|
||||
// const packages = JSON.parse(fs.readFileSync(packagesConfig, "utf-8"));
|
||||
const packages = JSON.parse(await fs.readFile(packagesConfig, "utf-8"));
|
||||
|
||||
if (
|
||||
!packages.dependencies ||
|
||||
!Object.prototype.hasOwnProperty.call(packages.dependencies, packageName)
|
||||
) {
|
||||
log.warn(`${colors.green(packageName)} is not installed.`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
log.info(`Uninstalling ${colors.green(packageName)}...`);
|
||||
|
||||
try {
|
||||
await Utils.executeYarnCommand("remove", packageName);
|
||||
log.info(`${colors.green(packageName)} has been successfully uninstalled.`);
|
||||
} catch (code_1) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.error(`Failed to uninstall ${colors.green(packageName)}. Exit code: ${code_1}`);
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
export default program;
|
||||
68
server/command-line/upgrade.ts
Normal file
68
server/command-line/upgrade.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import log from "../log";
|
||||
import colors from "chalk";
|
||||
import {Command} from "commander";
|
||||
import Config from "../config";
|
||||
import Utils from "./utils";
|
||||
|
||||
const program = new Command("upgrade");
|
||||
program
|
||||
.arguments("[packages...]")
|
||||
.description("Upgrade installed themes and packages to their latest versions")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.action(function (packages) {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Get paths to the location of packages directory
|
||||
const packagesConfig = path.join(Config.getPackagesPath(), "package.json");
|
||||
const packagesList = JSON.parse(fs.readFileSync(packagesConfig, "utf-8")).dependencies;
|
||||
const argsList = ["upgrade", "--latest"];
|
||||
|
||||
let count = 0;
|
||||
|
||||
if (!Object.entries(packagesList).length) {
|
||||
log.warn("There are no packages installed.");
|
||||
return;
|
||||
}
|
||||
|
||||
// If a package names are supplied, check they exist
|
||||
if (packages.length) {
|
||||
log.info("Upgrading the following packages:");
|
||||
packages.forEach((p) => {
|
||||
log.info(`- ${colors.green(p)}`);
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(packagesList, p)) {
|
||||
argsList.push(p);
|
||||
count++;
|
||||
} else {
|
||||
log.error(`${colors.green(p)} is not installed.`);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
log.info("Upgrading all packages...");
|
||||
}
|
||||
|
||||
if (count === 0 && packages.length) {
|
||||
log.warn("There are not any packages to upgrade.");
|
||||
return;
|
||||
}
|
||||
|
||||
const command = argsList.shift();
|
||||
const params = argsList;
|
||||
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
return Utils.executeYarnCommand(command, ...params)
|
||||
.then(() => {
|
||||
log.info("Package(s) have been successfully upgraded.");
|
||||
})
|
||||
.catch((code) => {
|
||||
log.error(`Failed to upgrade package(s). Exit code ${String(code)}`);
|
||||
process.exit(1);
|
||||
});
|
||||
});
|
||||
|
||||
export default program;
|
||||
83
server/command-line/users/add.ts
Normal file
83
server/command-line/users/add.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import log from "../../log";
|
||||
import colors from "chalk";
|
||||
import {Command} from "commander";
|
||||
import fs from "fs";
|
||||
import Helper from "../../helper";
|
||||
import Config from "../../config";
|
||||
import Utils from "../utils";
|
||||
|
||||
const program = new Command("add");
|
||||
program
|
||||
.description("Add a new user")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.option("--password [password]", "new password, will be prompted if not specified")
|
||||
.option("--save-logs", "if password is specified, this enables saving logs to disk")
|
||||
.argument("<name>", "name of the user")
|
||||
.action(function (name, cmdObj) {
|
||||
if (!fs.existsSync(Config.getUsersPath())) {
|
||||
log.error(`${Config.getUsersPath()} does not exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ClientManager = require("../../clientManager");
|
||||
const manager = new ClientManager();
|
||||
const users = manager.getUsers();
|
||||
|
||||
if (users === undefined) {
|
||||
// There was an error, already logged
|
||||
return;
|
||||
}
|
||||
|
||||
if (users.includes(name)) {
|
||||
log.error(`User ${colors.bold(name)} already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmdObj.password) {
|
||||
add(manager, name, cmdObj.password, !!cmdObj.saveLogs);
|
||||
return;
|
||||
}
|
||||
|
||||
log.prompt(
|
||||
{
|
||||
text: "Enter password:",
|
||||
silent: true,
|
||||
},
|
||||
function (err, password) {
|
||||
if (!password) {
|
||||
log.error("Password cannot be empty.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!err) {
|
||||
log.prompt(
|
||||
{
|
||||
text: "Save logs to disk?",
|
||||
default: "yes",
|
||||
},
|
||||
function (err2, enableLog) {
|
||||
if (!err2) {
|
||||
add(
|
||||
manager,
|
||||
name,
|
||||
password,
|
||||
enableLog.charAt(0).toLowerCase() === "y"
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function add(manager, name, password, enableLog) {
|
||||
const hash = Helper.password.hash(password);
|
||||
manager.addUser(name, hash, enableLog);
|
||||
|
||||
log.info(`User ${colors.bold(name)} created.`);
|
||||
log.info(`User file located at ${colors.green(Config.getUserConfigPath(name))}.`);
|
||||
}
|
||||
|
||||
export default program;
|
||||
48
server/command-line/users/edit.ts
Normal file
48
server/command-line/users/edit.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import log from "../../log";
|
||||
import {Command} from "commander";
|
||||
import child from "child_process";
|
||||
import colors from "chalk";
|
||||
import fs from "fs";
|
||||
import Config from "../../config";
|
||||
import Utils from "../utils";
|
||||
|
||||
const program = new Command("edit");
|
||||
program
|
||||
.description(`Edit user file located at ${colors.green(Config.getUserConfigPath("<name>"))}`)
|
||||
.argument("<name>", "name of the user")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.action(function (name) {
|
||||
if (!fs.existsSync(Config.getUsersPath())) {
|
||||
log.error(`${Config.getUsersPath()} does not exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ClientManager = require("../../clientManager");
|
||||
const users = new ClientManager().getUsers();
|
||||
|
||||
if (users === undefined) {
|
||||
// There was an error, already logged
|
||||
return;
|
||||
}
|
||||
|
||||
if (!users.includes(name)) {
|
||||
log.error(`User ${colors.bold(name)} does not exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const child_spawn = child.spawn(
|
||||
process.env.EDITOR || "vi",
|
||||
[Config.getUserConfigPath(name)],
|
||||
{stdio: "inherit"}
|
||||
);
|
||||
child_spawn.on("error", function () {
|
||||
log.error(
|
||||
`Unable to open ${colors.green(Config.getUserConfigPath(name))}. ${colors.bold(
|
||||
"$EDITOR"
|
||||
)} is not set, and ${colors.bold("vi")} was not found.`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
export default program;
|
||||
15
server/command-line/users/index.ts
Normal file
15
server/command-line/users/index.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
import Config from "../../config";
|
||||
let add, reset;
|
||||
|
||||
if (!Config.values.ldap.enable) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
add = require("./add").default;
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
reset = require("./reset").default;
|
||||
}
|
||||
|
||||
import list from "./list";
|
||||
import remove from "./remove";
|
||||
import edit from "./edit";
|
||||
|
||||
export default [list, remove, edit, add, reset];
|
||||
34
server/command-line/users/list.ts
Normal file
34
server/command-line/users/list.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import log from "../../log";
|
||||
import colors from "chalk";
|
||||
import {Command} from "commander";
|
||||
import Utils from "../utils";
|
||||
|
||||
const program = new Command("list");
|
||||
program
|
||||
.description("List all users")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.action(async function () {
|
||||
const ClientManager = (await import("../../clientManager")).default;
|
||||
const users = new ClientManager().getUsers();
|
||||
|
||||
if (users === undefined) {
|
||||
// There was an error, already logged
|
||||
return;
|
||||
}
|
||||
|
||||
if (users.length === 0) {
|
||||
log.info(
|
||||
`There are currently no users. Create one with ${colors.bold(
|
||||
"thelounge add <name>"
|
||||
)}.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info("Users:");
|
||||
users.forEach((user, i) => {
|
||||
log.info(`${i + 1}. ${colors.bold(user)}`);
|
||||
});
|
||||
});
|
||||
|
||||
export default program;
|
||||
34
server/command-line/users/remove.ts
Normal file
34
server/command-line/users/remove.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import log from "../../log";
|
||||
import colors from "chalk";
|
||||
import {Command} from "commander";
|
||||
import fs from "fs";
|
||||
import Config from "../../config";
|
||||
import Utils from "../utils";
|
||||
|
||||
const program = new Command("remove");
|
||||
program
|
||||
.description("Remove an existing user")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.argument("<name>", "name of the user")
|
||||
.action(function (name) {
|
||||
if (!fs.existsSync(Config.getUsersPath())) {
|
||||
log.error(`${Config.getUsersPath()} does not exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ClientManager = require("../../clientManager");
|
||||
const manager = new ClientManager();
|
||||
|
||||
try {
|
||||
if (manager.removeUser(name)) {
|
||||
log.info(`User ${colors.bold(name)} removed.`);
|
||||
} else {
|
||||
log.error(`User ${colors.bold(name)} does not exist.`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
// There was an error, already logged
|
||||
}
|
||||
});
|
||||
|
||||
export default program;
|
||||
75
server/command-line/users/reset.ts
Normal file
75
server/command-line/users/reset.ts
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import log from "../../log";
|
||||
import colors from "chalk";
|
||||
import {Command} from "commander";
|
||||
import fs from "fs";
|
||||
import Helper from "../../helper";
|
||||
import Config from "../../config";
|
||||
import Utils from "../utils";
|
||||
|
||||
const program = new Command("reset");
|
||||
program
|
||||
.description("Reset user password")
|
||||
.on("--help", Utils.extraHelp)
|
||||
.argument("<name>", "name of the user")
|
||||
.option("--password [password]", "new password, will be prompted if not specified")
|
||||
.action(function (name, cmdObj) {
|
||||
if (!fs.existsSync(Config.getUsersPath())) {
|
||||
log.error(`${Config.getUsersPath()} does not exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const ClientManager = require("../../clientManager");
|
||||
const users = new ClientManager().getUsers();
|
||||
|
||||
if (users === undefined) {
|
||||
// There was an error, already logged
|
||||
return;
|
||||
}
|
||||
|
||||
if (!users.includes(name)) {
|
||||
log.error(`User ${colors.bold(name)} does not exist.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmdObj.password) {
|
||||
change(name, cmdObj.password);
|
||||
return;
|
||||
}
|
||||
|
||||
log.prompt(
|
||||
{
|
||||
text: "Enter new password:",
|
||||
silent: true,
|
||||
},
|
||||
function (err, password) {
|
||||
if (err) {
|
||||
return;
|
||||
}
|
||||
|
||||
change(name, password);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
function change(name, password) {
|
||||
const pathReal = Config.getUserConfigPath(name);
|
||||
const pathTemp = pathReal + ".tmp";
|
||||
const user = JSON.parse(fs.readFileSync(pathReal, "utf-8"));
|
||||
|
||||
user.password = Helper.password.hash(password);
|
||||
user.sessions = {};
|
||||
|
||||
const newUser = JSON.stringify(user, null, "\t");
|
||||
|
||||
// Write to a temp file first, in case the write fails
|
||||
// we do not lose the original file (for example when disk is full)
|
||||
fs.writeFileSync(pathTemp, newUser, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.renameSync(pathTemp, pathReal);
|
||||
|
||||
log.info(`Successfully reset password for ${colors.bold(name)}.`);
|
||||
}
|
||||
|
||||
export default program;
|
||||
190
server/command-line/utils.ts
Normal file
190
server/command-line/utils.ts
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
import _ from "lodash";
|
||||
import log from "../log";
|
||||
import colors from "chalk";
|
||||
import fs from "fs";
|
||||
import Helper from "../helper";
|
||||
import Config from "../config";
|
||||
import path from "path";
|
||||
import {spawn} from "child_process";
|
||||
let home: string;
|
||||
|
||||
class Utils {
|
||||
static extraHelp(this: void) {
|
||||
[
|
||||
"",
|
||||
"Environment variable:",
|
||||
` THELOUNGE_HOME Path for all configuration files and folders. Defaults to ${colors.green(
|
||||
Helper.expandHome(Utils.defaultHome())
|
||||
)}`,
|
||||
"",
|
||||
].forEach((e) => log.raw(e));
|
||||
}
|
||||
|
||||
static defaultHome() {
|
||||
if (home) {
|
||||
return home;
|
||||
}
|
||||
|
||||
const distConfig = Utils.getFileFromRelativeToRoot(".thelounge_home");
|
||||
|
||||
home = fs.readFileSync(distConfig, "utf-8").trim();
|
||||
|
||||
return home;
|
||||
}
|
||||
|
||||
static getFileFromRelativeToRoot(...fileName: string[]) {
|
||||
// e.g. /thelounge/server/command-line/utils.ts
|
||||
if (process.env.NODE_ENV === "test" || process.env.NODE_ENV === "development") {
|
||||
return path.resolve(path.join(__dirname, "..", "..", ...fileName));
|
||||
}
|
||||
|
||||
// e.g. /thelounge/dist/server/command-line/utils.ts
|
||||
return path.resolve(path.join(__dirname, "..", "..", "..", ...fileName));
|
||||
}
|
||||
|
||||
// Parses CLI options such as `-c public=true`, `-c debug.raw=true`, etc.
|
||||
static parseConfigOptions(this: void, val: string, memo?: any) {
|
||||
// Invalid option that is not of format `key=value`, do nothing
|
||||
if (!val.includes("=")) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return memo;
|
||||
}
|
||||
|
||||
const parseValue = (value: string) => {
|
||||
switch (value) {
|
||||
case "true":
|
||||
return true;
|
||||
case "false":
|
||||
return false;
|
||||
case "undefined":
|
||||
return undefined;
|
||||
case "null":
|
||||
return null;
|
||||
default:
|
||||
if (/^-?[0-9]+$/.test(value)) {
|
||||
// Numbers like port
|
||||
return parseInt(value, 10);
|
||||
} else if (/^\[.*\]$/.test(value)) {
|
||||
// Arrays
|
||||
// Supporting arrays `[a,b]` and `[a, b]`
|
||||
const array = value.slice(1, -1).split(/,\s*/);
|
||||
|
||||
// If [] is given, it will be parsed as `[ "" ]`, so treat this as empty
|
||||
if (array.length === 1 && array[0] === "") {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array.map(parseValue) as Array<Record<string, string>>; // Re-parses all values of the array
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
};
|
||||
|
||||
// First time the option is parsed, memo is not set
|
||||
if (memo === undefined) {
|
||||
memo = {};
|
||||
}
|
||||
|
||||
// Note: If passed `-c foo="bar=42"` (with single or double quotes), `val`
|
||||
// will always be passed as `foo=bar=42`, never with quotes.
|
||||
const position = val.indexOf("="); // Only split on the first = found
|
||||
const key = val.slice(0, position);
|
||||
const value = val.slice(position + 1);
|
||||
const parsedValue = parseValue(value);
|
||||
|
||||
if (_.has(memo, key)) {
|
||||
log.warn(`Configuration key ${colors.bold(key)} was already specified, ignoring...`);
|
||||
} else {
|
||||
memo = _.set(memo, key, parsedValue);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return memo;
|
||||
}
|
||||
|
||||
static executeYarnCommand(command: string, ...parameters: string[]) {
|
||||
const yarn = require.resolve("yarn/bin/yarn.js");
|
||||
const packagesPath = Config.getPackagesPath();
|
||||
const cachePath = path.join(packagesPath, "package_manager_cache");
|
||||
|
||||
const staticParameters = [
|
||||
"--cache-folder",
|
||||
cachePath,
|
||||
"--cwd",
|
||||
packagesPath,
|
||||
"--json",
|
||||
"--ignore-scripts",
|
||||
"--non-interactive",
|
||||
];
|
||||
|
||||
const env = {
|
||||
// We only ever operate in production mode
|
||||
NODE_ENV: "production",
|
||||
|
||||
// If The Lounge runs from a user that does not have a home directory,
|
||||
// yarn may fail when it tries to read certain folders,
|
||||
// we give it an existing folder so the reads do not throw a permission error.
|
||||
// Yarn uses os.homedir() to figure out the path, which internally reads
|
||||
// from the $HOME env on unix. On Windows it uses $USERPROFILE, but
|
||||
// the user folder should always exist on Windows, so we don't set it.
|
||||
HOME: cachePath,
|
||||
};
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let success = false;
|
||||
const add = spawn(
|
||||
process.execPath,
|
||||
[yarn, command, ...staticParameters, ...parameters],
|
||||
{env: env}
|
||||
);
|
||||
|
||||
add.stdout.on("data", (data) => {
|
||||
data.toString()
|
||||
.trim()
|
||||
.split("\n")
|
||||
.forEach((line) => {
|
||||
try {
|
||||
line = JSON.parse(line);
|
||||
|
||||
if (line.type === "success") {
|
||||
success = true;
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Stdout buffer has limitations and yarn may print
|
||||
// big package trees, for example in the upgrade command
|
||||
// See https://github.com/thelounge/thelounge/issues/3679
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
add.stderr.on("data", (data) => {
|
||||
data.toString()
|
||||
.trim()
|
||||
.split("\n")
|
||||
.forEach((line: string) => {
|
||||
const json = JSON.parse(line);
|
||||
|
||||
if (json.type === "error") {
|
||||
log.error(json.data);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
add.on("error", (e) => {
|
||||
log.error(`${e.message}:`, e.stack || "");
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
add.on("close", (code) => {
|
||||
if (!success || code !== 0) {
|
||||
return reject(code);
|
||||
}
|
||||
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Utils;
|
||||
288
server/config.ts
Normal file
288
server/config.ts
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
/* eslint-disable @typescript-eslint/no-var-requires */
|
||||
import path from "path";
|
||||
import fs, {Stats} from "fs";
|
||||
import os from "os";
|
||||
import _ from "lodash";
|
||||
import colors from "chalk";
|
||||
|
||||
import log from "./log";
|
||||
import Helper from "./helper";
|
||||
import Utils from "./command-line/utils";
|
||||
import Network from "./models/network";
|
||||
|
||||
// TODO: Type this
|
||||
export type WebIRC = {
|
||||
[key: string]: any;
|
||||
};
|
||||
|
||||
type Https = {
|
||||
enable: boolean;
|
||||
key: string;
|
||||
certificate: string;
|
||||
ca: string;
|
||||
};
|
||||
|
||||
type FileUpload = {
|
||||
enable: boolean;
|
||||
maxFileSize: number;
|
||||
baseUrl?: string;
|
||||
};
|
||||
|
||||
export type Defaults = Pick<
|
||||
Network,
|
||||
| "name"
|
||||
| "host"
|
||||
| "port"
|
||||
| "password"
|
||||
| "tls"
|
||||
| "rejectUnauthorized"
|
||||
| "nick"
|
||||
| "username"
|
||||
| "realname"
|
||||
| "leaveMessage"
|
||||
| "sasl"
|
||||
| "saslAccount"
|
||||
| "saslPassword"
|
||||
> & {
|
||||
join?: string;
|
||||
};
|
||||
|
||||
type Identd = {
|
||||
enable: boolean;
|
||||
port: number;
|
||||
};
|
||||
|
||||
type SearchDN = {
|
||||
rootDN: string;
|
||||
rootPassword: string;
|
||||
filter: string;
|
||||
base: string;
|
||||
scope: string;
|
||||
};
|
||||
|
||||
type Ldap = {
|
||||
enable: boolean;
|
||||
url: string;
|
||||
tlsOptions: any;
|
||||
primaryKey: string;
|
||||
searchDN: SearchDN;
|
||||
baseDN?: string;
|
||||
};
|
||||
|
||||
type TlsOptions = any;
|
||||
|
||||
type Debug = {
|
||||
ircFramework: boolean;
|
||||
raw: boolean;
|
||||
};
|
||||
|
||||
export type ConfigType = {
|
||||
public: boolean;
|
||||
host: string | undefined;
|
||||
port: number;
|
||||
bind: string | undefined;
|
||||
reverseProxy: boolean;
|
||||
maxHistory: number;
|
||||
https: Https;
|
||||
theme: string;
|
||||
prefetch: boolean;
|
||||
disableMediaPreview: boolean;
|
||||
prefetchStorage: boolean;
|
||||
prefetchMaxImageSize: number;
|
||||
prefetchMaxSearchSize: number;
|
||||
prefetchTimeout: number;
|
||||
fileUpload: FileUpload;
|
||||
transports: string[];
|
||||
leaveMessage: string;
|
||||
defaults: Defaults;
|
||||
lockNetwork: boolean;
|
||||
messageStorage: string[];
|
||||
useHexIp: boolean;
|
||||
webirc?: WebIRC;
|
||||
identd: Identd;
|
||||
oidentd?: string;
|
||||
ldap: Ldap;
|
||||
debug: Debug;
|
||||
themeColor: string;
|
||||
};
|
||||
|
||||
class Config {
|
||||
values = require(path.resolve(
|
||||
path.join(__dirname, "..", "defaults", "config.js")
|
||||
)) as ConfigType;
|
||||
#homePath = "";
|
||||
|
||||
getHomePath() {
|
||||
return this.#homePath;
|
||||
}
|
||||
|
||||
getConfigPath() {
|
||||
return path.join(this.#homePath, "config.js");
|
||||
}
|
||||
|
||||
getUserLogsPath() {
|
||||
return path.join(this.#homePath, "logs");
|
||||
}
|
||||
|
||||
getStoragePath() {
|
||||
return path.join(this.#homePath, "storage");
|
||||
}
|
||||
|
||||
getFileUploadPath() {
|
||||
return path.join(this.#homePath, "uploads");
|
||||
}
|
||||
|
||||
getUsersPath() {
|
||||
return path.join(this.#homePath, "users");
|
||||
}
|
||||
|
||||
getUserConfigPath(name: string) {
|
||||
return path.join(this.getUsersPath(), `${name}.json`);
|
||||
}
|
||||
|
||||
getClientCertificatesPath() {
|
||||
return path.join(this.#homePath, "certificates");
|
||||
}
|
||||
|
||||
getPackagesPath() {
|
||||
return path.join(this.#homePath, "packages");
|
||||
}
|
||||
|
||||
getPackageModulePath(packageName: string) {
|
||||
return path.join(this.getPackagesPath(), "node_modules", packageName);
|
||||
}
|
||||
|
||||
getDefaultNick() {
|
||||
if (!this.values.defaults.nick) {
|
||||
return "thelounge";
|
||||
}
|
||||
|
||||
return this.values.defaults.nick.replace(/%/g, () =>
|
||||
Math.floor(Math.random() * 10).toString()
|
||||
);
|
||||
}
|
||||
|
||||
merge(newConfig: ConfigType) {
|
||||
this._merge_config_objects(this.values, newConfig);
|
||||
}
|
||||
|
||||
_merge_config_objects(oldConfig: ConfigType, newConfig: ConfigType) {
|
||||
// semi exposed function so that we can test it
|
||||
// it mutates the oldConfig, but returns it as a convenience for testing
|
||||
|
||||
for (const key in newConfig) {
|
||||
if (!Object.prototype.hasOwnProperty.call(oldConfig, key)) {
|
||||
log.warn(`Unknown key "${colors.bold(key)}", please verify your config.`);
|
||||
}
|
||||
}
|
||||
|
||||
return _.mergeWith(oldConfig, newConfig, (objValue, srcValue, key) => {
|
||||
// Do not override config variables if the type is incorrect (e.g. object changed into a string)
|
||||
if (
|
||||
typeof objValue !== "undefined" &&
|
||||
objValue !== null &&
|
||||
typeof objValue !== typeof srcValue
|
||||
) {
|
||||
log.warn(`Incorrect type for "${colors.bold(key)}", please verify your config.`);
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return objValue;
|
||||
}
|
||||
|
||||
// For arrays, simply override the value with user provided one.
|
||||
if (_.isArray(objValue)) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return srcValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setHome(newPath: string) {
|
||||
this.#homePath = Helper.expandHome(newPath);
|
||||
|
||||
// Reload config from new home location
|
||||
const configPath = this.getConfigPath();
|
||||
|
||||
if (fs.existsSync(configPath)) {
|
||||
const userConfig = require(configPath);
|
||||
|
||||
if (_.isEmpty(userConfig)) {
|
||||
log.warn(
|
||||
`The file located at ${colors.green(
|
||||
configPath
|
||||
)} does not appear to expose anything.`
|
||||
);
|
||||
log.warn(
|
||||
`Make sure it is non-empty and the configuration is exported using ${colors.bold(
|
||||
"module.exports = { ... }"
|
||||
)}.`
|
||||
);
|
||||
log.warn("Using default configuration...");
|
||||
}
|
||||
|
||||
this.merge(userConfig);
|
||||
}
|
||||
|
||||
if (this.values.fileUpload.baseUrl) {
|
||||
try {
|
||||
new URL("test/file.png", this.values.fileUpload.baseUrl);
|
||||
} catch (e: any) {
|
||||
this.values.fileUpload.baseUrl = undefined;
|
||||
|
||||
log.warn(
|
||||
`The ${colors.bold("fileUpload.baseUrl")} you specified is invalid: ${String(
|
||||
e
|
||||
)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const manifestPath = Utils.getFileFromRelativeToRoot("public", "thelounge.webmanifest");
|
||||
|
||||
// Check if manifest exists, if not, the app most likely was not built
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
log.error(
|
||||
`The client application was not built. Run ${colors.bold(
|
||||
"NODE_ENV=production yarn build"
|
||||
)} to resolve this.`
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Load theme color from the web manifest
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
this.values.themeColor = manifest.theme_color;
|
||||
|
||||
// log dir probably shouldn't be world accessible.
|
||||
// Create it with the desired permission bits if it doesn't exist yet.
|
||||
let logsStat: Stats | undefined = undefined;
|
||||
|
||||
const userLogsPath = this.getUserLogsPath();
|
||||
|
||||
try {
|
||||
logsStat = fs.statSync(userLogsPath);
|
||||
} catch {
|
||||
// ignored on purpose, node v14.17.0 will give us {throwIfNoEntry: false}
|
||||
}
|
||||
|
||||
if (!logsStat) {
|
||||
try {
|
||||
fs.mkdirSync(userLogsPath, {recursive: true, mode: 0o750});
|
||||
} catch (e: any) {
|
||||
log.error("Unable to create logs directory", e);
|
||||
}
|
||||
} else if (logsStat && logsStat.mode & 0o001) {
|
||||
log.warn(
|
||||
userLogsPath,
|
||||
"is world readable.",
|
||||
"The log files may be exposed. Please fix the permissions."
|
||||
);
|
||||
|
||||
if (os.platform() !== "win32") {
|
||||
log.warn(`run \`chmod o-x "${userLogsPath}"\` to correct it.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default new Config();
|
||||
185
server/helper.ts
Normal file
185
server/helper.ts
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
import pkg from "../package.json";
|
||||
import _ from "lodash";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import fs from "fs";
|
||||
import net from "net";
|
||||
import bcrypt from "bcryptjs";
|
||||
import crypto from "crypto";
|
||||
|
||||
export type Hostmask = {
|
||||
nick: string;
|
||||
ident: string;
|
||||
hostname: string;
|
||||
};
|
||||
|
||||
const Helper = {
|
||||
expandHome,
|
||||
getVersion,
|
||||
getVersionCacheBust,
|
||||
getVersionNumber,
|
||||
getGitCommit,
|
||||
ip2hex,
|
||||
parseHostmask,
|
||||
compareHostmask,
|
||||
compareWithWildcard,
|
||||
|
||||
password: {
|
||||
hash: passwordHash,
|
||||
compare: passwordCompare,
|
||||
requiresUpdate: passwordRequiresUpdate,
|
||||
},
|
||||
};
|
||||
|
||||
export default Helper;
|
||||
|
||||
function getVersion() {
|
||||
const gitCommit = getGitCommit();
|
||||
const version = `v${pkg.version}`;
|
||||
return gitCommit ? `source (${gitCommit} / ${version})` : version;
|
||||
}
|
||||
|
||||
function getVersionNumber() {
|
||||
return pkg.version;
|
||||
}
|
||||
|
||||
let _gitCommit: string | null = null;
|
||||
|
||||
function getGitCommit() {
|
||||
if (_gitCommit) {
|
||||
return _gitCommit;
|
||||
}
|
||||
|
||||
if (!fs.existsSync(path.resolve(__dirname, "..", ".git"))) {
|
||||
_gitCommit = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
_gitCommit = require("child_process")
|
||||
.execSync(
|
||||
"git rev-parse --short HEAD", // Returns hash of current commit
|
||||
{stdio: ["ignore", "pipe", "ignore"]}
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
return _gitCommit;
|
||||
} catch (e: any) {
|
||||
// Not a git repository or git is not installed
|
||||
_gitCommit = null;
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVersionCacheBust() {
|
||||
const hash = crypto.createHash("sha256").update(Helper.getVersion()).digest("hex");
|
||||
|
||||
return hash.substring(0, 10);
|
||||
}
|
||||
|
||||
function ip2hex(address: string) {
|
||||
// no ipv6 support
|
||||
if (!net.isIPv4(address)) {
|
||||
return "00000000";
|
||||
}
|
||||
|
||||
return address
|
||||
.split(".")
|
||||
.map(function (octet) {
|
||||
let hex = parseInt(octet, 10).toString(16);
|
||||
|
||||
if (hex.length === 1) {
|
||||
hex = "0" + hex;
|
||||
}
|
||||
|
||||
return hex;
|
||||
})
|
||||
.join("");
|
||||
}
|
||||
|
||||
// Expand ~ into the current user home dir.
|
||||
// This does *not* support `~other_user/tmp` => `/home/other_user/tmp`.
|
||||
function expandHome(shortenedPath: string) {
|
||||
if (!shortenedPath) {
|
||||
return "";
|
||||
}
|
||||
|
||||
const home = os.homedir().replace("$", "$$$$");
|
||||
return path.resolve(shortenedPath.replace(/^~($|\/|\\)/, home + "$1"));
|
||||
}
|
||||
|
||||
function passwordRequiresUpdate(password: string) {
|
||||
return bcrypt.getRounds(password) !== 11;
|
||||
}
|
||||
|
||||
function passwordHash(password: string) {
|
||||
return bcrypt.hashSync(password, bcrypt.genSaltSync(11));
|
||||
}
|
||||
|
||||
function passwordCompare(password: string, expected: string) {
|
||||
return bcrypt.compare(password, expected);
|
||||
}
|
||||
|
||||
function parseHostmask(hostmask: string): Hostmask {
|
||||
let nick = "";
|
||||
let ident = "*";
|
||||
let hostname = "*";
|
||||
let parts: string[] = [];
|
||||
|
||||
// Parse hostname first, then parse the rest
|
||||
parts = hostmask.split("@");
|
||||
|
||||
if (parts.length >= 2) {
|
||||
hostname = parts[1] || "*";
|
||||
hostmask = parts[0];
|
||||
}
|
||||
|
||||
hostname = hostname.toLowerCase();
|
||||
|
||||
parts = hostmask.split("!");
|
||||
|
||||
if (parts.length >= 2) {
|
||||
ident = parts[1] || "*";
|
||||
hostmask = parts[0];
|
||||
}
|
||||
|
||||
ident = ident.toLowerCase();
|
||||
|
||||
nick = hostmask.toLowerCase() || "*";
|
||||
|
||||
const result = {
|
||||
nick: nick,
|
||||
ident: ident,
|
||||
hostname: hostname,
|
||||
};
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function compareHostmask(a: Hostmask, b: Hostmask) {
|
||||
return (
|
||||
compareWithWildcard(a.nick, b.nick) &&
|
||||
compareWithWildcard(a.ident, b.ident) &&
|
||||
compareWithWildcard(a.hostname, b.hostname)
|
||||
);
|
||||
}
|
||||
|
||||
function compareWithWildcard(a: string, b: string) {
|
||||
// we allow '*' and '?' wildcards in our comparison.
|
||||
// this is mostly aligned with https://modern.ircdocs.horse/#wildcard-expressions
|
||||
// but we do not support the escaping. The ABNF does not seem to be clear as to
|
||||
// how to escape the escape char '\', which is valid in a nick,
|
||||
// whereas the wildcards tend not to be (as per RFC1459).
|
||||
|
||||
// The "*" wildcard is ".*" in regex, "?" is "."
|
||||
// so we tokenize and join with the proper char back together,
|
||||
// escaping any other regex modifier
|
||||
const wildmany_split = a.split("*").map((sub) => {
|
||||
const wildone_split = sub.split("?").map((p) => _.escapeRegExp(p));
|
||||
return wildone_split.join(".");
|
||||
});
|
||||
const user_regex = wildmany_split.join(".*");
|
||||
const re = new RegExp(`^${user_regex}$`, "i"); // case insensitive
|
||||
return re.test(b);
|
||||
}
|
||||
140
server/identification.ts
Normal file
140
server/identification.ts
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
import log from "./log";
|
||||
import fs from "fs";
|
||||
import net, {Socket} from "net";
|
||||
import colors from "chalk";
|
||||
import Helper from "./helper";
|
||||
import Config from "./config";
|
||||
|
||||
type Connection = {
|
||||
socket: Socket;
|
||||
user: string;
|
||||
};
|
||||
class Identification {
|
||||
private connectionId: number;
|
||||
private connections: Map<number, Connection>;
|
||||
private oidentdFile?: string;
|
||||
|
||||
constructor(startedCallback: (identHandler: Identification, err?: Error) => void) {
|
||||
this.connectionId = 0;
|
||||
this.connections = new Map();
|
||||
|
||||
if (typeof Config.values.oidentd === "string") {
|
||||
this.oidentdFile = Helper.expandHome(Config.values.oidentd);
|
||||
log.info(`Oidentd file: ${colors.green(this.oidentdFile)}`);
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
if (Config.values.identd.enable) {
|
||||
if (this.oidentdFile) {
|
||||
log.warn(
|
||||
"Using both identd and oidentd at the same time, this is most likely not intended."
|
||||
);
|
||||
}
|
||||
|
||||
const server = net.createServer(this.serverConnection.bind(this));
|
||||
|
||||
server.on("error", (err) => {
|
||||
startedCallback(this, err);
|
||||
});
|
||||
|
||||
server.listen(
|
||||
{
|
||||
port: Config.values.identd.port || 113,
|
||||
host: Config.values.bind,
|
||||
},
|
||||
() => {
|
||||
const address = server.address();
|
||||
|
||||
if (typeof address === "string") {
|
||||
log.info(`Identd server available on ${colors.green(address)}`);
|
||||
} else if (address?.address) {
|
||||
log.info(
|
||||
`Identd server available on ${colors.green(
|
||||
address.address + ":" + address.port.toString()
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
startedCallback(this);
|
||||
}
|
||||
);
|
||||
} else {
|
||||
startedCallback(this);
|
||||
}
|
||||
}
|
||||
|
||||
serverConnection(socket: Socket) {
|
||||
socket.on("error", (err: string) => log.error(`Identd socket error: ${err}`));
|
||||
socket.on("data", (data) => {
|
||||
this.respondToIdent(socket, data);
|
||||
socket.end();
|
||||
});
|
||||
}
|
||||
|
||||
respondToIdent(socket: Socket, buffer: Buffer) {
|
||||
const data = buffer.toString().split(",");
|
||||
|
||||
const lport = parseInt(data[0], 10) || 0;
|
||||
const fport = parseInt(data[1], 10) || 0;
|
||||
|
||||
if (lport < 1 || fport < 1 || lport > 65535 || fport > 65535) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const connection of this.connections.values()) {
|
||||
if (connection.socket.remotePort === fport && connection.socket.localPort === lport) {
|
||||
return socket.write(
|
||||
`${lport}, ${fport} : USERID : TheLounge : ${connection.user}\r\n`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
socket.write(`${lport}, ${fport} : ERROR : NO-USER\r\n`);
|
||||
}
|
||||
|
||||
addSocket(socket: Socket, user: string) {
|
||||
const id = ++this.connectionId;
|
||||
|
||||
this.connections.set(id, {socket, user});
|
||||
|
||||
if (this.oidentdFile) {
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
removeSocket(id: number) {
|
||||
this.connections.delete(id);
|
||||
|
||||
if (this.oidentdFile) {
|
||||
this.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
refresh() {
|
||||
let file = "# Warning: file generated by The Lounge: changes will be overwritten!\n";
|
||||
|
||||
this.connections.forEach((connection) => {
|
||||
if (!connection.socket.remotePort || !connection.socket.localPort) {
|
||||
throw new Error("Socket has no remote or local port");
|
||||
}
|
||||
|
||||
file +=
|
||||
`fport ${connection.socket.remotePort}` +
|
||||
` lport ${connection.socket.localPort}` +
|
||||
` { reply "${connection.user}" }\n`;
|
||||
});
|
||||
|
||||
if (this.oidentdFile) {
|
||||
fs.writeFile(this.oidentdFile, file, {flag: "w+"}, function (err) {
|
||||
if (err) {
|
||||
log.error("Failed to update oidentd file!", err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Identification;
|
||||
1
server/index.d.ts
vendored
Normal file
1
server/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
import "./types";
|
||||
8
server/index.ts
Executable file
8
server/index.ts
Executable file
|
|
@ -0,0 +1,8 @@
|
|||
import * as dns from "dns";
|
||||
|
||||
// Set DNS result order early before anything that may depend on it happens.
|
||||
if (dns.setDefaultResultOrder) {
|
||||
dns.setDefaultResultOrder("verbatim");
|
||||
}
|
||||
|
||||
import "./command-line";
|
||||
38
server/log.ts
Normal file
38
server/log.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import colors from "chalk";
|
||||
import read from "read";
|
||||
|
||||
function timestamp() {
|
||||
const datetime = new Date().toISOString().split(".")[0].replace("T", " ");
|
||||
|
||||
return colors.dim(datetime);
|
||||
}
|
||||
|
||||
const log = {
|
||||
/* eslint-disable no-console */
|
||||
error(...args: string[]) {
|
||||
console.error(timestamp(), colors.red("[ERROR]"), ...args);
|
||||
},
|
||||
warn(...args: string[]) {
|
||||
console.error(timestamp(), colors.yellow("[WARN]"), ...args);
|
||||
},
|
||||
info(...args: string[]) {
|
||||
console.log(timestamp(), colors.blue("[INFO]"), ...args);
|
||||
},
|
||||
debug(...args: string[]) {
|
||||
console.log(timestamp(), colors.green("[DEBUG]"), ...args);
|
||||
},
|
||||
raw(...args: string[]) {
|
||||
console.log(...args);
|
||||
},
|
||||
/* eslint-enable no-console */
|
||||
|
||||
prompt(
|
||||
options: {prompt?: string; default?: string; text: string; silent?: boolean},
|
||||
callback: (error, result, isDefault) => void
|
||||
): void {
|
||||
options.prompt = [timestamp(), colors.cyan("[PROMPT]"), options.text].join(" ");
|
||||
read(options, callback);
|
||||
},
|
||||
};
|
||||
|
||||
export default log;
|
||||
336
server/models/chan.ts
Normal file
336
server/models/chan.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import _ from "lodash";
|
||||
import log from "../log";
|
||||
import Config from "../config";
|
||||
import User from "./user";
|
||||
import Msg, {MessageType} 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;
|
||||
};
|
||||
|
||||
class Chan {
|
||||
// TODO: don't force existence, figure out how to make TS infer it.
|
||||
id!: number;
|
||||
messages!: Msg[];
|
||||
name!: string;
|
||||
key!: string;
|
||||
topic!: string;
|
||||
firstUnread!: number;
|
||||
unread!: number;
|
||||
highlight!: number;
|
||||
users!: Map<string, User>;
|
||||
muted!: boolean;
|
||||
type!: ChanType;
|
||||
state!: ChanState;
|
||||
|
||||
userAway?: boolean;
|
||||
special?: SpecialChanType;
|
||||
data?: any;
|
||||
closed?: boolean;
|
||||
num_users?: number;
|
||||
static optionalProperties = ["userAway", "special", "data", "closed", "num_users"];
|
||||
|
||||
constructor(attr?: Partial<Chan>) {
|
||||
_.defaults(this, attr, {
|
||||
id: 0,
|
||||
messages: [],
|
||||
name: "",
|
||||
key: "",
|
||||
topic: "",
|
||||
type: ChanType.CHANNEL,
|
||||
state: ChanState.PARTED,
|
||||
firstUnread: 0,
|
||||
unread: 0,
|
||||
highlight: 0,
|
||||
users: new Map(),
|
||||
muted: false,
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.dereferencePreviews(this.messages);
|
||||
}
|
||||
|
||||
pushMessage(client: Client, msg: Msg, increasesUnread = false) {
|
||||
const chan = this.id;
|
||||
const obj = {chan, msg} as {
|
||||
chan: number;
|
||||
msg: Msg;
|
||||
unread?: number;
|
||||
highlight?: number;
|
||||
};
|
||||
|
||||
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;
|
||||
|
||||
if (msg.self) {
|
||||
// reset counters/markers when receiving self-/echo-message
|
||||
this.unread = 0;
|
||||
this.firstUnread = msg.id;
|
||||
this.highlight = 0;
|
||||
} else if (!isOpen) {
|
||||
if (!this.firstUnread) {
|
||||
this.firstUnread = msg.id;
|
||||
}
|
||||
|
||||
if (increasesUnread || msg.highlight) {
|
||||
obj.unread = ++this.unread;
|
||||
}
|
||||
|
||||
if (msg.highlight) {
|
||||
obj.highlight = ++this.highlight;
|
||||
}
|
||||
}
|
||||
|
||||
client.emit("msg", obj);
|
||||
|
||||
// Never store messages in public mode as the session
|
||||
// is completely destroyed when the page gets closed
|
||||
if (Config.values.public) {
|
||||
return;
|
||||
}
|
||||
|
||||
// showInActive is only processed on "msg", don't need it on page reload
|
||||
if (msg.showInActive) {
|
||||
delete msg.showInActive;
|
||||
}
|
||||
|
||||
this.writeUserLog(client, msg);
|
||||
|
||||
if (Config.values.maxHistory >= 0 && this.messages.length > Config.values.maxHistory) {
|
||||
const deleted = this.messages.splice(
|
||||
0,
|
||||
this.messages.length - Config.values.maxHistory
|
||||
);
|
||||
|
||||
// If maxHistory is 0, image would be dereferenced before client had a chance to retrieve it,
|
||||
// so for now, just don't implement dereferencing for this edge case.
|
||||
if (Config.values.maxHistory > 0) {
|
||||
this.dereferencePreviews(deleted);
|
||||
}
|
||||
}
|
||||
}
|
||||
dereferencePreviews(messages) {
|
||||
if (!Config.values.prefetch || !Config.values.prefetchStorage) {
|
||||
return;
|
||||
}
|
||||
|
||||
messages.forEach((message) => {
|
||||
if (message.previews) {
|
||||
message.previews.forEach((preview) => {
|
||||
if (preview.thumb) {
|
||||
storage.dereference(preview.thumb);
|
||||
preview.thumb = "";
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
getSortedUsers(irc?: Network["irc"]) {
|
||||
const users = Array.from(this.users.values());
|
||||
|
||||
if (!irc || !irc.network || !irc.network.options || !irc.network.options.PREFIX) {
|
||||
return users;
|
||||
}
|
||||
|
||||
const userModeSortPriority = {};
|
||||
irc.network.options.PREFIX.forEach((prefix, index) => {
|
||||
userModeSortPriority[prefix.symbol] = index;
|
||||
});
|
||||
|
||||
userModeSortPriority[""] = 99; // No mode is lowest
|
||||
|
||||
return users.sort(function (a, b) {
|
||||
if (a.mode === b.mode) {
|
||||
return a.nick.toLowerCase() < b.nick.toLowerCase() ? -1 : 1;
|
||||
}
|
||||
|
||||
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
|
||||
* better control of performance and memory usage.
|
||||
*
|
||||
* @param {(int|bool)} lastActiveChannel - Last known active user channel id (needed to control how many messages are sent)
|
||||
* 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;
|
||||
|
||||
newChannel[prop] = this[prop].slice(-messagesToSend);
|
||||
}
|
||||
|
||||
(newChannel as FilteredChannel).totalMessages = this[prop].length;
|
||||
} else {
|
||||
newChannel[prop] = this[prop];
|
||||
}
|
||||
|
||||
return newChannel;
|
||||
}, {}) as FilteredChannel;
|
||||
}
|
||||
writeUserLog(client: Client, msg: Msg) {
|
||||
this.messages.push(msg);
|
||||
|
||||
// Are there any logs enabled
|
||||
if (client.messageStorage.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetChannel: Chan = this;
|
||||
|
||||
// Is this particular message or channel loggable
|
||||
if (!msg.isLoggable() || !this.isLoggable()) {
|
||||
// Because notices are nasty and can be shown in active channel on the client
|
||||
// if there is no open query, we want to always log notices in the sender's name
|
||||
if (msg.type === MessageType.NOTICE && msg.showInActive) {
|
||||
targetChannel.name = msg.from.nick || ""; // TODO: check if || works
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the parent network where this channel is in
|
||||
const target = client.find(this.id);
|
||||
|
||||
if (!target) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const messageStorage of client.messageStorage) {
|
||||
messageStorage.index(target.network, targetChannel, msg);
|
||||
}
|
||||
}
|
||||
loadMessages(client: Client, network: Network) {
|
||||
if (!this.isLoggable()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!network.irc) {
|
||||
// Network created, but misconfigured
|
||||
log.warn(
|
||||
`Failed to load messages for ${client.name}, network ${network.name} is not initialized.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!client.messageProvider) {
|
||||
if (network.irc.network.cap.isEnabled("znc.in/playback")) {
|
||||
// if we do have a message provider we might be able to only fetch partial history,
|
||||
// so delay the cap in this case.
|
||||
requestZncPlayback(this, network, 0);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
client.messageProvider
|
||||
.getMessages(network, this)
|
||||
.then((messages) => {
|
||||
if (messages.length === 0) {
|
||||
if (network.irc!.network.cap.isEnabled("znc.in/playback")) {
|
||||
requestZncPlayback(this, network, 0);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.messages.unshift(...messages);
|
||||
|
||||
if (!this.firstUnread) {
|
||||
this.firstUnread = messages[messages.length - 1].id;
|
||||
}
|
||||
|
||||
client.emit("more", {
|
||||
chan: this.id,
|
||||
messages: messages.slice(-100),
|
||||
totalMessages: messages.length,
|
||||
});
|
||||
|
||||
if (network.irc!.network.cap.isEnabled("znc.in/playback")) {
|
||||
const from = Math.floor(messages[messages.length - 1].time.getTime() / 1000);
|
||||
|
||||
requestZncPlayback(this, network, from);
|
||||
}
|
||||
})
|
||||
.catch((err: Error) =>
|
||||
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) {
|
||||
network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString());
|
||||
}
|
||||
|
||||
export default Chan;
|
||||
|
||||
export type Channel = Chan;
|
||||
134
server/models/msg.ts
Normal file
134
server/models/msg.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
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",
|
||||
}
|
||||
|
||||
class Msg {
|
||||
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!: UserInMessage[] | string[];
|
||||
statusmsgGroup!: string;
|
||||
params!: string[];
|
||||
|
||||
constructor(attr?: Partial<Msg>) {
|
||||
// Some properties need to be copied in the Msg object instead of referenced
|
||||
if (attr) {
|
||||
["from", "target"].forEach((prop) => {
|
||||
if (attr[prop]) {
|
||||
this[prop] = {
|
||||
mode: attr[prop].mode,
|
||||
nick: attr[prop].nick,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
_.defaults(this, attr, {
|
||||
from: {},
|
||||
id: 0,
|
||||
previews: [],
|
||||
text: "",
|
||||
type: MessageType.MESSAGE,
|
||||
self: false,
|
||||
});
|
||||
|
||||
if (this.time) {
|
||||
this.time = new Date(this.time);
|
||||
} else {
|
||||
this.time = new Date();
|
||||
}
|
||||
}
|
||||
|
||||
findPreview(link: string) {
|
||||
return this.previews.find((preview) => preview.link === link);
|
||||
}
|
||||
|
||||
isLoggable() {
|
||||
if (this.type === MessageType.TOPIC) {
|
||||
// Do not log topic that is sent on channel join
|
||||
return !!this.from.nick;
|
||||
}
|
||||
|
||||
switch (this.type) {
|
||||
case MessageType.MONOSPACE_BLOCK:
|
||||
case MessageType.ERROR:
|
||||
case MessageType.TOPIC_SET_BY:
|
||||
case MessageType.MODE_CHANNEL:
|
||||
case MessageType.MODE_USER:
|
||||
case MessageType.RAW:
|
||||
case MessageType.WHOIS:
|
||||
case MessageType.PLUGIN:
|
||||
return false;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export default Msg;
|
||||
|
||||
export type Message = Msg;
|
||||
653
server/models/network.ts
Normal file
653
server/models/network.ts
Normal file
|
|
@ -0,0 +1,653 @@
|
|||
import _ from "lodash";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import IrcFramework, {Client as IRCClient} from "irc-framework";
|
||||
import Chan, {Channel, ChanType} from "./chan";
|
||||
import Msg, {MessageType} 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,
|
||||
};
|
||||
|
||||
type NetworkIrcOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
password: string;
|
||||
nick: string;
|
||||
username: string;
|
||||
gecos: string;
|
||||
tls: boolean;
|
||||
rejectUnauthorized: boolean;
|
||||
webirc: WebIRC | null;
|
||||
client_certificate: ClientCertificateType | null;
|
||||
socks?: {
|
||||
host: string;
|
||||
port: number;
|
||||
user: string;
|
||||
pass: string;
|
||||
};
|
||||
sasl_mechanism?: string;
|
||||
account?:
|
||||
| {
|
||||
account: string;
|
||||
password: string;
|
||||
}
|
||||
| Record<string, never>;
|
||||
};
|
||||
|
||||
type NetworkStatus = {
|
||||
connected: boolean;
|
||||
secure: boolean;
|
||||
};
|
||||
|
||||
export type IgnoreListItem = Hostmask & {
|
||||
when?: number;
|
||||
};
|
||||
|
||||
type IgnoreList = IgnoreListItem[];
|
||||
|
||||
type NonNullableIRCWithOptions = NonNullable<IRCClient & {options: NetworkIrcOptions}>;
|
||||
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
export type NetworkWithIrcFramework = Network & {
|
||||
// eslint-disable-next-line no-use-before-define
|
||||
irc: NonNullable<Network["irc"]> & {
|
||||
options: NonNullableIRCWithOptions;
|
||||
};
|
||||
};
|
||||
|
||||
class Network {
|
||||
nick!: string;
|
||||
name!: string;
|
||||
host!: string;
|
||||
port!: number;
|
||||
tls!: boolean;
|
||||
userDisconnected!: boolean;
|
||||
rejectUnauthorized!: boolean;
|
||||
password!: string;
|
||||
awayMessage!: string;
|
||||
commands!: any[];
|
||||
username!: string;
|
||||
realname!: string;
|
||||
leaveMessage!: string;
|
||||
sasl!: string;
|
||||
saslAccount!: string;
|
||||
saslPassword!: string;
|
||||
channels!: Chan[];
|
||||
uuid!: string;
|
||||
proxyHost!: string;
|
||||
proxyPort!: number;
|
||||
proxyUsername!: string;
|
||||
proxyPassword!: string;
|
||||
proxyEnabled!: boolean;
|
||||
highlightRegex?: RegExp;
|
||||
|
||||
irc?: IrcFramework.Client & {
|
||||
options?: NetworkIrcOptions;
|
||||
};
|
||||
|
||||
chanCache!: Chan[];
|
||||
ignoreList!: IgnoreList;
|
||||
keepNick!: string | null;
|
||||
|
||||
status!: NetworkStatus;
|
||||
|
||||
serverOptions!: {
|
||||
CHANTYPES: string[];
|
||||
PREFIX: Prefix;
|
||||
NETWORK: string;
|
||||
};
|
||||
|
||||
// TODO: this is only available on export
|
||||
hasSTSPolicy!: boolean;
|
||||
|
||||
constructor(attr?: Partial<Network>) {
|
||||
_.defaults(this, attr, {
|
||||
name: "",
|
||||
nick: "",
|
||||
host: "",
|
||||
port: 6667,
|
||||
tls: false,
|
||||
userDisconnected: false,
|
||||
rejectUnauthorized: false,
|
||||
password: "",
|
||||
awayMessage: "",
|
||||
commands: [],
|
||||
username: "",
|
||||
realname: "",
|
||||
leaveMessage: "",
|
||||
sasl: "",
|
||||
saslAccount: "",
|
||||
saslPassword: "",
|
||||
channels: [],
|
||||
irc: null,
|
||||
serverOptions: {
|
||||
CHANTYPES: ["#", "&"],
|
||||
PREFIX: new Prefix([
|
||||
{symbol: "!", mode: "Y"},
|
||||
{symbol: "@", mode: "o"},
|
||||
{symbol: "%", mode: "h"},
|
||||
{symbol: "+", mode: "v"},
|
||||
]),
|
||||
NETWORK: "",
|
||||
},
|
||||
|
||||
proxyHost: "",
|
||||
proxyPort: 1080,
|
||||
proxyUsername: "",
|
||||
proxyPassword: "",
|
||||
proxyEnabled: false,
|
||||
|
||||
chanCache: [],
|
||||
ignoreList: [],
|
||||
keepNick: null,
|
||||
});
|
||||
|
||||
if (!this.uuid) {
|
||||
this.uuid = uuidv4();
|
||||
}
|
||||
|
||||
if (!this.name) {
|
||||
this.name = this.host;
|
||||
}
|
||||
|
||||
this.channels.unshift(
|
||||
new Chan({
|
||||
name: this.name,
|
||||
type: ChanType.LOBBY,
|
||||
// The lobby only starts as muted if every channel (unless it's special) is muted.
|
||||
// This is A) easier to implement and B) stops some confusion on startup.
|
||||
muted:
|
||||
this.channels.length >= 1 &&
|
||||
this.channels.every((chan) => chan.muted || chan.type === ChanType.SPECIAL),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
validate(this: Network, client: Client) {
|
||||
// Remove !, :, @ and whitespace characters from nicknames and usernames
|
||||
const cleanNick = (str: string) => str.replace(/[\x00\s:!@]/g, "_").substring(0, 100);
|
||||
|
||||
// Remove new lines and limit length
|
||||
const cleanString = (str: string) => str.replace(/[\x00\r\n]/g, "").substring(0, 300);
|
||||
|
||||
this.setNick(cleanNick(String(this.nick || Config.getDefaultNick())));
|
||||
|
||||
if (!this.username) {
|
||||
// If username is empty, make one from the provided nick
|
||||
this.username = this.nick.replace(/[^a-zA-Z0-9]/g, "");
|
||||
}
|
||||
|
||||
this.username = cleanString(this.username) || "thelounge";
|
||||
this.realname = cleanString(this.realname) || "The Lounge User";
|
||||
this.leaveMessage = cleanString(this.leaveMessage);
|
||||
this.password = cleanString(this.password);
|
||||
this.host = cleanString(this.host).toLowerCase();
|
||||
this.name = cleanString(this.name);
|
||||
this.saslAccount = cleanString(this.saslAccount);
|
||||
this.saslPassword = cleanString(this.saslPassword);
|
||||
|
||||
this.proxyHost = cleanString(this.proxyHost);
|
||||
this.proxyPort = this.proxyPort || 1080;
|
||||
this.proxyUsername = cleanString(this.proxyUsername);
|
||||
this.proxyPassword = cleanString(this.proxyPassword);
|
||||
this.proxyEnabled = !!this.proxyEnabled;
|
||||
|
||||
const error = function (network: Network, text: string) {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: text,
|
||||
}),
|
||||
true
|
||||
);
|
||||
};
|
||||
|
||||
if (!this.port) {
|
||||
this.port = this.tls ? 6697 : 6667;
|
||||
}
|
||||
|
||||
if (!["", "plain", "external"].includes(this.sasl)) {
|
||||
this.sasl = "";
|
||||
}
|
||||
|
||||
if (Config.values.lockNetwork) {
|
||||
// This check is needed to prevent invalid user configurations
|
||||
if (
|
||||
!Config.values.public &&
|
||||
this.host &&
|
||||
this.host.length > 0 &&
|
||||
this.host !== Config.values.defaults.host
|
||||
) {
|
||||
error(this, `The hostname you specified (${this.host}) is not allowed.`);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Config.values.public) {
|
||||
this.name = Config.values.defaults.name;
|
||||
// Sync lobby channel name
|
||||
this.channels[0].name = Config.values.defaults.name;
|
||||
}
|
||||
|
||||
this.host = Config.values.defaults.host;
|
||||
this.port = Config.values.defaults.port;
|
||||
this.tls = Config.values.defaults.tls;
|
||||
this.rejectUnauthorized = Config.values.defaults.rejectUnauthorized;
|
||||
}
|
||||
|
||||
if (this.host.length === 0) {
|
||||
error(this, "You must specify a hostname to connect.");
|
||||
return false;
|
||||
}
|
||||
|
||||
const stsPolicy = STSPolicies.get(this.host);
|
||||
|
||||
if (stsPolicy && !this.tls) {
|
||||
error(
|
||||
this,
|
||||
`${this.host} has an active strict transport security policy, will connect to port ${stsPolicy.port} over a secure connection.`
|
||||
);
|
||||
|
||||
this.port = stsPolicy.port;
|
||||
this.tls = true;
|
||||
this.rejectUnauthorized = true;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
createIrcFramework(this: NetworkWithIrcFramework, client: Client) {
|
||||
this.irc = new IrcFramework.Client({
|
||||
version: false, // We handle it ourselves
|
||||
outgoing_addr: Config.values.bind,
|
||||
enable_chghost: true,
|
||||
enable_echomessage: true,
|
||||
enable_setname: true,
|
||||
auto_reconnect: true,
|
||||
|
||||
// Exponential backoff maxes out at 300 seconds after 9 reconnects,
|
||||
// it will keep trying for well over an hour (plus the timeouts)
|
||||
auto_reconnect_max_retries: 30,
|
||||
|
||||
// TODO: this type should be set after setIrcFrameworkOptions
|
||||
}) as NetworkWithIrcFramework["irc"];
|
||||
|
||||
this.setIrcFrameworkOptions(client);
|
||||
|
||||
this.irc.requestCap([
|
||||
"znc.in/self-message", // Legacy echo-message for ZNC
|
||||
"znc.in/playback", // See http://wiki.znc.in/Playback
|
||||
]);
|
||||
}
|
||||
|
||||
setIrcFrameworkOptions(this: NetworkWithIrcFramework, client: Client) {
|
||||
this.irc.options.host = this.host;
|
||||
this.irc.options.port = this.port;
|
||||
this.irc.options.password = this.password;
|
||||
this.irc.options.nick = this.nick;
|
||||
this.irc.options.username = Config.values.useHexIp
|
||||
? Helper.ip2hex(client.config.browser!.ip!)
|
||||
: this.username;
|
||||
this.irc.options.gecos = this.realname;
|
||||
this.irc.options.tls = this.tls;
|
||||
this.irc.options.rejectUnauthorized = this.rejectUnauthorized;
|
||||
this.irc.options.webirc = this.createWebIrc(client);
|
||||
this.irc.options.client_certificate = null;
|
||||
|
||||
if (this.proxyEnabled) {
|
||||
this.irc.options.socks = {
|
||||
host: this.proxyHost,
|
||||
port: this.proxyPort,
|
||||
user: this.proxyUsername,
|
||||
pass: this.proxyPassword,
|
||||
};
|
||||
} else {
|
||||
delete this.irc.options.socks;
|
||||
}
|
||||
|
||||
if (!this.sasl) {
|
||||
delete this.irc.options.sasl_mechanism;
|
||||
delete this.irc.options.account;
|
||||
} else if (this.sasl === "external") {
|
||||
this.irc.options.sasl_mechanism = "EXTERNAL";
|
||||
this.irc.options.account = {};
|
||||
this.irc.options.client_certificate = ClientCertificate.get(this.uuid);
|
||||
} else if (this.sasl === "plain") {
|
||||
delete this.irc.options.sasl_mechanism;
|
||||
this.irc.options.account = {
|
||||
account: this.saslAccount,
|
||||
password: this.saslPassword,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
createWebIrc(client: Client) {
|
||||
if (
|
||||
!Config.values.webirc ||
|
||||
!Object.prototype.hasOwnProperty.call(Config.values.webirc, this.host)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const webircObject = {
|
||||
password: Config.values.webirc[this.host],
|
||||
username: "thelounge",
|
||||
address: client.config.browser?.ip,
|
||||
hostname: client.config.browser?.hostname,
|
||||
options: {},
|
||||
};
|
||||
|
||||
// https://ircv3.net/specs/extensions/webirc#options
|
||||
if (client.config.browser?.isSecure) {
|
||||
webircObject.options = {
|
||||
secure: true,
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof Config.values.webirc[this.host] === "function") {
|
||||
webircObject.password = null;
|
||||
|
||||
return Config.values.webirc[this.host](webircObject, this) as typeof webircObject;
|
||||
}
|
||||
|
||||
return webircObject;
|
||||
}
|
||||
|
||||
edit(this: NetworkWithIrcFramework, client: Client, args: any) {
|
||||
const oldNetworkName = this.name;
|
||||
const oldNick = this.nick;
|
||||
const oldRealname = this.realname;
|
||||
|
||||
this.keepNick = null;
|
||||
this.nick = args.nick;
|
||||
this.host = String(args.host || "");
|
||||
this.name = String(args.name || "") || this.host;
|
||||
this.port = parseInt(args.port, 10);
|
||||
this.tls = !!args.tls;
|
||||
this.rejectUnauthorized = !!args.rejectUnauthorized;
|
||||
this.password = String(args.password || "");
|
||||
this.username = String(args.username || "");
|
||||
this.realname = String(args.realname || "");
|
||||
this.leaveMessage = String(args.leaveMessage || "");
|
||||
this.sasl = String(args.sasl || "");
|
||||
this.saslAccount = String(args.saslAccount || "");
|
||||
this.saslPassword = String(args.saslPassword || "");
|
||||
|
||||
this.proxyHost = String(args.proxyHost || "");
|
||||
this.proxyPort = parseInt(args.proxyPort, 10);
|
||||
this.proxyUsername = String(args.proxyUsername || "");
|
||||
this.proxyPassword = String(args.proxyPassword || "");
|
||||
this.proxyEnabled = !!args.proxyEnabled;
|
||||
|
||||
// Split commands into an array
|
||||
this.commands = String(args.commands || "")
|
||||
.replace(/\r\n|\r|\n/g, "\n")
|
||||
.split("\n")
|
||||
.filter((command) => command.length > 0);
|
||||
|
||||
// Sync lobby channel name
|
||||
this.channels[0].name = this.name;
|
||||
|
||||
if (this.name !== oldNetworkName) {
|
||||
// Send updated network name to all connected clients
|
||||
client.emit("network:name", {
|
||||
uuid: this.uuid,
|
||||
name: this.name,
|
||||
});
|
||||
}
|
||||
|
||||
if (!this.validate(client)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.irc) {
|
||||
const connected = this.irc.connection && this.irc.connection.connected;
|
||||
|
||||
if (this.nick !== oldNick) {
|
||||
if (connected) {
|
||||
// Send new nick straight away
|
||||
this.irc.changeNick(this.nick);
|
||||
} else {
|
||||
this.irc.user.nick = this.nick;
|
||||
|
||||
// Update UI nick straight away if IRC is not connected
|
||||
client.emit("nick", {
|
||||
network: this.uuid,
|
||||
nick: this.nick,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
connected &&
|
||||
this.realname !== oldRealname &&
|
||||
this.irc.network.cap.isEnabled("setname")
|
||||
) {
|
||||
this.irc.raw("SETNAME", this.realname);
|
||||
}
|
||||
|
||||
this.setIrcFrameworkOptions(client);
|
||||
|
||||
if (this.irc.options?.username) {
|
||||
this.irc.user.username = this.irc.options.username;
|
||||
}
|
||||
|
||||
if (this.irc.options?.gecos) {
|
||||
this.irc.user.gecos = this.irc.options.gecos;
|
||||
}
|
||||
}
|
||||
|
||||
client.save();
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.channels.forEach((channel) => channel.destroy());
|
||||
}
|
||||
|
||||
setNick(this: Network, nick: string) {
|
||||
this.nick = nick;
|
||||
this.highlightRegex = new RegExp(
|
||||
// Do not match characters and numbers (unless IRC color)
|
||||
"(?:^|[^a-z0-9]|\x03[0-9]{1,2})" +
|
||||
// Escape nickname, as it may contain regex stuff
|
||||
_.escapeRegExp(nick) +
|
||||
// Do not match characters and numbers
|
||||
"(?:[^a-z0-9]|$)",
|
||||
|
||||
// Case insensitive search
|
||||
"i"
|
||||
);
|
||||
|
||||
if (this.keepNick === nick) {
|
||||
this.keepNick = null;
|
||||
}
|
||||
|
||||
if (this.irc?.options) {
|
||||
this.irc.options.nick = nick;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
getNetworkStatus() {
|
||||
const status = {
|
||||
connected: false,
|
||||
secure: false,
|
||||
};
|
||||
|
||||
if (this.irc && this.irc.connection && this.irc.connection.transport) {
|
||||
const transport = this.irc.connection.transport;
|
||||
|
||||
if (transport.socket) {
|
||||
const isLocalhost = transport.socket.remoteAddress === "127.0.0.1";
|
||||
const isAuthorized = transport.socket.encrypted && transport.socket.authorized;
|
||||
|
||||
status.connected = transport.isConnected();
|
||||
status.secure = isAuthorized || isLocalhost;
|
||||
}
|
||||
}
|
||||
|
||||
return status;
|
||||
}
|
||||
|
||||
addChannel(newChan: Chan) {
|
||||
let index = this.channels.length; // Default to putting as the last item in the array
|
||||
|
||||
// Don't sort special channels in amongst channels/users.
|
||||
if (newChan.type === ChanType.CHANNEL || newChan.type === ChanType.QUERY) {
|
||||
// We start at 1 so we don't test against the lobby
|
||||
for (let i = 1; i < this.channels.length; i++) {
|
||||
const compareChan = this.channels[i];
|
||||
|
||||
// Negative if the new chan is alphabetically before the next chan in the list, positive if after
|
||||
if (
|
||||
newChan.name.localeCompare(compareChan.name, undefined, {
|
||||
sensitivity: "base",
|
||||
}) <= 0 ||
|
||||
(compareChan.type !== ChanType.CHANNEL && compareChan.type !== ChanType.QUERY)
|
||||
) {
|
||||
index = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.channels.splice(index, 0, newChan);
|
||||
return index;
|
||||
}
|
||||
|
||||
quit(quitMessage?: string) {
|
||||
if (!this.irc) {
|
||||
return;
|
||||
}
|
||||
|
||||
// https://ircv3.net/specs/extensions/sts#rescheduling-expiry-on-disconnect
|
||||
STSPolicies.refreshExpiration(this.host);
|
||||
|
||||
this.irc.quit(quitMessage || this.leaveMessage || Config.values.leaveMessage);
|
||||
}
|
||||
|
||||
exportForEdit() {
|
||||
const fieldsToReturn = [
|
||||
"uuid",
|
||||
"name",
|
||||
"nick",
|
||||
"password",
|
||||
"username",
|
||||
"realname",
|
||||
"leaveMessage",
|
||||
"sasl",
|
||||
"saslAccount",
|
||||
"saslPassword",
|
||||
"commands",
|
||||
|
||||
"proxyEnabled",
|
||||
"proxyHost",
|
||||
"proxyPort",
|
||||
"proxyUsername",
|
||||
"proxyPassword",
|
||||
];
|
||||
|
||||
if (!Config.values.lockNetwork) {
|
||||
fieldsToReturn.push("host");
|
||||
fieldsToReturn.push("port");
|
||||
fieldsToReturn.push("tls");
|
||||
fieldsToReturn.push("rejectUnauthorized");
|
||||
}
|
||||
|
||||
const data = _.pick(this, fieldsToReturn) as Network;
|
||||
|
||||
data.hasSTSPolicy = !!STSPolicies.get(this.host);
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
export() {
|
||||
const network = _.pick(this, [
|
||||
"uuid",
|
||||
"awayMessage",
|
||||
"nick",
|
||||
"name",
|
||||
"host",
|
||||
"port",
|
||||
"tls",
|
||||
"userDisconnected",
|
||||
"rejectUnauthorized",
|
||||
"password",
|
||||
"username",
|
||||
"realname",
|
||||
"leaveMessage",
|
||||
"sasl",
|
||||
"saslAccount",
|
||||
"saslPassword",
|
||||
"commands",
|
||||
"ignoreList",
|
||||
|
||||
"proxyHost",
|
||||
"proxyPort",
|
||||
"proxyUsername",
|
||||
"proxyEnabled",
|
||||
"proxyPassword",
|
||||
]) as Network;
|
||||
|
||||
network.channels = this.channels
|
||||
.filter(function (channel) {
|
||||
return channel.type === ChanType.CHANNEL || channel.type === ChanType.QUERY;
|
||||
})
|
||||
.map(function (chan) {
|
||||
const keys = ["name", "muted"];
|
||||
|
||||
if (chan.type === ChanType.CHANNEL) {
|
||||
keys.push("key");
|
||||
} else if (chan.type === ChanType.QUERY) {
|
||||
keys.push("type");
|
||||
}
|
||||
|
||||
return _.pick(chan, keys);
|
||||
// Override the type because we're omitting ID
|
||||
}) as Channel[];
|
||||
|
||||
return network;
|
||||
}
|
||||
|
||||
getChannel(name: string) {
|
||||
name = name.toLowerCase();
|
||||
|
||||
return _.find(this.channels, function (that, i) {
|
||||
// Skip network lobby (it's always unshifted into first position)
|
||||
return i > 0 && that.name.toLowerCase() === name;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default Network;
|
||||
42
server/models/prefix.ts
Normal file
42
server/models/prefix.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
type PrefixSymbol = string;
|
||||
|
||||
type PrefixObject = {
|
||||
symbol: PrefixSymbol;
|
||||
mode: string;
|
||||
};
|
||||
|
||||
class Prefix {
|
||||
prefix: PrefixObject[];
|
||||
modeToSymbol: {[mode: string]: string};
|
||||
symbols: string[];
|
||||
|
||||
constructor(prefix: PrefixObject[]) {
|
||||
this.prefix = prefix || []; // [{symbol: "@", mode: "o"}, ... ]
|
||||
this.modeToSymbol = {};
|
||||
this.symbols = [];
|
||||
this._update_internals();
|
||||
}
|
||||
|
||||
_update_internals() {
|
||||
// clean out the old cruft
|
||||
this.modeToSymbol = {};
|
||||
this.symbols = [];
|
||||
|
||||
const that = this;
|
||||
this.prefix.forEach(function (p) {
|
||||
that.modeToSymbol[p.mode] = p.symbol;
|
||||
that.symbols.push(p.symbol);
|
||||
});
|
||||
}
|
||||
|
||||
update(prefix: PrefixObject[]) {
|
||||
this.prefix = prefix || [];
|
||||
this._update_internals();
|
||||
}
|
||||
|
||||
forEach(f: (value: PrefixObject, index: number, array: PrefixObject[]) => void) {
|
||||
return this.prefix.forEach(f);
|
||||
}
|
||||
}
|
||||
|
||||
export default Prefix;
|
||||
44
server/models/user.ts
Normal file
44
server/models/user.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import _ from "lodash";
|
||||
import Prefix from "./prefix";
|
||||
|
||||
class User {
|
||||
modes!: string[];
|
||||
// Users in the channel have only one mode assigned
|
||||
mode!: string;
|
||||
away!: string;
|
||||
nick!: string;
|
||||
lastMessage!: number;
|
||||
|
||||
constructor(attr: Partial<User>, prefix?: Prefix) {
|
||||
_.defaults(this, attr, {
|
||||
modes: [],
|
||||
away: "",
|
||||
nick: "",
|
||||
lastMessage: 0,
|
||||
});
|
||||
|
||||
Object.defineProperty(this, "mode", {
|
||||
get() {
|
||||
// eslint-disable-next-line @typescript-eslint/no-unsafe-return
|
||||
return this.modes[0] || "";
|
||||
},
|
||||
});
|
||||
|
||||
this.setModes(this.modes, prefix || new Prefix([]));
|
||||
}
|
||||
|
||||
setModes(modes: string[], prefix: Prefix) {
|
||||
// irc-framework sets character mode, but The Lounge works with symbols
|
||||
this.modes = modes.map((mode) => prefix.modeToSymbol[mode]);
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return {
|
||||
nick: this.nick,
|
||||
modes: this.modes,
|
||||
lastMessage: this.lastMessage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export default User;
|
||||
67
server/plugins/auth.ts
Normal file
67
server/plugins/auth.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import colors from "chalk";
|
||||
import Client from "../client";
|
||||
import ClientManager from "../clientManager";
|
||||
import log from "../log";
|
||||
|
||||
export type AuthHandler = (
|
||||
manager: ClientManager,
|
||||
client: Client,
|
||||
user: string,
|
||||
password: string,
|
||||
callback: (success: boolean) => void
|
||||
) => void;
|
||||
|
||||
// The order defines priority: the first available plugin is used.
|
||||
// Always keep 'local' auth plugin at the end of the list; it should always be enabled.
|
||||
const plugins = [import("./auth/ldap"), import("./auth/local")];
|
||||
|
||||
const toExport = {
|
||||
moduleName: "<module with no name>",
|
||||
|
||||
// Must override: implements authentication mechanism
|
||||
auth: () => unimplemented("auth"),
|
||||
|
||||
// Optional to override: implements filter for loading users at start up
|
||||
// This allows an auth plugin to check if a user is still acceptable, if the plugin
|
||||
// can do so without access to the user's unhashed password.
|
||||
// Returning 'false' triggers fallback to default behaviour of loading all users
|
||||
loadUsers: () => false,
|
||||
// local auth should always be enabled, but check here to verify
|
||||
initialized: false,
|
||||
// TODO: fix typing
|
||||
async initialize() {
|
||||
if (toExport.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Override default API stubs with exports from first enabled plugin found
|
||||
const resolvedPlugins = await Promise.all(plugins);
|
||||
|
||||
for (const {default: plugin} of resolvedPlugins) {
|
||||
if (plugin.isEnabled()) {
|
||||
toExport.initialized = true;
|
||||
|
||||
for (const name in plugin) {
|
||||
toExport[name] = plugin[name];
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!toExport.initialized) {
|
||||
log.error("None of the auth plugins is enabled");
|
||||
}
|
||||
},
|
||||
} as any;
|
||||
|
||||
function unimplemented(funcName: string) {
|
||||
log.debug(
|
||||
`Auth module ${colors.bold(toExport.moduleName)} doesn't implement function ${colors.bold(
|
||||
funcName
|
||||
)}`
|
||||
);
|
||||
}
|
||||
|
||||
// Default API implementations
|
||||
export default toExport;
|
||||
244
server/plugins/auth/ldap.ts
Normal file
244
server/plugins/auth/ldap.ts
Normal file
|
|
@ -0,0 +1,244 @@
|
|||
import ldap, {SearchOptions} from "ldapjs";
|
||||
import colors from "chalk";
|
||||
|
||||
import log from "../../log";
|
||||
import Config from "../../config";
|
||||
import type {AuthHandler} from "../auth";
|
||||
|
||||
function ldapAuthCommon(
|
||||
user: string,
|
||||
bindDN: string,
|
||||
password: string,
|
||||
callback: (success: boolean) => void
|
||||
) {
|
||||
const config = Config.values;
|
||||
|
||||
const ldapclient = ldap.createClient({
|
||||
url: config.ldap.url,
|
||||
tlsOptions: config.ldap.tlsOptions,
|
||||
});
|
||||
|
||||
ldapclient.on("error", function (err: Error) {
|
||||
log.error(`Unable to connect to LDAP server: ${err.toString()}`);
|
||||
callback(false);
|
||||
});
|
||||
|
||||
ldapclient.bind(bindDN, password, function (err) {
|
||||
ldapclient.unbind();
|
||||
|
||||
if (err) {
|
||||
log.error(`LDAP bind failed: ${err.toString()}`);
|
||||
callback(false);
|
||||
} else {
|
||||
callback(true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function simpleLdapAuth(user: string, password: string, callback: (success: boolean) => void) {
|
||||
if (!user || !password) {
|
||||
return callback(false);
|
||||
}
|
||||
|
||||
const config = Config.values;
|
||||
|
||||
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
|
||||
const bindDN = `${config.ldap.primaryKey}=${userDN},${config.ldap.baseDN || ""}`;
|
||||
|
||||
log.info(`Auth against LDAP ${config.ldap.url} with provided bindDN ${bindDN}`);
|
||||
|
||||
ldapAuthCommon(user, bindDN, password, callback);
|
||||
}
|
||||
|
||||
/**
|
||||
* LDAP auth using initial DN search (see config comment for ldap.searchDN)
|
||||
*/
|
||||
function advancedLdapAuth(user: string, password: string, callback: (success: boolean) => void) {
|
||||
if (!user || !password) {
|
||||
return callback(false);
|
||||
}
|
||||
|
||||
const config = Config.values;
|
||||
const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1");
|
||||
|
||||
const ldapclient = ldap.createClient({
|
||||
url: config.ldap.url,
|
||||
tlsOptions: config.ldap.tlsOptions,
|
||||
});
|
||||
|
||||
const base = config.ldap.searchDN.base;
|
||||
const 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()}`);
|
||||
callback(false);
|
||||
});
|
||||
|
||||
ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function (err) {
|
||||
if (err) {
|
||||
log.error("Invalid LDAP root credentials");
|
||||
ldapclient.unbind();
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
ldapclient.search(base, searchOptions, function (err2, res) {
|
||||
if (err2) {
|
||||
log.warn(`LDAP User not found: ${userDN}`);
|
||||
ldapclient.unbind();
|
||||
callback(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let found = false;
|
||||
|
||||
res.on("searchEntry", function (entry) {
|
||||
found = true;
|
||||
const bindDN = entry.objectName;
|
||||
log.info(`Auth against LDAP ${config.ldap.url} with found bindDN ${bindDN || ""}`);
|
||||
ldapclient.unbind();
|
||||
|
||||
// TODO: Fix type !
|
||||
ldapAuthCommon(user, bindDN!, password, callback);
|
||||
});
|
||||
|
||||
res.on("error", function (err3: Error) {
|
||||
log.error(`LDAP error: ${err3.toString()}`);
|
||||
callback(false);
|
||||
});
|
||||
|
||||
res.on("end", function (result) {
|
||||
ldapclient.unbind();
|
||||
|
||||
if (!found) {
|
||||
log.warn(
|
||||
`LDAP Search did not find anything for: ${userDN} (${
|
||||
result?.status.toString() || "unknown"
|
||||
})`
|
||||
);
|
||||
callback(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const ldapAuth: AuthHandler = (manager, client, user, password, callback) => {
|
||||
// TODO: Enable the use of starttls() as an alternative to ldaps
|
||||
|
||||
// TODO: move this out of here and get rid of `manager` and `client` in
|
||||
// auth plugin API
|
||||
function callbackWrapper(valid: boolean) {
|
||||
if (valid && !client) {
|
||||
manager.addUser(user, null, true);
|
||||
}
|
||||
|
||||
callback(valid);
|
||||
}
|
||||
|
||||
let auth: typeof simpleLdapAuth | typeof advancedLdapAuth;
|
||||
|
||||
if ("baseDN" in Config.values.ldap) {
|
||||
auth = simpleLdapAuth;
|
||||
} else {
|
||||
auth = advancedLdapAuth;
|
||||
}
|
||||
|
||||
return auth(user, password, callbackWrapper);
|
||||
};
|
||||
|
||||
/**
|
||||
* Use the LDAP filter from config to check that users still exist before loading them
|
||||
* via the supplied callback function.
|
||||
*/
|
||||
|
||||
function advancedLdapLoadUsers(users: string[], callbackLoadUser) {
|
||||
const config = Config.values;
|
||||
|
||||
const ldapclient = ldap.createClient({
|
||||
url: config.ldap.url,
|
||||
tlsOptions: config.ldap.tlsOptions,
|
||||
});
|
||||
|
||||
const base = config.ldap.searchDN.base;
|
||||
|
||||
ldapclient.on("error", function (err: Error) {
|
||||
log.error(`Unable to connect to LDAP server: ${err.toString()}`);
|
||||
});
|
||||
|
||||
ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function (err) {
|
||||
if (err) {
|
||||
log.error("Invalid LDAP root credentials");
|
||||
return true;
|
||||
}
|
||||
|
||||
const remainingUsers = new Set(users);
|
||||
|
||||
const 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) {
|
||||
log.error(`LDAP search error: ${err2?.toString()}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
res.on("searchEntry", function (entry) {
|
||||
const user = entry.attributes[0].vals[0].toString();
|
||||
|
||||
if (remainingUsers.has(user)) {
|
||||
remainingUsers.delete(user);
|
||||
callbackLoadUser(user);
|
||||
}
|
||||
});
|
||||
|
||||
res.on("error", function (err3) {
|
||||
log.error(`LDAP error: ${err3.toString()}`);
|
||||
});
|
||||
|
||||
res.on("end", function () {
|
||||
remainingUsers.forEach((user) => {
|
||||
log.warn(
|
||||
`No account info in LDAP for ${colors.bold(
|
||||
user
|
||||
)} but user config file exists`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
ldapclient.unbind();
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function ldapLoadUsers(users: string[], callbackLoadUser) {
|
||||
if ("baseDN" in Config.values.ldap) {
|
||||
// simple LDAP case can't test for user existence without access to the
|
||||
// user's unhashed password, so indicate need to fallback to default
|
||||
// loadUser behaviour by returning false
|
||||
return false;
|
||||
}
|
||||
|
||||
return advancedLdapLoadUsers(users, callbackLoadUser);
|
||||
}
|
||||
|
||||
function isLdapEnabled() {
|
||||
return !Config.values.public && Config.values.ldap.enable;
|
||||
}
|
||||
|
||||
export default {
|
||||
moduleName: "ldap",
|
||||
auth: ldapAuth,
|
||||
isEnabled: isLdapEnabled,
|
||||
loadUsers: ldapLoadUsers,
|
||||
};
|
||||
52
server/plugins/auth/local.ts
Normal file
52
server/plugins/auth/local.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import colors from "chalk";
|
||||
import log from "../../log";
|
||||
import Helper from "../../helper";
|
||||
import type {AuthHandler} from "../auth";
|
||||
|
||||
const localAuth: AuthHandler = (manager, client, user, password, callback) => {
|
||||
// If no user is found, or if the client has not provided a password,
|
||||
// fail the authentication straight away
|
||||
if (!client || !password) {
|
||||
return callback(false);
|
||||
}
|
||||
|
||||
// If this user has no password set, fail the authentication
|
||||
if (!client.config.password) {
|
||||
log.error(
|
||||
`User ${colors.bold(
|
||||
user
|
||||
)} with no local password set tried to sign in. (Probably a LDAP user)`
|
||||
);
|
||||
return callback(false);
|
||||
}
|
||||
|
||||
Helper.password
|
||||
.compare(password, client.config.password)
|
||||
.then((matching) => {
|
||||
if (matching && Helper.password.requiresUpdate(client.config.password)) {
|
||||
const hash = Helper.password.hash(password);
|
||||
|
||||
client.setPassword(hash, (success) => {
|
||||
if (success) {
|
||||
log.info(
|
||||
`User ${colors.bold(
|
||||
client.name
|
||||
)} logged in and their hashed password has been updated to match new security requirements`
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
callback(matching);
|
||||
})
|
||||
.catch((error) => {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.error(`Error while checking users password. Error: ${error}`);
|
||||
});
|
||||
};
|
||||
|
||||
export default {
|
||||
moduleName: "local",
|
||||
auth: localAuth,
|
||||
isEnabled: () => true,
|
||||
};
|
||||
137
server/plugins/changelog.ts
Normal file
137
server/plugins/changelog.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import got, {Response} from "got";
|
||||
import colors from "chalk";
|
||||
import log from "../log";
|
||||
import pkg from "../../package.json";
|
||||
import ClientManager from "../clientManager";
|
||||
|
||||
const TIME_TO_LIVE = 15 * 60 * 1000; // 15 minutes, in milliseconds
|
||||
|
||||
export default {
|
||||
isUpdateAvailable: false,
|
||||
fetch,
|
||||
checkForUpdates,
|
||||
};
|
||||
export type ChangelogData = {
|
||||
current: {
|
||||
version: string;
|
||||
changelog?: string;
|
||||
};
|
||||
expiresAt: number;
|
||||
latest?: {
|
||||
prerelease: boolean;
|
||||
version: string;
|
||||
url: string;
|
||||
};
|
||||
packages?: boolean;
|
||||
};
|
||||
|
||||
const versions = {
|
||||
current: {
|
||||
version: `v${pkg.version}`,
|
||||
changelog: undefined,
|
||||
},
|
||||
expiresAt: -1,
|
||||
latest: undefined,
|
||||
packages: undefined,
|
||||
} as ChangelogData;
|
||||
|
||||
async function fetch() {
|
||||
const time = Date.now();
|
||||
|
||||
// Serving information from cache
|
||||
if (versions.expiresAt > time) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await got("https://api.github.com/repos/thelounge/thelounge/releases", {
|
||||
headers: {
|
||||
Accept: "application/vnd.github.v3.html", // Request rendered markdown
|
||||
"User-Agent": pkg.name + "; +" + pkg.repository.url, // Identify the client
|
||||
},
|
||||
});
|
||||
|
||||
if (response.statusCode !== 200) {
|
||||
return versions;
|
||||
}
|
||||
|
||||
updateVersions(response);
|
||||
|
||||
// Add expiration date to the data to send to the client for later refresh
|
||||
versions.expiresAt = time + TIME_TO_LIVE;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.error(`Failed to fetch changelog: ${error}`);
|
||||
}
|
||||
|
||||
return versions;
|
||||
}
|
||||
|
||||
function updateVersions(response: Response<string>) {
|
||||
let i: number;
|
||||
let release: {tag_name: string; body_html: any; prerelease: boolean; html_url: any};
|
||||
let prerelease = false;
|
||||
|
||||
const body = JSON.parse(response.body);
|
||||
|
||||
// Find the current release among releases on GitHub
|
||||
for (i = 0; i < body.length; i++) {
|
||||
release = body[i];
|
||||
|
||||
if (release.tag_name === versions.current.version) {
|
||||
versions.current.changelog = release.body_html;
|
||||
prerelease = release.prerelease;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Find the latest release made after the current one if there is one
|
||||
if (i > 0) {
|
||||
for (let j = 0; j < i; j++) {
|
||||
release = body[j];
|
||||
|
||||
// Find latest release or pre-release if current version is also a pre-release
|
||||
if (!release.prerelease || release.prerelease === prerelease) {
|
||||
module.exports.isUpdateAvailable = true;
|
||||
|
||||
versions.latest = {
|
||||
prerelease: release.prerelease,
|
||||
version: release.tag_name,
|
||||
url: release.html_url,
|
||||
};
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function checkForUpdates(manager: ClientManager) {
|
||||
fetch()
|
||||
.then((versionData) => {
|
||||
if (!module.exports.isUpdateAvailable) {
|
||||
// Check for updates every 24 hours + random jitter of <3 hours
|
||||
setTimeout(
|
||||
() => checkForUpdates(manager),
|
||||
24 * 3600 * 1000 + Math.floor(Math.random() * 10000000)
|
||||
);
|
||||
}
|
||||
|
||||
if (!versionData.latest) {
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(
|
||||
`The Lounge ${colors.green(
|
||||
versionData.latest.version
|
||||
)} is available. Read more on GitHub: ${versionData.latest.url}`
|
||||
);
|
||||
|
||||
// Notify all connected clients about the new version
|
||||
manager.clients.forEach((client) => client.emit("changelog:newversion"));
|
||||
})
|
||||
.catch((error: Error) => {
|
||||
log.error(`Failed to check for updates: ${error.message}`);
|
||||
});
|
||||
}
|
||||
138
server/plugins/clientCertificate.ts
Normal file
138
server/plugins/clientCertificate.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import path from "path";
|
||||
import fs from "fs";
|
||||
import crypto from "crypto";
|
||||
import {md, pki} from "node-forge";
|
||||
import log from "../log";
|
||||
import Config from "../config";
|
||||
|
||||
export default {
|
||||
get,
|
||||
remove,
|
||||
};
|
||||
|
||||
export type ClientCertificateType = {
|
||||
private_key: string;
|
||||
certificate: string;
|
||||
};
|
||||
|
||||
function get(uuid: string): ClientCertificateType | null {
|
||||
if (Config.values.public) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const folderPath = Config.getClientCertificatesPath();
|
||||
const paths = getPaths(folderPath, uuid);
|
||||
|
||||
if (!fs.existsSync(paths.privateKeyPath) || !fs.existsSync(paths.certificatePath)) {
|
||||
return generateAndWrite(folderPath, paths);
|
||||
}
|
||||
|
||||
try {
|
||||
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);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function remove(uuid: string) {
|
||||
if (Config.values.public) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const paths = getPaths(Config.getClientCertificatesPath(), uuid);
|
||||
|
||||
try {
|
||||
if (fs.existsSync(paths.privateKeyPath)) {
|
||||
fs.unlinkSync(paths.privateKeyPath);
|
||||
}
|
||||
|
||||
if (fs.existsSync(paths.certificatePath)) {
|
||||
fs.unlinkSync(paths.certificatePath);
|
||||
}
|
||||
} catch (e: any) {
|
||||
log.error("Unable to remove certificate", e);
|
||||
}
|
||||
}
|
||||
|
||||
function generateAndWrite(folderPath: string, paths: {privateKeyPath: any; certificatePath: any}) {
|
||||
const certificate = generate();
|
||||
|
||||
try {
|
||||
fs.mkdirSync(folderPath, {recursive: true});
|
||||
|
||||
fs.writeFileSync(paths.privateKeyPath, certificate.private_key, {
|
||||
mode: 0o600,
|
||||
});
|
||||
fs.writeFileSync(paths.certificatePath, certificate.certificate, {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
return certificate;
|
||||
} catch (e: any) {
|
||||
log.error("Unable to write certificate", String(e));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function generate() {
|
||||
const keys = pki.rsa.generateKeyPair(2048);
|
||||
const cert = pki.createCertificate();
|
||||
|
||||
cert.publicKey = keys.publicKey;
|
||||
cert.serialNumber = crypto.randomBytes(16).toString("hex").toUpperCase();
|
||||
|
||||
// Set notBefore a day earlier just in case the time between
|
||||
// the client and server is not perfectly in sync
|
||||
cert.validity.notBefore = new Date();
|
||||
cert.validity.notBefore.setDate(cert.validity.notBefore.getDate() - 1);
|
||||
|
||||
// Set notAfter 100 years into the future just in case
|
||||
// the server actually validates this field
|
||||
cert.validity.notAfter = new Date();
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 100);
|
||||
|
||||
const attrs = [
|
||||
{
|
||||
name: "commonName",
|
||||
value: "The Lounge IRC Client",
|
||||
},
|
||||
];
|
||||
cert.setSubject(attrs);
|
||||
cert.setIssuer(attrs);
|
||||
|
||||
// Set extensions that indicate this is a client authentication certificate
|
||||
cert.setExtensions([
|
||||
{
|
||||
name: "extKeyUsage",
|
||||
clientAuth: true,
|
||||
},
|
||||
{
|
||||
name: "nsCertType",
|
||||
client: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// Sign this certificate with a SHA256 signature
|
||||
cert.sign(keys.privateKey, md.sha256.create());
|
||||
|
||||
const pem = {
|
||||
private_key: pki.privateKeyToPem(keys.privateKey),
|
||||
certificate: pki.certificateToPem(cert),
|
||||
} as ClientCertificateType;
|
||||
|
||||
return pem;
|
||||
}
|
||||
|
||||
function getPaths(folderPath: string, uuid: string) {
|
||||
return {
|
||||
privateKeyPath: path.join(folderPath, `${uuid}.pem`),
|
||||
certificatePath: path.join(folderPath, `${uuid}.crt`),
|
||||
};
|
||||
}
|
||||
42
server/plugins/dev-server.ts
Normal file
42
server/plugins/dev-server.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
import webpackDevMiddleware from "webpack-dev-middleware";
|
||||
import webpackHotMiddleware from "webpack-hot-middleware";
|
||||
import express from "express";
|
||||
|
||||
import log from "../log";
|
||||
|
||||
import webpack from "webpack";
|
||||
import config from "../../webpack.config";
|
||||
|
||||
export default (app: express.Application) => {
|
||||
log.debug("Starting server in development mode");
|
||||
|
||||
const webpackConfig = config(undefined, {mode: "production"});
|
||||
|
||||
if (
|
||||
!webpackConfig ||
|
||||
!webpackConfig.plugins?.length ||
|
||||
!webpackConfig.entry ||
|
||||
!webpackConfig.entry["js/bundle.js"]
|
||||
) {
|
||||
throw new Error("No valid production webpack config found");
|
||||
}
|
||||
|
||||
webpackConfig.plugins.push(new webpack.HotModuleReplacementPlugin());
|
||||
webpackConfig.entry["js/bundle.js"].push(
|
||||
"webpack-hot-middleware/client?path=storage/__webpack_hmr"
|
||||
);
|
||||
|
||||
const compiler = webpack(webpackConfig);
|
||||
|
||||
app.use(
|
||||
webpackDevMiddleware(compiler, {
|
||||
index: "/",
|
||||
publicPath: webpackConfig.output?.publicPath,
|
||||
})
|
||||
).use(
|
||||
// TODO: Fix compiler type
|
||||
webpackHotMiddleware(compiler as any, {
|
||||
path: "/storage/__webpack_hmr",
|
||||
})
|
||||
);
|
||||
};
|
||||
52
server/plugins/inputs/action.ts
Normal file
52
server/plugins/inputs/action.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {ChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["slap", "me"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (chan.type !== ChanType.CHANNEL && chan.type !== ChanType.QUERY) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `${cmd} command can only be used in channels and queries.`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let text;
|
||||
|
||||
switch (cmd) {
|
||||
case "slap":
|
||||
text = "slaps " + args[0] + " around a bit with a large trout";
|
||||
/* fall through */
|
||||
case "me":
|
||||
if (args.length === 0) {
|
||||
break;
|
||||
}
|
||||
|
||||
text = text || args.join(" ");
|
||||
|
||||
irc.action(chan.name, text);
|
||||
|
||||
if (!irc.network.cap.isEnabled("echo-message")) {
|
||||
irc.emit("action", {
|
||||
nick: irc.user.nick,
|
||||
target: chan.name,
|
||||
message: text,
|
||||
});
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
24
server/plugins/inputs/away.ts
Normal file
24
server/plugins/inputs/away.ts
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
const commands = ["away", "back"];
|
||||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
let reason = "";
|
||||
|
||||
if (cmd === "away") {
|
||||
reason = args.join(" ") || " ";
|
||||
|
||||
network.irc.raw("AWAY", reason);
|
||||
} else {
|
||||
// back command
|
||||
network.irc.raw("AWAY");
|
||||
}
|
||||
|
||||
network.awayMessage = reason;
|
||||
|
||||
this.save();
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
53
server/plugins/inputs/ban.ts
Normal file
53
server/plugins/inputs/ban.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import {ChanType} from "../../models/chan";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["ban", "unban", "banlist", "kickban"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (chan.type !== ChanType.CHANNEL) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `${cmd} command can only be used in channels.`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd !== "banlist" && args.length === 0) {
|
||||
if (args.length === 0) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `Usage: /${cmd} <nick>`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "kickban":
|
||||
irc.raw("KICK", chan.name, args[0], args.slice(1).join(" "));
|
||||
// fall through
|
||||
case "ban":
|
||||
irc.ban(chan.name, args[0]);
|
||||
break;
|
||||
case "unban":
|
||||
irc.unban(chan.name, args[0]);
|
||||
break;
|
||||
case "banlist":
|
||||
irc.banlist(chan.name);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
51
server/plugins/inputs/connect.ts
Normal file
51
server/plugins/inputs/connect.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["connect", "server"];
|
||||
const allowDisconnected = true;
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
if (args.length === 0) {
|
||||
network.userDisconnected = false;
|
||||
this.save();
|
||||
|
||||
const irc = network.irc;
|
||||
|
||||
if (!irc) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (irc.connection && irc.connection.connected) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You are already connected.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
irc.connect();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let port = args[1] || "";
|
||||
const tls = port[0] === "+";
|
||||
|
||||
if (tls) {
|
||||
port = port.substring(1);
|
||||
}
|
||||
|
||||
const host = args[0];
|
||||
this.connect({host, port, tls});
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
allowDisconnected,
|
||||
};
|
||||
37
server/plugins/inputs/ctcp.ts
Normal file
37
server/plugins/inputs/ctcp.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["ctcp"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (args.length < 2) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "Usage: /ctcp <nick> <ctcp_type>",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = args.shift()!;
|
||||
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.CTCP_REQUEST,
|
||||
ctcpMessage: `"${target}" to ${args[0]}`,
|
||||
from: chan.getUser(irc.user.nick),
|
||||
})
|
||||
);
|
||||
|
||||
const type = args.shift()!;
|
||||
|
||||
irc.ctcpRequest(target, type, ...args);
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
19
server/plugins/inputs/disconnect.ts
Normal file
19
server/plugins/inputs/disconnect.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["disconnect"];
|
||||
const allowDisconnected = true;
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
const quitMessage = args[0] ? args.join(" ") : undefined;
|
||||
|
||||
network.quit(quitMessage);
|
||||
network.userDisconnected = true;
|
||||
|
||||
this.save();
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
allowDisconnected,
|
||||
};
|
||||
155
server/plugins/inputs/ignore.ts
Normal file
155
server/plugins/inputs/ignore.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
import Msg, {MessageType} from "../../models/msg";
|
||||
import Helper from "../../helper";
|
||||
import {PluginInputHandler} from "./index";
|
||||
import {IgnoreListItem} from "../../models/network";
|
||||
import {ChanType, SpecialChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["ignore", "unignore", "ignorelist"];
|
||||
|
||||
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)) {
|
||||
chan.pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `Usage: /${cmd} <nick>[!ident][@host]`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (cmd !== "ignorelist") {
|
||||
// Trim to remove any spaces from the hostmask
|
||||
target = args[0].trim();
|
||||
hostmask = Helper.parseHostmask(target) as IgnoreListItem;
|
||||
}
|
||||
|
||||
switch (cmd) {
|
||||
case "ignore": {
|
||||
// IRC nicks are case insensitive
|
||||
if (hostmask!.nick.toLowerCase() === network.nick.toLowerCase()) {
|
||||
chan.pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You can't ignore yourself",
|
||||
})
|
||||
);
|
||||
} else 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({
|
||||
type: MessageType.ERROR,
|
||||
text: "The specified user/hostmask is already ignored",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case "unignore": {
|
||||
const idx = network.ignoreList.findIndex(function (entry) {
|
||||
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 {
|
||||
chan.pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "The specified user/hostmask is not ignored",
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
102
server/plugins/inputs/index.ts
Normal file
102
server/plugins/inputs/index.ts
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
import Client from "../../client";
|
||||
import log from "../../log";
|
||||
import Chan, {Channel} from "../../models/chan";
|
||||
import Network, {NetworkWithIrcFramework} from "../../models/network";
|
||||
import {PackageInfo} from "../packages";
|
||||
|
||||
export type PluginInputHandler = (
|
||||
this: Client,
|
||||
network: NetworkWithIrcFramework,
|
||||
chan: Channel,
|
||||
cmd: string,
|
||||
args: string[]
|
||||
) => void;
|
||||
|
||||
type Plugin = {
|
||||
commands: string[];
|
||||
input: (network: Network, chan: Chan, cmd: string, args: string[]) => void;
|
||||
allowDisconnected?: boolean | undefined;
|
||||
};
|
||||
|
||||
const clientSideCommands = ["/collapse", "/expand", "/search"];
|
||||
|
||||
const passThroughCommands = [
|
||||
"/as",
|
||||
"/bs",
|
||||
"/cs",
|
||||
"/ho",
|
||||
"/hs",
|
||||
"/join",
|
||||
"/ms",
|
||||
"/ns",
|
||||
"/os",
|
||||
"/rs",
|
||||
];
|
||||
|
||||
const userInputs = new Map<string, Plugin>();
|
||||
const builtInInputs = [
|
||||
"action",
|
||||
"away",
|
||||
"ban",
|
||||
"connect",
|
||||
"ctcp",
|
||||
"disconnect",
|
||||
"ignore",
|
||||
"invite",
|
||||
"kick",
|
||||
"kill",
|
||||
"list",
|
||||
"mode",
|
||||
"msg",
|
||||
"nick",
|
||||
"notice",
|
||||
"part",
|
||||
"quit",
|
||||
"raw",
|
||||
"rejoin",
|
||||
"topic",
|
||||
"whois",
|
||||
"mute",
|
||||
];
|
||||
|
||||
for (const input of builtInInputs) {
|
||||
import(`./${input}`)
|
||||
.then(
|
||||
(plugin: {
|
||||
default: {
|
||||
commands: string[];
|
||||
input: (network: Network, chan: Chan, cmd: string, args: string[]) => void;
|
||||
allowDisconnected?: boolean;
|
||||
};
|
||||
}) => {
|
||||
plugin.default.commands.forEach((command: string) =>
|
||||
userInputs.set(command, plugin.default)
|
||||
);
|
||||
}
|
||||
)
|
||||
.catch((err) => {
|
||||
log.error(err);
|
||||
});
|
||||
}
|
||||
|
||||
const pluginCommands = new Map();
|
||||
|
||||
const getCommands = () =>
|
||||
Array.from(userInputs.keys())
|
||||
.concat(Array.from(pluginCommands.keys()))
|
||||
.map((command) => `/${command}`)
|
||||
.concat(clientSideCommands)
|
||||
.concat(passThroughCommands)
|
||||
.sort();
|
||||
|
||||
const addPluginCommand = (packageInfo: PackageInfo, command, func) => {
|
||||
func.packageInfo = packageInfo;
|
||||
pluginCommands.set(command, func);
|
||||
};
|
||||
|
||||
export default {
|
||||
addPluginCommand,
|
||||
getCommands,
|
||||
pluginCommands,
|
||||
userInputs,
|
||||
};
|
||||
31
server/plugins/inputs/invite.ts
Normal file
31
server/plugins/inputs/invite.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {ChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["invite", "invitelist"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (cmd === "invitelist") {
|
||||
irc.inviteList(chan.name);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length === 2) {
|
||||
irc.raw("INVITE", args[0], args[1]); // Channel provided in the command
|
||||
} else if (args.length === 1 && chan.type === ChanType.CHANNEL) {
|
||||
irc.raw("INVITE", args[0], chan.name); // Current channel
|
||||
} else {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `${cmd} command can only be used in channels or by specifying a target.`,
|
||||
})
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
30
server/plugins/inputs/kick.ts
Normal file
30
server/plugins/inputs/kick.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {ChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["kick"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (chan.type !== ChanType.CHANNEL) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `${cmd} command can only be used in channels.`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length !== 0) {
|
||||
irc.raw("KICK", chan.name, args[0], args.slice(1).join(" "));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
16
server/plugins/inputs/kill.ts
Normal file
16
server/plugins/inputs/kill.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["kill"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (args.length !== 0) {
|
||||
irc.raw("KILL", args[0], args.slice(1).join(" "));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
14
server/plugins/inputs/list.ts
Normal file
14
server/plugins/inputs/list.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["list"];
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
network.chanCache = [];
|
||||
network.irc.list(...args);
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
72
server/plugins/inputs/mode.ts
Normal file
72
server/plugins/inputs/mode.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {ChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["mode", "umode", "op", "deop", "hop", "dehop", "voice", "devoice"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc, nick}, chan, cmd, args) {
|
||||
if (cmd === "umode") {
|
||||
irc.raw("MODE", nick, ...args);
|
||||
|
||||
return;
|
||||
} else if (cmd !== "mode") {
|
||||
if (chan.type !== ChanType.CHANNEL) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `${cmd} command can only be used in channels.`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const target = args.filter((arg) => arg !== "");
|
||||
|
||||
if (target.length === 0) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `Usage: /${cmd} <nick> [...nick]`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const mode = {
|
||||
op: "+o",
|
||||
hop: "+h",
|
||||
voice: "+v",
|
||||
deop: "-o",
|
||||
dehop: "-h",
|
||||
devoice: "-v",
|
||||
}[cmd];
|
||||
|
||||
const limit = parseInt(irc.network.supports("MODES")) || target.length;
|
||||
|
||||
for (let i = 0; i < target.length; i += limit) {
|
||||
const targets = target.slice(i, i + limit);
|
||||
const amode = `${mode![0]}${mode![1].repeat(targets.length)}`;
|
||||
irc.raw("MODE", chan.name, amode, ...targets);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length === 0 || args[0][0] === "+" || args[0][0] === "-") {
|
||||
args.unshift(
|
||||
chan.type === ChanType.CHANNEL || chan.type === ChanType.QUERY ? chan.name : nick
|
||||
);
|
||||
}
|
||||
|
||||
irc.raw("MODE", ...args);
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
125
server/plugins/inputs/msg.ts
Normal file
125
server/plugins/inputs/msg.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import Chan, {ChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["query", "msg", "say"];
|
||||
|
||||
function getTarget(cmd: string, args: string[], chan: Chan) {
|
||||
switch (cmd) {
|
||||
case "msg":
|
||||
case "query":
|
||||
return args.shift();
|
||||
default:
|
||||
return chan.name;
|
||||
}
|
||||
}
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
let targetName = getTarget(cmd, args, chan);
|
||||
|
||||
if (cmd === "query") {
|
||||
if (!targetName) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You cannot open a query window without an argument.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const target = network.getChannel(targetName);
|
||||
|
||||
if (typeof target === "undefined") {
|
||||
const char = targetName[0];
|
||||
|
||||
if (
|
||||
network.irc.network.options.CHANTYPES &&
|
||||
network.irc.network.options.CHANTYPES.includes(char)
|
||||
) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You can not open query windows for channels, use /join instead.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (let i = 0; i < network.irc.network.options.PREFIX.length; i++) {
|
||||
if (network.irc.network.options.PREFIX[i].symbol === char) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You can not open query windows for names starting with a user prefix.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const newChan = this.createChannel({
|
||||
type: ChanType.QUERY,
|
||||
name: targetName,
|
||||
});
|
||||
|
||||
this.emit("join", {
|
||||
network: network.uuid,
|
||||
chan: newChan.getFilteredClone(true),
|
||||
shouldOpen: true,
|
||||
index: network.addChannel(newChan),
|
||||
});
|
||||
this.save();
|
||||
newChan.loadMessages(this, network);
|
||||
}
|
||||
}
|
||||
|
||||
if (args.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (!targetName) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const msg = args.join(" ");
|
||||
|
||||
if (msg.length === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
network.irc.say(targetName, msg);
|
||||
|
||||
if (!network.irc.network.cap.isEnabled("echo-message")) {
|
||||
const parsedTarget = network.irc.network.extractTargetGroup(targetName);
|
||||
let targetGroup;
|
||||
|
||||
if (parsedTarget) {
|
||||
targetName = parsedTarget.target as string;
|
||||
targetGroup = parsedTarget.target_group;
|
||||
}
|
||||
|
||||
const channel = network.getChannel(targetName);
|
||||
|
||||
if (typeof channel !== "undefined") {
|
||||
network.irc.emit("privmsg", {
|
||||
nick: network.irc.user.nick,
|
||||
ident: network.irc.user.username,
|
||||
hostname: network.irc.user.host,
|
||||
target: targetName,
|
||||
group: targetGroup,
|
||||
message: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
73
server/plugins/inputs/mute.ts
Normal file
73
server/plugins/inputs/mute.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import Chan from "../../models/chan";
|
||||
import Network from "../../models/network";
|
||||
import {PluginInputHandler} from "./index";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
import Client from "../../client";
|
||||
|
||||
const commands = ["mute", "unmute"];
|
||||
const allowDisconnected = true;
|
||||
|
||||
function args_to_channels(network: Network, args: string[]) {
|
||||
const targets: Chan[] = [];
|
||||
|
||||
for (const arg of args) {
|
||||
const target = network.channels.find((c) => c.name === arg);
|
||||
|
||||
if (target) {
|
||||
targets.push(target);
|
||||
}
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
function change_mute_state(client: Client, target: Chan, valueToSet: boolean) {
|
||||
if (target.type === "special") {
|
||||
return;
|
||||
}
|
||||
|
||||
target.setMuteStatus(valueToSet);
|
||||
client.emit("mute:changed", {
|
||||
target: target.id,
|
||||
status: valueToSet,
|
||||
});
|
||||
}
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
const valueToSet = cmd === "mute" ? true : false;
|
||||
const client = this;
|
||||
|
||||
if (args.length === 0) {
|
||||
change_mute_state(client, chan, valueToSet);
|
||||
return;
|
||||
}
|
||||
|
||||
const targets = args_to_channels(network, args);
|
||||
|
||||
if (targets.length !== args.length) {
|
||||
const targetNames = targets.map((ch) => ch.name);
|
||||
const missing = args.filter((x) => !targetNames.includes(x));
|
||||
chan.pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `No open ${
|
||||
missing.length === 1 ? "channel or user" : "channels or users"
|
||||
} found for ${missing.join(",")}`,
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
for (const target of targets) {
|
||||
change_mute_state(client, target, valueToSet);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
allowDisconnected,
|
||||
};
|
||||
73
server/plugins/inputs/nick.ts
Normal file
73
server/plugins/inputs/nick.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
const commands = ["nick"];
|
||||
const allowDisconnected = true;
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
if (args.length === 0) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "Usage: /nick <your new nick>",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (args.length !== 1) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "Nicknames may not contain spaces.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const newNick = args[0];
|
||||
|
||||
if (newNick.length > 100) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "Nicknames may not be this long.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If we were trying to keep a nick and user changes nick, stop trying to keep the old one
|
||||
network.keepNick = null;
|
||||
|
||||
// If connected to IRC, send to server and wait for ACK
|
||||
// otherwise update the nick and UI straight away
|
||||
if (network.irc) {
|
||||
if (network.irc.connection && network.irc.connection.connected) {
|
||||
network.irc.changeNick(newNick);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
network.irc.options.nick = network.irc.user.nick = newNick;
|
||||
}
|
||||
|
||||
network.setNick(newNick);
|
||||
|
||||
this.emit("nick", {
|
||||
network: network.uuid,
|
||||
nick: newNick,
|
||||
});
|
||||
|
||||
this.save();
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
allowDisconnected,
|
||||
};
|
||||
44
server/plugins/inputs/notice.ts
Normal file
44
server/plugins/inputs/notice.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["notice"];
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
if (!args[1]) {
|
||||
return;
|
||||
}
|
||||
|
||||
let targetName = args[0];
|
||||
let message = args.slice(1).join(" ");
|
||||
|
||||
network.irc.notice(targetName, message);
|
||||
|
||||
if (!network.irc.network.cap.isEnabled("echo-message")) {
|
||||
let targetGroup;
|
||||
const parsedTarget = network.irc.network.extractTargetGroup(targetName);
|
||||
|
||||
if (parsedTarget) {
|
||||
targetName = parsedTarget.target;
|
||||
targetGroup = parsedTarget.target_group;
|
||||
}
|
||||
|
||||
const targetChan = network.getChannel(targetName);
|
||||
|
||||
if (typeof targetChan === "undefined") {
|
||||
message = "{to " + args[0] + "} " + message;
|
||||
}
|
||||
|
||||
network.irc.emit("notice", {
|
||||
nick: network.irc.user.nick,
|
||||
target: targetName,
|
||||
group: targetGroup,
|
||||
message: message,
|
||||
});
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
56
server/plugins/inputs/part.ts
Normal file
56
server/plugins/inputs/part.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import Config from "../../config";
|
||||
import {ChanType, ChanState} from "../../models/chan";
|
||||
|
||||
const commands = ["close", "leave", "part"];
|
||||
const allowDisconnected = true;
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
let target = chan;
|
||||
|
||||
if (args.length > 0) {
|
||||
const newTarget = network.getChannel(args[0]);
|
||||
|
||||
if (typeof newTarget !== "undefined") {
|
||||
// If first argument is a channel user is in, part that channel
|
||||
target = newTarget;
|
||||
args.shift();
|
||||
}
|
||||
}
|
||||
|
||||
if (target.type === ChanType.LOBBY) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You can not part from networks, use /quit instead.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// If target is not a channel or we are not connected, instantly remove the channel
|
||||
// Otherwise send part to the server and wait for response
|
||||
if (
|
||||
target.type !== ChanType.CHANNEL ||
|
||||
target.state === ChanState.PARTED ||
|
||||
!network.irc ||
|
||||
!network.irc.connection ||
|
||||
!network.irc.connection.connected
|
||||
) {
|
||||
this.part(network, target);
|
||||
} else {
|
||||
const partMessage = args.join(" ") || network.leaveMessage || Config.values.leaveMessage;
|
||||
network.irc.part(target.name, partMessage);
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
allowDisconnected,
|
||||
};
|
||||
31
server/plugins/inputs/quit.ts
Normal file
31
server/plugins/inputs/quit.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import _ from "lodash";
|
||||
|
||||
import {PluginInputHandler} from "./index";
|
||||
import ClientCertificate from "../clientCertificate";
|
||||
|
||||
const commands = ["quit"];
|
||||
const allowDisconnected = true;
|
||||
|
||||
const input: PluginInputHandler = function (network, chan, cmd, args) {
|
||||
const client = this;
|
||||
|
||||
client.networks = _.without(client.networks, network);
|
||||
network.destroy();
|
||||
client.save();
|
||||
client.emit("quit", {
|
||||
network: network.uuid,
|
||||
});
|
||||
|
||||
const quitMessage = args[0] ? args.join(" ") : undefined;
|
||||
network.quit(quitMessage);
|
||||
|
||||
ClientCertificate.remove(network.uuid);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
allowDisconnected,
|
||||
};
|
||||
16
server/plugins/inputs/raw.ts
Normal file
16
server/plugins/inputs/raw.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["raw", "send", "quote"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (args.length !== 0) {
|
||||
irc.connection.write(args.join(" "));
|
||||
}
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
29
server/plugins/inputs/rejoin.ts
Normal file
29
server/plugins/inputs/rejoin.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {ChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["cycle", "rejoin"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan) {
|
||||
if (chan.type !== ChanType.CHANNEL) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "You can only rejoin channels.",
|
||||
})
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
irc.part(chan.name, "Rejoining");
|
||||
irc.join(chan.name);
|
||||
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
28
server/plugins/inputs/topic.ts
Normal file
28
server/plugins/inputs/topic.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {ChanType} from "../../models/chan";
|
||||
|
||||
const commands = ["topic"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (chan.type !== ChanType.CHANNEL) {
|
||||
chan.pushMessage(
|
||||
this,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `${cmd} command can only be used in channels.`,
|
||||
})
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
irc.setTopic(chan.name, args.join(" "));
|
||||
return true;
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
20
server/plugins/inputs/whois.ts
Normal file
20
server/plugins/inputs/whois.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import {PluginInputHandler} from "./index";
|
||||
|
||||
const commands = ["whois"];
|
||||
|
||||
const input: PluginInputHandler = function ({irc}, chan, cmd, args) {
|
||||
if (args.length === 1) {
|
||||
// This queries server of the other user and not of the current user, which
|
||||
// does not know idle time.
|
||||
// See http://superuser.com/a/272069/208074.
|
||||
irc.raw("WHOIS", args[0], args[0]);
|
||||
} else {
|
||||
// Re-assembling the command parsed in client.js
|
||||
irc.raw(`${cmd} ${args.join(" ")}`);
|
||||
}
|
||||
};
|
||||
|
||||
export default {
|
||||
commands,
|
||||
input,
|
||||
};
|
||||
72
server/plugins/irc-events/away.ts
Normal file
72
server/plugins/irc-events/away.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
import {ChanType} from "../../models/chan";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("away", (data) => handleAway(MessageType.AWAY, data));
|
||||
irc.on("back", (data) => handleAway(MessageType.BACK, data));
|
||||
|
||||
function handleAway(type: MessageType, data) {
|
||||
const away = data.message;
|
||||
|
||||
if (data.self) {
|
||||
const msg = new Msg({
|
||||
self: true,
|
||||
type: type,
|
||||
text: away,
|
||||
time: data.time,
|
||||
});
|
||||
|
||||
network.channels[0].pushMessage(client, msg, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
network.channels.forEach((chan) => {
|
||||
let user;
|
||||
|
||||
switch (chan.type) {
|
||||
case ChanType.QUERY: {
|
||||
if (data.nick.toLowerCase() !== chan.name.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (chan.userAway === away) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Store current away message on channel model,
|
||||
// because query windows have no users
|
||||
chan.userAway = away;
|
||||
|
||||
user = chan.getUser(data.nick);
|
||||
|
||||
const msg = new Msg({
|
||||
type: type,
|
||||
text: away || "",
|
||||
time: data.time,
|
||||
from: user,
|
||||
});
|
||||
|
||||
chan.pushMessage(client, msg);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
case ChanType.CHANNEL: {
|
||||
user = chan.findUser(data.nick);
|
||||
|
||||
if (!user || user.away === away) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.away = away;
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
78
server/plugins/irc-events/cap.ts
Normal file
78
server/plugins/irc-events/cap.ts
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg from "../../models/msg";
|
||||
import STSPolicies from "../sts";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("cap ls", (data) => {
|
||||
handleSTS(data, true);
|
||||
});
|
||||
|
||||
irc.on("cap new", (data) => {
|
||||
handleSTS(data, false);
|
||||
});
|
||||
|
||||
function handleSTS(data, shouldReconnect) {
|
||||
if (!Object.prototype.hasOwnProperty.call(data.capabilities, "sts")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const isSecure = irc.connection.transport.socket.encrypted;
|
||||
const values = {} as any;
|
||||
|
||||
data.capabilities.sts.split(",").map((value) => {
|
||||
value = value.split("=", 2);
|
||||
values[value[0]] = value[1];
|
||||
});
|
||||
|
||||
if (isSecure) {
|
||||
const duration = parseInt(values.duration, 10);
|
||||
|
||||
if (isNaN(duration)) {
|
||||
return;
|
||||
}
|
||||
|
||||
STSPolicies.update(network.host, network.port, duration);
|
||||
} else {
|
||||
const port = parseInt(values.port, 10);
|
||||
|
||||
if (isNaN(port)) {
|
||||
return;
|
||||
}
|
||||
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: `Server sent a strict transport security policy, reconnecting to ${network.host}:${port}…`,
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
// Forcefully end the connection if STS is seen in CAP LS
|
||||
// We will update the port and tls setting if we see CAP NEW,
|
||||
// but will not force a reconnection
|
||||
if (shouldReconnect) {
|
||||
irc.connection.end();
|
||||
}
|
||||
|
||||
// Update the port
|
||||
network.port = port;
|
||||
irc.options.port = port;
|
||||
|
||||
// Enable TLS
|
||||
network.tls = true;
|
||||
network.rejectUnauthorized = true;
|
||||
irc.options.tls = true;
|
||||
irc.options.rejectUnauthorized = true;
|
||||
|
||||
if (shouldReconnect) {
|
||||
// Start a new connection
|
||||
irc.connect();
|
||||
}
|
||||
|
||||
client.save();
|
||||
}
|
||||
}
|
||||
};
|
||||
30
server/plugins/irc-events/chghost.ts
Normal file
30
server/plugins/irc-events/chghost.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
// If server supports CHGHOST cap, then changing the hostname does not require
|
||||
// sending PART and JOIN, which means less work for us over all
|
||||
irc.on("user updated", function (data) {
|
||||
network.channels.forEach((chan) => {
|
||||
const user = chan.findUser(data.nick);
|
||||
|
||||
if (typeof user === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
time: data.time,
|
||||
type: MessageType.CHGHOST,
|
||||
new_ident: data.ident !== data.new_ident ? data.new_ident : "",
|
||||
new_host: data.hostname !== data.new_hostname ? data.new_hostname : "",
|
||||
self: data.nick === irc.user.nick,
|
||||
from: user,
|
||||
});
|
||||
|
||||
chan.pushMessage(client, msg);
|
||||
});
|
||||
});
|
||||
};
|
||||
223
server/plugins/irc-events/connection.ts
Normal file
223
server/plugins/irc-events/connection.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/* eslint-disable @typescript-eslint/restrict-plus-operands */
|
||||
import _ from "lodash";
|
||||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import log from "../../log";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import Helper from "../../helper";
|
||||
import Config from "../../config";
|
||||
import {ChanType, ChanState} from "../../models/chan";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: "Network created, connecting to " + network.host + ":" + network.port + "...",
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
irc.on("registered", function () {
|
||||
if (network.irc.network.cap.enabled.length > 0) {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: "Enabled capabilities: " + network.irc.network.cap.enabled.join(", "),
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Always restore away message for this network
|
||||
if (network.awayMessage) {
|
||||
irc.raw("AWAY", network.awayMessage);
|
||||
// Only set generic away message if there are no clients attached
|
||||
} else if (client.awayMessage && _.size(client.attachedClients) === 0) {
|
||||
irc.raw("AWAY", client.awayMessage);
|
||||
}
|
||||
|
||||
let delay = 1000;
|
||||
|
||||
if (Array.isArray(network.commands)) {
|
||||
network.commands.forEach((cmd) => {
|
||||
setTimeout(function () {
|
||||
client.input({
|
||||
target: network.channels[0].id,
|
||||
text: cmd,
|
||||
});
|
||||
}, delay);
|
||||
delay += 1000;
|
||||
});
|
||||
}
|
||||
|
||||
network.channels.forEach((chan) => {
|
||||
if (chan.type !== ChanType.CHANNEL) {
|
||||
return;
|
||||
}
|
||||
|
||||
setTimeout(function () {
|
||||
network.irc.join(chan.name, chan.key);
|
||||
}, delay);
|
||||
delay += 1000;
|
||||
});
|
||||
});
|
||||
|
||||
irc.on("socket connected", function () {
|
||||
if (irc.network.options.PREFIX) {
|
||||
network.serverOptions.PREFIX.update(irc.network.options.PREFIX);
|
||||
}
|
||||
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: "Connected to the network.",
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
sendStatus();
|
||||
});
|
||||
|
||||
irc.on("close", function () {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: "Disconnected from the network, and will not reconnect. Use /connect to reconnect again.",
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
let identSocketId;
|
||||
|
||||
irc.on("raw socket connected", function (socket) {
|
||||
let ident = client.name || network.username;
|
||||
|
||||
if (Config.values.useHexIp) {
|
||||
ident = Helper.ip2hex(client.config.browser!.ip!);
|
||||
}
|
||||
|
||||
identSocketId = client.manager.identHandler.addSocket(socket, ident);
|
||||
});
|
||||
|
||||
irc.on("socket close", function (error) {
|
||||
if (identSocketId > 0) {
|
||||
client.manager.identHandler.removeSocket(identSocketId);
|
||||
identSocketId = 0;
|
||||
}
|
||||
|
||||
network.channels.forEach((chan) => {
|
||||
chan.users = new Map();
|
||||
chan.state = ChanState.PARTED;
|
||||
});
|
||||
|
||||
if (error) {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: `Connection closed unexpectedly: ${String(error)}`,
|
||||
}),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
if (network.keepNick) {
|
||||
// We disconnected without getting our original nick back yet, just set it back locally
|
||||
irc.options.nick = irc.user.nick = network.keepNick;
|
||||
|
||||
network.setNick(network.keepNick);
|
||||
network.keepNick = null;
|
||||
|
||||
client.emit("nick", {
|
||||
network: network.uuid,
|
||||
nick: network.nick,
|
||||
});
|
||||
}
|
||||
|
||||
sendStatus();
|
||||
});
|
||||
|
||||
if (Config.values.debug.ircFramework) {
|
||||
irc.on("debug", function (message) {
|
||||
log.debug(
|
||||
`[${client.name} (${client.id}) on ${network.name} (${network.uuid}]`,
|
||||
message
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (Config.values.debug.raw) {
|
||||
irc.on("raw", function (message) {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
self: !message.from_server,
|
||||
type: MessageType.RAW,
|
||||
text: message.line,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
irc.on("socket error", function (err) {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: "Socket error: " + err,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
irc.on("reconnecting", function (data) {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: `Disconnected from the network. Reconnecting in ${Math.round(
|
||||
data.wait / 1000
|
||||
)} seconds… (Attempt ${Number(data.attempt)})`,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
irc.on("ping timeout", function () {
|
||||
network.channels[0].pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
text: "Ping timeout, disconnecting…",
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
|
||||
irc.on("server options", function (data) {
|
||||
network.serverOptions.PREFIX.update(data.options.PREFIX);
|
||||
|
||||
if (data.options.CHANTYPES) {
|
||||
network.serverOptions.CHANTYPES = data.options.CHANTYPES;
|
||||
}
|
||||
|
||||
network.serverOptions.NETWORK = data.options.NETWORK;
|
||||
|
||||
client.emit("network:options", {
|
||||
network: network.uuid,
|
||||
serverOptions: network.serverOptions,
|
||||
});
|
||||
});
|
||||
|
||||
function sendStatus() {
|
||||
const status = network.getNetworkStatus();
|
||||
const toSend = {
|
||||
...status,
|
||||
network: network.uuid,
|
||||
};
|
||||
|
||||
client.emit("network:status", toSend);
|
||||
}
|
||||
};
|
||||
91
server/plugins/irc-events/ctcp.ts
Normal file
91
server/plugins/irc-events/ctcp.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import _ from "lodash";
|
||||
import {IrcEventHandler} from "../../client";
|
||||
import Helper from "../../helper";
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import User from "../../models/user";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
const ctcpResponses = {
|
||||
CLIENTINFO: () =>
|
||||
Object.getOwnPropertyNames(ctcpResponses)
|
||||
.filter((key) => key !== "CLIENTINFO" && typeof ctcpResponses[key] === "function")
|
||||
.join(" "),
|
||||
PING: ({message}: {message: string}) => message.substring(5),
|
||||
SOURCE: () => pkg.repository.url,
|
||||
VERSION: () => pkg.name + " " + Helper.getVersion() + " -- " + pkg.homepage,
|
||||
};
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
const lobby = network.channels[0];
|
||||
|
||||
irc.on("ctcp response", function (data) {
|
||||
const shouldIgnore = network.ignoreList.some(function (entry) {
|
||||
return Helper.compareHostmask(entry, data);
|
||||
});
|
||||
|
||||
if (shouldIgnore) {
|
||||
return;
|
||||
}
|
||||
|
||||
let chan = network.getChannel(data.nick);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
chan = lobby;
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.CTCP,
|
||||
time: data.time,
|
||||
from: chan.getUser(data.nick),
|
||||
ctcpMessage: data.message,
|
||||
});
|
||||
chan.pushMessage(client, msg, true);
|
||||
});
|
||||
|
||||
// Limit requests to a rate of one per second max
|
||||
irc.on(
|
||||
"ctcp request",
|
||||
_.throttle(
|
||||
(data) => {
|
||||
// Ignore echoed ctcp requests that aren't targeted at us
|
||||
// See https://github.com/kiwiirc/irc-framework/issues/225
|
||||
if (
|
||||
data.nick === irc.user.nick &&
|
||||
data.nick !== data.target &&
|
||||
network.irc.network.cap.isEnabled("echo-message")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const shouldIgnore = network.ignoreList.some(function (entry) {
|
||||
return Helper.compareHostmask(entry, data);
|
||||
});
|
||||
|
||||
if (shouldIgnore) {
|
||||
return;
|
||||
}
|
||||
|
||||
const target = data.from_server ? data.hostname : data.nick;
|
||||
const response = ctcpResponses[data.type];
|
||||
|
||||
if (response) {
|
||||
irc.ctcpResponse(target, data.type, response(data));
|
||||
}
|
||||
|
||||
// Let user know someone is making a CTCP request against their nick
|
||||
const msg = new Msg({
|
||||
type: MessageType.CTCP_REQUEST,
|
||||
time: data.time,
|
||||
from: new User({nick: target}),
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
hostmask: data.ident + "@" + data.hostname,
|
||||
ctcpMessage: data.message,
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
},
|
||||
1000,
|
||||
{trailing: false}
|
||||
)
|
||||
);
|
||||
};
|
||||
94
server/plugins/irc-events/error.ts
Normal file
94
server/plugins/irc-events/error.ts
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import Config from "../../config";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("irc error", function (data) {
|
||||
const msg = new Msg({
|
||||
type: MessageType.ERROR,
|
||||
error: data.error,
|
||||
showInActive: true,
|
||||
nick: data.nick,
|
||||
channel: data.channel,
|
||||
reason: data.reason,
|
||||
command: data.command,
|
||||
});
|
||||
|
||||
let target = network.channels[0];
|
||||
|
||||
// If this error is channel specific and a channel
|
||||
// with this name exists, put this error in that channel
|
||||
if (data.channel) {
|
||||
const channel = network.getChannel(data.channel);
|
||||
|
||||
if (typeof channel !== "undefined") {
|
||||
target = channel;
|
||||
msg.showInActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
target.pushMessage(client, msg, true);
|
||||
});
|
||||
|
||||
irc.on("nick in use", function (data) {
|
||||
let message = data.nick + ": " + (data.reason || "Nickname is already in use.");
|
||||
|
||||
if (irc.connection.registered === false && !Config.values.public) {
|
||||
message += " An attempt to use it will be made when this nick quits.";
|
||||
|
||||
// Clients usually get nick in use on connect when reconnecting to a network
|
||||
// after a network failure (like ping timeout), and as a result of that,
|
||||
// TL will append a random number to the nick.
|
||||
// keepNick will try to set the original nick name back if it sees a QUIT for that nick.
|
||||
network.keepNick = irc.user.nick;
|
||||
}
|
||||
|
||||
const lobby = network.channels[0];
|
||||
const msg = new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: message,
|
||||
showInActive: true,
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
|
||||
if (irc.connection.registered === false) {
|
||||
const nickLen = parseInt(network.irc.network.options.NICKLEN, 10) || 16;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
const random = (data.nick || irc.user.nick) + Math.floor(Math.random() * 10);
|
||||
|
||||
// Safeguard nick changes up to allowed length
|
||||
// Some servers may send "nick in use" error even for randomly generated nicks
|
||||
if (random.length <= nickLen) {
|
||||
irc.changeNick(random);
|
||||
}
|
||||
}
|
||||
|
||||
client.emit("nick", {
|
||||
network: network.uuid,
|
||||
nick: irc.user.nick,
|
||||
});
|
||||
});
|
||||
|
||||
irc.on("nick invalid", function (data) {
|
||||
const lobby = network.channels[0];
|
||||
const msg = new Msg({
|
||||
type: MessageType.ERROR,
|
||||
text: data.nick + ": " + (data.reason || "Nickname is invalid."),
|
||||
showInActive: true,
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
|
||||
if (irc.connection.registered === false) {
|
||||
irc.changeNick(Config.getDefaultNick());
|
||||
}
|
||||
|
||||
client.emit("nick", {
|
||||
network: network.uuid,
|
||||
nick: irc.user.nick,
|
||||
});
|
||||
});
|
||||
};
|
||||
19
server/plugins/irc-events/help.ts
Normal file
19
server/plugins/irc-events/help.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("help", function (data) {
|
||||
const lobby = network.channels[0];
|
||||
|
||||
if (data.help) {
|
||||
const msg = new Msg({
|
||||
type: MessageType.MONOSPACE_BLOCK,
|
||||
command: "help",
|
||||
text: data.help,
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
19
server/plugins/irc-events/info.ts
Normal file
19
server/plugins/irc-events/info.ts
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Msg, {MessageType} from "../../models/msg";
|
||||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("info", function (data) {
|
||||
const lobby = network.channels[0];
|
||||
|
||||
if (data.info) {
|
||||
const msg = new Msg({
|
||||
type: MessageType.MONOSPACE_BLOCK,
|
||||
command: "info",
|
||||
text: data.info,
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
}
|
||||
});
|
||||
};
|
||||
28
server/plugins/irc-events/invite.ts
Normal file
28
server/plugins/irc-events/invite.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("invite", function (data) {
|
||||
let chan = network.getChannel(data.channel);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
chan = network.channels[0];
|
||||
}
|
||||
|
||||
const invitedYou = data.invited === irc.user.nick;
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.INVITE,
|
||||
time: data.time,
|
||||
from: chan.getUser(data.nick),
|
||||
target: chan.getUser(data.invited),
|
||||
channel: data.channel,
|
||||
highlight: invitedYou,
|
||||
invitedYou: invitedYou,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
});
|
||||
};
|
||||
55
server/plugins/irc-events/join.ts
Normal file
55
server/plugins/irc-events/join.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import Msg, {MessageType} from "../../models/msg";
|
||||
import User from "../../models/user";
|
||||
import type {IrcEventHandler} from "../../client";
|
||||
import {ChanState} from "../../models/chan";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("join", function (data) {
|
||||
let chan = network.getChannel(data.channel);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
chan = client.createChannel({
|
||||
name: data.channel,
|
||||
state: ChanState.JOINED,
|
||||
});
|
||||
|
||||
client.emit("join", {
|
||||
network: network.uuid,
|
||||
chan: chan.getFilteredClone(true),
|
||||
index: network.addChannel(chan),
|
||||
});
|
||||
client.save();
|
||||
|
||||
chan.loadMessages(client, network);
|
||||
|
||||
// Request channels' modes
|
||||
network.irc.raw("MODE", chan.name);
|
||||
} else if (data.nick === irc.user.nick) {
|
||||
chan.state = ChanState.JOINED;
|
||||
|
||||
client.emit("channel:state", {
|
||||
chan: chan.id,
|
||||
state: chan.state,
|
||||
});
|
||||
}
|
||||
|
||||
const user = new User({nick: data.nick});
|
||||
const msg = new Msg({
|
||||
time: data.time,
|
||||
from: user,
|
||||
hostmask: data.ident + "@" + data.hostname,
|
||||
gecos: data.gecos,
|
||||
account: data.account,
|
||||
type: MessageType.JOIN,
|
||||
self: data.nick === irc.user.nick,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
|
||||
chan.setUser(new User({nick: data.nick}));
|
||||
client.emit("users", {
|
||||
chan: chan.id,
|
||||
});
|
||||
});
|
||||
};
|
||||
40
server/plugins/irc-events/kick.ts
Normal file
40
server/plugins/irc-events/kick.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
import {ChanState} from "../../models/chan";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
import User from "../../models/user";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("kick", function (data) {
|
||||
const chan = network.getChannel(data.channel!);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.KICK,
|
||||
time: data.time,
|
||||
from: chan.getUser(data.nick),
|
||||
target: chan.getUser(data.kicked!),
|
||||
text: data.message || "",
|
||||
highlight: data.kicked === irc.user.nick,
|
||||
self: data.nick === irc.user.nick,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
|
||||
if (data.kicked === irc.user.nick) {
|
||||
chan.users = new Map();
|
||||
chan.state = ChanState.PARTED;
|
||||
|
||||
client.emit("channel:state", {
|
||||
chan: chan.id,
|
||||
state: chan.state,
|
||||
});
|
||||
} else {
|
||||
chan.removeUser(msg.target as User);
|
||||
}
|
||||
});
|
||||
};
|
||||
536
server/plugins/irc-events/link.ts
Normal file
536
server/plugins/irc-events/link.ts
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
import * as cheerio from "cheerio";
|
||||
import got from "got";
|
||||
import {URL} from "url";
|
||||
import mime from "mime-types";
|
||||
|
||||
import log from "../../log";
|
||||
import Config from "../../config";
|
||||
import {findLinksWithSchema} from "../../../client/js/helpers/ircmessageparser/findLinks";
|
||||
import storage from "../storage";
|
||||
import Client from "../../client";
|
||||
import Chan from "../../models/chan";
|
||||
import Msg from "../../models/msg";
|
||||
|
||||
type FetchRequest = {
|
||||
data: Buffer;
|
||||
type: string;
|
||||
size: number;
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
msg.previews = findLinksWithSchema(cleanText).reduce((cleanLinks: LinkPreview[], link) => {
|
||||
const url = normalizeURL(link.link);
|
||||
|
||||
// If the URL is invalid and cannot be normalized, don't fetch it
|
||||
if (!url) {
|
||||
return cleanLinks;
|
||||
}
|
||||
|
||||
// If there are too many urls in this message, only fetch first X valid links
|
||||
if (cleanLinks.length > 4) {
|
||||
return cleanLinks;
|
||||
}
|
||||
|
||||
// Do not fetch duplicate links twice
|
||||
if (cleanLinks.some((l) => l.link === link.link)) {
|
||||
return cleanLinks;
|
||||
}
|
||||
|
||||
const preview: LinkPreview = {
|
||||
type: "loading",
|
||||
head: "",
|
||||
body: "",
|
||||
thumb: "",
|
||||
size: -1,
|
||||
link: link.link, // Send original matched link to the client
|
||||
shown: null,
|
||||
};
|
||||
|
||||
cleanLinks.push(preview);
|
||||
|
||||
fetch(url, {
|
||||
accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
language: client.config.browser?.language || "",
|
||||
})
|
||||
.then((res) => {
|
||||
parse(msg, chan, preview, res, client);
|
||||
})
|
||||
.catch((err) => {
|
||||
preview.type = "error";
|
||||
preview.error = "message";
|
||||
preview.message = err.message;
|
||||
emitPreview(client, chan, msg, preview);
|
||||
});
|
||||
|
||||
return cleanLinks;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function parseHtml(preview, res, client: Client) {
|
||||
// TODO:
|
||||
// eslint-disable-next-line @typescript-eslint/no-misused-promises
|
||||
return new Promise((resolve: (preview: FetchRequest | null) => void) => {
|
||||
const $ = cheerio.load(res.data);
|
||||
|
||||
return parseHtmlMedia($, preview, client)
|
||||
.then((newRes) => resolve(newRes))
|
||||
.catch(() => {
|
||||
preview.type = "link";
|
||||
preview.head =
|
||||
$('meta[property="og:title"]').attr("content") ||
|
||||
$("head > title, title").first().text() ||
|
||||
"";
|
||||
preview.body =
|
||||
$('meta[property="og:description"]').attr("content") ||
|
||||
$('meta[name="description"]').attr("content") ||
|
||||
"";
|
||||
|
||||
if (preview.head.length) {
|
||||
preview.head = preview.head.substr(0, 100);
|
||||
}
|
||||
|
||||
if (preview.body.length) {
|
||||
preview.body = preview.body.substr(0, 300);
|
||||
}
|
||||
|
||||
if (!Config.values.prefetchStorage && Config.values.disableMediaPreview) {
|
||||
resolve(res);
|
||||
return;
|
||||
}
|
||||
|
||||
let thumb =
|
||||
$('meta[property="og:image"]').attr("content") ||
|
||||
$('meta[name="twitter:image:src"]').attr("content") ||
|
||||
$('link[rel="image_src"]').attr("href") ||
|
||||
"";
|
||||
|
||||
// Make sure thumbnail is a valid and absolute url
|
||||
if (thumb.length) {
|
||||
thumb = normalizeURL(thumb, preview.link) || "";
|
||||
}
|
||||
|
||||
// Verify that thumbnail pic exists and is under allowed size
|
||||
if (thumb.length) {
|
||||
fetch(thumb, {language: client.config.browser?.language || ""})
|
||||
.then((resThumb) => {
|
||||
if (
|
||||
resThumb !== null &&
|
||||
imageTypeRegex.test(resThumb.type) &&
|
||||
resThumb.size <= Config.values.prefetchMaxImageSize * 1024
|
||||
) {
|
||||
preview.thumbActualUrl = thumb;
|
||||
}
|
||||
|
||||
resolve(resThumb);
|
||||
})
|
||||
.catch(() => resolve(null));
|
||||
} else {
|
||||
resolve(res);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// TODO: type $
|
||||
function parseHtmlMedia($: any, preview, client: Client): Promise<FetchRequest> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (Config.values.disableMediaPreview) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
let foundMedia = false;
|
||||
const openGraphType = $('meta[property="og:type"]').attr("content");
|
||||
|
||||
// Certain news websites may include video and audio tags,
|
||||
// despite actually being an article (as indicated by og:type).
|
||||
// If there is og:type tag, we will only select video or audio if it matches
|
||||
if (
|
||||
openGraphType &&
|
||||
!openGraphType.startsWith("video") &&
|
||||
!openGraphType.startsWith("music")
|
||||
) {
|
||||
reject();
|
||||
return;
|
||||
}
|
||||
|
||||
["video", "audio"].forEach((type) => {
|
||||
if (foundMedia) {
|
||||
return;
|
||||
}
|
||||
|
||||
$(`meta[property="og:${type}:type"]`).each(function (this: cheerio.Element, i: number) {
|
||||
const mimeType = $(this).attr("content");
|
||||
|
||||
if (!mimeType) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (mediaTypeRegex.test(mimeType)) {
|
||||
// If we match a clean video or audio tag, parse that as a preview instead
|
||||
let mediaUrl = $($(`meta[property="og:${type}"]`).get(i)).attr("content");
|
||||
|
||||
if (!mediaUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Make sure media is a valid url
|
||||
mediaUrl = normalizeURL(mediaUrl, preview.link, true);
|
||||
|
||||
// Make sure media is a valid url
|
||||
if (!mediaUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
foundMedia = true;
|
||||
|
||||
fetch(mediaUrl, {
|
||||
accept:
|
||||
type === "video"
|
||||
? "video/webm,video/ogg,video/*;q=0.9,application/ogg;q=0.7,audio/*;q=0.6,*/*;q=0.5"
|
||||
: "audio/webm, audio/ogg, audio/wav, audio/*;q=0.9, application/ogg;q=0.7, video/*;q=0.6; */*;q=0.5",
|
||||
language: client.config.browser?.language || "",
|
||||
})
|
||||
.then((resMedia) => {
|
||||
if (resMedia === null || !mediaTypeRegex.test(resMedia.type)) {
|
||||
return reject();
|
||||
}
|
||||
|
||||
preview.type = type;
|
||||
preview.media = mediaUrl;
|
||||
preview.mediaType = resMedia.type;
|
||||
|
||||
resolve(resMedia);
|
||||
})
|
||||
.catch(reject);
|
||||
|
||||
return false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!foundMedia) {
|
||||
reject();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parse(msg: Msg, chan: Chan, preview: LinkPreview, res: FetchRequest, client: Client) {
|
||||
let promise: Promise<FetchRequest | null> | null = null;
|
||||
|
||||
preview.size = res.size;
|
||||
|
||||
switch (res.type) {
|
||||
case "text/html":
|
||||
preview.size = -1;
|
||||
promise = parseHtml(preview, res, client);
|
||||
break;
|
||||
|
||||
case "text/plain":
|
||||
preview.type = "link";
|
||||
preview.body = res.data.toString().substr(0, 300);
|
||||
break;
|
||||
|
||||
case "image/png":
|
||||
case "image/gif":
|
||||
case "image/jpg":
|
||||
case "image/jpeg":
|
||||
case "image/jxl":
|
||||
case "image/webp":
|
||||
case "image/avif":
|
||||
if (!Config.values.prefetchStorage && Config.values.disableMediaPreview) {
|
||||
return removePreview(msg, preview);
|
||||
}
|
||||
|
||||
if (res.size > Config.values.prefetchMaxImageSize * 1024) {
|
||||
preview.type = "error";
|
||||
preview.error = "image-too-big";
|
||||
preview.maxSize = Config.values.prefetchMaxImageSize * 1024;
|
||||
} else {
|
||||
preview.type = "image";
|
||||
preview.thumbActualUrl = preview.link;
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
case "audio/midi":
|
||||
case "audio/mpeg":
|
||||
case "audio/mpeg3":
|
||||
case "audio/ogg":
|
||||
case "audio/wav":
|
||||
case "audio/x-wav":
|
||||
case "audio/x-mid":
|
||||
case "audio/x-midi":
|
||||
case "audio/x-mpeg":
|
||||
case "audio/x-mpeg-3":
|
||||
case "audio/flac":
|
||||
case "audio/x-flac":
|
||||
case "audio/mp4":
|
||||
case "audio/x-m4a":
|
||||
if (!preview.link.startsWith("https://")) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Config.values.disableMediaPreview) {
|
||||
return removePreview(msg, preview);
|
||||
}
|
||||
|
||||
preview.type = "audio";
|
||||
preview.media = preview.link;
|
||||
preview.mediaType = res.type;
|
||||
|
||||
break;
|
||||
|
||||
case "video/webm":
|
||||
case "video/ogg":
|
||||
case "video/mp4":
|
||||
if (!preview.link.startsWith("https://")) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (Config.values.disableMediaPreview) {
|
||||
return removePreview(msg, preview);
|
||||
}
|
||||
|
||||
preview.type = "video";
|
||||
preview.media = preview.link;
|
||||
preview.mediaType = res.type;
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
return removePreview(msg, preview);
|
||||
}
|
||||
|
||||
if (!promise) {
|
||||
return handlePreview(client, chan, msg, preview, res);
|
||||
}
|
||||
|
||||
void promise.then((newRes) => handlePreview(client, chan, msg, preview, newRes));
|
||||
}
|
||||
|
||||
function handlePreview(client: Client, chan: Chan, msg: Msg, preview: LinkPreview, res) {
|
||||
const thumb = preview.thumbActualUrl || "";
|
||||
delete preview.thumbActualUrl;
|
||||
|
||||
if (!thumb.length || !Config.values.prefetchStorage) {
|
||||
preview.thumb = thumb;
|
||||
return emitPreview(client, chan, msg, preview);
|
||||
}
|
||||
|
||||
// Get the correct file extension for the provided content-type
|
||||
// This is done to prevent user-input being stored in the file name (extension)
|
||||
const extension = mime.extension(res.type);
|
||||
|
||||
if (!extension) {
|
||||
// For link previews, drop the thumbnail
|
||||
// For other types, do not display preview at all
|
||||
if (preview.type !== "link") {
|
||||
return removePreview(msg, preview);
|
||||
}
|
||||
|
||||
return emitPreview(client, chan, msg, preview);
|
||||
}
|
||||
|
||||
storage.store(res.data, extension, (uri) => {
|
||||
preview.thumb = uri;
|
||||
|
||||
emitPreview(client, chan, msg, preview);
|
||||
});
|
||||
}
|
||||
|
||||
function emitPreview(client: Client, chan: Chan, msg: Msg, preview: LinkPreview) {
|
||||
// If there is no title but there is preview or description, set title
|
||||
// otherwise bail out and show no preview
|
||||
if (!preview.head.length && preview.type === "link") {
|
||||
if (preview.thumb.length || preview.body.length) {
|
||||
preview.head = "Untitled page";
|
||||
} else {
|
||||
return removePreview(msg, preview);
|
||||
}
|
||||
}
|
||||
|
||||
client.emit("msg:preview", {
|
||||
id: msg.id,
|
||||
chan: chan.id,
|
||||
preview: preview,
|
||||
});
|
||||
}
|
||||
|
||||
function removePreview(msg: Msg, preview: LinkPreview) {
|
||||
// If a preview fails to load, remove the link from msg object
|
||||
// So that client doesn't attempt to display an preview on page reload
|
||||
const index = msg.previews.indexOf(preview);
|
||||
|
||||
if (index > -1) {
|
||||
msg.previews.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function getRequestHeaders(headers: Record<string, string>) {
|
||||
const formattedHeaders = {
|
||||
// Certain websites like Amazon only add <meta> tags to known bots,
|
||||
// lets pretend to be them to get the metadata
|
||||
"User-Agent":
|
||||
"Mozilla/5.0 (compatible; The Lounge IRC Client; +https://github.com/thelounge/thelounge)" +
|
||||
" facebookexternalhit/1.1 Twitterbot/1.0",
|
||||
Accept: headers.accept || "*/*",
|
||||
"X-Purpose": "preview",
|
||||
};
|
||||
|
||||
if (headers.language) {
|
||||
formattedHeaders["Accept-Language"] = headers.language;
|
||||
}
|
||||
|
||||
return formattedHeaders;
|
||||
}
|
||||
|
||||
function fetch(uri: string, headers: Record<string, string>) {
|
||||
// Stringify the object otherwise the objects won't compute to the same value
|
||||
const cacheKey = JSON.stringify([uri, headers]);
|
||||
let promise = currentFetchPromises.get(cacheKey);
|
||||
|
||||
if (promise) {
|
||||
return promise;
|
||||
}
|
||||
|
||||
const prefetchTimeout = Config.values.prefetchTimeout;
|
||||
|
||||
if (!prefetchTimeout) {
|
||||
log.warn(
|
||||
"prefetchTimeout is missing from your The Lounge configuration, defaulting to 5000 ms"
|
||||
);
|
||||
}
|
||||
|
||||
promise = new Promise<FetchRequest>((resolve, reject) => {
|
||||
let buffer = Buffer.from("");
|
||||
let contentLength = 0;
|
||||
let contentType: string | undefined;
|
||||
let limit = Config.values.prefetchMaxImageSize * 1024;
|
||||
|
||||
try {
|
||||
const gotStream = got.stream(uri, {
|
||||
retry: 0,
|
||||
timeout: prefetchTimeout || 5000, // milliseconds
|
||||
headers: getRequestHeaders(headers),
|
||||
https: {
|
||||
rejectUnauthorized: false,
|
||||
},
|
||||
});
|
||||
|
||||
gotStream
|
||||
.on("response", function (res) {
|
||||
contentLength = parseInt(res.headers["content-length"], 10) || 0;
|
||||
contentType = res.headers["content-type"];
|
||||
|
||||
if (contentType && imageTypeRegex.test(contentType)) {
|
||||
// response is an image
|
||||
// if Content-Length header reports a size exceeding the prefetch limit, abort fetch
|
||||
// and if file is not to be stored we don't need to download further either
|
||||
if (contentLength > limit || !Config.values.prefetchStorage) {
|
||||
gotStream.destroy();
|
||||
}
|
||||
} else if (contentType && mediaTypeRegex.test(contentType)) {
|
||||
// We don't need to download the file any further after we received content-type header
|
||||
gotStream.destroy();
|
||||
} else {
|
||||
// if not image, limit download to the max search size, since we need only meta tags
|
||||
// twitter.com sends opengraph meta tags within ~20kb of data for individual tweets, the default is set to 50.
|
||||
// for sites like Youtube the og tags are in the first 300K and hence this is configurable by the admin
|
||||
limit =
|
||||
"prefetchMaxSearchSize" in Config.values
|
||||
? Config.values.prefetchMaxSearchSize * 1024
|
||||
: // set to the previous size if config option is unset
|
||||
50 * 1024;
|
||||
}
|
||||
})
|
||||
.on("error", (e) => reject(e))
|
||||
.on("data", (data) => {
|
||||
buffer = Buffer.concat(
|
||||
[buffer, data],
|
||||
buffer.length + (data as Array<any>).length
|
||||
);
|
||||
|
||||
if (buffer.length >= limit) {
|
||||
gotStream.destroy();
|
||||
}
|
||||
})
|
||||
.on("end", () => gotStream.destroy())
|
||||
.on("close", () => {
|
||||
let type = "";
|
||||
|
||||
// If we downloaded more data then specified in Content-Length, use real data size
|
||||
const size = contentLength > buffer.length ? contentLength : buffer.length;
|
||||
|
||||
if (contentType) {
|
||||
type = contentType.split(/ *; */).shift() || "";
|
||||
}
|
||||
|
||||
resolve({data: buffer, type, size});
|
||||
});
|
||||
} catch (e: any) {
|
||||
return reject(e);
|
||||
}
|
||||
});
|
||||
|
||||
const removeCache = () => currentFetchPromises.delete(cacheKey);
|
||||
|
||||
promise.then(removeCache).catch(removeCache);
|
||||
|
||||
currentFetchPromises.set(cacheKey, promise);
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
function normalizeURL(link: string, baseLink?: string, disallowHttp = false) {
|
||||
try {
|
||||
const url = new URL(link, baseLink);
|
||||
|
||||
// Only fetch http and https links
|
||||
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (disallowHttp && url.protocol === "http:") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Do not fetch links without hostname or ones that contain authorization
|
||||
if (!url.hostname || url.username || url.password) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Drop hash from the url, if any
|
||||
url.hash = "";
|
||||
|
||||
return url.toString();
|
||||
} catch (e: any) {
|
||||
// if an exception was thrown, the url is not valid
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
64
server/plugins/irc-events/list.ts
Normal file
64
server/plugins/irc-events/list.ts
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Chan, {ChanType, SpecialChanType} from "../../models/chan";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
const MAX_CHANS = 500;
|
||||
|
||||
irc.on("channel list start", function () {
|
||||
network.chanCache = [];
|
||||
|
||||
updateListStatus({
|
||||
text: "Loading channel list, this can take a moment...",
|
||||
});
|
||||
});
|
||||
|
||||
irc.on("channel list", function (channels) {
|
||||
Array.prototype.push.apply(network.chanCache, channels);
|
||||
|
||||
updateListStatus({
|
||||
text: `Loaded ${network.chanCache.length} channels...`,
|
||||
});
|
||||
});
|
||||
|
||||
irc.on("channel list end", function () {
|
||||
updateListStatus(
|
||||
network.chanCache.sort((a, b) => b.num_users! - a.num_users!).slice(0, MAX_CHANS)
|
||||
);
|
||||
|
||||
network.chanCache = [];
|
||||
});
|
||||
|
||||
function updateListStatus(
|
||||
msg:
|
||||
| {
|
||||
text: string;
|
||||
}
|
||||
| Chan[]
|
||||
) {
|
||||
let chan = network.getChannel("Channel List");
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
chan = client.createChannel({
|
||||
type: ChanType.SPECIAL,
|
||||
special: SpecialChanType.CHANNELLIST,
|
||||
name: "Channel List",
|
||||
data: msg,
|
||||
});
|
||||
|
||||
client.emit("join", {
|
||||
network: network.uuid,
|
||||
chan: chan.getFilteredClone(true),
|
||||
index: network.addChannel(chan),
|
||||
});
|
||||
} else {
|
||||
chan.data = msg;
|
||||
|
||||
client.emit("msg:special", {
|
||||
chan: chan.id,
|
||||
data: msg,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
234
server/plugins/irc-events/message.ts
Normal file
234
server/plugins/irc-events/message.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import Msg, {MessageType} from "../../models/msg";
|
||||
import LinkPrefetch from "./link";
|
||||
import cleanIrcMessage from "../../../client/js/helpers/ircmessageparser/cleanIrcMessage";
|
||||
import Helper from "../../helper";
|
||||
import {IrcEventHandler} from "../../client";
|
||||
import Chan, {ChanType} from "../../models/chan";
|
||||
import User from "../../models/user";
|
||||
|
||||
const nickRegExp = /(?:\x03[0-9]{1,2}(?:,[0-9]{1,2})?)?([\w[\]\\`^{|}-]+)/g;
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
irc.on("action", function (data) {
|
||||
data.type = MessageType.ACTION;
|
||||
handleMessage(data);
|
||||
});
|
||||
|
||||
irc.on("privmsg", function (data) {
|
||||
data.type = MessageType.MESSAGE;
|
||||
handleMessage(data);
|
||||
});
|
||||
|
||||
irc.on("wallops", function (data) {
|
||||
data.from_server = true;
|
||||
data.type = MessageType.WALLOPS;
|
||||
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;
|
||||
}) {
|
||||
let chan: Chan | undefined;
|
||||
let from: User;
|
||||
let highlight = false;
|
||||
let showInActive = false;
|
||||
const self = data.nick === irc.user.nick;
|
||||
|
||||
// Some servers send messages without any nickname
|
||||
if (!data.nick) {
|
||||
data.from_server = true;
|
||||
data.nick = data.hostname || network.host;
|
||||
}
|
||||
|
||||
// Check if the sender is in our ignore list
|
||||
const shouldIgnore =
|
||||
!self &&
|
||||
network.ignoreList.some(function (entry) {
|
||||
return Helper.compareHostmask(entry, data);
|
||||
});
|
||||
|
||||
// Server messages that aren't targeted at a channel go to the server window
|
||||
if (
|
||||
data.from_server &&
|
||||
(!data.target ||
|
||||
!network.getChannel(data.target) ||
|
||||
network.getChannel(data.target)?.type !== ChanType.CHANNEL)
|
||||
) {
|
||||
chan = network.channels[0];
|
||||
from = chan.getUser(data.nick);
|
||||
} else {
|
||||
if (shouldIgnore) {
|
||||
return;
|
||||
}
|
||||
|
||||
let target = data.target;
|
||||
|
||||
// If the message is targeted at us, use sender as target instead
|
||||
if (target.toLowerCase() === irc.user.nick.toLowerCase()) {
|
||||
target = data.nick;
|
||||
}
|
||||
|
||||
chan = network.getChannel(target);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
// Send notices that are not targeted at us into the server window
|
||||
if (data.type === MessageType.NOTICE) {
|
||||
showInActive = true;
|
||||
chan = network.channels[0];
|
||||
} else {
|
||||
chan = client.createChannel({
|
||||
type: ChanType.QUERY,
|
||||
name: target,
|
||||
});
|
||||
|
||||
client.emit("join", {
|
||||
network: network.uuid,
|
||||
chan: chan.getFilteredClone(true),
|
||||
index: network.addChannel(chan),
|
||||
});
|
||||
client.save();
|
||||
chan.loadMessages(client, network);
|
||||
}
|
||||
}
|
||||
|
||||
from = chan.getUser(data.nick);
|
||||
|
||||
// Query messages (unless self or muted) always highlight
|
||||
if (chan.type === ChanType.QUERY) {
|
||||
highlight = !self;
|
||||
} else if (chan.type === ChanType.CHANNEL) {
|
||||
from.lastMessage = data.time || Date.now();
|
||||
}
|
||||
}
|
||||
|
||||
// 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,
|
||||
text: data.message,
|
||||
self: self,
|
||||
from: from,
|
||||
highlight: highlight,
|
||||
users: [],
|
||||
});
|
||||
|
||||
if (showInActive) {
|
||||
msg.showInActive = true;
|
||||
}
|
||||
|
||||
// remove IRC formatting for custom highlight testing
|
||||
const cleanMessage = cleanIrcMessage(data.message);
|
||||
|
||||
// Self messages in channels are never highlighted
|
||||
// Non-self messages are highlighted as soon as the nick is detected
|
||||
if (!msg.highlight && !msg.self) {
|
||||
msg.highlight = network.highlightRegex?.test(data.message);
|
||||
|
||||
// If we still don't have a highlight, test against custom highlights if there's any
|
||||
if (!msg.highlight && client.highlightRegex) {
|
||||
msg.highlight = client.highlightRegex.test(cleanMessage);
|
||||
}
|
||||
}
|
||||
|
||||
// if highlight exceptions match, do not highlight at all
|
||||
if (msg.highlight && client.highlightExceptionRegex) {
|
||||
msg.highlight = !client.highlightExceptionRegex.test(cleanMessage);
|
||||
}
|
||||
|
||||
if (data.group) {
|
||||
msg.statusmsgGroup = data.group;
|
||||
}
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
||||
// No prefetch URLs unless are simple MESSAGE or ACTION types
|
||||
if ([MessageType.MESSAGE, MessageType.ACTION].includes(data.type)) {
|
||||
LinkPrefetch(client, chan, msg, cleanMessage);
|
||||
}
|
||||
|
||||
chan.pushMessage(client, msg, !msg.self);
|
||||
|
||||
// Do not send notifications if the channel is muted or for messages older than 15 minutes (znc buffer for example)
|
||||
if (!chan.muted && msg.highlight && (!data.time || data.time > Date.now() - 900000)) {
|
||||
let title = chan.name;
|
||||
let body = cleanMessage;
|
||||
|
||||
if (msg.type === MessageType.ACTION) {
|
||||
// For actions, do not include colon in the message
|
||||
body = `${data.nick} ${body}`;
|
||||
} else if (chan.type !== ChanType.QUERY) {
|
||||
// In channels, prepend sender nickname to the message
|
||||
body = `${data.nick}: ${body}`;
|
||||
}
|
||||
|
||||
// If a channel is active on any client, highlight won't increment and notification will say (0 mention)
|
||||
if (chan.highlight > 0) {
|
||||
title += ` (${chan.highlight} ${
|
||||
chan.type === ChanType.QUERY ? "new message" : "mention"
|
||||
}${chan.highlight > 1 ? "s" : ""})`;
|
||||
}
|
||||
|
||||
if (chan.highlight > 1) {
|
||||
body += `\n\n… and ${chan.highlight - 1} other message${
|
||||
chan.highlight > 2 ? "s" : ""
|
||||
}`;
|
||||
}
|
||||
|
||||
client.manager.webPush.push(
|
||||
client,
|
||||
{
|
||||
type: "notification",
|
||||
chanId: chan.id,
|
||||
timestamp: data.time || Date.now(),
|
||||
title: title,
|
||||
body: body,
|
||||
},
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
// Keep track of all mentions in channels for this client
|
||||
if (msg.highlight && chan.type === ChanType.CHANNEL) {
|
||||
client.mentions.push({
|
||||
chanId: chan.id,
|
||||
msgId: msg.id,
|
||||
type: msg.type,
|
||||
time: msg.time,
|
||||
text: msg.text,
|
||||
from: msg.from,
|
||||
});
|
||||
|
||||
if (client.mentions.length > 100) {
|
||||
client.mentions.splice(0, client.mentions.length - 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
148
server/plugins/irc-events/mode.ts
Normal file
148
server/plugins/irc-events/mode.ts
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
import _ from "lodash";
|
||||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
// The following saves the channel key based on channel mode instead of
|
||||
// extracting it from `/join #channel key`. This lets us not have to
|
||||
// temporarily store the key until successful join, but also saves the key
|
||||
// if a key is set or changed while being on the channel.
|
||||
irc.on("channel info", function (data) {
|
||||
if (!data.modes) {
|
||||
return;
|
||||
}
|
||||
|
||||
const targetChan = network.getChannel(data.channel);
|
||||
|
||||
if (typeof targetChan === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
data.modes.forEach((mode) => {
|
||||
const text = mode.mode;
|
||||
const add = text[0] === "+";
|
||||
const char = text[1];
|
||||
|
||||
if (char === "k") {
|
||||
targetChan.key = add ? mode.param : "";
|
||||
client.save();
|
||||
}
|
||||
});
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.MODE_CHANNEL,
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
text: `${data.raw_modes} ${data.raw_params.join(" ")}`,
|
||||
});
|
||||
targetChan.pushMessage(client, msg);
|
||||
});
|
||||
|
||||
irc.on("user info", function (data) {
|
||||
const serverChan = network.channels[0];
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.MODE_USER,
|
||||
raw_modes: data.raw_modes,
|
||||
self: false,
|
||||
showInActive: true,
|
||||
});
|
||||
serverChan.pushMessage(client, msg);
|
||||
});
|
||||
|
||||
irc.on("mode", function (data) {
|
||||
let targetChan;
|
||||
|
||||
if (data.target === irc.user.nick) {
|
||||
targetChan = network.channels[0];
|
||||
} else {
|
||||
targetChan = network.getChannel(data.target);
|
||||
|
||||
if (typeof targetChan === "undefined") {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
time: data.time,
|
||||
type: MessageType.MODE,
|
||||
from: targetChan.getUser(data.nick),
|
||||
text: `${data.raw_modes} ${data.raw_params.join(" ")}`,
|
||||
self: data.nick === irc.user.nick,
|
||||
});
|
||||
|
||||
const users: string[] = [];
|
||||
|
||||
for (const param of data.raw_params) {
|
||||
if (targetChan.findUser(param)) {
|
||||
users.push(param);
|
||||
}
|
||||
}
|
||||
|
||||
if (users.length > 0) {
|
||||
msg.users = users;
|
||||
}
|
||||
|
||||
targetChan.pushMessage(client, msg);
|
||||
|
||||
let usersUpdated = false;
|
||||
const userModeSortPriority = {};
|
||||
const supportsMultiPrefix = network.irc.network.cap.isEnabled("multi-prefix");
|
||||
|
||||
irc.network.options.PREFIX.forEach((prefix, index) => {
|
||||
userModeSortPriority[prefix.symbol] = index;
|
||||
});
|
||||
|
||||
data.modes.forEach((mode) => {
|
||||
const add = mode.mode[0] === "+";
|
||||
const char = mode.mode[1];
|
||||
|
||||
if (char === "k") {
|
||||
targetChan.key = add ? mode.param : "";
|
||||
client.save();
|
||||
}
|
||||
|
||||
if (!mode.param) {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = targetChan.findUser(mode.param);
|
||||
|
||||
if (!user) {
|
||||
return;
|
||||
}
|
||||
|
||||
usersUpdated = true;
|
||||
|
||||
if (!supportsMultiPrefix) {
|
||||
return;
|
||||
}
|
||||
|
||||
const changedMode = network.serverOptions.PREFIX.modeToSymbol[char];
|
||||
|
||||
if (!add) {
|
||||
_.pull(user.modes, changedMode);
|
||||
} else if (!user.modes.includes(changedMode)) {
|
||||
user.modes.push(changedMode);
|
||||
user.modes.sort(function (a, b) {
|
||||
return userModeSortPriority[a] - userModeSortPriority[b];
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (!usersUpdated) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!supportsMultiPrefix) {
|
||||
// TODO: This is horrible
|
||||
irc.raw("NAMES", data.target);
|
||||
} else {
|
||||
client.emit("users", {
|
||||
chan: targetChan.id,
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
82
server/plugins/irc-events/modelist.ts
Normal file
82
server/plugins/irc-events/modelist.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
import {SpecialChanType, ChanType} from "../../models/chan";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("banlist", (list) => {
|
||||
const data = list.bans.map((ban) => ({
|
||||
hostmask: ban.banned,
|
||||
banned_by: ban.banned_by,
|
||||
banned_at: ban.banned_at * 1000,
|
||||
}));
|
||||
|
||||
handleList(SpecialChanType.BANLIST, "Ban list", list.channel, data);
|
||||
});
|
||||
|
||||
irc.on("inviteList", (list) => {
|
||||
const data = list.invites.map((invite) => ({
|
||||
hostmask: invite.invited,
|
||||
invited_by: invite.invited_by,
|
||||
invited_at: invite.invited_at * 1000,
|
||||
}));
|
||||
|
||||
handleList(SpecialChanType.INVITELIST, "Invite list", list.channel, data);
|
||||
});
|
||||
|
||||
function handleList(
|
||||
type: SpecialChanType,
|
||||
name: string,
|
||||
channel: string,
|
||||
data: {
|
||||
hostmask: string;
|
||||
invited_by?: string;
|
||||
inivted_at?: number;
|
||||
}[]
|
||||
) {
|
||||
if (data.length === 0) {
|
||||
const msg = new Msg({
|
||||
time: new Date(),
|
||||
type: MessageType.ERROR,
|
||||
text: `${name} is empty`,
|
||||
});
|
||||
let chan = network.getChannel(channel);
|
||||
|
||||
// Send error to lobby if we receive empty list for a channel we're not in
|
||||
if (typeof chan === "undefined") {
|
||||
msg.showInActive = true;
|
||||
chan = network.channels[0];
|
||||
}
|
||||
|
||||
chan.pushMessage(client, msg, true);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const chanName = `${name} for ${channel}`;
|
||||
let chan = network.getChannel(chanName);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
chan = client.createChannel({
|
||||
type: ChanType.SPECIAL,
|
||||
special: type,
|
||||
name: chanName,
|
||||
data: data,
|
||||
});
|
||||
client.emit("join", {
|
||||
network: network.uuid,
|
||||
chan: chan.getFilteredClone(true),
|
||||
index: network.addChannel(chan),
|
||||
});
|
||||
} else {
|
||||
chan.data = data;
|
||||
|
||||
client.emit("msg:special", {
|
||||
chan: chan.id,
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
29
server/plugins/irc-events/motd.ts
Normal file
29
server/plugins/irc-events/motd.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("motd", function (data) {
|
||||
const lobby = network.channels[0];
|
||||
|
||||
if (data.motd) {
|
||||
const msg = new Msg({
|
||||
type: MessageType.MONOSPACE_BLOCK,
|
||||
command: "motd",
|
||||
text: data.motd,
|
||||
});
|
||||
lobby.pushMessage(client, msg);
|
||||
}
|
||||
|
||||
if (data.error) {
|
||||
const msg = new Msg({
|
||||
type: MessageType.MONOSPACE_BLOCK,
|
||||
command: "motd",
|
||||
text: data.error,
|
||||
});
|
||||
lobby.pushMessage(client, msg);
|
||||
}
|
||||
});
|
||||
};
|
||||
28
server/plugins/irc-events/names.ts
Normal file
28
server/plugins/irc-events/names.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("userlist", function (data) {
|
||||
const chan = network.getChannel(data.channel);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const newUsers = new Map();
|
||||
|
||||
data.users.forEach((user) => {
|
||||
const newUser = chan.getUser(user.nick);
|
||||
newUser.setModes(user.modes, network.serverOptions.PREFIX);
|
||||
|
||||
newUsers.set(user.nick.toLowerCase(), newUser);
|
||||
});
|
||||
|
||||
chan.users = newUsers;
|
||||
|
||||
client.emit("users", {
|
||||
chan: chan.id,
|
||||
});
|
||||
});
|
||||
};
|
||||
52
server/plugins/irc-events/nick.ts
Normal file
52
server/plugins/irc-events/nick.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("nick", function (data) {
|
||||
const self = data.nick === irc.user.nick;
|
||||
|
||||
if (self) {
|
||||
network.setNick(data.new_nick);
|
||||
|
||||
const lobby = network.channels[0];
|
||||
const msg = new Msg({
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
text: `You're now known as ${data.new_nick}`,
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
|
||||
client.save();
|
||||
client.emit("nick", {
|
||||
network: network.uuid,
|
||||
nick: data.new_nick,
|
||||
});
|
||||
}
|
||||
|
||||
network.channels.forEach((chan) => {
|
||||
const user = chan.findUser(data.nick);
|
||||
|
||||
if (typeof user === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
time: data.time,
|
||||
from: user,
|
||||
type: MessageType.NICK,
|
||||
new_nick: data.new_nick,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
|
||||
chan.removeUser(user);
|
||||
user.nick = data.new_nick;
|
||||
chan.setUser(user);
|
||||
|
||||
client.emit("users", {
|
||||
chan: chan.id,
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
36
server/plugins/irc-events/part.ts
Normal file
36
server/plugins/irc-events/part.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("part", function (data) {
|
||||
if (!data.channel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chan = network.getChannel(data.channel);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const user = chan.getUser(data.nick);
|
||||
const msg = new Msg({
|
||||
type: MessageType.PART,
|
||||
time: data.time,
|
||||
text: data.message || "",
|
||||
hostmask: data.ident + "@" + data.hostname,
|
||||
from: user,
|
||||
self: data.nick === irc.user.nick,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
|
||||
if (data.nick === irc.user.nick) {
|
||||
client.part(network, chan);
|
||||
} else {
|
||||
chan.removeUser(user);
|
||||
}
|
||||
});
|
||||
};
|
||||
34
server/plugins/irc-events/quit.ts
Normal file
34
server/plugins/irc-events/quit.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("quit", function (data) {
|
||||
network.channels.forEach((chan) => {
|
||||
const user = chan.findUser(data.nick);
|
||||
|
||||
if (typeof user === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
time: data.time,
|
||||
type: MessageType.QUIT,
|
||||
text: data.message || "",
|
||||
hostmask: data.ident + "@" + data.hostname,
|
||||
from: user,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
|
||||
chan.removeUser(user);
|
||||
});
|
||||
|
||||
// If user with the nick we are trying to keep has quit, try to get this nick
|
||||
if (network.keepNick === data.nick) {
|
||||
irc.changeNick(network.keepNick);
|
||||
network.keepNick = null;
|
||||
}
|
||||
});
|
||||
};
|
||||
28
server/plugins/irc-events/sasl.ts
Normal file
28
server/plugins/irc-events/sasl.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("loggedin", (data) => {
|
||||
const lobby = network.channels[0];
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.LOGIN,
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
text: "Logged in as: " + data.account,
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
});
|
||||
|
||||
irc.on("loggedout", () => {
|
||||
const lobby = network.channels[0];
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.LOGOUT,
|
||||
text: "Logged out",
|
||||
});
|
||||
lobby.pushMessage(client, msg, true);
|
||||
});
|
||||
};
|
||||
46
server/plugins/irc-events/topic.ts
Normal file
46
server/plugins/irc-events/topic.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("topic", function (data) {
|
||||
const chan = network.getChannel(data.channel);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
time: data.time,
|
||||
type: MessageType.TOPIC,
|
||||
from: data.nick && chan.getUser(data.nick),
|
||||
text: data.topic,
|
||||
self: data.nick === irc.user.nick,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
|
||||
chan.topic = data.topic;
|
||||
client.emit("topic", {
|
||||
chan: chan.id,
|
||||
topic: chan.topic,
|
||||
});
|
||||
});
|
||||
|
||||
irc.on("topicsetby", function (data) {
|
||||
const chan = network.getChannel(data.channel);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
const msg = new Msg({
|
||||
type: MessageType.TOPIC_SET_BY,
|
||||
from: chan.getUser(data.nick),
|
||||
when: new Date(data.when * 1000),
|
||||
self: data.nick === irc.user.nick,
|
||||
});
|
||||
chan.pushMessage(client, msg);
|
||||
});
|
||||
};
|
||||
37
server/plugins/irc-events/unhandled.ts
Normal file
37
server/plugins/irc-events/unhandled.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("unknown command", function (command) {
|
||||
let target = network.channels[0];
|
||||
|
||||
// Do not display users own name
|
||||
if (command.params.length > 0 && command.params[0] === network.irc.user.nick) {
|
||||
command.params.shift();
|
||||
}
|
||||
|
||||
// Check the length again because we may shift the nick above
|
||||
if (command.params.length > 0) {
|
||||
// If this numeric starts with a channel name that exists
|
||||
// put this message in that channel
|
||||
const channel = network.getChannel(command.params[0]);
|
||||
|
||||
if (typeof channel !== "undefined") {
|
||||
target = channel;
|
||||
}
|
||||
}
|
||||
|
||||
target.pushMessage(
|
||||
client,
|
||||
new Msg({
|
||||
type: MessageType.UNHANDLED,
|
||||
command: command.command,
|
||||
params: command.params,
|
||||
}),
|
||||
true
|
||||
);
|
||||
});
|
||||
};
|
||||
23
server/plugins/irc-events/welcome.ts
Normal file
23
server/plugins/irc-events/welcome.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
|
||||
import Msg from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("registered", function (data) {
|
||||
network.setNick(data.nick);
|
||||
|
||||
const lobby = network.channels[0];
|
||||
const msg = new Msg({
|
||||
text: "You're now known as " + data.nick,
|
||||
});
|
||||
lobby.pushMessage(client, msg);
|
||||
|
||||
client.save();
|
||||
client.emit("nick", {
|
||||
network: network.uuid,
|
||||
nick: data.nick,
|
||||
});
|
||||
});
|
||||
};
|
||||
62
server/plugins/irc-events/whois.ts
Normal file
62
server/plugins/irc-events/whois.ts
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import {IrcEventHandler} from "../../client";
|
||||
import {ChanType} from "../../models/chan";
|
||||
|
||||
import Msg, {MessageType} from "../../models/msg";
|
||||
|
||||
export default <IrcEventHandler>function (irc, network) {
|
||||
const client = this;
|
||||
|
||||
irc.on("whois", handleWhois);
|
||||
|
||||
irc.on("whowas", (data) => {
|
||||
data.whowas = true;
|
||||
|
||||
handleWhois(data);
|
||||
});
|
||||
|
||||
function handleWhois(data) {
|
||||
let chan = network.getChannel(data.nick);
|
||||
|
||||
if (typeof chan === "undefined") {
|
||||
// Do not create new windows for errors as they may contain illegal characters
|
||||
if (data.error) {
|
||||
chan = network.channels[0];
|
||||
} else {
|
||||
chan = client.createChannel({
|
||||
type: ChanType.QUERY,
|
||||
name: data.nick,
|
||||
});
|
||||
|
||||
client.emit("join", {
|
||||
shouldOpen: true,
|
||||
network: network.uuid,
|
||||
chan: chan.getFilteredClone(true),
|
||||
index: network.addChannel(chan),
|
||||
});
|
||||
chan.loadMessages(client, network);
|
||||
client.save();
|
||||
}
|
||||
}
|
||||
|
||||
let msg;
|
||||
|
||||
if (data.error) {
|
||||
msg = new Msg({
|
||||
type: MessageType.ERROR,
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-plus-operands
|
||||
text: "No such nick: " + data.nick,
|
||||
});
|
||||
} else {
|
||||
// Absolute datetime in milliseconds since nick is idle
|
||||
data.idleTime = Date.now() - data.idle * 1000;
|
||||
// Absolute datetime in milliseconds when nick logged on.
|
||||
data.logonTime = data.logon * 1000;
|
||||
msg = new Msg({
|
||||
type: MessageType.WHOIS,
|
||||
whois: data,
|
||||
});
|
||||
}
|
||||
|
||||
chan.pushMessage(client, msg);
|
||||
}
|
||||
};
|
||||
287
server/plugins/messageStorage/sqlite.ts
Normal file
287
server/plugins/messageStorage/sqlite.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
import type {Database} from "sqlite3";
|
||||
|
||||
import log from "../../log";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import Config from "../../config";
|
||||
import Msg, {Message} from "../../models/msg";
|
||||
import Client from "../../client";
|
||||
import Chan, {Channel} from "../../models/chan";
|
||||
import type {
|
||||
SearchResponse,
|
||||
SearchQuery,
|
||||
SqliteMessageStorage as ISqliteMessageStorage,
|
||||
} from "./types";
|
||||
import Network from "../../models/network";
|
||||
|
||||
// TODO; type
|
||||
let sqlite3: any;
|
||||
|
||||
try {
|
||||
sqlite3 = require("sqlite3");
|
||||
} catch (e: any) {
|
||||
Config.values.messageStorage = Config.values.messageStorage.filter((item) => item !== "sqlite");
|
||||
|
||||
log.error(
|
||||
"Unable to load sqlite3 module. See https://github.com/mapbox/node-sqlite3/wiki/Binaries"
|
||||
);
|
||||
}
|
||||
|
||||
const currentSchemaVersion = 1520239200;
|
||||
|
||||
const schema = [
|
||||
// Schema version #1
|
||||
"CREATE TABLE IF NOT EXISTS options (name TEXT, value TEXT, CONSTRAINT name_unique UNIQUE (name))",
|
||||
"CREATE TABLE IF NOT EXISTS messages (network TEXT, channel TEXT, time INTEGER, type TEXT, msg TEXT)",
|
||||
"CREATE INDEX IF NOT EXISTS network_channel ON messages (network, channel)",
|
||||
"CREATE INDEX IF NOT EXISTS time ON messages (time)",
|
||||
];
|
||||
|
||||
class SqliteMessageStorage implements ISqliteMessageStorage {
|
||||
client: Client;
|
||||
isEnabled: boolean;
|
||||
database!: Database;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
enable() {
|
||||
const logsPath = Config.getUserLogsPath();
|
||||
const sqlitePath = path.join(logsPath, `${this.client.name}.sqlite3`);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(logsPath, {recursive: true});
|
||||
} catch (e: any) {
|
||||
log.error("Unable to create logs directory", String(e));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEnabled = true;
|
||||
|
||||
this.database = new sqlite3.Database(sqlitePath);
|
||||
this.database.serialize(() => {
|
||||
schema.forEach((line) => this.database.run(line));
|
||||
|
||||
this.database.get(
|
||||
"SELECT value FROM options WHERE name = 'schema_version'",
|
||||
(err, row) => {
|
||||
if (err) {
|
||||
return log.error(`Failed to retrieve schema version: ${err.toString()}`);
|
||||
}
|
||||
|
||||
// New table
|
||||
if (row === undefined) {
|
||||
this.database.serialize(() =>
|
||||
this.database.run(
|
||||
"INSERT INTO options (name, value) VALUES ('schema_version', ?)",
|
||||
currentSchemaVersion
|
||||
)
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const storedSchemaVersion = parseInt(row.value, 10);
|
||||
|
||||
if (storedSchemaVersion === currentSchemaVersion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (storedSchemaVersion > currentSchemaVersion) {
|
||||
return log.error(
|
||||
`sqlite messages schema version is higher than expected (${storedSchemaVersion} > ${currentSchemaVersion}). Is The Lounge out of date?`
|
||||
);
|
||||
}
|
||||
|
||||
log.info(
|
||||
`sqlite messages schema version is out of date (${storedSchemaVersion} < ${currentSchemaVersion}). Running migrations if any.`
|
||||
);
|
||||
|
||||
this.database.serialize(() =>
|
||||
this.database.run(
|
||||
"UPDATE options SET value = ? WHERE name = 'schema_version'",
|
||||
currentSchemaVersion
|
||||
)
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
close(callback?: (error?: Error | null) => void) {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.isEnabled = false;
|
||||
|
||||
this.database.close((err) => {
|
||||
if (err) {
|
||||
log.error(`Failed to close sqlite database: ${err.message}`);
|
||||
}
|
||||
|
||||
if (callback) {
|
||||
callback(err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
index(network: Network, channel: Chan, msg: Msg) {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const clonedMsg = Object.keys(msg).reduce((newMsg, prop) => {
|
||||
// id is regenerated when messages are retrieved
|
||||
// previews are not stored because storage is cleared on lounge restart
|
||||
// type and time are stored in a separate column
|
||||
if (prop !== "id" && prop !== "previews" && prop !== "type" && prop !== "time") {
|
||||
newMsg[prop] = msg[prop];
|
||||
}
|
||||
|
||||
return newMsg;
|
||||
}, {});
|
||||
|
||||
this.database.serialize(() =>
|
||||
this.database.run(
|
||||
"INSERT INTO messages(network, channel, time, type, msg) VALUES(?, ?, ?, ?, ?)",
|
||||
network.uuid,
|
||||
channel.name.toLowerCase(),
|
||||
msg.time.getTime(),
|
||||
msg.type,
|
||||
JSON.stringify(clonedMsg)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
deleteChannel(network: Network, channel: Channel) {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.database.serialize(() =>
|
||||
this.database.run(
|
||||
"DELETE FROM messages WHERE network = ? AND channel = ?",
|
||||
network.uuid,
|
||||
channel.name.toLowerCase()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load messages for given channel on a given network and resolve a promise with loaded messages.
|
||||
*
|
||||
* @param Network network - Network object where the channel is
|
||||
* @param Chan channel - Channel object for which to load messages for
|
||||
*/
|
||||
getMessages(network: Network, channel: Channel) {
|
||||
if (!this.isEnabled || Config.values.maxHistory === 0) {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
// If unlimited history is specified, load 100k messages
|
||||
const limit = Config.values.maxHistory < 0 ? 100000 : Config.values.maxHistory;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.database.serialize(() =>
|
||||
this.database.all(
|
||||
"SELECT msg, type, time FROM messages WHERE network = ? AND channel = ? ORDER BY time DESC LIMIT ?",
|
||||
[network.uuid, channel.name.toLowerCase(), limit],
|
||||
(err, rows) => {
|
||||
if (err) {
|
||||
return reject(err);
|
||||
}
|
||||
|
||||
resolve(
|
||||
rows.reverse().map((row) => {
|
||||
const msg = JSON.parse(row.msg);
|
||||
msg.time = row.time;
|
||||
msg.type = row.type;
|
||||
|
||||
const newMsg = new Msg(msg);
|
||||
newMsg.id = this.client.idMsg++;
|
||||
|
||||
return newMsg;
|
||||
})
|
||||
);
|
||||
}
|
||||
)
|
||||
);
|
||||
}) as Promise<Message[]>;
|
||||
}
|
||||
|
||||
search(query: SearchQuery): Promise<SearchResponse | []> {
|
||||
if (!this.isEnabled) {
|
||||
// this should never be hit as messageProvider is checked in client.search()
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
// Using the '@' character to escape '%' and '_' in patterns.
|
||||
const escapedSearchTerm = query.searchTerm.replace(/([%_@])/g, "@$1");
|
||||
|
||||
let select =
|
||||
'SELECT msg, type, time, network, channel FROM messages WHERE type = "message" AND json_extract(msg, "$.text") LIKE ? ESCAPE \'@\'';
|
||||
const params = [`%${escapedSearchTerm}%`];
|
||||
|
||||
if (query.networkUuid) {
|
||||
select += " AND network = ? ";
|
||||
params.push(query.networkUuid);
|
||||
}
|
||||
|
||||
if (query.channelName) {
|
||||
select += " AND channel = ? ";
|
||||
params.push(query.channelName.toLowerCase());
|
||||
}
|
||||
|
||||
const maxResults = 100;
|
||||
|
||||
select += " ORDER BY time DESC LIMIT ? OFFSET ? ";
|
||||
params.push(maxResults.toString());
|
||||
query.offset = parseInt(query.offset as string, 10) || 0;
|
||||
params.push(String(query.offset));
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.database.all(select, params, (err, rows) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
const response: SearchResponse = {
|
||||
searchTerm: query.searchTerm,
|
||||
target: query.channelName,
|
||||
networkUuid: query.networkUuid,
|
||||
offset: query.offset as number,
|
||||
results: parseSearchRowsToMessages(query.offset as number, rows).reverse(),
|
||||
};
|
||||
resolve(response);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
canProvideMessages() {
|
||||
return this.isEnabled;
|
||||
}
|
||||
}
|
||||
|
||||
export default SqliteMessageStorage;
|
||||
|
||||
// TODO: type any
|
||||
function parseSearchRowsToMessages(id: number, rows: any[]) {
|
||||
const messages: Msg[] = [];
|
||||
|
||||
for (const row of rows) {
|
||||
const msg = JSON.parse(row.msg);
|
||||
msg.time = row.time;
|
||||
msg.type = row.type;
|
||||
msg.networkUuid = row.network;
|
||||
msg.channelName = row.channel;
|
||||
msg.id = id;
|
||||
messages.push(new Msg(msg));
|
||||
id += 1;
|
||||
}
|
||||
|
||||
return messages;
|
||||
}
|
||||
171
server/plugins/messageStorage/text.ts
Normal file
171
server/plugins/messageStorage/text.ts
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
/* eslint-disable @typescript-eslint/restrict-template-expressions */
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import filenamify from "filenamify";
|
||||
|
||||
import log from "../../log";
|
||||
import Config from "../../config";
|
||||
import {MessageStorage} from "./types";
|
||||
import Client from "../../client";
|
||||
import Channel from "../../models/chan";
|
||||
import {Message, MessageType} from "../../models/msg";
|
||||
import Network from "../../models/network";
|
||||
|
||||
class TextFileMessageStorage implements MessageStorage {
|
||||
client: Client;
|
||||
isEnabled: boolean;
|
||||
|
||||
constructor(client: Client) {
|
||||
this.client = client;
|
||||
this.isEnabled = false;
|
||||
}
|
||||
|
||||
enable() {
|
||||
this.isEnabled = true;
|
||||
}
|
||||
|
||||
close(callback: () => void) {
|
||||
this.isEnabled = false;
|
||||
|
||||
if (callback) {
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
index(network: Network, channel: Channel, msg: Message) {
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logPath = path.join(
|
||||
Config.getUserLogsPath(),
|
||||
this.client.name,
|
||||
TextFileMessageStorage.getNetworkFolderName(network)
|
||||
);
|
||||
|
||||
try {
|
||||
fs.mkdirSync(logPath, {recursive: true});
|
||||
} catch (e: any) {
|
||||
log.error("Unable to create logs directory", String(e));
|
||||
return;
|
||||
}
|
||||
|
||||
let line = `[${msg.time.toISOString()}] `;
|
||||
|
||||
// message types from src/models/msg.js
|
||||
switch (msg.type) {
|
||||
case MessageType.ACTION:
|
||||
// [2014-01-01 00:00:00] * @Arnold is eating cookies
|
||||
line += `* ${msg.from.mode}${msg.from.nick} ${msg.text}`;
|
||||
break;
|
||||
case MessageType.JOIN:
|
||||
// [2014-01-01 00:00:00] *** Arnold (~arnold@foo.bar) joined
|
||||
line += `*** ${msg.from.nick} (${msg.hostmask}) joined`;
|
||||
break;
|
||||
case MessageType.KICK:
|
||||
// [2014-01-01 00:00:00] *** Arnold was kicked by Bernie (Don't steal my cookies!)
|
||||
line += `*** ${msg.target.nick} was kicked by ${msg.from.nick} (${msg.text})`;
|
||||
break;
|
||||
case MessageType.MESSAGE:
|
||||
// [2014-01-01 00:00:00] <@Arnold> Put that cookie down.. Now!!
|
||||
line += `<${msg.from.mode}${msg.from.nick}> ${msg.text}`;
|
||||
break;
|
||||
case MessageType.MODE:
|
||||
// [2014-01-01 00:00:00] *** Arnold set mode +o Bernie
|
||||
line += `*** ${msg.from.nick} set mode ${msg.text}`;
|
||||
break;
|
||||
case MessageType.NICK:
|
||||
// [2014-01-01 00:00:00] *** Arnold changed nick to Bernie
|
||||
line += `*** ${msg.from.nick} changed nick to ${msg.new_nick}`;
|
||||
break;
|
||||
case MessageType.NOTICE:
|
||||
// [2014-01-01 00:00:00] -Arnold- pssst, I have cookies!
|
||||
line += `-${msg.from.nick}- ${msg.text}`;
|
||||
break;
|
||||
case MessageType.PART:
|
||||
// [2014-01-01 00:00:00] *** Arnold (~arnold@foo.bar) left (Bye all!)
|
||||
line += `*** ${msg.from.nick} (${msg.hostmask}) left (${msg.text})`;
|
||||
break;
|
||||
case MessageType.QUIT:
|
||||
// [2014-01-01 00:00:00] *** Arnold (~arnold@foo.bar) quit (Connection reset by peer)
|
||||
line += `*** ${msg.from.nick} (${msg.hostmask}) quit (${msg.text})`;
|
||||
break;
|
||||
case MessageType.CHGHOST:
|
||||
// [2014-01-01 00:00:00] *** Arnold changed host to: new@fancy.host
|
||||
line += `*** ${msg.from.nick} changed host to '${msg.new_ident}@${msg.new_host}'`;
|
||||
break;
|
||||
case MessageType.TOPIC:
|
||||
// [2014-01-01 00:00:00] *** Arnold changed topic to: welcome everyone!
|
||||
line += `*** ${msg.from.nick} changed topic to '${msg.text}'`;
|
||||
break;
|
||||
|
||||
default:
|
||||
// unhandled events will not be logged
|
||||
return;
|
||||
}
|
||||
|
||||
line += "\n";
|
||||
|
||||
fs.appendFile(
|
||||
path.join(logPath, TextFileMessageStorage.getChannelFileName(channel)),
|
||||
line,
|
||||
(e) => {
|
||||
if (e) {
|
||||
log.error("Failed to write user log", e.message);
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
deleteChannel() {
|
||||
/* TODO: Truncating text logs is disabled, until we figure out some UI for it
|
||||
if (!this.isEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logPath = path.join(
|
||||
Config.getUserLogsPath(),
|
||||
this.client.name,
|
||||
TextFileMessageStorage.getNetworkFolderName(network),
|
||||
TextFileMessageStorage.getChannelFileName(channel)
|
||||
);
|
||||
|
||||
fs.truncate(logPath, 0, (e) => {
|
||||
if (e) {
|
||||
log.error("Failed to truncate user log", e);
|
||||
}
|
||||
});*/
|
||||
}
|
||||
|
||||
getMessages() {
|
||||
// Not implemented for text log files
|
||||
// They do not contain enough data to fully re-create message objects
|
||||
// Use sqlite storage instead
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
canProvideMessages() {
|
||||
return false;
|
||||
}
|
||||
|
||||
static getNetworkFolderName(network: Network) {
|
||||
// Limit network name in the folder name to 23 characters
|
||||
// So we can still fit 12 characters of the uuid for de-duplication
|
||||
const networkName = cleanFilename(network.name.substring(0, 23).replace(/ /g, "-"));
|
||||
|
||||
return `${networkName}-${network.uuid.substring(networkName.length + 1)}`;
|
||||
}
|
||||
|
||||
static getChannelFileName(channel: Channel) {
|
||||
return `${cleanFilename(channel.name)}.log`;
|
||||
}
|
||||
}
|
||||
|
||||
export default TextFileMessageStorage;
|
||||
|
||||
function cleanFilename(name: string) {
|
||||
name = filenamify(name, {replacement: "_"});
|
||||
name = name.toLowerCase();
|
||||
|
||||
return name;
|
||||
}
|
||||
45
server/plugins/messageStorage/types.d.ts
vendored
Normal file
45
server/plugins/messageStorage/types.d.ts
vendored
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import type {Database} from "sqlite3";
|
||||
|
||||
import {Channel} from "../../models/channel";
|
||||
import {Message} from "../../models/message";
|
||||
import {Network} from "../../models/network";
|
||||
import Client from "../../client";
|
||||
|
||||
interface MessageStorage {
|
||||
client: Client;
|
||||
isEnabled: boolean;
|
||||
|
||||
enable(): void;
|
||||
|
||||
close(callback?: () => void): void;
|
||||
|
||||
index(network: Network, channel: Channel, msg: Message): void;
|
||||
|
||||
deleteChannel(network: Network, channel: Channel);
|
||||
|
||||
getMessages(network: Network, channel: Channel): Promise<Message[]>;
|
||||
|
||||
canProvideMessages(): boolean;
|
||||
}
|
||||
|
||||
export type SearchQuery = {
|
||||
searchTerm: string;
|
||||
networkUuid: string;
|
||||
channelName: string;
|
||||
offset: number | string;
|
||||
};
|
||||
|
||||
export type SearchResponse =
|
||||
| (Omit<SearchQuery, "channelName" | "offset"> & {
|
||||
results: Message[];
|
||||
target: string;
|
||||
offset: number;
|
||||
})
|
||||
| [];
|
||||
|
||||
type SearchFunction = (query: SearchQuery) => Promise<SearchResponse>;
|
||||
|
||||
export interface SqliteMessageStorage extends MessageStorage {
|
||||
database: Database;
|
||||
search: SearchFunction | [];
|
||||
}
|
||||
271
server/plugins/packages/index.ts
Normal file
271
server/plugins/packages/index.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import _ from "lodash";
|
||||
import log from "../../log";
|
||||
import colors from "chalk";
|
||||
import path from "path";
|
||||
import semver from "semver";
|
||||
import Helper from "../../helper";
|
||||
import Config from "../../config";
|
||||
import themes from "./themes";
|
||||
import inputs from "../inputs";
|
||||
import fs from "fs";
|
||||
import Utils from "../../command-line/utils";
|
||||
import Client from "../../client";
|
||||
|
||||
type Package = {
|
||||
onServerStart: (packageApis: any) => void;
|
||||
};
|
||||
|
||||
const packageMap = new Map<string, Package>();
|
||||
|
||||
export type PackageInfo = {
|
||||
packageName: string;
|
||||
thelounge?: {supports: string};
|
||||
version: string;
|
||||
type?: string;
|
||||
files?: string[];
|
||||
// Legacy support
|
||||
name?: string;
|
||||
};
|
||||
|
||||
const stylesheets: string[] = [];
|
||||
const files: string[] = [];
|
||||
|
||||
const TIME_TO_LIVE = 15 * 60 * 1000; // 15 minutes, in milliseconds
|
||||
|
||||
const cache = {
|
||||
outdated: undefined,
|
||||
};
|
||||
|
||||
let experimentalWarningPrinted = false;
|
||||
|
||||
export default {
|
||||
getFiles,
|
||||
getStylesheets,
|
||||
getPackage,
|
||||
loadPackages,
|
||||
outdated,
|
||||
};
|
||||
|
||||
// TODO: verify binds worked. Used to be 'this' instead of 'packageApis'
|
||||
const packageApis = function (packageInfo: PackageInfo) {
|
||||
return {
|
||||
Stylesheets: {
|
||||
addFile: addStylesheet.bind(packageApis, packageInfo.packageName),
|
||||
},
|
||||
PublicFiles: {
|
||||
add: addFile.bind(packageApis, packageInfo.packageName),
|
||||
},
|
||||
Commands: {
|
||||
add: inputs.addPluginCommand.bind(packageApis, packageInfo),
|
||||
runAsUser: (command: string, targetId: number, client: Client) =>
|
||||
client.inputLine({target: targetId, text: command}),
|
||||
},
|
||||
Config: {
|
||||
getConfig: () => Config.values,
|
||||
getPersistentStorageDir: getPersistentStorageDir.bind(
|
||||
packageApis,
|
||||
packageInfo.packageName
|
||||
),
|
||||
},
|
||||
Logger: {
|
||||
error: (...args: string[]) => log.error(`[${packageInfo.packageName}]`, ...args),
|
||||
warn: (...args: string[]) => log.warn(`[${packageInfo.packageName}]`, ...args),
|
||||
info: (...args: string[]) => log.info(`[${packageInfo.packageName}]`, ...args),
|
||||
debug: (...args: string[]) => log.debug(`[${packageInfo.packageName}]`, ...args),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
function addStylesheet(packageName: string, filename: string) {
|
||||
stylesheets.push(packageName + "/" + filename);
|
||||
}
|
||||
|
||||
function getStylesheets() {
|
||||
return stylesheets;
|
||||
}
|
||||
|
||||
function addFile(packageName: string, filename: string) {
|
||||
files.push(packageName + "/" + filename);
|
||||
}
|
||||
|
||||
function getFiles() {
|
||||
return files.concat(stylesheets);
|
||||
}
|
||||
|
||||
function getPackage(name: string) {
|
||||
return packageMap.get(name);
|
||||
}
|
||||
|
||||
function getEnabledPackages(packageJson: string) {
|
||||
try {
|
||||
const json = JSON.parse(fs.readFileSync(packageJson, "utf-8"));
|
||||
return Object.keys(json.dependencies);
|
||||
} catch (e: any) {
|
||||
log.error(`Failed to read packages/package.json: ${colors.red(e)}`);
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
function getPersistentStorageDir(packageName: string) {
|
||||
const dir = path.join(Config.getPackagesPath(), packageName);
|
||||
fs.mkdirSync(dir, {recursive: true}); // we don't care if it already exists or not
|
||||
return dir;
|
||||
}
|
||||
|
||||
function loadPackage(packageName: string) {
|
||||
let packageInfo: PackageInfo;
|
||||
// TODO: type
|
||||
let packageFile: Package;
|
||||
|
||||
try {
|
||||
const packagePath = Config.getPackageModulePath(packageName);
|
||||
|
||||
packageInfo = JSON.parse(fs.readFileSync(path.join(packagePath, "package.json"), "utf-8"));
|
||||
|
||||
if (!packageInfo.thelounge) {
|
||||
throw "'thelounge' is not present in package.json";
|
||||
}
|
||||
|
||||
if (
|
||||
packageInfo.thelounge.supports &&
|
||||
!semver.satisfies(Helper.getVersionNumber(), packageInfo.thelounge.supports, {
|
||||
includePrerelease: true, // our pre-releases should respect the semver guarantees
|
||||
})
|
||||
) {
|
||||
throw `v${packageInfo.version} does not support this version of The Lounge. Supports: ${packageInfo.thelounge.supports}`;
|
||||
}
|
||||
|
||||
packageFile = require(packagePath);
|
||||
} catch (e: any) {
|
||||
log.error(`Package ${colors.bold(packageName)} could not be loaded: ${colors.red(e)}`);
|
||||
|
||||
if (e instanceof Error) {
|
||||
log.debug(e.stack ? e.stack : e.message);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const version = packageInfo.version;
|
||||
packageInfo = {
|
||||
...packageInfo.thelounge,
|
||||
packageName: packageName,
|
||||
version,
|
||||
};
|
||||
|
||||
packageMap.set(packageName, packageFile);
|
||||
|
||||
if (packageInfo.type === "theme") {
|
||||
// @ts-expect-error Argument of type 'PackageInfo' is not assignable to parameter of type 'ThemeModule'.
|
||||
themes.addTheme(packageName, packageInfo);
|
||||
|
||||
if (packageInfo.files) {
|
||||
packageInfo.files.forEach((file) => addFile(packageName, file));
|
||||
}
|
||||
}
|
||||
|
||||
if (packageFile.onServerStart) {
|
||||
packageFile.onServerStart(packageApis(packageInfo));
|
||||
}
|
||||
|
||||
log.info(`Package ${colors.bold(packageName)} ${colors.green("v" + version)} loaded`);
|
||||
|
||||
if (packageInfo.type !== "theme" && !experimentalWarningPrinted) {
|
||||
experimentalWarningPrinted = true;
|
||||
|
||||
log.info(
|
||||
"There are packages using the experimental plugin API. " +
|
||||
"Be aware that this API is not yet stable and may change in future The Lounge releases."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function loadPackages() {
|
||||
const packageJson = path.join(Config.getPackagesPath(), "package.json");
|
||||
const packages = getEnabledPackages(packageJson);
|
||||
|
||||
packages.forEach(loadPackage);
|
||||
|
||||
watchPackages(packageJson);
|
||||
}
|
||||
|
||||
function watchPackages(packageJson: string) {
|
||||
fs.watch(
|
||||
packageJson,
|
||||
{
|
||||
persistent: false,
|
||||
},
|
||||
_.debounce(
|
||||
() => {
|
||||
const updated = getEnabledPackages(packageJson);
|
||||
|
||||
for (const packageName of updated) {
|
||||
if (packageMap.has(packageName)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
loadPackage(packageName);
|
||||
}
|
||||
},
|
||||
1000,
|
||||
{maxWait: 10000}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function outdated(cacheTimeout = TIME_TO_LIVE) {
|
||||
if (cache.outdated !== undefined) {
|
||||
return cache.outdated;
|
||||
}
|
||||
|
||||
// Get paths to the location of packages directory
|
||||
const packagesPath = Config.getPackagesPath();
|
||||
const packagesConfig = path.join(packagesPath, "package.json");
|
||||
const packagesList = JSON.parse(fs.readFileSync(packagesConfig, "utf-8")).dependencies;
|
||||
const argsList = [
|
||||
"outdated",
|
||||
"--latest",
|
||||
"--json",
|
||||
"--production",
|
||||
"--ignore-scripts",
|
||||
"--non-interactive",
|
||||
"--cwd",
|
||||
packagesPath,
|
||||
];
|
||||
|
||||
// Check if the configuration file exists
|
||||
if (!Object.entries(packagesList).length) {
|
||||
// CLI calls outdated with zero TTL, so we can print the warning there
|
||||
if (!cacheTimeout) {
|
||||
log.warn("There are no packages installed.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
const command = argsList.shift();
|
||||
const params = argsList;
|
||||
|
||||
if (!command) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If we get an error from calling outdated and the code isn't 0, then there are no outdated packages
|
||||
// TODO: was (...argsList), verify this works
|
||||
await Utils.executeYarnCommand(command, ...params)
|
||||
.then(() => updateOutdated(false))
|
||||
.catch((code) => updateOutdated(code !== 0));
|
||||
|
||||
if (cacheTimeout > 0) {
|
||||
setTimeout(() => {
|
||||
delete cache.outdated;
|
||||
}, cacheTimeout);
|
||||
}
|
||||
|
||||
return cache.outdated;
|
||||
}
|
||||
|
||||
function updateOutdated(outdatedPackages) {
|
||||
cache.outdated = outdatedPackages;
|
||||
}
|
||||
68
server/plugins/packages/publicClient.ts
Normal file
68
server/plugins/packages/publicClient.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import {PackageInfo} from "./index";
|
||||
import Client from "../../client";
|
||||
import Chan from "../../models/chan";
|
||||
import Msg, {MessageType, UserInMessage} from "../../models/msg";
|
||||
|
||||
export default class PublicClient {
|
||||
private client: Client;
|
||||
private packageInfo: PackageInfo;
|
||||
|
||||
constructor(client: Client, packageInfo: PackageInfo) {
|
||||
this.client = client;
|
||||
this.packageInfo = packageInfo;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {String} command - IRC command to run, this is in the same format that a client would send to the server (eg: JOIN #test)
|
||||
* @param {String} targetId - The id of the channel to simulate the command coming from. Replies will go to this channel if appropriate
|
||||
*/
|
||||
runAsUser(command: string, targetId: string) {
|
||||
this.client.inputLine({target: targetId, text: command});
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Object} attributes
|
||||
*/
|
||||
createChannel(attributes: Partial<Chan>) {
|
||||
return this.client.createChannel(attributes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits an `event` to the browser client, with `data` in the body of the event.
|
||||
*
|
||||
* @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);
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {Number} chanId
|
||||
*/
|
||||
getChannel(chanId: number) {
|
||||
return this.client.find(chanId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sends a message to this client, displayed in the given channel.
|
||||
*
|
||||
* @param {String} text the message to send
|
||||
* @param {Chan} chan the channel to send the message to
|
||||
*/
|
||||
sendMessage(text: string, chan: Chan) {
|
||||
chan.pushMessage(
|
||||
this.client,
|
||||
new Msg({
|
||||
type: MessageType.PLUGIN,
|
||||
text: text,
|
||||
from: {
|
||||
nick: this.packageInfo.name || this.packageInfo.packageName,
|
||||
} as UserInMessage,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
91
server/plugins/packages/themes.ts
Normal file
91
server/plugins/packages/themes.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
import fs from "fs";
|
||||
import path from "path";
|
||||
import _ from "lodash";
|
||||
|
||||
import Config from "../../config";
|
||||
import Utils from "../../command-line/utils";
|
||||
|
||||
type Module = {
|
||||
type?: string;
|
||||
name?: string;
|
||||
};
|
||||
|
||||
type ThemeModule = Module & {
|
||||
type: "theme";
|
||||
themeColor: string;
|
||||
css: string;
|
||||
};
|
||||
|
||||
export type ThemeForClient = {
|
||||
displayName: string;
|
||||
filename?: string;
|
||||
name: string;
|
||||
themeColor: string | null;
|
||||
};
|
||||
|
||||
const themes = new Map<string, ThemeForClient>();
|
||||
|
||||
export default {
|
||||
addTheme,
|
||||
getAll,
|
||||
getByName,
|
||||
loadLocalThemes,
|
||||
};
|
||||
|
||||
function loadLocalThemes() {
|
||||
const builtInThemes = fs.readdirSync(Utils.getFileFromRelativeToRoot("public", "themes"));
|
||||
|
||||
builtInThemes
|
||||
.filter((theme) => theme.endsWith(".css"))
|
||||
.map(makeLocalThemeObject)
|
||||
.forEach((theme) => themes.set(theme.name, theme));
|
||||
}
|
||||
|
||||
function addTheme(packageName: string, packageObject: ThemeModule) {
|
||||
const theme = makePackageThemeObject(packageName, packageObject);
|
||||
|
||||
if (theme) {
|
||||
themes.set(theme.name, theme);
|
||||
}
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
const filteredThemes: ThemeForClient[] = [];
|
||||
|
||||
for (const theme of themes.values()) {
|
||||
filteredThemes.push(_.pick(theme, ["displayName", "name", "themeColor"]));
|
||||
}
|
||||
|
||||
return _.sortBy(filteredThemes, "displayName");
|
||||
}
|
||||
|
||||
function getByName(name: string) {
|
||||
return themes.get(name);
|
||||
}
|
||||
|
||||
function makeLocalThemeObject(css: string) {
|
||||
const themeName = css.slice(0, -4);
|
||||
return {
|
||||
displayName: themeName.charAt(0).toUpperCase() + themeName.slice(1),
|
||||
name: themeName,
|
||||
themeColor: null,
|
||||
};
|
||||
}
|
||||
|
||||
function makePackageThemeObject(
|
||||
moduleName: string,
|
||||
module: ThemeModule
|
||||
): ThemeForClient | undefined {
|
||||
if (!module || module.type !== "theme") {
|
||||
return;
|
||||
}
|
||||
|
||||
const themeColor = /^#[0-9A-F]{6}$/i.test(module.themeColor) ? module.themeColor : null;
|
||||
const modulePath = Config.getPackageModulePath(moduleName);
|
||||
return {
|
||||
displayName: module.name || moduleName,
|
||||
filename: path.join(modulePath, module.css),
|
||||
name: moduleName,
|
||||
themeColor: themeColor,
|
||||
};
|
||||
}
|
||||
104
server/plugins/storage.ts
Normal file
104
server/plugins/storage.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import log from "../log";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import crypto from "crypto";
|
||||
import Config from "../config";
|
||||
|
||||
class Storage {
|
||||
references: Map<string, number>;
|
||||
constructor() {
|
||||
this.references = new Map();
|
||||
}
|
||||
|
||||
emptyDir() {
|
||||
// Ensures that a directory is empty.
|
||||
// Deletes directory contents if the directory is not empty.
|
||||
// If the directory does not exist, it is created.
|
||||
|
||||
const dir = Config.getStoragePath();
|
||||
let items;
|
||||
|
||||
try {
|
||||
items = fs.readdirSync(dir);
|
||||
} catch (e: any) {
|
||||
fs.mkdirSync(dir, {recursive: true});
|
||||
return;
|
||||
}
|
||||
|
||||
// TODO: Use `fs.rmdirSync(dir, {recursive: true});` when it's stable (node 13+)
|
||||
items.forEach((item) => deleteFolder(path.join(dir, item)));
|
||||
}
|
||||
|
||||
dereference(url) {
|
||||
const references = (this.references.get(url) || 0) - 1;
|
||||
|
||||
if (references < 0) {
|
||||
return log.warn("Tried to dereference a file that has no references", url);
|
||||
}
|
||||
|
||||
if (references > 0) {
|
||||
return this.references.set(url, references);
|
||||
}
|
||||
|
||||
this.references.delete(url);
|
||||
|
||||
// Drop "storage/" from url and join it with full storage path
|
||||
const filePath = path.join(Config.getStoragePath(), url.substring(8));
|
||||
|
||||
fs.unlink(filePath, (err) => {
|
||||
if (err) {
|
||||
log.error("Failed to delete stored file", err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
store(data, extension: string, callback: (url: string) => void) {
|
||||
const hash = crypto.createHash("sha256").update(data).digest("hex");
|
||||
const a = hash.substring(0, 2);
|
||||
const b = hash.substring(2, 4);
|
||||
const folder = path.join(Config.getStoragePath(), a, b);
|
||||
const filePath = path.join(folder, `${hash.substring(4)}.${extension}`);
|
||||
const url = `storage/${a}/${b}/${hash.substring(4)}.${extension}`;
|
||||
|
||||
this.references.set(url, 1 + (this.references.get(url) || 0));
|
||||
|
||||
// If file with this name already exists, we don't need to write it again
|
||||
if (fs.existsSync(filePath)) {
|
||||
return callback(url);
|
||||
}
|
||||
|
||||
fs.mkdir(folder, {recursive: true}, (mkdirErr) => {
|
||||
if (mkdirErr) {
|
||||
log.error("Failed to create storage folder", mkdirErr.message);
|
||||
|
||||
return callback("");
|
||||
}
|
||||
|
||||
fs.writeFile(filePath, data, (err) => {
|
||||
if (err) {
|
||||
log.error("Failed to store a file", err.message);
|
||||
|
||||
return callback("");
|
||||
}
|
||||
|
||||
callback(url);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new Storage();
|
||||
|
||||
function deleteFolder(dir: string) {
|
||||
fs.readdirSync(dir).forEach((item) => {
|
||||
item = path.join(dir, item);
|
||||
|
||||
if (fs.lstatSync(item).isDirectory()) {
|
||||
deleteFolder(item);
|
||||
} else {
|
||||
fs.unlinkSync(item);
|
||||
}
|
||||
});
|
||||
|
||||
fs.rmdirSync(dir);
|
||||
}
|
||||
108
server/plugins/sts.ts
Normal file
108
server/plugins/sts.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import _ from "lodash";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import log from "../log";
|
||||
import Config from "../config";
|
||||
|
||||
type PolicyOption = {
|
||||
port: number;
|
||||
duration: number;
|
||||
expires: number;
|
||||
host: string;
|
||||
};
|
||||
|
||||
type PolicyMap = Map<string, Omit<PolicyOption, "host">>;
|
||||
|
||||
class STSPolicies {
|
||||
stsFile: string;
|
||||
refresh: _.DebouncedFunc<any>;
|
||||
|
||||
private policies: PolicyMap;
|
||||
|
||||
constructor() {
|
||||
this.stsFile = path.join(Config.getHomePath(), "sts-policies.json");
|
||||
this.policies = new Map();
|
||||
// eslint-disable-next-line @typescript-eslint/unbound-method
|
||||
this.refresh = _.debounce(this.saveFile, 10000, {maxWait: 60000});
|
||||
|
||||
if (!fs.existsSync(this.stsFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedPolicies = JSON.parse(fs.readFileSync(this.stsFile, "utf-8")) as PolicyOption[];
|
||||
const now = Date.now();
|
||||
|
||||
storedPolicies.forEach((value) => {
|
||||
if (value.expires > now) {
|
||||
this.policies.set(value.host, {
|
||||
port: value.port,
|
||||
duration: value.duration,
|
||||
expires: value.expires,
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
get(host: string) {
|
||||
const policy = this.policies.get(host);
|
||||
|
||||
if (typeof policy === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (policy.expires <= Date.now()) {
|
||||
this.policies.delete(host);
|
||||
this.refresh();
|
||||
return null;
|
||||
}
|
||||
|
||||
return policy;
|
||||
}
|
||||
|
||||
update(host: string, port: number, duration: number) {
|
||||
if (duration > 0) {
|
||||
this.policies.set(host, {
|
||||
port: port,
|
||||
duration: duration,
|
||||
expires: Date.now() + duration * 1000,
|
||||
});
|
||||
} else {
|
||||
this.policies.delete(host);
|
||||
}
|
||||
|
||||
this.refresh();
|
||||
}
|
||||
|
||||
refreshExpiration(host: string) {
|
||||
const policy = this.policies.get(host);
|
||||
|
||||
if (typeof policy === "undefined") {
|
||||
return null;
|
||||
}
|
||||
|
||||
policy.expires = Date.now() + policy.duration * 1000;
|
||||
}
|
||||
|
||||
saveFile() {
|
||||
const policiesToStore: PolicyOption[] = [];
|
||||
|
||||
this.policies.forEach((value, key) => {
|
||||
policiesToStore.push({
|
||||
host: key,
|
||||
port: value.port,
|
||||
duration: value.duration,
|
||||
expires: value.expires,
|
||||
});
|
||||
});
|
||||
|
||||
const file = JSON.stringify(policiesToStore, null, "\t");
|
||||
|
||||
fs.writeFile(this.stsFile, file, {flag: "w+"}, (err) => {
|
||||
if (err) {
|
||||
log.error("Failed to update STS policies file!", err.message);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new STSPolicies();
|
||||
336
server/plugins/uploader.ts
Normal file
336
server/plugins/uploader.ts
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import Config from "../config";
|
||||
import busboy, {BusboyHeaders} from "@fastify/busboy";
|
||||
import {v4 as uuidv4} from "uuid";
|
||||
import path from "path";
|
||||
import fs from "fs";
|
||||
import fileType from "file-type";
|
||||
import readChunk from "read-chunk";
|
||||
import crypto from "crypto";
|
||||
import isUtf8 from "is-utf8";
|
||||
import log from "../log";
|
||||
import contentDisposition from "content-disposition";
|
||||
import type {Socket} from "socket.io";
|
||||
import {Request, Response} from "express";
|
||||
|
||||
// Map of allowed mime types to their respecive default filenames
|
||||
// that will be rendered in browser without forcing them to be downloaded
|
||||
const inlineContentDispositionTypes = {
|
||||
"application/ogg": "media.ogx",
|
||||
"audio/midi": "audio.midi",
|
||||
"audio/mpeg": "audio.mp3",
|
||||
"audio/ogg": "audio.ogg",
|
||||
"audio/vnd.wave": "audio.wav",
|
||||
"audio/x-flac": "audio.flac",
|
||||
"audio/x-m4a": "audio.m4a",
|
||||
"image/bmp": "image.bmp",
|
||||
"image/gif": "image.gif",
|
||||
"image/jpeg": "image.jpg",
|
||||
"image/png": "image.png",
|
||||
"image/webp": "image.webp",
|
||||
"image/avif": "image.avif",
|
||||
"image/jxl": "image.jxl",
|
||||
"text/plain": "text.txt",
|
||||
"video/mp4": "video.mp4",
|
||||
"video/ogg": "video.ogv",
|
||||
"video/webm": "video.webm",
|
||||
};
|
||||
|
||||
const uploadTokens = new Map();
|
||||
|
||||
class Uploader {
|
||||
constructor(socket: Socket) {
|
||||
socket.on("upload:auth", () => {
|
||||
const token = uuidv4();
|
||||
|
||||
socket.emit("upload:auth", token);
|
||||
|
||||
// Invalidate the token in one minute
|
||||
const timeout = Uploader.createTokenTimeout(token);
|
||||
|
||||
uploadTokens.set(token, timeout);
|
||||
});
|
||||
|
||||
socket.on("upload:ping", (token) => {
|
||||
if (typeof token !== "string") {
|
||||
return;
|
||||
}
|
||||
|
||||
let timeout = uploadTokens.get(token);
|
||||
|
||||
if (!timeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timeout);
|
||||
timeout = Uploader.createTokenTimeout(token);
|
||||
uploadTokens.set(token, timeout);
|
||||
});
|
||||
}
|
||||
|
||||
static createTokenTimeout(this: void, token: string) {
|
||||
return setTimeout(() => uploadTokens.delete(token), 60 * 1000);
|
||||
}
|
||||
|
||||
// TODO: type
|
||||
static router(this: void, express: any) {
|
||||
express.get("/uploads/:name/:slug*?", Uploader.routeGetFile);
|
||||
express.post("/uploads/new/:token", Uploader.routeUploadFile);
|
||||
}
|
||||
|
||||
static async routeGetFile(this: void, req: Request, res: Response) {
|
||||
const name = req.params.name;
|
||||
|
||||
const nameRegex = /^[0-9a-f]{16}$/;
|
||||
|
||||
if (!nameRegex.test(name)) {
|
||||
return res.status(404).send("Not found");
|
||||
}
|
||||
|
||||
const folder = name.substring(0, 2);
|
||||
const uploadPath = Config.getFileUploadPath();
|
||||
const filePath = path.join(uploadPath, folder, name);
|
||||
let detectedMimeType = await Uploader.getFileType(filePath);
|
||||
|
||||
// doesn't exist
|
||||
if (detectedMimeType === null) {
|
||||
return res.status(404).send("Not found");
|
||||
}
|
||||
|
||||
// Force a download in the browser if it's not an allowed type (binary or otherwise unknown)
|
||||
let slug = req.params.slug;
|
||||
const isInline = detectedMimeType in inlineContentDispositionTypes;
|
||||
let disposition = isInline ? "inline" : "attachment";
|
||||
|
||||
if (!slug && isInline) {
|
||||
slug = inlineContentDispositionTypes[detectedMimeType];
|
||||
}
|
||||
|
||||
if (slug) {
|
||||
disposition = contentDisposition(slug.trim(), {
|
||||
fallback: false,
|
||||
type: disposition,
|
||||
});
|
||||
}
|
||||
|
||||
// Send a more common mime type for audio files
|
||||
// so that browsers can play them correctly
|
||||
if (detectedMimeType === "audio/vnd.wave") {
|
||||
detectedMimeType = "audio/wav";
|
||||
} else if (detectedMimeType === "audio/x-flac") {
|
||||
detectedMimeType = "audio/flac";
|
||||
} else if (detectedMimeType === "audio/x-m4a") {
|
||||
detectedMimeType = "audio/mp4";
|
||||
} else if (detectedMimeType === "video/quicktime") {
|
||||
detectedMimeType = "video/mp4";
|
||||
}
|
||||
|
||||
res.setHeader("Content-Disposition", disposition);
|
||||
res.setHeader("Cache-Control", "max-age=86400");
|
||||
res.contentType(detectedMimeType);
|
||||
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
|
||||
static routeUploadFile(this: void, req: Request, res: Response) {
|
||||
let busboyInstance: NodeJS.WritableStream | busboy | null | undefined;
|
||||
let uploadUrl: string | URL;
|
||||
let randomName: string;
|
||||
let destDir: fs.PathLike;
|
||||
let destPath: fs.PathLike | null;
|
||||
let streamWriter: fs.WriteStream | null;
|
||||
|
||||
const doneCallback = () => {
|
||||
// detach the stream and drain any remaining data
|
||||
if (busboyInstance) {
|
||||
req.unpipe(busboyInstance);
|
||||
req.on("readable", req.read.bind(req));
|
||||
|
||||
busboyInstance.removeAllListeners();
|
||||
busboyInstance = null;
|
||||
}
|
||||
|
||||
// close the output file stream
|
||||
if (streamWriter) {
|
||||
streamWriter.end();
|
||||
streamWriter = null;
|
||||
}
|
||||
};
|
||||
|
||||
const abortWithError = (err: any) => {
|
||||
doneCallback();
|
||||
|
||||
// if we ended up erroring out, delete the output file from disk
|
||||
if (destPath && fs.existsSync(destPath)) {
|
||||
fs.unlinkSync(destPath);
|
||||
destPath = null;
|
||||
}
|
||||
|
||||
return res.status(400).json({error: err.message});
|
||||
};
|
||||
|
||||
// if the authentication token is incorrect, bail out
|
||||
if (uploadTokens.delete(req.params.token) !== true) {
|
||||
return abortWithError(Error("Invalid upload token"));
|
||||
}
|
||||
|
||||
// if the request does not contain any body data, bail out
|
||||
if (req.headers["content-length"] && parseInt(req.headers["content-length"]) < 1) {
|
||||
return abortWithError(Error("Length Required"));
|
||||
}
|
||||
|
||||
// Only allow multipart, as busboy can throw an error on unsupported types
|
||||
if (
|
||||
!(
|
||||
req.headers["content-type"] &&
|
||||
req.headers["content-type"].startsWith("multipart/form-data")
|
||||
)
|
||||
) {
|
||||
return abortWithError(Error("Unsupported Content Type"));
|
||||
}
|
||||
|
||||
// create a new busboy processor, it is wrapped in try/catch
|
||||
// because it can throw on malformed headers
|
||||
try {
|
||||
busboyInstance = new busboy({
|
||||
headers: req.headers as BusboyHeaders,
|
||||
limits: {
|
||||
files: 1, // only allow one file per upload
|
||||
fileSize: Uploader.getMaxFileSize(),
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return abortWithError(err);
|
||||
}
|
||||
|
||||
// Any error or limit from busboy will abort the upload with an error
|
||||
busboyInstance.on("error", abortWithError);
|
||||
busboyInstance.on("partsLimit", () => abortWithError(Error("Parts limit reached")));
|
||||
busboyInstance.on("filesLimit", () => abortWithError(Error("Files limit reached")));
|
||||
busboyInstance.on("fieldsLimit", () => abortWithError(Error("Fields limit reached")));
|
||||
|
||||
// generate a random output filename for the file
|
||||
// we use do/while loop to prevent the rare case of generating a file name
|
||||
// that already exists on disk
|
||||
do {
|
||||
randomName = crypto.randomBytes(8).toString("hex");
|
||||
destDir = path.join(Config.getFileUploadPath(), randomName.substring(0, 2));
|
||||
destPath = path.join(destDir, randomName);
|
||||
} while (fs.existsSync(destPath));
|
||||
|
||||
// we split the filename into subdirectories (by taking 2 letters from the beginning)
|
||||
// this helps avoid file system and certain tooling limitations when there are
|
||||
// too many files on one folder
|
||||
try {
|
||||
fs.mkdirSync(destDir, {recursive: true});
|
||||
} catch (err: any) {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.error(`Error ensuring ${destDir} exists for uploads: ${err.message}`);
|
||||
|
||||
return abortWithError(err);
|
||||
}
|
||||
|
||||
// Open a file stream for writing
|
||||
streamWriter = fs.createWriteStream(destPath);
|
||||
streamWriter.on("error", abortWithError);
|
||||
|
||||
busboyInstance.on(
|
||||
"file",
|
||||
(
|
||||
fieldname: any,
|
||||
fileStream: {
|
||||
on: (
|
||||
arg0: string,
|
||||
arg1: {(err: any): Response<any, Record<string, any>>; (): void}
|
||||
) => void;
|
||||
unpipe: (arg0: any) => void;
|
||||
read: {bind: (arg0: any) => any};
|
||||
pipe: (arg0: any) => void;
|
||||
},
|
||||
filename: string | number | boolean
|
||||
) => {
|
||||
uploadUrl = `${randomName}/${encodeURIComponent(filename)}`;
|
||||
|
||||
if (Config.values.fileUpload.baseUrl) {
|
||||
uploadUrl = new URL(uploadUrl, Config.values.fileUpload.baseUrl).toString();
|
||||
} else {
|
||||
uploadUrl = `uploads/${uploadUrl}`;
|
||||
}
|
||||
|
||||
// if the busboy data stream errors out or goes over the file size limit
|
||||
// abort the processing with an error
|
||||
// @ts-expect-error Argument of type '(err: any) => Response<any, Record<string, any>>' is not assignable to parameter of type '{ (err: any): Response<any, Record<string, any>>; (): void; }'.ts(2345)
|
||||
fileStream.on("error", abortWithError);
|
||||
fileStream.on("limit", () => {
|
||||
fileStream.unpipe(streamWriter);
|
||||
fileStream.on("readable", fileStream.read.bind(fileStream));
|
||||
|
||||
return abortWithError(Error("File size limit reached"));
|
||||
});
|
||||
|
||||
// Attempt to write the stream to file
|
||||
fileStream.pipe(streamWriter);
|
||||
}
|
||||
);
|
||||
|
||||
busboyInstance.on("finish", () => {
|
||||
doneCallback();
|
||||
|
||||
if (!uploadUrl) {
|
||||
return res.status(400).json({error: "Missing file"});
|
||||
}
|
||||
|
||||
// upload was done, send the generated file url to the client
|
||||
res.status(200).json({
|
||||
url: uploadUrl,
|
||||
});
|
||||
});
|
||||
|
||||
// pipe request body to busboy for processing
|
||||
return req.pipe(busboyInstance);
|
||||
}
|
||||
|
||||
static getMaxFileSize() {
|
||||
const configOption = Config.values.fileUpload.maxFileSize;
|
||||
|
||||
// Busboy uses Infinity to allow unlimited file size
|
||||
if (configOption < 1) {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
// maxFileSize is in bytes, but config option is passed in as KB
|
||||
return configOption * 1024;
|
||||
}
|
||||
|
||||
// Returns null if an error occurred (e.g. file not found)
|
||||
// Returns a string with the type otherwise
|
||||
static async getFileType(filePath: string) {
|
||||
try {
|
||||
const buffer = await readChunk(filePath, 0, 5120);
|
||||
|
||||
// returns {ext, mime} if found, null if not.
|
||||
const file = await fileType.fromBuffer(buffer);
|
||||
|
||||
// if a file type was detected correctly, return it
|
||||
if (file) {
|
||||
return file.mime;
|
||||
}
|
||||
|
||||
// if the buffer is a valid UTF-8 buffer, use text/plain
|
||||
if (isUtf8(buffer)) {
|
||||
return "text/plain";
|
||||
}
|
||||
|
||||
// otherwise assume it's random binary data
|
||||
return "application/octet-stream";
|
||||
} catch (e: any) {
|
||||
if (e.code !== "ENOENT") {
|
||||
// eslint-disable-next-line @typescript-eslint/restrict-template-expressions
|
||||
log.warn(`Failed to read ${filePath}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Uploader;
|
||||
107
server/plugins/webpush.ts
Normal file
107
server/plugins/webpush.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import _ from "lodash";
|
||||
import log from "../log";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import WebPushAPI from "web-push";
|
||||
import Config from "../config";
|
||||
import Client from "../client";
|
||||
import * as os from "os";
|
||||
class WebPush {
|
||||
vapidKeys?: {
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
};
|
||||
|
||||
constructor() {
|
||||
const vapidPath = path.join(Config.getHomePath(), "vapid.json");
|
||||
|
||||
let vapidStat: fs.Stats | undefined = undefined;
|
||||
|
||||
try {
|
||||
vapidStat = fs.statSync(vapidPath);
|
||||
} catch {
|
||||
// ignored on purpose, node v14.17.0 will give us {throwIfNoEntry: false}
|
||||
}
|
||||
|
||||
if (vapidStat) {
|
||||
const isWorldReadable = (vapidStat.mode & 0o004) !== 0;
|
||||
|
||||
if (isWorldReadable) {
|
||||
log.warn(
|
||||
vapidPath,
|
||||
"is world readable.",
|
||||
"The file contains secrets. Please fix the permissions."
|
||||
);
|
||||
|
||||
if (os.platform() !== "win32") {
|
||||
log.warn(`run \`chmod o= "${vapidPath}"\` to correct it.`);
|
||||
}
|
||||
}
|
||||
|
||||
const data = fs.readFileSync(vapidPath, "utf-8");
|
||||
const parsedData = JSON.parse(data);
|
||||
|
||||
if (
|
||||
typeof parsedData.publicKey === "string" &&
|
||||
typeof parsedData.privateKey === "string"
|
||||
) {
|
||||
this.vapidKeys = {
|
||||
publicKey: parsedData.publicKey,
|
||||
privateKey: parsedData.privateKey,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (!this.vapidKeys) {
|
||||
this.vapidKeys = WebPushAPI.generateVAPIDKeys();
|
||||
|
||||
fs.writeFileSync(vapidPath, JSON.stringify(this.vapidKeys, null, "\t"), {
|
||||
mode: 0o600,
|
||||
});
|
||||
|
||||
log.info("New VAPID key pair has been generated for use with push subscription.");
|
||||
}
|
||||
|
||||
WebPushAPI.setVapidDetails(
|
||||
"https://github.com/thelounge/thelounge",
|
||||
this.vapidKeys.publicKey,
|
||||
this.vapidKeys.privateKey
|
||||
);
|
||||
}
|
||||
|
||||
push(client: Client, payload: any, onlyToOffline: boolean) {
|
||||
_.forOwn(client.config.sessions, ({pushSubscription}, token) => {
|
||||
if (pushSubscription) {
|
||||
if (onlyToOffline && _.find(client.attachedClients, {token}) !== undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.pushSingle(client, pushSubscription, payload);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pushSingle(client: Client, subscription: WebPushAPI.PushSubscription, payload: any) {
|
||||
WebPushAPI.sendNotification(subscription, JSON.stringify(payload)).catch((error) => {
|
||||
if (error.statusCode >= 400 && error.statusCode < 500) {
|
||||
log.warn(
|
||||
`WebPush subscription for ${client.name} returned an error (${String(
|
||||
error.statusCode
|
||||
)}), removing subscription`
|
||||
);
|
||||
|
||||
_.forOwn(client.config.sessions, ({pushSubscription}, token) => {
|
||||
if (pushSubscription && pushSubscription.endpoint === subscription.endpoint) {
|
||||
client.unregisterPushSubscription(token);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
log.error(`WebPush Error (${String(error)})`);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default WebPush;
|
||||
1045
server/server.ts
Normal file
1045
server/server.ts
Normal file
File diff suppressed because it is too large
Load diff
27
server/tsconfig.json
Normal file
27
server/tsconfig.json
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
{
|
||||
"extends": "../tsconfig.base.json" /* Path to base configuration file to inherit from. Requires TypeScript version 2.1 or later. */,
|
||||
"include": [
|
||||
"**/*",
|
||||
"../client/js/helpers/ircmessageparser/*.ts"
|
||||
] /* 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": [
|
||||
"../client/js/constants.ts",
|
||||
"../babel.config.cjs",
|
||||
"../defaults/config.js",
|
||||
"../package.json",
|
||||
"../webpack.config.ts"
|
||||
] /* 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. */,
|
||||
"ts-node": {
|
||||
"files": true
|
||||
},
|
||||
"compilerOptions": {
|
||||
"outDir": "../dist" /* Specify an output folder for all emitted files. See more: https://www.typescriptlang.org/tsconfig#outDir */,
|
||||
"noEmit": false /* Disable emitting file from a compilation. See more: https://www.typescriptlang.org/tsconfig#noEmit */,
|
||||
|
||||
// TODO: Remove eventually
|
||||
"noImplicitAny": false /*Enable error reporting for expressions and declarations with an implied any type. See more: https://www.typescriptlang.org/tsconfig#noImplicitAny */
|
||||
} /* Instructs the TypeScript compiler how to compile .ts files. */,
|
||||
"exclude": [
|
||||
"./dist"
|
||||
] /* Specifies a list of glob patterns that match files to be excluded from compilation. Requires TypeScript version 2.0 or later. */
|
||||
}
|
||||
2
server/types/index.d.ts
vendored
Normal file
2
server/types/index.d.ts
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
import "./modules";
|
||||
import "./socket-events";
|
||||
430
server/types/modules/irc-framework.d.ts
vendored
Normal file
430
server/types/modules/irc-framework.d.ts
vendored
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
/* eslint-disable no-use-before-define */
|
||||
// @ts-nocheck
|
||||
// eslint-disable
|
||||
|
||||
// https://raw.githubusercontent.com/eternagame/HTML-Chat/vue-rewrite/src/app/types/modules/irc-framework/irc-framework.d.ts
|
||||
// TODO: Fix this
|
||||
declare module "irc-framework" {
|
||||
import {EventEmitter} from "eventemitter3";
|
||||
// import { DuplexStream } from 'stream';
|
||||
import Connection from "irc-framework/src/transports/websocket";
|
||||
|
||||
type ConnectionOpts = {
|
||||
connected: boolean;
|
||||
requested_disconnect: boolean;
|
||||
|
||||
reconnect_attempts: number;
|
||||
|
||||
// When an IRC connection was successfully registered.
|
||||
registered: boolean;
|
||||
|
||||
transport: any;
|
||||
write: (data: string) => void;
|
||||
end: () => void;
|
||||
};
|
||||
|
||||
export interface MessageEventArgs {
|
||||
account?: any;
|
||||
group?: any;
|
||||
hostname: string;
|
||||
ident: string;
|
||||
message: string;
|
||||
nick: string;
|
||||
reply: (message: string) => void;
|
||||
tags: {[key: string]: string};
|
||||
target: string;
|
||||
time?: any;
|
||||
type: "privmsg" | "action" | "notice" | "wallops";
|
||||
}
|
||||
export interface JoinEventArgs {
|
||||
account: boolean;
|
||||
channel: string;
|
||||
gecos: string;
|
||||
hostname: string;
|
||||
ident: string;
|
||||
nick: string;
|
||||
time?: any;
|
||||
}
|
||||
export interface KickEventArgs {
|
||||
kicked: string;
|
||||
nick: string;
|
||||
ident: string;
|
||||
hostname: string;
|
||||
channel: string;
|
||||
message: string;
|
||||
time: number;
|
||||
}
|
||||
export interface RawEventArgs {
|
||||
from_server: boolean;
|
||||
line: string;
|
||||
}
|
||||
export interface RegisteredEventArgs {
|
||||
nick: string;
|
||||
}
|
||||
export interface QuitEventArgs {
|
||||
hostname: string;
|
||||
ident: string;
|
||||
message: string;
|
||||
nick: string;
|
||||
time?: any;
|
||||
channel?: string;
|
||||
kicked?: string;
|
||||
}
|
||||
interface Mode {
|
||||
mode: string;
|
||||
param: string;
|
||||
}
|
||||
export interface ModeEventArgs {
|
||||
modes: Mode[];
|
||||
nick: string;
|
||||
raw_modes: string;
|
||||
raw_params: string[];
|
||||
target: string;
|
||||
time?: any;
|
||||
}
|
||||
export interface ServerOptionsEventArgs {
|
||||
options: any;
|
||||
cap: any;
|
||||
}
|
||||
export interface NickInvalidEventArgs {
|
||||
nick: string;
|
||||
reason: string;
|
||||
}
|
||||
export interface NickInUseEventArgs {
|
||||
nick: string;
|
||||
reason: string;
|
||||
}
|
||||
export interface IrcErrorEventArgs {
|
||||
error: string;
|
||||
channel: string;
|
||||
reason: string;
|
||||
nick?: string;
|
||||
command?: string;
|
||||
}
|
||||
export class Client extends EventEmitter {
|
||||
constructor(options: ClientConstructorParameters);
|
||||
|
||||
// Added by Max
|
||||
connection: ConnectionOpts;
|
||||
network: {
|
||||
options: {
|
||||
CHANTYPES: string;
|
||||
PREFIX: any;
|
||||
CHANMODES: string;
|
||||
NICKLEN: string;
|
||||
};
|
||||
cap: {
|
||||
isEnabled: (cap: string) => boolean;
|
||||
enabled: string[];
|
||||
};
|
||||
extractTargetGroup: (target: string) => any;
|
||||
supports(feature: "MODES"): string;
|
||||
supports(feature: string): boolean;
|
||||
};
|
||||
// End of added by Max
|
||||
|
||||
static setDefaultTransport(transport: any): void;
|
||||
|
||||
// get Message(): ClassDecorator;//TODO
|
||||
/** Applies the default options to the options object given as impot, and returns it. */
|
||||
_applyDefaultOptions(
|
||||
user_options: ClientConstructorParameters
|
||||
): ClientConstructorParameters;
|
||||
|
||||
createStructure(): void;
|
||||
|
||||
/** Is connected to the IRC network and successfully registered. */
|
||||
connected: boolean;
|
||||
|
||||
// TODO
|
||||
/** The object for the connected message, as long as the client is connected. */ user: IrcUser;
|
||||
|
||||
// TODO
|
||||
/** Request */ requestCap(capability: string[]): void;
|
||||
|
||||
use(a: any): any;
|
||||
|
||||
connect(connect_options?: Record<string, unknown>): void;
|
||||
|
||||
/**
|
||||
* Proxy the command handler events onto the client object, with some added sugar
|
||||
* Events are handled in order:
|
||||
* 1. Received from the command handler
|
||||
* 2. Checked if any extra properties/methods are to be added to the params + re-emitted
|
||||
* 3. Routed through middleware
|
||||
* 4. Emitted from the client instance
|
||||
*/
|
||||
proxyIrcEvents(): void;
|
||||
|
||||
addCommandHandlerListeners(): void;
|
||||
|
||||
registerToNetwork(): void;
|
||||
|
||||
startPeriodicPing(): void;
|
||||
|
||||
raw(...raw_data_line: string[]): void;
|
||||
|
||||
rawString(...parameters: Array<string>): string;
|
||||
|
||||
rawString(parameters: Array<string>): string;
|
||||
|
||||
quit(quit_message?: string): void;
|
||||
|
||||
ping(message?: string): void;
|
||||
|
||||
changeNick(nick: string): void;
|
||||
|
||||
sendMessage(commandName: string, target: string, message: string): string[];
|
||||
|
||||
say(target: string, message: string): string[];
|
||||
|
||||
notice(target: string, message: string): string[];
|
||||
|
||||
join(channel: string, key?: string): void;
|
||||
|
||||
part(channel: string, message?: string): void;
|
||||
|
||||
mode(channel: string, mode: string, extra_args?: string[]): void;
|
||||
|
||||
inviteList(channel: string, cb?: (e: Event) => any): void;
|
||||
|
||||
// TODO: typeof e?
|
||||
invite(channel: string, nick: string): void;
|
||||
|
||||
addInvite(channel: string, mask: string): void;
|
||||
|
||||
removeInvite(channel: string, mask: string): void;
|
||||
|
||||
banlist(channel: string, cb?: (e: Event) => any): void;
|
||||
|
||||
ban(channel: string, mask: string): void;
|
||||
|
||||
unban(channel: string, mask: string): void;
|
||||
|
||||
setTopic(channel: string, newTopic: string): void;
|
||||
|
||||
ctcpRequest(target: string, type: string, ...params: Array<string>): void;
|
||||
|
||||
ctcpResponse(target: string, type: string, ...params: Array<string>): void;
|
||||
|
||||
action(target: string, message: string): string[];
|
||||
|
||||
whowas(target: string, cb?: (event: Event) => any): void;
|
||||
|
||||
whois(nick: string, cb: (event: any) => void): void;
|
||||
|
||||
/**
|
||||
* WHO requests are queued up to run serially.
|
||||
* This is mostly because networks will only reply serially and it makes
|
||||
* it easier to include the correct replies to callbacks
|
||||
*/
|
||||
who(target: string, cb: (event: any) => void): void;
|
||||
|
||||
list(...params: Array<string>): void;
|
||||
|
||||
channel(channel_name: string): IrcChannel;
|
||||
|
||||
match(
|
||||
match_regex: string,
|
||||
cb: (event: Event) => any,
|
||||
message_type: string
|
||||
): {stop: () => void};
|
||||
|
||||
matchNotice(match_regex: string, cb: (event: Event) => any): void;
|
||||
|
||||
matchMessage(match_regex: string, cb: (event: Event) => any): void;
|
||||
|
||||
matchAction(match_regex: string, cb: (event: Event) => any): void;
|
||||
|
||||
stringToBlocks(str: string, block_size?: number): string[];
|
||||
|
||||
on(eventType: string | symbol, cb: (event: any) => void): this;
|
||||
|
||||
on(eventType: "raw", cb: (event: RawEventArgs) => void): this;
|
||||
|
||||
on(eventType: "join", cb: (event: JoinEventArgs) => void): this;
|
||||
|
||||
on(eventType: "registered", cb: (event: RegisteredEventArgs) => void): this;
|
||||
|
||||
on(eventType: "quit", cb: (event: QuitEventArgs) => void): this;
|
||||
|
||||
on(eventType: "part", cb: (event: QuitEventArgs) => void): this;
|
||||
|
||||
on(eventType: "kick", cb: (event: QuitEventArgs) => void): this;
|
||||
|
||||
on(eventType: "message", cb: (event: MessageEventArgs) => any): this;
|
||||
|
||||
on(eventType: "notice", cb: (event: MessageEventArgs /* TODO */) => any): this;
|
||||
|
||||
on(eventType: "mode", cb: (event: ModeEventArgs) => any): this;
|
||||
|
||||
on(eventType: "socket close", cb: (event: Record<string, unknown>) => any): this;
|
||||
|
||||
on(eventType: "socket connected", cb: (event: Record<string, unknown>) => any): this;
|
||||
|
||||
on(eventType: "raw socket connected", cb: (event: Record<string, unknown>) => any): this;
|
||||
|
||||
on(eventType: "server options", cb: (event: ServerOptionsEventArgs) => any): this;
|
||||
|
||||
on(eventType: "debug", cb: (message: string) => any): this;
|
||||
|
||||
on(eventType: "nick in use", cb: (event: NickInUseEventArgs) => any): this;
|
||||
|
||||
on(eventType: "nick invalid", cb: (event: NickInvalidEventArgs) => any): this;
|
||||
|
||||
on(eventType: "irc error", cb: (event: IrcErrorEventArgs) => any): this;
|
||||
}
|
||||
export class Message {
|
||||
// TODO: What is actually in it and what was in the event?
|
||||
constructor(command?: string, ...args: string[]);
|
||||
|
||||
account?: IrcUser;
|
||||
|
||||
group?: any;
|
||||
|
||||
hostname: string;
|
||||
|
||||
ident: string;
|
||||
|
||||
message: string;
|
||||
|
||||
nick: string;
|
||||
|
||||
reply(e: any): any;
|
||||
|
||||
tags: Record<string, unknown>;
|
||||
|
||||
// any
|
||||
time?: any;
|
||||
|
||||
type: string;
|
||||
}
|
||||
|
||||
// interface IrcUser {
|
||||
// /**The current nick you are currently using.*/
|
||||
// nick: string;
|
||||
// /**Your username (ident) that the network sees you as using.*/
|
||||
// username: string;
|
||||
// /**Your current gecos (realname).*/
|
||||
// gecos: string;
|
||||
// /**On supported servers, the hostname that the networksees you are using.*/
|
||||
// host: string;
|
||||
// /**Your current away status. Empty for not away.*/
|
||||
// away: string;
|
||||
// /**A set() instance with your current message modes.*/
|
||||
// modes: Set<string>;
|
||||
// }
|
||||
// TODO: what to call it? why is it channel.users empty after join?
|
||||
interface IrcUser {
|
||||
hostname: string;
|
||||
ident: string;
|
||||
modes: string[]; // any[]
|
||||
nick: string;
|
||||
username: string;
|
||||
gecos: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export interface ChannelInfoEventArgs {
|
||||
channel: string;
|
||||
created_at?: number;
|
||||
modes?: Mode[]; // TODO: check type
|
||||
url?: string;
|
||||
}
|
||||
class IrcChannel extends EventEmitter {
|
||||
constructor(irc_client: Client, channel_name: string, key: string);
|
||||
|
||||
irc_client: Client;
|
||||
|
||||
name: string;
|
||||
|
||||
say(message: string): string[];
|
||||
|
||||
notice(message: string): string[];
|
||||
|
||||
join(key?: string): void;
|
||||
|
||||
part(message?: string): void;
|
||||
|
||||
mode(mode: string, extra_args?: string[]): void;
|
||||
|
||||
banlist(cb: (e: Event) => any): void;
|
||||
|
||||
ban(mask: string): void;
|
||||
|
||||
unban(mask: string): void;
|
||||
|
||||
users: IrcUser[];
|
||||
|
||||
/**
|
||||
* Relay messages between this channel to another
|
||||
* @param {IrcChannel|String} target_chan Target channel
|
||||
* @param {Object} opts Extra options
|
||||
*
|
||||
* opts may contain the following properties:
|
||||
* one_way (false) Only relay messages to target_chan, not the reverse
|
||||
* replay_nicks (true) Include the sending nick as part of the relayed message
|
||||
*/
|
||||
relay(target_chan: IrcChannel | string, opts: Record<string, any>): void;
|
||||
|
||||
// stream(stream_ops: Object): DuplexStream;
|
||||
|
||||
updateUsers(cb: (channel: IrcChannel) => any): void;
|
||||
|
||||
on(eventType: "channel info", cb: (event: ChannelInfoEventArgs) => any): this;
|
||||
|
||||
on(eventType: string | symbol, cb: (event: any) => any): this;
|
||||
}
|
||||
|
||||
export interface UserListEventArgs {
|
||||
channel: string;
|
||||
users: IrcUser[]; // TODO: check type
|
||||
}
|
||||
export interface WhoListEventArgs {
|
||||
target: string;
|
||||
users: IrcUser[]; // TODO: check type
|
||||
}
|
||||
export interface BanlistEventArgs {
|
||||
channel: string;
|
||||
bans: IrcUser[]; // TODO: check type
|
||||
}
|
||||
export interface TopicEventArgs {
|
||||
channel: string;
|
||||
topic: string;
|
||||
nick?: string;
|
||||
time?: number;
|
||||
}
|
||||
export interface TopicSetByEventArgs {
|
||||
channel: string;
|
||||
nick: string;
|
||||
ident: string;
|
||||
hostname: string;
|
||||
when?: number;
|
||||
}
|
||||
interface ClientConstructorParameters {
|
||||
host?: string;
|
||||
nick?: string;
|
||||
outgoing_addr?: string;
|
||||
username?: string;
|
||||
gecos?: string;
|
||||
encoding?: string;
|
||||
version?: string | boolean;
|
||||
enable_chghost?: boolean;
|
||||
enable_echomessage?: boolean;
|
||||
enable_setname?: boolean;
|
||||
message_max_length?: number;
|
||||
auto_reconnect?: boolean;
|
||||
auto_reconnect_wait?: number;
|
||||
auto_reconnect_max_retries?: number;
|
||||
ping_interval?: number;
|
||||
ping_timeout?: number;
|
||||
transport?: new (options: any) => Connection;
|
||||
ssl?: boolean;
|
||||
webirc?: {
|
||||
password?: string;
|
||||
username?: string;
|
||||
hostname?: string;
|
||||
ip?: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
224
server/types/socket-events.d.ts
vendored
Normal file
224
server/types/socket-events.d.ts
vendored
Normal file
|
|
@ -0,0 +1,224 @@
|
|||
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: {results: ClientMessage[]}) => 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 {}
|
||||
Loading…
Add table
Add a link
Reference in a new issue