Feature/global notifications (#1646)

Add global notifications
This commit is contained in:
Sebastian Sdorra
2021-05-05 14:43:16 +02:00
committed by GitHub
parent de28cac4ab
commit b975fb655d
81 changed files with 3450 additions and 30 deletions

View File

@@ -84,6 +84,7 @@ public class MeDtoFactory extends HalAppenderMapper {
private MeDto createDto(User user) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
if (UserPermissions.delete(user).isPermitted()) {
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
}
@@ -100,6 +101,8 @@ public class MeDtoFactory extends HalAppenderMapper {
linksBuilder.single(link("apiKeys", resourceLinks.apiKeyCollection().self(user.getName())));
}
linksBuilder.single(link("notifications", resourceLinks.me().notifications()));
Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);

View File

@@ -65,13 +65,15 @@ public class MeResource {
private final PasswordService passwordService;
private final Provider<ApiKeyResource> apiKeyResourceProvider;
private final Provider<NotificationResource> notificationResourceProvider;
@Inject
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider) {
public MeResource(MeDtoFactory meDtoFactory, UserManager userManager, PasswordService passwordService, Provider<ApiKeyResource> apiKeyResourceProvider, Provider<NotificationResource> notificationResourceProvider) {
this.meDtoFactory = meDtoFactory;
this.userManager = userManager;
this.passwordService = passwordService;
this.apiKeyResourceProvider = apiKeyResourceProvider;
this.notificationResourceProvider = notificationResourceProvider;
}
/**
@@ -144,4 +146,9 @@ public class MeResource {
public ApiKeyResource apiKeys() {
return apiKeyResourceProvider.get();
}
@Path("notifications")
public NotificationResource notifications() {
return notificationResourceProvider.get();
}
}

View File

@@ -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.api.v2.resources;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
import lombok.Data;
import sonia.scm.notifications.StoredNotification;
import sonia.scm.notifications.Type;
import java.time.Instant;
@Data
public class NotificationDto extends HalRepresentation {
private Instant createdAt;
private Type type;
private String link;
private String message;
public NotificationDto(StoredNotification notification, Links links) {
super(links);
this.type = notification.getType();
this.link = notification.getLink();
this.message = notification.getMessage();
this.createdAt = notification.getCreatedAt();
}
}

View File

@@ -0,0 +1,213 @@
/*
* 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.api.v2.resources;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Link;
import de.otto.edison.hal.Links;
import io.swagger.v3.oas.annotations.OpenAPIDefinition;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import sonia.scm.notifications.NotificationChannelId;
import sonia.scm.notifications.NotificationStore;
import sonia.scm.notifications.StoredNotification;
import sonia.scm.security.SessionId;
import sonia.scm.sse.Channel;
import sonia.scm.sse.ChannelRegistry;
import sonia.scm.sse.Registration;
import sonia.scm.web.VndMediaType;
import javax.inject.Inject;
import javax.ws.rs.DELETE;
import javax.ws.rs.GET;
import javax.ws.rs.Path;
import javax.ws.rs.PathParam;
import javax.ws.rs.Produces;
import javax.ws.rs.QueryParam;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.UriInfo;
import javax.ws.rs.sse.Sse;
import javax.ws.rs.sse.SseEventSink;
import java.util.List;
import java.util.stream.Collectors;
@OpenAPIDefinition(tags = {
@Tag(name = "Notifications", description = "Notification related endpoints")
})
public class NotificationResource {
private final NotificationStore store;
private final ChannelRegistry channelRegistry;
@Inject
public NotificationResource(NotificationStore store, ChannelRegistry channelRegistry) {
this.store = store;
this.channelRegistry = channelRegistry;
}
@GET
@Path("")
@Produces(VndMediaType.NOTIFICATION_COLLECTION)
@Operation(
summary = "Notifications",
description = "Returns all notifications for the current user",
tags = "Notifications",
operationId = "notifications_get_all"
)
@ApiResponse(
responseCode = "200",
description = "success",
content = @Content(
mediaType = VndMediaType.NOTIFICATION_COLLECTION,
schema = @Schema(implementation = HalRepresentation.class)
)
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public HalRepresentation getAll(@Context UriInfo uriInfo) {
return new HalRepresentation(
createCollectionLinks(uriInfo),
createEmbeddedNotifications(uriInfo)
);
}
@DELETE
@Path("{id}")
@Operation(
summary = "Dismiss",
description = "Dismiss the notification with the given id",
tags = "Notifications",
operationId = "notifications_dismiss"
)
@ApiResponse(
responseCode = "204",
description = "no content"
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response dismiss(@PathParam("id") String id) {
store.remove(id);
return Response.noContent().build();
}
@DELETE
@Path("")
@Operation(
summary = "Dismiss all",
description = "Dismiss all notifications for the current user",
tags = "Notifications",
operationId = "notifications_dismiss_all"
)
@ApiResponse(
responseCode = "204",
description = "no content"
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public Response dismissAll() {
store.clear();
return Response.noContent().build();
}
@GET
@Path("subscribe")
@Produces(MediaType.SERVER_SENT_EVENTS)
@Operation(
summary = "Subscribe",
description = "Subscribe to the sse event stream of notification for the current user",
tags = "Notifications",
operationId = "notifications_subscribe"
)
@ApiResponse(
responseCode = "200"
)
@ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
@ApiResponse(
responseCode = "500",
description = "internal server error",
content = @Content(
mediaType = VndMediaType.ERROR_TYPE,
schema = @Schema(implementation = ErrorDto.class)
))
public void subscribe(@Context Sse sse, @Context SseEventSink eventSink, @QueryParam(SessionId.PARAMETER) SessionId sessionId) {
Channel channel = channelRegistry.channel(NotificationChannelId.current());
channel.register(new Registration(sessionId, sse, eventSink));
}
private Embedded createEmbeddedNotifications(UriInfo uriInfo) {
List<NotificationDto> notifications = store.getAll()
.stream()
.map(n -> map(uriInfo, n))
.collect(Collectors.toList());
return Embedded.embedded("notifications", notifications);
}
private NotificationDto map(UriInfo uriInfo, StoredNotification storedNotification) {
String href = uriInfo.getAbsolutePathBuilder().path(storedNotification.getId()).build().toASCIIString();
Links links = Links.linkingTo().single(Link.link("dismiss", href)).build();
return new NotificationDto(storedNotification, links);
}
private Links createCollectionLinks(UriInfo uriInfo) {
String self = selfLink(uriInfo);
return Links.linkingTo()
.self(self)
.single(Link.link("clear", self))
.single(Link.link("subscribe", subscribeLink(uriInfo)))
.build();
}
private String selfLink(UriInfo uriInfo) {
return uriInfo.getAbsolutePath().toASCIIString();
}
private String subscribeLink(UriInfo uriInfo) {
return uriInfo.getRequestUriBuilder().path("subscribe").build().toASCIIString();
}
}

View File

@@ -41,6 +41,7 @@ import sonia.scm.ConcurrentModificationException;
import sonia.scm.NotFoundException;
import sonia.scm.Type;
import sonia.scm.importexport.ExportFileExtensionResolver;
import sonia.scm.importexport.ExportNotificationHandler;
import sonia.scm.importexport.ExportService;
import sonia.scm.importexport.FullScmRepositoryExporter;
import sonia.scm.importexport.RepositoryImportExportEncryption;
@@ -98,6 +99,7 @@ public class RepositoryExportResource {
private final RepositoryExportInformationToDtoMapper informationToDtoMapper;
private final ExportFileExtensionResolver fileExtensionResolver;
private final ResourceLinks resourceLinks;
private final ExportNotificationHandler notificationHandler;
@Inject
public RepositoryExportResource(RepositoryManager manager,
@@ -108,8 +110,8 @@ public class RepositoryExportResource {
RepositoryExportInformationToDtoMapper informationToDtoMapper,
ExportFileExtensionResolver fileExtensionResolver,
ResourceLinks resourceLinks,
MeterRegistry registry
) {
MeterRegistry registry,
ExportNotificationHandler notificationHandler) {
this.manager = manager;
this.serviceFactory = serviceFactory;
this.fullScmRepositoryExporter = fullScmRepositoryExporter;
@@ -119,6 +121,7 @@ public class RepositoryExportResource {
this.fileExtensionResolver = fileExtensionResolver;
this.resourceLinks = resourceLinks;
this.repositoryExportHandler = this.createExportHandlerPool(registry);
this.notificationHandler = notificationHandler;
}
/**
@@ -312,11 +315,7 @@ public class RepositoryExportResource {
Repository repository = getVerifiedRepository(namespace, name);
RepositoryPermissions.export(repository).check();
checkRepositoryIsAlreadyExporting(repository);
return exportAsync(repository, request.isAsync(), () -> {
Response response = exportFullRepository(repository, request.getPassword(), request.isAsync());
exportService.setExportFinished(repository);
return response;
});
return exportAsync(repository, request.isAsync(), () -> exportFullRepository(repository, request.getPassword(), request.isAsync()));
}
@DELETE
@@ -458,6 +457,7 @@ public class RepositoryExportResource {
return Response.status(204).build();
} else {
StreamingOutput output = os -> fullScmRepositoryExporter.export(repository, os, password);
exportService.setExportFinished(repository);
return Response
.ok(output, "application/x-gzip")
@@ -485,6 +485,7 @@ public class RepositoryExportResource {
return createResponse(repository, fileExtension, compressed, output);
}
} catch (IOException e) {
notificationHandler.handleFailedExport(repository);
throw new ExportFailedException(entity(repository).build(), "repository export failed", e);
}
}
@@ -533,7 +534,7 @@ public class RepositoryExportResource {
return executorService;
}
@SuppressWarnings("java:S110") // is ok for this type of exceptions
private static class WrongTypeException extends BadRequestException {
private static final String CODE = "4hSNNTBiu1";

View File

@@ -194,10 +194,12 @@ class ResourceLinks {
static class MeLinks {
private final LinkBuilder meLinkBuilder;
private final LinkBuilder notificationLinkBuilder;
private UserLinks userLinks;
MeLinks(ScmPathInfo pathInfo, UserLinks user) {
meLinkBuilder = new LinkBuilder(pathInfo, MeResource.class);
notificationLinkBuilder = new LinkBuilder(pathInfo, MeResource.class, NotificationResource.class);
userLinks = user;
}
@@ -216,6 +218,10 @@ class ResourceLinks {
public String passwordChange() {
return meLinkBuilder.method("changePassword").parameters().href();
}
String notifications() {
return notificationLinkBuilder.method("notifications").parameters().method("getAll").parameters().href();
}
}
public ApiKeyCollectionLinks apiKeyCollection() {

View File

@@ -0,0 +1,59 @@
/*
* 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.importexport;
import sonia.scm.notifications.Notification;
import sonia.scm.notifications.NotificationSender;
import sonia.scm.notifications.Type;
import sonia.scm.repository.Repository;
import javax.inject.Inject;
public class ExportNotificationHandler {
private final NotificationSender notificationSender;
@Inject
public ExportNotificationHandler(NotificationSender notificationSender) {
this.notificationSender = notificationSender;
}
public void handleFailedExport(Repository repository) {
notificationSender.send(getExportFailedNotification(repository));
}
public void handleSuccessfulExport(Repository repository) {
notificationSender.send(getExportSuccessfulNotification(repository));
}
private Notification getExportFailedNotification(Repository repository) {
return new Notification(Type.ERROR, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "exportFailed");
}
private Notification getExportSuccessfulNotification(Repository repository) {
return new Notification(Type.SUCCESS, "/repo/" + repository.getNamespaceAndName() + "/settings/general", "exportFinished");
}
}

View File

@@ -55,12 +55,14 @@ public class ExportService {
private final BlobStoreFactory blobStoreFactory;
private final DataStoreFactory dataStoreFactory;
private final ExportFileExtensionResolver fileExtensionResolver;
private final ExportNotificationHandler notificationHandler;
@Inject
public ExportService(BlobStoreFactory blobStoreFactory, DataStoreFactory dataStoreFactory, ExportFileExtensionResolver fileExtensionResolver) {
public ExportService(BlobStoreFactory blobStoreFactory, DataStoreFactory dataStoreFactory, ExportFileExtensionResolver fileExtensionResolver, ExportNotificationHandler notificationHandler) {
this.blobStoreFactory = blobStoreFactory;
this.dataStoreFactory = dataStoreFactory;
this.fileExtensionResolver = fileExtensionResolver;
this.notificationHandler = notificationHandler;
}
public OutputStream store(Repository repository, boolean withMetadata, boolean compressed, boolean encrypted) {
@@ -69,6 +71,7 @@ public class ExportService {
try {
return storeNewBlob(repository.getId()).getOutputStream();
} catch (IOException e) {
notificationHandler.handleFailedExport(repository);
throw new ExportFailedException(
entity(repository).build(),
"Could not store repository export to blob file",
@@ -120,6 +123,7 @@ public class ExportService {
RepositoryExportInformation info = dataStore.get(repository.getId());
info.setStatus(ExportStatus.FINISHED);
dataStore.put(repository.getId(), info);
notificationHandler.handleSuccessfulExport(repository);
}
public boolean isExporting(Repository repository) {

View File

@@ -59,6 +59,7 @@ public class FullScmRepositoryExporter {
private final WorkdirProvider workdirProvider;
private final RepositoryExportingCheck repositoryExportingCheck;
private final RepositoryImportExportEncryption repositoryImportExportEncryption;
private final ExportNotificationHandler notificationHandler;
@Inject
public FullScmRepositoryExporter(EnvironmentInformationXmlGenerator environmentGenerator,
@@ -67,7 +68,7 @@ public class FullScmRepositoryExporter {
TarArchiveRepositoryStoreExporter storeExporter,
WorkdirProvider workdirProvider,
RepositoryExportingCheck repositoryExportingCheck,
RepositoryImportExportEncryption repositoryImportExportEncryption) {
RepositoryImportExportEncryption repositoryImportExportEncryption, ExportNotificationHandler notificationHandler) {
this.environmentGenerator = environmentGenerator;
this.metadataGenerator = metadataGenerator;
this.serviceFactory = serviceFactory;
@@ -75,13 +76,19 @@ public class FullScmRepositoryExporter {
this.workdirProvider = workdirProvider;
this.repositoryExportingCheck = repositoryExportingCheck;
this.repositoryImportExportEncryption = repositoryImportExportEncryption;
this.notificationHandler = notificationHandler;
}
public void export(Repository repository, OutputStream outputStream, String password) {
repositoryExportingCheck.withExportingLock(repository, () -> {
exportInLock(repository, outputStream, password);
return null;
});
try {
repositoryExportingCheck.withExportingLock(repository, () -> {
exportInLock(repository, outputStream, password);
return null;
});
} catch (ExportFailedException ex) {
notificationHandler.handleFailedExport(repository);
throw ex;
}
}
private void exportInLock(Repository repository, OutputStream outputStream, String password) {

View File

@@ -63,6 +63,8 @@ import sonia.scm.net.ahc.ContentTransformer;
import sonia.scm.net.ahc.DefaultAdvancedHttpClient;
import sonia.scm.net.ahc.JsonContentTransformer;
import sonia.scm.net.ahc.XmlContentTransformer;
import sonia.scm.notifications.DefaultNotificationSender;
import sonia.scm.notifications.NotificationSender;
import sonia.scm.plugin.DefaultPluginManager;
import sonia.scm.plugin.PluginLoader;
import sonia.scm.plugin.PluginManager;
@@ -263,6 +265,8 @@ class ScmServletModule extends ServletModule {
bind(PermissionProvider.class).to(RepositoryPermissionProvider.class);
bind(HealthCheckService.class).to(DefaultHealthCheckService.class);
bind(NotificationSender.class).to(DefaultNotificationSender.class);
}
private <T> void bind(Class<T> clazz, Class<? extends T> defaultImplementation) {

View File

@@ -0,0 +1,58 @@
/*
* 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 com.google.common.annotations.VisibleForTesting;
import sonia.scm.sse.Channel;
import sonia.scm.sse.ChannelRegistry;
import sonia.scm.sse.Message;
import javax.inject.Inject;
public class DefaultNotificationSender implements NotificationSender {
@VisibleForTesting
static final String MESSAGE_NAME = "notification";
private final NotificationStore store;
private final ChannelRegistry channelRegistry;
@Inject
public DefaultNotificationSender(NotificationStore store, ChannelRegistry channelRegistry) {
this.store = store;
this.channelRegistry = channelRegistry;
}
@Override
public void send(Notification notification, String recipient) {
store.add(notification, recipient);
Channel channel = channelRegistry.channel(new NotificationChannelId(recipient));
channel.broadcast(message(notification));
}
private Message message(Notification notification) {
return new Message(MESSAGE_NAME, Notification.class, notification);
}
}

View File

@@ -0,0 +1,44 @@
/*
* 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.EqualsAndHashCode;
import org.apache.shiro.SecurityUtils;
@EqualsAndHashCode
public class NotificationChannelId {
private final String username;
public NotificationChannelId(String username) {
this.username = username;
}
public static NotificationChannelId current() {
return new NotificationChannelId(
SecurityUtils.getSubject().getPrincipal().toString()
);
}
}

View File

@@ -0,0 +1,162 @@
/*
* 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 com.github.legman.Subscribe;
import com.google.common.util.concurrent.Striped;
import lombok.Data;
import org.apache.shiro.SecurityUtils;
import sonia.scm.HandlerEventType;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.user.UserEvent;
import javax.inject.Inject;
import javax.inject.Singleton;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.XmlElement;
import javax.xml.bind.annotation.XmlElementWrapper;
import javax.xml.bind.annotation.XmlRootElement;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.stream.Collectors;
@Singleton
@SuppressWarnings("UnstableApiUsage") // striped is still marked as beta
public class NotificationStore {
private static final String NAME = "notifications";
private final DataStore<StoredNotifications> store;
private final KeyGenerator keyGenerator;
private final Striped<ReadWriteLock> locks = Striped.readWriteLock(10);
@Inject
public NotificationStore(DataStoreFactory dataStoreFactory, KeyGenerator keyGenerator) {
this.store = dataStoreFactory.withType(StoredNotifications.class)
.withName(NAME)
.build();
this.keyGenerator = keyGenerator;
}
public String add(Notification notification, String username) {
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
String id = keyGenerator.createKey();
notifications.getEntries().add(new StoredNotification(id, notification));
store.put(username, notifications);
return id;
} finally {
lock.unlock();
}
}
private StoredNotifications get(String username) {
return store.getOptional(username).orElse(new StoredNotifications());
}
public List<StoredNotification> getAll() {
String username = getCurrentUsername();
Lock lock = locks.get(username).readLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
return Collections.unmodifiableList(notifications.getEntries());
} finally {
lock.unlock();
}
}
public void remove(String id) {
String username = getCurrentUsername();
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
List<StoredNotification> entries = notifications.getEntries()
.stream()
.filter(n -> !id.equals(n.id))
.collect(Collectors.toList());
notifications.setEntries(entries);
store.put(username, notifications);
} finally {
lock.unlock();
}
}
public void clear() {
String username = getCurrentUsername();
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
StoredNotifications notifications = get(username);
notifications.getEntries().clear();
store.put(username, notifications);
} finally {
lock.unlock();
}
}
private String getCurrentUsername() {
return SecurityUtils.getSubject().getPrincipal().toString();
}
@Subscribe
public void handle(UserEvent event) {
if (event.getEventType() == HandlerEventType.DELETE) {
String username = event.getItem().getName();
Lock lock = locks.get(username).writeLock();
try {
lock.lock();
store.remove(username);
} finally {
lock.unlock();
}
}
}
@Data
@XmlRootElement
@XmlAccessorType(XmlAccessType.FIELD)
static class StoredNotifications {
@XmlElement(name = "notification")
@XmlElementWrapper(name = "notifications")
private List<StoredNotification> entries;
public List<StoredNotification> getEntries() {
if (entries == null) {
entries = new ArrayList<>();
}
return entries;
}
}
}

View File

@@ -0,0 +1,57 @@
/*
* 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.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import sonia.scm.xml.XmlInstantAdapter;
import javax.xml.bind.annotation.XmlAccessType;
import javax.xml.bind.annotation.XmlAccessorType;
import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
import java.time.Instant;
@Data
@NoArgsConstructor
@AllArgsConstructor
@XmlAccessorType(XmlAccessType.FIELD)
public class StoredNotification {
String id;
Type type;
String link;
String message;
@XmlJavaTypeAdapter(XmlInstantAdapter.class)
Instant createdAt;
StoredNotification(String id, Notification notification) {
this.id = id;
this.createdAt = notification.getCreatedAt();
this.type = notification.getType();
this.link = notification.getLink();
this.message = notification.getMessage();
}
}