From 1b755de476a3b94a672f104d139bf16facaa1f5f Mon Sep 17 00:00:00 2001 From: ItsOnlyBinary Date: Fri, 24 Jul 2020 18:15:59 +0100 Subject: [PATCH] Add push notification support --- package-lock.json | 133 +++++++++++++ package.json | 1 + src/configProfileTemplate/config.ini | 15 ++ src/extensions/webchat/index.js | 178 +++++++++++++++++- .../webchat/kiwibnc_notifications.html | 115 +++++++++++ src/extensions/webchat/notification-worker.js | 61 ++++++ src/extensions/webchat/routes_client.js | 28 +++ 7 files changed, 530 insertions(+), 1 deletion(-) create mode 100644 src/extensions/webchat/kiwibnc_notifications.html create mode 100644 src/extensions/webchat/notification-worker.js diff --git a/package-lock.json b/package-lock.json index b21083f..822ddba 100644 --- a/package-lock.json +++ b/package-lock.json @@ -65,6 +65,29 @@ "negotiator": "0.6.2" } }, + "agent-base": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.1.tgz", + "integrity": "sha512-01q25QQDwLSsyfhrKbn8yuur+JNw0H+0Y4JiGIKd3z9aYk/w/2kxD/Upc+t2ZBBSUNff50VjPsSW2YxM8QYKVg==", + "requires": { + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "ajv": { "version": "6.10.2", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.10.2.tgz", @@ -180,6 +203,17 @@ "safer-buffer": "~2.1.0" } }, + "asn1.js": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/asn1.js/-/asn1.js-5.4.1.tgz", + "integrity": "sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==", + "requires": { + "bn.js": "^4.0.0", + "inherits": "^2.0.1", + "minimalistic-assert": "^1.0.0", + "safer-buffer": "^2.1.0" + } + }, "assert-plus": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/assert-plus/-/assert-plus-1.0.0.tgz", @@ -389,6 +423,11 @@ "resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.7.2.tgz", "integrity": "sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==" }, + "bn.js": { + "version": "4.11.9", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.9.tgz", + "integrity": "sha512-E6QoYqCKZfgatHTdHzs1RRKP7ip4vvm+EyRUeE2RF0NblwVvb0p6jSVeNTOFxPn26QXN2o6SMfNxKp6kU8zQaw==" + }, "brace-expansion": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", @@ -425,6 +464,11 @@ } } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-indexof-polyfill": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/buffer-indexof-polyfill/-/buffer-indexof-polyfill-1.0.1.tgz", @@ -773,6 +817,14 @@ "safer-buffer": "^2.1.0" } }, + "ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -1308,6 +1360,38 @@ "sshpk": "^1.7.0" } }, + "http_ece": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/http_ece/-/http_ece-1.1.0.tgz", + "integrity": "sha512-bptAfCDdPJxOs5zYSe7Y3lpr772s1G346R4Td5LgRUeCwIGpCGDUTJxRrhTNcAXbx37spge0kWEIH7QAYWNTlA==", + "requires": { + "urlsafe-base64": "~1.0.0" + } + }, + "https-proxy-agent": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.0.tgz", + "integrity": "sha512-EkYm5BcKUGiduxzSt3Eppko+PiNWNEpa4ySk9vTC6wDsQJW9rHSa+UhGNJoRYp7bz6Ht1eaRIa6QaJqO5rCFbA==", + "requires": { + "agent-base": "6", + "debug": "4" + }, + "dependencies": { + "debug": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", + "requires": { + "ms": "^2.1.1" + } + }, + "ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + } + } + }, "iconv-lite": { "version": "0.4.24", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", @@ -1630,6 +1714,25 @@ "verror": "1.10.0" } }, + "jwa": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", + "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", + "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", + "requires": { + "jwa": "^2.0.0", + "safe-buffer": "^5.0.1" + } + }, "keygrip": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", @@ -1976,6 +2079,11 @@ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz", "integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==" }, + "minimalistic-assert": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/minimalistic-assert/-/minimalistic-assert-1.0.1.tgz", + "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" + }, "minimatch": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz", @@ -3450,6 +3558,11 @@ "requires-port": "^1.0.0" } }, + "urlsafe-base64": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/urlsafe-base64/-/urlsafe-base64-1.0.0.tgz", + "integrity": "sha1-I/iQaabGL0bPOh07ABac77kL4MY=" + }, "use": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz", @@ -3488,6 +3601,26 @@ "extsprintf": "^1.2.0" } }, + "web-push": { + "version": "3.4.4", + "resolved": "https://registry.npmjs.org/web-push/-/web-push-3.4.4.tgz", + "integrity": "sha512-tB0F+ccobsfw5jTWBinWJKyd/YdCdRbKj+CFSnsJeEgFYysOULvWFYyeCxn9KuQvG/3UF1t3cTAcJzBec5LCWA==", + "requires": { + "asn1.js": "^5.3.0", + "http_ece": "1.1.0", + "https-proxy-agent": "^5.0.0", + "jws": "^4.0.0", + "minimist": "^1.2.5", + "urlsafe-base64": "^1.0.0" + }, + "dependencies": { + "minimist": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.5.tgz", + "integrity": "sha512-FM9nNUYrRBAELZQT3xeZQ7fmMOBg6nWNmJKTcgsJeaLstP/UODVpGsr5OhXhhXg6f+qtJ8uiZ+PUxkDWcgIXLw==" + } + } + }, "which": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/which/-/which-1.3.1.tgz", diff --git a/package.json b/package.json index a13f53e..efd9f9e 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "toml": "^2.3.6", "unzipper": "^0.10.5", "uuid": "^3.3.2", + "web-push": "^3.4.4", "ws": "^7.1.2" }, "devDependencies": { diff --git a/src/configProfileTemplate/config.ini b/src/configProfileTemplate/config.ini index 7915c46..3d07ec5 100644 --- a/src/configProfileTemplate/config.ini +++ b/src/configProfileTemplate/config.ini @@ -45,6 +45,21 @@ public_register = true # The webchat UI will be automatically downloaded from here download_url = "https://builds.kiwiirc.com/zips/kiwiirc_master.zip" +# Push Notifications + +# VAPID keys will be displayed in the log if not populated +vapid_public_key = "" +vapid_private_key = "" +vapid_subject = "mailto:user@example.com" + +# Time in seconds push notifications will be retained by the push service +push_ttl = 3600 + +notification_title = "You where mentioned in %NetworkName% %BufferName%" +notification_icon = "/static/favicon.png" +notification_ttl = 10000 +notification_all_browsers = false + # Extra configuration for the Kiwi web interface. The webchat extension must be loaded # See https://github.com/kiwiirc/kiwiirc/wiki/Configuration-Options "startupOptions.nick" = "" diff --git a/src/extensions/webchat/index.js b/src/extensions/webchat/index.js index a2b107a..16c969c 100644 --- a/src/extensions/webchat/index.js +++ b/src/extensions/webchat/index.js @@ -2,9 +2,12 @@ const https = require('https'); const fs = require('fs-extra'); const os = require('os'); const path = require('path'); +const webpush = require('web-push'); const unzipper = require('unzipper'); const routesAdmin = require('./routes_admin'); const routesClient = require('./routes_client'); +const { mParam, mParamU } = require('../../libs/helpers'); +const messageTags = require('irc-framework/src/messagetags'); module.exports.init = async function init(hooks, app) { if (!app.conf.get('webserver.enabled') || !app.conf.get('webserver.public_dir')) { @@ -14,18 +17,191 @@ module.exports.init = async function init(hooks, app) { await downloadKiwiIrc(publicPath, app.conf.get('webchat.download_url', '')); + await initDB(app.db); + + const vapidPublicKey = app.conf.get('webchat.vapid_public_key'); + const vapidPrivateKey = app.conf.get('webchat.vapid_private_key'); + + if (!vapidPublicKey || !vapidPrivateKey) { + const vapidKeys = webpush.generateVAPIDKeys(); + console.log('VAPID keys, add them to your config.ini to enable push nofication support'); + console.log('vapid_public_key:', vapidKeys.publicKey); + console.log('vapid_private_key:', vapidKeys.privateKey); + } + routesAdmin(app); routesClient(app); // Add an admin auth token to admin clients - hooks.on('available_isupports', async event => { + hooks.on('available_isupports', async (event) => { if (event.client.state.authAdmin) { let token = app.crypt.encrypt('userid='+event.client.state.authUserId); event.tokens.push('kiwibnc/admin=' + token); } }); + + hooks.on('message_from_client', async (event) => { + const client = event.client; + const msg = event.message; + + if ( + msg.command.toUpperCase() === 'PRIVMSG' && + mParamU(msg, 0, '') === '*BNC' + ) { + return handleBncPrivMsgCommand(app.db, client, msg); + } + }); + + hooks.on('message_notification', async (event) => { + const userId = event.upstream.state.authUserId; + const networkId = event.upstream.state.authNetworkId; + + let hasClient = false; + let activeDataIds = []; + + app.cons.findAllUsersClients(userId).forEach((con) => { + if (con.state.authNetworkId !== networkId) { + return; + } + + const dataId = con.state.tempGet('notification_data_id'); + if (dataId) { + activeDataIds.push(dataId); + } + + hasClient = true; + }); + + const allBrowsers = app.conf.get('webchat.notification_all_browsers', false); + if (!allBrowsers && hasClient) { + // This notifcation has a connected client so ignore it + return; + } + + const pushReceivers = await app.db.dbUsers('browsers') + .select('endpoint', 'ep_data', 'data_id') + .where('user_id', userId); + + const options = { + vapidDetails: { + subject: app.conf.get('webchat.vapid_subject', 'mailto:user@example.com'), + publicKey: vapidPublicKey, + privateKey: vapidPrivateKey, + }, + TTL: app.conf.get('webchat.push_ttl', 3600), + }; + + pushReceivers.forEach(async (receiver) => { + if (activeDataIds.includes(receiver.data_id)) { + // This browser is currently connected so ignore it + return; + } + const pushSubscription = { endpoint: receiver.endpoint, ...JSON.parse(receiver.ep_data) }; + + const titleDefault = 'You where mentioned in %NetworkName% %BufferName%'; + const title = app.conf.get('webchat.notification_title', titleDefault) + .replace('%NetworkName%', event.upstream.state.authNetworkName) + .replace('%BufferName%', event.buffer.name); + + const payload = JSON.stringify({ + notification: { + title: title, + body: event.message.params.slice(-1)[0], + icon: app.conf.get('webchat.notification_icon', '/static/favicon.png'), + ttl: app.conf.get('webchat.notification_ttl', 10000), + }, + }); + + try { + await webpush.sendNotification( + pushSubscription, + payload, + options + ); + } catch (e) { + if (e.statusCode === 410) { + // Push subscription has expired + await app.db.dbUsers('browsers') + .where('user_id', userId) + .where('data_id', receiver.data_id) + .delete(); + } + } + }); + }); }; +async function initDB(db) { + await db.run(` + CREATE TABLE IF NOT EXISTS browsers ( + user_id INTEGER NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL, + data_id TEXT NOT NULL, + endpoint TEXT NOT NULL, + ep_data TEXT, + CONSTRAINT browsers_PK PRIMARY KEY (user_id, endpoint), + CONSTRAINT browsers_UN UNIQUE (user_id, data_id) + ); + `); +} + +async function handleBncPrivMsgCommand(db, client, msg) { + const [subCommand, commandData] = mParam(msg, 1).split(' '); + if (subCommand.toUpperCase() !== 'BROWSER') { + return; + } + const cmdData = messageTags.decode(commandData || ''); + + if (cmdData.id && cmdData.endpoint) { + const result = await db.dbUsers('browsers') + .select('data_id', 'ep_data') + .where('user_id', client.state.authUserId) + .where('endpoint', cmdData.endpoint) + .first(); + + if (result) { + // Entry exists update data_id and time + await db.dbUsers('browsers').update({ + data_id: cmdData.id, + updated_at: Date.now() / 1000, + }) + .where('user_id', client.state.authUserId) + .where('endpoint', cmdData.endpoint) + } else { + // New entry + await db.dbUsers('browsers').insert({ + user_id: client.state.authUserId, + created_at: Date.now() / 1000, + updated_at: Date.now() / 1000, + data_id: cmdData.id, + endpoint: cmdData.endpoint, + }); + } + } else if (cmdData.id && cmdData.data) { + const result = await db.dbUsers('browsers') + .select('ep_data') + .where('user_id', client.state.authUserId) + .where('data_id', cmdData.id) + .first(); + + if (result && !result.ep_data) { + // Entry exists without ep_data add the data + await db.dbUsers('browsers').update({ + ep_data: cmdData.data, + updated_at: Date.now() / 1000, + }) + .where('user_id', client.state.authUserId) + .where('data_id', cmdData.id); + } + } else if (cmdData.id) { + // This is a client connection add data_id for connected networks tracking + client.state.tempSet('notification_data_id', cmdData.id); + } + + client.writeMsg('BROWSER', 'RPL_OK'); +} + async function downloadKiwiIrc(publicPath, downloadUrl) { let downloadPath = path.join(os.tmpdir(), 'kiwiirc_download'); diff --git a/src/extensions/webchat/kiwibnc_notifications.html b/src/extensions/webchat/kiwibnc_notifications.html new file mode 100644 index 0000000..ddfceed --- /dev/null +++ b/src/extensions/webchat/kiwibnc_notifications.html @@ -0,0 +1,115 @@ + diff --git a/src/extensions/webchat/notification-worker.js b/src/extensions/webchat/notification-worker.js new file mode 100644 index 0000000..039545d --- /dev/null +++ b/src/extensions/webchat/notification-worker.js @@ -0,0 +1,61 @@ +self.addEventListener('push', async (event) => { + if (!(self.Notification && self.Notification.permission === 'granted')) { + return; + } + + const reg = event.target.registration; + + let data = {}; + if (event.data) { + data = event.data.json(); + } + + if (data.unregister) { + // This allows removing a worker via a push notification + reg.unregister(); + return + } + + if (!data.notification) { + return; + } + + const title = data.notification.title; + delete data.notification.title; + + let notify; + try { + notify = new Notification(title, data.notification); + + if (notify && data.ttl) { + setTimeout(notify.close.bind(notify), data.notification.ttl); + } + } catch (e) { + if (e.name !== 'TypeError') { + return; + } + + // Chrome & Firefox does not support `new Notification` inside of service workers + const notifyId = generateId(); + data.notification.tag = notifyId; + notify = reg.showNotification(title, data.notification); + + if (data.notification.ttl) { + setTimeout(() => { + reg.getNotifications({ tag: notifyId }).then((notifications) => { + notifications.forEach((n) => n.close()); + }); + }, data.notification.ttl); + } + } + + // service workers must show an notification when push received + // without this waitUntil the notification will show as + // "This site has been updated in the background" + event.waitUntil(notify); +}); + +function generateId() { + let base36Date = Date.now().toString(36); + return base36Date + Math.floor((Math.random() * 100000)).toString(36); +}; diff --git a/src/extensions/webchat/routes_client.js b/src/extensions/webchat/routes_client.js index 4d6abaf..170ab33 100644 --- a/src/extensions/webchat/routes_client.js +++ b/src/extensions/webchat/routes_client.js @@ -14,6 +14,21 @@ module.exports = function(app) { ); }); + router.get('kiwi.bnc_notifications', '/kiwibnc_notifications.html', async (ctx, next) => { + ctx.body = await fs.readFile( + path.join(__dirname, 'kiwibnc_notifications.html'), + { encoding: 'utf8' }, + ); + }); + + router.get('kiwi.notifications_worker', '/notification-worker.js', async (ctx, next) => { + ctx.body = await fs.readFile( + path.join(__dirname, 'notification-worker.js'), + { encoding: 'utf8' }, + ); + ctx.type = 'application/javascript'; + }); + router.get('kiwi.config', '/static/config.json', async (ctx, next) => { let config = await fs.readFile(path.join(publicPath, 'static', 'config.json')); config = JSON.parse(config); @@ -24,6 +39,11 @@ module.exports = function(app) { startupScreen: 'welcome', }; + const vapidPublicKey = app.conf.get('webchat.vapid_public_key'); + if (vapidPublicKey) { + config.vapidPublicKey = vapidPublicKey; + } + config.startupOptions = { ...config.startupOptions, port: '{{port}}', @@ -45,6 +65,14 @@ module.exports = function(app) { basePath: ctx.basePath, }); + if (vapidPublicKey) { + config.plugins.push({ + name: 'kiwibnc-notifications', + url: router.url('kiwi.bnc_notifications', {}), + basePath: ctx.basePath, + }); + } + let extraConf = app.conf.get('webchat'); for (let prop in extraConf) { config[prop] = extraConf[prop];