From 7a24394565ae2387facb92654b9feb2bdaf6d65b Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 27 Nov 2025 21:59:45 +0100 Subject: [PATCH 01/35] feat(webpush): Receive push notifications with web push Allows receiving notifications when no tab is opened. Needs the default CSP to allow `worker-src 'self'` Signed-off-by: sim --- lib/Controller/WebController.php | 48 ++++++++++++ public/service-worker.js | 72 ++++++++++++++++++ src/NotificationsApp.vue | 124 ++++++++++++++++++++++++++++--- 3 files changed, 233 insertions(+), 11 deletions(-) create mode 100644 lib/Controller/WebController.php create mode 100644 public/service-worker.js diff --git a/lib/Controller/WebController.php b/lib/Controller/WebController.php new file mode 100644 index 000000000..6b1fda17d --- /dev/null +++ b/lib/Controller/WebController.php @@ -0,0 +1,48 @@ +> + */ + #[PublicPage] + #[NoCSRFRequired] + #[FrontpageRoute(verb: 'GET', url: '/service-worker.js')] + public function serviceWorker(): StreamResponse { + $response = new StreamResponse(__DIR__ . '/../../service-worker.js'); + $response->setHeaders([ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/' + ]); + $policy = new ContentSecurityPolicy(); + $policy->addAllowedWorkerSrcDomain("'self'"); + $policy->addAllowedScriptDomain("'self'"); + $policy->addAllowedConnectDomain("'self'"); + $response->setContentSecurityPolicy($policy); + return $response; + } +} diff --git a/public/service-worker.js b/public/service-worker.js new file mode 100644 index 000000000..ffce312b4 --- /dev/null +++ b/public/service-worker.js @@ -0,0 +1,72 @@ +'use strict'; + +function showBgNotification(title) { + registration.showNotification(title); +} + +self.addEventListener('push', function(event) { + console.info("Received push message"); + + if (event.data) { + const content = event.data.json(); + console.log("Got ", content); + // Send the event to the last focused page only, + // show a notification with the subject if we don't have any + // active tab + event.waitUntil( + self.clients.matchAll() + .then(clientList => { + const client = clientList[0]; + if (client !== undefined) { + console.debug("Sending to client ", client); + client.postMessage({'type':'push','content':content}); + } else if (content.subject) { + console.debug("No valid client to send notif - showing bg notif") + showBgNotification(content.subject); + } else { + console.warn("No valid client to send notif") + } + }) + .catch(err => { + console.error("Couldn't send message: ", err); + }) + ); + } +}); + +self.addEventListener('pushsubscriptionchange', function(event) { + console.log("Push Subscription Change", event); + event.waitUntil( + self.clients.matchAll() + .then(clientList => { + const client = clientList[0]; + if (client !== undefined) { + console.debug("Sending to client ", client); + client.postMessage({'type':'pushEndpoint'}); + } else { + console.warn("No valid client to send notif") + } + }) + .catch(err => { + console.error("Couldn't send message: ", err); + }) + ); +}); + +self.addEventListener('registration', function(event){ + console.log("Registered"); +}); + + +self.addEventListener('install', function(event){ + console.log("Installed"); + // Replace currenctly active serviceWorkers with this one + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', function(event){ + console.log("Activated") + // Ensure we control the clients + event.waitUntil(self.clients.claim()); +}); + diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 52e9e75bd..81205e47d 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -87,7 +87,7 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { listen } from '@nextcloud/notify_push' -import { generateOcsUrl, imagePath } from '@nextcloud/router' +import { generateOcsUrl, imagePath, generateUrl } from '@nextcloud/router' import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' @@ -232,8 +232,20 @@ export default { this.hasNotifyPush = true } - // Set up the background checker - this._setPollingInterval(this.pollIntervalBase) + if (this.webNotificationsGranted === true) { + // set the polling interface after checking web push status + this.setWebPush((hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + this._setPollingInterval(this.pollIntervalBase) + }) + } else { + // Set up the background checker + this._setPollingInterval(this.pollIntervalBase) + } this._watchTabVisibility() subscribe('networkOffline', this.handleNetworkOffline) @@ -250,14 +262,104 @@ export default { methods: { t, + loadServiceWorker() { + return navigator.serviceWorker.register( + generateUrl('/apps/notifications/service-worker.js', {}, { noRewrite: true }), + {scope: "/"} + ).then((registration) => { + console.info('ServiceWorker registered') + return registration + }) + }, + listenForPush(registration) { + navigator.serviceWorker.addEventListener('message', (event) => { + console.debug("Received from serviceWorker: ", JSON.stringify(event.data)) + if (event.data.type == 'push') { + const activationToken = event.data.content.activationToken + if (activationToken) { + const form = new FormData() + form.append('activationToken', activationToken) + axios.post(generateOcsUrl('apps/notifications/api/v2/webpush/activate'), form) + .then((r) => { + if (r.status === 200 || r.status === 202) { + console.debug("Push notifications activated, slowing polling to 15 minutes") + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + this._setPollingInterval(this.pollIntervalBase) + } else { + console.warn("An error occured while activating push registration", r) + } + }) + } else { + // force=true: we don't have to check if we're the last tab, + // the serviceworker send the event to a single tab + this._fetchAfterNotifyPush(true) + } + } else if (event.data.type == 'pushEndoint') { + registerPush(registration) + .catch(er => console.error(er)) + } + }) + }, + registerPush(registration) { + return registration.pushManager.subscribe().then((sub) => { + const form = new FormData() + form.append('endpoint', sub.endpoint) + form.append('uaPublicKey', this.b64UrlEncode(sub.getKey('p256dh'))) + form.append('auth', this.b64UrlEncode(sub.getKey('auth'))) + form.append('apptypes', ['all']) + return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) + }) + }, + /** + * callback(boolean) if the push notifications has been subscribed (statusCode == 200) + */ + setWebPush(callback) { + if ('serviceWorker' in navigator) { + window.addEventListener('load', () => { + this.loadServiceWorker() + .then((r) => { + this.listenForPush(r) + return this.registerPush(r) + }) + .then((r) => callback(r.status == 200)) + .catch(er => { + console.error(er) + callback(false) + }) + }) + } + }, + userStatusUpdated(state) { if (getCurrentUser().uid === state.userId) { this.userStatus = state.status } }, + b64UrlEncode(inArr) { + return new Uint8Array(inArr) + .toBase64() + .replaceAll('+', '-') + .replaceAll('/', '_') + .replaceAll('=', '') + }, async onOpen() { - this.requestWebNotificationPermissions() + if (this.webNotificationsGranted === null) { + this.requestWebNotificationPermissions() + .then((granted) => { + if (granted) { + this.setWebPush((hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + this._setPollingInterval(this.pollIntervalBase) + }) + } + }) + } await setCurrentTabAsActive(this.tabId) await this._fetch() @@ -332,9 +434,9 @@ export default { /** * Performs the AJAX request to retrieve the notifications */ - _fetchAfterNotifyPush() { + _fetchAfterNotifyPush(force = false) { this.backgroundFetching = true - if (this.hasNotifyPush && this.tabId !== this.lastTabId) { + if (force || (this.hasNotifyPush && this.tabId !== this.lastTabId)) { console.debug('Deferring notification refresh from browser storage are notify_push event to give the last tab the chance to do it') setTimeout(() => { this._fetch() @@ -457,7 +559,7 @@ export default { return } - if (window.location.protocol === 'http:') { + if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost') { console.debug('Notifications require HTTPS') this.webNotificationsGranted = false return @@ -470,15 +572,15 @@ export default { /** * Check if we can do web notifications */ - async requestWebNotificationPermissions() { + requestWebNotificationPermissions() { if (this.webNotificationsGranted !== null) { - return + return new Promise((resolve, reject) => resolve(this.webNotificationsGranted)) } console.info('Requesting notifications permissions') - window.Notification.requestPermission() + return window.Notification.requestPermission() .then((permissions) => { - this.webNotificationsGranted = permissions === 'granted' + return this.webNotificationsGranted = permissions === 'granted' }) }, From 3560525fc82630eaf5d59ea250106a1f882ce440 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 28 Nov 2025 09:16:49 +0100 Subject: [PATCH 02/35] feat(webpush): Do not fetch on (web) push if notify_push is used To avoid concurrency fetch Signed-off-by: sim --- src/NotificationsApp.vue | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 81205e47d..93016caa8 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -232,9 +232,13 @@ export default { this.hasNotifyPush = true } + // set the polling interval after checking web push status + // if web push may be configured if (this.webNotificationsGranted === true) { - // set the polling interface after checking web push status - this.setWebPush((hasWebPush) => { + // We dont fetch on push if notify_push is enabled, to avoid concurrency fetch. + // We could do the other way: fallback to notify_push only if we don't have + // web push + this.setWebPush(!hasPush, (hasWebPush) => { if (hasWebPush) { console.debug('Has web push, slowing polling to 15 minutes') this.pollIntervalBase = 15 * 60 * 1000 @@ -271,7 +275,7 @@ export default { return registration }) }, - listenForPush(registration) { + listenForPush(registration, syncOnPush) { navigator.serviceWorker.addEventListener('message', (event) => { console.debug("Received from serviceWorker: ", JSON.stringify(event.data)) if (event.data.type == 'push') { @@ -291,9 +295,11 @@ export default { } }) } else { - // force=true: we don't have to check if we're the last tab, - // the serviceworker send the event to a single tab - this._fetchAfterNotifyPush(true) + if (syncOnPush) { + // force=true: we don't have to check if we're the last tab, + // the serviceworker send the event to a single tab + this._fetchAfterNotifyPush(true) + } } } else if (event.data.type == 'pushEndoint') { registerPush(registration) @@ -312,14 +318,16 @@ export default { }) }, /** + * syncOnPush: boolean, if we fetch for notifications on push. Param used to avoid concurrency + * fetch if another mechanism is in place * callback(boolean) if the push notifications has been subscribed (statusCode == 200) */ - setWebPush(callback) { + setWebPush(syncOnPush, callback) { if ('serviceWorker' in navigator) { window.addEventListener('load', () => { this.loadServiceWorker() .then((r) => { - this.listenForPush(r) + this.listenForPush(r, syncOnPush) return this.registerPush(r) }) .then((r) => callback(r.status == 200)) @@ -349,7 +357,7 @@ export default { this.requestWebNotificationPermissions() .then((granted) => { if (granted) { - this.setWebPush((hasWebPush) => { + this.setWebPush(!this.hasNotifyPush, (hasWebPush) => { if (hasWebPush) { console.debug('Has web push, slowing polling to 15 minutes') this.pollIntervalBase = 15 * 60 * 1000 From d9c03cb241f24e619e6fce2b3d7b6827babd1146 Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 28 Nov 2025 11:30:41 +0100 Subject: [PATCH 03/35] feat(webpush): Fix webpush setup after permission granted Move out onload from setWebPush Signed-off-by: sim --- src/NotificationsApp.vue | 38 +++++++++++++++++++------------------- 1 file changed, 19 insertions(+), 19 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 93016caa8..9102fcf8e 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -238,13 +238,15 @@ export default { // We dont fetch on push if notify_push is enabled, to avoid concurrency fetch. // We could do the other way: fallback to notify_push only if we don't have // web push - this.setWebPush(!hasPush, (hasWebPush) => { - if (hasWebPush) { - console.debug('Has web push, slowing polling to 15 minutes') - this.pollIntervalBase = 15 * 60 * 1000 - this.hasNotifyPush = true - } - this._setPollingInterval(this.pollIntervalBase) + window.addEventListener('load', () => { + this.setWebPush(!hasPush, (hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + this._setPollingInterval(this.pollIntervalBase) + }) }) } else { // Set up the background checker @@ -324,18 +326,16 @@ export default { */ setWebPush(syncOnPush, callback) { if ('serviceWorker' in navigator) { - window.addEventListener('load', () => { - this.loadServiceWorker() - .then((r) => { - this.listenForPush(r, syncOnPush) - return this.registerPush(r) - }) - .then((r) => callback(r.status == 200)) - .catch(er => { - console.error(er) - callback(false) - }) - }) + this.loadServiceWorker() + .then((r) => { + this.listenForPush(r, syncOnPush) + return this.registerPush(r) + }) + .then((r) => callback(r.status == 200)) + .catch(er => { + console.error(er) + callback(false) + }) } }, From 26355cb0a074ffdd15125a9db954e8c6520dfa52 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 12:18:34 +0100 Subject: [PATCH 04/35] feat(webpush): Use new appTypes format Signed-off-by: sim --- src/NotificationsApp.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 9102fcf8e..07aa58f77 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -315,7 +315,7 @@ export default { form.append('endpoint', sub.endpoint) form.append('uaPublicKey', this.b64UrlEncode(sub.getKey('p256dh'))) form.append('auth', this.b64UrlEncode(sub.getKey('auth'))) - form.append('apptypes', ['all']) + form.append('appTypes', 'all') return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) }) }, From 6fe15ae78580d7edaadd5ec6bf88273cf477f9fd Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 13:44:05 +0100 Subject: [PATCH 05/35] feat(webpush): Lint Signed-off-by: sim --- lib/Controller/WebController.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/Controller/WebController.php b/lib/Controller/WebController.php index 6b1fda17d..bfab4c31f 100644 --- a/lib/Controller/WebController.php +++ b/lib/Controller/WebController.php @@ -26,9 +26,9 @@ public function __construct( ) { parent::__construct($appName, $request); } - /** - * @return StreamResponse> - */ + /** + * @return StreamResponse> + */ #[PublicPage] #[NoCSRFRequired] #[FrontpageRoute(verb: 'GET', url: '/service-worker.js')] From 3e34f6c9be5f164fbf963c346364b43ffebb4648 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 13:52:35 +0100 Subject: [PATCH 06/35] feat(webpush): Add serviceWorker to OpenApi Signed-off-by: sim --- lib/Controller/WebController.php | 2 +- openapi-full.json | 30 ++++++++++++++++++++++++++++++ openapi.json | 30 ++++++++++++++++++++++++++++++ 3 files changed, 61 insertions(+), 1 deletion(-) diff --git a/lib/Controller/WebController.php b/lib/Controller/WebController.php index bfab4c31f..c80b83403 100644 --- a/lib/Controller/WebController.php +++ b/lib/Controller/WebController.php @@ -27,7 +27,7 @@ public function __construct( parent::__construct($appName, $request); } /** - * @return StreamResponse> + * @return StreamResponse */ #[PublicPage] #[NoCSRFRequired] diff --git a/openapi-full.json b/openapi-full.json index d68228a9c..ff62c9a7a 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -2350,6 +2350,36 @@ } } }, + "/index.php/apps/notifications/service-worker.js": { + "get": { + "operationId": "web-service-worker", + "tags": [ + "web" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } + }, "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/vapid": { "get": { "operationId": "web_push-get-vapid", diff --git a/openapi.json b/openapi.json index f54f69fab..c64346ba4 100644 --- a/openapi.json +++ b/openapi.json @@ -1143,6 +1143,36 @@ } } } + }, + "/index.php/apps/notifications/service-worker.js": { + "get": { + "operationId": "web-service-worker", + "tags": [ + "web" + ], + "security": [ + {}, + { + "bearer_auth": [] + }, + { + "basic_auth": [] + } + ], + "responses": { + "200": { + "description": "", + "content": { + "*/*": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + } + } + } } }, "tags": [] From 8b1ea99e26d41b1aa15cfb10a08768fcb1be22ff Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 14:55:57 +0100 Subject: [PATCH 07/35] feat(webpush): Fix force param Signed-off-by: sim --- src/NotificationsApp.vue | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 07aa58f77..de960bfbe 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -441,10 +441,12 @@ export default { /** * Performs the AJAX request to retrieve the notifications + * + * @param {boolean} force to not check if we are the last tab before fetching events. This is useful with web push, as the service worker sends the event to only one tab, that may not be the last one */ _fetchAfterNotifyPush(force = false) { this.backgroundFetching = true - if (force || (this.hasNotifyPush && this.tabId !== this.lastTabId)) { + if (!force && (this.hasNotifyPush && this.tabId !== this.lastTabId)) { console.debug('Deferring notification refresh from browser storage are notify_push event to give the last tab the chance to do it') setTimeout(() => { this._fetch() From b766876433489c970570d4f95cfefb35596347cd Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 14:56:15 +0100 Subject: [PATCH 08/35] feat(webpush): Lint js Signed-off-by: sim --- public/service-worker.js | 121 +++++++++++++++++++-------------------- src/NotificationsApp.vue | 52 +++++++++-------- 2 files changed, 87 insertions(+), 86 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index ffce312b4..fcd32fe08 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,72 +1,69 @@ -'use strict'; +'use strict' +/** + * @param {string} title shown in UI notification + */ function showBgNotification(title) { - registration.showNotification(title); + self.registration.showNotification(title) } -self.addEventListener('push', function(event) { - console.info("Received push message"); +self.addEventListener('push', function(event) { + console.info('Received push message') - if (event.data) { - const content = event.data.json(); - console.log("Got ", content); - // Send the event to the last focused page only, - // show a notification with the subject if we don't have any - // active tab - event.waitUntil( - self.clients.matchAll() - .then(clientList => { - const client = clientList[0]; - if (client !== undefined) { - console.debug("Sending to client ", client); - client.postMessage({'type':'push','content':content}); - } else if (content.subject) { - console.debug("No valid client to send notif - showing bg notif") - showBgNotification(content.subject); - } else { - console.warn("No valid client to send notif") - } - }) - .catch(err => { - console.error("Couldn't send message: ", err); - }) - ); - } -}); + if (event.data) { + const content = event.data.json() + console.log('Got ', content) + // Send the event to the last focused page only, + // show a notification with the subject if we don't have any + // active tab + event.waitUntil(self.clients.matchAll() + .then((clientList) => { + const client = clientList[0] + if (client !== undefined) { + console.debug('Sending to client ', client) + client.postMessage({ type: 'push', content }) + } else if (content.subject) { + console.debug('No valid client to send notif - showing bg notif') + showBgNotification(content.subject) + } else { + console.warn('No valid client to send notif') + } + }) + .catch((err) => { + console.error("Couldn't send message: ", err) + })) + } +}) self.addEventListener('pushsubscriptionchange', function(event) { - console.log("Push Subscription Change", event); - event.waitUntil( - self.clients.matchAll() - .then(clientList => { - const client = clientList[0]; - if (client !== undefined) { - console.debug("Sending to client ", client); - client.postMessage({'type':'pushEndpoint'}); - } else { - console.warn("No valid client to send notif") - } - }) - .catch(err => { - console.error("Couldn't send message: ", err); - }) - ); -}); + console.log('Push Subscription Change', event) + event.waitUntil(self.clients.matchAll() + .then((clientList) => { + const client = clientList[0] + if (client !== undefined) { + console.debug('Sending to client ', client) + client.postMessage({ type: 'pushEndpoint' }) + } else { + console.warn('No valid client to send notif') + } + }) + .catch((err) => { + console.error("Couldn't send message: ", err) + })) +}) -self.addEventListener('registration', function(event){ - console.log("Registered"); -}); +self.addEventListener('registration', function() { + console.log('Registered') +}) +self.addEventListener('install', function(event) { + console.log('Installed') + // Replace currenctly active serviceWorkers with this one + event.waitUntil(self.skipWaiting()) +}) -self.addEventListener('install', function(event){ - console.log("Installed"); - // Replace currenctly active serviceWorkers with this one - event.waitUntil(self.skipWaiting()); -}); - -self.addEventListener('activate', function(event){ - console.log("Activated") - // Ensure we control the clients - event.waitUntil(self.clients.claim()); -}); - +self.addEventListener('activate', function(event) { + console.log('Activated') + // Ensure we control the clients + event.waitUntil(self.clients.claim()) +}) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index de960bfbe..56daac6c8 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -87,7 +87,7 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { listen } from '@nextcloud/notify_push' -import { generateOcsUrl, imagePath, generateUrl } from '@nextcloud/router' +import { generateOcsUrl, generateUrl, imagePath } from '@nextcloud/router' import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' @@ -232,7 +232,7 @@ export default { this.hasNotifyPush = true } - // set the polling interval after checking web push status + // set the polling interval after checking web push status // if web push may be configured if (this.webNotificationsGranted === true) { // We dont fetch on push if notify_push is enabled, to avoid concurrency fetch. @@ -268,48 +268,50 @@ export default { methods: { t, - loadServiceWorker() { + loadServiceWorker() { return navigator.serviceWorker.register( generateUrl('/apps/notifications/service-worker.js', {}, { noRewrite: true }), - {scope: "/"} + { scope: '/' }, ).then((registration) => { console.info('ServiceWorker registered') return registration }) }, - listenForPush(registration, syncOnPush) { + + listenForPush(registration, syncOnPush) { navigator.serviceWorker.addEventListener('message', (event) => { - console.debug("Received from serviceWorker: ", JSON.stringify(event.data)) - if (event.data.type == 'push') { - const activationToken = event.data.content.activationToken - if (activationToken) { + console.debug('Received from serviceWorker: ', JSON.stringify(event.data)) + if (event.data.type === 'push') { + const activationToken = event.data.content.activationToken + if (activationToken) { const form = new FormData() form.append('activationToken', activationToken) axios.post(generateOcsUrl('apps/notifications/api/v2/webpush/activate'), form) .then((r) => { - if (r.status === 200 || r.status === 202) { - console.debug("Push notifications activated, slowing polling to 15 minutes") + if (r.status === 200 || r.status === 202) { + console.debug('Push notifications activated, slowing polling to 15 minutes') this.pollIntervalBase = 15 * 60 * 1000 this.hasNotifyPush = true this._setPollingInterval(this.pollIntervalBase) } else { - console.warn("An error occured while activating push registration", r) + console.warn('An error occured while activating push registration', r) } }) } else { - if (syncOnPush) { + if (syncOnPush) { // force=true: we don't have to check if we're the last tab, // the serviceworker send the event to a single tab this._fetchAfterNotifyPush(true) } } - } else if (event.data.type == 'pushEndoint') { - registerPush(registration) - .catch(er => console.error(er)) + } else if (event.data.type === 'pushEndoint') { + this.registerPush(registration) + .catch((er) => console.error(er)) } }) }, - registerPush(registration) { + + registerPush(registration) { return registration.pushManager.subscribe().then((sub) => { const form = new FormData() form.append('endpoint', sub.endpoint) @@ -319,20 +321,21 @@ export default { return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) }) }, + /** - * syncOnPush: boolean, if we fetch for notifications on push. Param used to avoid concurrency + * @param {boolean} syncOnPush if we fetch for notifications on push. Param used to avoid concurrency * fetch if another mechanism is in place - * callback(boolean) if the push notifications has been subscribed (statusCode == 200) + * @param {boolean} callback if the push notifications has been subscribed (statusCode == 200) */ - setWebPush(syncOnPush, callback) { + setWebPush(syncOnPush, callback) { if ('serviceWorker' in navigator) { this.loadServiceWorker() .then((r) => { this.listenForPush(r, syncOnPush) return this.registerPush(r) }) - .then((r) => callback(r.status == 200)) - .catch(er => { + .then((r) => callback(r.status === 200)) + .catch((er) => { console.error(er) callback(false) }) @@ -344,9 +347,10 @@ export default { this.userStatus = state.status } }, + b64UrlEncode(inArr) { return new Uint8Array(inArr) - .toBase64() + .toBase64() .replaceAll('+', '-') .replaceAll('/', '_') .replaceAll('=', '') @@ -584,7 +588,7 @@ export default { */ requestWebNotificationPermissions() { if (this.webNotificationsGranted !== null) { - return new Promise((resolve, reject) => resolve(this.webNotificationsGranted)) + return new Promise((resolve) => resolve(this.webNotificationsGranted)) } console.info('Requesting notifications permissions') From 78d4d655f4b7fa21a694f2a5bf1328a547924115 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 17:03:15 +0100 Subject: [PATCH 09/35] feat(webpush): Allow using webpush from web session Signed-off-by: sim --- lib/Controller/WebPushController.php | 80 ++++++++++++------- lib/Push.php | 4 + .../Unit/Controller/WebPushControllerTest.php | 7 +- 3 files changed, 62 insertions(+), 29 deletions(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 8a4e6bfb7..02dffc752 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -27,6 +27,7 @@ use OCP\ISession; use OCP\IUser; use OCP\IUserSession; +use Psr\Log\LoggerInterface; use Symfony\Component\Uid\Uuid; enum NewSubStatus: int { @@ -53,6 +54,7 @@ public function __construct( protected IUserSession $userSession, protected IProvider $tokenProvider, protected Manager $identityProof, + protected LoggerInterface $logger, ) { parent::__construct($appName, $request); } @@ -114,13 +116,15 @@ public function registerWP(string $endpoint, string $uaPublicKey, string $auth, } $tokenId = $this->session->get('token-id'); - if (!\is_int($tokenId)) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); - } - try { - $token = $this->tokenProvider->getTokenById($tokenId); - } catch (InvalidTokenException) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + if (\is_null($tokenId)) { + $token = $this->session; + } else { + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + $this->logger->error('Invalid token exception', ['exception' => $e]); + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } } [$status, $activationToken] = $this->saveSubscription($user, $token, $endpoint, $uaPublicKey, $auth, $appTypes); @@ -158,11 +162,16 @@ public function activateWP(string $activationToken): DataResponse { return new DataResponse([], Http::STATUS_UNAUTHORIZED); } - $tokenId = (int)$this->session->get('token-id'); - try { - $token = $this->tokenProvider->getTokenById($tokenId); - } catch (InvalidTokenException) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + $tokenId = $this->session->get('token-id'); + if (\is_null($tokenId)) { + $token = $this->session; + } else { + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + $this->logger->error('Invalid token exception', ['exception' => $e]); + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } } $status = $this->activateSubscription($user, $token, $activationToken); @@ -193,11 +202,16 @@ public function removeWP(): DataResponse { return new DataResponse([], Http::STATUS_UNAUTHORIZED); } - $tokenId = (int)$this->session->get('token-id'); - try { - $token = $this->tokenProvider->getTokenById($tokenId); - } catch (InvalidTokenException) { - return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + $tokenId = $this->session->get('token-id'); + if (\is_null($tokenId)) { + $token = $this->session; + } else { + try { + $token = $this->tokenProvider->getTokenById($tokenId); + } catch (InvalidTokenException $e) { + $this->logger->error('Invalid token exception', ['exception' => $e]); + return new DataResponse(['message' => 'INVALID_SESSION_TOKEN'], Http::STATUS_BAD_REQUEST); + } } if ($this->deleteSubscription($user, $token)) { @@ -218,12 +232,12 @@ protected function getWPClient(): WebPushClient { * - CREATED if the user didn't have an activated subscription with this endpoint, pubkey and auth * - UPDATED if the subscription has been updated (use to change appTypes) */ - protected function saveSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): array { + protected function saveSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): array { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_webpush') ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token)))) ->andWhere($query->expr()->eq('endpoint', $query->createNamedParameter($endpoint))) ->andWhere($query->expr()->eq('ua_public', $query->createNamedParameter($uaPublicKey))) ->andWhere($query->expr()->eq('auth', $query->createNamedParameter($auth))) @@ -256,12 +270,12 @@ protected function saveSubscription(IUser $user, IToken $token, string $endpoint * - NO_TOKEN if we don't have this token * - NO_SUB if we don't have this subscription */ - protected function activateSubscription(IUser $user, IToken $token, string $activationToken): ActivationSubStatus { + protected function activateSubscription(IUser $user, IToken|ISession $token, string $activationToken): ActivationSubStatus { $query = $this->db->getQueryBuilder(); $query->select('*') ->from('notifications_webpush') ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId()))); + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token)))); $result = $query->executeQuery(); $row = $result->fetch(); $result->closeCursor(); @@ -275,7 +289,7 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti $query->update('notifications_webpush') ->set('activated', $query->createNamedParameter(true)) ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))) + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT))) ->andWhere($query->expr()->eq('activation_token', $query->createNamedParameter($activationToken))); if ($query->executeStatement() !== 0) { @@ -288,12 +302,12 @@ protected function activateSubscription(IUser $user, IToken $token, string $acti * @param string $appTypes comma separated list of types * @return bool If the entry was created */ - protected function insertSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, string $appTypes): bool { + protected function insertSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $activationToken, string $appTypes): bool { $query = $this->db->getQueryBuilder(); $query->insert('notifications_webpush') ->values([ 'uid' => $query->createNamedParameter($user->getUID()), - 'token' => $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT), + 'token' => $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT), 'endpoint' => $query->createNamedParameter($endpoint), 'ua_public' => $query->createNamedParameter($uaPublicKey), 'auth' => $query->createNamedParameter($auth), @@ -307,7 +321,7 @@ protected function insertSubscription(IUser $user, IToken $token, string $endpoi * @param string $appTypes comma separated list of types * @return bool If the entry was updated */ - protected function updateSubscription(IUser $user, IToken $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): bool { + protected function updateSubscription(IUser $user, IToken|ISession $token, string $endpoint, string $uaPublicKey, string $auth, string $appTypes): bool { $query = $this->db->getQueryBuilder(); $query->update('notifications_webpush') ->set('endpoint', $query->createNamedParameter($endpoint)) @@ -315,7 +329,7 @@ protected function updateSubscription(IUser $user, IToken $token, string $endpoi ->set('auth', $query->createNamedParameter($auth)) ->set('app_types', $query->createNamedParameter($appTypes)) ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT))); return $query->executeStatement() !== 0; } @@ -323,12 +337,22 @@ protected function updateSubscription(IUser $user, IToken $token, string $endpoi /** * @return bool If the entry was deleted */ - protected function deleteSubscription(IUser $user, IToken $token): bool { + protected function deleteSubscription(IUser $user, IToken|ISession $token): bool { $query = $this->db->getQueryBuilder(); $query->delete('notifications_webpush') ->where($query->expr()->eq('uid', $query->createNamedParameter($user->getUID()))) - ->andWhere($query->expr()->eq('token', $query->createNamedParameter($token->getId(), IQueryBuilder::PARAM_INT))); + ->andWhere($query->expr()->eq('token', $query->createNamedParameter($this->getId($token), IQueryBuilder::PARAM_INT))); return $query->executeStatement() !== 0; } + + /** + * @return Int tokenId from IToken or ISession, negative in case of Session id, positive otherwise + */ + private function getId(IToken|ISession $token): Int { + return match(true) { + $token instanceof IToken => $token->getId(), + $token instanceof ISession => -1 * $token->getId(), + }; + } } diff --git a/lib/Push.php b/lib/Push.php index 5fbbbdca8..82fea7146 100644 --- a/lib/Push.php +++ b/lib/Push.php @@ -780,6 +780,10 @@ protected function sendNotificationsToProxies(): void { } protected function validateToken(int $tokenId, int $maxAge): TokenValidation { + // This is a web session token + if ($tokenId < 0) { + return TokenValidation::VALID; + } $age = $this->cache->get('t' . $tokenId); if ($age === null) { diff --git a/tests/Unit/Controller/WebPushControllerTest.php b/tests/Unit/Controller/WebPushControllerTest.php index 4433a85f4..c9b520167 100644 --- a/tests/Unit/Controller/WebPushControllerTest.php +++ b/tests/Unit/Controller/WebPushControllerTest.php @@ -26,6 +26,7 @@ use OCP\IUserSession; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\MockObject\MockObject; +use Psr\Log\LoggerInterface; use Test\TestCase; class WebPushControllerTest extends TestCase { @@ -36,6 +37,7 @@ class WebPushControllerTest extends TestCase { protected IUserSession&MockObject $userSession; protected IProvider&MockObject $tokenProvider; protected Manager&MockObject $identityProof; + protected LoggerInterface&MockObject $logger; protected IUser&MockObject $user; protected WebPushController $controller; @@ -53,6 +55,7 @@ protected function setUp(): void { $this->userSession = $this->createMock(IUserSession::class); $this->tokenProvider = $this->createMock(IProvider::class); $this->identityProof = $this->createMock(Manager::class); + $this->logger = $this->createMock(LoggerInterface::class); } protected function getController(array $methods = []): WebPushController|MockObject { @@ -65,7 +68,8 @@ protected function getController(array $methods = []): WebPushController|MockObj $this->session, $this->userSession, $this->tokenProvider, - $this->identityProof + $this->identityProof, + $this->logger, ); } @@ -79,6 +83,7 @@ protected function getController(array $methods = []): WebPushController|MockObj $this->userSession, $this->tokenProvider, $this->identityProof, + $this->logger, ]) ->onlyMethods($methods) ->getMock(); From fab581e4433e1909d19b53272ae979892051b22b Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 17:06:50 +0100 Subject: [PATCH 10/35] feat(webpush): typo Signed-off-by: sim --- src/NotificationsApp.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 56daac6c8..56ac9cf96 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -304,7 +304,7 @@ export default { this._fetchAfterNotifyPush(true) } } - } else if (event.data.type === 'pushEndoint') { + } else if (event.data.type === 'pushEndpoint') { this.registerPush(registration) .catch((er) => console.error(er)) } From ded6dd6d58efe24e471ef7c8423d7c5ba1e846e2 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 17:12:01 +0100 Subject: [PATCH 11/35] feat(webpush): Add push subscription options Signed-off-by: sim --- src/NotificationsApp.vue | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 56ac9cf96..87435e22c 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -312,7 +312,11 @@ export default { }, registerPush(registration) { - return registration.pushManager.subscribe().then((sub) => { + // TODO: add applicationServerKey, Some browsers like Chrome require it + const options = { + userVisibleOnly: true, + }; + return registration.pushManager.subscribe(options).then((sub) => { const form = new FormData() form.append('endpoint', sub.endpoint) form.append('uaPublicKey', this.b64UrlEncode(sub.getKey('p256dh'))) From 85cf5b63c551e1eccba78e778426c6e97dadbee8 Mon Sep 17 00:00:00 2001 From: sim Date: Thu, 12 Feb 2026 17:31:13 +0100 Subject: [PATCH 12/35] feat(webpush): Fix OpenAPI for service worker Signed-off-by: sim --- lib/Controller/WebController.php | 4 ++++ openapi-full.json | 3 ++- openapi.json | 3 ++- 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/lib/Controller/WebController.php b/lib/Controller/WebController.php index c80b83403..31d7da0fd 100644 --- a/lib/Controller/WebController.php +++ b/lib/Controller/WebController.php @@ -27,7 +27,11 @@ public function __construct( parent::__construct($appName, $request); } /** + * Return the service worker with `Service-Worker-Allowed: /` header + * * @return StreamResponse + * + * 200: The service worker */ #[PublicPage] #[NoCSRFRequired] diff --git a/openapi-full.json b/openapi-full.json index ff62c9a7a..d9b995eed 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -2353,6 +2353,7 @@ "/index.php/apps/notifications/service-worker.js": { "get": { "operationId": "web-service-worker", + "summary": "Return the service worker with `Service-Worker-Allowed: /` header", "tags": [ "web" ], @@ -2367,7 +2368,7 @@ ], "responses": { "200": { - "description": "", + "description": "The service worker", "content": { "*/*": { "schema": { diff --git a/openapi.json b/openapi.json index c64346ba4..c0b741484 100644 --- a/openapi.json +++ b/openapi.json @@ -1147,6 +1147,7 @@ "/index.php/apps/notifications/service-worker.js": { "get": { "operationId": "web-service-worker", + "summary": "Return the service worker with `Service-Worker-Allowed: /` header", "tags": [ "web" ], @@ -1161,7 +1162,7 @@ ], "responses": { "200": { - "description": "", + "description": "The service worker", "content": { "*/*": { "schema": { From bf102dbfc5e7a6936eb7f2ecb63eef24c7f8355f Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 13 Feb 2026 10:26:03 +0100 Subject: [PATCH 13/35] feat(webpush): Improve notification content from the background Signed-off-by: sim --- public/service-worker.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index fcd32fe08..f89711365 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,10 +1,14 @@ 'use strict' /** - * @param {string} title shown in UI notification + * @param {string} content received by Nextcloud, splitted to be shown in UI notification */ -function showBgNotification(title) { - self.registration.showNotification(title) +function showBgNotification(content) { + let [title, ...msg] = content.split('\n') + const options = { + body: msg.join('\n'), + } + self.registration.showNotification(title, options) } self.addEventListener('push', function(event) { From 1e69c30eae7d397237d143f2036ce389fa15b06e Mon Sep 17 00:00:00 2001 From: sim Date: Fri, 13 Feb 2026 10:27:33 +0100 Subject: [PATCH 14/35] feat(webpush): Add hack to avoid being unregistered on silent notification Signed-off-by: sim --- public/service-worker.js | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/public/service-worker.js b/public/service-worker.js index f89711365..de4c495ec 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -11,6 +11,29 @@ function showBgNotification(content) { self.registration.showNotification(title, options) } +/** + * For Chrom* and Apple (who followed Chrome) users: + * We need to show a silent notification that we remove to avoid being unregistered, + * because they require userVisibleOnly=true registrations, and so forbid silent notifs. + */ +function silentNotificationHack() { + const tag = (Math.random() + 1).toString(36).substring(2) + const options = { + silent: true, + tag: tag, + } + self.registration.showNotification("Got a background event", options).then(() =>{ + const options = { + tag: tag + } + return self.registration.getNotifications(options) + }).then((notification) => { + if (notification[0]) { + notification[0].close() + } + }); +} + self.addEventListener('push', function(event) { console.info('Received push message') @@ -26,16 +49,21 @@ self.addEventListener('push', function(event) { if (client !== undefined) { console.debug('Sending to client ', client) client.postMessage({ type: 'push', content }) + // Here, the user has an active tab, we don't need to show a notification from the sw } else if (content.subject) { console.debug('No valid client to send notif - showing bg notif') showBgNotification(content.subject) } else { console.warn('No valid client to send notif') + silentNotificationHack() } }) .catch((err) => { console.error("Couldn't send message: ", err) + silentNotificationHack() })) + } else { + silentNotificationHack() } }) From e4ec6ef6cd5c412d7737d6ae29e9b53f059362c7 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 14:33:06 +0100 Subject: [PATCH 15/35] feat(webpush): Subscribe with server VAPID key Signed-off-by: sim --- src/NotificationsApp.vue | 46 +++++++++++++++++++++++++++++----------- 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 87435e22c..ef8490a98 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -311,19 +311,41 @@ export default { }) }, + b64EncodeApplicationServerKey(applicationServerKey) { + return (new Uint8Array(applicationServerKey)) + .toBase64({ alphabet: 'base64url' }) + .replaceAll('=', '') + }, + registerPush(registration) { - // TODO: add applicationServerKey, Some browsers like Chrome require it - const options = { - userVisibleOnly: true, - }; - return registration.pushManager.subscribe(options).then((sub) => { - const form = new FormData() - form.append('endpoint', sub.endpoint) - form.append('uaPublicKey', this.b64UrlEncode(sub.getKey('p256dh'))) - form.append('auth', this.b64UrlEncode(sub.getKey('auth'))) - form.append('appTypes', 'all') - return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) - }) + return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) + .then((r) => r.data.ocs.data.vapid) + .then((vapid) => { + console.log('Server vapid key=' + vapid) + const options = { + applicationServerKey: vapid, + userVisibleOnly: true, + } + return registration.pushManager.getSubscription().then((sub) => { + if (sub !== null && this.b64EncodeApplicationServerKey(sub.options.applicationServerKey) !== vapid) { + console.log('VAPID key changed, unsubscribing first') + return sub.unsubscribe().then(() => { + console.log('Unsubscribed') + return registration.pushManager.subscribe(options) + }) + } else { + return registration.pushManager.subscribe(options) + } + }) + }).then((sub) => { + console.log(sub) + const form = new FormData() + form.append('endpoint', sub.endpoint) + form.append('uaPublicKey', this.b64UrlEncode(sub.getKey('p256dh'))) + form.append('auth', this.b64UrlEncode(sub.getKey('auth'))) + form.append('appTypes', 'all') + return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) + }) }, /** From 3c750da10a0aa5415619ac8e0024bef6daa6ef0d Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 15:34:11 +0100 Subject: [PATCH 16/35] feat(webpush): Fix push for web session with ID=0 Signed-off-by: sim --- lib/Controller/WebPushController.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lib/Controller/WebPushController.php b/lib/Controller/WebPushController.php index 02dffc752..5ae226489 100644 --- a/lib/Controller/WebPushController.php +++ b/lib/Controller/WebPushController.php @@ -352,7 +352,8 @@ protected function deleteSubscription(IUser $user, IToken|ISession $token): bool private function getId(IToken|ISession $token): Int { return match(true) { $token instanceof IToken => $token->getId(), - $token instanceof ISession => -1 * $token->getId(), + // (-id - 1) to avoid session with id = 0 + $token instanceof ISession => -1 - (int)$token->getId(), }; } } From bf8f2f428ae2c237c1a5c63ffd735961cfe517e9 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 15:35:57 +0100 Subject: [PATCH 17/35] feat(webpush): Use single B64 URL-safe function Signed-off-by: sim --- src/NotificationsApp.vue | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index ef8490a98..5fd0c8e51 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -311,12 +311,6 @@ export default { }) }, - b64EncodeApplicationServerKey(applicationServerKey) { - return (new Uint8Array(applicationServerKey)) - .toBase64({ alphabet: 'base64url' }) - .replaceAll('=', '') - }, - registerPush(registration) { return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) .then((r) => r.data.ocs.data.vapid) @@ -327,7 +321,7 @@ export default { userVisibleOnly: true, } return registration.pushManager.getSubscription().then((sub) => { - if (sub !== null && this.b64EncodeApplicationServerKey(sub.options.applicationServerKey) !== vapid) { + if (sub !== null && this.b64UrlEncode(sub.options.applicationServerKey) !== vapid) { console.log('VAPID key changed, unsubscribing first') return sub.unsubscribe().then(() => { console.log('Unsubscribed') @@ -376,9 +370,7 @@ export default { b64UrlEncode(inArr) { return new Uint8Array(inArr) - .toBase64() - .replaceAll('+', '-') - .replaceAll('/', '_') + .toBase64({ alphabet: 'base64url' }) .replaceAll('=', '') }, From 7e12f3646711a573725b6e184165d11b3af24af5 Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 16:50:49 +0100 Subject: [PATCH 18/35] feat(webpush): Register with userVisibleOnly, only if silent push aren't supported Signed-off-by: sim --- src/NotificationsApp.vue | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 5fd0c8e51..a8aa0b0db 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -311,14 +311,14 @@ export default { }) }, - registerPush(registration) { + registerPush(registration, userVisibleOnly = false) { return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) .then((r) => r.data.ocs.data.vapid) .then((vapid) => { console.log('Server vapid key=' + vapid) const options = { applicationServerKey: vapid, - userVisibleOnly: true, + userVisibleOnly, } return registration.pushManager.getSubscription().then((sub) => { if (sub !== null && this.b64UrlEncode(sub.options.applicationServerKey) !== vapid) { @@ -357,7 +357,21 @@ export default { .then((r) => callback(r.status === 200)) .catch((er) => { console.error(er) - callback(false) + if (er.name === 'NotAllowedError') { + // try again with userVisibleOnly = true + // Because Chrome. + console.log('Try to register for with with userVisibleOnly=true') + this.loadServiceWorker().then((r) => { + this.registerPush(r, true) + .then((r) => callback(r.status === 200)) + .catch((er) => { + console.error(er) + callback(false) + }) + }) + } else { + callback(false) + } }) } }, From df0292f2bd7b3c97455c82e3d68ead3169b6436a Mon Sep 17 00:00:00 2001 From: sim Date: Mon, 16 Feb 2026 16:52:06 +0100 Subject: [PATCH 19/35] feat(webpush): Show silent notification only if needed Signed-off-by: sim --- public/service-worker.js | 32 ++++++++++++++------------------ 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/public/service-worker.js b/public/service-worker.js index de4c495ec..a86092aab 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -4,11 +4,11 @@ * @param {string} content received by Nextcloud, splitted to be shown in UI notification */ function showBgNotification(content) { - let [title, ...msg] = content.split('\n') + const [title, ...msg] = content.split('\n') const options = { body: msg.join('\n'), } - self.registration.showNotification(title, options) + return self.registration.showNotification(title, options) } /** @@ -16,22 +16,18 @@ function showBgNotification(content) { * We need to show a silent notification that we remove to avoid being unregistered, * because they require userVisibleOnly=true registrations, and so forbid silent notifs. */ -function silentNotificationHack() { - const tag = (Math.random() + 1).toString(36).substring(2) +function silentNotification() { + const tag = 'silent' const options = { silent: true, - tag: tag, + tag, + body: 'This site has been updated from the background', } - self.registration.showNotification("Got a background event", options).then(() =>{ - const options = { - tag: tag + return self.registration.pushManager.getSubscription().then((sub) => { + if (sub.options.userVisibleOnly) { + return self.registration.showNotification(location.host, options) } - return self.registration.getNotifications(options) - }).then((notification) => { - if (notification[0]) { - notification[0].close() - } - }); + }) } self.addEventListener('push', function(event) { @@ -52,18 +48,18 @@ self.addEventListener('push', function(event) { // Here, the user has an active tab, we don't need to show a notification from the sw } else if (content.subject) { console.debug('No valid client to send notif - showing bg notif') - showBgNotification(content.subject) + return showBgNotification(content.subject) } else { console.warn('No valid client to send notif') - silentNotificationHack() + return silentNotification() } }) .catch((err) => { console.error("Couldn't send message: ", err) - silentNotificationHack() + return silentNotification() })) } else { - silentNotificationHack() + event.waitUntil(silentNotification()) } }) From fa9d6123ba70aff43c00911ee7f032af2ef6afa6 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 17 Feb 2026 13:09:31 +0100 Subject: [PATCH 20/35] fix(webpush): Add worker src domain self Signed-off-by: Joas Schilling --- lib/AppInfo/Application.php | 1 + lib/Listener/CSPListener.php | 42 ++++++++++++++++++++++++++++++++++++ 2 files changed, 43 insertions(+) create mode 100644 lib/Listener/CSPListener.php diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 7c5c313f9..2fcdb890e 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -41,6 +41,7 @@ public function register(IRegistrationContext $context): void { $context->registerNotifierService(AdminNotifications::class); + $context->registerEventListener(AddContentSecurityPolicyEvent::class, CSPListener::class); $context->registerEventListener(AddMissingIndicesEvent::class, AddMissingIndicesListener::class); $context->registerEventListener(UserDeletedEvent::class, UserDeletedListener::class); $context->registerEventListener(BeforeTemplateRenderedEvent::class, BeforeTemplateRenderedListener::class); diff --git a/lib/Listener/CSPListener.php b/lib/Listener/CSPListener.php new file mode 100644 index 000000000..1c1e06503 --- /dev/null +++ b/lib/Listener/CSPListener.php @@ -0,0 +1,42 @@ + + */ +readonly class CSPListener implements IEventListener { + public function __construct( + protected IUserSession $userSession, + ) { + } + + #[\Override] + public function handle(Event $event): void { + if (!($event instanceof AddContentSecurityPolicyEvent)) { + return; + } + + $user = $this->userSession->getUser(); + if (!$user instanceof IUser) { + return; + } + + $csp = new ContentSecurityPolicy(); + $csp->addAllowedWorkerSrcDomain("'self'"); + $event->addPolicy($csp); + } +} From c270710eb9ca0180bf2e0b2636d549b00196521f Mon Sep 17 00:00:00 2001 From: S1m <31284753+p1gp1g@users.noreply.github.com> Date: Tue, 17 Feb 2026 13:13:59 +0100 Subject: [PATCH 21/35] Update lib/Controller/WebController.php Co-authored-by: Joas Schilling <213943+nickvergessen@users.noreply.github.com> Signed-off-by: S1m <31284753+p1gp1g@users.noreply.github.com> --- lib/Controller/WebController.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/Controller/WebController.php b/lib/Controller/WebController.php index 31d7da0fd..de9b7cfcd 100644 --- a/lib/Controller/WebController.php +++ b/lib/Controller/WebController.php @@ -18,7 +18,7 @@ use OCP\AppFramework\Http\StreamResponse; use OCP\IRequest; -#[OpenAPI(scope: OpenAPI::SCOPE_DEFAULT)] +#[OpenAPI(scope: OpenAPI::SCOPE_IGNORE)] class WebController extends Controller { public function __construct( string $appName, From 993c3048ee6c74b6e97439d3daa7cbe6d3dc5bcc Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 13:19:12 +0100 Subject: [PATCH 22/35] feat(webpush): Call callback(false) if browser doesn't support serviceWorker Signed-off-by: sim --- src/NotificationsApp.vue | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index a8aa0b0db..66b4b93b7 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -102,6 +102,8 @@ import { } from './services/notificationsService.js' import { createWebNotification } from './services/webNotificationsService.js' +window.axios = axios + const sessionKeepAlive = loadState('core', 'config', { session_keepalive: true }).session_keepalive const hasThrottledPushNotifications = loadState('notifications', 'throttled_push_notifications') @@ -373,6 +375,8 @@ export default { callback(false) } }) + } else { + callback(false) } }, From 3f733f103f82a275589a87ab6478bfff24ed465f Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 13:54:59 +0100 Subject: [PATCH 23/35] feat(webpush): Use full name for arguments Signed-off-by: sim --- src/NotificationsApp.vue | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 66b4b93b7..632a02469 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -289,14 +289,14 @@ export default { const form = new FormData() form.append('activationToken', activationToken) axios.post(generateOcsUrl('apps/notifications/api/v2/webpush/activate'), form) - .then((r) => { - if (r.status === 200 || r.status === 202) { + .then((response) => { + if (response.status === 200 || response.status === 202) { console.debug('Push notifications activated, slowing polling to 15 minutes') this.pollIntervalBase = 15 * 60 * 1000 this.hasNotifyPush = true this._setPollingInterval(this.pollIntervalBase) } else { - console.warn('An error occured while activating push registration', r) + console.warn('An error occured while activating push registration', response) } }) } else { @@ -315,7 +315,7 @@ export default { registerPush(registration, userVisibleOnly = false) { return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) - .then((r) => r.data.ocs.data.vapid) + .then((response) => response.data.ocs.data.vapid) .then((vapid) => { console.log('Server vapid key=' + vapid) const options = { @@ -352,20 +352,20 @@ export default { setWebPush(syncOnPush, callback) { if ('serviceWorker' in navigator) { this.loadServiceWorker() - .then((r) => { - this.listenForPush(r, syncOnPush) - return this.registerPush(r) + .then((registration) => { + this.listenForPush(registration, syncOnPush) + return this.registerPush(registration) }) - .then((r) => callback(r.status === 200)) + .then((response) => callback(response.status === 200)) .catch((er) => { console.error(er) if (er.name === 'NotAllowedError') { // try again with userVisibleOnly = true // Because Chrome. console.log('Try to register for with with userVisibleOnly=true') - this.loadServiceWorker().then((r) => { - this.registerPush(r, true) - .then((r) => callback(r.status === 200)) + this.loadServiceWorker().then((registration) => { + this.registerPush(registration, true) + .then((response) => callback(response.status === 200)) .catch((er) => { console.error(er) callback(false) From 034702469b134925337c17b15afac7fde8f2839b Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 13:59:33 +0100 Subject: [PATCH 24/35] feat(webpush): Keep requestWebNotificationPermissions async Signed-off-by: sim --- src/NotificationsApp.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 632a02469..a06cb5321 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -622,7 +622,7 @@ export default { /** * Check if we can do web notifications */ - requestWebNotificationPermissions() { + async requestWebNotificationPermissions() { if (this.webNotificationsGranted !== null) { return new Promise((resolve) => resolve(this.webNotificationsGranted)) } From 730f420ab045e70496dbb645ff7daa6a66523758 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 15:01:52 +0100 Subject: [PATCH 25/35] feat(webpush): Move web push function to dedicated service file Signed-off-by: sim --- src/NotificationsApp.vue | 153 ++++++--------------------------- src/services/webPushService.js | 134 +++++++++++++++++++++++++++++ 2 files changed, 160 insertions(+), 127 deletions(-) create mode 100644 src/services/webPushService.js diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index a06cb5321..b34507972 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -87,7 +87,7 @@ import { emit, subscribe, unsubscribe } from '@nextcloud/event-bus' import { loadState } from '@nextcloud/initial-state' import { t } from '@nextcloud/l10n' import { listen } from '@nextcloud/notify_push' -import { generateOcsUrl, generateUrl, imagePath } from '@nextcloud/router' +import { generateOcsUrl, imagePath } from '@nextcloud/router' import NcButton from '@nextcloud/vue/components/NcButton' import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent' import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu' @@ -101,6 +101,9 @@ import { setCurrentTabAsActive, } from './services/notificationsService.js' import { createWebNotification } from './services/webNotificationsService.js' +import { + setWebPush, +} from './services/webPushService.js' window.axios = axios @@ -241,14 +244,26 @@ export default { // We could do the other way: fallback to notify_push only if we don't have // web push window.addEventListener('load', () => { - this.setWebPush(!hasPush, (hasWebPush) => { - if (hasWebPush) { - console.debug('Has web push, slowing polling to 15 minutes') - this.pollIntervalBase = 15 * 60 * 1000 - this.hasNotifyPush = true - } - this._setPollingInterval(this.pollIntervalBase) - }) + setWebPush( + // onActivated= + (hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + // Set polling interval for the default or new value + this._setPollingInterval(this.pollIntervalBase) + }, + // onPush= + () => { + if (!hasPush) { + this._fetchAfterNotifyPush(true) + } else { + console.debug('Has notify_push, no need to fetch from web push.') + } + }, + ) }) } else { // Set up the background checker @@ -270,134 +285,18 @@ export default { methods: { t, - loadServiceWorker() { - return navigator.serviceWorker.register( - generateUrl('/apps/notifications/service-worker.js', {}, { noRewrite: true }), - { scope: '/' }, - ).then((registration) => { - console.info('ServiceWorker registered') - return registration - }) - }, - - listenForPush(registration, syncOnPush) { - navigator.serviceWorker.addEventListener('message', (event) => { - console.debug('Received from serviceWorker: ', JSON.stringify(event.data)) - if (event.data.type === 'push') { - const activationToken = event.data.content.activationToken - if (activationToken) { - const form = new FormData() - form.append('activationToken', activationToken) - axios.post(generateOcsUrl('apps/notifications/api/v2/webpush/activate'), form) - .then((response) => { - if (response.status === 200 || response.status === 202) { - console.debug('Push notifications activated, slowing polling to 15 minutes') - this.pollIntervalBase = 15 * 60 * 1000 - this.hasNotifyPush = true - this._setPollingInterval(this.pollIntervalBase) - } else { - console.warn('An error occured while activating push registration', response) - } - }) - } else { - if (syncOnPush) { - // force=true: we don't have to check if we're the last tab, - // the serviceworker send the event to a single tab - this._fetchAfterNotifyPush(true) - } - } - } else if (event.data.type === 'pushEndpoint') { - this.registerPush(registration) - .catch((er) => console.error(er)) - } - }) - }, - - registerPush(registration, userVisibleOnly = false) { - return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) - .then((response) => response.data.ocs.data.vapid) - .then((vapid) => { - console.log('Server vapid key=' + vapid) - const options = { - applicationServerKey: vapid, - userVisibleOnly, - } - return registration.pushManager.getSubscription().then((sub) => { - if (sub !== null && this.b64UrlEncode(sub.options.applicationServerKey) !== vapid) { - console.log('VAPID key changed, unsubscribing first') - return sub.unsubscribe().then(() => { - console.log('Unsubscribed') - return registration.pushManager.subscribe(options) - }) - } else { - return registration.pushManager.subscribe(options) - } - }) - }).then((sub) => { - console.log(sub) - const form = new FormData() - form.append('endpoint', sub.endpoint) - form.append('uaPublicKey', this.b64UrlEncode(sub.getKey('p256dh'))) - form.append('auth', this.b64UrlEncode(sub.getKey('auth'))) - form.append('appTypes', 'all') - return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) - }) - }, - - /** - * @param {boolean} syncOnPush if we fetch for notifications on push. Param used to avoid concurrency - * fetch if another mechanism is in place - * @param {boolean} callback if the push notifications has been subscribed (statusCode == 200) - */ - setWebPush(syncOnPush, callback) { - if ('serviceWorker' in navigator) { - this.loadServiceWorker() - .then((registration) => { - this.listenForPush(registration, syncOnPush) - return this.registerPush(registration) - }) - .then((response) => callback(response.status === 200)) - .catch((er) => { - console.error(er) - if (er.name === 'NotAllowedError') { - // try again with userVisibleOnly = true - // Because Chrome. - console.log('Try to register for with with userVisibleOnly=true') - this.loadServiceWorker().then((registration) => { - this.registerPush(registration, true) - .then((response) => callback(response.status === 200)) - .catch((er) => { - console.error(er) - callback(false) - }) - }) - } else { - callback(false) - } - }) - } else { - callback(false) - } - }, - userStatusUpdated(state) { if (getCurrentUser().uid === state.userId) { this.userStatus = state.status } }, - b64UrlEncode(inArr) { - return new Uint8Array(inArr) - .toBase64({ alphabet: 'base64url' }) - .replaceAll('=', '') - }, - async onOpen() { if (this.webNotificationsGranted === null) { this.requestWebNotificationPermissions() .then((granted) => { if (granted) { - this.setWebPush(!this.hasNotifyPush, (hasWebPush) => { + setWebPush(!this.hasNotifyPush, (hasWebPush) => { if (hasWebPush) { console.debug('Has web push, slowing polling to 15 minutes') this.pollIntervalBase = 15 * 60 * 1000 @@ -492,7 +391,7 @@ export default { this._fetch() }, 5000) } else { - console.debug('Refreshing notifications are notify_push event') + console.debug('Refreshing notifications following push event') this._fetch() } }, diff --git a/src/services/webPushService.js b/src/services/webPushService.js new file mode 100644 index 000000000..3efd022d5 --- /dev/null +++ b/src/services/webPushService.js @@ -0,0 +1,134 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import axios from '@nextcloud/axios' +import { generateOcsUrl, generateUrl } from '@nextcloud/router' + +/** + * Load service worker + */ +function loadServiceWorker() { + return navigator.serviceWorker.register( + generateUrl('/apps/notifications/service-worker.js', {}, { noRewrite: true }), + { scope: '/' }, + ).then((registration) => { + console.info('ServiceWorker registered') + return registration + }) +} +/** + * Set Push notification Listener + * + * @param {ServiceWorkerRegistration} registration current SW registration + * @param {function(boolean)} onActivated called when we send activation token + * @param {function()} onPush called when we receive a push notification + */ +function listenForPush(registration, onActivated, onPush) { + navigator.serviceWorker.addEventListener('message', (event) => { + console.debug('Received from serviceWorker: ', JSON.stringify(event.data)) + if (event.data.type === 'push') { + const activationToken = event.data.content.activationToken + if (activationToken) { + const form = new FormData() + form.append('activationToken', activationToken) + axios.post(generateOcsUrl('apps/notifications/api/v2/webpush/activate'), form) + .then((response) => { + onActivated(response.status === 200 || response.status === 202) + }) + } else { + onPush() + } + } else if (event.data.type === 'pushEndpoint') { + registerPush(registration) + .catch((er) => console.error(er)) + } + }) +} + +/** + * + * @param {ServiceWorkerRegistration} registration current SW registration + * @param {boolean} userVisibleOnly if push subscription should set `userVisibleOnly` options (for Chrome) + */ +function registerPush(registration, userVisibleOnly = false) { + return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) + .then((response) => response.data.ocs.data.vapid) + .then((vapid) => { + console.log('Server vapid key=' + vapid) + const options = { + applicationServerKey: vapid, + userVisibleOnly, + } + return registration.pushManager.getSubscription().then((sub) => { + if (sub !== null && b64UrlEncode(sub.options.applicationServerKey) !== vapid) { + console.log('VAPID key changed, unsubscribing first') + return sub.unsubscribe().then(() => { + console.log('Unsubscribed') + return registration.pushManager.subscribe(options) + }) + } else { + return registration.pushManager.subscribe(options) + } + }) + }).then((sub) => { + console.log(sub) + const form = new FormData() + form.append('endpoint', sub.endpoint) + form.append('uaPublicKey', b64UrlEncode(sub.getKey('p256dh'))) + form.append('auth', b64UrlEncode(sub.getKey('auth'))) + form.append('appTypes', 'all') + return axios.post(generateOcsUrl('apps/notifications/api/v2/webpush'), form) + }) +} + +/** + * @param {function(reload)} onActivated arg=true if the push notifications has been subscribed (statusCode == 200) + * @param {function()} onPush run everytime we receive a push notification and we need to sync with the server + */ +function setWebPush(onActivated, onPush) { + if ('serviceWorker' in navigator) { + loadServiceWorker() + .then((registration) => { + listenForPush(registration, onActivated, onPush) + return registerPush(registration) + }) + .catch((er) => { + console.error(er) + if (er.name === 'NotAllowedError') { + // try again with userVisibleOnly = true + // Because Chrome. + console.log('Try to register for with with userVisibleOnly=true') + loadServiceWorker().then((registration) => { + registerPush(registration, true) + .catch((er) => { + console.error(er) + onActivated(false) + }) + }) + } else { + onActivated(false) + } + }) + } else { + onActivated(false) + } +} + +/** + * + * @param {Array} inArr input to encode + */ +function b64UrlEncode(inArr) { + return new Uint8Array(inArr) + .toBase64({ alphabet: 'base64url' }) + .replaceAll('=', '') +} + +export { + b64UrlEncode, + listenForPush, + loadServiceWorker, + registerPush, + setWebPush, +} From 9017d4d3ee353bc607aa290c438f20b513552a31 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 17 Feb 2026 14:36:21 +0100 Subject: [PATCH 26/35] fix: Add missing class imports Signed-off-by: Joas Schilling --- lib/AppInfo/Application.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/AppInfo/Application.php b/lib/AppInfo/Application.php index 2fcdb890e..1186c17a9 100644 --- a/lib/AppInfo/Application.php +++ b/lib/AppInfo/Application.php @@ -13,6 +13,7 @@ use OCA\Notifications\Capabilities; use OCA\Notifications\Listener\AddMissingIndicesListener; use OCA\Notifications\Listener\BeforeTemplateRenderedListener; +use OCA\Notifications\Listener\CSPListener; use OCA\Notifications\Listener\UserCreatedListener; use OCA\Notifications\Listener\UserDeletedListener; use OCA\Notifications\Notifier\AdminNotifications; @@ -23,6 +24,7 @@ use OCP\AppFramework\Http\Events\BeforeTemplateRenderedEvent; use OCP\DB\Events\AddMissingIndicesEvent; use OCP\Notification\IManager; +use OCP\Security\CSP\AddContentSecurityPolicyEvent; use OCP\User\Events\UserCreatedEvent; use OCP\User\Events\UserDeletedEvent; From 112995731d90f98dfc87a25836f87b355bb58443 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 15:03:48 +0100 Subject: [PATCH 27/35] feat(webpush): Use better secure context check Signed-off-by: sim --- src/NotificationsApp.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index b34507972..726d381bd 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -508,7 +508,7 @@ export default { return } - if (window.location.protocol === 'http:' && window.location.hostname !== 'localhost') { + if (!window.isSecureContext) { console.debug('Notifications require HTTPS') this.webNotificationsGranted = false return From fa870cbbbeb282920afc6b8784f7427ed4ed6721 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 15:04:42 +0100 Subject: [PATCH 28/35] feat(webpush): Lint debugging line Signed-off-by: sim --- src/NotificationsApp.vue | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 726d381bd..2efc83e57 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -105,8 +105,6 @@ import { setWebPush, } from './services/webPushService.js' -window.axios = axios - const sessionKeepAlive = loadState('core', 'config', { session_keepalive: true }).session_keepalive const hasThrottledPushNotifications = loadState('notifications', 'throttled_push_notifications') From 11109c743f21d7ea3d95506a3913b0ed85277c54 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 15:22:28 +0100 Subject: [PATCH 29/35] feat(webpush): Always catch when browser need userVisibleOnly Signed-off-by: sim --- src/services/webPushService.js | 38 +++++++++++++++++++--------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/services/webPushService.js b/src/services/webPushService.js index 3efd022d5..8f70b2a82 100644 --- a/src/services/webPushService.js +++ b/src/services/webPushService.js @@ -49,16 +49,15 @@ function listenForPush(registration, onActivated, onPush) { /** * * @param {ServiceWorkerRegistration} registration current SW registration - * @param {boolean} userVisibleOnly if push subscription should set `userVisibleOnly` options (for Chrome) */ -function registerPush(registration, userVisibleOnly = false) { +function registerPush(registration) { return axios.get(generateOcsUrl('apps/notifications/api/v2/webpush/vapid')) .then((response) => response.data.ocs.data.vapid) .then((vapid) => { console.log('Server vapid key=' + vapid) const options = { applicationServerKey: vapid, - userVisibleOnly, + userVisibleOnly: false, } return registration.pushManager.getSubscription().then((sub) => { if (sub !== null && b64UrlEncode(sub.options.applicationServerKey) !== vapid) { @@ -66,9 +65,27 @@ function registerPush(registration, userVisibleOnly = false) { return sub.unsubscribe().then(() => { console.log('Unsubscribed') return registration.pushManager.subscribe(options) + .catch((er) => { + if (er.name === 'NotAllowedError') { + // if push subscription should set `userVisibleOnly` options (for Chrome) + console.log('Browser probably require `userVisibleOnly=true`') + return registration.pushManager.subscribe({ ...options, userVisibleOnly: true }) + } else { + throw er + } + }) }) } else { return registration.pushManager.subscribe(options) + .catch((er) => { + if (er.name === 'NotAllowedError') { + // if push subscription should set `userVisibleOnly` options (for Chrome) + console.log('Browser probably require `userVisibleOnly=true`') + return registration.pushManager.subscribe({ ...options, userVisibleOnly: true }) + } else { + throw er + } + }) } }) }).then((sub) => { @@ -95,20 +112,7 @@ function setWebPush(onActivated, onPush) { }) .catch((er) => { console.error(er) - if (er.name === 'NotAllowedError') { - // try again with userVisibleOnly = true - // Because Chrome. - console.log('Try to register for with with userVisibleOnly=true') - loadServiceWorker().then((registration) => { - registerPush(registration, true) - .catch((er) => { - console.error(er) - onActivated(false) - }) - }) - } else { - onActivated(false) - } + onActivated(false) }) } else { onActivated(false) From 17d09f60deb27156c5c6570d10248a6348a85ee2 Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 15:48:47 +0100 Subject: [PATCH 30/35] feat(webpush): Avoid redundant code to set web push Signed-off-by: sim --- src/NotificationsApp.vue | 53 +++++++++++++++++++--------------------- 1 file changed, 25 insertions(+), 28 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index 2efc83e57..e0e00c4e7 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -242,26 +242,7 @@ export default { // We could do the other way: fallback to notify_push only if we don't have // web push window.addEventListener('load', () => { - setWebPush( - // onActivated= - (hasWebPush) => { - if (hasWebPush) { - console.debug('Has web push, slowing polling to 15 minutes') - this.pollIntervalBase = 15 * 60 * 1000 - this.hasNotifyPush = true - } - // Set polling interval for the default or new value - this._setPollingInterval(this.pollIntervalBase) - }, - // onPush= - () => { - if (!hasPush) { - this._fetchAfterNotifyPush(true) - } else { - console.debug('Has notify_push, no need to fetch from web push.') - } - }, - ) + this._setWebPush() }) } else { // Set up the background checker @@ -289,19 +270,35 @@ export default { } }, + async _setWebPush() { + return setWebPush( + // onActivated= + (hasWebPush) => { + if (hasWebPush) { + console.debug('Has web push, slowing polling to 15 minutes') + this.pollIntervalBase = 15 * 60 * 1000 + this.hasNotifyPush = true + } + // Set polling interval for the default or new value + this._setPollingInterval(this.pollIntervalBase) + }, + // onPush= + () => { + if (!hasPush) { + this._fetchAfterNotifyPush(true) + } else { + console.debug('Has notify_push, no need to fetch from web push.') + } + }, + ) + }, + async onOpen() { if (this.webNotificationsGranted === null) { this.requestWebNotificationPermissions() .then((granted) => { if (granted) { - setWebPush(!this.hasNotifyPush, (hasWebPush) => { - if (hasWebPush) { - console.debug('Has web push, slowing polling to 15 minutes') - this.pollIntervalBase = 15 * 60 * 1000 - this.hasNotifyPush = true - } - this._setPollingInterval(this.pollIntervalBase) - }) + this._setWebPutsh() } }) } From b962973a389f36332cf6963fc8b7446f6882986b Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 15:49:12 +0100 Subject: [PATCH 31/35] feat(webpush): Use web push dedicated function to fetch notifications Signed-off-by: sim --- src/NotificationsApp.vue | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index e0e00c4e7..d54ce0183 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -285,7 +285,7 @@ export default { // onPush= () => { if (!hasPush) { - this._fetchAfterNotifyPush(true) + this._fetchAfterWebPush() } else { console.debug('Has notify_push, no need to fetch from web push.') } @@ -375,31 +375,42 @@ export default { /** * Performs the AJAX request to retrieve the notifications - * - * @param {boolean} force to not check if we are the last tab before fetching events. This is useful with web push, as the service worker sends the event to only one tab, that may not be the last one */ - _fetchAfterNotifyPush(force = false) { + _fetchAfterNotifyPush() { this.backgroundFetching = true - if (!force && (this.hasNotifyPush && this.tabId !== this.lastTabId)) { + if (this.hasNotifyPush && this.tabId !== this.lastTabId) { console.debug('Deferring notification refresh from browser storage are notify_push event to give the last tab the chance to do it') setTimeout(() => { this._fetch() }, 5000) } else { - console.debug('Refreshing notifications following push event') + console.debug('Refreshing notifications are notify_push event') this._fetch() } }, + /** + * Performs the AJAX request to retrieve the notifications + * + * Unlike with notify_push, we don't have to check if we are the last tab before + * fetching events. The service worker sends the event to only one tab, that may + * not be the last one + */ + _fetchAfterWebPush() { + this.backgroundFetching = true + console.debug('Refreshing notifications following web push event') + this._fetch(true) + }, + /** * Performs the AJAX request to retrieve the notifications */ - async _fetch() { + async _fetch(force = false) { if (this.notifications.length && this.notifications[0].notificationId > this.webNotificationsThresholdId) { this.webNotificationsThresholdId = this.notifications[0].notificationId } - const response = await getNotificationsData(this.tabId, this.lastETag, !this.backgroundFetching, this.hasNotifyPush) + const response = await getNotificationsData(this.tabId, this.lastETag, force || !this.backgroundFetching, this.hasNotifyPush) if (response.status === 204) { // 204 No Content - Intercept when no notifiers are there. From 8c22c5e12be0de6bd0900133842adc2b407efbaf Mon Sep 17 00:00:00 2001 From: sim Date: Tue, 17 Feb 2026 15:54:49 +0100 Subject: [PATCH 32/35] feat(webpush): Add SPDX header Signed-off-by: sim --- public/service-worker.js | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/public/service-worker.js b/public/service-worker.js index a86092aab..e8420f06c 100644 --- a/public/service-worker.js +++ b/public/service-worker.js @@ -1,3 +1,8 @@ +/** + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ + 'use strict' /** From d380839a7dfab59a2af7fc698beb477980d3dd06 Mon Sep 17 00:00:00 2001 From: S1m <31284753+p1gp1g@users.noreply.github.com> Date: Tue, 17 Feb 2026 17:15:51 +0100 Subject: [PATCH 33/35] Fix hasNotifyPush check Co-authored-by: Maksim Sukharev Signed-off-by: S1m <31284753+p1gp1g@users.noreply.github.com> --- src/NotificationsApp.vue | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/NotificationsApp.vue b/src/NotificationsApp.vue index d54ce0183..f133fd0ec 100644 --- a/src/NotificationsApp.vue +++ b/src/NotificationsApp.vue @@ -284,7 +284,7 @@ export default { }, // onPush= () => { - if (!hasPush) { + if (!this.hasNotifyPush) { this._fetchAfterWebPush() } else { console.debug('Has notify_push, no need to fetch from web push.') From 946a5e7761ef6e343e1d2725a95bd42c42f27708 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 24 Feb 2026 10:28:59 +0100 Subject: [PATCH 34/35] chore(assets): Recompile assets Signed-off-by: Joas Schilling --- openapi-full.json | 31 ------------------------------- openapi.json | 31 ------------------------------- 2 files changed, 62 deletions(-) diff --git a/openapi-full.json b/openapi-full.json index d9b995eed..d68228a9c 100644 --- a/openapi-full.json +++ b/openapi-full.json @@ -2350,37 +2350,6 @@ } } }, - "/index.php/apps/notifications/service-worker.js": { - "get": { - "operationId": "web-service-worker", - "summary": "Return the service worker with `Service-Worker-Allowed: /` header", - "tags": [ - "web" - ], - "security": [ - {}, - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "responses": { - "200": { - "description": "The service worker", - "content": { - "*/*": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } - }, "/ocs/v2.php/apps/notifications/api/{apiVersion}/webpush/vapid": { "get": { "operationId": "web_push-get-vapid", diff --git a/openapi.json b/openapi.json index c0b741484..f54f69fab 100644 --- a/openapi.json +++ b/openapi.json @@ -1143,37 +1143,6 @@ } } } - }, - "/index.php/apps/notifications/service-worker.js": { - "get": { - "operationId": "web-service-worker", - "summary": "Return the service worker with `Service-Worker-Allowed: /` header", - "tags": [ - "web" - ], - "security": [ - {}, - { - "bearer_auth": [] - }, - { - "basic_auth": [] - } - ], - "responses": { - "200": { - "description": "The service worker", - "content": { - "*/*": { - "schema": { - "type": "string", - "format": "binary" - } - } - } - } - } - } } }, "tags": [] From a42e9dbf5ff97b523cd90f46b2fa087b6bedd5a3 Mon Sep 17 00:00:00 2001 From: Joas Schilling Date: Tue, 24 Feb 2026 10:40:12 +0100 Subject: [PATCH 35/35] fix(webpush): Fix psalm in WebController Signed-off-by: Joas Schilling --- lib/Controller/WebController.php | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/lib/Controller/WebController.php b/lib/Controller/WebController.php index de9b7cfcd..d73560632 100644 --- a/lib/Controller/WebController.php +++ b/lib/Controller/WebController.php @@ -10,6 +10,7 @@ namespace OCA\Notifications\Controller; use OCP\AppFramework\Controller; +use OCP\AppFramework\Http; use OCP\AppFramework\Http\Attribute\FrontpageRoute; use OCP\AppFramework\Http\Attribute\NoCSRFRequired; use OCP\AppFramework\Http\Attribute\OpenAPI; @@ -29,7 +30,7 @@ public function __construct( /** * Return the service worker with `Service-Worker-Allowed: /` header * - * @return StreamResponse + * @return StreamResponse * * 200: The service worker */ @@ -37,11 +38,13 @@ public function __construct( #[NoCSRFRequired] #[FrontpageRoute(verb: 'GET', url: '/service-worker.js')] public function serviceWorker(): StreamResponse { - $response = new StreamResponse(__DIR__ . '/../../service-worker.js'); - $response->setHeaders([ - 'Content-Type' => 'application/javascript', - 'Service-Worker-Allowed' => '/' - ]); + $response = new StreamResponse( + __DIR__ . '/../../service-worker.js', + headers: [ + 'Content-Type' => 'application/javascript', + 'Service-Worker-Allowed' => '/' + ] + ); $policy = new ContentSecurityPolicy(); $policy->addAllowedWorkerSrcDomain("'self'"); $policy->addAllowedScriptDomain("'self'");