hardlounge/src/client.js

735 lines
16 KiB
JavaScript
Raw Normal View History

"use strict";
2018-01-11 11:33:36 +00:00
const _ = require("lodash");
2018-06-15 20:31:06 +00:00
const log = require("./log");
2018-03-02 18:28:54 +00:00
const colors = require("chalk");
2018-01-11 11:33:36 +00:00
const Chan = require("./models/chan");
const crypto = require("crypto");
const Msg = require("./models/msg");
const Network = require("./models/network");
const Helper = require("./helper");
const UAParser = require("ua-parser-js");
2020-02-24 13:35:15 +00:00
const {v4: uuidv4} = require("uuid");
2019-01-16 09:23:12 +00:00
const escapeRegExp = require("lodash/escapeRegExp");
const constants = require("../client/js/constants.js");
2019-07-02 16:02:02 +00:00
const inputs = require("./plugins/inputs");
const PublicClient = require("./plugins/packages/publicClient");
2014-09-13 21:29:45 +00:00
const MessageStorage = require("./plugins/messageStorage/sqlite");
const TextFileMessageStorage = require("./plugins/messageStorage/text");
2014-09-13 21:29:45 +00:00
module.exports = Client;
2018-01-11 11:33:36 +00:00
const events = [
"away",
2020-02-19 10:18:47 +00:00
"cap",
"connection",
"unhandled",
2014-09-13 21:29:45 +00:00
"ctcp",
2017-09-19 15:22:50 +00:00
"chghost",
2014-09-13 21:29:45 +00:00
"error",
2016-02-12 11:24:13 +00:00
"invite",
2014-09-13 21:29:45 +00:00
"join",
"kick",
"mode",
2019-04-14 11:44:44 +00:00
"modelist",
2014-09-13 21:29:45 +00:00
"motd",
"message",
"names",
"nick",
"part",
"quit",
"topic",
"welcome",
"list",
"whois",
2014-09-13 21:29:45 +00:00
];
function Client(manager, name, config = {}) {
2014-09-13 21:29:45 +00:00
_.merge(this, {
awayMessage: "",
lastActiveChannel: -1,
attachedClients: {},
2014-09-13 21:29:45 +00:00
config: config,
2018-04-10 13:15:44 +00:00
id: uuidv4(),
idChan: 1,
idMsg: 1,
2014-09-16 19:47:01 +00:00
name: name,
2014-09-13 21:29:45 +00:00
networks: [],
manager: manager,
messageStorage: [],
2019-01-16 09:23:12 +00:00
highlightRegex: null,
2014-09-13 21:29:45 +00:00
});
2016-05-31 21:28:31 +00:00
2018-01-11 11:33:36 +00:00
const client = this;
2017-11-28 17:56:53 +00:00
2019-12-15 15:29:39 +00:00
client.config.log = Boolean(client.config.log);
client.config.password = String(client.config.password);
if (!Helper.config.public && client.config.log) {
if (Helper.config.messageStorage.includes("sqlite")) {
client.messageStorage.push(new MessageStorage(client));
}
if (Helper.config.messageStorage.includes("text")) {
client.messageStorage.push(new TextFileMessageStorage(client));
}
2017-11-28 17:56:53 +00:00
for (const messageStorage of client.messageStorage) {
messageStorage.enable();
2017-11-28 17:56:53 +00:00
}
}
if (typeof client.config.sessions !== "object") {
client.config.sessions = {};
}
2019-01-16 09:23:12 +00:00
if (typeof client.config.clientSettings !== "object") {
client.config.clientSettings = {};
}
if (typeof client.config.browser !== "object") {
client.config.browser = {};
}
// TODO: Backwards compatibility with older versions, remove in a future release?
if (client.config.awayMessage) {
client.config.clientSettings.awayMessage = client.config.awayMessage;
delete client.config.awayMessage;
}
if (client.config.clientSettings.awayMessage) {
client.awayMessage = client.config.clientSettings.awayMessage;
}
2019-01-16 09:23:12 +00:00
client.compileCustomHighlights();
2017-07-10 19:47:03 +00:00
_.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;
}
2014-09-13 21:29:45 +00:00
}
Client.prototype.createChannel = function(attr) {
const chan = new Chan(attr);
chan.id = this.idChan++;
return chan;
};
2014-09-13 21:29:45 +00:00
Client.prototype.emit = function(event, data) {
2019-12-18 09:22:11 +00:00
if (this.manager !== null) {
this.manager.sockets.in(this.id).emit(event, data);
2014-09-13 21:29:45 +00:00
}
2014-09-13 17:18:42 +00:00
};
2014-09-13 21:29:45 +00:00
2016-10-09 08:54:44 +00:00
Client.prototype.find = function(channelId) {
2018-01-11 11:33:36 +00:00
let network = null;
let chan = null;
2018-01-11 11:33:36 +00:00
for (const i in this.networks) {
const n = this.networks[i];
2016-10-09 08:54:44 +00:00
chan = _.find(n.channels, {id: channelId});
2014-09-13 21:29:45 +00:00
if (chan) {
network = n;
break;
}
}
2014-09-13 21:29:45 +00:00
if (network && chan) {
return {network, chan};
2014-09-13 21:29:45 +00:00
}
2016-10-09 08:54:44 +00:00
return false;
2014-09-13 21:29:45 +00:00
};
Client.prototype.connect = function(args, isStartup = false) {
2018-01-11 11:33:36 +00:00
const client = this;
let channels = [];
// Get channel id for lobby before creating other channels for nicer ids
const lobbyChannelId = client.idChan++;
2016-06-19 17:12:42 +00:00
if (args.channels) {
2018-01-11 11:33:36 +00:00
let badName = false;
2016-06-19 17:12:42 +00:00
args.channels.forEach((chan) => {
2016-06-19 17:12:42 +00:00
if (!chan.name) {
badName = true;
return;
}
2019-07-17 09:33:59 +00:00
channels.push(
client.createChannel({
name: chan.name,
key: chan.key || "",
type: chan.type,
})
);
});
2016-06-19 17:12:42 +00:00
if (badName && client.name) {
2019-07-17 09:33:59 +00:00
log.warn(
"User '" +
client.name +
"' on network '" +
args.name +
"' has an invalid channel which has been ignored"
);
2016-06-19 17:12:42 +00:00
}
2019-07-17 09:33:59 +00:00
// `join` is kept for backwards compatibility when updating from versions <2.0
// also used by the "connect" window
2016-06-19 17:12:42 +00:00
} else if (args.join) {
channels = args.join
2016-10-09 08:54:44 +00:00
.replace(/,/g, " ")
2016-06-19 17:12:42 +00:00
.split(/\s+/g)
.map((chan) => {
if (!chan.match(/^[#&!+]/)) {
chan = `#${chan}`;
}
return client.createChannel({
name: chan,
2016-06-19 17:12:42 +00:00
});
});
}
2018-01-11 11:33:36 +00:00
const network = new Network({
2017-11-28 17:25:15 +00:00
uuid: args.uuid,
2019-07-17 09:33:59 +00:00
name: String(
args.name || (Helper.config.displayNetwork ? "" : Helper.config.defaults.name) || ""
),
host: String(args.host || ""),
port: parseInt(args.port, 10),
tls: !!args.tls,
userDisconnected: !!args.userDisconnected,
2018-03-05 18:11:41 +00:00
rejectUnauthorized: !!args.rejectUnauthorized,
password: String(args.password || ""),
nick: String(args.nick || ""),
username: String(args.username || ""),
realname: String(args.realname || ""),
commands: args.commands || [],
channels: channels,
ignoreList: args.ignoreList ? args.ignoreList : [],
});
// 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)) {
2016-02-21 12:02:35 +00:00
return;
}
network.createIrcFramework(client);
events.forEach((plugin) => {
2019-07-17 09:33:59 +00:00
require(`./plugins/irc-events/${plugin}`).apply(client, [network.irc, network]);
});
if (network.userDisconnected) {
2019-07-17 09:33:59 +00:00
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) {
network.irc.connect();
}
if (!isStartup) {
client.save();
channels.forEach((channel) => channel.loadMessages(client, network));
}
2014-09-13 21:29:45 +00:00
};
Client.prototype.generateToken = function(callback) {
crypto.randomBytes(64, (err, buf) => {
2016-10-09 08:54:44 +00:00
if (err) {
throw err;
}
callback(buf.toString("hex"));
2016-05-31 21:28:31 +00:00
});
};
Client.prototype.calculateTokenHash = function(token) {
2019-07-17 09:33:59 +00:00
return crypto
.createHash("sha512")
.update(token)
.digest("hex");
};
Client.prototype.updateSession = function(token, ip, request) {
const client = this;
const agent = UAParser(request.headers["user-agent"] || "");
let friendlyAgent = "";
2017-08-13 18:37:12 +00:00
if (agent.browser.name) {
friendlyAgent = `${agent.browser.name} ${agent.browser.major}`;
} else {
friendlyAgent = "Unknown browser";
}
2017-08-13 18:37:12 +00:00
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();
};
2016-05-31 21:28:31 +00:00
Client.prototype.setPassword = function(hash, callback) {
2018-01-11 11:33:36 +00:00
const client = this;
2016-05-31 21:28:31 +00:00
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);
2019-07-17 09:33:59 +00:00
}
return callback(true);
});
};
2014-09-13 21:29:45 +00:00
Client.prototype.input = function(data) {
2018-01-11 11:33:36 +00:00
const client = this;
data.text.split("\n").forEach((line) => {
data.text = line;
client.inputLine(data);
});
};
Client.prototype.inputLine = function(data) {
2018-01-11 11:33:36 +00:00
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;
2016-03-06 09:24:56 +00:00
2018-01-11 11:33:36 +00:00
let text = data.text;
2016-03-06 09:24:56 +00:00
// 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.Type.LOBBY) {
2019-07-17 09:33:59 +00:00
target.chan.pushMessage(
this,
new Msg({
type: Msg.Type.ERROR,
text: "Messages can not be sent to lobbies.",
})
);
return;
}
2016-03-06 09:24:56 +00:00
text = "say " + text.replace(/^\//, "");
} else {
text = text.substr(1);
2014-09-13 21:29:45 +00:00
}
2016-03-06 09:24:56 +00:00
2018-01-11 11:33:36 +00:00
const args = text.split(" ");
const cmd = args.shift().toLowerCase();
2016-03-06 09:24:56 +00:00
2018-01-11 11:33:36 +00:00
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 (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;
2019-07-17 09:33:59 +00:00
plugin.input(
new PublicClient(client, plugin.packageInfo),
2019-07-17 09:33:59 +00:00
{network: target.network, chan: target.chan},
cmd,
args
);
}
} else if (connected) {
irc.raw(text);
}
if (!connected) {
2019-07-17 09:33:59 +00:00
target.chan.pushMessage(
this,
new Msg({
type: Msg.Type.ERROR,
text: "You are not connected to the IRC network, unable to send your command.",
})
);
2016-03-06 09:24:56 +00:00
}
2014-09-13 21:29:45 +00:00
};
2019-01-16 09:23:12 +00:00
Client.prototype.compileCustomHighlights = function() {
const client = this;
if (typeof client.config.clientSettings.highlights !== "string") {
client.highlightRegex = null;
return;
}
// Ensure we don't have empty string in the list of highlights
// otherwise, users get notifications for everything
const highlightsTokens = client.config.clientSettings.highlights
.split(",")
.map((highlight) => escapeRegExp(highlight.trim()))
.filter((highlight) => highlight.length > 0);
if (highlightsTokens.length === 0) {
client.highlightRegex = null;
return;
}
2019-07-17 09:33:59 +00:00
client.highlightRegex = new RegExp(
`(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join("|")})(?:$|[ .,+!?|/:<>(){}'"-])`,
"i"
);
2019-01-16 09:23:12 +00:00
};
2014-09-13 21:29:45 +00:00
Client.prototype.more = function(data) {
const client = this;
const target = client.find(data.target);
2014-09-13 21:29:45 +00:00
if (!target) {
return null;
2014-09-13 21:29:45 +00:00
}
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 (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 {
2014-09-13 21:29:45 +00:00
chan: chan.id,
messages: messages,
totalMessages: chan.messages.length,
};
2014-09-13 21:29:45 +00:00
};
Client.prototype.clearHistory = function(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);
}
};
Client.prototype.open = function(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;
}
target = this.find(target);
if (!target) {
return;
}
target.chan.unread = 0;
2017-09-03 15:57:07 +00:00
target.chan.highlight = 0;
if (target.chan.messages.length > 0) {
target.chan.firstUnread = target.chan.messages[target.chan.messages.length - 1].id;
}
attachedClient.openChannel = target.chan.id;
this.lastActiveChannel = target.chan.id;
this.emit("open", target.chan.id);
};
2014-09-24 19:42:36 +00:00
Client.prototype.sort = function(data) {
const order = data.order;
2014-09-24 19:42:36 +00:00
if (!_.isArray(order)) {
return;
}
2014-09-24 19:42:36 +00:00
switch (data.type) {
2019-07-17 09:33:59 +00:00
case "networks":
this.networks.sort((a, b) => order.indexOf(a.uuid) - order.indexOf(b.uuid));
2019-07-17 09:33:59 +00:00
// Sync order to connected clients
this.emit("sync_sort", {
order: this.networks.map((obj) => obj.uuid),
type: data.type,
});
2014-09-24 19:42:36 +00:00
2019-07-17 09:33:59 +00:00
break;
2019-07-17 09:33:59 +00:00
case "channels": {
const network = _.find(this.networks, {uuid: data.target});
2019-07-17 09:33:59 +00:00
if (!network) {
return;
}
2019-07-17 09:33:59 +00:00
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.Type.LOBBY) {
return -1;
} else if (b.type === Chan.Type.LOBBY) {
return 1;
}
2019-07-17 09:33:59 +00:00
return order.indexOf(a.id) - order.indexOf(b.id);
});
2019-07-17 09:33:59 +00:00
// Sync order to connected clients
this.emit("sync_sort", {
order: network.channels.map((obj) => obj.id),
type: data.type,
target: network.uuid,
});
break;
}
2018-01-11 11:33:36 +00:00
}
this.save();
2014-09-24 19:42:36 +00:00
};
Client.prototype.names = function(data) {
2018-01-11 11:33:36 +00:00
const client = this;
const target = client.find(data.target);
if (!target) {
return;
}
client.emit("names", {
id: target.chan.id,
2017-11-16 20:32:03 +00:00
users: target.chan.getSortedUsers(target.network.irc),
});
};
2017-08-30 17:26:45 +00:00
Client.prototype.quit = function(signOut) {
2019-12-18 09:22:11 +00:00
const sockets = this.manager.sockets.sockets;
const room = sockets.adapter.rooms[this.id];
if (room && room.sockets) {
for (const user in room.sockets) {
const socket = sockets.connected[user];
if (socket) {
2017-08-30 17:26:45 +00:00
if (signOut) {
socket.emit("sign-out");
}
socket.disconnect();
}
2014-09-29 15:49:38 +00:00
}
}
this.networks.forEach((network) => {
network.quit(Helper.config.leaveMessage);
network.destroy();
2014-09-13 21:29:45 +00:00
});
for (const messageStorage of this.messageStorage) {
messageStorage.close();
}
2014-09-13 17:18:42 +00:00
};
2014-10-11 20:44:56 +00:00
2017-07-10 19:47:03 +00:00
Client.prototype.clientAttach = function(socketId, token) {
2018-01-11 11:33:36 +00:00
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};
};
Client.prototype.clientDetach = function(socketId) {
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);
}
});
}
};
2017-07-10 19:47:03 +00:00
Client.prototype.registerPushSubscription = function(session, subscription, noSave) {
2019-07-17 09:33:59 +00:00
if (
!_.isPlainObject(subscription) ||
!_.isPlainObject(subscription.keys) ||
typeof subscription.endpoint !== "string" ||
!/^https?:\/\//.test(subscription.endpoint) ||
typeof subscription.keys.p256dh !== "string" ||
typeof subscription.keys.auth !== "string"
) {
2017-07-10 19:47:03 +00:00
session.pushSubscription = null;
return;
}
const data = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
2017-07-10 19:47:03 +00:00
};
session.pushSubscription = data;
if (!noSave) {
this.save();
2017-07-10 19:47:03 +00:00
}
return data;
};
Client.prototype.unregisterPushSubscription = function(token) {
this.config.sessions[token].pushSubscription = null;
this.save();
2017-07-10 19:47:03 +00:00
};
2019-07-17 09:33:59 +00:00
Client.prototype.save = _.debounce(
function SaveClient() {
if (Helper.config.public) {
return;
}
2014-11-09 16:01:22 +00:00
2019-07-17 09:33:59 +00:00
const client = this;
client.manager.saveUser(client);
2019-07-17 09:33:59 +00:00
},
5000,
{maxWait: 20000}
2019-07-17 09:33:59 +00:00
);