ts progress

This commit is contained in:
Max Leiter 2022-05-01 22:56:38 -07:00
parent 2e3d9a6265
commit 01d640db47
No known key found for this signature in database
GPG key ID: A3512F2F2F17EBDA
84 changed files with 3812 additions and 2396 deletions

81
.eslintrc.yml Normal file
View file

@ -0,0 +1,81 @@
---
root: true
parserOptions:
ecmaVersion: 2022
parser: "babel-eslint"
env:
es6: true
browser: true
mocha: true
node: true
rules:
block-scoped-var: error
curly: [error, all]
dot-notation: error
eqeqeq: error
handle-callback-err: error
no-alert: error
no-catch-shadow: error
no-control-regex: off
no-console: error
no-duplicate-imports: error
no-else-return: error
no-implicit-globals: error
no-restricted-globals:
- error
- event
- fdescribe
no-shadow: error
no-template-curly-in-string: error
no-unsafe-negation: error
no-useless-computed-key: error
no-useless-constructor: error
no-useless-return: error
no-use-before-define:
- error
- functions: false
no-var: error
object-shorthand:
- error
- methods
- avoidExplicitReturnArrows: true
padding-line-between-statements:
- error
- blankLine: always
prev:
- block
- block-like
next: "*"
- blankLine: always
prev: "*"
next:
- block
- block-like
prefer-const: error
prefer-rest-params: error
prefer-spread: error
spaced-comment: [error, always]
strict: off
yoda: error
vue/component-tags-order:
- error
- order:
- template
- style
- script
vue/no-mutating-props: off
vue/no-v-html: off
vue/require-default-prop: off
vue/v-slot-style: [error, longform]
vue/multi-word-component-names: off
plugins:
- vue
extends:
- eslint:recommended
- plugin:vue/recommended
- prettier

View file

@ -16,21 +16,30 @@
</div>
</template>
<script>
const constants = require("../js/constants");
import eventbus from "../js/eventbus";
<script lang="ts">
import constants from "@/js/constants";
import eventbus from "@/js/eventbus";
import Mousetrap from "mousetrap";
import throttle from "lodash/throttle";
import storage from "../js/localStorage";
import isIgnoredKeybind from "../js/helpers/isIgnoredKeybind";
import storage from "@/js/localStorage";
import isIgnoredKeybind from "@/js/helpers/isIgnoredKeybind";
import Sidebar from "./Sidebar.vue";
import ImageViewer from "./ImageViewer.vue";
import ContextMenu from "./ContextMenu.vue";
import ConfirmDialog from "./ConfirmDialog.vue";
import Mentions from "./Mentions.vue";
import Vue from "vue";
export default {
// This stops Vue from complaining about adding objects to the component context
declare module "vue/types/vue" {
interface Vue {
debouncedResize: () => void;
dayChangeTimeout: number;
}
}
export default Vue.extend({
name: "App",
components: {
Sidebar,
@ -40,7 +49,7 @@ export default {
Mentions,
},
computed: {
viewportClasses() {
viewportClasses(): Object {
return {
notified: this.$store.getters.highlightCount > 0,
"menu-open": this.$store.state.appLoaded && this.$store.state.sidebarOpen,
@ -58,7 +67,6 @@ export default {
Mousetrap.bind("alt+s", this.toggleSidebar);
Mousetrap.bind("alt+m", this.toggleMentions);
// Make a single throttled resize listener available to all components
this.debouncedResize = throttle(() => {
eventbus.emit("resize");
}, 100);
@ -75,19 +83,19 @@ export default {
this.dayChangeTimeout = setTimeout(emitDayChange, this.msUntilNextDay());
},
beforeDestroy() {
Mousetrap.unbind("esc", this.escapeKey);
Mousetrap.unbind("alt+u", this.toggleUserList);
Mousetrap.unbind("alt+s", this.toggleSidebar);
Mousetrap.unbind("alt+m", this.toggleMentions);
Mousetrap.unbind("esc");
Mousetrap.unbind("alt+u");
Mousetrap.unbind("alt+s");
Mousetrap.unbind("alt+m");
window.removeEventListener("resize", this.debouncedResize);
clearTimeout(this.dayChangeTimeout);
},
methods: {
escapeKey() {
escapeKey(): void {
eventbus.emit("escapekey");
},
toggleSidebar(e) {
toggleSidebar(e): boolean {
if (isIgnoredKeybind(e)) {
return true;
}
@ -96,7 +104,7 @@ export default {
return false;
},
toggleUserList(e) {
toggleUserList(e): boolean {
if (isIgnoredKeybind(e)) {
return true;
}
@ -105,19 +113,23 @@ export default {
return false;
},
toggleMentions() {
toggleMentions(): void {
if (this.$store.state.networks.length !== 0) {
eventbus.emit("mentions:toggle");
}
},
msUntilNextDay() {
msUntilNextDay(): number {
// Compute how many milliseconds are remaining until the next day starts
const today = new Date();
const tommorow = new Date(today.getFullYear(), today.getMonth(), today.getDate() + 1);
const tommorow = new Date(
today.getFullYear(),
today.getMonth(),
today.getDate() + 1
).getTime();
return tommorow - today;
return tommorow - today.getTime();
},
prepareOpenStates() {
prepareOpenStates(): void {
const viewportWidth = window.innerWidth;
let isUserlistOpen = storage.get("thelounge.state.userlist");
@ -137,5 +149,5 @@ export default {
this.$store.commit("userlistOpen", isUserlistOpen === "true");
},
},
};
});
</script>

View file

@ -28,8 +28,7 @@ const timeFormats = {
msg12hWithSeconds: "hh:mm:ss A",
};
// This file is required by server, can't use es6 export
module.exports = {
export default {
colorCodeMap,
commands: [],
condensedTypes,

View file

@ -176,7 +176,7 @@ export function generateChannelContextMenu($root, channel, network) {
query: "conversation",
};
// We don't allow the muting of Chan.Type.SPECIAL channels
// We don't allow the muting of ChanType.SPECIAL channels
const mutableChanTypes = Object.keys(humanFriendlyChanTypeMap);
if (mutableChanTypes.includes(channel.type)) {

View file

@ -11,14 +11,14 @@
// https://www.chromium.org/for-testers/bug-reporting-guidelines/uncaught-securityerror-failed-to-read-the-localstorage-property-from-window-access-is-denied-for-this-document
export default {
set(key, value) {
set(key: string, value: string) {
try {
window.localStorage.setItem(key, value);
} catch (e) {
//
}
},
get(key) {
get(key: string) {
try {
return window.localStorage.getItem(key);
} catch (e) {
@ -26,7 +26,7 @@ export default {
return null;
}
},
remove(key) {
remove(key: string) {
try {
window.localStorage.removeItem(key);
} catch (e) {

View file

@ -15,9 +15,11 @@ import "./socket-events";
import "./webpush";
import "./keybinds";
import type {Channel} from "@/backend/models/channel";
const favicon = document.getElementById("favicon");
const faviconNormal = favicon.getAttribute("href");
const faviconAlerted = favicon.dataset.other;
const faviconNormal = favicon?.getAttribute("href") || "";
const faviconAlerted = favicon?.dataset.other || "";
new Vue({
el: "#viewport",
@ -26,10 +28,10 @@ new Vue({
socket.open();
},
methods: {
switchToChannel(channel) {
switchToChannel(channel: Channel) {
navigate("RoutedChat", {id: channel.id});
},
closeChannel(channel) {
closeChannel(channel: Channel) {
if (channel.type === "lobby") {
eventbus.emit(
"confirm-dialog",
@ -38,7 +40,7 @@ new Vue({
text: `Are you sure you want to quit and remove ${channel.name}? This cannot be undone.`,
button: "Remove network",
},
(result) => {
(result: boolean) => {
if (!result) {
return;
}
@ -75,7 +77,7 @@ store.watch(
(state) => state.sidebarOpen,
(sidebarOpen) => {
if (window.innerWidth > constants.mobileViewportPixels) {
storage.set("thelounge.state.sidebar", sidebarOpen);
storage.set("thelounge.state.sidebar", sidebarOpen.toString());
eventbus.emit("resize");
}
}
@ -84,7 +86,7 @@ store.watch(
store.watch(
(state) => state.userlistOpen,
(userlistOpen) => {
storage.set("thelounge.state.userlist", userlistOpen);
storage.set("thelounge.state.userlist", userlistOpen.toString());
eventbus.emit("resize");
}
);
@ -100,13 +102,15 @@ store.watch(
store.watch(
(_, getters) => getters.highlightCount,
(highlightCount) => {
favicon.setAttribute("href", highlightCount > 0 ? faviconAlerted : faviconNormal);
favicon?.setAttribute("href", highlightCount > 0 ? faviconAlerted : faviconNormal);
if (navigator.setAppBadge) {
// TODO: investigate types
const nav = navigate as any;
if (nav.setAppBadge) {
if (highlightCount > 0) {
navigator.setAppBadge(highlightCount);
nav.setAppBadge(highlightCount);
} else {
navigator.clearAppBadge();
nav.clearAppBadge();
}
}
}

28
client/tsconfig.json Normal file
View file

@ -0,0 +1,28 @@
{
"compilerOptions": {
// https://v2.vuejs.org/v2/guide/typescript.html?redirect=true#Recommended-Configuration
// this aligns with Vue's browser support
"target": "es5",
// this enables stricter inference for data properties on `this`
"strict": true,
// if using webpack 2+ or rollup, to leverage tree shaking:
"module": "es2015",
"moduleResolution": "node",
"lib": ["es2019", "dom"],
"sourceMap": true,
"rootDir": "./",
"outDir": "./dist",
"allowJs": true,
"noImplicitAny": true,
"allowSyntheticDefaultImports": true,
"baseUrl": "./",
"paths": {
"@/js/*": ["./js/*"],
"@/css/*": ["./css/*"],
"@/img/*": ["./img/*"],
"@/components/*": ["./components/*"],
"@/backend/*": ["../src/types/*"]
},
"jsx": "preserve"
}
}

4
client/types.d.ts vendored Normal file
View file

@ -0,0 +1,4 @@
declare module "*.vue" {
import Vue from "vue";
export default Vue;
}

View file

@ -14,7 +14,7 @@
"scripts": {
"build": "webpack",
"coverage": "run-s test:* && nyc --nycrc-path=test/.nycrc-report.json report",
"dev": "node index start --dev",
"dev": "ts-node index start --dev",
"format:prettier": "prettier --write \"**/*.*\"",
"lint:check-eslint": "eslint-config-prettier .eslintrc.cjs",
"lint:eslint": "eslint . --ext .js,.vue --report-unused-disable-directives --color",
@ -41,6 +41,7 @@
},
"dependencies": {
"@fastify/busboy": "1.0.0",
"@types/ldapjs": "2.2.2",
"bcryptjs": "2.4.3",
"chalk": "4.1.2",
"cheerio": "1.0.0-rc.10",
@ -74,13 +75,23 @@
"devDependencies": {
"@babel/core": "7.17.10",
"@babel/preset-env": "7.17.10",
"@babel/preset-typescript": "7.16.7",
"@fortawesome/fontawesome-free": "5.15.4",
"@textcomplete/core": "0.1.11",
"@textcomplete/textarea": "0.1.10",
"@types/express": "4.17.13",
"@types/lodash": "4.14.182",
"@types/mousetrap": "1.6.9",
"@types/sqlite3": "3.1.8",
"@types/ua-parser-js": "0.7.36",
"@types/uuid": "8.3.4",
"@types/ws": "8.5.3",
"@vue/runtime-dom": "3.2.33",
"@vue/server-test-utils": "1.3.0",
"@vue/test-utils": "1.3.0",
"babel-loader": "8.2.5",
"babel-plugin-istanbul": "6.1.1",
"babel-preset-typescript-vue": "1.1.1",
"chai": "4.3.6",
"copy-webpack-plugin": "10.2.4",
"css-loader": "6.5.1",
@ -109,6 +120,10 @@
"socket.io-client": "4.4.1",
"stylelint": "14.3.0",
"stylelint-config-standard": "24.0.0",
"ts-loader": "9.3.0",
"ts-node": "10.7.0",
"tsconfig-paths-webpack-plugin": "3.5.2",
"typescript": "4.6.4",
"undate": "0.3.0",
"vue": "2.6.14",
"vue-loader": "15.9.8",

View file

@ -1,771 +0,0 @@
"use strict";
const _ = require("lodash");
const log = require("./log");
const colors = require("chalk");
const Chan = require("./models/chan");
const crypto = require("crypto");
const Msg = require("./models/msg");
const Network = require("./models/network");
const Config = require("./config");
const UAParser = require("ua-parser-js");
const {v4: uuidv4} = require("uuid");
const escapeRegExp = require("lodash/escapeRegExp");
const constants = require("../client/js/constants.js");
const inputs = require("./plugins/inputs");
const PublicClient = require("./plugins/packages/publicClient");
const MessageStorage = require("./plugins/messageStorage/sqlite");
const TextFileMessageStorage = require("./plugins/messageStorage/text");
module.exports = Client;
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",
];
function Client(manager, name, config = {}) {
_.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 MessageStorage(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 = {};
}
// TODO: Backwards compatibility with older versions, remove in a future release?
if (client.config.awayMessage) {
client.config.clientSettings.awayMessage = client.config.awayMessage;
delete client.config.awayMessage;
}
if (client.config.clientSettings.awayMessage) {
client.awayMessage = client.config.clientSettings.awayMessage;
}
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;
}
}
Client.prototype.createChannel = function (attr) {
const chan = new Chan(attr);
chan.id = this.idChan++;
return chan;
};
Client.prototype.emit = function (event, data) {
if (this.manager !== null) {
this.manager.sockets.in(this.id).emit(event, data);
}
};
Client.prototype.find = function (channelId) {
let network = null;
let chan = null;
for (const i in this.networks) {
const n = this.networks[i];
chan = _.find(n.channels, {id: channelId});
if (chan) {
network = n;
break;
}
}
if (network && chan) {
return {network, chan};
}
return false;
};
Client.prototype.connect = function (args, isStartup = false) {
const client = this;
let channels = [];
// Get channel id for lobby before creating other channels for nicer ids
const lobbyChannelId = client.idChan++;
if (args.channels) {
let badName = false;
args.channels.forEach((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 '" +
args.name +
"' has an invalid channel which has been ignored"
);
}
// `join` is kept for backwards compatibility when updating from versions <2.0
// also used by the "connect" window
} else if (args.join) {
channels = args.join
.replace(/,/g, " ")
.split(/\s+/g)
.map((chan) => {
if (!chan.match(/^[#&!+]/)) {
chan = `#${chan}`;
}
return client.createChannel({
name: chan,
});
});
}
const network = new Network({
uuid: args.uuid,
name: String(
args.name || (Config.values.lockNetwork ? Config.values.defaults.name : "") || ""
),
host: String(args.host || ""),
port: parseInt(args.port, 10),
tls: !!args.tls,
userDisconnected: !!args.userDisconnected,
rejectUnauthorized: !!args.rejectUnauthorized,
password: String(args.password || ""),
nick: String(args.nick || ""),
username: String(args.username || ""),
realname: String(args.realname || ""),
leaveMessage: String(args.leaveMessage || ""),
sasl: String(args.sasl || ""),
saslAccount: String(args.saslAccount || ""),
saslPassword: String(args.saslPassword || ""),
commands: args.commands || [],
channels: channels,
ignoreList: args.ignoreList ? args.ignoreList : [],
proxyEnabled: !!args.proxyEnabled,
proxyHost: String(args.proxyHost || ""),
proxyPort: parseInt(args.proxyPort, 10),
proxyUsername: String(args.proxyUsername || ""),
proxyPassword: String(args.proxyPassword || ""),
});
// 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.createIrcFramework(client);
events.forEach((plugin) => {
require(`./plugins/irc-events/${plugin}`).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) {
network.irc.connect();
}
if (!isStartup) {
client.save();
channels.forEach((channel) => channel.loadMessages(client, network));
}
};
Client.prototype.generateToken = function (callback) {
crypto.randomBytes(64, (err, buf) => {
if (err) {
throw err;
}
callback(buf.toString("hex"));
});
};
Client.prototype.calculateTokenHash = function (token) {
return crypto.createHash("sha512").update(token).digest("hex");
};
Client.prototype.updateSession = function (token, ip, request) {
const client = this;
const agent = UAParser(request.headers["user-agent"] || "");
let friendlyAgent = "";
if (agent.browser.name) {
friendlyAgent = `${agent.browser.name} ${agent.browser.major}`;
} else {
friendlyAgent = "Unknown browser";
}
if (agent.os.name) {
friendlyAgent += ` on ${agent.os.name}`;
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();
};
Client.prototype.setPassword = function (hash, callback) {
const client = this;
const oldHash = client.config.password;
client.config.password = hash;
client.manager.saveUser(client, function (err) {
if (err) {
// If user file fails to write, reset it back
client.config.password = oldHash;
return callback(false);
}
return callback(true);
});
};
Client.prototype.input = function (data) {
const client = this;
data.text.split("\n").forEach((line) => {
data.text = line;
client.inputLine(data);
});
};
Client.prototype.inputLine = function (data) {
const client = this;
const target = client.find(data.target);
if (!target) {
return;
}
// Sending a message to a channel is higher priority than merely opening one
// so that reloading the page will open this channel
this.lastActiveChannel = target.chan.id;
let text = data.text;
// This is either a normal message or a command escaped with a leading '/'
if (text.charAt(0) !== "/" || text.charAt(1) === "/") {
if (target.chan.type === Chan.Type.LOBBY) {
target.chan.pushMessage(
this,
new Msg({
type: Msg.Type.ERROR,
text: "Messages can not be sent to lobbies.",
})
);
return;
}
text = "say " + text.replace(/^\//, "");
} else {
text = text.substr(1);
}
const args = text.split(" ");
const cmd = args.shift().toLowerCase();
const irc = target.network.irc;
let connected = irc && irc.connection && irc.connection.connected;
if (inputs.userInputs.has(cmd)) {
const plugin = inputs.userInputs.get(cmd);
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
connected = true;
plugin.input.apply(client, [target.network, target.chan, cmd, args]);
}
} else if (inputs.pluginCommands.has(cmd)) {
const plugin = inputs.pluginCommands.get(cmd);
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
connected = true;
plugin.input(
new PublicClient(client, plugin.packageInfo),
{network: target.network, chan: target.chan},
cmd,
args
);
}
} else if (connected) {
irc.raw(text);
}
if (!connected) {
target.chan.pushMessage(
this,
new Msg({
type: Msg.Type.ERROR,
text: "You are not connected to the IRC network, unable to send your command.",
})
);
}
};
Client.prototype.compileCustomHighlights = function () {
this.highlightRegex = compileHighlightRegex(this.config.clientSettings.highlights);
this.highlightExceptionRegex = compileHighlightRegex(
this.config.clientSettings.highlightExceptions
);
};
function compileHighlightRegex(customHighlightString) {
if (typeof customHighlightString !== "string") {
return null;
}
// Ensure we don't have empty strings in the list of highlights
const highlightsTokens = customHighlightString
.split(",")
.map((highlight) => escapeRegExp(highlight.trim()))
.filter((highlight) => highlight.length > 0);
if (highlightsTokens.length === 0) {
return null;
}
return new RegExp(
`(?:^|[ .,+!?|/:<>(){}'"@&~-])(?:${highlightsTokens.join("|")})(?:$|[ .,+!?|/:<>(){}'"-])`,
"i"
);
}
Client.prototype.more = function (data) {
const client = this;
const target = client.find(data.target);
if (!target) {
return null;
}
const chan = target.chan;
let messages = [];
let index = 0;
// If client requests -1, send last 100 messages
if (data.lastId < 0) {
index = chan.messages.length;
} else {
index = chan.messages.findIndex((val) => val.id === data.lastId);
}
// If requested id is not found, an empty array will be sent
if (index > 0) {
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,
};
};
Client.prototype.clearHistory = function (data) {
const client = this;
const target = client.find(data.target);
if (!target) {
return;
}
target.chan.messages = [];
target.chan.unread = 0;
target.chan.highlight = 0;
target.chan.firstUnread = 0;
client.emit("history:clear", {
target: target.chan.id,
});
if (!target.chan.isLoggable()) {
return;
}
for (const messageStorage of this.messageStorage) {
messageStorage.deleteChannel(target.network, target.chan);
}
};
Client.prototype.search = function (query) {
if (this.messageProvider === undefined) {
return Promise.resolve([]);
}
return this.messageProvider.search(query);
};
Client.prototype.open = function (socketId, target) {
// Due to how socket.io works internally, normal events may arrive later than
// the disconnect event, and because we can't control this timing precisely,
// process this event normally even if there is no attached client anymore.
const attachedClient = this.attachedClients[socketId] || {};
// Opening a window like settings
if (target === null) {
attachedClient.openChannel = -1;
return;
}
target = this.find(target);
if (!target) {
return;
}
target.chan.unread = 0;
target.chan.highlight = 0;
if (target.chan.messages.length > 0) {
target.chan.firstUnread = target.chan.messages[target.chan.messages.length - 1].id;
}
attachedClient.openChannel = target.chan.id;
this.lastActiveChannel = target.chan.id;
this.emit("open", target.chan.id);
};
Client.prototype.sort = function (data) {
const order = data.order;
if (!_.isArray(order)) {
return;
}
switch (data.type) {
case "networks":
this.networks.sort((a, b) => order.indexOf(a.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 === Chan.Type.LOBBY) {
return -1;
} else if (b.type === Chan.Type.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();
};
Client.prototype.names = function (data) {
const client = this;
const target = client.find(data.target);
if (!target) {
return;
}
client.emit("names", {
id: target.chan.id,
users: target.chan.getSortedUsers(target.network.irc),
});
};
Client.prototype.part = function (network, 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,
});
};
Client.prototype.quit = function (signOut) {
const sockets = this.manager.sockets.sockets;
const room = sockets.adapter.rooms.get(this.id);
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();
}
};
Client.prototype.clientAttach = function (socketId, token) {
const client = this;
if (client.awayMessage && _.size(client.attachedClients) === 0) {
client.networks.forEach(function (network) {
// Only remove away on client attachment if
// there is no away message on this network
if (network.irc && !network.awayMessage) {
network.irc.raw("AWAY");
}
});
}
const openChannel = client.lastActiveChannel;
client.attachedClients[socketId] = {token, openChannel};
};
Client.prototype.clientDetach = function (socketId) {
const client = this;
delete this.attachedClients[socketId];
if (client.awayMessage && _.size(client.attachedClients) === 0) {
client.networks.forEach(function (network) {
// Only set away on client deattachment if
// there is no away message on this network
if (network.irc && !network.awayMessage) {
network.irc.raw("AWAY", client.awayMessage);
}
});
}
};
Client.prototype.registerPushSubscription = function (session, subscription, noSave) {
if (
!_.isPlainObject(subscription) ||
!_.isPlainObject(subscription.keys) ||
typeof subscription.endpoint !== "string" ||
!/^https?:\/\//.test(subscription.endpoint) ||
typeof subscription.keys.p256dh !== "string" ||
typeof subscription.keys.auth !== "string"
) {
session.pushSubscription = null;
return;
}
const data = {
endpoint: subscription.endpoint,
keys: {
p256dh: subscription.keys.p256dh,
auth: subscription.keys.auth,
},
};
session.pushSubscription = data;
if (!noSave) {
this.save();
}
return data;
};
Client.prototype.unregisterPushSubscription = function (token) {
this.config.sessions[token].pushSubscription = null;
this.save();
};
Client.prototype.save = _.debounce(
function SaveClient() {
if (Config.values.public) {
return;
}
const client = this;
client.manager.saveUser(client);
},
5000,
{maxWait: 20000}
);

787
src/client.ts Normal file
View file

@ -0,0 +1,787 @@
"use strict";
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 from "./models/chan";
import Msg from "./models/msg";
import Config from "./config";
import constants from "../client/js/constants.js";
import inputs from "./plugins/inputs";
import PublicClient from "./plugins/packages/publicClient";
import SqliteMessageStorage from "./plugins/messageStorage/sqlite";
import TextFileMessageStorage from "./plugins/messageStorage/text";
import {ClientConfig, Mention, PushSubscription} from "src/types/client";
import Network from "./models/network";
import ClientManager from "./clientManager";
import {MessageType} from "./types/models/message";
import {ChanType} from "./types/models/channel";
import {MessageStorage} from "./types/plugins/messageStorage";
const events = [
"away",
"cap",
"connection",
"unhandled",
"ctcp",
"chghost",
"error",
"help",
"info",
"invite",
"join",
"kick",
"list",
"mode",
"modelist",
"motd",
"message",
"names",
"nick",
"part",
"quit",
"sasl",
"topic",
"welcome",
"whois",
];
class Client {
awayMessage: string;
lastActiveChannel: number;
attachedClients: {
[socketId: string]: {token: string; openChannel: number};
};
config: ClientConfig & {
networks: Network[];
};
id: number;
idMsg: number;
idChan: number;
name: string;
networks: Network[];
mentions: Mention[];
manager: ClientManager;
messageStorage: MessageStorage[];
highlightRegex?: RegExp;
highlightExceptionRegex?: RegExp;
messageProvider?: SqliteMessageStorage;
fileHash: string;
constructor(manager: ClientManager, name?: string, config = {} as ClientConfig) {
_.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 = null;
let chan = null;
for (const i in this.networks) {
const n = this.networks[i];
chan = _.find(n.channels, {id: channelId});
if (chan) {
network = n;
break;
}
}
if (network && chan) {
return {network, chan};
}
return false;
}
connect(args: any, isStartup = false) {
const client = this;
let channels = [];
// Get channel id for lobby before creating other channels for nicer ids
const lobbyChannelId = client.idChan++;
if (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 '" +
args.name +
"' has an invalid channel which has been ignored"
);
}
}
const network = new Network({
uuid: args.uuid,
name: String(
args.name || (Config.values.lockNetwork ? Config.values.defaults.name : "") || ""
),
host: String(args.host || ""),
port: parseInt(args.port, 10),
tls: !!args.tls,
userDisconnected: !!args.userDisconnected,
rejectUnauthorized: !!args.rejectUnauthorized,
password: String(args.password || ""),
nick: String(args.nick || ""),
username: String(args.username || ""),
realname: String(args.realname || ""),
leaveMessage: String(args.leaveMessage || ""),
sasl: String(args.sasl || ""),
saslAccount: String(args.saslAccount || ""),
saslPassword: String(args.saslPassword || ""),
commands: args.commands || [],
channels: channels,
ignoreList: args.ignoreList ? args.ignoreList : [],
proxyEnabled: !!args.proxyEnabled,
proxyHost: String(args.proxyHost || ""),
proxyPort: parseInt(args.proxyPort, 10),
proxyUsername: String(args.proxyUsername || ""),
proxyPassword: String(args.proxyPassword || ""),
});
// 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.createIrcFramework(client);
events.forEach((plugin) => {
require(`./plugins/irc-events/${plugin}`).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) {
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 = 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.substr(1);
}
const args = text.split(" ");
const cmd = args.shift().toLowerCase();
const irc = target.network.irc;
let connected = irc && irc.connection && irc.connection.connected;
if (inputs.userInputs.has(cmd)) {
const plugin = inputs.userInputs.get(cmd);
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
connected = true;
plugin.input.apply(client, [target.network, target.chan, cmd, args]);
}
} else if (inputs.pluginCommands.has(cmd)) {
const plugin = inputs.pluginCommands.get(cmd);
if (typeof plugin.input === "function" && (connected || plugin.allowDisconnected)) {
connected = true;
plugin.input(
new PublicClient(client, plugin.packageInfo),
{network: target.network, chan: target.chan},
cmd,
args
);
}
} else if (connected) {
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) {
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 = [];
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: string) {
if (this.messageProvider === undefined) {
return Promise.resolve([]);
}
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 any);
// Opening a window like settings
if (target === null) {
attachedClient.openChannel = -1;
return;
}
const targetNetChan = this.find(target);
if (!targetNetChan) {
return;
}
targetNetChan.chan.unread = 0;
targetNetChan.chan.highlight = 0;
if (targetNetChan.chan.messages.length > 0) {
targetNetChan.chan.firstUnread =
targetNetChan.chan.messages[targetNetChan.chan.messages.length - 1].id;
}
attachedClient.openChannel = targetNetChan.chan.id;
this.lastActiveChannel = targetNetChan.chan.id;
this.emit("open", targetNetChan.chan.id);
}
sort(data) {
const order = data.order;
if (!_.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) {
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;
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: PushSubscription,
noSave: boolean = 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 = null;
this.save();
}
save = _.debounce(
function SaveClient() {
if (Config.values.public) {
return;
}
const client = this;
client.manager.saveUser(client);
},
5000,
{maxWait: 20000}
);
}
export default Client;

View file

@ -1,284 +0,0 @@
"use strict";
const _ = require("lodash");
const log = require("./log");
const colors = require("chalk");
const crypto = require("crypto");
const fs = require("fs");
const path = require("path");
const Auth = require("./plugins/auth");
const Client = require("./client");
const Config = require("./config");
const WebPush = require("./plugins/webpush");
module.exports = ClientManager;
function ClientManager() {
this.clients = [];
}
ClientManager.prototype.init = function (identHandler, sockets) {
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();
}
}
};
ClientManager.prototype.findClient = function (name) {
name = name.toLowerCase();
return this.clients.find((u) => u.name.toLowerCase() === name);
};
ClientManager.prototype.loadUsers = function () {
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));
}
};
ClientManager.prototype.autoloadUsers = function () {
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}
)
);
};
ClientManager.prototype.loadUser = function (name) {
const userConfig = 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;
};
ClientManager.prototype.getUsers = function () {
if (!fs.existsSync(Config.getUsersPath())) {
return [];
}
return fs
.readdirSync(Config.getUsersPath())
.filter((file) => file.endsWith(".json"))
.map((file) => file.slice(0, -5));
};
ClientManager.prototype.addUser = function (name, password, enableLog) {
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) {
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) {
// 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;
};
ClientManager.prototype.getDataToSave = function (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};
};
ClientManager.prototype.saveUser = function (client, callback) {
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) {
log.error(`Failed to update user ${colors.green(client.name)} (${e})`);
if (callback) {
callback(e);
}
}
};
ClientManager.prototype.removeUser = function (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;
};
function readUserConfig(name) {
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);
} catch (e) {
log.error(`Failed to read user ${colors.bold(name)}: ${e}`);
}
return false;
}

295
src/clientManager.ts Normal file
View file

@ -0,0 +1,295 @@
"use strict";
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 from "./client";
import Config from "./config";
import WebPush from "./plugins/webpush";
import log from "./log";
import {Namespace, Server, Socket} from "socket.io";
class ClientManager {
clients: Client[];
sockets: Namespace;
identHandler: any;
webPush: WebPush;
constructor() {
this.clients = [];
}
init(identHandler, sockets: Namespace) {
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, 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) {
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) {
// 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?: Error) => 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) {
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);
} catch (e) {
log.error(`Failed to read user ${colors.bold(name)}: ${e}`);
}
return false;
}
}
export default ClientManager;

View file

@ -1,14 +1,15 @@
"use strict";
const log = require("../log");
const fs = require("fs");
const path = require("path");
const colors = require("chalk");
const program = require("commander");
const Helper = require("../helper");
const Config = require("../config");
const Utils = require("./utils");
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();
program
.version(Helper.getVersion(), "-v, --version")
.option(

View file

@ -1,13 +1,14 @@
"use strict";
const log = require("../log");
const colors = require("chalk");
const semver = require("semver");
const program = require("commander");
const Helper = require("../helper");
const Config = require("../config");
const Utils = require("./utils");
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";
const program = new Command();
program
.command("install <package>")
.description("Install a theme or a package")

View file

@ -1,10 +1,11 @@
"use strict";
const program = require("commander");
const Utils = require("./utils");
const packageManager = require("../plugins/packages");
const log = require("../log");
import {Command} from "commander";
import Utils from "./utils";
import packageManager from "../plugins/packages";
import log from "../log";
const program = new Command();
program
.command("outdated")
.description("Check for any outdated packages")

View file

@ -1,12 +1,14 @@
"use strict";
const log = require("../log");
const colors = require("chalk");
const fs = require("fs");
const path = require("path");
const program = require("commander");
const Config = require("../config");
const Utils = require("./utils");
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();
program
.command("start")

View file

@ -1,11 +1,12 @@
"use strict";
const log = require("../log");
const colors = require("chalk");
const program = require("commander");
const Config = require("../config");
const Utils = require("./utils");
import log from "../log";
import colors from "chalk";
import {Command} from "commander";
import Config from "../config";
import Utils from "./utils";
const program = new Command();
program
.command("uninstall <package>")
.description("Uninstall a theme or a package")

View file

@ -2,10 +2,11 @@
const log = require("../log");
const colors = require("chalk");
const program = require("commander");
const {Command} = require("commander");
const Config = require("../config");
const Utils = require("./utils");
const program = new Command();
program
.command("upgrade [packages...]")
.description("Upgrade installed themes and packages to their latest versions")

View file

@ -1,14 +1,15 @@
"use strict";
const _ = require("lodash");
const log = require("../log");
const colors = require("chalk");
const fs = require("fs");
const Helper = require("../helper");
const Config = require("../config");
const path = require("path");
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;
let home: string;
class Utils {
static extraHelp() {
@ -120,7 +121,7 @@ class Utils {
return new Promise((resolve, reject) => {
let success = false;
const add = require("child_process").spawn(
const add = spawn(
process.execPath,
[yarn, command, ...staticParameters, ...parameters],
{env: env}
@ -168,10 +169,10 @@ class Utils {
return reject(code);
}
resolve();
resolve(true);
});
});
}
}
module.exports = Utils;
export default Utils;

View file

@ -1,16 +1,19 @@
"use strict";
const path = require("path");
const fs = require("fs");
const os = require("os");
const _ = require("lodash");
const colors = require("chalk");
const log = require("./log");
const Helper = require("./helper");
import path from "path";
import fs from "fs";
import os from "os";
import _ from "lodash";
import colors from "chalk";
import log from "./log";
import Helper from "./helper";
import {Config as ConfigType} from "src/types/config";
class Config {
values = require(path.resolve(path.join(__dirname, "..", "defaults", "config.js")));
#homePath;
values = require(path.resolve(
path.join(__dirname, "..", "defaults", "config.js")
)) as ConfigType;
#homePath: string;
getHomePath() {
return this.#homePath;
@ -36,7 +39,7 @@ class Config {
return path.join(this.#homePath, "users");
}
getUserConfigPath(name) {
getUserConfigPath(name: string) {
return path.join(this.getUsersPath(), `${name}.json`);
}
@ -48,7 +51,7 @@ class Config {
return path.join(this.#homePath, "packages");
}
getPackageModulePath(packageName) {
getPackageModulePath(packageName: string) {
return path.join(this.getPackagesPath(), "node_modules", packageName);
}
@ -57,14 +60,16 @@ class Config {
return "thelounge";
}
return this.values.defaults.nick.replace(/%/g, () => Math.floor(Math.random() * 10));
return this.values.defaults.nick.replace(/%/g, () =>
Math.floor(Math.random() * 10).toString()
);
}
merge(newConfig) {
merge(newConfig: ConfigType) {
this._merge_config_objects(this.values, newConfig);
}
_merge_config_objects(oldConfig, 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
@ -93,7 +98,7 @@ class Config {
});
}
setHome(newPath) {
setHome(newPath: string) {
this.#homePath = Helper.expandHome(newPath);
// Reload config from new home location
@ -179,4 +184,4 @@ class Config {
}
}
module.exports = new Config();
export default new Config();

View file

@ -1,13 +1,14 @@
"use strict";
const pkg = require("../package.json");
const _ = require("lodash");
const path = require("path");
const os = require("os");
const fs = require("fs");
const net = require("net");
const bcrypt = require("bcryptjs");
const crypto = require("crypto");
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";
import User from "./models/user";
const Helper = {
expandHome,
@ -27,7 +28,7 @@ const Helper = {
},
};
module.exports = Helper;
export default Helper;
function getVersion() {
const gitCommit = getGitCommit();
@ -73,7 +74,7 @@ function getVersionCacheBust() {
return hash.substring(0, 10);
}
function ip2hex(address) {
function ip2hex(address: string) {
// no ipv6 support
if (!net.isIPv4(address)) {
return "00000000";
@ -95,7 +96,7 @@ function ip2hex(address) {
// Expand ~ into the current user home dir.
// This does *not* support `~other_user/tmp` => `/home/other_user/tmp`.
function expandHome(shortenedPath) {
function expandHome(shortenedPath: string) {
if (!shortenedPath) {
return "";
}
@ -104,19 +105,19 @@ function expandHome(shortenedPath) {
return path.resolve(shortenedPath.replace(/^~($|\/|\\)/, home + "$1"));
}
function passwordRequiresUpdate(password) {
function passwordRequiresUpdate(password: string) {
return bcrypt.getRounds(password) !== 11;
}
function passwordHash(password) {
function passwordHash(password: string) {
return bcrypt.hashSync(password, bcrypt.genSaltSync(11));
}
function passwordCompare(password, expected) {
function passwordCompare(password: string, expected: string) {
return bcrypt.compare(password, expected);
}
function parseHostmask(hostmask) {
function parseHostmask(hostmask: string): Hostmask {
let nick = "";
let ident = "*";
let hostname = "*";
@ -152,7 +153,7 @@ function parseHostmask(hostmask) {
return result;
}
function compareHostmask(a, b) {
function compareHostmask(a: Hostmask, b: Hostmask) {
return (
compareWithWildcard(a.nick, b.nick) &&
compareWithWildcard(a.ident, b.ident) &&
@ -160,7 +161,7 @@ function compareHostmask(a, b) {
);
}
function compareWithWildcard(a, b) {
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

View file

@ -1,14 +1,18 @@
"use strict";
const log = require("./log");
const fs = require("fs");
const net = require("net");
const colors = require("chalk");
const Helper = require("./helper");
const Config = require("./config");
import log from "./log";
import fs from "fs";
import net from "net";
import colors from "chalk";
import Helper from "./helper";
import Config from "./config";
class Identification {
constructor(startedCallback) {
private connectionId: number;
private connections: Map<any, any>;
private oidentdFile: string;
constructor(startedCallback: Function) {
this.connectionId = 0;
this.connections = new Map();
@ -39,11 +43,15 @@ class Identification {
},
() => {
const address = server.address();
log.info(
`Identd server available on ${colors.green(
address.address + ":" + address.port
)}`
);
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
)}`
);
}
startedCallback(this);
}
@ -120,4 +128,4 @@ class Identification {
}
}
module.exports = Identification;
export default Identification;

1
src/index.d.ts vendored Normal file
View file

@ -0,0 +1 @@
/// <reference path="types/index.d.ts" />

View file

@ -1,304 +0,0 @@
"use strict";
const _ = require("lodash");
const log = require("../log");
const Config = require("../config");
const User = require("./user");
const Msg = require("./msg");
const storage = require("../plugins/storage");
module.exports = Chan;
Chan.Type = {
CHANNEL: "channel",
LOBBY: "lobby",
QUERY: "query",
SPECIAL: "special",
};
Chan.SpecialType = {
BANLIST: "list_bans",
INVITELIST: "list_invites",
CHANNELLIST: "list_channels",
IGNORELIST: "list_ignored",
};
Chan.State = {
PARTED: 0,
JOINED: 1,
};
function Chan(attr) {
_.defaults(this, attr, {
id: 0,
messages: [],
name: "",
key: "",
topic: "",
type: Chan.Type.CHANNEL,
state: Chan.State.PARTED,
firstUnread: 0,
unread: 0,
highlight: 0,
users: new Map(),
muted: false,
});
}
Chan.prototype.destroy = function () {
this.dereferencePreviews(this.messages);
};
Chan.prototype.pushMessage = function (client, msg, increasesUnread) {
const chan = this.id;
const obj = {chan, msg};
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);
}
}
};
Chan.prototype.dereferencePreviews = function (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 = "";
}
});
}
});
};
Chan.prototype.getSortedUsers = function (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];
});
};
Chan.prototype.findMessage = function (msgId) {
return this.messages.find((message) => message.id === msgId);
};
Chan.prototype.findUser = function (nick) {
return this.users.get(nick.toLowerCase());
};
Chan.prototype.getUser = function (nick) {
return this.findUser(nick) || new User({nick});
};
Chan.prototype.setUser = function (user) {
this.users.set(user.nick.toLowerCase(), user);
};
Chan.prototype.removeUser = function (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.
*/
Chan.prototype.getFilteredClone = function (lastActiveChannel, lastMessage) {
return Object.keys(this).reduce((newChannel, prop) => {
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 > -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.totalMessages = this[prop].length;
} else {
newChannel[prop] = this[prop];
}
return newChannel;
}, {});
};
Chan.prototype.writeUserLog = function (client, msg) {
this.messages.push(msg);
// Are there any logs enabled
if (client.messageStorage.length === 0) {
return;
}
let targetChannel = 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 === Msg.Type.NOTICE && msg.showInActive) {
targetChannel = {
name: msg.from.nick,
};
} 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);
}
};
Chan.prototype.loadMessages = function (client, 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) => log.error(`Failed to load messages for ${client.name}: ${err}`));
};
Chan.prototype.isLoggable = function () {
return this.type === Chan.Type.CHANNEL || this.type === Chan.Type.QUERY;
};
Chan.prototype.setMuteStatus = function (muted) {
this.muted = !!muted;
};
function requestZncPlayback(channel, network, from) {
network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString());
}

293
src/models/chan.ts Normal file
View file

@ -0,0 +1,293 @@
"use strict";
import _ from "lodash";
import log from "../log";
import Config from "../config";
import User from "./user";
import Msg from "./msg";
import storage from "../plugins/storage";
import {ChanState, ChanType, FilteredChannel} from "src/types/models/channel";
import Client from "src/client";
import Network from "./network";
import {MessageType} from "src/types/models/message";
class Chan {
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;
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: boolean) {
const chan = this.id;
const obj = {chan, msg} as any;
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) {
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});
}
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 (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 > -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;
}
let 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;
} 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) => log.error(`Failed to load messages for ${client.name}: ${err}`));
}
isLoggable() {
return this.type === ChanType.CHANNEL || this.type === ChanType.QUERY;
}
setMuteStatus(muted) {
this.muted = !!muted;
}
}
function requestZncPlayback(channel, network, from) {
network.irc.raw("ZNC", "*playback", "PLAY", channel.name, from.toString());
}
export default Chan;

View file

@ -1,92 +0,0 @@
"use strict";
const _ = require("lodash");
class Msg {
constructor(attr) {
// 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: Msg.Type.MESSAGE,
self: false,
});
if (this.time > 0) {
this.time = new Date(this.time);
} else {
this.time = new Date();
}
}
findPreview(link) {
return this.previews.find((preview) => preview.link === link);
}
isLoggable() {
if (this.type === Msg.Type.TOPIC) {
// Do not log topic that is sent on channel join
return !!this.from.nick;
}
switch (this.type) {
case Msg.Type.MONOSPACE_BLOCK:
case Msg.Type.ERROR:
case Msg.Type.TOPIC_SET_BY:
case Msg.Type.MODE_CHANNEL:
case Msg.Type.MODE_USER:
case Msg.Type.RAW:
case Msg.Type.WHOIS:
case Msg.Type.PLUGIN:
return false;
default:
return true;
}
}
}
Msg.Type = {
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",
};
module.exports = Msg;

80
src/models/msg.ts Normal file
View file

@ -0,0 +1,80 @@
"use strict";
import _ from "lodash";
import {UserInMessage, MessagePreview, MessageType} from "src/types/models/message";
class Msg {
from: UserInMessage;
id: number;
previews: MessagePreview[];
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;
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.getTime() > 0) {
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;

View file

@ -1,558 +0,0 @@
"use strict";
const _ = require("lodash");
const {v4: uuidv4} = require("uuid");
const IrcFramework = require("irc-framework");
const Chan = require("./chan");
const Msg = require("./msg");
const Prefix = require("./prefix");
const Helper = require("../helper");
const Config = require("../config");
const STSPolicies = require("../plugins/sts");
const ClientCertificate = require("../plugins/clientCertificate");
module.exports = Network;
/**
* @type {Object} List of keys which should be sent to the client by default.
*/
const fieldsForClient = {
uuid: true,
name: true,
nick: true,
serverOptions: true,
};
function Network(attr) {
_.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: Chan.Type.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 === Chan.Type.SPECIAL),
})
);
}
Network.prototype.validate = function (client) {
// Remove !, :, @ and whitespace characters from nicknames and usernames
const cleanNick = (str) => str.replace(/[\x00\s:!@]/g, "_").substring(0, 100);
// Remove new lines and limit length
const cleanString = (str) => 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, text) {
network.channels[0].pushMessage(
client,
new Msg({
type: Msg.Type.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;
};
Network.prototype.createIrcFramework = function (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,
});
this.setIrcFrameworkOptions(client);
this.irc.requestCap([
"znc.in/self-message", // Legacy echo-message for ZNC
"znc.in/playback", // See http://wiki.znc.in/Playback
]);
};
Network.prototype.setIrcFrameworkOptions = function (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,
};
}
};
Network.prototype.createWebIrc = function (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,
};
// 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);
}
return webircObject;
};
Network.prototype.edit = function (client, args) {
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);
this.irc.user.username = this.irc.options.username;
this.irc.user.gecos = this.irc.options.gecos;
}
client.save();
};
Network.prototype.destroy = function () {
this.channels.forEach((channel) => channel.destroy());
};
Network.prototype.setNick = function (nick) {
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) {
this.irc.options.nick = nick;
}
};
/**
* Get a clean clone of this network that will be sent to the client.
* This function performs manual cloning of network object for
* better control of performance and memory usage.
*
* Both of the parameters that are accepted by this function are passed into channels' getFilteredClone call.
*
* @see {@link Chan#getFilteredClone}
*/
Network.prototype.getFilteredClone = function (lastActiveChannel, lastMessage) {
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;
}, {});
filteredNetwork.status = this.getNetworkStatus();
return filteredNetwork;
};
Network.prototype.getNetworkStatus = function () {
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;
};
Network.prototype.addChannel = function (newChan) {
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 === Chan.Type.CHANNEL || newChan.type === Chan.Type.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, {sensitivity: "base"}) <= 0 ||
(compareChan.type !== Chan.Type.CHANNEL && compareChan.type !== Chan.Type.QUERY)
) {
index = i;
break;
}
}
}
this.channels.splice(index, 0, newChan);
return index;
};
Network.prototype.quit = function (quitMessage) {
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);
};
Network.prototype.exportForEdit = function () {
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);
data.hasSTSPolicy = !!STSPolicies.get(this.host);
return data;
};
Network.prototype.export = function () {
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",
]);
network.channels = this.channels
.filter(function (channel) {
return channel.type === Chan.Type.CHANNEL || channel.type === Chan.Type.QUERY;
})
.map(function (chan) {
const keys = ["name", "muted"];
if (chan.type === Chan.Type.CHANNEL) {
keys.push("key");
} else if (chan.type === Chan.Type.QUERY) {
keys.push("type");
}
return _.pick(chan, keys);
});
return network;
};
Network.prototype.getChannel = function (name) {
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;
});
};

627
src/models/network.ts Normal file
View file

@ -0,0 +1,627 @@
"use strict";
import _ from "lodash";
import {v4 as uuidv4} from "uuid";
import IrcFramework from "irc-framework";
import Chan from "./chan";
import Msg from "./msg";
import Prefix from "./prefix";
import Helper from "../helper";
import Config from "../config";
import STSPolicies from "../plugins/sts";
import ClientCertificate from "../plugins/clientCertificate";
import {Channel, ChanType} from "src/types/models/channel";
import Client from "src/client";
import {NetworkStatus} from "src/types/models/network";
import {MessageType} from "src/types/models/message";
import {WebIRC} from "src/types/config";
/**
* @type {Object} List of keys which should be sent to the client by default.
*/
const fieldsForClient = {
uuid: true,
name: true,
nick: true,
serverOptions: true,
};
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?: {
host: string;
port: number;
password: string;
nick: string;
username: string;
gecos: string;
tls: boolean;
rejectUnauthorized: boolean;
webirc: WebIRC;
client_certificate?: ClientCertificate;
socks: {
host: string;
port: number;
user: string;
pass: string;
};
sasl_mechanism: string;
account:
| {
account: string;
password: string;
}
| {};
};
};
chanCache: Chan[];
ignoreList: string[];
keepNick?: string;
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(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(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,
});
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(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,
} as any;
// 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);
}
return webircObject;
}
edit(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);
this.irc.user.username = this.irc.options.username;
this.irc.user.gecos = this.irc.options.gecos;
}
client.save();
}
destroy() {
this.channels.forEach((channel) => channel.destroy());
}
setNick(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) {
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;

View file

@ -1,7 +1,11 @@
"use strict";
class Prefix {
constructor(prefix) {
prefix: PrefixObject[];
modeToSymbol: {[mode: string]: string};
symbols: string[];
constructor(prefix: PrefixObject[]) {
this.prefix = prefix || []; // [{symbol: "@", mode: "o"}, ... ]
this.modeToSymbol = {};
this.symbols = [];
@ -20,14 +24,14 @@ class Prefix {
});
}
update(prefix) {
update(prefix: PrefixObject[]) {
this.prefix = prefix || [];
this._update_internals();
}
forEach(f) {
forEach(f: (value: PrefixObject, index: number, array: PrefixObject[]) => void) {
return this.prefix.forEach(f);
}
}
module.exports = Prefix;
export default Prefix;

View file

@ -1,35 +0,0 @@
"use strict";
const _ = require("lodash");
module.exports = User;
function User(attr, prefix) {
_.defaults(this, attr, {
modes: [],
away: "",
nick: "",
lastMessage: 0,
});
Object.defineProperty(this, "mode", {
get() {
return this.modes[0] || "";
},
});
this.setModes(this.modes, prefix);
}
User.prototype.setModes = function (modes, prefix) {
// irc-framework sets character mode, but The Lounge works with symbols
this.modes = modes.map((mode) => prefix.modeToSymbol[mode]);
};
User.prototype.toJSON = function () {
return {
nick: this.nick,
modes: this.modes,
lastMessage: this.lastMessage,
};
};

45
src/models/user.ts Normal file
View file

@ -0,0 +1,45 @@
"use strict";
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() {
return this.modes[0] || "";
},
});
this.setModes(this.modes, 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;

View file

@ -1,7 +1,7 @@
"use strict";
const log = require("../log");
const colors = require("chalk");
import colors from "chalk";
import log from "../log";
// 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.
@ -15,8 +15,7 @@ function unimplemented(funcName) {
);
}
// Default API implementations
module.exports = {
const toExport = {
moduleName: "<module with no name>",
// Must override: implements authentication mechanism
@ -27,7 +26,11 @@ module.exports = {
// can do so without access to the user's unhashed password.
// Returning 'false' triggers fallback to default behaviour of loading all users
loadUsers: () => false,
};
// TODO: fix typing
} as any;
// Default API implementations
export default toExport;
// local auth should always be enabled, but check here to verify
let somethingEnabled = false;
@ -38,7 +41,7 @@ for (const plugin of plugins) {
somethingEnabled = true;
for (const name in plugin) {
module.exports[name] = plugin[name];
toExport[name] = plugin[name];
}
break;

View file

@ -1,11 +1,18 @@
"use strict";
const log = require("../../log");
const Config = require("../../config");
const ldap = require("ldapjs");
const colors = require("chalk");
import log from "../../log";
import Config from "../../config";
import ldap, {SearchOptions} from "ldapjs";
import colors from "chalk";
import ClientManager from "src/clientManager";
import Client from "src/client";
function ldapAuthCommon(user, bindDN, password, callback) {
function ldapAuthCommon(
user: string,
bindDN: string,
password: string,
callback: (success: boolean) => void
) {
const config = Config.values;
const ldapclient = ldap.createClient({
@ -30,7 +37,7 @@ function ldapAuthCommon(user, bindDN, password, callback) {
});
}
function simpleLdapAuth(user, password, callback) {
function simpleLdapAuth(user: string, password: string, callback: (success: boolean) => void) {
if (!user || !password) {
return callback(false);
}
@ -48,7 +55,7 @@ function simpleLdapAuth(user, password, callback) {
/**
* LDAP auth using initial DN search (see config comment for ldap.searchDN)
*/
function advancedLdapAuth(user, password, callback) {
function advancedLdapAuth(user: string, password: string, callback: (success: boolean) => void) {
if (!user || !password) {
return callback(false);
}
@ -66,7 +73,7 @@ function advancedLdapAuth(user, password, callback) {
scope: config.ldap.searchDN.scope,
filter: `(&(${config.ldap.primaryKey}=${userDN})${config.ldap.searchDN.filter})`,
attributes: ["dn"],
};
} as SearchOptions;
ldapclient.on("error", function (err) {
log.error(`Unable to connect to LDAP server: ${err}`);
@ -117,12 +124,18 @@ function advancedLdapAuth(user, password, callback) {
});
}
function ldapAuth(manager, client, user, password, callback) {
function ldapAuth(
manager: ClientManager,
client: Client,
user: string,
password: string,
callback: (success: boolean) => void
) {
// 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) {
function callbackWrapper(valid: boolean) {
if (valid && !client) {
manager.addUser(user, null, true);
}
@ -173,7 +186,7 @@ function advancedLdapLoadUsers(users, callbackLoadUser) {
filter: `${config.ldap.searchDN.filter}`,
attributes: [config.ldap.primaryKey],
paged: true,
};
} as SearchOptions;
ldapclient.search(base, searchOptions, function (err2, res) {
if (err2) {
@ -182,6 +195,8 @@ function advancedLdapLoadUsers(users, callbackLoadUser) {
}
res.on("searchEntry", function (entry) {
//@ts-ignore
//TODO
const user = entry.attributes[0]._vals[0].toString();
if (remainingUsers.has(user)) {
@ -226,7 +241,7 @@ function isLdapEnabled() {
return !Config.values.public && Config.values.ldap.enable;
}
module.exports = {
export default {
moduleName: "ldap",
auth: ldapAuth,
isEnabled: isLdapEnabled,

View file

@ -1,18 +1,18 @@
"use strict";
const path = require("path");
const fs = require("fs");
const crypto = require("crypto");
const {md, pki} = require("node-forge");
const log = require("../log");
const Config = require("../config");
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";
module.exports = {
export default {
get,
remove,
};
function get(uuid) {
function get(uuid: string): ClientCertificate {
if (Config.values.public) {
return null;
}
@ -28,7 +28,7 @@ function get(uuid) {
return {
private_key: fs.readFileSync(paths.privateKeyPath, "utf-8"),
certificate: fs.readFileSync(paths.certificatePath, "utf-8"),
};
} as ClientCertificate;
} catch (e) {
log.error("Unable to get certificate", e);
}
@ -36,7 +36,7 @@ function get(uuid) {
return null;
}
function remove(uuid) {
function remove(uuid: string) {
if (Config.values.public) {
return null;
}
@ -56,7 +56,7 @@ function remove(uuid) {
}
}
function generateAndWrite(folderPath, paths) {
function generateAndWrite(folderPath: string, paths: {privateKeyPath: any; certificatePath: any}) {
const certificate = generate();
try {
@ -121,12 +121,12 @@ function generate() {
const pem = {
private_key: pki.privateKeyToPem(keys.privateKey),
certificate: pki.certificateToPem(cert),
};
} as ClientCertificate;
return pem;
}
function getPaths(folderPath, uuid) {
function getPaths(folderPath: string, uuid: string) {
return {
privateKeyPath: path.join(folderPath, `${uuid}.pem`),
certificatePath: path.join(folderPath, `${uuid}.crt`),

View file

@ -6,7 +6,7 @@ const Msg = require("../../models/msg");
exports.commands = ["slap", "me"];
exports.input = function ({irc}, chan, cmd, args) {
if (chan.type !== Chan.Type.CHANNEL && chan.type !== Chan.Type.QUERY) {
if (chan.type !== ChanType.CHANNEL && chan.type !== ChanType.QUERY) {
chan.pushMessage(
this,
new Msg({

View file

@ -6,7 +6,7 @@ const Msg = require("../../models/msg");
exports.commands = ["ban", "unban", "banlist", "kickban"];
exports.input = function ({irc}, chan, cmd, args) {
if (chan.type !== Chan.Type.CHANNEL) {
if (chan.type !== ChanType.CHANNEL) {
chan.pushMessage(
this,
new Msg({

View file

@ -119,7 +119,7 @@ exports.input = function (network, chan, cmd, args) {
if (typeof newChan === "undefined") {
newChan = client.createChannel({
type: Chan.Type.SPECIAL,
type: ChanType.SPECIAL,
special: Chan.SpecialType.IGNORELIST,
name: chanName,
data: ignored,

View file

@ -13,7 +13,7 @@ exports.input = function ({irc}, chan, cmd, args) {
if (args.length === 2) {
irc.raw("INVITE", args[0], args[1]); // Channel provided in the command
} else if (args.length === 1 && chan.type === Chan.Type.CHANNEL) {
} else if (args.length === 1 && chan.type === ChanType.CHANNEL) {
irc.raw("INVITE", args[0], chan.name); // Current channel
} else {
chan.pushMessage(

View file

@ -6,7 +6,7 @@ const Msg = require("../../models/msg");
exports.commands = ["kick"];
exports.input = function ({irc}, chan, cmd, args) {
if (chan.type !== Chan.Type.CHANNEL) {
if (chan.type !== ChanType.CHANNEL) {
chan.pushMessage(
this,
new Msg({

View file

@ -11,7 +11,7 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
return;
} else if (cmd !== "mode") {
if (chan.type !== Chan.Type.CHANNEL) {
if (chan.type !== ChanType.CHANNEL) {
chan.pushMessage(
this,
new Msg({
@ -59,7 +59,7 @@ exports.input = function ({irc, nick}, chan, cmd, args) {
if (args.length === 0 || args[0][0] === "+" || args[0][0] === "-") {
args.unshift(
chan.type === Chan.Type.CHANNEL || chan.type === Chan.Type.QUERY ? chan.name : nick
chan.type === ChanType.CHANNEL || chan.type === ChanType.QUERY ? chan.name : nick
);
}

View file

@ -1,5 +1,6 @@
"use strict";
const {ChanType} = require("src/types/models/channel");
const Chan = require("../../models/chan");
const Msg = require("../../models/msg");
@ -63,7 +64,7 @@ exports.input = function (network, chan, cmd, args) {
}
const newChan = this.createChannel({
type: Chan.Type.QUERY,
type: ChanType.QUERY,
name: targetName,
});

View file

@ -20,7 +20,7 @@ exports.input = function (network, chan, cmd, args) {
}
}
if (target.type === Chan.Type.LOBBY) {
if (target.type === ChanType.LOBBY) {
chan.pushMessage(
this,
new Msg({
@ -34,7 +34,7 @@ exports.input = function (network, chan, cmd, args) {
// 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 !== Chan.Type.CHANNEL ||
target.type !== ChanType.CHANNEL ||
target.state === Chan.State.PARTED ||
!network.irc ||
!network.irc.connection ||

View file

@ -6,7 +6,7 @@ const Chan = require("../../models/chan");
exports.commands = ["cycle", "rejoin"];
exports.input = function ({irc}, chan) {
if (chan.type !== Chan.Type.CHANNEL) {
if (chan.type !== ChanType.CHANNEL) {
chan.pushMessage(
this,
new Msg({

View file

@ -6,7 +6,7 @@ const Msg = require("../../models/msg");
exports.commands = ["topic"];
exports.input = function ({irc}, chan, cmd, args) {
if (chan.type !== Chan.Type.CHANNEL) {
if (chan.type !== ChanType.CHANNEL) {
chan.pushMessage(
this,
new Msg({

View file

@ -28,8 +28,8 @@ module.exports = function (irc, network) {
network.channels.forEach((chan) => {
let user;
switch (chan.type) {
case Chan.Type.QUERY: {
switch (ChanType) {
case ChanType.QUERY: {
if (data.nick.toLowerCase() !== chan.name.toLowerCase()) {
return;
}
@ -56,7 +56,7 @@ module.exports = function (irc, network) {
break;
}
case Chan.Type.CHANNEL: {
case ChanType.CHANNEL: {
user = chan.findUser(data.nick);
if (!user || user.away === away) {

View file

@ -1,13 +1,16 @@
"use strict";
const _ = require("lodash");
const log = require("../../log");
const Msg = require("../../models/msg");
const Chan = require("../../models/chan");
const Helper = require("../../helper");
const Config = require("../../config");
import _ from "lodash";
import log from "../../log";
import Msg from "../../models/msg";
import Chan from "../../models/chan";
import Helper from "../../helper";
import Config from "../../config";
import Network from "src/models/network";
import {ChanState, ChanType} from "src/types/models/channel";
import {MessageType} from "src/types/models/message";
module.exports = function (irc, network) {
export default function (irc: Network["irc"], network: Network) {
const client = this;
network.channels[0].pushMessage(
@ -52,7 +55,7 @@ module.exports = function (irc, network) {
}
network.channels.forEach((chan) => {
if (chan.type !== Chan.Type.CHANNEL) {
if (chan.type !== ChanType.CHANNEL) {
return;
}
@ -109,14 +112,14 @@ module.exports = function (irc, network) {
network.channels.forEach((chan) => {
chan.users = new Map();
chan.state = Chan.State.PARTED;
chan.state = ChanState.PARTED;
});
if (error) {
network.channels[0].pushMessage(
client,
new Msg({
type: Msg.Type.ERROR,
type: MessageType.ERROR,
text: `Connection closed unexpectedly: ${error}`,
}),
true
@ -154,7 +157,7 @@ module.exports = function (irc, network) {
client,
new Msg({
self: !message.from_server,
type: Msg.Type.RAW,
type: MessageType.RAW,
text: message.line,
}),
true
@ -166,7 +169,7 @@ module.exports = function (irc, network) {
network.channels[0].pushMessage(
client,
new Msg({
type: Msg.Type.ERROR,
type: MessageType.ERROR,
text: "Socket error: " + err,
}),
true
@ -212,8 +215,11 @@ module.exports = function (irc, network) {
function sendStatus() {
const status = network.getNetworkStatus();
status.network = network.uuid;
const toSend = {
...status,
network: network.uuid,
};
client.emit("network:status", status);
client.emit("network:status", toSend);
}
};
}

View file

@ -35,7 +35,7 @@ module.exports = function (irc, network) {
if (typeof chan === "undefined") {
chan = client.createChannel({
type: Chan.Type.SPECIAL,
type: ChanType.SPECIAL,
special: Chan.SpecialType.CHANNELLIST,
name: "Channel List",
data: msg,

View file

@ -56,7 +56,7 @@ module.exports = function (irc, network) {
data.from_server &&
(!data.target ||
!network.getChannel(data.target) ||
network.getChannel(data.target).type !== Chan.Type.CHANNEL)
network.getChannel(data.target).type !== ChanType.CHANNEL)
) {
chan = network.channels[0];
from = chan.getUser(data.nick);
@ -81,7 +81,7 @@ module.exports = function (irc, network) {
chan = network.channels[0];
} else {
chan = client.createChannel({
type: Chan.Type.QUERY,
type: ChanType.QUERY,
name: target,
});
@ -98,9 +98,9 @@ module.exports = function (irc, network) {
from = chan.getUser(data.nick);
// Query messages (unless self or muted) always highlight
if (chan.type === Chan.Type.QUERY) {
if (chan.type === ChanType.QUERY) {
highlight = !self;
} else if (chan.type === Chan.Type.CHANNEL) {
} else if (chan.type === ChanType.CHANNEL) {
from.lastMessage = data.time || Date.now();
}
}
@ -166,7 +166,7 @@ module.exports = function (irc, network) {
if (msg.type === Msg.Type.ACTION) {
// For actions, do not include colon in the message
body = `${data.nick} ${body}`;
} else if (chan.type !== Chan.Type.QUERY) {
} else if (chan.type !== ChanType.QUERY) {
// In channels, prepend sender nickname to the message
body = `${data.nick}: ${body}`;
}
@ -174,7 +174,7 @@ module.exports = function (irc, network) {
// 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 === Chan.Type.QUERY ? "new message" : "mention"
chan.type === ChanType.QUERY ? "new message" : "mention"
}${chan.highlight > 1 ? "s" : ""})`;
}
@ -198,7 +198,7 @@ module.exports = function (irc, network) {
}
// Keep track of all mentions in channels for this client
if (msg.highlight && chan.type === Chan.Type.CHANNEL) {
if (msg.highlight && chan.type === ChanType.CHANNEL) {
client.mentions.push({
chanId: chan.id,
msgId: msg.id,

View file

@ -51,7 +51,7 @@ module.exports = function (irc, network) {
if (typeof chan === "undefined") {
chan = client.createChannel({
type: Chan.Type.SPECIAL,
type: ChanType.SPECIAL,
special: type,
name: chanName,
data: data,

View file

@ -23,7 +23,7 @@ module.exports = function (irc, network) {
chan = network.channels[0];
} else {
chan = client.createChannel({
type: Chan.Type.QUERY,
type: ChanType.QUERY,
name: data.nick,
});

View file

@ -1,10 +1,16 @@
"use strict";
const log = require("../../log");
const path = require("path");
const fs = require("fs");
const Config = require("../../config");
const Msg = require("../../models/msg");
import log from "../../log";
import path from "path";
import fs from "fs";
import Config from "../../config";
import Msg from "../../models/msg";
import type {Database} from "sqlite3";
import {Network} from "src/types/models/network";
import {Channel} from "src/types/models/channel";
import {Message} from "src/types/models/message";
import Client from "src/client";
import Chan from "src/models/chan";
let sqlite3;
@ -28,8 +34,12 @@ const schema = [
"CREATE INDEX IF NOT EXISTS time ON messages (time)",
];
class MessageStorage {
constructor(client) {
class SqliteMessageStorage implements SqliteMessageStorage {
client: Client;
isEnabled: boolean;
database: Database;
constructor(client: Client) {
this.client = client;
this.isEnabled = false;
}
@ -98,7 +108,7 @@ class MessageStorage {
});
}
close(callback) {
close(callback?: (error?: Error) => void) {
if (!this.isEnabled) {
return;
}
@ -116,7 +126,7 @@ class MessageStorage {
});
}
index(network, channel, msg) {
index(network: Network, channel: Chan, msg: Msg) {
if (!this.isEnabled) {
return;
}
@ -144,7 +154,7 @@ class MessageStorage {
);
}
deleteChannel(network, channel) {
deleteChannel(network: Network, channel: Channel) {
if (!this.isEnabled) {
return;
}
@ -164,7 +174,7 @@ class MessageStorage {
* @param Network network - Network object where the channel is
* @param Chan channel - Channel object for which to load messages for
*/
getMessages(network, channel) {
getMessages(network: Network, channel: Channel) {
if (!this.isEnabled || Config.values.maxHistory === 0) {
return Promise.resolve([]);
}
@ -197,7 +207,7 @@ class MessageStorage {
}
)
);
});
}) as Promise<Message[]>;
}
search(query) {
@ -225,7 +235,7 @@ class MessageStorage {
const maxResults = 100;
select += " ORDER BY time DESC LIMIT ? OFFSET ? ";
params.push(maxResults);
params.push(maxResults.toString());
query.offset = parseInt(query.offset, 10) || 0;
params.push(query.offset);
@ -252,7 +262,7 @@ class MessageStorage {
}
}
module.exports = MessageStorage;
export default SqliteMessageStorage;
function parseSearchRowsToMessages(id, rows) {
const messages = [];

View file

@ -1,14 +1,22 @@
"use strict";
const log = require("../../log");
const fs = require("fs");
const path = require("path");
const filenamify = require("filenamify");
const Config = require("../../config");
const Msg = require("../../models/msg");
import log from "../../log";
import fs from "fs";
import path from "path";
import filenamify from "filenamify";
import Config from "../../config";
import Msg from "../../models/msg";
import {Network} from "src/types/models/network";
import {Channel} from "src/types/models/channel";
import {Message, MessageType} from "src/types/models/message";
import {MessageStorage} from "src/types/plugins/messageStorage";
import Client from "src/client";
class TextFileMessageStorage {
constructor(client) {
class TextFileMessageStorage implements MessageStorage {
client: Client;
isEnabled: boolean;
constructor(client: Client) {
this.client = client;
this.isEnabled = false;
}
@ -17,7 +25,7 @@ class TextFileMessageStorage {
this.isEnabled = true;
}
close(callback) {
close(callback: () => void) {
this.isEnabled = false;
if (callback) {
@ -25,7 +33,7 @@ class TextFileMessageStorage {
}
}
index(network, channel, msg) {
index(network: Network, channel: Channel, msg: Message) {
if (!this.isEnabled) {
return;
}
@ -47,47 +55,47 @@ class TextFileMessageStorage {
// message types from src/models/msg.js
switch (msg.type) {
case Msg.Type.ACTION:
case MessageType.ACTION:
// [2014-01-01 00:00:00] * @Arnold is eating cookies
line += `* ${msg.from.mode}${msg.from.nick} ${msg.text}`;
break;
case Msg.Type.JOIN:
case MessageType.JOIN:
// [2014-01-01 00:00:00] *** Arnold (~arnold@foo.bar) joined
line += `*** ${msg.from.nick} (${msg.hostmask}) joined`;
break;
case Msg.Type.KICK:
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 Msg.Type.MESSAGE:
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 Msg.Type.MODE:
case MessageType.MODE:
// [2014-01-01 00:00:00] *** Arnold set mode +o Bernie
line += `*** ${msg.from.nick} set mode ${msg.text}`;
break;
case Msg.Type.NICK:
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 Msg.Type.NOTICE:
case MessageType.NOTICE:
// [2014-01-01 00:00:00] -Arnold- pssst, I have cookies!
line += `-${msg.from.nick}- ${msg.text}`;
break;
case Msg.Type.PART:
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 Msg.Type.QUIT:
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 Msg.Type.CHGHOST:
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 Msg.Type.TOPIC:
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;
@ -141,7 +149,7 @@ class TextFileMessageStorage {
return false;
}
static getNetworkFolderName(network) {
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, "-"));
@ -149,12 +157,12 @@ class TextFileMessageStorage {
return `${networkName}-${network.uuid.substring(networkName.length + 1)}`;
}
static getChannelFileName(channel) {
static getChannelFileName(channel: Channel) {
return `${cleanFilename(channel.name)}.log`;
}
}
module.exports = TextFileMessageStorage;
export default TextFileMessageStorage;
function cleanFilename(name) {
name = filenamify(name, {replacement: "_"});

View file

@ -1,20 +1,21 @@
"use strict";
const _ = require("lodash");
const log = require("../../log");
const colors = require("chalk");
const path = require("path");
const semver = require("semver");
const Helper = require("../../helper");
const Config = require("../../config");
const themes = require("./themes");
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";
const packageMap = new Map();
const inputs = require("../inputs");
const fs = require("fs");
const Utils = require("../../command-line/utils");
import inputs from "../inputs";
import fs from "fs";
import Utils from "../../command-line/utils";
import Client from "src/client";
const stylesheets = [];
const files = [];
const stylesheets: string[] = [];
const files: string[] = [];
const TIME_TO_LIVE = 15 * 60 * 1000; // 15 minutes, in milliseconds
@ -24,7 +25,7 @@ const cache = {
let experimentalWarningPrinted = false;
module.exports = {
export default {
getFiles,
getStylesheets,
getPackage,
@ -42,7 +43,7 @@ const packageApis = function (packageInfo) {
},
Commands: {
add: inputs.addPluginCommand.bind(this, packageInfo),
runAsUser: (command, targetId, client) =>
runAsUser: (command: string, targetId: number, client: Client) =>
client.inputLine({target: targetId, text: command}),
},
Config: {
@ -66,7 +67,7 @@ function getStylesheets() {
return stylesheets;
}
function addFile(packageName, filename) {
function addFile(packageName: string, filename: string) {
files.push(packageName + "/" + filename);
}
@ -78,7 +79,7 @@ function getPackage(name) {
return packageMap.get(name);
}
function getEnabledPackages(packageJson) {
function getEnabledPackages(packageJson: string) {
try {
const json = JSON.parse(fs.readFileSync(packageJson, "utf-8"));
return Object.keys(json.dependencies);
@ -89,15 +90,16 @@ function getEnabledPackages(packageJson) {
return [];
}
function getPersistentStorageDir(packageName) {
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) {
let packageInfo;
let packageFile;
function loadPackage(packageName: string) {
let packageInfo: PackageInfo;
// TODO: type
let packageFile: any;
try {
const packagePath = Config.getPackageModulePath(packageName);
@ -125,9 +127,11 @@ function loadPackage(packageName) {
}
const version = packageInfo.version;
packageInfo = packageInfo.thelounge;
packageInfo.packageName = packageName;
packageInfo.version = version;
packageInfo = {
...packageInfo.thelounge,
packageName: packageName,
version,
};
packageMap.set(packageName, packageFile);
@ -164,7 +168,7 @@ function loadPackages() {
watchPackages(packageJson);
}
function watchPackages(packageJson) {
function watchPackages(packageJson: string) {
fs.watch(
packageJson,
{
@ -219,7 +223,8 @@ async function outdated(cacheTimeout = TIME_TO_LIVE) {
}
// If we get an error from calling outdated and the code isn't 0, then there are no outdated packages
await Utils.executeYarnCommand(...argsList)
// TODO: was (...argsList), verify this works
await Utils.executeYarnCommand(argsList.shift(), ...argsList)
.then(() => updateOutdated(false))
.catch((code) => updateOutdated(code !== 0));

View file

@ -1,12 +1,14 @@
"use strict";
const fs = require("fs");
const Config = require("../../config");
const path = require("path");
const _ = require("lodash");
import fs from "fs";
import path from "path";
import _ from "lodash";
import Config from "../../config";
const themes = new Map();
module.exports = {
export default {
addTheme,
getAll,
getByName,
@ -24,7 +26,7 @@ function loadLocalThemes() {
.forEach((theme) => themes.set(theme.name, theme));
}
function addTheme(packageName, packageObject) {
function addTheme(packageName: string, packageObject) {
const theme = makePackageThemeObject(packageName, packageObject);
if (theme) {
@ -46,7 +48,7 @@ function getByName(name) {
return themes.get(name);
}
function makeLocalThemeObject(css) {
function makeLocalThemeObject(css: string) {
const themeName = css.slice(0, -4);
return {
displayName: themeName.charAt(0).toUpperCase() + themeName.slice(1),
@ -55,7 +57,7 @@ function makeLocalThemeObject(css) {
};
}
function makePackageThemeObject(moduleName, module) {
function makePackageThemeObject(moduleName: string, module: ThemeModule) {
if (!module || module.type !== "theme") {
return;
}

View file

@ -1,12 +1,17 @@
"use strict";
const _ = require("lodash");
const fs = require("fs");
const path = require("path");
const log = require("../log");
const Config = require("../config");
import _ from "lodash";
import fs from "fs";
import path from "path";
import log from "../log";
import Config from "../config";
import type {PolicyMap, PolicyOption} from "src/types/plugins/sts";
class STSPolicies {
private stsFile: string;
private policies: PolicyMap;
private refresh: _.DebouncedFunc<any>;
constructor() {
this.stsFile = path.join(Config.getHomePath(), "sts-policies.json");
this.policies = new Map();
@ -16,7 +21,7 @@ class STSPolicies {
return;
}
const storedPolicies = JSON.parse(fs.readFileSync(this.stsFile, "utf-8"));
const storedPolicies = JSON.parse(fs.readFileSync(this.stsFile, "utf-8")) as PolicyOption[];
const now = Date.now();
storedPolicies.forEach((value) => {
@ -30,7 +35,7 @@ class STSPolicies {
});
}
get(host) {
get(host: string) {
const policy = this.policies.get(host);
if (typeof policy === "undefined") {
@ -46,7 +51,7 @@ class STSPolicies {
return policy;
}
update(host, port, duration) {
update(host: string, port: number, duration: number) {
if (duration > 0) {
this.policies.set(host, {
port: port,
@ -60,7 +65,7 @@ class STSPolicies {
this.refresh();
}
refreshExpiration(host) {
refreshExpiration(host: string) {
const policy = this.policies.get(host);
if (typeof policy === "undefined") {
@ -92,4 +97,4 @@ class STSPolicies {
}
}
module.exports = new STSPolicies();
export default new STSPolicies();

View file

@ -1,37 +1,50 @@
"use strict";
const _ = require("lodash");
const log = require("./log");
const pkg = require("../package.json");
const Client = require("./client");
const ClientManager = require("./clientManager");
const express = require("express");
const fs = require("fs");
const path = require("path");
const io = require("socket.io");
const dns = require("dns");
const Uploader = require("./plugins/uploader");
const Helper = require("./helper");
const Config = require("./config");
const colors = require("chalk");
const net = require("net");
const Identification = require("./identification");
const changelog = require("./plugins/changelog");
const inputs = require("./plugins/inputs");
const Auth = require("./plugins/auth");
import _ from "lodash";
import log from "./log";
import pkg from "../package.json";
import Client from "./client";
import ClientManager from "./clientManager";
import express from "express";
import fs from "fs";
import path from "path";
import {Server} from "socket.io";
import dns from "dns";
import Uploader from "./plugins/uploader";
import Helper from "./helper";
import Config from "./config";
import colors from "chalk";
import net from "net";
import Identification from "./identification";
import changelog from "./plugins/changelog";
import inputs from "./plugins/inputs";
import Auth from "./plugins/auth";
const themes = require("./plugins/packages/themes");
import themes from "./plugins/packages/themes";
themes.loadLocalThemes();
const packages = require("./plugins/packages/index");
const Chan = require("./models/chan");
import packages from "./plugins/packages/index";
import Chan from "./models/chan";
import {
ClientConfiguration,
Defaults,
IndexTemplateConfiguration,
ServerConfiguration,
} from "./types/config";
import {Server as wsServer} from "ws";
import {ChanType} from "./types/models/channel";
// A random number that will force clients to reload the page if it differs
const serverHash = Math.floor(Date.now() * Math.random());
let manager = null;
module.exports = function (options = {}) {
export default function (
options: ServerOptions = {
dev: false,
}
) {
log.info(`The Lounge ${colors.green(Helper.getVersion())} \
(Node.js ${colors.green(process.versions.node)} on ${colors.green(process.platform)} ${
process.arch
@ -165,11 +178,13 @@ module.exports = function (options = {}) {
);
}
const sockets = io(server, {
wsEngine: require("ws").Server,
const sockets = new Server(server, {
wsEngine: wsServer,
cookie: false,
serveClient: false,
transports: Config.values.transports,
// TODO: type as Server.Transport[]
transports: Config.values.transports as any,
pingTimeout: 60000,
});
@ -250,7 +265,7 @@ module.exports = function (options = {}) {
});
return server;
};
}
function getClientLanguage(socket) {
const acceptLanguage = socket.handshake.headers["accept-language"];
@ -342,7 +357,7 @@ function indexRequest(req, res) {
throw err;
}
const config = getServerConfiguration();
const config = getServerConfiguration() as IndexTemplateConfiguration;
config.cacheBust = Helper.getVersionCacheBust();
res.send(_.template(file)(config));
@ -465,8 +480,8 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
const hash = Helper.password.hash(p1);
client.setPassword(hash, (success) => {
const obj = {success: false};
client.setPassword(hash, (success: boolean) => {
const obj = {success: false, error: undefined};
if (success) {
obj.success = true;
@ -477,7 +492,7 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
socket.emit("change-password", obj);
});
})
.catch((error) => {
.catch((error: any) => {
log.error(`Error while checking users password. Error: ${error}`);
});
}
@ -673,14 +688,14 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
const {chan, network} = client.find(target);
// If the user mutes the lobby, we mute the entire network.
if (chan.type === Chan.Type.LOBBY) {
if (chan.type === ChanType.LOBBY) {
for (const channel of network.channels) {
if (channel.type !== Chan.Type.SPECIAL) {
if (channel.type !== ChanType.SPECIAL) {
channel.setMuteStatus(setMutedTo);
}
}
} else {
if (chan.type !== Chan.Type.SPECIAL) {
if (chan.type !== ChanType.SPECIAL) {
chan.setMuteStatus(setMutedTo);
}
}
@ -757,8 +772,13 @@ function initializeClient(socket, client, token, lastMessage, openChannel) {
}
}
function getClientConfiguration() {
const config = _.pick(Config.values, ["public", "lockNetwork", "useHexIp", "prefetch"]);
function getClientConfiguration(): ClientConfiguration {
const config = _.pick(Config.values, [
"public",
"lockNetwork",
"useHexIp",
"prefetch",
]) as ClientConfiguration;
config.fileUpload = Config.values.fileUpload.enable;
config.ldapEnabled = Config.values.ldap.enable;
@ -774,7 +794,7 @@ function getClientConfiguration() {
"password",
"realname",
"join",
]);
]) as Defaults;
}
config.isUpdateAvailable = changelog.isUpdateAvailable;
@ -795,8 +815,8 @@ function getClientConfiguration() {
return config;
}
function getServerConfiguration() {
const config = _.clone(Config.values);
function getServerConfiguration(): ServerConfiguration {
const config = _.clone(Config.values) as ServerConfiguration;
config.stylesheets = packages.getStylesheets();
@ -917,6 +937,9 @@ function reverseDnsLookup(ip, callback) {
}
dns.resolve(hostnames[0], net.isIP(ip) === 6 ? "AAAA" : "A", (resolveErr, resolvedIps) => {
// TODO: investigate SoaRecord class
if (!Array.isArray(resolvedIps)) return callback(ip);
if (resolveErr || resolvedIps.length < 1) {
return callback(ip);
}

11
src/tsconfig.json Normal file
View file

@ -0,0 +1,11 @@
{
"extends": "../tsconfig.json",
"files": ["index.d.ts"],
"compilerOptions": {
"allowJs": true,
"checkJs": true,
"types": ["node"],
"resolveJsonModule": true
}
}

40
src/types/client.d.ts vendored Normal file
View file

@ -0,0 +1,40 @@
import {MessageType, UserInMessage} from "./models/message";
type ClientConfig = {
log: boolean;
password: string;
sessions: {
[token: string]: {
lastUse: number;
ip: string;
agent: string;
pushSubscription: PushSubscription;
};
};
clientSettings: {
[key: string]: any;
};
browser?: {
language?: string;
ip?: string;
hostname?: string;
isSecure?: boolean;
};
};
type PushSubscription = {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
};
type Mention = {
chanId: number;
msgId: number;
type: MessageType;
time: number;
text: string;
from: UserInMessage;
};

113
src/types/config.d.ts vendored Normal file
View file

@ -0,0 +1,113 @@
type Config = {
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;
};
type ClientConfiguration = Pick<
Config,
"public" | "lockNetwork" | "useHexIp" | "prefetch" | "defaults"
> & {
fileUpload: boolean;
ldapEnabled: boolean;
isUpdateAvailable: boolean;
applicationServerKey: string;
version: string;
gitCommit: string;
defaultTheme: string;
themes: string[];
defaults: Defaults & {
sasl?: string;
saslAccount?: string;
saslPassword?: string;
};
fileUploadMaxFileSize?: number;
};
type ServerConfiguration = Config & {
stylesheets: string[];
};
// TODO: Type this
type WebIRC = {
[key: string]: any;
};
type Https = {
enable: boolean;
key: string;
certificate: string;
ca: string;
};
export type FileUpload = {
enable: boolean;
maxFileSize: number;
baseUrl?: string;
};
export type Defaults = {
name: string;
host: string;
port: number;
password: string;
tls: boolean;
rejectUnauthorized: boolean;
nick: string;
username: string;
realname: string;
join: string;
leaveMessage: string;
};
export type Identd = {
enable: boolean;
port: number;
};
export type Ldap = {
enable: boolean;
url: string;
tlsOptions: any;
primaryKey: string;
searchDN: SearchDN;
};
export type TlsOptions = any;
export type SearchDN = {
rootDN: string;
rootPassword: string;
filter: string;
base: string;
scope: string;
};
export type Debug = {
ircFramework: boolean;
raw: boolean;
};

5
src/types/helper.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
type Hostmask = {
nick: string;
ident: string;
hostname: string;
};

5
src/types/index.d.ts vendored Normal file
View file

@ -0,0 +1,5 @@
/// <reference path="models/index.d.ts" />
/// <reference path="plugins/index.d.ts" />
/// <reference path="config.d.ts" />
/// <reference path="helper.d.ts" />
/// <reference path="server.d.ts" />

27
src/types/models/channel.d.ts vendored Normal file
View file

@ -0,0 +1,27 @@
import Chan from "src/models/chan";
export type Channel = Chan;
export type FilteredChannel = Chan & {
users: [];
totalMessages: number;
};
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,
}

3
src/types/models/index.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference path="channel.d.ts" />
/// <reference path="prefix.d.ts" />
/// <reference path="message.d.ts" />

43
src/types/models/message.d.ts vendored Normal file
View file

@ -0,0 +1,43 @@
import Msg from "src/models/msg";
import User from "src/models/user";
type Message = Msg;
type UserInMessage = Partial<User> & {
mode: string;
};
type MessagePreview = {
link: 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",
}

8
src/types/models/network.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
import NetworkClass from "src/models/network";
export type Network = NetworkClass;
export type NetworkStatus = {
connected: boolean;
secure: boolean;
};

6
src/types/models/prefix.d.ts vendored Normal file
View file

@ -0,0 +1,6 @@
type PrefixSymbol = string;
type PrefixObject = {
symbol: PrefixSymbol;
mode: string;
};

3
src/types/models/user.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
import UserClass from "src/models/user";
export type User = UserClass;

414
src/types/modules/irc-framework.d.ts vendored Normal file
View file

@ -0,0 +1,414 @@
// https://raw.githubusercontent.com/eternagame/HTML-Chat/vue-rewrite/src/app/types/modules/irc-framework/irc-framework.d.ts
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;
};
export class Client extends EventEmitter {
constructor(options: ClientConstructorParameters);
// Added by Max
connection: ConnectionOpts;
network: {
options: {
CHANTYPES: string;
PREFIX: any;
CHANMODES: string;
};
cap: {
isEnabled: (cap: string) => boolean;
enabled: string[];
};
};
// 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?: Object): 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<any> */): void;
ctcpResponse(target: string, type: string /* , params: Array<any> */): 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: {}) => any): this;
on(eventType: "socket connected", cb: (event: {}) => any): this;
on(eventType: "raw socket connected", cb: (event: {}) => 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: Object;
// any
time?: any;
type: string;
}
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"; // TODO
}
export interface JoinEventArgs {
// todo: is that wrong?
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;
}
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;
}
// 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;
}
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: Object): 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 ChannelInfoEventArgs {
channel: string;
created_at?: number;
modes?: Mode[]; // TODO: check type
url?: string;
}
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;
};
}
}

9
src/types/packages/index.d.ts vendored Normal file
View file

@ -0,0 +1,9 @@
/// <reference path="themes.d.ts" />
type PackageInfo = {
packageName: string;
thelounge?: {supports: any};
version: string;
type?: string;
files?: string[];
};

10
src/types/packages/themes.d.ts vendored Normal file
View file

@ -0,0 +1,10 @@
// TODO: move to index.d.ts when more types are added
type Module = {
type: string;
name: string;
};
type ThemeModule = Module & {
themeColor: string;
css: string;
};

View file

@ -0,0 +1,4 @@
type ClientCertificate = {
private_key: string;
certificate: string;
};

3
src/types/plugins/index.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
/// <reference path="sts.d.ts" />
/// <reference path="messageStorage/index.d.ts" />
/// <reference path="clientCertificate.d.ts" />

View file

@ -0,0 +1,26 @@
import {Channel} from "../../models/channel";
import {Message} from "../../models/message";
import {Network} from "../../models/network";
import sqlite from "sqlite3";
import Client from "src/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;
}
interface SqliteMessageStorage extends MessageStorage {
database: sqlite.Database;
}

8
src/types/plugins/sts.d.ts vendored Normal file
View file

@ -0,0 +1,8 @@
type PolicyOption = {
port: number;
duration: number;
expires: number;
host: string;
};
export type PolicyMap = Map<string, Omit<PolicyOption, "host">>;

3
src/types/server.d.ts vendored Normal file
View file

@ -0,0 +1,3 @@
type ServerOptions = {
dev: boolean;
};

View file

@ -13,7 +13,7 @@ describe("Commands", function () {
const lobby = new Chan({
name: "Network Lobby",
type: Chan.Type.LOBBY,
type: ChanType.LOBBY,
});
const testableNetwork = {

View file

@ -24,7 +24,7 @@ describe("Network", function () {
const network = new Network({
name: "Super Nice Network",
channels: [
new Chan({name: "AAAA!", type: Chan.Type.QUERY}),
new Chan({name: "AAAA!", type: ChanType.QUERY}),
new Chan({name: "#thelounge"}),
new Chan({name: "&foobar"}),
],
@ -32,7 +32,7 @@ describe("Network", function () {
network.channels.push(new Chan({name: "#swag"}));
expect(network.channels[0].name).to.equal("Super Nice Network");
expect(network.channels[0].type).to.equal(Chan.Type.LOBBY);
expect(network.channels[0].type).to.equal(ChanType.LOBBY);
});
it("should maintain channel reference", function () {
@ -83,8 +83,8 @@ describe("Network", function () {
new Chan({name: "&foobar", key: "", muted: false}),
new Chan({name: "#secret", key: "foo", muted: false}),
new Chan({name: "&secure", key: "bar", muted: true}),
new Chan({name: "Channel List", type: Chan.Type.SPECIAL}),
new Chan({name: "PrivateChat", type: Chan.Type.QUERY, muted: true}),
new Chan({name: "Channel List", type: ChanType.SPECIAL}),
new Chan({name: "PrivateChat", type: ChanType.QUERY, muted: true}),
],
});
network.setNick("chillin`");
@ -420,7 +420,7 @@ describe("Network", function () {
channels: [chan1, chan2],
});
const newUser = new Chan({name: "mcinkay", type: Chan.Type.QUERY});
const newUser = new Chan({name: "mcinkay", type: ChanType.QUERY});
network.addChannel(newUser);
expect(network.channels[1]).to.equal(chan1);
@ -431,14 +431,14 @@ describe("Network", function () {
it("should sort users alphabetically", function () {
const chan1 = new Chan({name: "#abc"});
const chan2 = new Chan({name: "#THELOUNGE"});
const user1 = new Chan({name: "astorije", type: Chan.Type.QUERY});
const user2 = new Chan({name: "xpaw", type: Chan.Type.QUERY});
const user1 = new Chan({name: "astorije", type: ChanType.QUERY});
const user2 = new Chan({name: "xpaw", type: ChanType.QUERY});
const network = new Network({
channels: [chan1, chan2, user1, user2],
});
const newUser = new Chan({name: "mcinkay", type: Chan.Type.QUERY});
const newUser = new Chan({name: "mcinkay", type: ChanType.QUERY});
network.addChannel(newUser);
expect(network.channels[1]).to.equal(chan1);
@ -451,14 +451,14 @@ describe("Network", function () {
it("should not sort special channels", function () {
const chan1 = new Chan({name: "#abc"});
const chan2 = new Chan({name: "#THELOUNGE"});
const user1 = new Chan({name: "astorije", type: Chan.Type.QUERY});
const user2 = new Chan({name: "xpaw", type: Chan.Type.QUERY});
const user1 = new Chan({name: "astorije", type: ChanType.QUERY});
const user2 = new Chan({name: "xpaw", type: ChanType.QUERY});
const network = new Network({
channels: [chan1, chan2, user1, user2],
});
const newBanlist = new Chan({name: "Banlist for #THELOUNGE", type: Chan.Type.SPECIAL});
const newBanlist = new Chan({name: "Banlist for #THELOUNGE", type: ChanType.SPECIAL});
network.addChannel(newBanlist);
expect(network.channels[1]).to.equal(chan1);
@ -471,15 +471,15 @@ describe("Network", function () {
it("should not compare against special channels", function () {
const chan1 = new Chan({name: "#abc"});
const chan2 = new Chan({name: "#THELOUNGE"});
const user1 = new Chan({name: "astorije", type: Chan.Type.QUERY});
const user1 = new Chan({name: "astorije", type: ChanType.QUERY});
const network = new Network({
channels: [chan1, chan2, user1],
});
const newBanlist = new Chan({name: "Banlist for #THELOUNGE", type: Chan.Type.SPECIAL});
const newBanlist = new Chan({name: "Banlist for #THELOUNGE", type: ChanType.SPECIAL});
network.addChannel(newBanlist);
const newUser = new Chan({name: "mcinkay", type: Chan.Type.QUERY});
const newUser = new Chan({name: "mcinkay", type: ChanType.QUERY});
network.addChannel(newUser);
expect(network.channels[1]).to.equal(chan1);
@ -490,9 +490,9 @@ describe("Network", function () {
});
it("should insert before first special channel", function () {
const banlist = new Chan({name: "Banlist for #THELOUNGE", type: Chan.Type.SPECIAL});
const banlist = new Chan({name: "Banlist for #THELOUNGE", type: ChanType.SPECIAL});
const chan1 = new Chan({name: "#thelounge"});
const user1 = new Chan({name: "astorije", type: Chan.Type.QUERY});
const user1 = new Chan({name: "astorije", type: ChanType.QUERY});
const network = new Network({
channels: [banlist, chan1, user1],

14
tsconfig.json Normal file
View file

@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "esnext",
"module": "commonjs",
"outDir": "dist",
"moduleResolution": "node",
"esModuleInterop": true,
"lib": ["es2019"],
"baseUrl": ".",
"allowJs": true
},
"exclude": ["src/node_modules", "public/*", "client/*"],
"include": ["src/**/*"]
}

3
vetur.config.js Normal file
View file

@ -0,0 +1,3 @@
module.exports = {
projects: ["./client/tsconfig.json"],
};

View file

@ -5,6 +5,7 @@ const path = require("path");
const CopyPlugin = require("copy-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const VueLoaderPlugin = require("vue-loader/lib/plugin");
const TsconfigPathsPlugin = require("tsconfig-paths-webpack-plugin");
const Helper = require("./src/helper.js");
const babelConfig = require("./babel.config.cjs");
@ -12,7 +13,7 @@ const isProduction = process.env.NODE_ENV === "production";
const config = {
mode: isProduction ? "production" : "development",
entry: {
"js/bundle.js": [path.resolve(__dirname, "client/js/vue.js")],
"js/bundle.js": [path.resolve(__dirname, "client/js/vue.ts")],
},
devtool: "source-map",
output: {
@ -28,14 +29,22 @@ const config = {
rules: [
{
test: /\.vue$/,
loader: "vue-loader",
},
{
test: /\.ts$/,
use: {
loader: "vue-loader",
loader: "ts-loader",
// options: {
// compilerOptions: {
// preserveWhitespace: false,
// },
// },
options: {
compilerOptions: {
preserveWhitespace: false,
},
appendTsSuffixTo: [/\.vue$/],
},
},
exclude: path.resolve(__dirname, "node_modules"),
},
{
test: /\.css$/,
@ -63,11 +72,21 @@ const config = {
],
},
{
test: /\.js$/,
test: /\.{js,ts,tsx}$/,
include: [path.resolve(__dirname, "client")],
use: {
loader: "babel-loader",
<<<<<<< HEAD
options: babelConfig,
||||||| parent of f6bd9354 (ts progress)
options: {
presets: [["@babel/env"]],
},
=======
options: {
presets: ["@babel/env", "babel-preset-typescript-vue"],
},
>>>>>>> f6bd9354 (ts progress)
},
},
],
@ -83,11 +102,27 @@ const config = {
},
},
},
resolve: {
alias: {
vue$: "vue/dist/vue.esm.js",
},
extensions: [".js", ".vue", ".json", ".ts"],
// modules: ["node_modules", path.resolve(__dirname, "client")],
plugins: [
new TsconfigPathsPlugin({
configFile: path.resolve(__dirname, "client/tsconfig.json"),
extensions: [".js", ".vue", ".json", ".ts"],
baseUrl: path.resolve(__dirname, "client"),
}),
],
},
externals: {
json3: "JSON", // socket.io uses json3.js, but we do not target any browsers that need it
},
plugins: [
new VueLoaderPlugin(),
new VueLoaderPlugin({
esModule: true,
}),
new MiniCssExtractPlugin({
filename: "css/style.css",
}),

300
yarn.lock
View file

@ -556,6 +556,13 @@
dependencies:
"@babel/helper-plugin-utils" "^7.14.5"
"@babel/plugin-syntax-typescript@^7.16.7":
version "7.17.10"
resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-7.17.10.tgz#80031e6042cad6a95ed753f672ebd23c30933195"
integrity sha512-xJefea1DWXW09pW4Tm9bjwVlPDyYA2it3fWlmEjpYz6alPvTUjL0EOzNzI/FEOyI3r4/J7uVH5UqKgl1TQ5hqQ==
dependencies:
"@babel/helper-plugin-utils" "^7.16.7"
"@babel/plugin-transform-arrow-functions@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.16.7.tgz#44125e653d94b98db76369de9c396dc14bef4154"
@ -791,6 +798,15 @@
dependencies:
"@babel/helper-plugin-utils" "^7.16.7"
"@babel/plugin-transform-typescript@^7.16.7", "@babel/plugin-transform-typescript@^7.3.2":
version "7.16.8"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-typescript/-/plugin-transform-typescript-7.16.8.tgz#591ce9b6b83504903fa9dd3652c357c2ba7a1ee0"
integrity sha512-bHdQ9k7YpBDO2d0NVfkj51DpQcvwIzIusJ7mEUaMlbZq3Kt/U47j24inXZHQ5MDiYpCs+oZiwnXyKedE8+q7AQ==
dependencies:
"@babel/helper-create-class-features-plugin" "^7.16.7"
"@babel/helper-plugin-utils" "^7.16.7"
"@babel/plugin-syntax-typescript" "^7.16.7"
"@babel/plugin-transform-unicode-escapes@^7.16.7":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.16.7.tgz#da8717de7b3287a2c6d659750c964f302b31ece3"
@ -897,6 +913,15 @@
"@babel/types" "^7.4.4"
esutils "^2.0.2"
"@babel/preset-typescript@7.16.7", "@babel/preset-typescript@^7.3.3":
version "7.16.7"
resolved "https://registry.yarnpkg.com/@babel/preset-typescript/-/preset-typescript-7.16.7.tgz#ab114d68bb2020afc069cd51b37ff98a046a70b9"
integrity sha512-WbVEmgXdIyvzB77AQjGBEyYPZx+8tTsO50XtfozQrkW8QB2rLJpH2lgx0TRw5EJrBxOZQ+wCcyPVQvS8tjEHpQ==
dependencies:
"@babel/helper-plugin-utils" "^7.16.7"
"@babel/helper-validator-option" "^7.16.7"
"@babel/plugin-transform-typescript" "^7.16.7"
"@babel/runtime@^7.8.4":
version "7.17.9"
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.17.9.tgz#d19fbf802d01a8cb6cf053a64e472d42c434ba72"
@ -961,6 +986,18 @@
"@babel/helper-validator-identifier" "^7.16.7"
to-fast-properties "^2.0.0"
"@cspotcode/source-map-consumer@0.8.0":
version "0.8.0"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-consumer/-/source-map-consumer-0.8.0.tgz#33bf4b7b39c178821606f669bbc447a6a629786b"
integrity sha512-41qniHzTU8yAGbCp04ohlmSrZf8bkf/iJsl3V0dRGsQN/5GFfx+LbCSsCpp2gqrqjTVg/K6O8ycoV35JIwAzAg==
"@cspotcode/source-map-support@0.7.0":
version "0.7.0"
resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.7.0.tgz#4789840aa859e46d2f3173727ab707c66bf344f5"
integrity sha512-X4xqRHqN8ACt2aHVe51OxeA2HjbcL4MqFqXkrmQszJ1NOUuUu5u6Vqx/0lZSVNku7velL5FC/s5uEAj1lsBMhA==
dependencies:
"@cspotcode/source-map-consumer" "0.8.0"
"@csstools/postcss-font-format-keywords@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@csstools/postcss-font-format-keywords/-/postcss-font-format-keywords-1.0.0.tgz#7e7df948a83a0dfb7eb150a96e2390ac642356a1"
@ -1232,6 +1269,34 @@
resolved "https://registry.yarnpkg.com/@trysound/sax/-/sax-0.2.0.tgz#cccaab758af56761eb7bf37af6f03f326dd798ad"
integrity sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==
"@tsconfig/node10@^1.0.7":
version "1.0.8"
resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.8.tgz#c1e4e80d6f964fbecb3359c43bd48b40f7cadad9"
integrity sha512-6XFfSQmMgq0CFLY1MslA/CPUfhIL919M1rMsa5lP2P097N2Wd1sSX0tx1u4olM16fLNhtHZpRhedZJphNJqmZg==
"@tsconfig/node12@^1.0.7":
version "1.0.9"
resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.9.tgz#62c1f6dee2ebd9aead80dc3afa56810e58e1a04c"
integrity sha512-/yBMcem+fbvhSREH+s14YJi18sp7J9jpuhYByADT2rypfajMZZN4WQ6zBGgBKp53NKmqI36wFYDb3yaMPurITw==
"@tsconfig/node14@^1.0.0":
version "1.0.1"
resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.1.tgz#95f2d167ffb9b8d2068b0b235302fafd4df711f2"
integrity sha512-509r2+yARFfHHE7T6Puu2jjkoycftovhXRqW328PDXTVGKihlb1P8Z9mMZH04ebyajfRY7dedfGynlrFHJUQCg==
"@tsconfig/node16@^1.0.2":
version "1.0.2"
resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e"
integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA==
"@types/body-parser@*":
version "1.19.2"
resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0"
integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g==
dependencies:
"@types/connect" "*"
"@types/node" "*"
"@types/cacheable-request@^6.0.1":
version "6.0.2"
resolved "https://registry.yarnpkg.com/@types/cacheable-request/-/cacheable-request-6.0.2.tgz#c324da0197de0a98a2312156536ae262429ff6b9"
@ -1254,6 +1319,13 @@
resolved "https://registry.yarnpkg.com/@types/component-emitter/-/component-emitter-1.2.11.tgz#50d47d42b347253817a39709fef03ce66a108506"
integrity sha512-SRXjM+tfsSlA9VuG8hGO2nft2p8zjXCK1VcC6N4NXbBbYbSia9kzCChYQajIjzIqOOOuh5Ock6MmV2oux4jDZQ==
"@types/connect@*":
version "3.4.35"
resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1"
integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ==
dependencies:
"@types/node" "*"
"@types/cookie@^0.4.1":
version "0.4.1"
resolved "https://registry.yarnpkg.com/@types/cookie/-/cookie-0.4.1.tgz#bfd02c1f2224567676c1545199f87c3a861d878d"
@ -1290,6 +1362,25 @@
resolved "https://registry.yarnpkg.com/@types/estree/-/estree-0.0.50.tgz#1e0caa9364d3fccd2931c3ed96fdbeaa5d4cca83"
integrity sha512-C6N5s2ZFtuZRj54k2/zyRhNDjJwwcViAM3Nbm8zjBpbqAdZ00mr0CFxvSKeO8Y/e03WVFLpQMdHYVfUd6SB+Hw==
"@types/express-serve-static-core@^4.17.18":
version "4.17.28"
resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.28.tgz#c47def9f34ec81dc6328d0b1b5303d1ec98d86b8"
integrity sha512-P1BJAEAW3E2DJUlkgq4tOL3RyMunoWXqbSCygWo5ZIWTjUgN1YnaXWW4VWl/oc8vs/XoYibEGBKP0uZyF4AHig==
dependencies:
"@types/node" "*"
"@types/qs" "*"
"@types/range-parser" "*"
"@types/express@4.17.13":
version "4.17.13"
resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.13.tgz#a76e2995728999bab51a33fabce1d705a3709034"
integrity sha512-6bSZTPaTIACxn48l50SR+axgrqm6qXFIxrdAKaG6PaJk3+zuUr35hBlgT7vOmJcum+OEaIBLtHV/qloEAFITeA==
dependencies:
"@types/body-parser" "*"
"@types/express-serve-static-core" "^4.17.18"
"@types/qs" "*"
"@types/serve-static" "*"
"@types/http-cache-semantics@*":
version "4.0.1"
resolved "https://registry.yarnpkg.com/@types/http-cache-semantics/-/http-cache-semantics-4.0.1.tgz#0ea7b61496902b95890dc4c3a116b60cb8dae812"
@ -1305,6 +1396,11 @@
resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.11.tgz#d421b6c527a3037f7c84433fd2c4229e016863d3"
integrity sha512-wOuvG1SN4Us4rez+tylwwwCV1psiNVOkJeM3AUWUNWg/jDQY2+HE/444y5gc+jBmRqASOm2Oeh5c1axHobwRKQ==
"@types/json5@^0.0.29":
version "0.0.29"
resolved "https://registry.yarnpkg.com/@types/json5/-/json5-0.0.29.tgz#ee28707ae94e11d2b827bcbe5270bcea7f3e71ee"
integrity sha1-7ihweulOEdK4J7y+UnC86n8+ce4=
"@types/keyv@*":
version "3.1.4"
resolved "https://registry.yarnpkg.com/@types/keyv/-/keyv-3.1.4.tgz#3ccdb1c6751b0c7e52300bcdacd5bcbf8faa75b6"
@ -1312,6 +1408,23 @@
dependencies:
"@types/node" "*"
"@types/ldapjs@2.2.2":
version "2.2.2"
resolved "https://registry.yarnpkg.com/@types/ldapjs/-/ldapjs-2.2.2.tgz#cf79510d8dc34e5579442c2743f8a228427eb99c"
integrity sha512-U5HdnwIZ5uZa+f3usxdqgyfNmOROxOxXvQdQtsu6sKo8fte5vej9br2csHxPvXreAbAO1bs8/rdEzvCLpi67nQ==
dependencies:
"@types/node" "*"
"@types/lodash@4.14.182":
version "4.14.182"
resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.182.tgz#05301a4d5e62963227eaafe0ce04dd77c54ea5c2"
integrity sha512-/THyiqyQAP9AfARo4pF+aCGcyiQ94tX/Is2I7HofNRqoYLgN1PBoOWu2/zTA5zMxzP5EFutMtWtGAFRKUe961Q==
"@types/mime@^1":
version "1.3.2"
resolved "https://registry.yarnpkg.com/@types/mime/-/mime-1.3.2.tgz#93e25bf9ee75fe0fd80b594bc4feb0e862111b5a"
integrity sha512-YATxVxgRqNH6nHEIsvg6k2Boc1JHI9ZbH5iWFFv/MTkchz3b1ieGDa5T0a9RznNdI0KhVbdbWSN+KWWrQZRxTw==
"@types/minimatch@^3.0.3":
version "3.0.5"
resolved "https://registry.yarnpkg.com/@types/minimatch/-/minimatch-3.0.5.tgz#1001cc5e6a3704b83c236027e77f2f58ea010f40"
@ -1322,6 +1435,11 @@
resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c"
integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ==
"@types/mousetrap@1.6.9":
version "1.6.9"
resolved "https://registry.yarnpkg.com/@types/mousetrap/-/mousetrap-1.6.9.tgz#f1ef9adbd1eac3466f21b6988b1c82c633a45340"
integrity sha512-HUAiN65VsRXyFCTicolwb5+I7FM6f72zjMWr+ajGk+YTvzBgXqa2A5U7d+rtsouAkunJ5U4Sb5lNJjo9w+nmXg==
"@types/node@*", "@types/node@>=10.0.0":
version "17.0.23"
resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.23.tgz#3b41a6e643589ac6442bdbd7a4a3ded62f33f7da"
@ -1337,6 +1455,16 @@
resolved "https://registry.yarnpkg.com/@types/parse-json/-/parse-json-4.0.0.tgz#2f8bb441434d163b35fb8ffdccd7138927ffb8c0"
integrity sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==
"@types/qs@*":
version "6.9.7"
resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb"
integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw==
"@types/range-parser@*":
version "1.2.4"
resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc"
integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw==
"@types/responselike@*", "@types/responselike@^1.0.0":
version "1.0.0"
resolved "https://registry.yarnpkg.com/@types/responselike/-/responselike-1.0.0.tgz#251f4fe7d154d2bad125abe1b429b23afd262e29"
@ -1344,6 +1472,38 @@
dependencies:
"@types/node" "*"
"@types/serve-static@*":
version "1.13.10"
resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.13.10.tgz#f5e0ce8797d2d7cc5ebeda48a52c96c4fa47a8d9"
integrity sha512-nCkHGI4w7ZgAdNkrEu0bv+4xNV/XDqW+DydknebMOQwkpDGx8G+HTlj7R7ABI8i8nKxVw0wtKPi1D+lPOkh4YQ==
dependencies:
"@types/mime" "^1"
"@types/node" "*"
"@types/sqlite3@3.1.8":
version "3.1.8"
resolved "https://registry.yarnpkg.com/@types/sqlite3/-/sqlite3-3.1.8.tgz#e64310c5841fc01c1a8795d960d951e4cf940296"
integrity sha512-sQMt/qnyUWnqiTcJXm5ZfNPIBeJ/DVvJDwxw+0tAxPJvadzfiP1QhryO1JOR6t1yfb8NpzQb/Rud06mob5laIA==
dependencies:
"@types/node" "*"
"@types/ua-parser-js@0.7.36":
version "0.7.36"
resolved "https://registry.yarnpkg.com/@types/ua-parser-js/-/ua-parser-js-0.7.36.tgz#9bd0b47f26b5a3151be21ba4ce9f5fa457c5f190"
integrity sha512-N1rW+njavs70y2cApeIw1vLMYXRwfBy+7trgavGuuTfOd7j1Yh7QTRc/yqsPl6ncokt72ZXuxEU0PiCp9bSwNQ==
"@types/uuid@8.3.4":
version "8.3.4"
resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-8.3.4.tgz#bd86a43617df0594787d38b735f55c805becf1bc"
integrity sha512-c/I8ZRb51j+pYGAu5CrFMRxqZ2ke4y2grEBO5AUjgSkSk+qT2Ea+OdWElz/OiMf5MNpn2b17kuVBwZLQJXzihw==
"@types/ws@8.5.3":
version "8.5.3"
resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.3.tgz#7d25a1ffbecd3c4f2d35068d0b283c037003274d"
integrity sha512-6YOoWjruKj1uLf3INHH7D3qTXwFfEsg1kf3c0uDdSBJwfa/llkwIjrAGV7j7mVgGNbzTQ3HiHKKDXl6bJPD97w==
dependencies:
"@types/node" "*"
"@ungap/promise-all-settled@1.1.2":
version "1.1.2"
resolved "https://registry.yarnpkg.com/@ungap/promise-all-settled/-/promise-all-settled-1.1.2.tgz#aa58042711d6e3275dd37dc597e5d31e8c290a44"
@ -1365,6 +1525,30 @@
optionalDependencies:
prettier "^1.18.2 || ^2.0.0"
"@vue/reactivity@3.2.33":
version "3.2.33"
resolved "https://registry.yarnpkg.com/@vue/reactivity/-/reactivity-3.2.33.tgz#c84eedb5225138dbfc2472864c151d3efbb4b673"
integrity sha512-62Sq0mp9/0bLmDuxuLD5CIaMG2susFAGARLuZ/5jkU1FCf9EDbwUuF+BO8Ub3Rbodx0ziIecM/NsmyjardBxfQ==
dependencies:
"@vue/shared" "3.2.33"
"@vue/runtime-core@3.2.33":
version "3.2.33"
resolved "https://registry.yarnpkg.com/@vue/runtime-core/-/runtime-core-3.2.33.tgz#2df8907c85c37c3419fbd1bdf1a2df097fa40df2"
integrity sha512-N2D2vfaXsBPhzCV3JsXQa2NECjxP3eXgZlFqKh4tgakp3iX6LCGv76DLlc+IfFZq+TW10Y8QUfeihXOupJ1dGw==
dependencies:
"@vue/reactivity" "3.2.33"
"@vue/shared" "3.2.33"
"@vue/runtime-dom@3.2.33":
version "3.2.33"
resolved "https://registry.yarnpkg.com/@vue/runtime-dom/-/runtime-dom-3.2.33.tgz#123b8969247029ea0d9c1983676d4706a962d848"
integrity sha512-LSrJ6W7CZTSUygX5s8aFkraDWlO6K4geOwA3quFF2O+hC3QuAMZt/0Xb7JKE3C4JD4pFwCSO7oCrZmZ0BIJUnw==
dependencies:
"@vue/runtime-core" "3.2.33"
"@vue/shared" "3.2.33"
csstype "^2.6.8"
"@vue/server-test-utils@1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@vue/server-test-utils/-/server-test-utils-1.3.0.tgz#56c8f41cbb4ed9af38a5668cc23f861fcbbcd44b"
@ -1373,6 +1557,11 @@
"@types/cheerio" "^0.22.10"
cheerio "^1.0.0-rc.2"
"@vue/shared@3.2.33":
version "3.2.33"
resolved "https://registry.yarnpkg.com/@vue/shared/-/shared-3.2.33.tgz#69a8c99ceb37c1b031d5cc4aec2ff1dc77e1161e"
integrity sha512-UBc1Pg1T3yZ97vsA2ueER0F6GbJebLHYlEi4ou1H5YL4KWvMOOWwpYo9/QpWq93wxKG6Wo13IY74Hcn/f7c7Bg==
"@vue/test-utils@1.3.0":
version "1.3.0"
resolved "https://registry.yarnpkg.com/@vue/test-utils/-/test-utils-1.3.0.tgz#d563decdcd9c68a7bca151d4179a2bfd6d5c3e15"
@ -1558,6 +1747,11 @@ acorn-jsx@^5.3.1:
resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937"
integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==
acorn-walk@^8.1.1:
version "8.2.0"
resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1"
integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA==
acorn@^8.4.1, acorn@^8.5.0, acorn@^8.7.0:
version "8.7.0"
resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.7.0.tgz#90951fde0f8f09df93549481e5fc141445b791cf"
@ -1706,6 +1900,11 @@ are-we-there-yet@^3.0.0:
delegates "^1.0.0"
readable-stream "^3.6.0"
arg@^4.1.0:
version "4.1.3"
resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089"
integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==
argparse@^1.0.7:
version "1.0.10"
resolved "https://registry.yarnpkg.com/argparse/-/argparse-1.0.10.tgz#bcd6791ea5ae09725e17e5ad988134cd40b3d911"
@ -1849,6 +2048,16 @@ babel-plugin-polyfill-regenerator@^0.3.0:
dependencies:
"@babel/helper-define-polyfill-provider" "^0.3.1"
babel-preset-typescript-vue@1.1.1:
version "1.1.1"
resolved "https://registry.yarnpkg.com/babel-preset-typescript-vue/-/babel-preset-typescript-vue-1.1.1.tgz#6a617dcb0ee26f911735d5f2bbe530286b2c7c02"
integrity sha512-wXeR7Y4xCsRUEdm4t4qlpv4wnxolS6jU0c7P2E6zJRWeG1sR0e6NL7DRN0tNuUwkUt0PU8bqVo4vzoA2VEuxnw==
dependencies:
"@babel/helper-plugin-utils" "^7.0.0"
"@babel/plugin-transform-typescript" "^7.3.2"
"@babel/preset-typescript" "^7.3.3"
vue-template-compiler "^2.6.11"
backo2@~1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/backo2/-/backo2-1.0.2.tgz#31ab1ac8b129363463e35b3ebb69f4dfcfba7947"
@ -2447,6 +2656,11 @@ cosmiconfig@^7.0.0, cosmiconfig@^7.0.1:
path-type "^4.0.0"
yaml "^1.10.0"
create-require@^1.1.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333"
integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==
cross-spawn@^6.0.5:
version "6.0.5"
resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4"
@ -2595,6 +2809,11 @@ csso@^4.2.0:
dependencies:
css-tree "^1.1.2"
csstype@^2.6.8:
version "2.6.20"
resolved "https://registry.yarnpkg.com/csstype/-/csstype-2.6.20.tgz#9229c65ea0b260cf4d3d997cb06288e36a8d6dda"
integrity sha512-/WwNkdXfckNgw6S5R125rrW8ez139lBHWouiBvX8dfMFtcn6V81REDqnH7+CRpRipfYlyU1CmOnOxrmGcFOjeA==
dayjs@1.10.8:
version "1.10.8"
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.8.tgz#267df4bc6276fcb33c04a6735287e3f429abec41"
@ -2712,6 +2931,11 @@ diff@5.0.0, diff@^5.0.0:
resolved "https://registry.yarnpkg.com/diff/-/diff-5.0.0.tgz#7ed6ad76d859d030787ec35855f5b1daf31d852b"
integrity sha512-/VTCrvm5Z0JGty/BWHljh+BAiw3IK+2j87NGMu8Nwc/f48WoDAC395uomO9ZD117ZOBaHmkX1oyLvkVM/aIT3w==
diff@^4.0.1:
version "4.0.2"
resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d"
integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A==
dir-glob@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/dir-glob/-/dir-glob-3.0.1.tgz#56dbf73d992a4a93ba1584f4534063fd2e41717f"
@ -2865,7 +3089,7 @@ engine.io@~6.1.0:
engine.io-parser "~5.0.3"
ws "~8.2.3"
enhanced-resolve@^5.8.3:
enhanced-resolve@^5.0.0, enhanced-resolve@^5.7.0, enhanced-resolve@^5.8.3:
version "5.9.3"
resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.9.3.tgz#44a342c012cbc473254af5cc6ae20ebd0aae5d88"
integrity sha512-Bq9VSor+kjvW3f9/MiiR4eE3XYgOl7/rS8lnSxbRbF3kS0B2r+Y9w5krBWxZgDxASVZbdYrn5wT4j/Wb0J9qow==
@ -4576,6 +4800,11 @@ make-dir@^3.0.0, make-dir@^3.0.2, make-dir@^3.1.0:
dependencies:
semver "^6.0.0"
make-error@^1.1.1:
version "1.3.6"
resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2"
integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==
make-fetch-happen@^9.1.0:
version "9.1.0"
resolved "https://registry.yarnpkg.com/make-fetch-happen/-/make-fetch-happen-9.1.0.tgz#53085a09e7971433e6765f7971bf63f4e05cb968"
@ -4680,7 +4909,7 @@ methods@~1.1.2:
resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee"
integrity sha1-VSmk1nZUE07cxSZmVoNbD4Ua/O4=
micromatch@^4.0.4:
micromatch@^4.0.0, micromatch@^4.0.4:
version "4.0.5"
resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6"
integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==
@ -4777,7 +5006,7 @@ minimist-options@4.1.0:
is-plain-obj "^1.1.0"
kind-of "^6.0.3"
minimist@^1.2.0, minimist@^1.2.5:
minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6:
version "1.2.6"
resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44"
integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q==
@ -7123,6 +7352,54 @@ trim-repeated@^1.0.0:
dependencies:
escape-string-regexp "^1.0.2"
ts-loader@9.3.0:
version "9.3.0"
resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.3.0.tgz#980f4dbfb60e517179e15e10ed98e454b132159f"
integrity sha512-2kLLAdAD+FCKijvGKi9sS0OzoqxLCF3CxHpok7rVgCZ5UldRzH0TkbwG9XECKjBzHsAewntC5oDaI/FwKzEUog==
dependencies:
chalk "^4.1.0"
enhanced-resolve "^5.0.0"
micromatch "^4.0.0"
semver "^7.3.4"
ts-node@10.7.0:
version "10.7.0"
resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.7.0.tgz#35d503d0fab3e2baa672a0e94f4b40653c2463f5"
integrity sha512-TbIGS4xgJoX2i3do417KSaep1uRAW/Lu+WAL2doDHC0D6ummjirVOXU5/7aiZotbQ5p1Zp9tP7U6cYhA0O7M8A==
dependencies:
"@cspotcode/source-map-support" "0.7.0"
"@tsconfig/node10" "^1.0.7"
"@tsconfig/node12" "^1.0.7"
"@tsconfig/node14" "^1.0.0"
"@tsconfig/node16" "^1.0.2"
acorn "^8.4.1"
acorn-walk "^8.1.1"
arg "^4.1.0"
create-require "^1.1.0"
diff "^4.0.1"
make-error "^1.1.1"
v8-compile-cache-lib "^3.0.0"
yn "3.1.1"
tsconfig-paths-webpack-plugin@3.5.2:
version "3.5.2"
resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-3.5.2.tgz#01aafff59130c04a8c4ebc96a3045c43c376449a"
integrity sha512-EhnfjHbzm5IYI9YPNVIxx1moxMI4bpHD2e0zTXeDNQcwjjRaGepP7IhTHJkyDBG0CAOoxRfe7jCG630Ou+C6Pw==
dependencies:
chalk "^4.1.0"
enhanced-resolve "^5.7.0"
tsconfig-paths "^3.9.0"
tsconfig-paths@^3.9.0:
version "3.14.1"
resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a"
integrity sha512-fxDhWnFSLt3VuTwtvJt5fpwxBHg5AdKWMsgcPOOIilyjymcYVZoCQF8fvFRezCNfblEXmi+PcM1eYHeOAgXCOQ==
dependencies:
"@types/json5" "^0.0.29"
json5 "^1.0.1"
minimist "^1.2.6"
strip-bom "^3.0.0"
tslib@^2.2.0:
version "2.3.1"
resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.3.1.tgz#e8a335add5ceae51aa261d32a490158ef042ef01"
@ -7175,6 +7452,11 @@ typedarray-to-buffer@^3.1.5:
dependencies:
is-typedarray "^1.0.0"
typescript@4.6.4:
version "4.6.4"
resolved "https://registry.yarnpkg.com/typescript/-/typescript-4.6.4.tgz#caa78bbc3a59e6a5c510d35703f6a09877ce45e9"
integrity sha512-9ia/jWHIEbo49HfjrLGfKbZSuWo9iTMwXO+Ca3pRsSpbsMbc7/IU8NKdCZVRRBafVPGnoJeFL76ZOAA84I9fEg==
ua-parser-js@1.0.2:
version "1.0.2"
resolved "https://registry.yarnpkg.com/ua-parser-js/-/ua-parser-js-1.0.2.tgz#e2976c34dbfb30b15d2c300b2a53eac87c57a775"
@ -7286,6 +7568,11 @@ uuid@^3.3.3:
resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.4.0.tgz#b23e4358afa8a202fe7a100af1f5f883f02007ee"
integrity sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==
v8-compile-cache-lib@^3.0.0:
version "3.0.1"
resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf"
integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==
v8-compile-cache@^2.0.3, v8-compile-cache@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/v8-compile-cache/-/v8-compile-cache-2.3.0.tgz#2de19618c66dc247dcfb6f99338035d8245a2cee"
@ -7385,7 +7672,7 @@ vue-style-loader@^4.1.0:
hash-sum "^1.0.2"
loader-utils "^1.0.2"
vue-template-compiler@2.6.14:
vue-template-compiler@2.6.14, vue-template-compiler@^2.6.11:
version "2.6.14"
resolved "https://registry.yarnpkg.com/vue-template-compiler/-/vue-template-compiler-2.6.14.tgz#a2f0e7d985670d42c9c9ee0d044fed7690f4f763"
integrity sha512-ODQS1SyMbjKoO1JBJZojSw6FE4qnh9rIpUZn2EUT86FKizx9uH5z6uXiIrm4/Nb/gwxTi/o17ZDEGWAXHvtC7g==
@ -7752,6 +8039,11 @@ yeast@0.1.2:
resolved "https://registry.yarnpkg.com/yeast/-/yeast-0.1.2.tgz#008e06d8094320c372dbc2f8ed76a0ca6c8ac419"
integrity sha1-AI4G2AlDIMNy28L47XagymyKxBk=
yn@3.1.1:
version "3.1.1"
resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50"
integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
yocto-queue@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b"