diff --git a/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy b/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy index 62dd20c001..1978e26395 100644 --- a/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy +++ b/build-plugins/src/main/groovy/com/cloudogu/scm/JavaModulePlugin.groovy @@ -55,6 +55,12 @@ class JavaModulePlugin implements Plugin { failOnError false } + project.sonarqube { + properties { + property "sonar.java.source", "8" + } + } + project.afterEvaluate { if (project.isCI) { project.plugins.apply("jacoco") diff --git a/docs/de/navigation.yml b/docs/de/navigation.yml index 0e25b8ec53..da6e8e4144 100644 --- a/docs/de/navigation.yml +++ b/docs/de/navigation.yml @@ -5,3 +5,4 @@ - /user/group/ - /user/admin/ - /user/profile/ + - /user/notification/ diff --git a/docs/de/user/notification/assets/bell.png b/docs/de/user/notification/assets/bell.png new file mode 100644 index 0000000000..a2556a7bf7 Binary files /dev/null and b/docs/de/user/notification/assets/bell.png differ diff --git a/docs/de/user/notification/assets/emptybell.png b/docs/de/user/notification/assets/emptybell.png new file mode 100644 index 0000000000..e3f472be2b Binary files /dev/null and b/docs/de/user/notification/assets/emptybell.png differ diff --git a/docs/de/user/notification/assets/notifications.png b/docs/de/user/notification/assets/notifications.png new file mode 100644 index 0000000000..9e957442a6 Binary files /dev/null and b/docs/de/user/notification/assets/notifications.png differ diff --git a/docs/de/user/notification/assets/toast.png b/docs/de/user/notification/assets/toast.png new file mode 100644 index 0000000000..642582c530 Binary files /dev/null and b/docs/de/user/notification/assets/toast.png differ diff --git a/docs/de/user/notification/index.md b/docs/de/user/notification/index.md new file mode 100644 index 0000000000..f65c4c9da8 --- /dev/null +++ b/docs/de/user/notification/index.md @@ -0,0 +1,36 @@ +--- +title: Benachrichtigungen +partiallyActive: true +--- + +Benachrichtigungen werden in SCM-Manager verwendet, um die Fertigstellung von langlaufenden Prozessen anzuzeigen +oder um den Benutzer auf Fehler hinzuweisen. + +Aktuelle Benachrichtigungen tauchen in Form einer Toast-Benachrichtigung am unteren rechten Rand auf. + +![Toast-Benachrichtigung](assets/toast.png) + +Die Farbe der Benachrichtigung gibt den Typ der Nachricht an: + +* Fehler (Rot): Ein Fehler ist aufgetreten +* Warnung (Gelb): Es ist ein Problem aufgetreten +* Erfolgreich (Grün): Eine Aktion wurde erfolgreich beendet +* Information (Blau): Zur Information + +Die Nachrichten verweisen auf eine Unterseite des SCM-Managers, die sich mit einem Klick auf den Text aufrufen lässt. +Nach dem Lesen der Nachricht, kann sie mit einem Klick auf das X in der oberen rechten Ecke geschlossen werden. +Nach dem Schließen der Nachricht ist sie immer noch über die Glocke am oberen rechten Rand zu finden. + +![Glockensymbol](assets/bell.png) + +Die Zahl neben der Glock gibt an wie viele Nachrichten eingegangen sind. +Mit einem Klick öffnet sich die Liste der Nachrichten. Neben den Informationen wie Typ der Nachricht (farbiger Rand) und Eingangsdatum, kann über einen Klick auf den Text zu einer SCM-Manager Unterseite navigiert werden. + +![Benachrichtigungen](assets/notifications.png) + +Über das Mülleimer-Symbol lässt sich eine einzelne Nachricht löschen. +Alle Nachrichten lassen sich über den "Alle löschen" Button löschen. + +Wenn keine Nachrichten mehr vorhanden sind, zeigt das Glocken-Symbol keinen Zähler mehr an. + +![Glockensymbol ohne Zähler](assets/emptybell.png) diff --git a/docs/en/navigation.yml b/docs/en/navigation.yml index 89a8367a9f..01cd136280 100644 --- a/docs/en/navigation.yml +++ b/docs/en/navigation.yml @@ -13,6 +13,7 @@ - /user/group/ - /user/admin/ - /user/profile/ + - /user/notification/ - section: Administration entries: diff --git a/docs/en/user/notification/assets/bell.png b/docs/en/user/notification/assets/bell.png new file mode 100644 index 0000000000..99dee39b49 Binary files /dev/null and b/docs/en/user/notification/assets/bell.png differ diff --git a/docs/en/user/notification/assets/emptybell.png b/docs/en/user/notification/assets/emptybell.png new file mode 100644 index 0000000000..db7fcac27a Binary files /dev/null and b/docs/en/user/notification/assets/emptybell.png differ diff --git a/docs/en/user/notification/assets/notifications.png b/docs/en/user/notification/assets/notifications.png new file mode 100644 index 0000000000..7f2a1863fc Binary files /dev/null and b/docs/en/user/notification/assets/notifications.png differ diff --git a/docs/en/user/notification/assets/toast.png b/docs/en/user/notification/assets/toast.png new file mode 100644 index 0000000000..ffb5044d64 Binary files /dev/null and b/docs/en/user/notification/assets/toast.png differ diff --git a/docs/en/user/notification/index.md b/docs/en/user/notification/index.md new file mode 100644 index 0000000000..6b9de90f34 --- /dev/null +++ b/docs/en/user/notification/index.md @@ -0,0 +1,38 @@ +--- +title: Notifications +partiallyActive: true +--- + +Notifications are used in SCM Manager to indicate the completion of long-running processes +or to alert the user about errors and warnings. + +Current notifications appear in the form of a toast notification at the bottom right. + +![toast-notification](assets/toast.png) + +The color of the notification indicates the type of message: + +* Error (red): An error has occurred. +* Warning (yellow): A problem has occurred +* Successful (green): An action has been successfully completed +* Information (blue): For informational notifications + +The messages refer to a page of SCM Manager, which can be accessed by clicking on the text of the notification. +When you have read the message, you can close it by clicking on the X in the upper right corner. +The messages can also be reviewed by clicking on the bell icon in the upper right corner. + +![bell icon](assets/bell.png) + +The number next to the bell icon indicates how many messages have been received. +If you move the mouse over the icon, you can read the messages. +The colored border indicates the type of the message, the date indicates when the message was received +and by clicking on the text you can open the page of the message. + +![Notifications](assets/notifications.png) + +The trash icon can be used to delete a single message. +All messages can be deleted by clicking the "Dismiss all" button. + +If there are no messages left, the bell icon will not show a counter anymore. + +![Bell icon without counter](assets/emptybell.png) diff --git a/gradle/changelog/global_notifications.yaml b/gradle/changelog/global_notifications.yaml new file mode 100644 index 0000000000..3a0815e28c --- /dev/null +++ b/gradle/changelog/global_notifications.yaml @@ -0,0 +1,2 @@ +- type: added + description: Add global notifications ([#1646](https://github.com/scm-manager/scm-manager/pull/1646)) diff --git a/gradle/dependencies.gradle b/gradle/dependencies.gradle index b110a04789..da87509504 100644 --- a/gradle/dependencies.gradle +++ b/gradle/dependencies.gradle @@ -100,6 +100,7 @@ ext { ssp: "com.github.sdorra:ssp-lib:${sspVersion}", sspProcessor: "com.github.sdorra:ssp-processor:${sspVersion}", + shiroUnit: 'com.github.sdorra:shiro-unit:1.0.1', shiroExtension: 'com.github.sdorra:junit-shiro-extension:1.0.1', diff --git a/scm-core/build.gradle b/scm-core/build.gradle index b79ebb4983..c16dba31f6 100644 --- a/scm-core/build.gradle +++ b/scm-core/build.gradle @@ -109,6 +109,9 @@ dependencies { testImplementation libraries.junitJupiterParams testRuntimeOnly libraries.junitJupiterEngine + // shiro + testImplementation libraries.shiroExtension + // junit 4 support testRuntimeOnly libraries.junitVintageEngine testImplementation libraries.junit diff --git a/scm-core/src/main/java/sonia/scm/notifications/Notification.java b/scm-core/src/main/java/sonia/scm/notifications/Notification.java new file mode 100644 index 0000000000..26bded357e --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/notifications/Notification.java @@ -0,0 +1,55 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.notifications; + +import lombok.Value; + +import java.time.Instant; + +/** + * Notifications can be used to send a message to specific user. + * + * @since 2.18.0 + */ +@Value +public class Notification { + + Type type; + String link; + String message; + Instant createdAt; + + public Notification(Type type, String link, String message) { + this(type, link, message, Instant.now()); + } + + Notification(Type type, String link, String message, Instant createdAt) { + this.type = type; + this.link = link; + this.message = message; + this.createdAt = createdAt; + } + +} diff --git a/scm-core/src/main/java/sonia/scm/notifications/NotificationSender.java b/scm-core/src/main/java/sonia/scm/notifications/NotificationSender.java new file mode 100644 index 0000000000..c24110941b --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/notifications/NotificationSender.java @@ -0,0 +1,50 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.notifications; + +import org.apache.shiro.SecurityUtils; + +/** + * Service for sending notifications. + * + * @since 2.18.0 + */ +public interface NotificationSender { + + /** + * Sends the notification to a specific user. + * @param notification notification to send + * @param recipient username of the receiving user + */ + void send(Notification notification, String recipient); + + /** + * Sends the notification to the current user. + * @param notification notification to send + */ + default void send(Notification notification) { + send(notification, SecurityUtils.getSubject().getPrincipal().toString()); + } +} diff --git a/scm-core/src/main/java/sonia/scm/notifications/Type.java b/scm-core/src/main/java/sonia/scm/notifications/Type.java new file mode 100644 index 0000000000..054169fff6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/notifications/Type.java @@ -0,0 +1,51 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.notifications; + +/** + * Type of notification. + * @since 2.18.0 + */ +public enum Type { + /** + * Notifications with an informative character e.g.: update available + */ + INFO, + + /** + * Success should be used if a long running action is finished successfully e.g.: export is ready to download + */ + SUCCESS, + + /** + * Notifications with a warning character e.g.: disk space is filled up to 80 percent. + */ + WARNING, + + /** + * Error should be used in the case of an failure e.g.: export failed + */ + ERROR +} diff --git a/scm-core/src/main/java/sonia/scm/security/SessionId.java b/scm-core/src/main/java/sonia/scm/security/SessionId.java index 0f1c3105d9..1f9a8e7da5 100644 --- a/scm-core/src/main/java/sonia/scm/security/SessionId.java +++ b/scm-core/src/main/java/sonia/scm/security/SessionId.java @@ -21,10 +21,9 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.security; -import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Preconditions; import com.google.common.base.Strings; import lombok.EqualsAndHashCode; @@ -40,7 +39,6 @@ import java.util.Optional; @EqualsAndHashCode public final class SessionId implements Serializable { - @VisibleForTesting public static final String PARAMETER = "X-SCM-Session-ID"; private final String value; diff --git a/scm-core/src/main/java/sonia/scm/sse/Channel.java b/scm-core/src/main/java/sonia/scm/sse/Channel.java new file mode 100644 index 0000000000..1cdd562ed6 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/Channel.java @@ -0,0 +1,120 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.security.SessionId; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.locks.ReadWriteLock; +import java.util.concurrent.locks.ReentrantReadWriteLock; +import java.util.function.Function; +import java.util.function.Predicate; + +public class Channel { + + private static final Logger LOG = LoggerFactory.getLogger(Channel.class); + + private final List clients = new ArrayList<>(); + private final ReadWriteLock lock = new ReentrantReadWriteLock(); + + private final Object channelId; + private final Function clientFactory; + + Channel(Object channelId) { + this(channelId, Client::new); + } + + Channel(Object channelId, Function clientFactory) { + this.channelId = channelId; + this.clientFactory = clientFactory; + } + + public void register(Registration registration) { + registration.validate(); + Client client = clientFactory.apply(registration); + + LOG.trace("registered new client {} to channel {}", client.getSessionId(), channelId); + lock.writeLock().lock(); + try { + clients.add(client); + } finally { + lock.writeLock().unlock(); + } + } + + public void broadcast(Message message) { + LOG.trace("broadcast message {} to clients of channel {}", message, channelId); + lock.readLock().lock(); + try { + clients.stream() + .filter(isNotSender(message)) + .forEach(client -> client.send(message)); + } finally { + lock.readLock().unlock(); + } + } + + private Predicate isNotSender(Message message) { + return client -> { + Optional senderSessionId = message.getSender(); + return senderSessionId + .map(sessionId -> !sessionId.equals(client.getSessionId())) + .orElse(true); + }; + } + + void removeClosedOrTimeoutClients() { + Instant timeoutLimit = Instant.now().minus(30, ChronoUnit.SECONDS); + lock.writeLock().lock(); + try { + int removeCounter = 0; + Iterator it = clients.iterator(); + while (it.hasNext()) { + Client client = it.next(); + if (client.isClosed()) { + LOG.trace("remove closed client with session {}", client.getSessionId()); + it.remove(); + removeCounter++; + } else if (client.getLastUsed().isBefore(timeoutLimit)) { + client.close(); + LOG.trace("remove client with session {}, because it has reached the timeout", client.getSessionId()); + it.remove(); + removeCounter++; + } + } + LOG.trace("removed {} closed clients from channel", removeCounter); + } finally { + lock.writeLock().unlock(); + } + } + +} diff --git a/scm-core/src/main/java/sonia/scm/sse/ChannelCleanupTask.java b/scm-core/src/main/java/sonia/scm/sse/ChannelCleanupTask.java new file mode 100644 index 0000000000..9f5170f515 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/ChannelCleanupTask.java @@ -0,0 +1,43 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import javax.inject.Inject; + +public class ChannelCleanupTask implements Runnable { + + private final ChannelRegistry registry; + + @Inject + public ChannelCleanupTask(ChannelRegistry registry) { + this.registry = registry; + } + + @Override + public void run() { + registry.removeClosedClients(); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/sse/ChannelRegistry.java b/scm-core/src/main/java/sonia/scm/sse/ChannelRegistry.java new file mode 100644 index 0000000000..8ded5162a8 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/ChannelRegistry.java @@ -0,0 +1,56 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import com.google.common.annotations.VisibleForTesting; + +import javax.inject.Singleton; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.function.Function; + +@Singleton +public class ChannelRegistry { + + private final Map channels = new ConcurrentHashMap<>(); + private final Function channelFactory; + + public ChannelRegistry() { + this(Channel::new); + } + + @VisibleForTesting + ChannelRegistry(Function channelFactory) { + this.channelFactory = channelFactory; + } + + public Channel channel(Object channelId) { + return channels.computeIfAbsent(channelId, channelFactory); + } + + void removeClosedClients() { + channels.values().forEach(Channel::removeClosedOrTimeoutClients); + } +} diff --git a/scm-core/src/main/java/sonia/scm/sse/Client.java b/scm-core/src/main/java/sonia/scm/sse/Client.java new file mode 100644 index 0000000000..af7da4d466 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/Client.java @@ -0,0 +1,100 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.security.SessionId; + +import javax.ws.rs.sse.OutboundSseEvent; +import javax.ws.rs.sse.SseEventSink; +import java.io.Closeable; +import java.time.Instant; +import java.util.function.Function; + +class Client implements Closeable { + + private static final Logger LOG = LoggerFactory.getLogger(Client.class); + + private final SessionId sessionId; + private final SseEventAdapter adapter; + private final SseEventSink eventSink; + + private Instant lastUsed; + private boolean exceptionallyClosed = false; + + Client(Registration registration) { + this(registration, reg -> new SseEventAdapter(reg.getSse())); + } + + Client(Registration registration, Function adapterFactory) { + sessionId = registration.getSessionId(); + adapter = adapterFactory.apply(registration); + eventSink = registration.getEventSink(); + lastUsed = Instant.now(); + } + + Instant getLastUsed() { + return lastUsed; + } + + SessionId getSessionId() { + return sessionId; + } + + boolean isExceptionallyClosed() { + return exceptionallyClosed; + } + + void send(Message message) { + if (!isClosed()) { + OutboundSseEvent event = adapter.create(message); + LOG.debug("send message to client with session id {}", sessionId); + lastUsed = Instant.now(); + eventSink.send(event).exceptionally(e -> { + if (LOG.isTraceEnabled()) { + LOG.trace("failed to send event to client with session id {}:", sessionId, e); + } else { + LOG.debug("failed to send event to client with session id {}: {}", sessionId, e.getMessage()); + } + exceptionallyClosed = true; + close(); + return null; + }); + } else { + LOG.debug("client has closed the connection, before we could send the message"); + } + } + + boolean isClosed() { + return exceptionallyClosed || eventSink.isClosed(); + } + + @Override + public void close() { + LOG.trace("close sse session of client with session id: {}", sessionId); + eventSink.close(); + } +} diff --git a/scm-core/src/main/java/sonia/scm/sse/Message.java b/scm-core/src/main/java/sonia/scm/sse/Message.java new file mode 100644 index 0000000000..228741f376 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/Message.java @@ -0,0 +1,54 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import lombok.Getter; +import sonia.scm.security.SessionId; + +import java.util.Optional; + +@Getter +public class Message { + + private final String name; + private final Class type; + private final Object data; + private final SessionId sender; + + public Message(String name, Class type, Object data) { + this(name, type, data, null); + } + + public Message(String name, Class type, Object data, SessionId sender) { + this.name = name; + this.type = type; + this.data = data; + this.sender = sender; + } + + public Optional getSender() { + return Optional.ofNullable(sender); + } +} diff --git a/scm-core/src/main/java/sonia/scm/sse/Registration.java b/scm-core/src/main/java/sonia/scm/sse/Registration.java new file mode 100644 index 0000000000..b558c8ffb2 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/Registration.java @@ -0,0 +1,46 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import com.google.common.base.Preconditions; +import lombok.Value; +import sonia.scm.security.SessionId; + +import javax.ws.rs.sse.Sse; +import javax.ws.rs.sse.SseEventSink; + +@Value +public class Registration { + + SessionId sessionId; + Sse sse; + SseEventSink eventSink; + + void validate() { + Preconditions.checkNotNull(sessionId, "sessionId is required"); + Preconditions.checkNotNull(sse, "sse is required"); + Preconditions.checkNotNull(eventSink, "eventSink is required"); + } +} diff --git a/scm-core/src/main/java/sonia/scm/sse/SseContextListener.java b/scm-core/src/main/java/sonia/scm/sse/SseContextListener.java new file mode 100644 index 0000000000..85572f4229 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/SseContextListener.java @@ -0,0 +1,53 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import sonia.scm.plugin.Extension; +import sonia.scm.schedule.Scheduler; + +import javax.inject.Inject; +import javax.servlet.ServletContextEvent; +import javax.servlet.ServletContextListener; + +@Extension +public class SseContextListener implements ServletContextListener { + + private final Scheduler scheduler; + + @Inject + public SseContextListener(Scheduler scheduler) { + this.scheduler = scheduler; + } + + @Override + public void contextInitialized(ServletContextEvent servletContextEvent) { + this.scheduler.schedule("0/30 * * * * ?", ChannelCleanupTask.class); + } + + @Override + public void contextDestroyed(ServletContextEvent servletContextEvent) { + // we have nothing to destroy + } +} diff --git a/scm-core/src/main/java/sonia/scm/sse/SseEventAdapter.java b/scm-core/src/main/java/sonia/scm/sse/SseEventAdapter.java new file mode 100644 index 0000000000..28daeff9c2 --- /dev/null +++ b/scm-core/src/main/java/sonia/scm/sse/SseEventAdapter.java @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import javax.ws.rs.core.MediaType; +import javax.ws.rs.sse.OutboundSseEvent; +import javax.ws.rs.sse.Sse; + +class SseEventAdapter { + + private final Sse sse; + + SseEventAdapter(Sse sse) { + this.sse = sse; + } + + OutboundSseEvent create(Message message) { + return sse.newEventBuilder() + .name(message.getName()) + .mediaType(MediaType.APPLICATION_JSON_TYPE) + .data(message.getType(), message.getData()) + .build(); + } + +} diff --git a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java index 1a846d66ba..0fe8252b21 100644 --- a/scm-core/src/main/java/sonia/scm/web/VndMediaType.java +++ b/scm-core/src/main/java/sonia/scm/web/VndMediaType.java @@ -93,6 +93,8 @@ public class VndMediaType { public static final String REPOSITORY_EXPORT = PREFIX + "repositoryExport" + SUFFIX; public static final String REPOSITORY_EXPORT_INFO = PREFIX + "repositoryExportInfo" + SUFFIX; + public static final String NOTIFICATION_COLLECTION = PREFIX + "notificationCollection" + SUFFIX; + private VndMediaType() { } diff --git a/scm-core/src/test/java/sonia/scm/notifications/NotificationSenderTest.java b/scm-core/src/test/java/sonia/scm/notifications/NotificationSenderTest.java new file mode 100644 index 0000000000..b4ce56d92f --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/notifications/NotificationSenderTest.java @@ -0,0 +1,61 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.notifications; + +import org.github.sdorra.jse.ShiroExtension; +import org.github.sdorra.jse.SubjectAware; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; + +import static org.assertj.core.api.Assertions.assertThat; + +@ExtendWith(ShiroExtension.class) +class NotificationSenderTest { + + @Test + @SubjectAware("trillian") + void shouldUsePrincipal() { + CapturingNotificationSender sender = new CapturingNotificationSender(); + + Notification notification = new Notification(Type.INFO, "/tricia", "Hello trillian"); + sender.send(notification); + + assertThat(sender.notification).isSameAs(notification); + assertThat(sender.recipient).isEqualTo("trillian"); + } + + private static class CapturingNotificationSender implements NotificationSender { + + private Notification notification; + private String recipient; + + @Override + public void send(Notification notification, String recipient) { + this.notification = notification; + this.recipient = recipient; + } + } + +} diff --git a/scm-core/src/test/java/sonia/scm/sse/ChannelCleanupTaskTest.java b/scm-core/src/test/java/sonia/scm/sse/ChannelCleanupTaskTest.java new file mode 100644 index 0000000000..7ce3cb964a --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/sse/ChannelCleanupTaskTest.java @@ -0,0 +1,51 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class ChannelCleanupTaskTest { + + @Mock + private ChannelRegistry registry; + + @InjectMocks + private ChannelCleanupTask task; + + @Test + void shouldRunCleanupFromRegistry() { + task.run(); + + verify(registry).removeClosedClients(); + } + +} diff --git a/scm-core/src/test/java/sonia/scm/sse/ChannelRegistryTest.java b/scm-core/src/test/java/sonia/scm/sse/ChannelRegistryTest.java new file mode 100644 index 0000000000..55028816e5 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/sse/ChannelRegistryTest.java @@ -0,0 +1,97 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.ArrayList; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; + +class ChannelRegistryTest { + + private ChannelRegistry registry; + + @Nested + class ChannelTests { + + @BeforeEach + void setUp() { + registry = new ChannelRegistry(); + } + + @Test + void shouldCreateNewChannel() { + Channel one = registry.channel("one"); + assertThat(one).isNotNull(); + } + + @Test + void shouldReturnSameChannelForSameId() { + Channel two = registry.channel("two"); + assertThat(two).isSameAs(registry.channel("two")); + } + + } + + @Nested + @ExtendWith(MockitoExtension.class) + class RemoveClosedClientsTests { + + private List channels; + + @BeforeEach + void setUp() { + channels = new ArrayList<>(); + registry = new ChannelRegistry(objectId -> { + Channel channel = mock(Channel.class); + channels.add(channel); + return channel; + }); + } + + @Test + void shouldCallRemoveClosedOrTimeoutClientsOnEachChannel() { + registry.channel("one"); + registry.channel("two"); + registry.channel("three"); + + registry.removeClosedClients(); + + assertThat(channels) + .hasSize(3) + .allSatisfy(channel -> verify(channel).removeClosedOrTimeoutClients()); + } + + } + +} diff --git a/scm-core/src/test/java/sonia/scm/sse/ChannelTest.java b/scm-core/src/test/java/sonia/scm/sse/ChannelTest.java new file mode 100644 index 0000000000..18731b2fa4 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/sse/ChannelTest.java @@ -0,0 +1,130 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.SessionId; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + + +@ExtendWith(MockitoExtension.class) +class ChannelTest { + + private Map clients; + private Channel channel; + + @BeforeEach + void setUp() { + this.clients = new HashMap<>(); + channel = new Channel("one", registration -> clients.get(registration)); + } + + @Test + void shouldRegisterAndSend() { + Client client = register(); + + Message message = broadcast("Hello World"); + + verify(client).send(message); + } + + @Test + void shouldNotSendToSender() { + Client clientOne = register(); + SessionId sessionOne = SessionId.valueOf("one"); + when(clientOne.getSessionId()).thenReturn(sessionOne); + + Client clientTwo = register(); + when(clientTwo.getSessionId()).thenReturn(SessionId.valueOf("two")); + + Message message = broadcast("Hello Two", sessionOne); + + verify(clientOne, never()).send(message); + verify(clientTwo).send(message); + } + + @Test + void shouldRemoveClosedClients() { + Client closedClient = register(); + when(closedClient.isClosed()).thenReturn(true); + Client activeClient = register(); + when(activeClient.getLastUsed()).thenReturn(Instant.now()); + + channel.removeClosedOrTimeoutClients(); + + Message message = broadcast("Hello active ones"); + + verify(closedClient, never()).send(message); + verify(activeClient).send(message); + } + + @Test + void shouldRemoveClientsWhichAreNotUsedWithin30Seconds() { + Client timedOutClient = register(); + when(timedOutClient.getLastUsed()).thenReturn(Instant.now().minus(31L, ChronoUnit.SECONDS)); + Client activeClient = register(); + when(activeClient.getLastUsed()).thenReturn(Instant.now().minus(29L, ChronoUnit.SECONDS)); + + channel.removeClosedOrTimeoutClients(); + + Message message = broadcast("Hello active ones"); + verify(timedOutClient, never()).send(message); + verify(timedOutClient).close(); + verify(activeClient).send(message); + verify(activeClient, never()).close(); + } + + private Client register() { + Registration registration = mock(Registration.class); + Client client = mock(Client.class); + clients.put(registration, client); + channel.register(registration); + return client; + } + + private Message broadcast(String data) { + return broadcast(data, null); + } + + private Message broadcast(String data, SessionId sessionId) { + Message message = new Message("hello", String.class, data, sessionId); + channel.broadcast(message); + return message; + } + +} diff --git a/scm-core/src/test/java/sonia/scm/sse/ClientTest.java b/scm-core/src/test/java/sonia/scm/sse/ClientTest.java new file mode 100644 index 0000000000..caaf2782b2 --- /dev/null +++ b/scm-core/src/test/java/sonia/scm/sse/ClientTest.java @@ -0,0 +1,138 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.sse; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sonia.scm.security.SessionId; + +import javax.ws.rs.sse.OutboundSseEvent; +import javax.ws.rs.sse.Sse; +import javax.ws.rs.sse.SseEventSink; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class ClientTest { + + @Mock + private SseEventSink eventSink; + + @Mock + private SseEventAdapter adapter; + + @Mock + private Sse sse; + + @Mock + private OutboundSseEvent sseEvent; + + @Mock + @SuppressWarnings("rawtypes") + private CompletionStage completionStage; + + @Test + void shouldSetInitialLastUsed() { + Client client = client("one"); + + assertThat(client.getLastUsed()).isNotNull(); + } + + @Test + void shouldReturnSessionId() { + Client client = client("two"); + + assertThat(client.getSessionId()).isEqualTo(SessionId.valueOf("two")); + } + + @Test + void shouldNotSendToClosedEventSink() { + Client client = client("three"); + when(eventSink.isClosed()).thenReturn(true); + + client.send(message(42)); + + verify(eventSink, never()).send(any(OutboundSseEvent.class)); + } + + @Test + void shouldCloseEventSink() { + Client client = client("one"); + + client.close(); + + verify(eventSink).close(); + } + + @Test + @SuppressWarnings("unchecked") + void shouldSendMessage() { + Message message = message(21); + when(adapter.create(message)).thenReturn(sseEvent); + when(eventSink.send(sseEvent)).thenReturn(completionStage); + + Client client = client("one"); + client.send(message); + + verify(eventSink).send(sseEvent); + } + + @Test + @SuppressWarnings("unchecked") + void shouldMarkAsClosedOnException() { + Message message = message(21); + when(adapter.create(message)).thenReturn(sseEvent); + when(completionStage.exceptionally(any())).then(ic -> { + Function function = ic.getArgument(0); + function.apply(new IllegalStateException("failed")); + return null; + }); + when(eventSink.send(sseEvent)).thenReturn(completionStage); + + Client client = client("one"); + client.send(message); + + verify(eventSink).close(); + assertThat(client.isExceptionallyClosed()).isTrue(); + } + + private Message message(int i) { + return new Message("count", Integer.class, i); + } + + private Client client(String sessionId) { + Registration registration = new Registration(SessionId.valueOf(sessionId), sse, eventSink); + return new Client(registration, reg -> adapter); + } + +} diff --git a/scm-test/build.gradle b/scm-test/build.gradle index ec5119431c..ce936d8aad 100644 --- a/scm-test/build.gradle +++ b/scm-test/build.gradle @@ -35,8 +35,6 @@ dependencies { api libraries.junitJupiterApi api libraries.junitJupiterParams api libraries.junitJupiterEngine - api libraries.shiroUnit - api libraries.shiroExtension // junit 4 support api libraries.junitVintageEngine @@ -51,6 +49,10 @@ dependencies { api libraries.mockitoCore api libraries.mockitoJunitJupiter + // shiro + api libraries.shiroExtension + api libraries.shiroUnit + // test rest api's api libraries.resteasyCore api libraries.resteasyValidatorProvider diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryByteDataStore.java b/scm-test/src/main/java/sonia/scm/store/InMemoryByteDataStore.java new file mode 100644 index 0000000000..fa67ee0b2f --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryByteDataStore.java @@ -0,0 +1,88 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.store; + +import sonia.scm.security.KeyGenerator; +import sonia.scm.security.UUIDKeyGenerator; + +import javax.xml.bind.JAXB; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +public class InMemoryByteDataStore implements DataStore { + + private final Class type; + private final KeyGenerator generator = new UUIDKeyGenerator(); + private final Map store = new HashMap<>(); + + InMemoryByteDataStore(Class type) { + this.type = type; + } + + @Override + public String put(T item) { + String id = generator.createKey(); + put(id, item); + return id; + } + + @Override + public void put(String id, T item) { + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + JAXB.marshal(item, baos); + store.put(id, baos.toByteArray()); + } + + @Override + public Map getAll() { + Map all = new HashMap<>(); + for (String id : store.keySet()) { + all.put(id, get(id)); + } + return Collections.unmodifiableMap(all); + } + + @Override + public void clear() { + store.clear(); + } + + @Override + public void remove(String id) { + store.remove(id); + } + + @Override + public T get(String id) { + byte[] bytes = store.get(id); + if (bytes != null) { + return JAXB.unmarshal(new ByteArrayInputStream(bytes), type); + } + return null; + } +} diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryByteDataStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryByteDataStoreFactory.java new file mode 100644 index 0000000000..2cbbbf78fc --- /dev/null +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryByteDataStoreFactory.java @@ -0,0 +1,47 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +package sonia.scm.store; + +import java.util.HashMap; +import java.util.Map; + +/** + * Stores data in memory but in contrast to {@link InMemoryDataStoreFactory} + * it uses jaxb to marshal and unmarshall the objects. + * + * @since 2.18.0 + */ +public class InMemoryByteDataStoreFactory implements DataStoreFactory { + + @SuppressWarnings("rawtypes") + private final Map stores = new HashMap<>(); + + @Override + @SuppressWarnings("unchecked") + public DataStore getStore(TypedStoreParameters storeParameters) { + String name = storeParameters.getName(); + return stores.computeIfAbsent(name, n -> new InMemoryByteDataStore(storeParameters.getType())); + } +} diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java index 3e752ae081..beef74a863 100644 --- a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStore.java @@ -21,7 +21,7 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.store; import sonia.scm.security.KeyGenerator; @@ -37,7 +37,9 @@ import java.util.Map; * @author Sebastian Sdorra * * @param type of stored object + * @deprecated use {@link InMemoryByteDataStore} instead. */ +@Deprecated public class InMemoryDataStore implements DataStore { private final Map store = new HashMap<>(); diff --git a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java index fe6955f81f..abd0aa489e 100644 --- a/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java +++ b/scm-test/src/main/java/sonia/scm/store/InMemoryDataStoreFactory.java @@ -21,14 +21,17 @@ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * SOFTWARE. */ - + package sonia.scm.store; /** * In memory configuration store factory for testing purposes. * * @author Sebastian Sdorra + * @deprecated use {@link InMemoryByteDataStoreFactory} instead. */ +@Deprecated +@SuppressWarnings("java:S3740") public class InMemoryDataStoreFactory implements DataStoreFactory { private InMemoryDataStore store; diff --git a/scm-ui/ui-api/src/index.ts b/scm-ui/ui-api/src/index.ts index 2434d1a977..974bd58233 100644 --- a/scm-ui/ui-api/src/index.ts +++ b/scm-ui/ui-api/src/index.ts @@ -45,6 +45,7 @@ export * from "./permissions"; export * from "./sources"; export * from "./import"; export * from "./diff"; +export * from "./notifications"; export { default as ApiProvider } from "./ApiProvider"; export * from "./ApiProvider"; diff --git a/scm-ui/ui-api/src/notifications.ts b/scm-ui/ui-api/src/notifications.ts new file mode 100644 index 0000000000..e5ea5a9a4a --- /dev/null +++ b/scm-ui/ui-api/src/notifications.ts @@ -0,0 +1,183 @@ +/* + * MIT License + * + * Copyright (c) 2020-present Cloudogu GmbH and Contributors + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +import { useMe } from "./login"; +import { useMutation, useQuery, useQueryClient } from "react-query"; +import { Link, Notification, NotificationCollection } from "@scm-manager/ui-types"; +import { apiClient } from "./apiclient"; +import { useCallback, useEffect, useState } from "react"; +import { requiredLink } from "./links"; + +export const useNotifications = () => { + const { data: me } = useMe(); + const link = (me?._links["notifications"] as Link)?.href; + const { data, error, isLoading, refetch } = useQuery( + "notifications", + () => apiClient.get(link).then(response => response.json()), + { + enabled: !!link + } + ); + + const memoizedRefetch = useCallback(() => { + return refetch().then(r => r.data); + }, [refetch]); + + return { + data, + error, + isLoading, + refetch: memoizedRefetch + }; +}; + +export const useDismissNotification = (notification: Notification) => { + const queryClient = useQueryClient(); + const link = requiredLink(notification, "dismiss"); + const { data, isLoading, error, mutate } = useMutation(() => apiClient.delete(link), { + onSuccess: () => { + queryClient.invalidateQueries("notifications"); + } + }); + return { + isLoading, + error, + dismiss: () => mutate(), + isCleared: !!data + }; +}; + +export const useClearNotifications = (notificationCollection: NotificationCollection) => { + const queryClient = useQueryClient(); + const link = requiredLink(notificationCollection, "clear"); + const { data, isLoading, error, mutate } = useMutation(() => apiClient.delete(link), { + onSuccess: () => { + queryClient.invalidateQueries("notifications"); + } + }); + return { + isLoading, + error, + clear: () => mutate(), + isCleared: !!data + }; +}; + +const isEqual = (left: Notification, right: Notification) => { + return left === right || (left.message === right.message && left.createdAt === right.createdAt); +}; + +export const useNotificationSubscription = ( + refetch: () => Promise, + notificationCollection?: NotificationCollection +) => { + const [notifications, setNotifications] = useState([]); + const [disconnectedAt, setDisconnectedAt] = useState(); + const link = (notificationCollection?._links.subscribe as Link)?.href; + + const onVisible = useCallback(() => { + // we don't need to catch the error, + // because if the refetch throws an error the parent useNotifications should catch it + refetch().then(collection => { + if (collection) { + const newNotifications = collection._embedded.notifications.filter(n => { + return disconnectedAt && disconnectedAt < new Date(n.createdAt); + }); + if (newNotifications.length > 0) { + setNotifications(previous => [...previous, ...newNotifications]); + } + setDisconnectedAt(undefined); + } + }); + }, [disconnectedAt, refetch]); + + const onHide = useCallback(() => { + setDisconnectedAt(new Date()); + }, []); + + const received = useCallback( + (notification: Notification) => { + setNotifications(previous => [...previous, notification]); + refetch(); + }, + [refetch] + ); + + useEffect(() => { + if (link) { + let cancel: () => void; + + const disconnect = () => { + if (cancel) { + cancel(); + } + }; + + const connect = () => { + disconnect(); + cancel = apiClient.subscribe(link, { + notification: event => { + received(JSON.parse(event.data)); + } + }); + }; + + const handleVisibilityChange = () => { + if (document.visibilityState === "visible") { + onVisible(); + } else { + onHide(); + } + }; + + if (document.visibilityState === "visible") { + connect(); + } + + document.addEventListener("visibilitychange", handleVisibilityChange); + + return () => { + disconnect(); + document.removeEventListener("visibilitychange", handleVisibilityChange); + }; + } + }, [link, onVisible, onHide, received]); + + const remove = useCallback( + (notification: Notification) => { + setNotifications(oldNotifications => [...oldNotifications.filter(n => !isEqual(n, notification))]); + }, + [setNotifications] + ); + + const clear = useCallback(() => { + setNotifications([]); + }, [setNotifications]); + + return { + notifications, + remove, + clear + }; +}; diff --git a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap index 2f196db6b1..b6765931b0 100644 --- a/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap +++ b/scm-ui/ui-components/src/__snapshots__/storyshots.test.ts.snap @@ -77782,6 +77782,8 @@ exports[`Storyshots Toast Danger 1`] = `null`; exports[`Storyshots Toast Info 1`] = `null`; +exports[`Storyshots Toast Multiple 1`] = `null`; + exports[`Storyshots Toast Open/Close 1`] = `
{ const navigationItems = this.createNavigationItems(); return ( -