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:
Max Leiter 2022-06-18 16:25:21 -08:00 committed by GitHub
commit dd05ee3a65
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
349 changed files with 13388 additions and 8803 deletions

864
server/client.ts Normal file
View 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
View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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];

View 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;

View 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;

View 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;

View 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
View 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
View 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
View 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
View file

@ -0,0 +1 @@
import "./types";

8
server/index.ts Executable file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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,
};

View 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
View 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}`);
});
}

View 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`),
};
}

View 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",
})
);
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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;
}
}
});
}
};

View 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();
}
}
};

View 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);
});
});
};

View 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);
}
};

View 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}
)
);
};

View 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,
});
});
};

View 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);
}
});
};

View 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);
}
});
};

View 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);
});
};

View 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,
});
});
};

View 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);
}
});
};

View 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;
}

View 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,
});
}
}
};

View 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);
}
}
}
};

View 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,
});
}
});
};

View 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,
});
}
}
};

View 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);
}
});
};

View 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,
});
});
};

View 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,
});
});
});
};

View 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);
}
});
};

View 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;
}
});
};

View 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);
});
};

View 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);
});
};

View 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
);
});
};

View 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,
});
});
};

View 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);
}
};

View 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;
}

View 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;
}

View 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 | [];
}

View 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;
}

View 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,
})
);
}
}

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

27
server/tsconfig.json Normal file
View 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
View file

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

430
server/types/modules/irc-framework.d.ts vendored Normal file
View 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
View 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 {}