5738642d44
- Rename a forgotten `lounge-user` - Generate nick fallbacks when already in use by appending 0-9 instead of 10-98 (?!). - Generate nick fallbacks when invalid similarly to our config default instead of random string. This is to make it less confusing when fallback gets used.
642 lines
14 KiB
JavaScript
642 lines
14 KiB
JavaScript
"use strict";
|
|
|
|
const _ = require("lodash");
|
|
const colors = require("chalk");
|
|
const pkg = require("../package.json");
|
|
const Chan = require("./models/chan");
|
|
const crypto = require("crypto");
|
|
const Msg = require("./models/msg");
|
|
const Network = require("./models/network");
|
|
const ircFramework = require("irc-framework");
|
|
const Helper = require("./helper");
|
|
const UAParser = require("ua-parser-js");
|
|
const MessageStorage = require("./plugins/sqlite");
|
|
|
|
module.exports = Client;
|
|
|
|
let id = 0;
|
|
const events = [
|
|
"away",
|
|
"connection",
|
|
"unhandled",
|
|
"banlist",
|
|
"ctcp",
|
|
"chghost",
|
|
"error",
|
|
"invite",
|
|
"join",
|
|
"kick",
|
|
"mode",
|
|
"motd",
|
|
"message",
|
|
"names",
|
|
"nick",
|
|
"part",
|
|
"quit",
|
|
"topic",
|
|
"welcome",
|
|
"list",
|
|
"whois",
|
|
];
|
|
const inputs = [
|
|
"ban",
|
|
"ctcp",
|
|
"msg",
|
|
"part",
|
|
"rejoin",
|
|
"action",
|
|
"away",
|
|
"connect",
|
|
"disconnect",
|
|
"invite",
|
|
"kick",
|
|
"mode",
|
|
"nick",
|
|
"notice",
|
|
"query",
|
|
"quit",
|
|
"raw",
|
|
"topic",
|
|
"list",
|
|
"whois",
|
|
].reduce(function(plugins, name) {
|
|
const plugin = require(`./plugins/inputs/${name}`);
|
|
plugin.commands.forEach((command) => plugins[command] = plugin);
|
|
return plugins;
|
|
}, {});
|
|
|
|
function Client(manager, name, config = {}) {
|
|
_.merge(this, {
|
|
awayMessage: config.awayMessage || "",
|
|
lastActiveChannel: -1,
|
|
attachedClients: {},
|
|
config: config,
|
|
id: id++,
|
|
name: name,
|
|
networks: [],
|
|
sockets: manager.sockets,
|
|
manager: manager,
|
|
});
|
|
|
|
const client = this;
|
|
let delay = 0;
|
|
|
|
if (!Helper.config.public) {
|
|
client.messageStorage = new MessageStorage();
|
|
|
|
if (client.config.log && Helper.config.messageStorage.includes("sqlite")) {
|
|
client.messageStorage.enable(client.name);
|
|
}
|
|
}
|
|
|
|
(client.config.networks || []).forEach((n) => {
|
|
setTimeout(function() {
|
|
client.connect(n);
|
|
}, delay);
|
|
delay += 1000;
|
|
});
|
|
|
|
if (typeof client.config.sessions !== "object") {
|
|
client.config.sessions = {};
|
|
}
|
|
|
|
_.forOwn(client.config.sessions, (session) => {
|
|
if (session.pushSubscription) {
|
|
this.registerPushSubscription(session, session.pushSubscription, true);
|
|
}
|
|
});
|
|
|
|
if (client.name) {
|
|
log.info(`User ${colors.bold(client.name)} loaded`);
|
|
}
|
|
}
|
|
|
|
Client.prototype.emit = function(event, data) {
|
|
if (this.sockets !== null) {
|
|
this.sockets.in(this.id).emit(event, data);
|
|
}
|
|
};
|
|
|
|
Client.prototype.find = function(channelId) {
|
|
let network = null;
|
|
let chan = null;
|
|
|
|
for (const i in this.networks) {
|
|
const n = this.networks[i];
|
|
chan = _.find(n.channels, {id: channelId});
|
|
|
|
if (chan) {
|
|
network = n;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (network && chan) {
|
|
return {network, chan};
|
|
}
|
|
|
|
return false;
|
|
};
|
|
|
|
Client.prototype.connect = function(args) {
|
|
const client = this;
|
|
const nick = args.nick || "thelounge";
|
|
let webirc = null;
|
|
let channels = [];
|
|
|
|
if (args.channels) {
|
|
let badName = false;
|
|
|
|
args.channels.forEach((chan) => {
|
|
if (!chan.name) {
|
|
badName = true;
|
|
return;
|
|
}
|
|
|
|
channels.push(new Chan({
|
|
name: chan.name,
|
|
key: chan.key || "",
|
|
type: chan.type,
|
|
}));
|
|
});
|
|
|
|
if (badName && client.name) {
|
|
log.warn("User '" + client.name + "' on network '" + 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(function(chan) {
|
|
return new Chan({
|
|
name: chan,
|
|
});
|
|
});
|
|
}
|
|
|
|
args.ip = args.ip || (client.config && client.config.ip) || client.ip;
|
|
args.hostname = args.hostname || (client.config && client.config.hostname) || client.hostname;
|
|
|
|
const network = new Network({
|
|
uuid: args.uuid,
|
|
name: args.name || (Helper.config.displayNetwork ? "" : Helper.config.defaults.name) || "",
|
|
host: args.host || "",
|
|
port: parseInt(args.port, 10) || (args.tls ? 6697 : 6667),
|
|
tls: !!args.tls,
|
|
rejectUnauthorized: !!args.rejectUnauthorized,
|
|
password: args.password,
|
|
username: args.username || nick.replace(/[^a-zA-Z0-9]/g, ""),
|
|
realname: args.realname || "The Lounge User",
|
|
commands: args.commands,
|
|
ip: args.ip,
|
|
hostname: args.hostname,
|
|
channels: channels,
|
|
});
|
|
network.setNick(nick);
|
|
|
|
client.networks.push(network);
|
|
client.emit("network", {
|
|
networks: [network.getFilteredClone(this.lastActiveChannel, -1)],
|
|
});
|
|
|
|
if (Helper.config.lockNetwork) {
|
|
// This check is needed to prevent invalid user configurations
|
|
if (!Helper.config.public && args.host && args.host.length > 0 && args.host !== Helper.config.defaults.host) {
|
|
network.channels[0].pushMessage(client, new Msg({
|
|
type: Msg.Type.ERROR,
|
|
text: "Hostname you specified is not allowed.",
|
|
}), true);
|
|
return;
|
|
}
|
|
|
|
network.host = Helper.config.defaults.host;
|
|
network.port = Helper.config.defaults.port;
|
|
network.tls = Helper.config.defaults.tls;
|
|
network.rejectUnauthorized = Helper.config.defaults.rejectUnauthorized;
|
|
}
|
|
|
|
if (network.host.length === 0) {
|
|
network.channels[0].pushMessage(client, new Msg({
|
|
type: Msg.Type.ERROR,
|
|
text: "You must specify a hostname to connect.",
|
|
}), true);
|
|
return;
|
|
}
|
|
|
|
if (Helper.config.webirc && network.host in Helper.config.webirc) {
|
|
if (!args.hostname) {
|
|
args.hostname = args.ip;
|
|
}
|
|
|
|
if (args.ip) {
|
|
if (Helper.config.webirc[network.host] instanceof Function) {
|
|
webirc = Helper.config.webirc[network.host](client, args);
|
|
} else {
|
|
webirc = {
|
|
password: Helper.config.webirc[network.host],
|
|
username: pkg.name,
|
|
address: args.ip,
|
|
hostname: args.hostname,
|
|
};
|
|
}
|
|
} else {
|
|
log.warn("Cannot find a valid WEBIRC configuration for " + nick
|
|
+ "!" + network.username + "@" + network.host);
|
|
}
|
|
}
|
|
|
|
network.irc = new ircFramework.Client({
|
|
version: false, // We handle it ourselves
|
|
host: network.host,
|
|
port: network.port,
|
|
nick: nick,
|
|
username: Helper.config.useHexIp ? Helper.ip2hex(args.ip) : network.username,
|
|
gecos: network.realname,
|
|
password: network.password,
|
|
tls: network.tls,
|
|
outgoing_addr: Helper.config.bind,
|
|
rejectUnauthorized: network.rejectUnauthorized,
|
|
enable_chghost: true,
|
|
enable_echomessage: true,
|
|
auto_reconnect: true,
|
|
auto_reconnect_wait: 10000 + Math.floor(Math.random() * 1000), // If multiple users are connected to the same network, randomize their reconnections a little
|
|
auto_reconnect_max_retries: 360, // At least one hour (plus timeouts) worth of reconnections
|
|
webirc: webirc,
|
|
});
|
|
|
|
network.irc.requestCap([
|
|
"znc.in/self-message", // Legacy echo-message for ZNC
|
|
]);
|
|
|
|
// Request only new messages from ZNC if we have sqlite logging enabled
|
|
// See http://wiki.znc.in/Playback
|
|
if (client.config.log && Helper.config.messageStorage.includes("sqlite")) {
|
|
network.irc.requestCap("znc.in/playback");
|
|
}
|
|
|
|
events.forEach((plugin) => {
|
|
require(`./plugins/irc-events/${plugin}`).apply(client, [
|
|
network.irc,
|
|
network,
|
|
]);
|
|
});
|
|
|
|
network.irc.connect();
|
|
|
|
client.save();
|
|
|
|
channels.forEach((channel) => channel.loadMessages(client, network));
|
|
};
|
|
|
|
Client.prototype.generateToken = function(callback) {
|
|
crypto.randomBytes(64, (err, buf) => {
|
|
if (err) {
|
|
throw err;
|
|
}
|
|
|
|
callback(buf.toString("hex"));
|
|
});
|
|
};
|
|
|
|
Client.prototype.calculateTokenHash = function(token) {
|
|
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 = "";
|
|
|
|
if (agent.browser.name) {
|
|
friendlyAgent = `${agent.browser.name} ${agent.browser.major}`;
|
|
} else {
|
|
friendlyAgent = "Unknown browser";
|
|
}
|
|
|
|
if (agent.os.name) {
|
|
friendlyAgent += ` on ${agent.os.name} ${agent.os.version}`;
|
|
}
|
|
|
|
client.config.sessions[token] = _.assign(client.config.sessions[token], {
|
|
lastUse: Date.now(),
|
|
ip: ip,
|
|
agent: friendlyAgent,
|
|
});
|
|
|
|
client.manager.updateUser(client.name, {
|
|
sessions: client.config.sessions,
|
|
});
|
|
};
|
|
|
|
Client.prototype.setPassword = function(hash, callback) {
|
|
const client = this;
|
|
|
|
client.manager.updateUser(client.name, {
|
|
password: hash,
|
|
}, function(err) {
|
|
if (err) {
|
|
return callback(false);
|
|
}
|
|
|
|
client.config.password = hash;
|
|
return callback(true);
|
|
});
|
|
};
|
|
|
|
Client.prototype.input = function(data) {
|
|
const client = this;
|
|
data.text.split("\n").forEach((line) => {
|
|
data.text = line;
|
|
client.inputLine(data);
|
|
});
|
|
};
|
|
|
|
Client.prototype.inputLine = function(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.Type.LOBBY) {
|
|
target.chan.pushMessage(this, new Msg({
|
|
type: Msg.Type.ERROR,
|
|
text: "Messages can not be sent to lobbies.",
|
|
}));
|
|
return;
|
|
}
|
|
|
|
text = "say " + text.replace(/^\//, "");
|
|
} else {
|
|
text = text.substr(1);
|
|
}
|
|
|
|
const args = text.split(" ");
|
|
const cmd = args.shift().toLowerCase();
|
|
|
|
const irc = target.network.irc;
|
|
let connected = irc && irc.connection && irc.connection.connected;
|
|
|
|
if (cmd in inputs) {
|
|
const plugin = inputs[cmd];
|
|
|
|
if (connected || plugin.allowDisconnected) {
|
|
connected = true;
|
|
plugin.input.apply(client, [target.network, target.chan, cmd, args]);
|
|
}
|
|
} else if (connected) {
|
|
irc.raw(text);
|
|
}
|
|
|
|
if (!connected) {
|
|
target.chan.pushMessage(this, new Msg({
|
|
type: Msg.Type.ERROR,
|
|
text: "You are not connected to the IRC network, unable to send your command.",
|
|
}));
|
|
}
|
|
};
|
|
|
|
Client.prototype.more = function(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) {
|
|
messages = chan.messages.slice(Math.max(0, index - 100), index);
|
|
}
|
|
|
|
return {
|
|
chan: chan.id,
|
|
messages: messages,
|
|
};
|
|
};
|
|
|
|
Client.prototype.open = function(socketId, target) {
|
|
// Opening a window like settings
|
|
if (target === null) {
|
|
this.attachedClients[socketId].openChannel = -1;
|
|
return;
|
|
}
|
|
|
|
target = this.find(target);
|
|
|
|
if (!target) {
|
|
return;
|
|
}
|
|
|
|
target.chan.firstUnread = 0;
|
|
target.chan.unread = 0;
|
|
target.chan.highlight = 0;
|
|
|
|
this.attachedClients[socketId].openChannel = target.chan.id;
|
|
this.lastActiveChannel = target.chan.id;
|
|
|
|
this.emit("open", target.chan.id);
|
|
};
|
|
|
|
Client.prototype.sort = function(data) {
|
|
const order = data.order;
|
|
|
|
if (!_.isArray(order)) {
|
|
return;
|
|
}
|
|
|
|
switch (data.type) {
|
|
case "networks":
|
|
this.networks.sort((a, b) => order.indexOf(a.id) - order.indexOf(b.id));
|
|
|
|
// Sync order to connected clients
|
|
this.emit("sync_sort", {order: this.networks.map((obj) => obj.id), type: data.type, target: data.target});
|
|
|
|
break;
|
|
|
|
case "channels": {
|
|
const network = _.find(this.networks, {id: data.target});
|
|
|
|
if (!network) {
|
|
return;
|
|
}
|
|
|
|
network.channels.sort((a, b) => 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: data.target});
|
|
|
|
break;
|
|
}
|
|
}
|
|
|
|
this.save();
|
|
};
|
|
|
|
Client.prototype.names = function(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),
|
|
});
|
|
};
|
|
|
|
Client.prototype.quit = function(signOut) {
|
|
const sockets = this.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) {
|
|
if (signOut) {
|
|
socket.emit("sign-out");
|
|
}
|
|
|
|
socket.disconnect();
|
|
}
|
|
}
|
|
}
|
|
|
|
this.networks.forEach((network) => {
|
|
if (network.irc) {
|
|
network.irc.quit(Helper.config.leaveMessage);
|
|
}
|
|
|
|
network.destroy();
|
|
});
|
|
|
|
if (this.messageStorage) {
|
|
this.messageStorage.close();
|
|
}
|
|
};
|
|
|
|
Client.prototype.clientAttach = function(socketId, token) {
|
|
const client = this;
|
|
let save = false;
|
|
|
|
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};
|
|
|
|
// Update old networks to store ip and hostmask
|
|
client.networks.forEach((network) => {
|
|
if (!network.ip) {
|
|
save = true;
|
|
network.ip = (client.config && client.config.ip) || client.ip;
|
|
}
|
|
|
|
if (!network.hostname) {
|
|
const hostmask = (client.config && client.config.hostname) || client.hostname;
|
|
|
|
if (hostmask) {
|
|
save = true;
|
|
network.hostmask = hostmask;
|
|
}
|
|
}
|
|
});
|
|
|
|
if (save) {
|
|
client.save();
|
|
}
|
|
};
|
|
|
|
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);
|
|
}
|
|
});
|
|
}
|
|
};
|
|
|
|
Client.prototype.registerPushSubscription = function(session, subscription, noSave) {
|
|
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.manager.updateUser(this.name, {
|
|
sessions: this.config.sessions,
|
|
});
|
|
}
|
|
|
|
return data;
|
|
};
|
|
|
|
Client.prototype.unregisterPushSubscription = function(token) {
|
|
this.config.sessions[token].pushSubscription = null;
|
|
this.manager.updateUser(this.name, {
|
|
sessions: this.config.sessions,
|
|
});
|
|
};
|
|
|
|
Client.prototype.save = _.debounce(function SaveClient() {
|
|
if (Helper.config.public) {
|
|
return;
|
|
}
|
|
|
|
const client = this;
|
|
const json = {};
|
|
json.networks = this.networks.map((n) => n.export());
|
|
client.manager.updateUser(client.name, json);
|
|
}, 1000, {maxWait: 10000});
|