From 0ac3ba28e1687dba0ba8747ec614e7528bed0173 Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Mon, 10 Jul 2017 22:47:03 +0300 Subject: [PATCH 1/2] Web Push Notifications --- README.md | 2 +- client/css/style.css | 3 +- client/index.html | 24 +++++- client/js/lounge.js | 1 + client/js/socket-events/init.js | 3 + client/js/webpush.js | 127 ++++++++++++++++++++++++++++++ client/service-worker.js | 41 ++++++++++ package.json | 3 +- src/client.js | 59 ++++++++++++-- src/clientManager.js | 2 + src/helper.js | 6 ++ src/models/chan.js | 2 +- src/plugins/irc-events/link.js | 2 +- src/plugins/irc-events/message.js | 20 +++++ src/plugins/webpush.js | 73 +++++++++++++++++ src/server.js | 29 ++++++- 16 files changed, 381 insertions(+), 16 deletions(-) create mode 100644 client/js/webpush.js create mode 100644 client/service-worker.js create mode 100644 src/plugins/webpush.js diff --git a/README.md b/README.md index dc60ec01..27b4cf49 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ Modern web IRC client designed for self-hosting. ## Overview -* **Modern features brought to IRC.** Link previews, new message markers, ~~push notifications~~ [*(soon!)*](https://github.com/thelounge/lounge/pull/1124), and more bring IRC to the 21st century. +* **Modern features brought to IRC.** Push notifications, link previews, new message markers, and more bring IRC to the 21st century. * **Always connected.** Remains connected to IRC servers while you are offline. * **Cross platform.** It doesn't matter what OS you use, it just works wherever Node.js runs. * **Responsive interface.** The client works smoothly on every desktop, smartphone and tablet. diff --git a/client/css/style.css b/client/css/style.css index f1a87cd1..6844fc9e 100644 --- a/client/css/style.css +++ b/client/css/style.css @@ -688,7 +688,8 @@ kbd { width: 100%; } -#windows label { +#windows label, +#settings .error { font-size: 14px; } diff --git a/client/index.html b/client/index.html index 09baadb5..d3dac9d5 100644 --- a/client/index.html +++ b/client/index.html @@ -293,20 +293,36 @@ {{/if}} + {{#unless public}}
-

Notifications

+

Push Notifications

+
+
+ +
+ Warning: + Push notifications are only supported over HTTPS connections. +
+
+ Warning: + Push notifications are not supported by your browser. +
+
+ {{/unless}} +
+

Browser Notifications

diff --git a/client/js/lounge.js b/client/js/lounge.js index 23e8dd5e..802cac4c 100644 --- a/client/js/lounge.js +++ b/client/js/lounge.js @@ -22,6 +22,7 @@ require("./socket-events"); const constants = require("./constants"); const storage = require("./localStorage"); const utils = require("./utils"); +require("./webpush"); $(function() { var sidebar = $("#sidebar, #footer"); diff --git a/client/js/socket-events/init.js b/client/js/socket-events/init.js index cf8b464b..a78c07fa 100644 --- a/client/js/socket-events/init.js +++ b/client/js/socket-events/init.js @@ -3,6 +3,7 @@ const $ = require("jquery"); const socket = require("../socket"); const render = require("../render"); +const webpush = require("../webpush"); const sidebar = $("#sidebar"); const storage = require("../localStorage"); @@ -21,6 +22,8 @@ socket.on("init", function(data) { storage.set("token", data.token); } + webpush.configurePushNotifications(data.pushSubscription, data.applicationServerKey); + $("body").removeClass("signed-out"); $("#loading").remove(); $("#sign-in").remove(); diff --git a/client/js/webpush.js b/client/js/webpush.js new file mode 100644 index 00000000..87306f16 --- /dev/null +++ b/client/js/webpush.js @@ -0,0 +1,127 @@ +"use strict"; + +const $ = require("jquery"); +const storage = require("./localStorage"); +const socket = require("./socket"); + +const pushNotificationsButton = $("#pushNotifications"); +let clientSubscribed = null; +let applicationServerKey; + +module.exports.configurePushNotifications = (subscribedOnServer, key) => { + applicationServerKey = key; + + // If client has push registration but the server knows nothing about it, + // this subscription is broken and client has to register again + if (clientSubscribed === true && subscribedOnServer === false) { + pushNotificationsButton.attr("disabled", true); + + navigator.serviceWorker.register("service-worker.js") + .then((registration) => registration.pushManager.getSubscription()) + .then((subscription) => subscription && subscription.unsubscribe()) + .then((successful) => { + if (successful) { + alternatePushButton().removeAttr("disabled"); + } + }); + } +}; + +if (isAllowedServiceWorkersHost()) { + $("#pushNotificationsHttps").hide(); + + if ("serviceWorker" in navigator) { + navigator.serviceWorker.register("service-worker.js").then((registration) => { + if (!registration.pushManager) { + return; + } + + return registration.pushManager.getSubscription().then((subscription) => { + $("#pushNotificationsUnsupported").hide(); + + pushNotificationsButton + .removeAttr("disabled") + .on("click", onPushButton); + + clientSubscribed = !!subscription; + + if (clientSubscribed) { + alternatePushButton(); + } + }); + }).catch((err) => { + $("#pushNotificationsUnsupported span").text(err); + }); + } +} + +function onPushButton() { + pushNotificationsButton.attr("disabled", true); + + navigator.serviceWorker.register("service-worker.js").then((registration) => { + registration.pushManager.getSubscription().then((existingSubscription) => { + if (existingSubscription) { + socket.emit("push:unregister"); + + return existingSubscription.unsubscribe(); + } + + return registration.pushManager.subscribe({ + applicationServerKey: urlBase64ToUint8Array(applicationServerKey), + userVisibleOnly: true + }).then((subscription) => { + const rawKey = subscription.getKey ? subscription.getKey("p256dh") : ""; + const key = rawKey ? window.btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : ""; + const rawAuthSecret = subscription.getKey ? subscription.getKey("auth") : ""; + const authSecret = rawAuthSecret ? window.btoa(String.fromCharCode.apply(null, new Uint8Array(rawAuthSecret))) : ""; + + socket.emit("push:register", { + token: storage.get("token"), + endpoint: subscription.endpoint, + keys: { + p256dh: key, + auth: authSecret + } + }); + + return true; + }); + }).then((successful) => { + if (successful) { + alternatePushButton().removeAttr("disabled"); + } + }); + }).catch((err) => { + $("#pushNotificationsUnsupported span").text(err).show(); + }); + + return false; +} + +function alternatePushButton() { + const text = pushNotificationsButton.text(); + + return pushNotificationsButton + .text(pushNotificationsButton.data("text-alternate")) + .data("text-alternate", text); +} + +function urlBase64ToUint8Array(base64String) { + const padding = "=".repeat((4 - base64String.length % 4) % 4); + const base64 = (base64String + padding) + .replace(/-/g, "+") + .replace(/_/g, "/"); + + const rawData = window.atob(base64); + const outputArray = new Uint8Array(rawData.length); + + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i); + } + + return outputArray; +} + +function isAllowedServiceWorkersHost() { + return location.protocol === "https:" || location.hostname === "localhost" || location.hostname === "127.0.0.1"; +} diff --git a/client/service-worker.js b/client/service-worker.js new file mode 100644 index 00000000..fcb63300 --- /dev/null +++ b/client/service-worker.js @@ -0,0 +1,41 @@ +// The Lounge - https://github.com/thelounge/lounge +/* global clients */ +"use strict"; + +self.addEventListener("push", function(event) { + if (!event.data) { + return; + } + + 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, + }) + ); + } +}); + +self.addEventListener("notificationclick", function(event) { + event.notification.close(); + + event.waitUntil(clients.matchAll({ + type: "window" + }).then(function(clientList) { + for (var i = 0; i < clientList.length; i++) { + var client = clientList[i]; + if ("focus" in client) { + return client.focus(); + } + } + + if (clients.openWindow) { + return clients.openWindow("."); + } + })); +}); diff --git a/package.json b/package.json index 89bb2e82..e7fcba68 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,8 @@ "socket.io": "1.7.4", "spdy": "3.4.7", "ua-parser-js": "0.7.14", - "urijs": "1.18.12" + "urijs": "1.18.12", + "web-push": "3.2.2" }, "devDependencies": { "babel-core": "6.26.0", diff --git a/src/client.js b/src/client.js index a7bb59cf..f259030f 100644 --- a/src/client.js +++ b/src/client.js @@ -94,11 +94,21 @@ function Client(manager, name, config) { client.config.sessions = {}; } + _.forOwn(client.config.sessions, (session) => { + if (session.pushSubscription) { + this.registerPushSubscription(session, session.pushSubscription, true); + } + }); + if (client.name) { log.info(`User ${colors.bold(client.name)} loaded`); } } +Client.prototype.isRegistered = function() { + return this.name.length > 0; +}; + Client.prototype.emit = function(event, data) { if (this.sockets !== null) { this.sockets.in(this.id).emit(event, data); @@ -310,11 +320,11 @@ Client.prototype.updateSession = function(token, ip, request) { friendlyAgent += ` on ${agent.os.name} ${agent.os.version}`; } - client.config.sessions[token] = { + client.config.sessions[token] = _.assign({ lastUse: Date.now(), ip: ip, agent: friendlyAgent, - }; + }, client.config.sessions[token]); }; Client.prototype.setPassword = function(hash, callback) { @@ -410,7 +420,7 @@ Client.prototype.more = function(data) { Client.prototype.open = function(socketId, target) { // Opening a window like settings if (target === null) { - this.attachedClients[socketId] = -1; + this.attachedClients[socketId].openChannel = -1; return; } @@ -423,7 +433,7 @@ Client.prototype.open = function(socketId, target) { target.chan.unread = 0; target.chan.highlight = false; - this.attachedClients[socketId] = target.chan.id; + this.attachedClients[socketId].openChannel = target.chan.id; this.lastActiveChannel = target.chan.id; this.emit("open", target.chan.id); @@ -493,7 +503,7 @@ Client.prototype.quit = function() { }); }; -Client.prototype.clientAttach = function(socketId) { +Client.prototype.clientAttach = function(socketId, token) { var client = this; var save = false; @@ -507,7 +517,10 @@ Client.prototype.clientAttach = function(socketId) { }); } - client.attachedClients[socketId] = client.lastActiveChannel; + client.attachedClients[socketId] = { + token: token, + openChannel: client.lastActiveChannel + }; // Update old networks to store ip and hostmask client.networks.forEach((network) => { @@ -547,6 +560,40 @@ Client.prototype.clientDetach = function(socketId) { } }; +Client.prototype.registerPushSubscription = function(session, subscription, noSave) { + if (!_.isPlainObject(subscription) || !_.isPlainObject(subscription.keys) + || typeof subscription.endpoint !== "string" || !/^https?:\/\//.test(subscription.endpoint) + || typeof subscription.keys.p256dh !== "string" || typeof subscription.keys.auth !== "string") { + session.pushSubscription = null; + return; + } + + const data = { + endpoint: subscription.endpoint, + keys: { + p256dh: subscription.keys.p256dh, + auth: subscription.keys.auth + } + }; + + session.pushSubscription = data; + + if (!noSave) { + this.manager.updateUser(this.name, { + sessions: this.config.sessions + }); + } + + return data; +}; + +Client.prototype.unregisterPushSubscription = function(token) { + this.config.sessions[token].pushSubscription = null; + this.manager.updateUser(this.name, { + sessions: this.config.sessions + }); +}; + Client.prototype.save = _.debounce(function SaveClient() { if (Helper.config.public) { return; diff --git a/src/clientManager.js b/src/clientManager.js index c50ccf92..bec7c314 100644 --- a/src/clientManager.js +++ b/src/clientManager.js @@ -5,6 +5,7 @@ var colors = require("colors/safe"); var fs = require("fs"); var Client = require("./client"); var Helper = require("./helper"); +const WebPush = require("./plugins/webpush"); module.exports = ClientManager; @@ -15,6 +16,7 @@ function ClientManager() { ClientManager.prototype.init = function(identHandler, sockets) { this.sockets = sockets; this.identHandler = identHandler; + this.webPush = new WebPush(); if (!Helper.config.public) { if ("autoload" in Helper.config) { diff --git a/src/helper.js b/src/helper.js index 93b968b6..288dcfec 100644 --- a/src/helper.js +++ b/src/helper.js @@ -19,6 +19,7 @@ var Helper = { getVersion: getVersion, getGitCommit: getGitCommit, ip2hex: ip2hex, + cleanIrcMessage: cleanIrcMessage, password: { hash: passwordHash, @@ -121,6 +122,11 @@ function expandHome(shortenedPath) { return path.resolve(shortenedPath.replace(/^~($|\/|\\)/, home + "$1")); } +function cleanIrcMessage(message) { + // TODO: This does not strip hex based colours + return message.replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, ""); +} + function passwordRequiresUpdate(password) { return bcrypt.getRounds(password) !== 11; } diff --git a/src/models/chan.js b/src/models/chan.js index 2fa194a2..e06e264f 100644 --- a/src/models/chan.js +++ b/src/models/chan.js @@ -41,7 +41,7 @@ Chan.prototype.pushMessage = function(client, msg, increasesUnread) { }; // If this channel is open in any of the clients, do not increase unread counter - var isOpen = _.includes(client.attachedClients, this.id); + const isOpen = _.find(client.attachedClients, {openChannel: this.id}) !== undefined; if ((increasesUnread || msg.highlight) && !isOpen) { obj.unread = ++this.unread; diff --git a/src/plugins/irc-events/link.js b/src/plugins/irc-events/link.js index 49e9f14e..06049b15 100644 --- a/src/plugins/irc-events/link.js +++ b/src/plugins/irc-events/link.js @@ -16,7 +16,7 @@ module.exports = function(client, chan, msg) { } // Remove all IRC formatting characters before searching for links - const cleanText = msg.text.replace(/\x02|\x1D|\x1F|\x16|\x0F|\x03(?:[0-9]{1,2}(?:,[0-9]{1,2})?)?/g, ""); + const cleanText = Helper.cleanIrcMessage(msg.text); // We will only try to prefetch http(s) links const links = findLinks(cleanText).filter((w) => /^https?:\/\//.test(w.link)); diff --git a/src/plugins/irc-events/message.js b/src/plugins/irc-events/message.js index a358a74a..fbfc7102 100644 --- a/src/plugins/irc-events/message.js +++ b/src/plugins/irc-events/message.js @@ -3,6 +3,7 @@ const Chan = require("../../models/chan"); const Msg = require("../../models/msg"); const LinkPrefetch = require("./link"); +const Helper = require("../../helper"); module.exports = function(irc, network) { var client = this; @@ -102,5 +103,24 @@ module.exports = function(irc, network) { } chan.pushMessage(client, msg, !self); + + // 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; + + if (chan.type !== Chan.Type.QUERY) { + title += ` (${chan.name}) mentioned you`; + } else { + title += " sent you a message"; + } + + client.manager.webPush.push(client, { + type: "notification", + chanId: chan.id, + timestamp: data.time || Date.now(), + title: `The Lounge: ${title}`, + body: Helper.cleanIrcMessage(data.message) + }, true); + } } }; diff --git a/src/plugins/webpush.js b/src/plugins/webpush.js new file mode 100644 index 00000000..5a5e4341 --- /dev/null +++ b/src/plugins/webpush.js @@ -0,0 +1,73 @@ +"use strict"; + +const _ = require("lodash"); +const fs = require("fs"); +const path = require("path"); +const WebPushAPI = require("web-push"); +const Helper = require("../helper"); + +class WebPush { + constructor() { + const vapidPath = path.join(Helper.HOME, "vapid.json"); + + if (fs.existsSync(vapidPath)) { + const data = fs.readFileSync(vapidPath, "utf-8"); + const parsedData = JSON.parse(data); + + if (typeof parsedData.publicKey === "string" && typeof parsedData.privateKey === "string") { + this.vapidKeys = { + publicKey: parsedData.publicKey, + privateKey: parsedData.privateKey, + }; + } + } + + if (!this.vapidKeys) { + this.vapidKeys = WebPushAPI.generateVAPIDKeys(); + + fs.writeFileSync(vapidPath, JSON.stringify(this.vapidKeys, null, "\t")); + + log.info("New VAPID key pair has been generated for use with push subscription."); + } + + WebPushAPI.setVapidDetails( + "https://github.com/thelounge/lounge", + this.vapidKeys.publicKey, + this.vapidKeys.privateKey + ); + } + + push(client, payload, onlyToOffline) { + _.forOwn(client.config.sessions, (session, token) => { + if (session.pushSubscription) { + if (onlyToOffline && _.find(client.attachedClients, {token: token}) !== undefined) { + return; + } + + this.pushSingle(client, session.pushSubscription, payload); + } + }); + } + + pushSingle(client, subscription, payload) { + WebPushAPI + .sendNotification(subscription, JSON.stringify(payload)) + .catch((error) => { + if (error.statusCode >= 400 && error.statusCode < 500) { + log.warn(`WebPush subscription for ${client.name} returned an error (${error.statusCode}), removing subscription`); + + _.forOwn(client.config.sessions, (session, token) => { + if (session.pushSubscription && session.pushSubscription.endpoint === subscription.endpoint) { + client.unregisterPushSubscription(token); + } + }); + + return; + } + + log.error("WebPush Error", error); + }); + } +} + +module.exports = WebPush; diff --git a/src/server.js b/src/server.js index 8dc20c52..d8c4626b 100644 --- a/src/server.js +++ b/src/server.js @@ -180,7 +180,7 @@ function initializeClient(socket, client, generateToken, token) { socket.on("disconnect", function() { client.clientDetach(socket.id); }); - client.clientAttach(socket.id); + client.clientAttach(socket.id, token); socket.on( "input", @@ -296,6 +296,31 @@ function initializeClient(socket, client, generateToken, token) { } }); + socket.on("push:register", (subscription) => { + if (!client.isRegistered() || !client.config.sessions[token]) { + return; + } + + const registration = client.registerPushSubscription(client.config.sessions[token], subscription); + + if (registration) { + client.manager.webPush.pushSingle(client, registration, { + type: "notification", + timestamp: Date.now(), + title: "The Lounge", + body: "🚀 Push notifications have been enabled" + }); + } + }); + + socket.on("push:unregister", () => { + if (!client.isRegistered()) { + return; + } + + client.unregisterPushSubscription(token); + }); + socket.on("sign-out", () => { delete client.config.sessions[token]; @@ -314,6 +339,8 @@ function initializeClient(socket, client, generateToken, token) { const sendInitEvent = (tokenToSend) => { socket.emit("init", { + applicationServerKey: manager.webPush.vapidKeys.publicKey, + pushSubscription: client.config.sessions[token], active: client.lastActiveChannel, networks: client.networks, token: tokenToSend From 36f2f644eace4ba6df5882600a31d49561d9baef Mon Sep 17 00:00:00 2001 From: Pavel Djundik Date: Tue, 15 Aug 2017 12:58:28 +0300 Subject: [PATCH 2/2] Add tests for stripping formatting --- test/tests/cleanircmessages.js | 48 ++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 test/tests/cleanircmessages.js diff --git a/test/tests/cleanircmessages.js b/test/tests/cleanircmessages.js new file mode 100644 index 00000000..6a3877c6 --- /dev/null +++ b/test/tests/cleanircmessages.js @@ -0,0 +1,48 @@ +"use strict"; + +const expect = require("chai").expect; +const Helper = require("../../src/helper"); + +describe("Clean IRC messages", function() { + it("should remove all formatting", function() { + const testCases = [{ + input: "\x0303", + expected: "" + }, { + input: "\x02bold", + expected: "bold" + }, { + input: "\x038yellowText", + expected: "yellowText" + }, { + input: "\x030,0white,white", + expected: "white,white" + }, { + input: "\x034,8yellowBGredText", + expected: "yellowBGredText" + }, { + input: "\x1ditalic", + expected: "italic" + }, { + input: "\x1funderline", + expected: "underline" + }, { + input: "\x02bold\x038yellow\x02nonBold\x03default", + expected: "boldyellownonBolddefault" + }, { + input: "\x02bold\x02 \x02bold\x02", + expected: "bold bold" + }, { + input: "\x02irc\x0f://\x1dfreenode.net\x0f/\x034,8thelounge", + expected: "irc://freenode.net/thelounge" + }, { + input: "\x02#\x038,9thelounge", + expected: "#thelounge" + }]; + + const actual = testCases.map((testCase) => Helper.cleanIrcMessage(testCase.input)); + const expected = testCases.map((testCase) => testCase.expected); + + expect(actual).to.deep.equal(expected); + }); +});