nodebb-plugin-web-push 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.eslintrc ADDED
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "nodebb"
3
+ }
package/.gitattributes ADDED
@@ -0,0 +1,22 @@
1
+ # Auto detect text files and perform LF normalization
2
+ * text=auto
3
+
4
+ # Custom for Visual Studio
5
+ *.cs diff=csharp
6
+ *.sln merge=union
7
+ *.csproj merge=union
8
+ *.vbproj merge=union
9
+ *.fsproj merge=union
10
+ *.dbproj merge=union
11
+
12
+ # Standard to msysgit
13
+ *.doc diff=astextplain
14
+ *.DOC diff=astextplain
15
+ *.docx diff=astextplain
16
+ *.DOCX diff=astextplain
17
+ *.dot diff=astextplain
18
+ *.DOT diff=astextplain
19
+ *.pdf diff=astextplain
20
+ *.PDF diff=astextplain
21
+ *.rtf diff=astextplain
22
+ *.RTF diff=astextplain
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2016 NodeBB Inc. <sales@nodebb.org>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in
13
+ all copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21
+ THE SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # Push Notifications (via Push API) Plugin for NodeBB
2
+
3
+ This plugin adds push notification functionality for NodeBB via the in-browser [Push API](https://developer.mozilla.org/en-US/docs/Web/API/Push_API).
4
+
5
+ Once installed, users are able to configure their push notification subscription settings from their account profile.
6
+
7
+ ## Installation
8
+
9
+ npm install nodebb-plugin-web-push
@@ -0,0 +1,26 @@
1
+ 'use strict';
2
+
3
+ module.exports = {
4
+ extends: ['@commitlint/config-angular'],
5
+ rules: {
6
+ 'header-max-length': [1, 'always', 72],
7
+ 'type-enum': [
8
+ 2,
9
+ 'always',
10
+ [
11
+ 'breaking',
12
+ 'build',
13
+ 'chore',
14
+ 'ci',
15
+ 'docs',
16
+ 'feat',
17
+ 'fix',
18
+ 'perf',
19
+ 'refactor',
20
+ 'revert',
21
+ 'style',
22
+ 'test',
23
+ ],
24
+ ],
25
+ },
26
+ };
@@ -0,0 +1,36 @@
1
+ 'use strict';
2
+
3
+ const user = require.main.require('./src/user');
4
+
5
+ const subscriptions = require('./subscriptions');
6
+
7
+ const helpers = require.main.require('./src/controllers/helpers');
8
+
9
+ const Controllers = module.exports;
10
+
11
+ Controllers.renderSettings = async function (req, res) {
12
+ if (res.locals.uid !== req.user.uid) {
13
+ return helpers.notAllowed(req, res);
14
+ }
15
+
16
+ const [{ username, userslug }, count] = await Promise.all([
17
+ user.getUserFields(res.locals.uid, ['username', 'userslug']),
18
+ subscriptions.count(req.uid),
19
+ ]);
20
+ console.log(count);
21
+
22
+ const payload = {
23
+ ...res.locals.userData,
24
+ title: '[[web-push:profile.label]]',
25
+ breadcrumbs: helpers.buildBreadcrumbs([{ text: username, url: `/user/${userslug}` }, { text: '[[web-push:profile.label]]' }]),
26
+ count,
27
+ };
28
+
29
+ res.render('account/web-push', payload);
30
+ };
31
+
32
+ Controllers.renderAdminPage = function (req, res/* , next */) {
33
+ res.render('admin/plugins/web-push', {
34
+ title: 'Quick Start',
35
+ });
36
+ };
@@ -0,0 +1,25 @@
1
+ 'use strict';
2
+
3
+ const db = require.main.require('./src/database');
4
+
5
+ const Subscriptions = module.exports;
6
+
7
+ Subscriptions.count = async uid => await db.sortedSetCard(`uid:${uid}:web-push:subscriptions`);
8
+
9
+ Subscriptions.list = async (uids) => {
10
+ const subscriptions = await db.getSortedSetsMembers(uids.map(uid => `uid:${uid}:web-push:subscriptions`));
11
+ const response = new Map();
12
+ subscriptions.forEach((subscriptions, idx) => {
13
+ response.set(uids[idx], new Set(subscriptions.map(sub => JSON.parse(sub))));
14
+ });
15
+
16
+ return response;
17
+ };
18
+
19
+ Subscriptions.add = async (uid, subscription) => {
20
+ await db.sortedSetAdd(`uid:${uid}:web-push:subscriptions`, Date.now(), JSON.stringify(subscription));
21
+ };
22
+
23
+ Subscriptions.remove = async (uid, subscription) => {
24
+ await db.sortedSetRemove(`uid:${uid}:web-push:subscriptions`, JSON.stringify(subscription));
25
+ };
package/library.js ADDED
@@ -0,0 +1,165 @@
1
+ 'use strict';
2
+
3
+ const nconf = require.main.require('nconf');
4
+ const winston = require.main.require('winston');
5
+ const webPush = require('web-push');
6
+ const validator = require('validator');
7
+
8
+ const user = require.main.require('./src/user');
9
+ const meta = require.main.require('./src/meta');
10
+ const utils = require.main.require('./src/utils');
11
+ const translator = require.main.require('./src/translator');
12
+
13
+ const controllers = require('./lib/controllers');
14
+ const subscriptions = require('./lib/subscriptions');
15
+
16
+ const routeHelpers = require.main.require('./src/routes/helpers');
17
+
18
+ const plugin = {};
19
+
20
+ plugin.init = async (params) => {
21
+ const { router, middleware/* , controllers */ } = params;
22
+ const accountMiddlewares = [
23
+ middleware.exposeUid,
24
+ middleware.ensureLoggedIn,
25
+ middleware.canViewUsers,
26
+ middleware.checkAccountPermissions,
27
+ middleware.buildAccountData,
28
+ ];
29
+
30
+ await assertVapidConfiguration();
31
+
32
+ routeHelpers.setupPageRoute(router, '/user/:userslug/web-push', accountMiddlewares, controllers.renderSettings);
33
+
34
+ routeHelpers.setupAdminPageRoute(router, '/admin/plugins/web-push', controllers.renderAdminPage);
35
+ };
36
+
37
+ plugin.appendConfig = async (config) => {
38
+ const { publicKey } = await meta.settings.get('web-push');
39
+ config['web-push'] = {
40
+ vapidKey: publicKey,
41
+ };
42
+
43
+ return config;
44
+ };
45
+
46
+ async function assertVapidConfiguration() {
47
+ let { publicKey, privateKey } = await meta.settings.get('web-push');
48
+ if (!publicKey || !privateKey) {
49
+ winston.warn('[plugins/web-push] VAPID key pair not found or invalid, regenerating.');
50
+ ({ publicKey, privateKey } = webPush.generateVAPIDKeys());
51
+ await meta.settings.set('web-push', { publicKey, privateKey });
52
+ } else {
53
+ winston.info('[plugins/web-push] VAPID keys OK.');
54
+ }
55
+
56
+ webPush.setVapidDetails(
57
+ nconf.get('url'),
58
+ publicKey,
59
+ privateKey
60
+ );
61
+ }
62
+
63
+ plugin.addRoutes = async ({ router, middleware, helpers }) => {
64
+ const middlewares = [
65
+ middleware.ensureLoggedIn,
66
+ // middleware.admin.checkPrivileges,
67
+ ];
68
+
69
+ routeHelpers.setupApiRoute(router, 'post', '/web-push/subscription', middlewares, async (req, res) => {
70
+ if (!req.uid) {
71
+ return helpers.formatApiResponse(204, res);
72
+ }
73
+
74
+ const { subscription } = req.body;
75
+ await subscriptions.add(req.uid, subscription);
76
+ helpers.formatApiResponse(200, res);
77
+ });
78
+
79
+ routeHelpers.setupApiRoute(router, 'delete', '/web-push/subscription', middlewares, async (req, res) => {
80
+ if (!req.uid) {
81
+ return helpers.notAllowed(req, res);
82
+ }
83
+
84
+ const { subscription } = req.body;
85
+ await subscriptions.remove(req.uid, subscription);
86
+ helpers.formatApiResponse(200, res);
87
+ });
88
+ };
89
+
90
+ plugin.addAdminNavigation = (header) => {
91
+ header.plugins.push({
92
+ route: '/plugins/web-push',
93
+ icon: 'fa-tint',
94
+ name: 'Push Notifications (via Push API)',
95
+ });
96
+
97
+ return header;
98
+ };
99
+
100
+ plugin.onNotificationPush = async ({ notification, uidsNotified: uids }) => {
101
+ const subs = await subscriptions.list(uids);
102
+ uids = uids.filter(uid => subs.get(uid).size);
103
+ const userSettings = await user.getMultipleUserSettings(uids);
104
+
105
+ let payloads = await Promise.all(uids.map(async (uid, idx) => {
106
+ const payload = await constructPayload(notification, userSettings[idx].userLang);
107
+ return [uid, payload];
108
+ }));
109
+ payloads = new Map(payloads);
110
+
111
+ payloads.forEach((payload, uid) => {
112
+ const targets = subs.get(uid);
113
+ targets.forEach(async (subscription) => {
114
+ try {
115
+ await webPush.sendNotification(subscription, JSON.stringify(payload));
116
+ } catch (e) {
117
+ // Errored — remove subscription from user
118
+ subscriptions.remove(uid, subscription);
119
+ }
120
+ });
121
+ });
122
+ };
123
+
124
+ plugin.addProfileItem = async (data) => {
125
+ const title = await translator.translate('[[web-push:profile.label]]');
126
+ data.links.push({
127
+ id: 'web-push',
128
+ route: 'web-push',
129
+ icon: 'fa-bell-o',
130
+ name: title,
131
+ visibility: {
132
+ self: true,
133
+ other: false,
134
+ moderator: false,
135
+ globalMod: false,
136
+ admin: false,
137
+ },
138
+ });
139
+
140
+ return data;
141
+ };
142
+
143
+ async function constructPayload({ bodyShort, bodyLong, path }, language) {
144
+ if (!language) {
145
+ language = meta.config.defaultLang || 'en-GB';
146
+ }
147
+
148
+ let [title, body] = await translator.translateKeys([bodyShort, bodyLong], language);
149
+ ([title, body] = [title, body].map(str => validator.unescape(utils.stripHTMLTags(str))));
150
+ const url = `${nconf.get('url')}${path}`;
151
+
152
+ // Handle empty bodyLong
153
+ if (!bodyLong) {
154
+ body = title;
155
+ title = meta.config.title || 'NodeBB';
156
+ }
157
+
158
+ return {
159
+ title,
160
+ body,
161
+ data: { url },
162
+ };
163
+ }
164
+
165
+ module.exports = plugin;
package/package.json ADDED
@@ -0,0 +1,49 @@
1
+ {
2
+ "name": "nodebb-plugin-web-push",
3
+ "version": "0.1.0",
4
+ "description": "A starter kit for quickly creating NodeBB plugins",
5
+ "main": "library.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/nodebb/nodebb-plugin-web-push"
9
+ },
10
+ "keywords": [
11
+ "nodebb",
12
+ "plugin",
13
+ "web-push",
14
+ "shell"
15
+ ],
16
+ "husky": {
17
+ "hooks": {
18
+ "pre-commit": "lint-staged",
19
+ "commit-msg": "commitlint -E HUSKY_GIT_PARAMS"
20
+ }
21
+ },
22
+ "lint-staged": {
23
+ "*.js": [
24
+ "eslint --fix",
25
+ "git add"
26
+ ]
27
+ },
28
+ "license": "MIT",
29
+ "bugs": {
30
+ "url": "https://github.com/nodebb/nodebb-plugin-web-push/issues"
31
+ },
32
+ "readmeFilename": "README.md",
33
+ "nbbpm": {
34
+ "compatibility": "^4.0.0"
35
+ },
36
+ "devDependencies": {
37
+ "@commitlint/cli": "19.4.1",
38
+ "@commitlint/config-angular": "19.4.1",
39
+ "eslint": "8.x",
40
+ "eslint-config-nodebb": "0.2.1",
41
+ "eslint-plugin-import": "2.x",
42
+ "husky": "9.1.5",
43
+ "lint-staged": "15.2.10"
44
+ },
45
+ "dependencies": {
46
+ "validator": "^13.12.0",
47
+ "web-push": "^3.6.7"
48
+ }
49
+ }
package/plugin.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "id": "nodebb-plugin-web-push",
3
+ "url": "https://github.com/NodeBB/nodebb-plugin-web-push",
4
+ "library": "./library.js",
5
+ "hooks": [
6
+ { "hook": "static:app.load", "method": "init" },
7
+ { "hook": "static:api.routes", "method": "addRoutes" },
8
+ { "hook": "filter:admin.header.build", "method": "addAdminNavigation" },
9
+ { "hook": "filter:config.get", "method": "appendConfig" },
10
+ { "hook": "action:notification.pushed", "method": "onNotificationPush" },
11
+ { "hook": "filter:user.profileMenu", "method": "addProfileItem" }
12
+ ],
13
+ "languages": "public/languages",
14
+ "scripts": [
15
+ "public/lib/main.js"
16
+ ],
17
+ "modules": {
18
+ "../client/account/web-push.js": "./public/lib/settings.js",
19
+ "../admin/plugins/web-push.js": "./public/lib/admin.js"
20
+ },
21
+ "templates": "templates"
22
+ }
@@ -0,0 +1,7 @@
1
+ {
2
+ "profile.label": "Push Notifications",
3
+ "profile.introduction": "In addition to in-application and email notifications, you may opt-in to receive push notifications as well. This will allow you to be notified even if the app is not open on your device.",
4
+ "profile.option": "Enable push notifications on this device",
5
+ "profile.devices": "Currently notifying <strong>%1</strong> device(s).",
6
+ "profile.permissionBlocked": "Your device is currently disallowing notifications from this site. Please grant the notification permission to continue."
7
+ }
@@ -0,0 +1,3 @@
1
+ {
2
+ "extends": "nodebb/public"
3
+ }
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+
3
+ /*
4
+ This file is located in the "modules" block of plugin.json
5
+ It is only loaded when the user navigates to /admin/plugins/web-push page
6
+ It is not bundled into the min file that is served on the first load of the page.
7
+ */
8
+
9
+ import { save, load } from 'settings';
10
+
11
+ export function init() {
12
+ handleSettingsForm();
13
+ };
14
+
15
+ function handleSettingsForm() {
16
+ load('web-push', $('.web-push-settings'));
17
+
18
+ $('#save').on('click', () => {
19
+ save('web-push', $('.web-push-settings')); // pass in a function in the 3rd parameter to override the default success/failure handler
20
+ });
21
+ }
@@ -0,0 +1,9 @@
1
+ 'use strict';
2
+
3
+ (async () => {
4
+ const [hooks, api] = await app.require(['hooks', 'api']);
5
+
6
+ hooks.on('action:app.load', async () => {
7
+ // ...
8
+ });
9
+ })();
@@ -0,0 +1,90 @@
1
+ 'use strict';
2
+
3
+ import { post, del } from 'api';
4
+ import { success } from 'alerts';
5
+
6
+ // eslint-disable-next-line import/prefer-default-export
7
+ export async function init() {
8
+ const containerEl = document.querySelector('.account');
9
+ const registration = await navigator.serviceWorker.ready;
10
+ let subscription = await registration.pushManager.getSubscription();
11
+ const convertedVapidKey = urlBase64ToUint8Array(config['web-push'].vapidKey);
12
+
13
+ containerEl.addEventListener('click', async (e) => {
14
+ const subselector = e.target.closest('[data-action]');
15
+ if (subselector) {
16
+ const action = e.target.getAttribute('data-action');
17
+
18
+ switch (action) {
19
+ // case 'test': {
20
+ // await post('/plugins/ntfy/test');
21
+ // success('[[ntfy:toast.test_success]]');
22
+ // break;
23
+ // }
24
+
25
+ case 'toggle': {
26
+ const countEl = document.querySelector('#deviceCount strong');
27
+ if (!subscription) {
28
+ try {
29
+ subscription = await registration.pushManager.subscribe({
30
+ userVisibleOnly: true,
31
+ applicationServerKey: convertedVapidKey,
32
+ });
33
+
34
+ await post('/plugins/web-push/subscription', { subscription: subscription.toJSON() });
35
+
36
+ // Update count
37
+ let count = parseInt(countEl.textContent, 10);
38
+ count += 1;
39
+ countEl.innerText = count;
40
+ } catch (e) {
41
+ subselector.checked = false;
42
+ }
43
+ } else {
44
+ await subscription.unsubscribe();
45
+ await del('/plugins/web-push/subscription', { subscription: subscription.toJSON() });
46
+ let count = parseInt(countEl.textContent, 10);
47
+ count -= 1;
48
+ countEl.innerText = count;
49
+ subscription = null;
50
+ }
51
+
52
+ break;
53
+ }
54
+ }
55
+ }
56
+ });
57
+
58
+ const enabledEl = document.getElementById('enabled');
59
+ if (subscription) {
60
+ enabledEl.checked = true;
61
+ }
62
+
63
+ // Show permission warning if applicable
64
+ const state = await registration.pushManager.permissionState({
65
+ userVisibleOnly: true,
66
+ applicationServerKey: convertedVapidKey,
67
+ });
68
+ if (state === 'denied') {
69
+ const warningEl = document.getElementById('permission-warning');
70
+ warningEl.classList.remove('d-none');
71
+ }
72
+ }
73
+
74
+ // This function is needed because Chrome doesn't accept a base64 encoded string
75
+ // as value for applicationServerKey in pushManager.subscribe yet
76
+ // https://bugs.chromium.org/p/chromium/issues/detail?id=802280
77
+ function urlBase64ToUint8Array(base64String) {
78
+ var padding = '='.repeat((4 - (base64String.length % 4)) % 4);
79
+ var base64 = (base64String + padding)
80
+ .replace(/\-/g, '+')
81
+ .replace(/_/g, '/');
82
+
83
+ var rawData = window.atob(base64);
84
+ var outputArray = new Uint8Array(rawData.length);
85
+
86
+ for (var i = 0; i < rawData.length; ++i) {
87
+ outputArray[i] = rawData.charCodeAt(i);
88
+ }
89
+ return outputArray;
90
+ }
package/renovate.json ADDED
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": [
3
+ "config:base"
4
+ ]
5
+ }
@@ -0,0 +1,5 @@
1
+ <h1>Hello!</h1>
2
+
3
+ <p>This file is served by nodebb at domain.com/assets/plugins/nodebb-plugin-web-push/static/samplefile.html</p>
4
+
5
+ Check plugin.json for the "staticDirs" property if you want to change the path.
@@ -0,0 +1,17 @@
1
+ <!-- IMPORT partials/account/header.tpl -->
2
+
3
+ <h3 class="fw-semibold fs-5">[[web-push:profile.label]]</h3>
4
+
5
+ <p>[[web-push:profile.introduction]]</p>
6
+
7
+ <div class="alert alert-warning d-none" id="permission-warning">[[web-push:profile.permissionBlocked]]</div>
8
+
9
+ <form role="form">
10
+ <div class="form-check form-switch">
11
+ <input type="checkbox" class="form-check-input" id="enabled" name="enabled" autocomplete="off" data-action="toggle">
12
+ <label for="enabled" class="form-check-label">[[web-push:profile.option]]</label>
13
+ <p class="form-text" id="deviceCount">[[web-push:profile.devices, {count}]]</p>
14
+ </div>
15
+ </form>
16
+
17
+ <!-- IMPORT partials/account/footer.tpl -->
@@ -0,0 +1,33 @@
1
+ <div class="acp-page-container">
2
+ <!-- IMPORT admin/partials/settings/header.tpl -->
3
+
4
+ <div class="row m-0">
5
+ <div id="spy-container" class="col-12 col-md-8 px-0 mb-4" tabindex="0">
6
+ <form role="form" class="web-push-settings">
7
+ <div class="mb-4">
8
+ <h5 class="fw-bold tracking-tight settings-header">General</h5>
9
+
10
+ <p class="lead">
11
+ Adjust these settings. You can then retrieve these settings in code via:
12
+ <br/><code>await meta.settings.get('web-push');</code>
13
+ </p>
14
+ <div class="mb-3">
15
+ <label class="form-label" for="setting-1">Setting 1</label>
16
+ <input type="text" id="setting-1" name="setting-1" title="Setting 1" class="form-control" placeholder="Setting 1">
17
+ </div>
18
+ <div class="mb-3">
19
+ <label class="form-label" for="setting-2">Setting 2</label>
20
+ <input type="text" id="setting-2" name="setting-2" title="Setting 2" class="form-control" placeholder="Setting 2">
21
+ </div>
22
+
23
+ <div class="form-check form-switch">
24
+ <input type="checkbox" class="form-check-input" id="setting-3" name="setting-3">
25
+ <label for="setting-3" class="form-check-label">Setting 3</label>
26
+ </div>
27
+ </div>
28
+ </form>
29
+ </div>
30
+
31
+ <!-- IMPORT admin/partials/settings/toc.tpl -->
32
+ </div>
33
+ </div>
package/test/.eslintrc ADDED
@@ -0,0 +1,9 @@
1
+ {
2
+ "env": {
3
+ "mocha": true
4
+ },
5
+ "rules": {
6
+ "no-unused-vars": "off"
7
+ }
8
+ }
9
+
package/test/index.js ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * You can run these tests by executing `npx mocha test/plugins-installed.js`
3
+ * from the NodeBB root folder. The regular test runner will also run these
4
+ * tests.
5
+ *
6
+ * Keep in mind tests do not activate all plugins, so if you are testing
7
+ * hook listeners, socket.io, or mounted routes, you will need to add your
8
+ * plugin to `config.json`, e.g.
9
+ *
10
+ * {
11
+ * "test_plugins": [
12
+ * "nodebb-plugin-web-push"
13
+ * ]
14
+ * }
15
+ */
16
+
17
+ 'use strict';
18
+
19
+ /* globals describe, it, before */
20
+
21
+ const assert = require('assert');
22
+
23
+ const db = require.main.require('./test/mocks/databasemock');
24
+
25
+ describe('nodebb-plugin-web-push', () => {
26
+ before(() => {
27
+ // Prepare for tests here
28
+ });
29
+
30
+ it('should pass', (done) => {
31
+ const actual = 'value';
32
+ const expected = 'value';
33
+ assert.strictEqual(actual, expected);
34
+ done();
35
+ });
36
+
37
+ it('should load config object', async () => { // Tests can be async functions too
38
+ const config = await db.getObject('config');
39
+ assert(config);
40
+ });
41
+ });