From d89bcafa61d3b656d1d3745c150205b7c777775b Mon Sep 17 00:00:00 2001 From: Adrian Fish Date: Mon, 18 Sep 2023 15:24:17 +0100 Subject: [PATCH 01/12] SAK-49287 Notifications. Improved push permissions workflow https://sakaiproject.atlassian.net/browse/SAK-49287 --- .../messaging/api/MessageListener.java | 24 -- .../messaging/api/UserMessagingService.java | 4 - .../messaging/api/model/UserNotification.java | 3 + .../impl/UserMessagingServiceImpl.java | 26 +- .../src/sass/modules/tool/portal/_portal.scss | 14 +- library/src/webapp/js/sakai-message-broker.js | 289 ++++++++++-------- pom.xml | 1 + .../entityprovider/PortalEntityProvider.java | 14 +- .../webapp/vm/morpheus/includeAccountNav.vm | 8 +- .../webapp/vm/morpheus/includeBodyScripts.vm | 11 + .../webapp/vm/morpheus/includeMobileFooter.vm | 6 +- .../webapp/vm/morpheus/includeStandardHead.vm | 11 +- serviceworker/pom.xml | 45 +++ serviceworker/src/main/webapp/WEB-INF/web.xml | 8 + serviceworker/src/main/webapp/favicon.ico | Bin 0 -> 15086 bytes .../src/main/webapp}/sakai-service-worker.js | 32 +- .../webapi/controllers/EventsController.java | 2 +- .../bundle/sakai-notifications.properties | 9 +- .../main/frontend/bundle-entry-points/base.js | 3 + .../tool/src/main/frontend/package-lock.json | 53 +++- .../tool/src/main/frontend/package.json | 6 +- .../test/sakai-date-picker.test.js | 4 +- .../packages/sakai-notifications/package.json | 3 +- .../src/SakaiNotifications.js | 190 ++++++------ .../packages/sakai-notifications/test/data.js | 28 +- .../test/sakai-notifications.test.js | 43 ++- 26 files changed, 535 insertions(+), 302 deletions(-) delete mode 100644 kernel/api/src/main/java/org/sakaiproject/messaging/api/MessageListener.java create mode 100644 serviceworker/pom.xml create mode 100644 serviceworker/src/main/webapp/WEB-INF/web.xml create mode 100644 serviceworker/src/main/webapp/favicon.ico rename {webapi/src/main/resources/static => serviceworker/src/main/webapp}/sakai-service-worker.js (54%) diff --git a/kernel/api/src/main/java/org/sakaiproject/messaging/api/MessageListener.java b/kernel/api/src/main/java/org/sakaiproject/messaging/api/MessageListener.java deleted file mode 100644 index dfa77ac81e6a..000000000000 --- a/kernel/api/src/main/java/org/sakaiproject/messaging/api/MessageListener.java +++ /dev/null @@ -1,24 +0,0 @@ -/** - * Copyright (c) 2003-2021 The Apereo Foundation - * - * Licensed under the Educational Community License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://opensource.org/licenses/ecl2 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package org.sakaiproject.messaging.api; - -import org.sakaiproject.messaging.api.model.UserNotification; - -public interface MessageListener { - - public void read(UserNotification un); -} - diff --git a/kernel/api/src/main/java/org/sakaiproject/messaging/api/UserMessagingService.java b/kernel/api/src/main/java/org/sakaiproject/messaging/api/UserMessagingService.java index deb5a1fe3ff2..84f2ccb8ed27 100644 --- a/kernel/api/src/main/java/org/sakaiproject/messaging/api/UserMessagingService.java +++ b/kernel/api/src/main/java/org/sakaiproject/messaging/api/UserMessagingService.java @@ -15,7 +15,6 @@ */ package org.sakaiproject.messaging.api; -import java.io.InputStream; import java.util.List; import java.util.Map; import java.util.Set; @@ -59,9 +58,6 @@ public interface UserMessagingService { */ boolean importTemplateFromResourceXmlFile(String templateResource, String templateRegistrationKey); - public void listen(String topic, MessageListener listener); - public void send(String topic, UserNotification ba); - /** * @return the list of notifications for the current user */ diff --git a/kernel/api/src/main/java/org/sakaiproject/messaging/api/model/UserNotification.java b/kernel/api/src/main/java/org/sakaiproject/messaging/api/model/UserNotification.java index f4ab2b606a4c..345779ae5623 100644 --- a/kernel/api/src/main/java/org/sakaiproject/messaging/api/model/UserNotification.java +++ b/kernel/api/src/main/java/org/sakaiproject/messaging/api/model/UserNotification.java @@ -92,4 +92,7 @@ public class UserNotification implements PersistableEntity { @Transient private String tool; + + @Transient + private Boolean isNotification = Boolean.TRUE; } diff --git a/kernel/kernel-impl/src/main/java/org/sakaiproject/messaging/impl/UserMessagingServiceImpl.java b/kernel/kernel-impl/src/main/java/org/sakaiproject/messaging/impl/UserMessagingServiceImpl.java index 24ee858f90e6..ebbfac93aa0e 100644 --- a/kernel/kernel-impl/src/main/java/org/sakaiproject/messaging/impl/UserMessagingServiceImpl.java +++ b/kernel/kernel-impl/src/main/java/org/sakaiproject/messaging/impl/UserMessagingServiceImpl.java @@ -17,7 +17,6 @@ import org.apache.commons.lang3.StringEscapeUtils; import org.apache.commons.lang3.StringUtils; -import org.apache.ignite.IgniteMessaging; import org.apache.http.HttpResponse; import org.bouncycastle.jce.ECNamedCurveTable; @@ -67,12 +66,10 @@ import org.sakaiproject.event.api.EventTrackingService; import org.sakaiproject.event.api.NotificationService; import org.sakaiproject.exception.IdUnusedException; -import org.sakaiproject.ignite.EagerIgniteSpringBean; import org.sakaiproject.messaging.api.model.UserNotification; import org.sakaiproject.messaging.api.UserNotificationData; import org.sakaiproject.messaging.api.UserNotificationHandler; import org.sakaiproject.messaging.api.Message; -import org.sakaiproject.messaging.api.MessageListener; import org.sakaiproject.messaging.api.MessageMedium; import org.sakaiproject.messaging.api.model.PushSubscription; import org.sakaiproject.messaging.api.model.UserNotification; @@ -121,24 +118,27 @@ public class UserMessagingServiceImpl implements UserMessagingService, Observer @Autowired private EmailTemplateService emailTemplateService; @Autowired private EntityManager entityManager; @Autowired private EventTrackingService eventTrackingService; - @Autowired private EagerIgniteSpringBean ignite; @Autowired private PreferencesService preferencesService; @Autowired private PushSubscriptionRepository pushSubscriptionRepository; @Autowired private ServerConfigurationService serverConfigurationService; + @Qualifier("org.sakaiproject.springframework.orm.hibernate.GlobalSessionFactory") @Autowired private SessionFactory sessionFactory; + @Autowired private SessionManager sessionManager; @Autowired private SiteService siteService; @Autowired private ToolManager toolManager; @Autowired private UserDirectoryService userDirectoryService; @Autowired private UserNotificationRepository userNotificationRepository; + @Qualifier("org.sakaiproject.time.api.UserTimeService") @Autowired private UserTimeService userTimeService; + @Setter private ResourceLoader resourceLoader; + @Qualifier("org.sakaiproject.springframework.orm.hibernate.GlobalTransactionManager") @Autowired private PlatformTransactionManager transactionManager; - private IgniteMessaging messaging; private List handlers = new ArrayList<>(); private Map handlerMap = new HashMap<>(); private ExecutorService executor; @@ -161,8 +161,6 @@ public void init() { objectMapper.registerModule(new JavaTimeModule()); - messaging = ignite.message(ignite.cluster().forLocal()); - Security.addProvider(new BouncyCastleProvider()); executor = Executors.newFixedThreadPool(20); @@ -593,20 +591,6 @@ private UserNotification decorateNotification(UserNotification notification) { return notification; } - - public void listen(String topic, MessageListener listener) { - - messaging.localListen(topic, (nodeId, message) -> { - - listener.read(decorateNotification((UserNotification) message)); - return true; - }); - } - - public void send(String topic, UserNotification un) { - messaging.send(topic, un); - } - @Transactional public void subscribeToPush(String endpoint, String auth, String userKey, String browserFingerprint) { diff --git a/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss b/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss index 90e191e00a9e..e69f9bf16b85 100644 --- a/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss +++ b/library/src/skins/default/src/sass/modules/tool/portal/_portal.scss @@ -241,11 +241,21 @@ .portal-notifications-button { position: relative; } -.portal-notifications-indicator { +.portal-notifications-indicator, .portal-notifications-no-permissions-indicator { position: absolute; - top: 10; + top: 10px; right: 20%; } + +.portal-notifications-no-permissions-indicator { + width: 50%; + height: 50%; + right: 10%; + font-size: 75%; + font-weight: bold; + color: white; +} + // make offcanvas header match Sakai's header: .offcanvas-header { flex-shrink: 0; diff --git a/library/src/webapp/js/sakai-message-broker.js b/library/src/webapp/js/sakai-message-broker.js index 8b5b5b493e9d..818102784339 100644 --- a/library/src/webapp/js/sakai-message-broker.js +++ b/library/src/webapp/js/sakai-message-broker.js @@ -3,180 +3,227 @@ portal.notifications = portal.notifications || {}; portal.notifications.pushCallbacks = new Map(); -if (portal?.user?.id) { +/** + * Create a promise which will setup the service worker and message registration + * functions before fulfilling. Consumers can wait on this promise and then register + * the push event they want to listen for. For an example of this, checkout + * sakai-notifications.js in webcomponents + */ +portal.notifications.setup = new Promise((resolve, reject) => { - const lastSubscribedUser = localStorage.getItem("last-sakai-user"); - const differentUser = lastSubscribedUser && lastSubscribedUser !== portal.user.id; + navigator.serviceWorker.register("/sakai-service-worker.js").then(reg => { - localStorage.setItem("last-sakai-user", portal.user.id); + const worker = reg.active; - if (portal.notifications.pushEnabled && (Notification.permission === "default" || differentUser)) { + if (worker) { - // Permission has neither been granted or denied yet. + // The service worker is already active, setup the listener and register function. - console.debug("No permission set or user changed"); + portal.notifications.setupServiceWorkerListener(); + console.debug("Worker registered and setup"); + resolve(); + } else { + console.debug("No active worker. Waiting for update ..."); - navigator.serviceWorker.register("/api/sakai-service-worker.js").then(registration => { + // Not active. We'll listen for an update then hook things up. - if (!registration.pushManager) { - // This must be Safari, or maybe IE3 or something :) - console.warn("No pushManager on this registration"); - return; - } + reg.addEventListener("updatefound", () => { - if (differentUser) { + console.debug("Worker updated. Waiting for state change ..."); - registration.pushManager.getSubscription() - .then(subscription => subscription && subscription.unsubscribe()) - .then(() => portal.notifications.subscribeIfPermitted(registration)); - } + const installingWorker = reg.installing; - window.addEventListener("DOMContentLoaded", () => { + installingWorker.addEventListener("statechange", e => { - console.debug("DOM loaded. Setting up permission triggers ..."); + console.debug("Worker state changed"); - // We're using the bullhorn buttons to trigger the permission request from the user. You - // can only instigate a permissions request from a user action. - document.querySelectorAll(".portal-notifications-button").forEach(b => { + if (e.target.state === "activated") { - b.addEventListener("click", e => { + console.debug("Worker activated. Setting up ..."); - portal.notifications.subscribeIfPermitted(registration); - }, { once: true }); + // The service worker has been updated, setup the listener and register function. + + portal.notifications.setupServiceWorkerListener(); + console.debug("Worker registered and setup"); + resolve(); + } }); }); - }); - } + } + }); +}); - portal.notifications.subscribeIfPermitted = registration => { +portal.notifications.registerPushCallback = (toolOrNotifications, cb) => { - console.debug("Requesting notifications permission ..."); + console.debug(`Registering push callback for ${toolOrNotifications}`); - Notification.requestPermission().then(permission => { + const callbacks = portal.notifications.pushCallbacks.get(toolOrNotifications) || []; + callbacks.push(cb); + portal.notifications.pushCallbacks.set(toolOrNotifications, callbacks); +}; - if (permission === "granted") { +portal.notifications.subscribeIfPermitted = reg => { - console.debug("Permission granted. Subscribing ..."); + console.debug("subscribeIfPermitted"); - // We have permission, Grab the public app server key. - fetch("/api/keys/sakaipush").then(r => r.text()).then(key => { + document.body?.querySelectorAll(".portal-notifications-no-permissions-indicator") + .forEach(el => el.classList.add("d-none")); + document.body?.querySelectorAll(".portal-notifications-indicator") + .forEach(el => el.classList.remove("d-none")); + document.body?.querySelector("sakai-notifications")?.refresh(); - console.debug("Got the key. Subscribing for push ..."); + return new Promise(resolve => { - // Subscribe with the public key - registration.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: key }).then(sub => { + if (Notification?.permission === "granted") { + console.debug("Permission already granted. Subscribing ..."); + portal.notifications.subscribe(reg, resolve); + } else if (Notification?.permission === "default") { - console.debug("Subscribed. Sending details to Sakai ..."); + console.debug("Push permission not set yet"); - const params = { - endpoint: sub.endpoint, - auth: sub.toJSON().keys.auth, - userKey: sub.toJSON().keys.p256dh, - browserFingerprint: getBrowserFingerprint(), - }; + Notification.requestPermission().then(permission => { - const url = "/api/users/me/prefs/pushEndpoint"; - fetch(url, { - credentials: "include", - method: "POST", - body: new URLSearchParams(params), - }) - .then(r => { + if (Notification.permission === "granted") { - if (!r.ok) { - throw new Error(`Network error while posting push endpoint: ${url}`); - } + console.debug("Permission granted. Subscribing ..."); + portal.notifications.subscribe(reg, resolve); + } + }) + .catch (error => console.error(error)); + } else { + resolve(); + } + }); +}; - console.debug("Subscription details sent successfully"); - }) - .catch (error => console.error(error)); - }); - }); - } - }); - }; +navigator.serviceWorker.register("/sakai-service-worker.js").then(reg => { + // We set this up for other parts of the code to call, without needing to register + // the service worker first. We capture the registration in the closure. + portal.notifications.callSubscribeIfPermitted = () => portal.notifications.subscribeIfPermitted(reg); +}); - portal.notifications.setupServiceWorkerListener = () => { +portal.notifications.serviceWorkerMessageListener = e => { - console.debug("setupServiceWorkerListener"); + // When the worker's EventSource receives an event it will message us (the client). This + // code looks up the matching callback and calls it. - // When the worker's EventSource receives an event it will message us (the client). This - // code looks up the matching callback and calls it. - navigator.serviceWorker.addEventListener('message', e => { + if (e.data.isNotification) { + const notificationsCallbacks = portal.notifications.pushCallbacks.get("notifications"); + notificationsCallbacks?.forEach(cb => cb(e.data)); + } else { + const toolCallbacks = portal.notifications.pushCallbacks.get(e.data.tool); + toolCallbacks && toolCallbacks.forEach(cb => cb(e.data)); + } +}; - const allCallbacks = portal.notifications.pushCallbacks.get("all"); - allCallbacks && allCallbacks.forEach(cb => cb(e.data)); - const toolCallbacks = portal.notifications.pushCallbacks.get(e.data.tool); - toolCallbacks && toolCallbacks.forEach(cb => cb(e.data)); - }); - }; +portal.notifications.setupServiceWorkerListener = () => { + + console.debug("setupServiceWorkerListener"); + + navigator.serviceWorker.addEventListener('message', portal.notifications.serviceWorkerMessageListener); +}; + +portal.notifications.subscribe = (reg, resolve) => { + + const pushKeyUrl = "/api/keys/sakaipush"; + console.debug(`Fetching the push key from ${pushKeyUrl} ...`); + fetch(pushKeyUrl).then(r => r.text()).then(key => { + + // Subscribe with the public key + reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: key, + }) + .then(sub => { + + console.debug("Subscribed. Sending details to Sakai ..."); + + const params = { + endpoint: sub.endpoint, + auth: sub.toJSON().keys.auth, + userKey: sub.toJSON().keys.p256dh, + browserFingerprint: getBrowserFingerprint(), + }; - portal.notifications.registerPushCallback = (toolOrAll, cb) => { + const url = "/api/users/me/pushEndpoint"; + fetch(url, { + credentials: "include", + method: "POST", + body: new URLSearchParams(params), + }) + .then(r => { - console.debug(`Registering push callback for ${toolOrAll}`); + if (!r.ok) { + throw new Error(`Network error while posting push endpoint: ${url}`); + } - const callbacks = portal.notifications.pushCallbacks.get(toolOrAll) || []; - callbacks.push(cb); - portal.notifications.pushCallbacks.set(toolOrAll, callbacks); - }; + console.debug("Subscription details sent successfully"); + }) + .catch (error => console.error(error)) + .finally(() => resolve()); + }); + }); +}; - /** - * Create a promise which will setup the service worker and message registration - * functions before fulfilling. Consumers can wait on this promise and then register - * the push event they want to listen for. For an example of this, checkout - * sui-notifications.js in webcomponents - */ - portal.notifications.setup = new Promise((resolve, reject) => { +portal.notifications.checkUserChangedThenSet = userId => { - console.debug("Registering worker ..."); + const lastSubscribedUser = localStorage.getItem("last-sakai-user"); + const differentUser = !lastSubscribedUser || lastSubscribedUser !== portal.user.id; - navigator.serviceWorker.register("/api/sakai-service-worker.js") - .then(registration => { + localStorage.setItem("last-sakai-user", userId); - const worker = registration.active; + return differentUser; +}; - if (worker) { +if (portal?.user?.id) { - // The serivce worker is already active, setup the listener and register function. + portal.notifications.setup.then(() => console.debug("Notifications setup complete")); - portal.notifications.setupServiceWorkerListener(); - console.debug("Worker registered and setup"); - resolve(); - } else { - console.debug("No active worker. Waiting for update ..."); + const differentUser = portal.notifications.checkUserChangedThenSet(portal.user.id); - // Not active. We'll listen for an update then hook things up. + if (Notification?.permission !== "granted") { + document.addEventListener("DOMContentLoaded", event => { + document.querySelectorAll(".portal-notifications-no-permissions-indicator").forEach(b => b.classList.remove("d-none")); + }); + } - registration.addEventListener("updatefound", () => { + if (portal.notifications.pushEnabled && (Notification?.permission === "default" || differentUser)) { - console.debug("Worker updated. Waiting for state change ..."); + // Permission has neither been granted or denied yet, or the user has changed. - const installingWorker = registration.installing; + console.debug("No permission set or user changed"); - installingWorker.addEventListener("statechange", e => { + navigator.serviceWorker.register("/sakai-service-worker.js").then(reg => { - console.debug("Worker state changed"); + if (!reg.pushManager) { + // This must be Safari < 16, or maybe IE3 or something :) + console.warn("No pushManager on this registration"); + return; + } - if (e.target.state === "activated") { + if (differentUser) { + + console.debug("The user has changed. Unsubscribing the previous user ..."); - console.debug("Worker activated. Setting up ..."); + // The user has changed. If there is a subscription, unsubscribe it and try to subscribe + // for the new user. + reg.pushManager.getSubscription().then(sub => { - // The service worker has been updated, setup the listener and register function. + if (sub) { + //sub.unsubscribe().finally(() => portal.notifications.subscribeIfPermitted(reg)); + sub.unsubscribe().finally(() => { - portal.notifications.setupServiceWorkerListener(); - console.debug("Worker registered and setup"); - resolve(); + if (Notification?.permission === "granted" && differentUser) { + portal.notifications.subscribeIfPermitted(reg); } }); - }); - } - }) - .catch (error => { - - console.error(`Failed to register service worker ${error}`); - reject(); - }); - }); - - portal.notifications.setup.then(() => console.debug("Notifications setup complete")); + } else { + if (Notification?.permission === "granted" && differentUser) { + portal.notifications.subscribeIfPermitted(reg); + } + } + }); + } + }); + } } diff --git a/pom.xml b/pom.xml index 814346594c23..5ab11999e565 100644 --- a/pom.xml +++ b/pom.xml @@ -107,6 +107,7 @@ samigo search sections + serviceworker shortenedurl signup simple-rss-portlet diff --git a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java index c96ded73a87f..d9df92986099 100644 --- a/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java +++ b/portal/portal-impl/impl/src/java/org/sakaiproject/portal/entityprovider/PortalEntityProvider.java @@ -146,19 +146,9 @@ public PortalNotifications handleNotify(EntityView view) { @EntityCustomAction(action = "notifications", viewKey = EntityView.VIEW_LIST) public ActionReturn getNotifications(EntityView view) { - String currentUserId = getCheckedCurrentUser(); - - ResourceLoader rl = new ResourceLoader("bullhorns"); - List notifications = userMessagingService.getNotifications(); - - Map data = new HashMap<>(); - data.put("i18n", rl); - - if (notifications.size() > 0) { - data.put("notifications", notifications); - } + getCheckedCurrentUser(); - return new ActionReturn(data); + return new ActionReturn(userMessagingService.getNotifications()); } @EntityCustomAction(action = "clearNotification", viewKey = EntityView.VIEW_LIST) diff --git a/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm index 2f3f6caacf04..73e6bffa38f3 100644 --- a/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm +++ b/portal/portal-render-engine-impl/impl/src/webapp/vm/morpheus/includeAccountNav.vm @@ -85,8 +85,12 @@ For opening help sidebar aria-controls="sakai-notificationsPanel" aria-label="$rloader.sit_notifications" title="$rloader.sit_notifications"> - -