From 642442c0417fe7a4525acdd8d179760058f5c9bd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Michel?= Date: Tue, 21 Mar 2017 15:15:33 +0100 Subject: [PATCH 01/72] Implement a proper LDAP authentication process The Lounge first log as a special user in order to search (as in LDAP's '"search" verb) for the user's full DN. It then attempts to bind using the found user DN and the user provided password. --- defaults/config.js | 70 ++++++++++++++++++++++++++++++++++++++++++++-- src/server.js | 65 ++++++++++++++++++++++++++++++++++++------ 2 files changed, 123 insertions(+), 12 deletions(-) diff --git a/defaults/config.js b/defaults/config.js index 98f4876c..742449f7 100644 --- a/defaults/config.js +++ b/defaults/config.js @@ -329,6 +329,23 @@ module.exports = { // @type object // @default {} // + // The authentication process works as follows: + // + // 1. Lounge connects to the LDAP server with its system credentials + // 2. It performs a LDAP search query to find the full DN associated to the + // user requesting to log in. + // 3. Lounge tries to connect a second time, but this time using the user's + // DN and password. Auth is validated iff this connection is successful. + // + // The search query takes a couple of parameters: + // - A base DN. Only children nodes of this DN will be likely to be returned + // - A search scope (see LDAP documentation) + // - The query itself, build as (&(=) ) + // where is the user name provided in the log in request, + // is provided by the config and is a filtering complement + // also given in the config, to filter for instance only for nodes of type + // inetOrgPerson, or whatever LDAP search allows. + // ldap: { // // Enable LDAP user authentication @@ -346,11 +363,35 @@ module.exports = { url: "ldaps://example.com", // - // LDAP base dn + // LDAP connection tls options (only used if scheme is ldaps://) + // + // @type object (see nodejs' tls.connect() options) + // @default {} + // + // Example: + // You can use this option in order to force the use of IPv6: + // { + // host: 'my::ip::v6' + // servername: 'ldaps://example.com' + // } + tlsOptions: {}, + + // + // LDAP searching bind DN + // This bind DN is used to query the server for the DN of the user. + // This is supposed to be a system user that has access in read only to + // the DNs of the people that are allowed to log in. // // @type string // - baseDN: "ou=accounts,dc=example,dc=com", + rootDN: "cn=thelounge,ou=system-users,dc=example,dc=com", + + // + // Password of the lounge LDAP system user + // + // @type string + // + rootPassword: "1234", // // LDAP primary key @@ -358,7 +399,30 @@ module.exports = { // @type string // @default "uid" // - primaryKey: "uid" + primaryKey: "uid", + + // + // LDAP filter + // + // @type string + // @default "uid" + // + filter: "(objectClass=inetOrgPerson)(memberOf=ou=accounts,dc=example,dc=com)", + + // + // LDAP search base (search only within this node) + // + // @type string + // + base: "dc=example,dc=com", + + // + // LDAP search scope + // + // @type string + // @default "sub" + // + scope: "sub" }, // Extra debugging diff --git a/src/server.js b/src/server.js index 636b5ab6..9fcd759a 100644 --- a/src/server.js +++ b/src/server.js @@ -283,26 +283,73 @@ function localAuth(client, user, password, callback) { } function ldapAuth(client, user, password, callback) { + if (!user) { + return callback(false); + } var userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); - var bindDN = Helper.config.ldap.primaryKey + "=" + userDN + "," + Helper.config.ldap.baseDN; var ldapclient = ldap.createClient({ - url: Helper.config.ldap.url + url: Helper.config.ldap.url, + tlsOptions: Helper.config.ldap.tlsOptions }); + var base = Helper.config.ldap.base; + var searchOptions = { + scope: Helper.config.ldap.scope, + filter: '(&(' + Helper.config.ldap.primaryKey + '=' + userDN + ')' + Helper.config.ldap.filter + ')', + attributes: ['dn'] + }; + ldapclient.on("error", function(err) { log.error("Unable to connect to LDAP server", err); callback(!err); }); - ldapclient.bind(bindDN, password, function(err) { - if (!err && !client) { - if (!manager.addUser(user, null)) { - log.error("Unable to create new user", user); - } + ldapclient.bind(Helper.config.ldap.rootDN, + Helper.config.ldap.rootPassword, + function(err) { + if (err) { + log.error("Invalid LDAP root credentials"); + ldapclient.unbind(); + callback(false); + } else { + ldapclient.search(base, searchOptions, function(err, res) { + if (err) { + log.warning("User not found: ", userDN); + ldapclient.unbind(); + callback(false); + } else { + var found = false; + res.on('searchEntry', function(entry) { + found = true; + var bindDN = entry.objectName; + log.info("Auth against LDAP ", Helper.config.ldap.url, " with bindDN ", bindDN); + ldapclient.unbind() + var ldapclient2 = ldap.createClient({ + url: Helper.config.ldap.url, + tlsOptions: Helper.config.ldap.tlsOptions + }); + ldapclient2.bind(bindDN, password, function(err) { + if (!err && !client) { + if (!manager.addUser(user, null)) { + log.error("Unable to create new user", user); + } + } + ldapclient2.unbind(); + callback(!err); + }); + }); + res.on('error', function(resErr) { + callback(false); + }); + res.on('end', function(result) { + if (!found) { + callback(false); + } + }); + } + }); } - ldapclient.unbind(); - callback(!err); }); } From ed3b4faa62a459a818c321d0850b58312b91476f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Michel?= Date: Tue, 21 Mar 2017 15:49:54 +0100 Subject: [PATCH 02/72] Fix eslint styling issues --- src/server.js | 42 +++++++++++++++++++++--------------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/src/server.js b/src/server.js index 9fcd759a..6af0fe47 100644 --- a/src/server.js +++ b/src/server.js @@ -286,18 +286,19 @@ function ldapAuth(client, user, password, callback) { if (!user) { return callback(false); } + var config = Helper.config; var userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); var ldapclient = ldap.createClient({ - url: Helper.config.ldap.url, - tlsOptions: Helper.config.ldap.tlsOptions + url: config.ldap.url, + tlsOptions: config.ldap.tlsOptions }); - var base = Helper.config.ldap.base; + var base = config.ldap.base; var searchOptions = { - scope: Helper.config.ldap.scope, - filter: '(&(' + Helper.config.ldap.primaryKey + '=' + userDN + ')' + Helper.config.ldap.filter + ')', - attributes: ['dn'] + scope: config.ldap.scope, + filter: "(&(" + config.ldap.primaryKey + "=" + userDN + ")" + config.ldap.filter + ")", + attributes: ["dn"] }; ldapclient.on("error", function(err) { @@ -305,44 +306,43 @@ function ldapAuth(client, user, password, callback) { callback(!err); }); - ldapclient.bind(Helper.config.ldap.rootDN, - Helper.config.ldap.rootPassword, - function(err) { + ldapclient.bind(config.ldap.rootDN, config.ldap.rootPassword, function(err) { if (err) { log.error("Invalid LDAP root credentials"); ldapclient.unbind(); callback(false); } else { - ldapclient.search(base, searchOptions, function(err, res) { - if (err) { + ldapclient.search(base, searchOptions, function(err2, res) { + if (err2) { log.warning("User not found: ", userDN); ldapclient.unbind(); callback(false); } else { var found = false; - res.on('searchEntry', function(entry) { + res.on("searchEntry", function(entry) { found = true; var bindDN = entry.objectName; - log.info("Auth against LDAP ", Helper.config.ldap.url, " with bindDN ", bindDN); - ldapclient.unbind() + log.info("Auth against LDAP ", config.ldap.url, " with bindDN ", bindDN); + ldapclient.unbind(); var ldapclient2 = ldap.createClient({ - url: Helper.config.ldap.url, - tlsOptions: Helper.config.ldap.tlsOptions + url: config.ldap.url, + tlsOptions: config.ldap.tlsOptions }); - ldapclient2.bind(bindDN, password, function(err) { - if (!err && !client) { + ldapclient2.bind(bindDN, password, function(err3) { + if (!err3 && !client) { if (!manager.addUser(user, null)) { log.error("Unable to create new user", user); } } ldapclient2.unbind(); - callback(!err); + callback(!err3); }); }); - res.on('error', function(resErr) { + res.on("error", function(err3) { + log.error("LDAP error: ", err3); callback(false); }); - res.on('end', function(result) { + res.on("end", function() { if (!found) { callback(false); } From 94d40256d98c55c82b0587b08f501685a35e9db5 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Fri, 1 Sep 2017 14:43:24 +0300 Subject: [PATCH 03/72] Always create condensed wrapper --- client/js/lounge.js | 2 +- client/js/render.js | 29 +++++------------------------ client/views/date-marker.tpl | 4 ++-- 3 files changed, 8 insertions(+), 27 deletions(-) diff --git a/client/js/lounge.js b/client/js/lounge.js index bd0da92d..4e7590b4 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -789,7 +789,7 @@ $(function() { $(".date-marker-text[data-label='Today'], .date-marker-text[data-label='Yesterday']") .closest(".date-marker-container") .each(function() { - $(this).replaceWith(templates.date_marker({msgDate: $(this).data("timestamp")})); + $(this).replaceWith(templates.date_marker({time: $(this).data("time")})); }); // This should always be 24h later but re-computing exact value just in case diff --git a/client/js/render.js b/client/js/render.js index 748ef771..bb6c4138 100644 --- a/client/js/render.js +++ b/client/js/render.js @@ -35,37 +35,17 @@ function buildChannelMessages(chanId, chanType, messages) { } function appendMessage(container, chanId, chanType, msg) { + let lastChild = container.children(".msg, .date-marker-container").last(); const renderedMessage = buildChatMessage(chanId, msg); // Check if date changed - let lastChild = container.find(".msg").last(); const msgTime = new Date(msg.time); - - // It's the first message in a window, - // then just append the message and do nothing else - if (lastChild.length === 0) { - container - .append(templates.date_marker({msgDate: msgTime})) - .append(renderedMessage); - - return; - } - - const prevMsgTime = new Date(lastChild.attr("data-time")); - const parent = lastChild.parent(); - - // If this message is condensed, we have to work on the wrapper - if (parent.hasClass("condensed")) { - lastChild = parent; - } + const prevMsgTime = new Date(lastChild.data("time")); // Insert date marker if date changed compared to previous message if (prevMsgTime.toDateString() !== msgTime.toDateString()) { - lastChild.after(templates.date_marker({msgDate: msgTime})); - - // If date changed, we don't need to do condensed logic - container.append(renderedMessage); - return; + lastChild = $(templates.date_marker({msgDate: msg.time})); + container.append(lastChild); } // If current window is not a channel or this message is not condensable, @@ -83,6 +63,7 @@ function appendMessage(container, chanId, chanType, msg) { return; } + // Always create a condensed container const newCondensed = buildChatMessage(chanId, { type: "condensed", time: msg.time, diff --git a/client/views/date-marker.tpl b/client/views/date-marker.tpl index 9e67f09f..b1d20be7 100644 --- a/client/views/date-marker.tpl +++ b/client/views/date-marker.tpl @@ -1,5 +1,5 @@ -
+
- +
From d543123095605dbb39d03900faae20d9aacd9a0d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Sat, 2 Sep 2017 08:49:47 -0400 Subject: [PATCH 04/72] Bump default image prefetch limit to 2MB --- defaults/config.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/defaults/config.js b/defaults/config.js index d9df8881..28917220 100644 --- a/defaults/config.js +++ b/defaults/config.js @@ -89,12 +89,12 @@ module.exports = { // Prefetch URLs Image Preview size limit // // If prefetch is enabled, The Lounge will only display content under the maximum size. - // Specified value is in kilobytes. Default value is 512 kilobytes. + // Specified value is in kilobytes. Default value is 2048 kilobytes. // // @type int - // @default 512 + // @default 2048 // - prefetchMaxImageSize: 512, + prefetchMaxImageSize: 2048, // // Display network From 7ee808169db2c246a39e5ae3a22a2febd8c0adfb Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Sat, 26 Aug 2017 19:36:18 +0300 Subject: [PATCH 05/72] Format messages on copy Fixes #1146 --- client/css/style.css | 12 +++++++++++- client/js/clipboard.js | 38 ++++++++++++++++++++++++++++++++++++++ client/js/lounge.js | 1 + 3 files changed, 50 insertions(+), 1 deletion(-) create mode 100644 client/js/clipboard.js diff --git a/client/css/style.css b/client/css/style.css index 775aff25..4ead5c6c 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -174,6 +174,7 @@ kbd { color: rgba(0, 0, 0, 0.35) !important; } +#js-copy-hack, #help, #windows .header .title, #windows .header .topic, @@ -185,6 +186,16 @@ kbd { cursor: text; } +#js-copy-hack { + position: absolute; + left: -999999px; +} + +#chat #js-copy-hack .condensed:not(.closed) .msg, +#chat #js-copy-hack > .msg { + display: block; +} + /* Icons */ #viewport .lt::before, @@ -1001,7 +1012,6 @@ kbd { #chat .time, #chat .from, #chat .content { - display: block; padding: 2px 0; flex: 0 0 auto; } diff --git a/client/js/clipboard.js b/client/js/clipboard.js new file mode 100644 index 00000000..8b8ba81b --- /dev/null +++ b/client/js/clipboard.js @@ -0,0 +1,38 @@ +"use strict"; + +const $ = require("jquery"); +const chat = document.getElementById("chat"); + +function copyMessages() { + const selection = window.getSelection(); + + // If selection does not span multiple elements, do nothing + if (selection.anchorNode === selection.focusNode) { + return; + } + + const range = selection.getRangeAt(0); + const documentFragment = range.cloneContents(); + const div = document.createElement("div"); + + $(documentFragment) + .find(".from .user") + .each((_, el) => { + el = $(el); + el.text(`<${el.text()}>`); + }); + + div.id = "js-copy-hack"; + div.appendChild(documentFragment); + chat.appendChild(div); + + selection.selectAllChildren(div); + + window.setTimeout(() => { + chat.removeChild(div); + selection.removeAllRanges(); + selection.addRange(range); + }, 0); +} + +$(chat).on("copy", ".messages", copyMessages); diff --git a/client/js/lounge.js b/client/js/lounge.js index 4e7590b4..324f6f47 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -21,6 +21,7 @@ const options = require("./options"); const utils = require("./utils"); require("./autocompletion"); require("./webpush"); +require("./clipboard"); $(function() { var sidebar = $("#sidebar, #footer"); From c845d5723ddff5d4eb5f86d97114f5f2500cca8d Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Sun, 3 Sep 2017 15:13:56 +0300 Subject: [PATCH 06/72] One line server startup errors --- src/server.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/server.js b/src/server.js index 664c2004..993ee223 100644 --- a/src/server.js +++ b/src/server.js @@ -94,6 +94,8 @@ module.exports = function() { }; } + server.on("error", (err) => log.error(`${err}`)); + server.listen(listenParams, () => { if (typeof listenParams === "string") { log.info("Available on socket " + colors.green(listenParams)); From cfa6db10c7db7075679103285fbbe861df92d97a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Michel?= Date: Tue, 29 Aug 2017 18:05:06 +0200 Subject: [PATCH 07/72] Make new LDAP options backward compatible Also draft some kind of plugin system for auth, although it essentially consists in writing a function and there is no mechanism to automatically fallback from one auth to another --- defaults/config.js | 97 +++++++++++++++---------- src/plugins/auth/_ldapCommon.js | 29 ++++++++ src/plugins/auth/advancedLdap.js | 72 +++++++++++++++++++ src/plugins/auth/ldap.js | 21 ++++++ src/plugins/auth/local.js | 38 ++++++++++ src/server.js | 117 +++---------------------------- 6 files changed, 232 insertions(+), 142 deletions(-) create mode 100644 src/plugins/auth/_ldapCommon.js create mode 100644 src/plugins/auth/advancedLdap.js create mode 100644 src/plugins/auth/ldap.js create mode 100644 src/plugins/auth/local.js diff --git a/defaults/config.js b/defaults/config.js index eeb02ea5..f26bb650 100644 --- a/defaults/config.js +++ b/defaults/config.js @@ -371,15 +371,22 @@ module.exports = { // 3. Lounge tries to connect a second time, but this time using the user's // DN and password. Auth is validated iff this connection is successful. // - // The search query takes a couple of parameters: - // - A base DN. Only children nodes of this DN will be likely to be returned - // - A search scope (see LDAP documentation) - // - The query itself, build as (&(=) ) + // The search query takes a couple of parameters in `searchDN`: + // - a base DN `searchDN/base`. Only children nodes of this DN will be likely + // to be returned; + // - a search scope `searchDN/scope` (see LDAP documentation); + // - the query itself, build as (&(=) ) // where is the user name provided in the log in request, // is provided by the config and is a filtering complement // also given in the config, to filter for instance only for nodes of type // inetOrgPerson, or whatever LDAP search allows. // + // Alternatively, you can specify the `bindDN` parameter. This will make the lounge + // ignore searchDN options and assume that the user DN is always: + // ,= + // where is the user name provided in the log in request, and + // and are provided by the config. + // ldap: { // // Enable LDAP user authentication @@ -399,33 +406,23 @@ module.exports = { // // LDAP connection tls options (only used if scheme is ldaps://) // - // @type object (see nodejs' tls.connect() options) + // @type object (see nodejs' tls.connect() options) // @default {} // // Example: // You can use this option in order to force the use of IPv6: // { - // host: 'my::ip::v6' - // servername: 'ldaps://example.com' + // host: 'my::ip::v6', + // servername: 'example.com' // } tlsOptions: {}, // - // LDAP searching bind DN - // This bind DN is used to query the server for the DN of the user. - // This is supposed to be a system user that has access in read only to - // the DNs of the people that are allowed to log in. + // LDAP base dn, alternative to searchDN // // @type string // - rootDN: "cn=thelounge,ou=system-users,dc=example,dc=com", - - // - // Password of the lounge LDAP system user - // - // @type string - // - rootPassword: "1234", + // baseDN: "ou=accounts,dc=example,dc=com", // // LDAP primary key @@ -436,27 +433,55 @@ module.exports = { primaryKey: "uid", // - // LDAP filter + // LDAP search dn settings. This defines the procedure by which the + // lounge first look for user DN before authenticating her. + // Ignored if baseDN is specified // - // @type string - // @default "uid" + // @type object // - filter: "(objectClass=inetOrgPerson)(memberOf=ou=accounts,dc=example,dc=com)", + searchDN: { - // - // LDAP search base (search only within this node) - // - // @type string - // - base: "dc=example,dc=com", + // + // LDAP searching bind DN + // This bind DN is used to query the server for the DN of the user. + // This is supposed to be a system user that has access in read only to + // the DNs of the people that are allowed to log in. + // + // @type string + // + rootDN: "cn=thelounge,ou=system-users,dc=example,dc=com", - // - // LDAP search scope - // - // @type string - // @default "sub" - // - scope: "sub" + // + // Password of the lounge LDAP system user + // + // @type string + // + rootPassword: "1234", + + // + // LDAP filter + // + // @type string + // @default "uid" + // + filter: "(objectClass=inetOrgPerson)(memberOf=ou=accounts,dc=example,dc=com)", + + // + // LDAP search base (search only within this node) + // + // @type string + // + base: "dc=example,dc=com", + + // + // LDAP search scope + // + // @type string + // @default "sub" + // + scope: "sub" + + } }, // Extra debugging diff --git a/src/plugins/auth/_ldapCommon.js b/src/plugins/auth/_ldapCommon.js new file mode 100644 index 00000000..77a0962b --- /dev/null +++ b/src/plugins/auth/_ldapCommon.js @@ -0,0 +1,29 @@ +"use strict"; + +const Helper = require("../../helper"); +const ldap = require("ldapjs"); + +function ldapAuthCommon(manager, client, user, bindDN, password, callback) { + const config = Helper.config; + + let ldapclient = ldap.createClient({ + url: config.ldap.url, + tlsOptions: config.ldap.tlsOptions + }); + + ldapclient.on("error", function(err) { + log.error("Unable to connect to LDAP server", err); + callback(!err); + }); + + ldapclient.bind(bindDN, password, function(err) { + if (!err && !client) { + manager.addUser(user, null); + } + ldapclient.unbind(); + callback(!err); + }); +} + +module.exports = ldapAuthCommon; + diff --git a/src/plugins/auth/advancedLdap.js b/src/plugins/auth/advancedLdap.js new file mode 100644 index 00000000..6d128e68 --- /dev/null +++ b/src/plugins/auth/advancedLdap.js @@ -0,0 +1,72 @@ +"use strict"; + +const Helper = require("../../helper"); +const ldap = require("ldapjs"); + +const _ldapAuthCommon = require("./_ldapCommon"); + +/** + * LDAP auth using initial DN search (see config comment for ldap.searchDN) + */ +function advancedLdapAuth(manager, client, user, password, callback) { + if (!user) { + return callback(false); + } + + const config = Helper.config; + const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); + + let ldapclient = ldap.createClient({ + url: config.ldap.url, + tlsOptions: config.ldap.tlsOptions + }); + + const base = config.ldap.searchDN.base; + const searchOptions = { + scope: config.ldap.searchDN.scope, + filter: "(&(" + config.ldap.primaryKey + "=" + userDN + ")" + config.ldap.searchDN.filter + ")", + attributes: ["dn"] + }; + + ldapclient.on("error", function(err) { + log.error("Unable to connect to LDAP server", err); + callback(!err); + }); + + ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function(err) { + if (err) { + log.error("Invalid LDAP root credentials"); + ldapclient.unbind(); + callback(false); + } else { + ldapclient.search(base, searchOptions, function(err2, res) { + if (err2) { + log.warning("User not found: ", userDN); + ldapclient.unbind(); + callback(false); + } else { + let found = false; + res.on("searchEntry", function(entry) { + found = true; + const bindDN = entry.objectName; + log.info("Auth against LDAP ", config.ldap.url, " with found bindDN ", bindDN); + ldapclient.unbind(); + + _ldapAuthCommon(manager, client, user, bindDN, password, callback); + }); + res.on("error", function(err3) { + log.error("LDAP error: ", err3); + callback(false); + }); + res.on("end", function() { + if (!found) { + callback(false); + } + }); + } + }); + } + }); +} + +module.exports = advancedLdapAuth; diff --git a/src/plugins/auth/ldap.js b/src/plugins/auth/ldap.js new file mode 100644 index 00000000..3f81bf61 --- /dev/null +++ b/src/plugins/auth/ldap.js @@ -0,0 +1,21 @@ +"use strict"; + +const Helper = require("../../helper"); +const _ldapAuthCommon = require("./_ldapCommon"); + +function ldapAuth(manager, client, user, password, callback) { + if (!user) { + return callback(false); + } + + const config = Helper.config; + + const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); + const bindDN = config.ldap.primaryKey + "=" + userDN + "," + config.ldap.baseDN; + + log.info("Auth against LDAP ", config.ldap.url, " with provided bindDN ", bindDN); + + _ldapAuthCommon(manager, client, user, bindDN, password, callback); +} + +module.exports = ldapAuth; diff --git a/src/plugins/auth/local.js b/src/plugins/auth/local.js new file mode 100644 index 00000000..ebb8b137 --- /dev/null +++ b/src/plugins/auth/local.js @@ -0,0 +1,38 @@ +"use strict"; + +const Helper = require("../../helper"); +const colors = require("colors/safe"); + +function localAuth(manager, client, user, password, callback) { + // If no user is found, or if the client has not provided a password, + // fail the authentication straight away + if (!client || !password) { + return callback(false); + } + + // If this user has no password set, fail the authentication + if (!client.config.password) { + log.error(`User ${colors.bold(user)} with no local password set tried to sign in. (Probably a LDAP user)`); + return callback(false); + } + + Helper.password + .compare(password, client.config.password) + .then((matching) => { + if (matching && Helper.password.requiresUpdate(client.config.password)) { + const hash = Helper.password.hash(password); + + client.setPassword(hash, (success) => { + if (success) { + log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`); + } + }); + } + + callback(matching); + }).catch((error) => { + log.error(`Error while checking users password. Error: ${error}`); + }); +} + +module.exports = localAuth; diff --git a/src/server.js b/src/server.js index 205801cc..baefd7e5 100644 --- a/src/server.js +++ b/src/server.js @@ -11,7 +11,9 @@ var path = require("path"); var io = require("socket.io"); var dns = require("dns"); var Helper = require("./helper"); -var ldap = require("ldapjs"); +var ldapAuth = require("./plugins/auth/ldap"); +var advancedLdapAuth = require("./plugins/auth/advancedLdap"); +var localAuth = require("./plugins/auth/local"); var colors = require("colors/safe"); const net = require("net"); const Identification = require("./identification"); @@ -372,109 +374,6 @@ function initializeClient(socket, client, generateToken, token) { } } -function localAuth(client, user, password, callback) { - // If no user is found, or if the client has not provided a password, - // fail the authentication straight away - if (!client || !password) { - return callback(false); - } - - // If this user has no password set, fail the authentication - if (!client.config.password) { - log.error(`User ${colors.bold(user)} with no local password set tried to sign in. (Probably a LDAP user)`); - return callback(false); - } - - Helper.password - .compare(password, client.config.password) - .then((matching) => { - if (matching && Helper.password.requiresUpdate(client.config.password)) { - const hash = Helper.password.hash(password); - - client.setPassword(hash, (success) => { - if (success) { - log.info(`User ${colors.bold(client.name)} logged in and their hashed password has been updated to match new security requirements`); - } - }); - } - - callback(matching); - }).catch((error) => { - log.error(`Error while checking users password. Error: ${error}`); - }); -} - -function ldapAuth(client, user, password, callback) { - if (!user) { - return callback(false); - } - var config = Helper.config; - var userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); - - var ldapclient = ldap.createClient({ - url: config.ldap.url, - tlsOptions: config.ldap.tlsOptions - }); - - var base = config.ldap.base; - var searchOptions = { - scope: config.ldap.scope, - filter: "(&(" + config.ldap.primaryKey + "=" + userDN + ")" + config.ldap.filter + ")", - attributes: ["dn"] - }; - - ldapclient.on("error", function(err) { - log.error("Unable to connect to LDAP server", err); - callback(!err); - }); - - ldapclient.bind(config.ldap.rootDN, config.ldap.rootPassword, function(err) { - if (err) { - log.error("Invalid LDAP root credentials"); - ldapclient.unbind(); - callback(false); - } else { - ldapclient.search(base, searchOptions, function(err2, res) { - if (err2) { - log.warning("User not found: ", userDN); - ldapclient.unbind(); - callback(false); - } else { - var found = false; - res.on("searchEntry", function(entry) { - found = true; - var bindDN = entry.objectName; - log.info("Auth against LDAP ", config.ldap.url, " with bindDN ", bindDN); - ldapclient.unbind(); - var ldapclient2 = ldap.createClient({ - url: config.ldap.url, - tlsOptions: config.ldap.tlsOptions - }); - ldapclient2.bind(bindDN, password, function(err3) { - if (!err3 && !client) { - if (!manager.addUser(user, null)) { - log.error("Unable to create new user", user); - } - } - ldapclient2.unbind(); - callback(!err3); - }); - }); - res.on("error", function(err3) { - log.error("LDAP error: ", err3); - callback(false); - }); - res.on("end", function() { - if (!found) { - callback(false); - } - }); - } - }); - } - }); -} - function performAuthentication(data) { const socket = this; let client; @@ -538,11 +437,17 @@ function performAuthentication(data) { } // Perform password checking + let auth = function() {}; if (!Helper.config.public && Helper.config.ldap.enable) { - ldapAuth(client, data.user, data.password, authCallback); + if ("baseDN" in Helper.config.ldap) { + auth = ldapAuth; + } else { + auth = advancedLdapAuth; + } } else { - localAuth(client, data.user, data.password, authCallback); + auth = localAuth; } + auth(manager, client, data.user, data.password, authCallback); } function reverseDnsLookup(ip, callback) { From 12ba10f6881c26a50f6cc3b1e1ef65d7d657c016 Mon Sep 17 00:00:00 2001 From: Elie Michel Date: Tue, 29 Aug 2017 19:11:06 +0200 Subject: [PATCH 08/72] Reorganize auth plugins --- src/plugins/auth/_ldapCommon.js | 29 -------- src/plugins/auth/advancedLdap.js | 72 -------------------- src/plugins/auth/ldap.js | 111 +++++++++++++++++++++++++++++-- src/plugins/auth/local.js | 8 ++- src/server.js | 13 ++-- 5 files changed, 118 insertions(+), 115 deletions(-) delete mode 100644 src/plugins/auth/_ldapCommon.js delete mode 100644 src/plugins/auth/advancedLdap.js diff --git a/src/plugins/auth/_ldapCommon.js b/src/plugins/auth/_ldapCommon.js deleted file mode 100644 index 77a0962b..00000000 --- a/src/plugins/auth/_ldapCommon.js +++ /dev/null @@ -1,29 +0,0 @@ -"use strict"; - -const Helper = require("../../helper"); -const ldap = require("ldapjs"); - -function ldapAuthCommon(manager, client, user, bindDN, password, callback) { - const config = Helper.config; - - let ldapclient = ldap.createClient({ - url: config.ldap.url, - tlsOptions: config.ldap.tlsOptions - }); - - ldapclient.on("error", function(err) { - log.error("Unable to connect to LDAP server", err); - callback(!err); - }); - - ldapclient.bind(bindDN, password, function(err) { - if (!err && !client) { - manager.addUser(user, null); - } - ldapclient.unbind(); - callback(!err); - }); -} - -module.exports = ldapAuthCommon; - diff --git a/src/plugins/auth/advancedLdap.js b/src/plugins/auth/advancedLdap.js deleted file mode 100644 index 6d128e68..00000000 --- a/src/plugins/auth/advancedLdap.js +++ /dev/null @@ -1,72 +0,0 @@ -"use strict"; - -const Helper = require("../../helper"); -const ldap = require("ldapjs"); - -const _ldapAuthCommon = require("./_ldapCommon"); - -/** - * LDAP auth using initial DN search (see config comment for ldap.searchDN) - */ -function advancedLdapAuth(manager, client, user, password, callback) { - if (!user) { - return callback(false); - } - - const config = Helper.config; - const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); - - let ldapclient = ldap.createClient({ - url: config.ldap.url, - tlsOptions: config.ldap.tlsOptions - }); - - const base = config.ldap.searchDN.base; - const searchOptions = { - scope: config.ldap.searchDN.scope, - filter: "(&(" + config.ldap.primaryKey + "=" + userDN + ")" + config.ldap.searchDN.filter + ")", - attributes: ["dn"] - }; - - ldapclient.on("error", function(err) { - log.error("Unable to connect to LDAP server", err); - callback(!err); - }); - - ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function(err) { - if (err) { - log.error("Invalid LDAP root credentials"); - ldapclient.unbind(); - callback(false); - } else { - ldapclient.search(base, searchOptions, function(err2, res) { - if (err2) { - log.warning("User not found: ", userDN); - ldapclient.unbind(); - callback(false); - } else { - let found = false; - res.on("searchEntry", function(entry) { - found = true; - const bindDN = entry.objectName; - log.info("Auth against LDAP ", config.ldap.url, " with found bindDN ", bindDN); - ldapclient.unbind(); - - _ldapAuthCommon(manager, client, user, bindDN, password, callback); - }); - res.on("error", function(err3) { - log.error("LDAP error: ", err3); - callback(false); - }); - res.on("end", function() { - if (!found) { - callback(false); - } - }); - } - }); - } - }); -} - -module.exports = advancedLdapAuth; diff --git a/src/plugins/auth/ldap.js b/src/plugins/auth/ldap.js index 3f81bf61..8506800e 100644 --- a/src/plugins/auth/ldap.js +++ b/src/plugins/auth/ldap.js @@ -1,9 +1,31 @@ "use strict"; const Helper = require("../../helper"); -const _ldapAuthCommon = require("./_ldapCommon"); +const ldap = require("ldapjs"); -function ldapAuth(manager, client, user, password, callback) { +function ldapAuthCommon(manager, client, user, bindDN, password, callback) { + const config = Helper.config; + + const ldapclient = ldap.createClient({ + url: config.ldap.url, + tlsOptions: config.ldap.tlsOptions + }); + + ldapclient.on("error", function(err) { + log.error("Unable to connect to LDAP server", err); + callback(!err); + }); + + ldapclient.bind(bindDN, password, function(err) { + if (!err && !client) { + manager.addUser(user, null); + } + ldapclient.unbind(); + callback(!err); + }); +} + +function simpleLdapAuth(manager, client, user, password, callback) { if (!user) { return callback(false); } @@ -15,7 +37,88 @@ function ldapAuth(manager, client, user, password, callback) { log.info("Auth against LDAP ", config.ldap.url, " with provided bindDN ", bindDN); - _ldapAuthCommon(manager, client, user, bindDN, password, callback); + ldapAuthCommon(manager, client, user, bindDN, password, callback); } -module.exports = ldapAuth; +/** + * LDAP auth using initial DN search (see config comment for ldap.searchDN) + */ +function advancedLdapAuth(manager, client, user, password, callback) { + if (!user) { + return callback(false); + } + + const config = Helper.config; + const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); + + const ldapclient = ldap.createClient({ + url: config.ldap.url, + tlsOptions: config.ldap.tlsOptions + }); + + const base = config.ldap.searchDN.base; + const searchOptions = { + scope: config.ldap.searchDN.scope, + filter: "(&(" + config.ldap.primaryKey + "=" + userDN + ")" + config.ldap.searchDN.filter + ")", + attributes: ["dn"] + }; + + ldapclient.on("error", function(err) { + log.error("Unable to connect to LDAP server", err); + callback(!err); + }); + + ldapclient.bind(config.ldap.searchDN.rootDN, config.ldap.searchDN.rootPassword, function(err) { + if (err) { + log.error("Invalid LDAP root credentials"); + ldapclient.unbind(); + callback(false); + } else { + ldapclient.search(base, searchOptions, function(err2, res) { + if (err2) { + log.warning("User not found: ", userDN); + ldapclient.unbind(); + callback(false); + } else { + let found = false; + res.on("searchEntry", function(entry) { + found = true; + const bindDN = entry.objectName; + log.info("Auth against LDAP ", config.ldap.url, " with found bindDN ", bindDN); + ldapclient.unbind(); + + ldapAuthCommon(manager, client, user, bindDN, password, callback); + }); + res.on("error", function(err3) { + log.error("LDAP error: ", err3); + callback(false); + }); + res.on("end", function() { + if (!found) { + callback(false); + } + }); + } + }); + } + }); +} + +function ldapAuth(manager, client, user, password, callback) { + let auth = function() {}; + if ("baseDN" in Helper.config.ldap) { + auth = simpleLdapAuth; + } else { + auth = advancedLdapAuth; + } + return auth(manager, client, user, password, callback); +} + +function isLdapEnabled() { + return !Helper.config.public && Helper.config.ldap.enable; +} + +module.exports = { + auth: ldapAuth, + isEnabled: isLdapEnabled +}; diff --git a/src/plugins/auth/local.js b/src/plugins/auth/local.js index ebb8b137..e8e0ff38 100644 --- a/src/plugins/auth/local.js +++ b/src/plugins/auth/local.js @@ -35,4 +35,10 @@ function localAuth(manager, client, user, password, callback) { }); } -module.exports = localAuth; +module.exports = { + auth: localAuth, + isEnabled: function() { + return true; + } +}; + diff --git a/src/server.js b/src/server.js index baefd7e5..c4e2b4d8 100644 --- a/src/server.js +++ b/src/server.js @@ -12,7 +12,6 @@ var io = require("socket.io"); var dns = require("dns"); var Helper = require("./helper"); var ldapAuth = require("./plugins/auth/ldap"); -var advancedLdapAuth = require("./plugins/auth/advancedLdap"); var localAuth = require("./plugins/auth/local"); var colors = require("colors/safe"); const net = require("net"); @@ -438,14 +437,10 @@ function performAuthentication(data) { // Perform password checking let auth = function() {}; - if (!Helper.config.public && Helper.config.ldap.enable) { - if ("baseDN" in Helper.config.ldap) { - auth = ldapAuth; - } else { - auth = advancedLdapAuth; - } - } else { - auth = localAuth; + if (ldapAuth.isEnabled()) { + auth = ldapAuth.auth; + } else if (localAuth.isEnabled()) { + auth = localAuth.auth; } auth(manager, client, data.user, data.password, authCallback); } From 00e54e49ac1bbd978932dbb032249050e36333c2 Mon Sep 17 00:00:00 2001 From: Elie Michel Date: Wed, 30 Aug 2017 11:49:21 +0200 Subject: [PATCH 09/72] Add tests for LDAP auth plugin --- defaults/config.js | 2 +- src/plugins/auth/ldap.js | 28 ++++--- src/plugins/auth/local.js | 4 +- src/server.js | 2 +- test/plugins/auth/ldap.js | 151 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 172 insertions(+), 15 deletions(-) create mode 100644 test/plugins/auth/ldap.js diff --git a/defaults/config.js b/defaults/config.js index f26bb650..cf9dd9a8 100644 --- a/defaults/config.js +++ b/defaults/config.js @@ -464,7 +464,7 @@ module.exports = { // @type string // @default "uid" // - filter: "(objectClass=inetOrgPerson)(memberOf=ou=accounts,dc=example,dc=com)", + filter: "(objectClass=person)(memberOf=ou=accounts,dc=example,dc=com)", // // LDAP search base (search only within this node) diff --git a/src/plugins/auth/ldap.js b/src/plugins/auth/ldap.js index 8506800e..2e98c1c9 100644 --- a/src/plugins/auth/ldap.js +++ b/src/plugins/auth/ldap.js @@ -3,7 +3,7 @@ const Helper = require("../../helper"); const ldap = require("ldapjs"); -function ldapAuthCommon(manager, client, user, bindDN, password, callback) { +function ldapAuthCommon(user, bindDN, password, callback) { const config = Helper.config; const ldapclient = ldap.createClient({ @@ -17,15 +17,12 @@ function ldapAuthCommon(manager, client, user, bindDN, password, callback) { }); ldapclient.bind(bindDN, password, function(err) { - if (!err && !client) { - manager.addUser(user, null); - } ldapclient.unbind(); callback(!err); }); } -function simpleLdapAuth(manager, client, user, password, callback) { +function simpleLdapAuth(user, password, callback) { if (!user) { return callback(false); } @@ -37,13 +34,13 @@ function simpleLdapAuth(manager, client, user, password, callback) { log.info("Auth against LDAP ", config.ldap.url, " with provided bindDN ", bindDN); - ldapAuthCommon(manager, client, user, bindDN, password, callback); + ldapAuthCommon(user, bindDN, password, callback); } /** * LDAP auth using initial DN search (see config comment for ldap.searchDN) */ -function advancedLdapAuth(manager, client, user, password, callback) { +function advancedLdapAuth(user, password, callback) { if (!user) { return callback(false); } @@ -87,7 +84,7 @@ function advancedLdapAuth(manager, client, user, password, callback) { log.info("Auth against LDAP ", config.ldap.url, " with found bindDN ", bindDN); ldapclient.unbind(); - ldapAuthCommon(manager, client, user, bindDN, password, callback); + ldapAuthCommon(user, bindDN, password, callback); }); res.on("error", function(err3) { log.error("LDAP error: ", err3); @@ -105,13 +102,24 @@ function advancedLdapAuth(manager, client, user, password, callback) { } function ldapAuth(manager, client, user, password, callback) { - let auth = function() {}; + // 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) { + if (valid && !client) { + manager.addUser(user, null); + } + callback(valid); + } + + let auth; if ("baseDN" in Helper.config.ldap) { auth = simpleLdapAuth; } else { auth = advancedLdapAuth; } - return auth(manager, client, user, password, callback); + return auth(user, password, callbackWrapper); } function isLdapEnabled() { diff --git a/src/plugins/auth/local.js b/src/plugins/auth/local.js index e8e0ff38..3f929768 100644 --- a/src/plugins/auth/local.js +++ b/src/plugins/auth/local.js @@ -37,8 +37,6 @@ function localAuth(manager, client, user, password, callback) { module.exports = { auth: localAuth, - isEnabled: function() { - return true; - } + isEnabled: () => true }; diff --git a/src/server.js b/src/server.js index c4e2b4d8..47e60fd7 100644 --- a/src/server.js +++ b/src/server.js @@ -436,7 +436,7 @@ function performAuthentication(data) { } // Perform password checking - let auth = function() {}; + let auth; if (ldapAuth.isEnabled()) { auth = ldapAuth.auth; } else if (localAuth.isEnabled()) { diff --git a/test/plugins/auth/ldap.js b/test/plugins/auth/ldap.js new file mode 100644 index 00000000..56e4fef7 --- /dev/null +++ b/test/plugins/auth/ldap.js @@ -0,0 +1,151 @@ +"use strict"; + +const ldapAuth = require("../../../src/plugins/auth/ldap"); +const Helper = require("../../../src/helper"); +const ldap = require("ldapjs"); +const expect = require("chai").expect; + +const user = "johndoe"; +const wrongUser = "eve"; +const correctPassword = "loremipsum"; +const wrongPassword = "dolorsitamet"; +const baseDN = "ou=accounts,dc=example,dc=com"; +const primaryKey = "uid"; +const serverPort = 1389; + +function normalizeDN(dn) { + return ldap.parseDN(dn).toString(); +} + +function startLdapServer(callback) { + const server = ldap.createServer(); + + const searchConf = Helper.config.ldap.searchDN; + const userDN = primaryKey + "=" + user + "," + baseDN; + + // Two users are authorized: john doe and the root user in case of + // advanced auth (the user that does the search for john's actual + // bindDN) + const authorizedUsers = {}; + authorizedUsers[normalizeDN(searchConf.rootDN)] = searchConf.rootPassword; + authorizedUsers[normalizeDN(userDN)] = correctPassword; + + function authorize(req, res, next) { + const bindDN = req.connection.ldap.bindDN; + + if (bindDN in authorizedUsers) { + return next(); + } + + return next(new ldap.InsufficientAccessRightsError()); + } + + Object.keys(authorizedUsers).forEach(function(dn) { + server.bind(dn, function(req, res, next) { + const bindDN = req.dn.toString(); + const password = req.credentials; + + if (bindDN in authorizedUsers && authorizedUsers[bindDN] === password) { + req.connection.ldap.bindDN = req.dn; + res.end(); + return next(); + } + + return next(new ldap.InsufficientAccessRightsError()); + }); + }); + + server.search(searchConf.base, authorize, function(req, res) { + const obj = { + dn: userDN, + attributes: { + objectclass: ["person", "top"], + cn: ["john doe"], + sn: ["johnny"], + uid: ["johndoe"], + memberof: [baseDN] + } + }; + + if (req.filter.matches(obj.attributes)) { + // TODO: check req.scope if ldapjs does not + res.send(obj); + } + + res.end(); + }); + + server.listen(serverPort, function() { + console.log("LDAP server listening at %s", server.url); + callback(); + }); + + return server; +} + +function testLdapAuth() { + // Create mock manager and client. When client is true, manager should not + // be used. But ideally the auth plugin should not use any of those. + const manager = {}; + const client = true; + + it("should successfully authenticate with correct password", function(done) { + ldapAuth.auth(manager, client, user, correctPassword, function(valid) { + expect(valid).to.equal(true); + done(); + }); + }); + + it("should fail to authenticate with incorrect password", function(done) { + ldapAuth.auth(manager, client, user, wrongPassword, function(valid) { + expect(valid).to.equal(false); + done(); + }); + }); + + it("should fail to authenticate with incorrect username", function(done) { + ldapAuth.auth(manager, client, wrongUser, correctPassword, function(valid) { + expect(valid).to.equal(false); + done(); + }); + }); +} + +describe("LDAP authentication plugin", function() { + before(function(done) { + this.server = startLdapServer(done); + }); + after(function(done) { + this.server.close(); + done(); + }); + + beforeEach(function(done) { + Helper.config.public = false; + Helper.config.ldap.enable = true; + Helper.config.ldap.url = "ldap://localhost:" + String(serverPort); + Helper.config.ldap.primaryKey = primaryKey; + done(); + }); + + describe("LDAP authentication availability", function() { + it("checks that the configuration is correctly tied to isEnabled()", function(done) { + Helper.config.ldap.enable = true; + expect(ldapAuth.isEnabled()).to.equal(true); + Helper.config.ldap.enable = false; + expect(ldapAuth.isEnabled()).to.equal(false); + done(); + }); + }); + + describe("Simple LDAP authentication (predefined DN pattern)", function() { + Helper.config.ldap.baseDN = baseDN; + testLdapAuth(); + }); + + describe("Advanced LDAP authentication (DN found by a prior search query)", function() { + delete Helper.config.ldap.baseDN; + testLdapAuth(); + }); +}); + From 803cff92c882b61cc71750c7cc0a2dbb132111ae Mon Sep 17 00:00:00 2001 From: Elie Michel Date: Thu, 31 Aug 2017 14:15:42 +0200 Subject: [PATCH 10/72] Set public to true for websocket tests A side effect of LDAP auth tests was breaking these other tests, that should have already forced public instance in their pre-condition. --- test/server.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/server.js b/test/server.js index 4d06f980..77c74922 100644 --- a/test/server.js +++ b/test/server.js @@ -37,6 +37,11 @@ describe("Server", () => { describe("WebSockets", () => { let client; + before((done) => { + Helper.config.public = true; + done(); + }); + beforeEach(() => { client = io(webURL, { path: "/socket.io/", From 435e14669b818af449e5cf78789796f6701866a1 Mon Sep 17 00:00:00 2001 From: Elie Michel Date: Fri, 1 Sep 2017 11:29:52 +0200 Subject: [PATCH 11/72] Change string formatting style --- src/plugins/auth/ldap.js | 16 ++++++++-------- test/plugins/auth/ldap.js | 7 ++----- 2 files changed, 10 insertions(+), 13 deletions(-) diff --git a/src/plugins/auth/ldap.js b/src/plugins/auth/ldap.js index 2e98c1c9..059d23ce 100644 --- a/src/plugins/auth/ldap.js +++ b/src/plugins/auth/ldap.js @@ -12,7 +12,7 @@ function ldapAuthCommon(user, bindDN, password, callback) { }); ldapclient.on("error", function(err) { - log.error("Unable to connect to LDAP server", err); + log.error(`Unable to connect to LDAP server: ${err}`); callback(!err); }); @@ -30,9 +30,9 @@ function simpleLdapAuth(user, password, callback) { const config = Helper.config; const userDN = user.replace(/([,\\/#+<>;"= ])/g, "\\$1"); - const bindDN = config.ldap.primaryKey + "=" + userDN + "," + config.ldap.baseDN; + const bindDN = `${config.ldap.primaryKey}=${userDN},${config.ldap.baseDN}`; - log.info("Auth against LDAP ", config.ldap.url, " with provided bindDN ", bindDN); + log.info(`Auth against LDAP ${config.ldap.url} with provided bindDN ${bindDN}`); ldapAuthCommon(user, bindDN, password, callback); } @@ -56,12 +56,12 @@ function advancedLdapAuth(user, password, callback) { const base = config.ldap.searchDN.base; const searchOptions = { scope: config.ldap.searchDN.scope, - filter: "(&(" + config.ldap.primaryKey + "=" + userDN + ")" + config.ldap.searchDN.filter + ")", + filter: `(&(${config.ldap.primaryKey}=${userDN})${config.ldap.searchDN.filter})`, attributes: ["dn"] }; ldapclient.on("error", function(err) { - log.error("Unable to connect to LDAP server", err); + log.error(`Unable to connect to LDAP server: ${err}`); callback(!err); }); @@ -73,7 +73,7 @@ function advancedLdapAuth(user, password, callback) { } else { ldapclient.search(base, searchOptions, function(err2, res) { if (err2) { - log.warning("User not found: ", userDN); + log.warning(`User not found: ${userDN}`); ldapclient.unbind(); callback(false); } else { @@ -81,13 +81,13 @@ function advancedLdapAuth(user, password, callback) { res.on("searchEntry", function(entry) { found = true; const bindDN = entry.objectName; - log.info("Auth against LDAP ", config.ldap.url, " with found bindDN ", bindDN); + log.info(`Auth against LDAP ${config.ldap.url} with found bindDN ${bindDN}`); ldapclient.unbind(); ldapAuthCommon(user, bindDN, password, callback); }); res.on("error", function(err3) { - log.error("LDAP error: ", err3); + log.error(`LDAP error: ${err3}`); callback(false); }); res.on("end", function() { diff --git a/test/plugins/auth/ldap.js b/test/plugins/auth/ldap.js index 56e4fef7..2016defe 100644 --- a/test/plugins/auth/ldap.js +++ b/test/plugins/auth/ldap.js @@ -75,11 +75,8 @@ function startLdapServer(callback) { res.end(); }); - server.listen(serverPort, function() { - console.log("LDAP server listening at %s", server.url); - callback(); - }); - + server.listen(serverPort, callback); + return server; } From 32e1a36980b86a6367ddafa83ee7c4641ff6e378 Mon Sep 17 00:00:00 2001 From: Elie Michel Date: Fri, 1 Sep 2017 11:44:53 +0200 Subject: [PATCH 12/72] Generalize auth plugin fallback mechanism @astorije this is for you ;) https://github.com/thelounge/lounge/pull/1478#discussion_r136492534 --- src/server.js | 22 +++++++++++++++------- test/plugins/auth/ldap.js | 2 +- 2 files changed, 16 insertions(+), 8 deletions(-) diff --git a/src/server.js b/src/server.js index 47e60fd7..da79905f 100644 --- a/src/server.js +++ b/src/server.js @@ -11,12 +11,17 @@ var path = require("path"); var io = require("socket.io"); var dns = require("dns"); var Helper = require("./helper"); -var ldapAuth = require("./plugins/auth/ldap"); -var localAuth = require("./plugins/auth/local"); var colors = require("colors/safe"); const net = require("net"); const Identification = require("./identification"); +// The order defined the priority: the first available plugin is used +// ALways keep local auth in the end, which should always be enabled. +const authPlugins = [ + require("./plugins/auth/ldap"), + require("./plugins/auth/local"), +]; + var manager = null; module.exports = function() { @@ -436,11 +441,14 @@ function performAuthentication(data) { } // Perform password checking - let auth; - if (ldapAuth.isEnabled()) { - auth = ldapAuth.auth; - } else if (localAuth.isEnabled()) { - auth = localAuth.auth; + let auth = () => { + log.error("None of the auth plugins is enabled"); + }; + for (let i = 0; i < authPlugins.length; ++i) { + if (authPlugins[i].isEnabled()) { + auth = authPlugins[i].auth; + break; + } } auth(manager, client, data.user, data.password, authCallback); } diff --git a/test/plugins/auth/ldap.js b/test/plugins/auth/ldap.js index 2016defe..0a4917fc 100644 --- a/test/plugins/auth/ldap.js +++ b/test/plugins/auth/ldap.js @@ -76,7 +76,7 @@ function startLdapServer(callback) { }); server.listen(serverPort, callback); - + return server; } From 477736a23160602adfee7753a8f2cdbab8b59518 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Sun, 3 Sep 2017 21:53:07 +0000 Subject: [PATCH 13/72] chore(package): update eslint to version 4.6.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index de7fc68a..05e8028b 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "chai": "4.1.2", "css.escape": "1.5.1", "emoji-regex": "6.5.1", - "eslint": "4.5.0", + "eslint": "4.6.1", "font-awesome": "4.7.0", "fuzzy": "0.1.3", "handlebars": "4.0.10", From d80efdfe23b3d0655bfbaec7985ad49200090157 Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Mon, 4 Sep 2017 15:33:33 -0700 Subject: [PATCH 14/72] Add /list to autocomplete --- client/js/constants.js | 1 + 1 file changed, 1 insertion(+) diff --git a/client/js/constants.js b/client/js/constants.js index ec3745c4..1dedf55f 100644 --- a/client/js/constants.js +++ b/client/js/constants.js @@ -37,6 +37,7 @@ const commands = [ "/join", "/kick", "/leave", + "/list", "/me", "/mode", "/msg", From f885ce456ebfc20063124792cf35ec7a9df9b693 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Tue, 5 Sep 2017 05:29:05 +0000 Subject: [PATCH 15/72] chore(package): update nyc to version 11.2.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 05e8028b..50559f57 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "mocha": "3.5.0", "mousetrap": "1.6.1", "npm-run-all": "4.1.1", - "nyc": "11.1.0", + "nyc": "11.2.0", "socket.io-client": "1.7.4", "stylelint": "8.0.0", "stylelint-config-standard": "17.0.0", From 07de177b0ebaeb2faa34e5534ebbb246cf00eee6 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Tue, 5 Sep 2017 11:45:00 +0000 Subject: [PATCH 16/72] chore(package): update stylelint to version 8.1.1 Closes #1494 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 05e8028b..55163787 100644 --- a/package.json +++ b/package.json @@ -82,7 +82,7 @@ "npm-run-all": "4.1.1", "nyc": "11.1.0", "socket.io-client": "1.7.4", - "stylelint": "8.0.0", + "stylelint": "8.1.1", "stylelint-config-standard": "17.0.0", "webpack": "3.5.5" } From e2a122c3ca55338ebfcb75a4b3f1b01df8eb5851 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 4 Sep 2017 19:52:02 +0300 Subject: [PATCH 17/72] Only change nick autocompletion when receiving a message And other minor optimizations and fixes --- client/js/render.js | 31 ++++--------------------------- client/js/socket-events/msg.js | 11 +++++++++++ 2 files changed, 15 insertions(+), 27 deletions(-) diff --git a/client/js/render.js b/client/js/render.js index bb6c4138..8150f6a1 100644 --- a/client/js/render.js +++ b/client/js/render.js @@ -36,7 +36,7 @@ function buildChannelMessages(chanId, chanType, messages) { function appendMessage(container, chanId, chanType, msg) { let lastChild = container.children(".msg, .date-marker-container").last(); - const renderedMessage = buildChatMessage(chanId, msg); + const renderedMessage = buildChatMessage(msg); // Check if date changed const msgTime = new Date(msg.time); @@ -44,7 +44,7 @@ function appendMessage(container, chanId, chanType, msg) { // Insert date marker if date changed compared to previous message if (prevMsgTime.toDateString() !== msgTime.toDateString()) { - lastChild = $(templates.date_marker({msgDate: msg.time})); + lastChild = $(templates.date_marker({time: msg.time})); container.append(lastChild); } @@ -64,25 +64,15 @@ function appendMessage(container, chanId, chanType, msg) { } // Always create a condensed container - const newCondensed = buildChatMessage(chanId, { - type: "condensed", - time: msg.time, - previews: [] - }); + const newCondensed = $(templates.msg_condensed({time: msg.time})); condensed.updateText(newCondensed, [msg.type]); newCondensed.append(renderedMessage); container.append(newCondensed); } -function buildChatMessage(chanId, msg) { +function buildChatMessage(msg) { const type = msg.type; - let target = "#chan-" + chanId; - if (type === "error") { - target = "#chan-" + chat.find(".active").data("id"); - } - - const chan = chat.find(target); let template = "msg"; // See if any of the custom highlight regexes match @@ -98,8 +88,6 @@ function buildChatMessage(chanId, msg) { template = "msg_action"; } else if (type === "unhandled") { template = "msg_unhandled"; - } else if (type === "condensed") { - template = "msg_condensed"; } const renderedMessage = $(templates[template](msg)); @@ -113,17 +101,6 @@ function buildChatMessage(chanId, msg) { renderPreview(preview, renderedMessage); }); - if ((type === "message" || type === "action" || type === "notice") && chan.hasClass("channel")) { - const nicks = chan.find(".users").data("nicks"); - if (nicks) { - const find = nicks.indexOf(msg.from); - if (find !== -1) { - nicks.splice(find, 1); - nicks.unshift(msg.from); - } - } - } - return renderedMessage; } diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js index 6549c66c..39b34bcf 100644 --- a/client/js/socket-events/msg.js +++ b/client/js/socket-events/msg.js @@ -63,4 +63,15 @@ function processReceivedMessage(data) { } }); } + + if ((data.msg.type === "message" || data.msg.type === "action" || data.msg.type === "notice") && channel.hasClass("channel")) { + const nicks = channel.find(".users").data("nicks"); + if (nicks) { + const find = nicks.indexOf(data.msg.from); + if (find !== -1) { + nicks.splice(find, 1); + nicks.unshift(data.msg.from); + } + } + } } From 8304f5da810abbf9ebc852fb951468feb43e15ac Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Wed, 6 Sep 2017 06:33:07 +0000 Subject: [PATCH 18/72] chore(package): update nyc to version 11.2.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 43f352eb..8c0890a1 100644 --- a/package.json +++ b/package.json @@ -80,7 +80,7 @@ "mocha": "3.5.0", "mousetrap": "1.6.1", "npm-run-all": "4.1.1", - "nyc": "11.2.0", + "nyc": "11.2.1", "socket.io-client": "1.7.4", "stylelint": "8.1.1", "stylelint-config-standard": "17.0.0", From 86289f0a6e8aacbfcf3a7be57ce875a6c97a188e Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Wed, 6 Sep 2017 16:08:19 +0000 Subject: [PATCH 19/72] chore(package): update webpack to version 3.5.6 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 43f352eb..e01007df 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,6 @@ "socket.io-client": "1.7.4", "stylelint": "8.1.1", "stylelint-config-standard": "17.0.0", - "webpack": "3.5.5" + "webpack": "3.5.6" } } From c81a74a20ce10fe044d2959f965a873a06d69a2a Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Wed, 6 Sep 2017 22:03:56 +0300 Subject: [PATCH 20/72] Render link previews in browser idle event Fixes #1504 --- client/js/socket-events/msg.js | 11 +++-------- client/js/socket-events/msg_preview.js | 6 +++--- client/js/utils.js | 13 ++++++++++++- 3 files changed, 18 insertions(+), 12 deletions(-) diff --git a/client/js/socket-events/msg.js b/client/js/socket-events/msg.js index 39b34bcf..4ef503ae 100644 --- a/client/js/socket-events/msg.js +++ b/client/js/socket-events/msg.js @@ -3,17 +3,12 @@ const $ = require("jquery"); const socket = require("../socket"); const render = require("../render"); +const utils = require("../utils"); const chat = $("#chat"); socket.on("msg", function(data) { - if (window.requestIdleCallback) { - // During an idle period the user agent will run idle callbacks in FIFO order - // until either the idle period ends or there are no more idle callbacks eligible to be run. - // We set a maximum timeout of 2 seconds so that messages don't take too long to appear. - window.requestIdleCallback(() => processReceivedMessage(data), {timeout: 2000}); - } else { - processReceivedMessage(data); - } + // We set a maximum timeout of 2 seconds so that messages don't take too long to appear. + utils.requestIdleCallback(() => processReceivedMessage(data), 2000); }); function processReceivedMessage(data) { diff --git a/client/js/socket-events/msg_preview.js b/client/js/socket-events/msg_preview.js index 42972d61..426bcf9b 100644 --- a/client/js/socket-events/msg_preview.js +++ b/client/js/socket-events/msg_preview.js @@ -3,9 +3,9 @@ const $ = require("jquery"); const renderPreview = require("../renderPreview"); const socket = require("../socket"); +const utils = require("../utils"); socket.on("msg:preview", function(data) { - const msg = $("#msg-" + data.id); - - renderPreview(data.preview, msg); + // Previews are not as important, we can wait longer for them to appear + utils.requestIdleCallback(() => renderPreview(data.preview, $("#msg-" + data.id)), 6000); }); diff --git a/client/js/utils.js b/client/js/utils.js index 00bb3416..46a8dccb 100644 --- a/client/js/utils.js +++ b/client/js/utils.js @@ -12,7 +12,8 @@ module.exports = { resetHeight, setNick, toggleNickEditor, - toggleNotificationMarkers + toggleNotificationMarkers, + requestIdleCallback, }; function resetHeight(element) { @@ -77,3 +78,13 @@ function move(array, old_index, new_index) { array.splice(new_index, 0, array.splice(old_index, 1)[0]); return array; } + +function requestIdleCallback(callback, timeout) { + if (window.requestIdleCallback) { + // During an idle period the user agent will run idle callbacks in FIFO order + // until either the idle period ends or there are no more idle callbacks eligible to be run. + window.requestIdleCallback(callback, {timeout: timeout}); + } else { + callback(); + } +} From d82f4007ec50b87ad7e48f96e394484266d057d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Thu, 7 Sep 2017 19:50:00 -0400 Subject: [PATCH 21/72] Fix `/expand` command also expanding condensed status messages --- client/js/lounge.js | 4 ++-- client/views/msg_preview_toggle.tpl | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/client/js/lounge.js b/client/js/lounge.js index 324f6f47..27bbc960 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -201,12 +201,12 @@ $(function() { } if (text.indexOf("/collapse") === 0) { - $(".chan.active .toggle-button.opened").click(); + $(".chan.active .toggle-preview.opened").click(); return; } if (text.indexOf("/expand") === 0) { - $(".chan.active .toggle-button:not(.opened)").click(); + $(".chan.active .toggle-preview:not(.opened)").click(); return; } diff --git a/client/views/msg_preview_toggle.tpl b/client/views/msg_preview_toggle.tpl index 386282cc..c9e48f6e 100644 --- a/client/views/msg_preview_toggle.tpl +++ b/client/views/msg_preview_toggle.tpl @@ -1,5 +1,5 @@ {{#preview}} -
-
-
- Ctrl + Shift + L -
-
-

Clear the current screen

-
-
-
Ctrl + K @@ -479,15 +470,6 @@
-
-
- + + L -
-
-

Clear the current screen

-
-
-
+ K @@ -583,15 +565,6 @@
-
-
- /clear -
-
-

Clear the current screen.

-
-
-
/collapse diff --git a/client/js/keybinds.js b/client/js/keybinds.js index 961961c4..c98a73de 100644 --- a/client/js/keybinds.js +++ b/client/js/keybinds.js @@ -2,7 +2,6 @@ const $ = require("jquery"); const Mousetrap = require("mousetrap"); -const utils = require("./utils"); const input = $("#input"); const sidebar = $("#sidebar"); const windows = $("#windows"); @@ -61,16 +60,6 @@ Mousetrap.bind([ channels.eq(target).click(); }); -Mousetrap.bind([ - "command+shift+l", - "ctrl+shift+l" -], function(e) { - if (e.target === input[0]) { - utils.clear(); - e.preventDefault(); - } -}); - Mousetrap.bind([ "escape" ], function() { diff --git a/client/js/lounge.js b/client/js/lounge.js index 4fececa3..d32ca2db 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -180,11 +180,6 @@ $(function() { input.val(""); resetInputHeight(input.get(0)); - if (text.indexOf("/clear") === 0) { - utils.clear(); - return; - } - if (text.indexOf("/collapse") === 0) { $(".chan.active .toggle-preview.opened").click(); return; diff --git a/client/js/utils.js b/client/js/utils.js index 46a8dccb..086a796e 100644 --- a/client/js/utils.js +++ b/client/js/utils.js @@ -1,11 +1,9 @@ "use strict"; const $ = require("jquery"); -const chat = $("#chat"); const input = $("#input"); module.exports = { - clear, confirmExit, forceFocus, move, @@ -26,12 +24,6 @@ function forceFocus() { input.trigger("click").focus(); } -function clear() { - chat.find(".active") - .find(".show-more").addClass("show").end() - .find(".messages .msg, .date-marker-container").remove(); -} - function toggleNickEditor(toggle) { $("#nick").toggleClass("editable", toggle); $("#nick-value").attr("contenteditable", toggle); From f26c2dad0f085d061a9cba0a93fd4a63121d88d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=89lie=20Michel?= Date: Wed, 30 Aug 2017 16:18:38 +0200 Subject: [PATCH 42/72] Take an optional argument in /part Fix #1430 --- client/index.html | 7 +++++-- src/plugins/inputs/part.js | 23 ++++++++++++++++------- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/client/index.html b/client/index.html index 44b8efed..47f26142 100644 --- a/client/index.html +++ b/client/index.html @@ -787,10 +787,13 @@
- /part + /part [channel]
-

Close the current channel or private message window.

+

+ Close the specified channel or private message window, or the + current channel if channel is ommitted. +

Aliases: /close, /leave

diff --git a/src/plugins/inputs/part.js b/src/plugins/inputs/part.js index 88a46563..0763ee77 100644 --- a/src/plugins/inputs/part.js +++ b/src/plugins/inputs/part.js @@ -9,7 +9,17 @@ exports.commands = ["close", "leave", "part"]; exports.allowDisconnected = true; exports.input = function(network, chan, cmd, args) { - if (chan.type === Chan.Type.LOBBY) { + let target = args.length === 0 ? chan : _.find(network.channels, {name: args[0]}); + let partMessage = args.length <= 1 ? Helper.config.leaveMessage : args.slice(1).join(" "); + + if (typeof target === "undefined") { + // In this case, we assume that the word args[0] is part of the leave + // message and we part the current chan. + target = chan; + partMessage = args.join(" "); + } + + if (target.type === Chan.Type.LOBBY) { chan.pushMessage(this, new Msg({ type: Msg.Type.ERROR, text: "You can not part from networks, use /quit instead." @@ -17,18 +27,17 @@ exports.input = function(network, chan, cmd, args) { return; } - network.channels = _.without(network.channels, chan); - chan.destroy(); + network.channels = _.without(network.channels, target); + target.destroy(); this.emit("part", { - chan: chan.id + chan: target.id }); - if (chan.type === Chan.Type.CHANNEL) { + if (target.type === Chan.Type.CHANNEL) { this.save(); if (network.irc) { - const partMessage = args[0] ? args.join(" ") : Helper.config.leaveMessage; - network.irc.part(chan.name, partMessage); + network.irc.part(target.name, partMessage); } } From e4c6d78762c8a9aa3fa80b930b3d2a0e2d4397e4 Mon Sep 17 00:00:00 2001 From: dgw Date: Wed, 13 Sep 2017 13:43:45 -0500 Subject: [PATCH 43/72] Display correct kick modes Defining both the kicker and the target before any code touches the channel user list ensures that everything is passed to the template. --- client/views/actions/kick.tpl | 4 ++-- src/plugins/irc-events/kick.js | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/client/views/actions/kick.tpl b/client/views/actions/kick.tpl index 425a62b8..d739f4c9 100644 --- a/client/views/actions/kick.tpl +++ b/client/views/actions/kick.tpl @@ -1,6 +1,6 @@ -{{> ../user_name nick=from}} +{{> ../user_name nick=from.nick mode=from.mode}} has kicked -{{> ../user_name nick=target mode=""}} +{{> ../user_name nick=target.nick mode=target.mode}} {{#if text}} ({{{parse text}}}) {{/if}} diff --git a/src/plugins/irc-events/kick.js b/src/plugins/irc-events/kick.js index 6b3174e6..2a15fa8a 100644 --- a/src/plugins/irc-events/kick.js +++ b/src/plugins/irc-events/kick.js @@ -11,12 +11,13 @@ module.exports = function(irc, network) { return; } - const user = chan.findUser(data.kicked); + const kicker = chan.findUser(data.nick); + const target = chan.findUser(data.kicked); if (data.kicked === irc.user.nick) { chan.users = []; } else { - chan.users = _.without(chan.users, user); + chan.users = _.without(chan.users, target); } client.emit("users", { @@ -26,9 +27,8 @@ module.exports = function(irc, network) { var msg = new Msg({ type: Msg.Type.KICK, time: data.time, - mode: user.mode, - from: data.nick, - target: data.kicked, + from: kicker, + target: target, text: data.message || "", highlight: data.kicked === irc.user.nick, self: data.nick === irc.user.nick From 79eb83d82ff48a58aac7bc1bd6a44ece5ef85dcc Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Thu, 14 Sep 2017 10:41:50 +0300 Subject: [PATCH 44/72] Move userLog function where it belongs Fixes #438 --- src/client.js | 18 ------------------ src/models/chan.js | 16 ++++++++++++++++ 2 files changed, 16 insertions(+), 18 deletions(-) diff --git a/src/client.js b/src/client.js index 97252954..651f516c 100644 --- a/src/client.js +++ b/src/client.js @@ -5,7 +5,6 @@ var colors = require("colors/safe"); var pkg = require("../package.json"); var Chan = require("./models/chan"); var crypto = require("crypto"); -var userLog = require("./userLog"); var Msg = require("./models/msg"); var Network = require("./models/network"); var ircFramework = require("irc-framework"); @@ -114,23 +113,6 @@ Client.prototype.emit = function(event, data) { if (this.sockets !== null) { this.sockets.in(this.id).emit(event, data); } - if (this.config.log === true) { - if (event === "msg") { - var target = this.find(data.chan); - if (target) { - var chan = target.chan.name; - if (target.chan.type === Chan.Type.LOBBY) { - chan = target.network.host; - } - userLog.write( - this.name, - target.network.host, - chan, - data.msg - ); - } - } - } }; Client.prototype.find = function(channelId) { diff --git a/src/models/chan.js b/src/models/chan.js index e06e264f..0706ff64 100644 --- a/src/models/chan.js +++ b/src/models/chan.js @@ -2,6 +2,7 @@ var _ = require("lodash"); var Helper = require("../helper"); +const userLog = require("../userLog"); const storage = require("../plugins/storage"); module.exports = Chan; @@ -57,6 +58,10 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) { this.messages.push(msg); + if (client.config.log === true) { + writeUserLog(client, msg); + } + if (Helper.config.maxHistory >= 0 && this.messages.length > Helper.config.maxHistory) { const deleted = this.messages.splice(0, this.messages.length - Helper.config.maxHistory); @@ -127,3 +132,14 @@ Chan.prototype.toJSON = function() { clone.messages = clone.messages.slice(-100); return clone; }; + +function writeUserLog(client, msg) { + const target = client.find(this.id); + + userLog.write( + client.name, + target.network.host, // TODO: Fix #1392, multiple connections to same server results in duplicate logs + this.type === Chan.Type.LOBBY ? target.network.host : this.name, + msg + ); +} From 17dd18a60539a41e18d0f1e40a9f8ec36a5e45de Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Thu, 14 Sep 2017 10:42:21 +0300 Subject: [PATCH 45/72] Write correct timestamp to user log --- src/userLog.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/userLog.js b/src/userLog.js index 3d65bb9f..7d76b1d7 100644 --- a/src/userLog.js +++ b/src/userLog.js @@ -18,7 +18,7 @@ module.exports.write = function(user, network, chan, msg) { var format = Helper.config.logs.format || "YYYY-MM-DD HH:mm:ss"; var tz = Helper.config.logs.timezone || "UTC+00:00"; - var time = moment().utcOffset(tz).format(format); + var time = moment(msg.time).utcOffset(tz).format(format); var line = `[${time}] `; var type = msg.type.trim(); From c4ebd141c5a2c955a76f53a57154b867c88b4f16 Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Wed, 28 Jun 2017 14:37:07 -0700 Subject: [PATCH 46/72] Add anchor tag to URL to signify open page for reloading --- client/js/lounge.js | 7 ++++--- client/js/socket-events/init.js | 5 ++++- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/client/js/lounge.js b/client/js/lounge.js index 27bbc960..679c21e6 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -338,9 +338,9 @@ $(function() { if (history && history.pushState) { if (data && data.replaceHistory && history.replaceState) { - history.replaceState(state, null, null); + history.replaceState(state, null, target); } else { - history.pushState(state, null, null); + history.pushState(state, null, target); } } }); @@ -594,7 +594,8 @@ $(function() { } }); }); - if ($("body").hasClass("public")) { + + if ($("body").hasClass("public") && window.location.hash === "#connect") { $("#connect").one("show", function() { var params = URI(document.location.search); params = params.search(true); diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js index a78c07fa..cd66f391 100644 --- a/client/js/socket-events/init.js +++ b/client/js/socket-events/init.js @@ -32,7 +32,10 @@ socket.on("init", function(data) { const target = sidebar.find("[data-id='" + id + "']").trigger("click", { replaceHistory: true }); - if (target.length === 0) { + const dataTarget = document.querySelector("[data-target='" + window.location.hash + "']"); + if (window.location.hash && dataTarget) { + dataTarget.click(); + } else if (target.length === 0) { const first = sidebar.find(".chan") .eq(0) .trigger("click"); From 474fbc0a9c2fc889163f10438a3149d59da06719 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Fri, 15 Sep 2017 09:06:48 +0000 Subject: [PATCH 47/72] chore(package): update webpack to version 3.6.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5444317e..32afef5f 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,6 @@ "socket.io-client": "1.7.4", "stylelint": "8.1.1", "stylelint-config-standard": "17.0.0", - "webpack": "3.5.6" + "webpack": "3.6.0" } } From 90d6916b109159dd9833439526a060f948b430ea Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Fri, 15 Sep 2017 20:57:02 +0000 Subject: [PATCH 48/72] chore(package): update eslint to version 4.7.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 5444317e..2eeded09 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "chai": "4.1.2", "css.escape": "1.5.1", "emoji-regex": "6.5.1", - "eslint": "4.6.1", + "eslint": "4.7.0", "font-awesome": "4.7.0", "fuzzy": "0.1.3", "handlebars": "4.0.10", From cb1b6db14e2f4138d80da65438428b944f344d0b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Sun, 17 Sep 2017 01:11:04 -0400 Subject: [PATCH 49/72] Fix references to undefined `this` --- src/models/chan.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/models/chan.js b/src/models/chan.js index 0706ff64..3a9ddcec 100644 --- a/src/models/chan.js +++ b/src/models/chan.js @@ -59,7 +59,7 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) { this.messages.push(msg); if (client.config.log === true) { - writeUserLog(client, msg); + writeUserLog.call(this, client, msg); } if (Helper.config.maxHistory >= 0 && this.messages.length > Helper.config.maxHistory) { From 163cfaba3cf84f158bdce469f0b50660d5681880 Mon Sep 17 00:00:00 2001 From: Max Leiter Date: Mon, 10 Jul 2017 09:01:20 -0700 Subject: [PATCH 50/72] Use away-notify to show user away status change --- client/css/style.css | 10 ++++++++++ client/index.html | 4 ++-- client/js/condensed.js | 6 ++++++ client/js/constants.js | 4 ++++ client/views/actions/away.tpl | 3 +++ client/views/actions/back.tpl | 2 ++ client/views/index.js | 2 ++ src/client.js | 1 + src/models/msg.js | 2 ++ src/models/user.js | 1 + src/plugins/irc-events/away.js | 30 ++++++++++++++++++++++++++++++ 11 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 client/views/actions/away.tpl create mode 100644 client/views/actions/back.tpl create mode 100644 src/plugins/irc-events/away.js diff --git a/client/css/style.css b/client/css/style.css index 4ead5c6c..03cc1fd6 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -208,6 +208,8 @@ kbd { #settings .extra-help, #settings #play::before, #form #submit::before, +#chat .away .from::before, +#chat .back .from::before, #chat .invite .from::before, #chat .join .from::before, #chat .kick .from::before, @@ -258,6 +260,12 @@ kbd { #form #submit::before { content: "\f1d8"; /* http://fontawesome.io/icon/paper-plane/ */ } +#chat .away .from::before, +#chat .back .from::before { + content: "\f017"; /* http://fontawesome.io/icon/clock-o/ */ + color: #7f8c8d; +} + #chat .invite .from::before { content: "\f003"; /* http://fontawesome.io/icon/envelope-o/ */ color: #2ecc40; @@ -1157,6 +1165,8 @@ kbd { } #chat .condensed .content, +#chat .away .content, +#chat .back .content, #chat .join .content, #chat .kick .content, #chat .mode .content, diff --git a/client/index.html b/client/index.html index 44b8efed..1c00c1ca 100644 --- a/client/index.html +++ b/client/index.html @@ -225,8 +225,8 @@

Status messages - - + +

diff --git a/client/js/condensed.js b/client/js/condensed.js index aa1c8808..8f98fcda 100644 --- a/client/js/condensed.js +++ b/client/js/condensed.js @@ -23,6 +23,12 @@ function updateText(condensed, addedTypes) { constants.condensedTypes.forEach((type) => { if (obj[type]) { switch (type) { + case "away": + strings.push(obj[type] + (obj[type] > 1 ? " users have gone away" : " user has gone away")); + break; + case "back": + strings.push(obj[type] + (obj[type] > 1 ? " users have come back" : " user has come back")); + break; case "join": strings.push(obj[type] + (obj[type] > 1 ? " users have joined the channel" : " user has joined the channel")); break; diff --git a/client/js/constants.js b/client/js/constants.js index 1dedf55f..3f408d0a 100644 --- a/client/js/constants.js +++ b/client/js/constants.js @@ -60,6 +60,8 @@ const commands = [ ]; const actionTypes = [ + "away", + "back", "ban_list", "invite", "join", @@ -77,6 +79,8 @@ const actionTypes = [ ]; const condensedTypes = [ + "away", + "back", "join", "part", "quit", diff --git a/client/views/actions/away.tpl b/client/views/actions/away.tpl new file mode 100644 index 00000000..f4e52519 --- /dev/null +++ b/client/views/actions/away.tpl @@ -0,0 +1,3 @@ +{{> ../user_name nick=from}} +is away +({{{parse text}}}) diff --git a/client/views/actions/back.tpl b/client/views/actions/back.tpl new file mode 100644 index 00000000..cb24ea5e --- /dev/null +++ b/client/views/actions/back.tpl @@ -0,0 +1,2 @@ +{{> ../user_name nick=from}} +is back diff --git a/client/views/index.js b/client/views/index.js index aae3c78b..50f6a93f 100644 --- a/client/views/index.js +++ b/client/views/index.js @@ -3,6 +3,8 @@ module.exports = { actions: { action: require("./actions/action.tpl"), + away: require("./actions/away.tpl"), + back: require("./actions/back.tpl"), ban_list: require("./actions/ban_list.tpl"), channel_list: require("./actions/channel_list.tpl"), ctcp: require("./actions/ctcp.tpl"), diff --git a/src/client.js b/src/client.js index cb38161d..8d068cb6 100644 --- a/src/client.js +++ b/src/client.js @@ -16,6 +16,7 @@ module.exports = Client; var id = 0; var events = [ + "away", "connection", "unhandled", "banlist", diff --git a/src/models/msg.js b/src/models/msg.js index 019a7198..fc5e3abd 100644 --- a/src/models/msg.js +++ b/src/models/msg.js @@ -29,7 +29,9 @@ class Msg { Msg.Type = { UNHANDLED: "unhandled", + AWAY: "away", ACTION: "action", + BACK: "back", ERROR: "error", INVITE: "invite", JOIN: "join", diff --git a/src/models/user.js b/src/models/user.js index 2f8340f0..a813bacb 100644 --- a/src/models/user.js +++ b/src/models/user.js @@ -7,6 +7,7 @@ module.exports = User; function User(attr, prefixLookup) { _.defaults(this, attr, { modes: [], + away: "", mode: "", nick: "", lastMessage: 0, diff --git a/src/plugins/irc-events/away.js b/src/plugins/irc-events/away.js new file mode 100644 index 00000000..06aa1218 --- /dev/null +++ b/src/plugins/irc-events/away.js @@ -0,0 +1,30 @@ +"use strict"; + +const _ = require("lodash"); +const Msg = require("../../models/msg"); + +module.exports = function(irc, network) { + const client = this; + irc.on("away", (data) => { + const away = data.message; + + network.channels.forEach((chan) => { + const user = _.find(chan.users, {nick: data.nick}); + + if (!user || user.away === away) { + return; + } + + const msg = new Msg({ + type: away ? Msg.Type.AWAY : Msg.Type.BACK, + text: away || "", + time: data.time, + from: data.nick, + mode: user.mode + }); + + chan.pushMessage(client, msg); + user.away = away; + }); + }); +}; From 59d2f93f61769201c51686b82ece4c1650a6d131 Mon Sep 17 00:00:00 2001 From: Alistair McKinlay Date: Thu, 22 Jun 2017 22:09:55 +0100 Subject: [PATCH 51/72] Allow themes from npm --- client/index.html | 4 +- defaults/config.js | 5 ++- src/helper.js | 10 +++++ src/plugins/themes.js | 85 +++++++++++++++++++++++++++++++++++++++++++ src/server.js | 25 ++++++++----- 5 files changed, 116 insertions(+), 13 deletions(-) create mode 100644 src/plugins/themes.js diff --git a/client/index.html b/client/index.html index 843e738b..cdce541f 100644 --- a/client/index.html +++ b/client/index.html @@ -264,8 +264,8 @@ diff --git a/defaults/config.js b/defaults/config.js index 7090760b..a2e3f136 100644 --- a/defaults/config.js +++ b/defaults/config.js @@ -51,11 +51,12 @@ module.exports = { // // Set the default theme. + // Find out how to add new themes at https://thelounge.github.io/docs/packages/themes // // @type string - // @default "themes/example.css" + // @default "example" // - theme: "themes/example.css", + theme: "example", // // Prefetch URLs diff --git a/src/helper.js b/src/helper.js index 288dcfec..73c53262 100644 --- a/src/helper.js +++ b/src/helper.js @@ -12,6 +12,8 @@ const colors = require("colors/safe"); var Helper = { config: null, expandHome: expandHome, + getPackagesPath: getPackagesPath, + getPackageModulePath: getPackageModulePath, getStoragePath: getStoragePath, getUserConfigPath: getUserConfigPath, getUserLogsPath: getUserLogsPath, @@ -96,6 +98,14 @@ function getStoragePath() { return path.join(this.HOME, "storage"); } +function getPackagesPath() { + return path.join(this.HOME, "packages", "node_modules"); +} + +function getPackageModulePath(packageName) { + return path.join(Helper.getPackagesPath(), packageName); +} + function ip2hex(address) { // no ipv6 support if (!net.isIPv4(address)) { diff --git a/src/plugins/themes.js b/src/plugins/themes.js new file mode 100644 index 00000000..8c90cdd9 --- /dev/null +++ b/src/plugins/themes.js @@ -0,0 +1,85 @@ +"use strict"; + +const fs = require("fs"); +const Helper = require("../helper"); +const colors = require("colors/safe"); +const path = require("path"); +const _ = require("lodash"); +const themes = new Map(); + +module.exports = { + getAll: getAll, + getFilename: getFilename +}; + +fs.readdir("client/themes/", (err, builtInThemes) => { + if (err) { + return; + } + builtInThemes + .filter((theme) => theme.endsWith(".css")) + .map(makeLocalThemeObject) + .forEach((theme) => themes.set(theme.name, theme)); +}); + +fs.readdir(Helper.getPackagesPath(), (err, packages) => { + if (err) { + return; + } + packages + .map(makePackageThemeObject) + .forEach((theme) => { + if (theme) { + themes.set(theme.name, theme); + } + }); +}); + +function getAll() { + return _.sortBy(Array.from(themes.values()), "displayName"); +} + +function getFilename(module) { + if (themes.has(module)) { + return themes.get(module).filename; + } +} + +function makeLocalThemeObject(css) { + const themeName = css.slice(0, -4); + return { + displayName: themeName.charAt(0).toUpperCase() + themeName.slice(1), + filename: path.join(__dirname, "..", "client", "themes", `${themeName}.css`), + name: themeName + }; +} + +function getModuleInfo(packageName) { + let module; + try { + module = require(Helper.getPackageModulePath(packageName)); + } catch (e) { + log.warn(`Specified theme ${colors.yellow(packageName)} is not installed in packages directory`); + return; + } + if (!module.lounge) { + log.warn(`Specified theme ${colors.yellow(packageName)} doesn't have required information.`); + return; + } + return module.lounge; +} + +function makePackageThemeObject(moduleName) { + const module = getModuleInfo(moduleName); + if (!module || module.type !== "theme") { + return; + } + const modulePath = Helper.getPackageModulePath(moduleName); + const displayName = module.name || moduleName; + const filename = path.join(modulePath, module.css); + return { + displayName: displayName, + filename: filename, + name: moduleName + }; +} diff --git a/src/server.js b/src/server.js index 5b708555..aebc6bfb 100644 --- a/src/server.js +++ b/src/server.js @@ -14,6 +14,7 @@ var Helper = require("./helper"); var colors = require("colors/safe"); const net = require("net"); const Identification = require("./identification"); +const themes = require("./plugins/themes"); // The order defined the priority: the first available plugin is used // ALways keep local auth in the end, which should always be enabled. @@ -51,6 +52,15 @@ module.exports = function() { .set("view engine", "html") .set("views", path.join(__dirname, "..", "client")); + app.get("/themes/:theme.css", (req, res) => { + const themeName = req.params.theme; + const theme = themes.getFilename(themeName); + if (theme === undefined) { + return res.status(404).send("Not found"); + } + return res.sendFile(theme); + }); + var config = Helper.config; var server = null; @@ -191,16 +201,13 @@ function index(req, res, next) { pkg, Helper.config ); + if (!data.theme.includes(".css")) { // Backwards compatibility for old way of specifying themes in settings + data.theme = `themes/${data.theme}.css`; + } else { + log.warn(`Referring to CSS files in the ${colors.green("theme")} setting of ${colors.green(Helper.CONFIG_PATH)} is ${colors.bold("deprecated")} and will be removed in a future version.`); + } data.gitCommit = Helper.getGitCommit(); - data.themes = fs.readdirSync("client/themes/").filter(function(themeFile) { - return themeFile.endsWith(".css"); - }).map(function(css) { - const filename = css.slice(0, -4); - return { - name: filename.charAt(0).toUpperCase() + filename.slice(1), - filename: filename - }; - }); + data.themes = themes.getAll(); const policies = [ "default-src *", From b8399471b330209d246bc416248e05a8ddc8ec99 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Sun, 17 Sep 2017 21:50:21 -0400 Subject: [PATCH 52/72] Enable ESLint `no-console` rule to avoid future mistakes --- .eslintrc.yml | 1 - client/js/socket.js | 2 -- index.js | 2 ++ scripts/build-fontawesome.js | 1 + src/command-line/utils.js | 2 +- src/log.js | 2 ++ test/fixtures/env.js | 2 +- webpack.config.js | 2 -- 8 files changed, 7 insertions(+), 7 deletions(-) diff --git a/.eslintrc.yml b/.eslintrc.yml index 4127a442..eb14e6de 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -28,7 +28,6 @@ rules: linebreak-style: [2, unix] no-catch-shadow: 2 no-confusing-arrow: 2 - no-console: 0 no-control-regex: 0 no-duplicate-imports: 2 no-else-return: 2 diff --git a/client/js/socket.js b/client/js/socket.js index 6f702fb1..b7ba0e70 100644 --- a/client/js/socket.js +++ b/client/js/socket.js @@ -35,8 +35,6 @@ const socket = io({ }); // Hides the "Send Message" button $("#submit").remove(); - - console.error(data); }); }); diff --git a/index.js b/index.js index f9ed254a..d1f292a3 100755 --- a/index.js +++ b/index.js @@ -8,9 +8,11 @@ process.chdir(__dirname); // Doing this check as soon as possible allows us to avoid ES6 parser errors or other issues var pkg = require("./package.json"); if (!require("semver").satisfies(process.version, pkg.engines.node)) { + /* eslint-disable no-console */ console.error("=== WARNING!"); console.error("=== The oldest supported Node.js version is", pkg.engines.node); console.error("=== We strongly encourage you to upgrade, see https://nodejs.org/en/download/package-manager/ for more details\n"); + /* eslint-enable no-console */ } require("./src/command-line"); diff --git a/scripts/build-fontawesome.js b/scripts/build-fontawesome.js index 6c55863a..79688fa3 100644 --- a/scripts/build-fontawesome.js +++ b/scripts/build-fontawesome.js @@ -1,3 +1,4 @@ +/* eslint-disable no-console */ "use strict"; var fs = require("fs-extra"); diff --git a/src/command-line/utils.js b/src/command-line/utils.js index 8f5676af..12de6d84 100644 --- a/src/command-line/utils.js +++ b/src/command-line/utils.js @@ -15,7 +15,7 @@ class Utils { "", ` LOUNGE_HOME Path for all configuration files and folders. Defaults to ${colors.green(Utils.defaultLoungeHome())}.`, "", - ].forEach((e) => console.log(e)); + ].forEach((e) => console.log(e)); // eslint-disable-line no-console } static defaultLoungeHome() { diff --git a/src/log.js b/src/log.js index 9a5ded3b..584d4e4f 100644 --- a/src/log.js +++ b/src/log.js @@ -16,6 +16,7 @@ function timestamp(type, messageArgs) { return messageArgs; } +/* eslint-disable no-console */ exports.error = function() { console.error.apply(console, timestamp(colors.red("[ERROR]"), arguments)); }; @@ -31,6 +32,7 @@ exports.info = function() { exports.debug = function() { console.log.apply(console, timestamp(colors.green("[DEBUG]"), arguments)); }; +/* eslint-enable no-console */ exports.prompt = (options, callback) => { options.prompt = timestamp(colors.cyan("[PROMPT]"), [options.text]).join(" "); diff --git a/test/fixtures/env.js b/test/fixtures/env.js index f561eafd..6e4dffba 100644 --- a/test/fixtures/env.js +++ b/test/fixtures/env.js @@ -1,7 +1,7 @@ "use strict"; global.log = { - error: () => console.error.apply(console, arguments), + error: () => console.error.apply(console, arguments), // eslint-disable-line no-console warn: () => {}, info: () => {}, debug: () => {}, diff --git a/webpack.config.js b/webpack.config.js index e1888588..afacb60d 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -79,8 +79,6 @@ if (process.env.NODE_ENV === "production") { sourceMap: true, comments: false })); -} else { - console.log("Building in development mode, bundles will not be minified."); } module.exports = config; From 64cc4927b34dad2dea7f3e9cf614eb3e331aa66b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Sun, 17 Sep 2017 21:50:41 -0400 Subject: [PATCH 53/72] Make sure we never ship with JS alerts by accident --- .eslintrc.yml | 1 + client/js/lounge.js | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.eslintrc.yml b/.eslintrc.yml index eb14e6de..5d79006a 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -26,6 +26,7 @@ rules: key-spacing: [2, {beforeColon: false, afterColon: true}] keyword-spacing: [2, {before: true, after: true}] linebreak-style: [2, unix] + no-alert: 2 no-catch-shadow: 2 no-confusing-arrow: 2 no-control-regex: 0 diff --git a/client/js/lounge.js b/client/js/lounge.js index f69a6e12..3aa7ebc9 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -418,7 +418,7 @@ $(function() { if (chan.hasClass("lobby")) { cmd = "/quit"; var server = chan.find(".name").html(); - if (!confirm("Disconnect from " + server + "?")) { + if (!confirm("Disconnect from " + server + "?")) { // eslint-disable-line no-alert return false; } } From 82c489871546e2b03daf54647ab4edfae0f3b89c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Sun, 17 Sep 2017 22:04:29 -0400 Subject: [PATCH 54/72] Use explicit keywords instead of cryptic codes in ESLint config Bikeshedding at its best, isn't it? --- .eslintrc.yml | 96 +++++++++++++++++++++++++-------------------------- 1 file changed, 48 insertions(+), 48 deletions(-) diff --git a/.eslintrc.yml b/.eslintrc.yml index 5d79006a..25ef87e0 100644 --- a/.eslintrc.yml +++ b/.eslintrc.yml @@ -9,54 +9,54 @@ env: node: true rules: - arrow-body-style: 2 - arrow-parens: [2, always] - arrow-spacing: 2 - block-scoped-var: 2 - block-spacing: [2, always] - brace-style: [2, 1tbs] - comma-dangle: 0 - curly: [2, all] - dot-location: [2, property] - dot-notation: 2 - eol-last: 2 - eqeqeq: 2 - handle-callback-err: 2 - indent: [2, tab] - key-spacing: [2, {beforeColon: false, afterColon: true}] - keyword-spacing: [2, {before: true, after: true}] - linebreak-style: [2, unix] - no-alert: 2 - no-catch-shadow: 2 - no-confusing-arrow: 2 - no-control-regex: 0 - no-duplicate-imports: 2 - no-else-return: 2 - no-implicit-globals: 2 - no-multi-spaces: 2 - no-multiple-empty-lines: [2, { "max": 1 }] - no-shadow: 2 - no-template-curly-in-string: 2 - no-trailing-spaces: 2 - no-unsafe-negation: 2 - no-useless-computed-key: 2 - no-useless-return: 2 - object-curly-spacing: [2, never] - padded-blocks: [2, never] - prefer-const: 2 - quote-props: [2, as-needed] - quotes: [2, double, avoid-escape] - semi-spacing: 2 - semi-style: [2, last] - semi: [2, always] - space-before-blocks: 2 - space-before-function-paren: [2, never] - space-in-parens: [2, never] - space-infix-ops: 2 - spaced-comment: [2, always] - strict: 2 - template-curly-spacing: 2 - yoda: 2 + arrow-body-style: error + arrow-parens: [error, always] + arrow-spacing: error + block-scoped-var: error + block-spacing: [error, always] + brace-style: [error, 1tbs] + comma-dangle: off + curly: [error, all] + dot-location: [error, property] + dot-notation: error + eol-last: error + eqeqeq: error + handle-callback-err: error + indent: [error, tab] + key-spacing: [error, {beforeColon: false, afterColon: true}] + keyword-spacing: [error, {before: true, after: true}] + linebreak-style: [error, unix] + no-alert: error + no-catch-shadow: error + no-confusing-arrow: error + no-control-regex: off + no-duplicate-imports: error + no-else-return: error + no-implicit-globals: error + no-multi-spaces: error + no-multiple-empty-lines: [error, { "max": 1 }] + no-shadow: error + no-template-curly-in-string: error + no-trailing-spaces: error + no-unsafe-negation: error + no-useless-computed-key: error + no-useless-return: error + object-curly-spacing: [error, never] + padded-blocks: [error, never] + prefer-const: error + quote-props: [error, as-needed] + quotes: [error, double, avoid-escape] + semi-spacing: error + semi-style: [error, last] + semi: [error, always] + space-before-blocks: error + space-before-function-paren: [error, never] + space-in-parens: [error, never] + space-infix-ops: error + spaced-comment: [error, always] + strict: error + template-curly-spacing: error + yoda: error globals: log: false From aa377ee59b125cc409ef1cd9179c8f1871a1c50d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Sun, 17 Sep 2017 22:19:32 -0400 Subject: [PATCH 55/72] Switch Font Awesome build script to use our logger --- scripts/build-fontawesome.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/scripts/build-fontawesome.js b/scripts/build-fontawesome.js index 79688fa3..74be669a 100644 --- a/scripts/build-fontawesome.js +++ b/scripts/build-fontawesome.js @@ -1,26 +1,27 @@ -/* eslint-disable no-console */ "use strict"; -var fs = require("fs-extra"); +const colors = require("colors/safe"); +const fs = require("fs-extra"); +const log = require("../src/log"); -var srcDir = "./node_modules/font-awesome/fonts/"; -var destDir = "./client/fonts/"; -var fonts = [ +const srcDir = "./node_modules/font-awesome/fonts/"; +const destDir = "./client/fonts/"; +const fonts = [ "fontawesome-webfont.woff", "fontawesome-webfont.woff2" ]; -fs.ensureDir(destDir, function(dirErr) { +fs.ensureDir(destDir, (dirErr) => { if (dirErr) { - console.error(dirErr); + log.error(dirErr); } - fonts.forEach(function(font) { - fs.copy(srcDir + font, destDir + font, function(err) { + fonts.forEach((font) => { + fs.copy(srcDir + font, destDir + font, (err) => { if (err) { - console.error(err); + log.error(err); } else { - console.log(font + " successfully installed."); + log.info(colors.bold(font) + " successfully installed."); } }); }); From 3ac15f97f1b243774dbad8978456bff73a38fdbb Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 18 Sep 2017 13:32:52 +0300 Subject: [PATCH 56/72] Use native font stack --- client/css/style.css | 30 ++++-------------------------- client/themes/morning.css | 8 -------- client/themes/zenburn.css | 8 -------- 3 files changed, 4 insertions(+), 42 deletions(-) diff --git a/client/css/style.css b/client/css/style.css index 746178cc..08f6006a 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1,25 +1,3 @@ -@font-face { - font-family: "Lato"; - font-weight: 400; - font-style: normal; - src: - local("Lato Regular"), - local("Lato-regular"), - url("fonts/Lato-regular/Lato-regular.woff2") format("woff2"), - url("fonts/Lato-regular/Lato-regular.woff") format("woff"); -} - -@font-face { - font-family: "Lato"; - font-weight: 700; - font-style: normal; - src: - local("Lato Bold"), - local("Lato-700"), - url("fonts/Lato-700/Lato-700.woff2") format("woff2"), - url("fonts/Lato-700/Lato-700.woff") format("woff"); -} - @font-face { font-family: "FontAwesome"; font-weight: normal; @@ -37,7 +15,7 @@ body { body { background: #455164; color: #222; - font: 16px Lato, sans-serif; + font: 16px -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; margin: 0; -webkit-user-select: none; -moz-user-select: none; @@ -869,10 +847,11 @@ kbd { visibility: hidden; } +#windows #form .input, #windows .header .topic, .messages .msg, .sidebar { - font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; + font-size: 13px; line-height: 1.4; } @@ -1492,7 +1471,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ } #windows #form .input { - font: 13px Consolas, Menlo, Monaco, "Lucida Console", "DejaVu Sans Mono", "Courier New", monospace; border: 1px solid #ddd; border-radius: 2px; margin: 0; @@ -1691,7 +1669,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ z-index: 1000000; display: none; padding: 5px 8px; - font: 12px Lato; + font-size: 12px; line-height: 1.2; -webkit-font-smoothing: subpixel-antialiased; color: #fff; diff --git a/client/themes/morning.css b/client/themes/morning.css index 9067c7f9..856c530c 100644 --- a/client/themes/morning.css +++ b/client/themes/morning.css @@ -29,14 +29,6 @@ body { background: #333c4a; } -#windows .header .topic, -#windows #form .input, -.messages .msg, -.sidebar { - font-family: inherit; - font-size: 13px; -} - #chat .count { background-color: #2e3642; } diff --git a/client/themes/zenburn.css b/client/themes/zenburn.css index 525110c7..21aabd23 100644 --- a/client/themes/zenburn.css +++ b/client/themes/zenburn.css @@ -30,14 +30,6 @@ body { background: #3f3f3f; } -#windows .header .topic, -#windows #form .input, -.messages .msg, -.sidebar { - font-family: inherit; - font-size: 13px; -} - #settings, #sign-in, #connect .title { From 4e776f7a5fa932f9f248f388ba82252d80ea295a Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 18 Sep 2017 13:33:04 +0300 Subject: [PATCH 57/72] Remove Lato --- client/css/fonts/Lato-700/LICENSE.txt | 93 ------------------ client/css/fonts/Lato-700/Lato-700.woff | Bin 25584 -> 0 bytes client/css/fonts/Lato-700/Lato-700.woff2 | Bin 16392 -> 0 bytes client/css/fonts/Lato-regular/LICENSE.txt | 93 ------------------ .../css/fonts/Lato-regular/Lato-regular.woff | Bin 24680 -> 0 bytes .../css/fonts/Lato-regular/Lato-regular.woff2 | Bin 16436 -> 0 bytes 6 files changed, 186 deletions(-) delete mode 100755 client/css/fonts/Lato-700/LICENSE.txt delete mode 100755 client/css/fonts/Lato-700/Lato-700.woff delete mode 100755 client/css/fonts/Lato-700/Lato-700.woff2 delete mode 100755 client/css/fonts/Lato-regular/LICENSE.txt delete mode 100755 client/css/fonts/Lato-regular/Lato-regular.woff delete mode 100755 client/css/fonts/Lato-regular/Lato-regular.woff2 diff --git a/client/css/fonts/Lato-700/LICENSE.txt b/client/css/fonts/Lato-700/LICENSE.txt deleted file mode 100755 index 98383e3d..00000000 --- a/client/css/fonts/Lato-700/LICENSE.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client/css/fonts/Lato-700/Lato-700.woff b/client/css/fonts/Lato-700/Lato-700.woff deleted file mode 100755 index 66c8242c1393f9997b8a3f33ad0af647858ceb1e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 25584 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 Zln52x>}3m45q8nK7(!(39UwQqEkMPy|t|Nqp< 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�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 vFP1XAth;3k-V{s diff --git a/client/css/fonts/Lato-regular/LICENSE.txt b/client/css/fonts/Lato-regular/LICENSE.txt deleted file mode 100755 index 98383e3d..00000000 --- a/client/css/fonts/Lato-regular/LICENSE.txt +++ /dev/null @@ -1,93 +0,0 @@ -Copyright (c) 2010-2014 by tyPoland Lukasz Dziedzic (team@latofonts.com) with Reserved Font Name "Lato" - -This Font Software is licensed under the SIL Open Font License, Version 1.1. -This license is copied below, and is also available with a FAQ at: -http://scripts.sil.org/OFL - - ------------------------------------------------------------ -SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 ------------------------------------------------------------ - -PREAMBLE -The goals of the Open Font License (OFL) are to stimulate worldwide -development of collaborative font projects, to support the font creation -efforts of academic and linguistic communities, and to provide a free and -open framework in which fonts may be shared and improved in partnership -with others. - -The OFL allows the licensed fonts to be used, studied, modified and -redistributed freely as long as they are not sold by themselves. The -fonts, including any derivative works, can be bundled, embedded, -redistributed and/or sold with any software provided that any reserved -names are not used by derivative works. The fonts and derivatives, -however, cannot be released under any other type of license. The -requirement for fonts to remain under this license does not apply -to any document created using the fonts or their derivatives. - -DEFINITIONS -"Font Software" refers to the set of files released by the Copyright -Holder(s) under this license and clearly marked as such. This may -include source files, build scripts and documentation. - -"Reserved Font Name" refers to any names specified as such after the -copyright statement(s). - -"Original Version" refers to the collection of Font Software components as -distributed by the Copyright Holder(s). - -"Modified Version" refers to any derivative made by adding to, deleting, -or substituting -- in part or in whole -- any of the components of the -Original Version, by changing formats or by porting the Font Software to a -new environment. - -"Author" refers to any designer, engineer, programmer, technical -writer or other person who contributed to the Font Software. - -PERMISSION & CONDITIONS -Permission is hereby granted, free of charge, to any person obtaining -a copy of the Font Software, to use, study, copy, merge, embed, modify, -redistribute, and sell modified and unmodified copies of the Font -Software, subject to the following conditions: - -1) Neither the Font Software nor any of its individual components, -in Original or Modified Versions, may be sold by itself. - -2) Original or Modified Versions of the Font Software may be bundled, -redistributed and/or sold with any software, provided that each copy -contains the above copyright notice and this license. These can be -included either as stand-alone text files, human-readable headers or -in the appropriate machine-readable metadata fields within text or -binary files as long as those fields can be easily viewed by the user. - -3) No Modified Version of the Font Software may use the Reserved Font -Name(s) unless explicit written permission is granted by the corresponding -Copyright Holder. This restriction only applies to the primary font name as -presented to the users. - -4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font -Software shall not be used to promote, endorse or advertise any -Modified Version, except to acknowledge the contribution(s) of the -Copyright Holder(s) and the Author(s) or with their explicit written -permission. - -5) The Font Software, modified or unmodified, in part or in whole, -must be distributed entirely under this license, and must not be -distributed under any other license. The requirement for fonts to -remain under this license does not apply to any document created -using the Font Software. - -TERMINATION -This license becomes null and void if any of the above conditions are -not met. - -DISCLAIMER -THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT -OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE -COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, -INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL -DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING -FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM -OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/client/css/fonts/Lato-regular/Lato-regular.woff b/client/css/fonts/Lato-regular/Lato-regular.woff deleted file mode 100755 index fe27504d07b8e4c62de236829887ed9f03826cb3..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 24680 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 Qz)O#=;7up{65!$e3k?s=y8r+H diff --git a/client/css/fonts/Lato-regular/Lato-regular.woff2 b/client/css/fonts/Lato-regular/Lato-regular.woff2 deleted file mode 100755 index c83fe9554271f5e672fed4be4c1cb39e4e63612b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 16436 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 K2co}`^8W!w9O Date: Mon, 18 Sep 2017 21:57:40 +0000 Subject: [PATCH 58/72] chore(package): update eslint to version 4.7.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index f9264f75..84318f5c 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ "chai": "4.1.2", "css.escape": "1.5.1", "emoji-regex": "6.5.1", - "eslint": "4.7.0", + "eslint": "4.7.1", "font-awesome": "4.7.0", "fuzzy": "0.1.3", "handlebars": "4.0.10", From 0c0df1efc981e4ed7af552f92043ec0dc17991f4 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 28 Aug 2017 12:18:31 +0300 Subject: [PATCH 59/72] Force reload the page if socket reconnects and server restarted --- client/js/lounge.js | 6 +++--- client/js/socket-events/auth.js | 26 +++++++++++++++++++++++++- client/js/socket.js | 15 +++++++++++---- client/js/utils.js | 5 +++++ src/server.js | 8 +++++++- 5 files changed, 51 insertions(+), 9 deletions(-) diff --git a/client/js/lounge.js b/client/js/lounge.js index 1f5a5251..ef4e20a1 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -588,9 +588,6 @@ $(function() { } setTimeout(updateDateMarkers, msUntilNextDay()); - // Only start opening socket.io connection after all events have been registered - socket.open(); - window.addEventListener("popstate", (e) => { const {state} = e; if (!state) { @@ -604,4 +601,7 @@ $(function() { }); } }); + + // Only start opening socket.io connection after all events have been registered + socket.open(); }); diff --git a/client/js/socket-events/auth.js b/client/js/socket-events/auth.js index 08ce2ebd..6cdf923f 100644 --- a/client/js/socket-events/auth.js +++ b/client/js/socket-events/auth.js @@ -3,8 +3,19 @@ const $ = require("jquery"); const socket = require("../socket"); const storage = require("../localStorage"); +const utils = require("../utils"); socket.on("auth", function(data) { + // If we reconnected and serverHash differents, that means the server restarted + // And we will reload the page to grab the latest version + if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) { + socket.disconnect(); + location.reload(true); + return; + } + + utils.serverHash = data.serverHash; + const login = $("#sign-in"); let token; const user = storage.get("user"); @@ -12,6 +23,13 @@ socket.on("auth", function(data) { login.find(".btn").prop("disabled", false); if (!data.success) { + if (login.length === 0) { + socket.disconnect(); + $("#connection-error").text("Authentication failed, reloading…"); + location.reload(); + return; + } + storage.remove("token"); const error = login.find(".error"); @@ -20,9 +38,15 @@ socket.on("auth", function(data) { }); } else if (user) { token = storage.get("token"); + if (token) { $("#loading-page-message").text("Authorizing…"); - socket.emit("auth", {user: user, token: token}); + + socket.emit("auth", { + user: user, + token: token, + lastMessage: utils.lastMessageId, + }); } } diff --git a/client/js/socket.js b/client/js/socket.js index b7ba0e70..a646ad47 100644 --- a/client/js/socket.js +++ b/client/js/socket.js @@ -8,9 +8,11 @@ const socket = io({ transports: $(document.body).data("transports"), path: path, autoConnect: false, - reconnection: false + reconnection: !$(document.body).hasClass("public") }); +window.lounge_socket = socket; // TODO: Remove later, this is for debugging + [ "connect_error", "connect_failed", @@ -34,7 +36,7 @@ const socket = io({ } }); // Hides the "Send Message" button - $("#submit").remove(); + $("#submit").hide(); }); }); @@ -43,11 +45,16 @@ socket.on("connecting", function() { }); socket.on("connect", function() { - $("#loading-page-message").text("Finalizing connection…"); + // Clear send buffer when reconnecting, socket.io would emit these + // immediately upon connection and it will have no effect, so we ensure + // nothing is sent to the server that might have happened. + socket.sendBuffer = []; + + status.text("Finalizing connection…"); }); socket.on("authorized", function() { - $("#loading-page-message").text("Authorized, loading messages…"); + $("#loading-page-message").text("Loading messages…"); }); module.exports = socket; diff --git a/client/js/utils.js b/client/js/utils.js index 086a796e..07b1d328 100644 --- a/client/js/utils.js +++ b/client/js/utils.js @@ -3,7 +3,12 @@ const $ = require("jquery"); const input = $("#input"); +var serverHash = -1; +var lastMessageId = -1; + module.exports = { + serverHash, + lastMessageId, confirmExit, forceFocus, move, diff --git a/src/server.js b/src/server.js index aebc6bfb..7069a905 100644 --- a/src/server.js +++ b/src/server.js @@ -23,6 +23,9 @@ const authPlugins = [ require("./plugins/auth/local"), ]; +// A random number that will force clients to reload the page if it differs +const serverHash = Math.floor(Date.now() * Math.random()); + var manager = null; module.exports = function() { @@ -135,7 +138,10 @@ module.exports = function() { if (config.public) { performAuthentication.call(socket, {}); } else { - socket.emit("auth", {success: true}); + socket.emit("auth", { + serverHash: serverHash, + success: true, + }); socket.on("auth", performAuthentication); } }); From 05fc00d9be2de82780f8dc78282bb1413df8dde1 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 28 Aug 2017 18:03:27 +0300 Subject: [PATCH 60/72] Display all the status changes in UI --- client/css/style.css | 9 +++++++ client/index.html | 2 +- client/js/render.js | 4 +++ client/js/socket-events/auth.js | 3 ++- client/js/socket-events/init.js | 35 +++++++++++++++++++------- client/js/socket.js | 44 ++++++++++++--------------------- client/themes/crypto.css | 6 +---- client/themes/example.css | 8 ------ client/themes/morning.css | 8 ------ client/themes/zenburn.css | 8 ------ 10 files changed, 59 insertions(+), 68 deletions(-) diff --git a/client/css/style.css b/client/css/style.css index 08f6006a..d776988a 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1483,6 +1483,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ } #connection-error { + font-size: 12px; + line-height: 36px; + font-weight: bold; + letter-spacing: 1px; + word-spacing: 3px; + text-transform: uppercase; + background: #e74c3c; + color: #fff; + text-align: center; display: none; } diff --git a/client/index.html b/client/index.html index cdce541f..61abcde2 100644 --- a/client/index.html +++ b/client/index.html @@ -63,7 +63,7 @@
- +
diff --git a/client/js/render.js b/client/js/render.js index cb0adc36..9e18ffe7 100644 --- a/client/js/render.js +++ b/client/js/render.js @@ -35,6 +35,10 @@ function buildChannelMessages(chanId, chanType, messages) { } function appendMessage(container, chanId, chanType, msg) { + if (utils.lastMessageId < msg.id) { + utils.lastMessageId = msg.id; + } + let lastChild = container.children(".msg, .date-marker-container").last(); const renderedMessage = buildChatMessage(msg); diff --git a/client/js/socket-events/auth.js b/client/js/socket-events/auth.js index 6cdf923f..cb0ef4ee 100644 --- a/client/js/socket-events/auth.js +++ b/client/js/socket-events/auth.js @@ -10,6 +10,7 @@ socket.on("auth", function(data) { // And we will reload the page to grab the latest version if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) { socket.disconnect(); + $("#connection-error").text("Server restarted, reloading…"); location.reload(true); return; } @@ -40,7 +41,7 @@ socket.on("auth", function(data) { token = storage.get("token"); if (token) { - $("#loading-page-message").text("Authorizing…"); + $("#loading-page-message, #connection-error").text("Authorizing…"); socket.emit("auth", { user: user, diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js index cd66f391..f200a834 100644 --- a/client/js/socket-events/init.js +++ b/client/js/socket-events/init.js @@ -6,11 +6,22 @@ const render = require("../render"); const webpush = require("../webpush"); const sidebar = $("#sidebar"); const storage = require("../localStorage"); +const utils = require("../utils"); socket.on("init", function(data) { - $("#loading-page-message").text("Rendering…"); + $("#loading-page-message, #connection-error").text("Rendering…"); + + const lastMessageId = utils.lastMessageId; + + // TODO: this is hacky + if (lastMessageId > -1) { + sidebar.find(".networks").empty(); + $("#chat").empty(); + } if (data.networks.length === 0) { + sidebar.find(".empty").show(); + $("#footer").find(".connect").trigger("click", { pushState: false, }); @@ -18,16 +29,22 @@ socket.on("init", function(data) { render.renderNetworks(data); } - if (data.token) { - storage.set("token", data.token); + if (lastMessageId > -1) { + $("#connection-error").removeClass("shown"); + $(".show-more-button, #input").prop("disabled", false); + $("#submit").show(); + } else { + if (data.token) { + storage.set("token", data.token); + } + + webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey); + + $("body").removeClass("signed-out"); + $("#loading").remove(); + $("#sign-in").remove(); } - webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey); - - $("body").removeClass("signed-out"); - $("#loading").remove(); - $("#sign-in").remove(); - const id = data.active; const target = sidebar.find("[data-id='" + id + "']").trigger("click", { replaceHistory: true diff --git a/client/js/socket.js b/client/js/socket.js index a646ad47..c5593f90 100644 --- a/client/js/socket.js +++ b/client/js/socket.js @@ -3,6 +3,7 @@ const $ = require("jquery"); const io = require("socket.io-client"); const path = window.location.pathname + "socket.io/"; +const status = $("#loading-page-message, #connection-error"); const socket = io({ transports: $(document.body).data("transports"), @@ -11,37 +12,16 @@ const socket = io({ reconnection: !$(document.body).hasClass("public") }); -window.lounge_socket = socket; // TODO: Remove later, this is for debugging +socket.on("disconnect", handleDisconnect); +socket.on("connect_error", handleDisconnect); +socket.on("error", handleDisconnect); -[ - "connect_error", - "connect_failed", - "disconnect", - "error", -].forEach(function(e) { - socket.on(e, function(data) { - $("#loading-page-message").text("Connection failed: " + data); - $("#connection-error").addClass("shown").one("click", function() { - window.onbeforeunload = null; - window.location.reload(); - }); - - // Disables sending a message by pressing Enter. `off` is necessary to - // cancel `inputhistory`, which overrides hitting Enter. `on` is then - // necessary to avoid creating new lines when hitting Enter without Shift. - // This is fairly hacky but this solution is not permanent. - $("#input").off("keydown").on("keydown", function(event) { - if (event.which === 13 && !event.shiftKey) { - event.preventDefault(); - } - }); - // Hides the "Send Message" button - $("#submit").hide(); - }); +socket.on("reconnecting", function(attempt) { + status.text(`Reconnecting… (attempt ${attempt})`); }); socket.on("connecting", function() { - $("#loading-page-message").text("Connecting…"); + status.text("Connecting…"); }); socket.on("connect", function() { @@ -54,7 +34,15 @@ socket.on("connect", function() { }); socket.on("authorized", function() { - $("#loading-page-message").text("Loading messages…"); + status.text("Loading messages…"); }); +function handleDisconnect(data) { + const message = data.message || data; + + status.text(`Waiting to reconnect… (${message})`).addClass("shown"); + $(".show-more-button, #input").prop("disabled", true); + $("#submit").hide(); +} + module.exports = socket; diff --git a/client/themes/crypto.css b/client/themes/crypto.css index 9b7bf72b..2f9f5424 100644 --- a/client/themes/crypto.css +++ b/client/themes/crypto.css @@ -65,12 +65,8 @@ a:hover, background: #00ff0e; } -.btn-reconnect { +#connection-error { background: #f00; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; } #settings .opt { diff --git a/client/themes/example.css b/client/themes/example.css index a8efcbf9..d9764ac1 100644 --- a/client/themes/example.css +++ b/client/themes/example.css @@ -46,14 +46,6 @@ body { border-radius: 2px; } -.btn-reconnect { - background: #e74c3c; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; -} - @media (max-width: 768px) { #sidebar { left: -220px; diff --git a/client/themes/morning.css b/client/themes/morning.css index 856c530c..0b576e8d 100644 --- a/client/themes/morning.css +++ b/client/themes/morning.css @@ -205,14 +205,6 @@ body { color: #99a2b4; } -.btn-reconnect { - background: #e74c3c; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; -} - /* Form elements */ #chat-container ::-moz-placeholder { diff --git a/client/themes/zenburn.css b/client/themes/zenburn.css index 21aabd23..e4823cdf 100644 --- a/client/themes/zenburn.css +++ b/client/themes/zenburn.css @@ -232,14 +232,6 @@ body { color: #d2d39b; } -.btn-reconnect { - background: #e74c3c; - color: #fff; - border: 0; - border-radius: 0; - margin: 0; -} - /* Form elements */ #chat-container ::-moz-placeholder { From cffa957e34ce2e2cc09d605f6ea9d929c80e9088 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 28 Aug 2017 23:14:01 +0300 Subject: [PATCH 61/72] Only send messages newer than last seen id --- src/server.js | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/server.js b/src/server.js index 7069a905..f222f6da 100644 --- a/src/server.js +++ b/src/server.js @@ -236,7 +236,7 @@ function index(req, res, next) { res.render("index", data); } -function initializeClient(socket, client, token) { +function initializeClient(socket, client, token, lastMessage) { socket.emit("authorized"); socket.on("disconnect", function() { @@ -400,11 +400,24 @@ function initializeClient(socket, client, token) { socket.join(client.id); const sendInitEvent = (tokenToSend) => { + let networks = client.networks; + + if (lastMessage > -1) { + // We need a deep cloned object because we are going to remove unneeded messages + networks = _.cloneDeep(networks); + + networks.forEach((network) => { + network.channels.forEach((channel) => { + channel.messages = channel.messages.filter((m) => m.id > lastMessage); + }); + }); + } + socket.emit("init", { applicationServerKey: manager.webPush.vapidKeys.publicKey, pushSubscription: client.config.sessions[token], active: client.lastActiveChannel, - networks: client.networks, + networks: networks, token: tokenToSend }); }; @@ -434,7 +447,7 @@ function performAuthentication(data) { const socket = this; let client; - const finalInit = () => initializeClient(socket, client, data.token || null); + const finalInit = () => initializeClient(socket, client, data.token || null, data.lastMessage || -1); const initClient = () => { client.ip = getClientIp(socket.request); From 532f55cb862202dc3ca393756212186a2b4b9714 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 28 Aug 2017 23:06:28 +0300 Subject: [PATCH 62/72] Redraw channels --- client/js/render.js | 47 ++++++++++++++++++++++--- client/js/socket-events/auth.js | 2 +- client/js/socket-events/init.js | 56 ++++++++++++++++++++---------- client/js/socket-events/more.js | 2 +- client/js/socket-events/network.js | 3 +- 5 files changed, 82 insertions(+), 28 deletions(-) diff --git a/client/js/render.js b/client/js/render.js index 9e18ffe7..bb8be296 100644 --- a/client/js/render.js +++ b/client/js/render.js @@ -8,6 +8,7 @@ const utils = require("./utils"); const sorting = require("./sorting"); const constants = require("./constants"); const condensed = require("./condensed"); +const helpers_parse = require("./libs/handlebars/parse"); const chat = $("#chat"); const sidebar = $("#sidebar"); @@ -27,11 +28,11 @@ module.exports = { renderNetworks, }; -function buildChannelMessages(chanId, chanType, messages) { +function buildChannelMessages(container, chanId, chanType, messages) { return messages.reduce((docFragment, message) => { appendMessage(docFragment, chanId, chanType, message); return docFragment; - }, $(document.createDocumentFragment())); + }, container); } function appendMessage(container, chanId, chanType, msg) { @@ -121,7 +122,7 @@ function renderChannel(data) { } function renderChannelMessages(data) { - const documentFragment = buildChannelMessages(data.id, data.type, data.messages); + const documentFragment = buildChannelMessages($(document.createDocumentFragment()), data.id, data.type, data.messages); const channel = chat.find("#chan-" + data.id + " .messages").append(documentFragment); const template = $(templates.unread_marker()); @@ -168,7 +169,7 @@ function renderChannelUsers(data) { } } -function renderNetworks(data) { +function renderNetworks(data, singleNetwork) { sidebar.find(".empty").hide(); sidebar.find(".networks").append( templates.network({ @@ -176,15 +177,51 @@ function renderNetworks(data) { }) ); + let newChannels; const channels = $.map(data.networks, function(n) { return n.channels; }); + + if (!singleNetwork && utils.lastMessageId > -1) { + newChannels = []; + + channels.forEach((channel) => { + const chan = $("#chan-" + channel.id); + + if (chan.length > 0) { + if (chan.data("type") === "channel") { + chan + .data("needsNamesRefresh", true) + .find(".header .topic") + .html(helpers_parse(channel.topic)) + .attr("title", channel.topic); + } + + if (channel.messages.length > 0) { + const container = chan.find(".messages"); + buildChannelMessages(container, channel.id, channel.type, channel.messages); + + if (container.find(".msg").length >= 100) { + container.find(".show-more").addClass("show"); + } + + container.trigger("keepToBottom"); + } + } else { + newChannels.push(channel); + } + }); + } else { + newChannels = channels; + } + chat.append( templates.chat({ channels: channels }) ); - channels.forEach((channel) => { + + newChannels.forEach((channel) => { renderChannel(channel); if (channel.type === "channel") { diff --git a/client/js/socket-events/auth.js b/client/js/socket-events/auth.js index cb0ef4ee..e544948f 100644 --- a/client/js/socket-events/auth.js +++ b/client/js/socket-events/auth.js @@ -6,7 +6,7 @@ const storage = require("../localStorage"); const utils = require("../utils"); socket.on("auth", function(data) { - // If we reconnected and serverHash differents, that means the server restarted + // If we reconnected and serverHash differs, that means the server restarted // And we will reload the page to grab the latest version if (utils.serverHash > -1 && data.serverHash > -1 && data.serverHash !== utils.serverHash) { socket.disconnect(); diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js index f200a834..6debd9d9 100644 --- a/client/js/socket-events/init.js +++ b/client/js/socket-events/init.js @@ -1,6 +1,7 @@ "use strict"; const $ = require("jquery"); +const escape = require("css.escape"); const socket = require("../socket"); const render = require("../render"); const webpush = require("../webpush"); @@ -12,11 +13,11 @@ socket.on("init", function(data) { $("#loading-page-message, #connection-error").text("Rendering…"); const lastMessageId = utils.lastMessageId; + let previousActive = 0; - // TODO: this is hacky if (lastMessageId > -1) { + previousActive = sidebar.find(".active").data("id"); sidebar.find(".networks").empty(); - $("#chat").empty(); } if (data.networks.length === 0) { @@ -45,21 +46,38 @@ socket.on("init", function(data) { $("#sign-in").remove(); } - const id = data.active; - const target = sidebar.find("[data-id='" + id + "']").trigger("click", { - replaceHistory: true - }); - const dataTarget = document.querySelector("[data-target='" + window.location.hash + "']"); - if (window.location.hash && dataTarget) { - dataTarget.click(); - } else if (target.length === 0) { - const first = sidebar.find(".chan") - .eq(0) - .trigger("click"); - if (first.length === 0) { - $("#footer").find(".connect").trigger("click", { - pushState: false, - }); - } - } + openCorrectChannel(previousActive, data.active); }); + +function openCorrectChannel(clientActive, serverActive) { + let target; + + // Open last active channel + if (clientActive > 0) { + target = sidebar.find("[data-id='" + clientActive + "']"); + } + + // Open window provided in location.hash + if (!target && window.location.hash) { + target = $("#footer, #sidebar").find("[data-target='" + escape(window.location.hash) + "']"); + } + + // Open last active channel according to the server + if (!target) { + target = sidebar.find("[data-id='" + serverActive + "']"); + } + + // If target channel is found, open it + if (target) { + target.trigger("click", { + replaceHistory: true + }); + + return; + } + + // Open the connect window + $("#footer .connect").trigger("click", { + pushState: false + }); +} diff --git a/client/js/socket-events/more.js b/client/js/socket-events/more.js index d762b542..b5f0fab6 100644 --- a/client/js/socket-events/more.js +++ b/client/js/socket-events/more.js @@ -33,7 +33,7 @@ socket.on("more", function(data) { } // Add the older messages - const documentFragment = render.buildChannelMessages(data.chan, type, data.messages); + const documentFragment = render.buildChannelMessages($(document.createDocumentFragment()), data.chan, type, data.messages); chan.prepend(documentFragment); // Move unread marker to correct spot if needed diff --git a/client/js/socket-events/network.js b/client/js/socket-events/network.js index 846a6a34..a55b0433 100644 --- a/client/js/socket-events/network.js +++ b/client/js/socket-events/network.js @@ -6,7 +6,7 @@ const render = require("../render"); const sidebar = $("#sidebar"); socket.on("network", function(data) { - render.renderNetworks(data); + render.renderNetworks(data, true); sidebar.find(".chan") .last() @@ -20,4 +20,3 @@ socket.on("network", function(data) { socket.on("network_changed", function(data) { sidebar.find("#network-" + data.network).data("options", data.serverOptions); }); - From 935c5b309ab380fbfe90127ad52c2f64efee35aa Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Sun, 10 Sep 2017 18:28:28 +0300 Subject: [PATCH 63/72] Force reconnect on server shutdown --- client/js/socket.js | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/client/js/socket.js b/client/js/socket.js index c5593f90..a9916b0a 100644 --- a/client/js/socket.js +++ b/client/js/socket.js @@ -2,6 +2,7 @@ const $ = require("jquery"); const io = require("socket.io-client"); +const utils = require("./utils"); const path = window.location.pathname + "socket.io/"; const status = $("#loading-page-message, #connection-error"); @@ -43,6 +44,12 @@ function handleDisconnect(data) { status.text(`Waiting to reconnect… (${message})`).addClass("shown"); $(".show-more-button, #input").prop("disabled", true); $("#submit").hide(); + + // If the server shuts down, socket.io skips reconnection + // and we have to manually call connect to start the process + if (socket.io.skipReconnect) { + utils.requestIdleCallback(() => socket.connect(), 2000); + } } module.exports = socket; From 6cfe60e4d996c587b81d296287458831643979e4 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Sun, 3 Sep 2017 18:57:07 +0300 Subject: [PATCH 64/72] Group push notifications per target --- client/service-worker.js | 32 ++++++++++++++++++++++--------- src/client.js | 2 +- src/models/chan.js | 4 ++-- src/plugins/irc-events/message.js | 21 ++++++++++++++------ 4 files changed, 41 insertions(+), 18 deletions(-) diff --git a/client/service-worker.js b/client/service-worker.js index fcb63300..641e050b 100644 --- a/client/service-worker.js +++ b/client/service-worker.js @@ -9,16 +9,30 @@ self.addEventListener("push", function(event) { const payload = event.data.json(); - if (payload.type === "notification") { - event.waitUntil( - self.registration.showNotification(payload.title, { - badge: "img/logo-64.png", - icon: "img/touch-icon-192x192.png", - body: payload.body, - timestamp: payload.timestamp, - }) - ); + if (payload.type !== "notification") { + return; } + + // get current notification, close it, and draw new + event.waitUntil( + self.registration + .getNotifications({ + tag: `chan-${payload.chanId}` + }) + .then((notifications) => { + for (const notification of notifications) { + notification.close(); + } + + return self.registration.showNotification(payload.title, { + tag: `chan-${payload.chanId}`, + badge: "img/logo-64.png", + icon: "img/touch-icon-192x192.png", + body: payload.body, + timestamp: payload.timestamp, + }); + }) + ); }); self.addEventListener("notificationclick", function(event) { diff --git a/src/client.js b/src/client.js index 2d436a4d..d4cd3ccc 100644 --- a/src/client.js +++ b/src/client.js @@ -430,7 +430,7 @@ Client.prototype.open = function(socketId, target) { target.chan.firstUnread = 0; target.chan.unread = 0; - target.chan.highlight = false; + target.chan.highlight = 0; this.attachedClients[socketId].openChannel = target.chan.id; this.lastActiveChannel = target.chan.id; diff --git a/src/models/chan.js b/src/models/chan.js index 3a9ddcec..c7889178 100644 --- a/src/models/chan.js +++ b/src/models/chan.js @@ -26,7 +26,7 @@ function Chan(attr) { type: Chan.Type.CHANNEL, firstUnread: 0, unread: 0, - highlight: false, + highlight: 0, users: [] }); } @@ -78,7 +78,7 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) { } if (msg.highlight) { - this.highlight = true; + this.highlight++; } } }; diff --git a/src/plugins/irc-events/message.js b/src/plugins/irc-events/message.js index fbfc7102..24251180 100644 --- a/src/plugins/irc-events/message.js +++ b/src/plugins/irc-events/message.js @@ -106,20 +106,29 @@ module.exports = function(irc, network) { // Do not send notifications for messages older than 15 minutes (znc buffer for example) if (highlight && (!data.time || data.time > Date.now() - 900000)) { - let title = data.nick; + let title = chan.name; + let body = Helper.cleanIrcMessage(data.message); + // In channels, prepend sender nickname to the message if (chan.type !== Chan.Type.QUERY) { - title += ` (${chan.name}) mentioned you`; - } else { - title += " sent you a message"; + body = `${data.nick}: ${body}`; + } + + // If a channel is active on any client, highlight won't increment and notification will say (0 mention) + if (chan.highlight > 0) { + title += ` (${chan.highlight} ${chan.type === Chan.Type.QUERY ? "new message" : "mention"}${chan.highlight > 1 ? "s" : ""})`; + } + + if (chan.highlight > 1) { + body += `\n\n… and ${chan.highlight - 1} other message${chan.highlight > 2 ? "s" : ""}`; } client.manager.webPush.push(client, { type: "notification", chanId: chan.id, timestamp: data.time || Date.now(), - title: `The Lounge: ${title}`, - body: Helper.cleanIrcMessage(data.message) + title: title, + body: body }, true); } } From 21c9919fa1163007e7af4f425466ce06b247e59d Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Tue, 19 Sep 2017 12:08:08 +0300 Subject: [PATCH 65/72] Print compatibility theme setting warning on startup --- src/helper.js | 8 ++++++++ src/server.js | 5 ----- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/helper.js b/src/helper.js index 73c53262..a9ba455a 100644 --- a/src/helper.js +++ b/src/helper.js @@ -84,6 +84,14 @@ function setHome(homePath) { log.warn("debug option is now an object, see defaults file for more information."); this.config.debug = {ircFramework: true}; } + + // TODO: Remove in future release + // Backwards compatibility for old way of specifying themes in settings + if (this.config.theme.includes(".css")) { + log.warn(`Referring to CSS files in the ${colors.green("theme")} setting of ${colors.green(Helper.CONFIG_PATH)} is ${colors.bold("deprecated")} and will be removed in a future version.`); + } else { + this.config.theme = `themes/${this.config.theme}.css`; + } } function getUserConfigPath(name) { diff --git a/src/server.js b/src/server.js index aebc6bfb..1cb377cc 100644 --- a/src/server.js +++ b/src/server.js @@ -201,11 +201,6 @@ function index(req, res, next) { pkg, Helper.config ); - if (!data.theme.includes(".css")) { // Backwards compatibility for old way of specifying themes in settings - data.theme = `themes/${data.theme}.css`; - } else { - log.warn(`Referring to CSS files in the ${colors.green("theme")} setting of ${colors.green(Helper.CONFIG_PATH)} is ${colors.bold("deprecated")} and will be removed in a future version.`); - } data.gitCommit = Helper.getGitCommit(); data.themes = themes.getAll(); From 6041e492ee1b0c2fd2120e14d1ccd2261e14ab8d Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Tue, 19 Sep 2017 18:01:02 +0300 Subject: [PATCH 66/72] Fix history not loading if first message is condensed --- client/js/socket-events/more.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/client/js/socket-events/more.js b/client/js/socket-events/more.js index d762b542..fafbfc20 100644 --- a/client/js/socket-events/more.js +++ b/client/js/socket-events/more.js @@ -79,11 +79,11 @@ socket.on("more", function(data) { chat.on("click", ".show-more-button", function() { const self = $(this); - const lastMessage = self.closest(".chat").find(".msg").first(); + const lastMessage = self.closest(".chat").find(".msg:not(.condensed)").first(); let lastMessageId = -1; if (lastMessage.length > 0) { - lastMessageId = parseInt(lastMessage[0].id.replace("msg-", ""), 10); + lastMessageId = parseInt(lastMessage.attr("id").replace("msg-", ""), 10); } self From 48cc8104556832fb3a85d7735f9115d139a3256c Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Tue, 19 Sep 2017 19:57:56 +0000 Subject: [PATCH 67/72] fix(package): update request to version 2.82.0 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 84318f5c..324f68a8 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "lodash": "4.17.4", "moment": "2.18.1", "read": "1.0.7", - "request": "2.81.0", + "request": "2.82.0", "semver": "5.4.1", "socket.io": "1.7.4", "spdy": "3.4.7", From 1c065ad1b64406eb2357ce81a4802f2435f37eb1 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Tue, 19 Sep 2017 12:18:54 +0300 Subject: [PATCH 68/72] Remove duplicate keybindings help --- client/css/style.css | 9 +++++ client/index.html | 81 ++++---------------------------------------- client/js/lounge.js | 4 +++ 3 files changed, 19 insertions(+), 75 deletions(-) diff --git a/client/css/style.css b/client/css/style.css index d4dd6b10..c88f9f20 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -1469,6 +1469,15 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ line-height: 1.8; } +.is-apple #help .key-all, +#help .key-apple { + display: none; +} + +.is-apple #help .key-apple { + display: inline-block; +} + #form { background: #eee; border-top: 1px solid #ddd; diff --git a/client/index.html b/client/index.html index 61abcde2..5b170e63 100644 --- a/client/index.html +++ b/client/index.html @@ -392,11 +392,9 @@

Keyboard Shortcuts

-

On Windows / Linux

-
- Ctrl + / + Ctrl + /

Switch to the previous/next window in the channel list

@@ -405,7 +403,7 @@
- Ctrl + K + Ctrl + K

@@ -422,7 +420,7 @@

- Ctrl + B + Ctrl + B

Mark all text typed after this shortcut as bold.

@@ -431,7 +429,7 @@
- Ctrl + U + Ctrl + U

Mark all text typed after this shortcut as underlined.

@@ -440,7 +438,7 @@
- Ctrl + I + Ctrl + I

Mark all text typed after this shortcut as italics.

@@ -449,74 +447,7 @@
- Ctrl + O -
-
-

- Mark all text typed after this shortcut to be reset to its - original formatting. -

-
-
- -

On macOS

- -
-
- + / -
-
-

Switch to the previous/next window in the channel list

-
-
- -
-
- + K -
-
-

- Mark any text typed after this shortcut to be colored. After - hitting this shortcut, enter an integer in the - 0—15 range to select the desired color. -

-

- A color reference can be found - here. -

-
-
- -
-
- + B -
-
-

Mark all text typed after this shortcut as bold.

-
-
- -
-
- + U -
-
-

Mark all text typed after this shortcut as underlined.

-
-
- -
-
- + I -
-
-

Mark all text typed after this shortcut as italics.

-
-
- -
-
- + O + Ctrl + O

diff --git a/client/js/lounge.js b/client/js/lounge.js index ef4e20a1..2bc96530 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -168,6 +168,10 @@ $(function() { }); } + if (navigator.platform.match(/(Mac|iPhone|iPod|iPad)/i)) { + $(document.body).addClass("is-apple"); + } + $("#form").on("submit", function(e) { e.preventDefault(); utils.forceFocus(); From 416ebf97ff3071fe0c15e813122b8b64ff4c82ce Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 18 Sep 2017 18:57:24 +0300 Subject: [PATCH 69/72] Create 'lounge install' command --- package.json | 1 + src/command-line/index.js | 1 + src/command-line/install.js | 82 +++++++++++++++++++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 src/command-line/install.js diff --git a/package.json b/package.json index 324f68a8..26c770fc 100644 --- a/package.json +++ b/package.json @@ -52,6 +52,7 @@ "ldapjs": "1.0.1", "lodash": "4.17.4", "moment": "2.18.1", + "package-json": "4.0.1", "read": "1.0.7", "request": "2.82.0", "semver": "5.4.1", diff --git a/src/command-line/index.js b/src/command-line/index.js index 285d5fe2..f5c1d57a 100644 --- a/src/command-line/index.js +++ b/src/command-line/index.js @@ -32,6 +32,7 @@ require("./add"); require("./remove"); require("./reset"); require("./edit"); +require("./install"); program.parse(process.argv); diff --git a/src/command-line/install.js b/src/command-line/install.js new file mode 100644 index 00000000..59507f45 --- /dev/null +++ b/src/command-line/install.js @@ -0,0 +1,82 @@ +"use strict"; + +const colors = require("colors/safe"); +const program = require("commander"); +const Helper = require("../helper"); +const Utils = require("./utils"); + +program + .command("install ") + .description("Install a theme or a package") + .on("--help", Utils.extraHelp) + .action(function(packageName) { + const fs = require("fs"); + const fsextra = require("fs-extra"); + const path = require("path"); + const child = require("child_process"); + const packageJson = require("package-json"); + + if (!fs.existsSync(Helper.CONFIG_PATH)) { + log.error(`${Helper.CONFIG_PATH} does not exist.`); + return; + } + + packageJson(packageName, { + fullMetadata: true + }).then((json) => { + if (!("lounge" in json)) { + log.error(`${colors.red(packageName)} does not have The Lounge metadata.`); + + process.exit(1); + } + + log.info(`Installing ${colors.green(packageName)}...`); + + const packagesPath = Helper.getPackagesPath(); + const packagesParent = path.dirname(packagesPath); + const packagesConfig = path.join(packagesParent, "package.json"); + + // Create node_modules folder, otherwise npm will start walking upwards to find one + fsextra.ensureDirSync(packagesPath); + + // Create package.json with private set to true to avoid npm warnings + fs.writeFileSync(packagesConfig, JSON.stringify({ + private: true, + description: "Packages for The Lounge. All packages in node_modules directory will be automatically loaded.", + }, null, "\t")); + + const npm = child.spawn( + process.platform === "win32" ? "npm.cmd" : "npm", + [ + "install", + "--production", + "--no-save", + "--no-bin-links", + "--no-package-lock", + "--prefix", + packagesParent, + packageName + ], + { + stdio: "inherit" + } + ); + + npm.on("error", (e) => { + log.error(`${e}`); + process.exit(1); + }); + + npm.on("close", (code) => { + if (code !== 0) { + log.error(`Failed to install ${colors.green(packageName)}. Exit code: ${code}`); + return; + } + + log.info(`${colors.green(packageName)} has been successfully installed.`); + }); + }).catch((e) => { + log.error(`${e}`); + process.exit(1); + }); + }); From 649e9c31920352f87a871db31449d78f3bf0a476 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Wed, 20 Sep 2017 10:44:36 +0300 Subject: [PATCH 70/72] Fix opening first channel on server start Fixes #1547 --- client/js/socket-events/init.js | 13 +++++++++---- src/models/chan.js | 2 +- src/models/network.js | 2 +- 3 files changed, 11 insertions(+), 6 deletions(-) diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js index 6debd9d9..ec8d4988 100644 --- a/client/js/socket-events/init.js +++ b/client/js/socket-events/init.js @@ -50,7 +50,7 @@ socket.on("init", function(data) { }); function openCorrectChannel(clientActive, serverActive) { - let target; + let target = $(); // Open last active channel if (clientActive > 0) { @@ -58,17 +58,22 @@ function openCorrectChannel(clientActive, serverActive) { } // Open window provided in location.hash - if (!target && window.location.hash) { + if (target.length === 0 && window.location.hash) { target = $("#footer, #sidebar").find("[data-target='" + escape(window.location.hash) + "']"); } // Open last active channel according to the server - if (!target) { + if (serverActive > 0 && target.length === 0) { target = sidebar.find("[data-id='" + serverActive + "']"); } + // Open first available channel + if (target.length === 0) { + target = sidebar.find(".chan").first(); + } + // If target channel is found, open it - if (target) { + if (target.length > 0) { target.trigger("click", { replaceHistory: true }); diff --git a/src/models/chan.js b/src/models/chan.js index 3a9ddcec..b657d73e 100644 --- a/src/models/chan.js +++ b/src/models/chan.js @@ -14,7 +14,7 @@ Chan.Type = { SPECIAL: "special", }; -var id = 0; +let id = 1; function Chan(attr) { _.defaults(this, attr, { diff --git a/src/models/network.js b/src/models/network.js index 71a7dd7d..68bdf964 100644 --- a/src/models/network.js +++ b/src/models/network.js @@ -5,7 +5,7 @@ var Chan = require("./chan"); module.exports = Network; -var id = 0; +let id = 1; function Network(attr) { _.defaults(this, attr, { From 3eaf12cc36400f6698286dda8c9200cf546d6104 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=A9r=C3=A9mie=20Astori?= Date: Thu, 21 Sep 2017 02:21:48 -0400 Subject: [PATCH 71/72] Increase font size on desktops and mobiles --- client/css/style.css | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/client/css/style.css b/client/css/style.css index c88f9f20..63e45bc6 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -855,10 +855,14 @@ kbd { #windows .header .topic, .messages .msg, .sidebar { - font-size: 13px; + font-size: 14px; line-height: 1.4; } +#windows #form .input { + font-size: 13px; +} + #windows #chat .header { display: block; } @@ -1525,7 +1529,7 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ background: #f6f6f6; color: #666; font: inherit; - font-size: 11px; + font-size: 13px; margin: 4px; line-height: 22px; height: 24px; @@ -2000,6 +2004,10 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ margin-top: 60px !important; } + .messages .msg { + font-size: 16px; + } + #sidebar, #footer { left: -220px; @@ -2043,14 +2051,6 @@ part/quit messages where we don't load previews (adds a blank line otherwise) */ } } -@media (min-width: 1610px) { - #windows .header .topic, - .messages .msg, - .sidebar { - font-size: 14px; - } -} - @media (max-width: 479px) { .container { margin: 40px 0 !important; From 203907f706cf703d7082b7b06988a4a65b90d035 Mon Sep 17 00:00:00 2001 From: "greenkeeper[bot]" Date: Thu, 21 Sep 2017 19:49:06 +0000 Subject: [PATCH 72/72] chore(package): update eslint to version 4.7.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 26c770fc..999a2d55 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "chai": "4.1.2", "css.escape": "1.5.1", "emoji-regex": "6.5.1", - "eslint": "4.7.1", + "eslint": "4.7.2", "font-awesome": "4.7.0", "fuzzy": "0.1.3", "handlebars": "4.0.10",