thelounge
Version:
The self-hosted Web IRC client
684 lines (683 loc) • 25.9 kB
JavaScript
;
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
__setModuleDefault(result, mod);
return result;
};
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const lodash_1 = __importDefault(require("lodash"));
const ua_parser_js_1 = __importDefault(require("ua-parser-js"));
const uuid_1 = require("uuid");
const escapeRegExp_1 = __importDefault(require("lodash/escapeRegExp"));
const crypto_1 = __importDefault(require("crypto"));
const chalk_1 = __importDefault(require("chalk"));
const log_1 = __importDefault(require("./log"));
const chan_1 = __importStar(require("./models/chan"));
const msg_1 = __importStar(require("./models/msg"));
const config_1 = __importDefault(require("./config"));
const irc_1 = require("../shared/irc");
const inputs_1 = __importDefault(require("./plugins/inputs"));
const publicClient_1 = __importDefault(require("./plugins/packages/publicClient"));
const sqlite_1 = __importDefault(require("./plugins/messageStorage/sqlite"));
const text_1 = __importDefault(require("./plugins/messageStorage/text"));
const network_1 = __importDefault(require("./models/network"));
const storageCleaner_1 = require("./storageCleaner");
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",
];
class Client {
awayMessage;
lastActiveChannel;
attachedClients;
config;
id;
idMsg;
idChan;
name;
networks;
mentions;
manager;
messageStorage;
highlightRegex;
highlightExceptionRegex;
messageProvider;
fileHash;
constructor(manager, name, config = {}) {
lodash_1.default.merge(this, {
awayMessage: "",
lastActiveChannel: -1,
attachedClients: {},
config: config,
id: (0, uuid_1.v4)(),
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_1.default.values.public && client.config.log) {
if (config_1.default.values.messageStorage.includes("sqlite")) {
client.messageProvider = new sqlite_1.default(client.name);
if (config_1.default.values.storagePolicy.enabled) {
log_1.default.info(`Activating storage cleaner. Policy: ${config_1.default.values.storagePolicy.deletionPolicy}. MaxAge: ${config_1.default.values.storagePolicy.maxAgeDays} days`);
const cleaner = new storageCleaner_1.StorageCleaner(client.messageProvider);
cleaner.start();
}
client.messageStorage.push(client.messageProvider);
}
if (config_1.default.values.messageStorage.includes("text")) {
client.messageStorage.push(new text_1.default(client.name));
}
for (const messageStorage of client.messageStorage) {
messageStorage.enable().catch((e) => log_1.default.error(e));
}
}
if (!lodash_1.default.isPlainObject(client.config.sessions)) {
client.config.sessions = {};
}
if (!lodash_1.default.isPlainObject(client.config.clientSettings)) {
client.config.clientSettings = {};
}
if (!lodash_1.default.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();
lodash_1.default.forOwn(client.config.sessions, (session) => {
if (session.pushSubscription) {
this.registerPushSubscription(session, session.pushSubscription, true);
}
});
}
connect() {
const client = this;
if (client.networks.length !== 0) {
throw new Error(`${client.name} is already connected`);
}
(client.config.networks || []).forEach((network) => client.connectToNetwork(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_1.default.info(`User ${chalk_1.default.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 = client.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 = client.manager.getDataToSave(client).newHash;
}
}
createChannel(attr) {
const chan = new chan_1.default(attr);
chan.id = this.idChan++;
return chan;
}
emit(event, data) {
if (this.manager !== null) {
this.manager.sockets.in(this.id.toString()).emit(event, data);
}
}
find(channelId) {
let network = null;
let chan = null;
for (const n of this.networks) {
chan = lodash_1.default.find(n.channels, { id: channelId });
if (chan) {
network = n;
break;
}
}
if (network && chan) {
return { network, chan };
}
return false;
}
networkFromConfig(args) {
const client = this;
let channels = [];
if (Array.isArray(args.channels)) {
let badChanConf = false;
args.channels.forEach((chan) => {
const type = chan_1.ChanType[(chan.type || "channel").toUpperCase()];
if (!chan.name || !type) {
badChanConf = true;
return;
}
channels.push(client.createChannel({
name: chan.name,
key: chan.key || "",
type: type,
muted: chan.muted,
}));
});
if (badChanConf && client.name) {
log_1.default.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) => {
if (!chan.match(/^[#&!+]/)) {
chan = `#${chan}`;
}
return client.createChannel({
name: chan,
});
});
}
// TODO; better typing for args
return new network_1.default({
uuid: args.uuid,
name: String(args.name || (config_1.default.values.lockNetwork ? config_1.default.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 || [],
channels: channels,
ignoreList: args.ignoreList ? args.ignoreList : [],
proxyEnabled: !!args.proxyEnabled,
proxyHost: String(args.proxyHost || ""),
proxyPort: parseInt(args.proxyPort, 10),
proxyUsername: String(args.proxyUsername || ""),
proxyPassword: String(args.proxyPassword || ""),
});
}
connectToNetwork(args, isStartup = false) {
const client = this;
// Get channel id for lobby before creating other channels for nicer ids
const lobbyChannelId = client.idChan++;
const network = this.networkFromConfig(args);
// Set network lobby channel id
network.getLobby().id = lobbyChannelId;
client.networks.push(network);
client.emit("network", {
networks: [network.getFilteredClone(this.lastActiveChannel, -1)],
});
if (!network.validate(client)) {
return;
}
network.createIrcFramework(client);
// TODO
// eslint-disable-next-line @typescript-eslint/no-misused-promises
events.forEach(async (plugin) => {
(await Promise.resolve().then(() => __importStar(require(`./plugins/irc-events/${plugin}`)))).default.apply(client, [
network.irc,
network,
]);
});
if (network.userDisconnected) {
network.getLobby().pushMessage(client, new msg_1.default({
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();
network.channels.forEach((channel) => channel.loadMessages(client, network));
}
}
generateToken(callback) {
crypto_1.default.randomBytes(64, (err, buf) => {
if (err) {
throw err;
}
callback(buf.toString("hex"));
});
}
calculateTokenHash(token) {
return crypto_1.default.createHash("sha512").update(token).digest("hex");
}
updateSession(token, ip, request) {
const client = this;
const agent = (0, ua_parser_js_1.default)(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] = lodash_1.default.assign(client.config.sessions[token], {
lastUse: Date.now(),
ip: ip,
agent: friendlyAgent,
});
client.save();
}
setPassword(hash, callback) {
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 = 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 === chan_1.ChanType.LOBBY) {
target.chan.pushMessage(this, new msg_1.default({
type: msg_1.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;
const connected = irc?.connected;
const emitFailureDisconnected = () => {
target.chan.pushMessage(this, new msg_1.default({
type: msg_1.MessageType.ERROR,
text: "You are not connected to the IRC network, unable to send your command.",
}));
};
const plugin = inputs_1.default.userInputs.get(cmd);
if (plugin) {
if (!connected && !plugin.allowDisconnected) {
emitFailureDisconnected();
return;
}
plugin.input.apply(client, [target.network, target.chan, cmd, args]);
return;
}
const extPlugin = inputs_1.default.pluginCommands.get(cmd);
if (extPlugin) {
if (!connected && !extPlugin.allowDisconnected) {
emitFailureDisconnected();
return;
}
extPlugin.input(new publicClient_1.default(client, extPlugin.packageInfo), { network: target.network, chan: target.chan }, cmd, args);
return;
}
if (!connected) {
emitFailureDisconnected();
return;
}
// TODO: fix
irc.raw(text);
}
compileCustomHighlights() {
function compileHighlightRegex(customHighlightString) {
if (typeof customHighlightString !== "string") {
return null;
}
// Ensure we don't have empty strings in the list of highlights
const highlightsTokens = customHighlightString
.split(",")
.map((highlight) => (0, escapeRegExp_1.default)(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 = [];
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 (irc_1.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).catch((e) => log_1.default.error(e));
}
}
async search(query) {
if (!this.messageProvider?.isEnabled) {
return {
...query,
results: [],
};
}
return this.messageProvider.search(query);
}
open(socketId, target) {
// 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] ||
{};
// 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) {
const order = data.order;
if (!lodash_1.default.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 = lodash_1.default.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 === chan_1.ChanType.LOBBY) {
return -1;
}
else if (b.type === chan_1.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) {
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, chan) {
const client = this;
network.channels = lodash_1.default.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) {
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().catch((e) => log_1.default.error(e));
}
}
clientAttach(socketId, token) {
const client = this;
if (client.awayMessage && lodash_1.default.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) {
const client = this;
delete this.attachedClients[socketId];
if (client.awayMessage && lodash_1.default.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, subscription, noSave = false) {
if (!lodash_1.default.isPlainObject(subscription) ||
!lodash_1.default.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) {
this.config.sessions[token].pushSubscription = undefined;
this.save();
}
save = lodash_1.default.debounce(function SaveClient() {
if (config_1.default.values.public) {
return;
}
const client = this;
client.manager.saveUser(client);
}, 5000, { maxWait: 20000 });
}
exports.default = Client;