diff --git a/pom.xml b/pom.xml
index f3d1c8a..97ace86 100644
--- a/pom.xml
+++ b/pom.xml
@@ -66,6 +66,16 @@
bcprov-jdk15on
1.64
+
+ org.apache.commons
+ commons-collections4
+ 4.3
+
+
+ junit
+ junit
+ 4.12
+
\ No newline at end of file
diff --git a/push-service-client-lib/pom.xml b/push-service-client-lib/pom.xml
index d2b5d5b..6969b62 100644
--- a/push-service-client-lib/pom.xml
+++ b/push-service-client-lib/pom.xml
@@ -16,10 +16,6 @@
push-service-client-lib
-
- org.bouncycastle
- bcprov-jdk15on
-
org.springframework
spring-web
@@ -28,6 +24,16 @@
org.springframework.boot
spring-boot-starter-web
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+ org.apache.commons
+ commons-collections4
+
diff --git a/push-service-client-lib/readme.txt b/push-service-client-lib/readme.txt
index a47e019..9bbdd9e 100644
--- a/push-service-client-lib/readme.txt
+++ b/push-service-client-lib/readme.txt
@@ -5,7 +5,7 @@
2. How to use the client-lib in a client application
====================================================
- Include property 'push-service-client.serverUrl=' and set it to the path to the server part
-- (optional) Enable loggin via property 'logging.level.de.pushservice=DEBUG'
+- (optional) Enable logging via property 'logging.level.de.pushservice=DEBUG'
- Enable component scan in app configuration via '@ComponentScan("de.pushservice.client")'
- Obtain an instance of SubscriptionService via '@Autowired'
- Add a JavaScript file with content 'registerServiceWorker();' to register the service worker and subscribing at the
diff --git a/push-service-client-lib/src/main/java/de/pushservice/client/ResponseReason.java b/push-service-client-lib/src/main/java/de/pushservice/client/ResponseReason.java
index d2ea593..ad1eaba 100644
--- a/push-service-client-lib/src/main/java/de/pushservice/client/ResponseReason.java
+++ b/push-service-client-lib/src/main/java/de/pushservice/client/ResponseReason.java
@@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity;
public enum ResponseReason {
OK(HttpStatus.OK),
+ EMPTY_SCOPE(HttpStatus.BAD_REQUEST),
UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR);
private final HttpStatus httpStatus;
diff --git a/push-service-client-lib/src/main/java/de/pushservice/client/controller/SubscriptionController.java b/push-service-client-lib/src/main/java/de/pushservice/client/controller/SubscriptionController.java
index 8d63749..9ed5eb1 100644
--- a/push-service-client-lib/src/main/java/de/pushservice/client/controller/SubscriptionController.java
+++ b/push-service-client-lib/src/main/java/de/pushservice/client/controller/SubscriptionController.java
@@ -1,6 +1,5 @@
package de.pushservice.client.controller;
-import de.pushservice.client.ResponseReason;
import de.pushservice.client.dto.SubscriptionDto;
import de.pushservice.client.service.SubscriptionService;
import org.slf4j.Logger;
@@ -22,8 +21,6 @@ public class SubscriptionController {
public ResponseEntity subscribe(@RequestBody SubscriptionDto subscriptionDto) {
LOGGER.debug(String.format("Received subscription for endpoint %s", subscriptionDto.getEndpoint()));
- this.subscriptionService.subscribe(subscriptionDto);
-
- return ResponseReason.OK.toResponseEntity();
+ return this.subscriptionService.subscribe(subscriptionDto).toResponseEntity();
}
}
diff --git a/push-service-client-lib/src/main/java/de/pushservice/client/dto/NotificationRequestDto.java b/push-service-client-lib/src/main/java/de/pushservice/client/dto/NotificationRequestDto.java
index 4758416..daaa81f 100644
--- a/push-service-client-lib/src/main/java/de/pushservice/client/dto/NotificationRequestDto.java
+++ b/push-service-client-lib/src/main/java/de/pushservice/client/dto/NotificationRequestDto.java
@@ -1,20 +1,20 @@
package de.pushservice.client.dto;
public class NotificationRequestDto {
- private SubscriptionDto subscriptionDto;
+ private String scope;
private MessageDto messageDto;
- public NotificationRequestDto(SubscriptionDto subscriptionDto, MessageDto messageDto) {
- this.subscriptionDto = subscriptionDto;
+ public NotificationRequestDto(String scope, MessageDto messageDto) {
+ this.scope = scope;
this.messageDto = messageDto;
}
- public SubscriptionDto getSubscriptionDto() {
- return subscriptionDto;
+ public String getScope() {
+ return scope;
}
- public void setSubscriptionDto(SubscriptionDto subscriptionDto) {
- this.subscriptionDto = subscriptionDto;
+ public void setScope(String scope) {
+ this.scope = scope;
}
public MessageDto getMessageDto() {
diff --git a/push-service-client-lib/src/main/java/de/pushservice/client/dto/SubscriptionDto.java b/push-service-client-lib/src/main/java/de/pushservice/client/dto/SubscriptionDto.java
index fa006ce..9093064 100644
--- a/push-service-client-lib/src/main/java/de/pushservice/client/dto/SubscriptionDto.java
+++ b/push-service-client-lib/src/main/java/de/pushservice/client/dto/SubscriptionDto.java
@@ -4,6 +4,7 @@ public class SubscriptionDto {
private String auth;
private String endpoint;
private String key;
+ private String scope;
public void setAuth(String auth) {
this.auth = auth;
@@ -28,4 +29,12 @@ public class SubscriptionDto {
public String getEndpoint() {
return endpoint;
}
+
+ public String getScope() {
+ return scope;
+ }
+
+ public void setScope(String scope) {
+ this.scope = scope;
+ }
}
diff --git a/push-service-client-lib/src/main/java/de/pushservice/client/service/SubscriptionService.java b/push-service-client-lib/src/main/java/de/pushservice/client/service/SubscriptionService.java
index 8181686..07d1b01 100644
--- a/push-service-client-lib/src/main/java/de/pushservice/client/service/SubscriptionService.java
+++ b/push-service-client-lib/src/main/java/de/pushservice/client/service/SubscriptionService.java
@@ -9,6 +9,7 @@ import de.pushservice.client.dto.NotificationRequestDto;
import de.pushservice.client.dto.PayloadDto;
import de.pushservice.client.dto.SubscriptionDto;
import de.pushservice.client.model.Urgency;
+import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -30,37 +31,41 @@ public class SubscriptionService {
@Autowired
private ObjectMapper objectMapper;
- private Set subscriptions = new HashSet<>();
+ private String scope;
- public void subscribe(SubscriptionDto subscriptionDto) {
- this.subscriptions.add(subscriptionDto);
+ public boolean isInitialized() {
+ return StringUtils.isNotEmpty(this.scope);
}
- public int notifyAll(PayloadDto payload, String topic, Urgency urgency) throws JsonProcessingException {
- int notificationsSend = 0;
- final Urgency tmpUrgency = Optional.ofNullable(urgency).orElse(Urgency.NORMAL);
-
- final MessageDto messageDto = new MessageDto(objectMapper.writeValueAsString(payload), topic, tmpUrgency);
-
- for (SubscriptionDto subscription : this.subscriptions) {
- final NotificationRequestDto requestDto = new NotificationRequestDto(subscription, messageDto);
-
- LOGGER.debug(String.format("Sending notification for endpoint %s", subscription.getEndpoint()));
-
- final ResponseEntity responseEntity = new RestTemplate()
- .exchange(this.config.getServerUrl(), HttpMethod.POST, new HttpEntity<>(requestDto), String.class);
-
- final ResponseReason responseReason = ResponseReason.fromResponseEntity(responseEntity);
-
- if (ResponseReason.OK == responseReason) {
- notificationsSend++;
- }
- else {
- // Well, nothing we can do about it
- LOGGER.error("Sending notification to endpoint failed! %s", responseReason);
+ public ResponseReason subscribe(SubscriptionDto subscriptionDto) {
+ if (StringUtils.isEmpty(this.scope)) {
+ this.scope = subscriptionDto.getScope();
+ }
+ else {
+ if(!this.scope.equals(subscriptionDto.getScope())) {
+ // Should not happen since the scope is the host + context path of the hosting app
+ LOGGER.warn(String.format("Scope changed! Old: %s, new %s", this.scope, subscriptionDto.getScope()));
}
}
- return notificationsSend;
+ final ResponseEntity responseEntity = new RestTemplate()
+ .exchange(this.config.getServerUrl() + "subscribe", HttpMethod.POST, new HttpEntity<>(subscriptionDto), String.class);
+
+ return ResponseReason.fromResponseEntity(responseEntity);
+ }
+
+ public void notify(PayloadDto payload, String topic, Urgency urgency) throws JsonProcessingException {
+ final Urgency tmpUrgency = Optional.ofNullable(urgency).orElse(Urgency.NORMAL);
+ final MessageDto messageDto = new MessageDto(objectMapper.writeValueAsString(payload), topic, tmpUrgency);
+ final NotificationRequestDto requestDto = new NotificationRequestDto(this.scope, messageDto);
+
+ final ResponseEntity responseEntity = new RestTemplate()
+ .exchange(this.config.getServerUrl() + "notify", HttpMethod.POST, new HttpEntity<>(requestDto), String.class);
+
+ final ResponseReason responseReason = ResponseReason.fromResponseEntity(responseEntity);
+
+ if (ResponseReason.OK != responseReason) {
+ LOGGER.error(String.format("Could not send notification! %s", responseReason));
+ }
}
}
diff --git a/push-service-client-lib/src/main/resources/static/javascript/push-service-client-init.js b/push-service-client-lib/src/main/resources/static/javascript/push-service-client-init.js
index 7053990..b68587e 100644
--- a/push-service-client-lib/src/main/resources/static/javascript/push-service-client-init.js
+++ b/push-service-client-lib/src/main/resources/static/javascript/push-service-client-init.js
@@ -5,7 +5,7 @@ function registerServiceWorker() {
// Registration was successful
console.log('ServiceWorker registration successful with scope: ', registration.scope);
- initializeState();
+ initializeState(registration.scope);
}, function(err) {
// registration failed :(
console.log('ServiceWorker registration failed: ', err);
@@ -14,7 +14,7 @@ function registerServiceWorker() {
}
}
-function initializeState() {
+function initializeState(registrationScope) {
if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
console.warn('Notifications aren\'t supported.');
return;
@@ -33,11 +33,11 @@ function initializeState() {
navigator.serviceWorker.ready.then(function (serviceWorkerRegistration) {
serviceWorkerRegistration.pushManager.getSubscription().then(function (subscription) {
if (!subscription) {
- subscribe();
+ subscribe(registrationScope);
return;
}
- sendSubscriptionToServer(subscription);
+ sendSubscriptionToServer(subscription, registrationScope);
})
.catch(function(err) {
console.warn('Error during getSubscription()', err);
@@ -45,10 +45,10 @@ function initializeState() {
});
}
-function subscribe() {
+function subscribe(registrationScope) {
navigator.serviceWorker.ready.then(function (serviceWorkerRegistration) {
serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true}).then(function (subscription) {
- return sendSubscriptionToServer(subscription);
+ return sendSubscriptionToServer(subscription, registrationScope);
})
.catch(function (e) {
if (Notification.permission === 'denied') {
@@ -60,7 +60,7 @@ function subscribe() {
});
}
-function sendSubscriptionToServer(subscription) {
+function sendSubscriptionToServer(subscription, registrationScope) {
var key = subscription.getKey ? subscription.getKey('p256dh') : '';
var auth = subscription.getKey ? subscription.getKey('auth') : '';
@@ -76,7 +76,8 @@ function sendSubscriptionToServer(subscription) {
// Take byte[] and turn it into a base64 encoded string suitable for
// POSTing to a server over HTTP
key: key ? btoa(String.fromCharCode.apply(null, new Uint8Array(key))) : '',
- auth: auth ? btoa(String.fromCharCode.apply(null, new Uint8Array(auth))) : ''
+ auth: auth ? btoa(String.fromCharCode.apply(null, new Uint8Array(auth))) : '',
+ scope: registrationScope
})
});
}
\ No newline at end of file
diff --git a/push-service-server/pom.xml b/push-service-server/pom.xml
index 2f1a81e..6b976ad 100644
--- a/push-service-server/pom.xml
+++ b/push-service-server/pom.xml
@@ -17,21 +17,77 @@
jar
+ postgres
+ mk
-
- nl.martijndwars
- web-push
-
+
org.springframework.boot
spring-boot-starter-web
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+
+ org.apache.commons
+ commons-lang3
+
+
+ org.apache.commons
+ commons-collections4
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+
+
+ org.flywaydb
+ flyway-core
+
+
+
+
+ nl.martijndwars
+ web-push
+
de.77zzcx7.push-service
push-service-client-lib
- ${version}
+ ${project.version}
+
+
+
+
+ org.hsqldb
+ hsqldb
+ runtime
+
+
+ org.postgresql
+ postgresql
+ runtime
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.restdocs
+ spring-restdocs-mockmvc
+ test
+
+
+ junit
+ junit
+ test
@@ -40,9 +96,10 @@
build-war
war
+ postgres,${deploymentProfile}
- ${project.artifactId}##${parallelDeploymentVersion}
+ ${project.artifactId}-${deploymentProfile}##${parallelDeploymentVersion}
diff --git a/push-service-server/src/main/java/de/pushservice/server/PushServiceApplication.java b/push-service-server/src/main/java/de/pushservice/server/PushServiceApplication.java
index e2bdeea..c72d749 100644
--- a/push-service-server/src/main/java/de/pushservice/server/PushServiceApplication.java
+++ b/push-service-server/src/main/java/de/pushservice/server/PushServiceApplication.java
@@ -4,11 +4,13 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
+import org.springframework.scheduling.annotation.EnableAsync;
/**
* This whole application is basically just a thin layer around the webpush-java lib
*/
@SpringBootApplication
+@EnableAsync
public class PushServiceApplication extends SpringBootServletInitializer {
public static void main(String[] args) {
SpringApplication.run(PushServiceApplication.class);
diff --git a/push-service-server/src/main/java/de/pushservice/server/controller/PushServiceController.java b/push-service-server/src/main/java/de/pushservice/server/controller/PushServiceController.java
index 9f91079..ded2296 100644
--- a/push-service-server/src/main/java/de/pushservice/server/controller/PushServiceController.java
+++ b/push-service-server/src/main/java/de/pushservice/server/controller/PushServiceController.java
@@ -1,14 +1,12 @@
package de.pushservice.server.controller;
-import de.pushservice.client.ResponseReason;
import de.pushservice.client.dto.NotificationRequestDto;
-import de.pushservice.server.decorator.MessageDecorator;
-import de.pushservice.server.decorator.SubscriptionDecorator;
-import nl.martijndwars.webpush.Notification;
-import nl.martijndwars.webpush.PushService;
+import de.pushservice.client.dto.SubscriptionDto;
+import de.pushservice.server.service.PushService;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -20,27 +18,20 @@ import java.security.Security;
public class PushServiceController {
private static final Logger LOGGER = LoggerFactory.getLogger(PushServiceController.class);
+ @Autowired
+ private PushService pushService;
+
static {
Security.addProvider(new BouncyCastleProvider());
}
- @PostMapping
+ @PostMapping("/notify")
public ResponseEntity notify(@RequestBody NotificationRequestDto notificationRequestDto) {
- try {
- final SubscriptionDecorator subscription = SubscriptionDecorator
- .fromDto(notificationRequestDto.getSubscriptionDto());
- final Notification notification = MessageDecorator.fromDto(notificationRequestDto.getMessageDto())
- .toNotification(subscription);
- final PushService extPushService = new PushService();
+ return this.pushService.notify(notificationRequestDto).toResponseEntity();
+ }
- extPushService.send(notification);
-
- return ResponseReason.OK.toResponseEntity();
- }
- catch(Exception e) {
- LOGGER.error("Error while sending notification!", e);
-
- return ResponseReason.UNKNOWN_ERROR.toResponseEntity();
- }
+ @PostMapping("/subscribe")
+ public ResponseEntity subscribe(@RequestBody SubscriptionDto subscriptionDto) {
+ return this.pushService.subscribe(subscriptionDto).toResponseEntity();
}
}
diff --git a/push-service-server/src/main/java/de/pushservice/server/dba/SubscriptionRepository.java b/push-service-server/src/main/java/de/pushservice/server/dba/SubscriptionRepository.java
new file mode 100644
index 0000000..5b2cb69
--- /dev/null
+++ b/push-service-server/src/main/java/de/pushservice/server/dba/SubscriptionRepository.java
@@ -0,0 +1,18 @@
+package de.pushservice.server.dba;
+
+import de.pushservice.server.model.Subscription;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.CrudRepository;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Optional;
+
+@Transactional(propagation = Propagation.REQUIRED)
+public interface SubscriptionRepository extends CrudRepository {
+ @Query("SELECT s FROM Subscription s WHERE s.scope = :scope")
+ Iterable getAllForScope(String scope);
+
+ @Query("SELECT s FROM Subscription s WHERE s.scope = :scope AND s.endpoint = :endpoint")
+ Optional getForScopeAndEndpoint(String scope, String endpoint);
+}
diff --git a/push-service-server/src/main/java/de/pushservice/server/decorator/MessageDecorator.java b/push-service-server/src/main/java/de/pushservice/server/decorator/MessageDecorator.java
deleted file mode 100644
index a18561e..0000000
--- a/push-service-server/src/main/java/de/pushservice/server/decorator/MessageDecorator.java
+++ /dev/null
@@ -1,36 +0,0 @@
-package de.pushservice.server.decorator;
-
-import de.pushservice.client.dto.MessageDto;
-import nl.martijndwars.webpush.Notification;
-import nl.martijndwars.webpush.Urgency;
-
-import java.security.NoSuchAlgorithmException;
-import java.security.NoSuchProviderException;
-import java.security.spec.InvalidKeySpecException;
-
-public class MessageDecorator {
- private MessageDto dto;
-
- private MessageDecorator(MessageDto dto) {
- this.dto = dto;
- }
-
- public static final MessageDecorator fromDto(MessageDto dto) {
- return new MessageDecorator(dto);
- }
-
- public Notification toNotification(SubscriptionDecorator subscription) throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
- return Notification.builder()
- .endpoint(subscription.getEndpoint())
- .userPublicKey(subscription.getUserPublicKey())
- .userAuth(subscription.getAuthAsBytes())
- .payload(dto.getPayload())
- .urgency(getExtUrgency())
- .topic(dto.getTopic())
- .build();
- }
-
- private Urgency getExtUrgency() {
- return Urgency.valueOf(dto.getUrgency().name());
- }
-}
diff --git a/push-service-server/src/main/java/de/pushservice/server/decorator/SubscriptionDecorator.java b/push-service-server/src/main/java/de/pushservice/server/decorator/SubscriptionDecorator.java
index f6890be..b9b7221 100644
--- a/push-service-server/src/main/java/de/pushservice/server/decorator/SubscriptionDecorator.java
+++ b/push-service-server/src/main/java/de/pushservice/server/decorator/SubscriptionDecorator.java
@@ -1,6 +1,6 @@
package de.pushservice.server.decorator;
-import de.pushservice.client.dto.SubscriptionDto;
+import de.pushservice.server.model.Subscription;
import org.bouncycastle.jce.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec;
@@ -15,17 +15,17 @@ import java.security.spec.InvalidKeySpecException;
import java.util.Base64;
public class SubscriptionDecorator {
- private SubscriptionDto dto;
+ private Subscription dto;
- private SubscriptionDecorator(SubscriptionDto dto) {
+ private SubscriptionDecorator(Subscription dto) {
this.dto = dto;
}
- public static final SubscriptionDecorator fromDto(SubscriptionDto dto) {
+ public static final SubscriptionDecorator from(Subscription dto) {
return new SubscriptionDecorator(dto);
}
- byte[] getAuthAsBytes() {
+ public byte[] getAuthAsBytes() {
return Base64.getDecoder().decode(dto.getAuth());
}
@@ -33,7 +33,7 @@ public class SubscriptionDecorator {
return Base64.getDecoder().decode(dto.getKey());
}
- PublicKey getUserPublicKey() throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
+ public PublicKey getUserPublicKey() throws NoSuchAlgorithmException, InvalidKeySpecException, NoSuchProviderException {
KeyFactory kf = KeyFactory.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME);
ECNamedCurveParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256r1");
ECPoint point = ecSpec.getCurve().decodePoint(getKeyAsBytes());
@@ -42,7 +42,7 @@ public class SubscriptionDecorator {
return kf.generatePublic(pubSpec);
}
- String getEndpoint() {
+ public String getEndpoint() {
return dto.getEndpoint();
}
}
diff --git a/push-service-server/src/main/java/de/pushservice/server/model/Subscription.java b/push-service-server/src/main/java/de/pushservice/server/model/Subscription.java
new file mode 100644
index 0000000..11e6868
--- /dev/null
+++ b/push-service-server/src/main/java/de/pushservice/server/model/Subscription.java
@@ -0,0 +1,85 @@
+package de.pushservice.server.model;
+
+import javax.persistence.Entity;
+import javax.persistence.GeneratedValue;
+import javax.persistence.GenerationType;
+import javax.persistence.Id;
+import java.time.LocalDateTime;
+
+@Entity
+public class Subscription {
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ private Long id;
+ private String scope;
+ private String auth;
+ private String key;
+ private String endpoint;
+ private LocalDateTime insertDate;
+ private LocalDateTime lastSeen;
+ private int errorCount;
+
+ public Long getId() {
+ return id;
+ }
+
+ public void setId(Long id) {
+ this.id = id;
+ }
+
+ public String getScope() {
+ return scope;
+ }
+
+ public void setScope(String scope) {
+ this.scope = scope;
+ }
+
+ public String getAuth() {
+ return auth;
+ }
+
+ public void setAuth(String auth) {
+ this.auth = auth;
+ }
+
+ public String getKey() {
+ return key;
+ }
+
+ public void setKey(String key) {
+ this.key = key;
+ }
+
+ public String getEndpoint() {
+ return endpoint;
+ }
+
+ public void setEndpoint(String endpoint) {
+ this.endpoint = endpoint;
+ }
+
+ public LocalDateTime getInsertDate() {
+ return insertDate;
+ }
+
+ public void setInsertDate(LocalDateTime insertDate) {
+ this.insertDate = insertDate;
+ }
+
+ public LocalDateTime getLastSeen() {
+ return lastSeen;
+ }
+
+ public void setLastSeen(LocalDateTime lastSeen) {
+ this.lastSeen = lastSeen;
+ }
+
+ public int getErrorCount() {
+ return errorCount;
+ }
+
+ public void setErrorCount(int errorCount) {
+ this.errorCount = errorCount;
+ }
+}
diff --git a/push-service-server/src/main/java/de/pushservice/server/service/ExternalPushServiceResponseHandler.java b/push-service-server/src/main/java/de/pushservice/server/service/ExternalPushServiceResponseHandler.java
new file mode 100644
index 0000000..1ac6dc0
--- /dev/null
+++ b/push-service-server/src/main/java/de/pushservice/server/service/ExternalPushServiceResponseHandler.java
@@ -0,0 +1,112 @@
+package de.pushservice.server.service;
+
+import de.pushservice.server.dba.SubscriptionRepository;
+import de.pushservice.server.model.Subscription;
+import org.apache.http.HttpResponse;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalDateTime;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+@Async
+@Component
+public class ExternalPushServiceResponseHandler {
+ private static final Logger LOGGER = LoggerFactory.getLogger(ExternalPushServiceResponseHandler.class);
+
+ @Autowired
+ private SubscriptionRepository subscriptionRepository;
+
+ /**
+ * @param asyncResponse - the response from the third party service
+ * @param subscription - the subscription the notification was sent for
+ *
+ * @see Google
+ * developer documentation on response codes from third party push service
+ */
+ public void handleResponse(Future asyncResponse, Subscription subscription) {
+ try {
+ // First, wait for the task to complete - get() blocks execution
+ final HttpResponse response = asyncResponse.get();
+
+ // Then handle the return code
+ switch (response.getStatusLine().getStatusCode()) {
+ case 201: // Created
+ // Happy path, third party service received the notification and processed it
+ LOGGER.debug(String.format("Received 201 for endpoint %s", subscription.getEndpoint()));
+
+ subscription.setErrorCount(0); // reset error count after we get a success response
+
+ updateSubscription(subscription);
+ break;
+ case 429: // Too many requests
+ // We spammed the third party service, no particular handling required
+ LOGGER.warn(String.format("Received 429 for endpoint %s", subscription.getEndpoint()));
+
+ subscription.setErrorCount(subscription.getErrorCount() + 1);
+
+ updateSubscription(subscription);
+ break;
+ case 400: // Invalid request
+ // The request sent to the third party service is malformed
+ LOGGER.error(String.format("Received 400 for endpoint %s", subscription.getEndpoint()));
+
+ subscription.setErrorCount(subscription.getErrorCount() + 1);
+
+ updateSubscription(subscription);
+ break;
+ case 404: // Not found
+ // Subscription expired or endpoint is wrong -> subscription needs to be removed
+ LOGGER.info(String.format("Received 404 for endpoint %s", subscription.getEndpoint()));
+
+ deleteSubscription(subscription);
+ break;
+ case 410: // Gone
+ // Subscription expired or endpoint is wrong -> subscription needs to be removed
+ LOGGER.info(String.format("Received 410 for endpoint %s", subscription.getEndpoint()));
+
+ deleteSubscription(subscription);
+ break;
+ case 413: // Payload too large
+ // The notification created by the client app is too large
+ LOGGER.warn(String.format("Received 413 for endpoint %s", subscription.getEndpoint()));
+
+ subscription.setErrorCount(subscription.getErrorCount() + 1);
+
+ updateSubscription(subscription);
+ break;
+ default:
+ }
+ } catch (InterruptedException ire) {
+ LOGGER.error(String.format("Thread interrupted for endpoint %s", subscription.getEndpoint()));
+
+ Thread.currentThread().interrupt();
+ } catch (ExecutionException ee) {
+ LOGGER.error(String.format("Execution failed for endpoint %s", subscription.getEndpoint()), ee);
+
+ this.handleError(ee.getCause(), subscription);
+ }
+ }
+
+ public void handleError(Throwable t, Subscription subscription) {
+ // logging done at call sites
+
+ subscription.setErrorCount(subscription.getErrorCount() + 1);
+
+ updateSubscription(subscription);
+ }
+
+ private void deleteSubscription(Subscription subscription) {
+ this.subscriptionRepository.delete(subscription);
+ }
+
+ private void updateSubscription(Subscription subscription) {
+ subscription.setLastSeen(LocalDateTime.now());
+
+ this.subscriptionRepository.save(subscription);
+ }
+}
\ No newline at end of file
diff --git a/push-service-server/src/main/java/de/pushservice/server/service/PushService.java b/push-service-server/src/main/java/de/pushservice/server/service/PushService.java
new file mode 100644
index 0000000..d042408
--- /dev/null
+++ b/push-service-server/src/main/java/de/pushservice/server/service/PushService.java
@@ -0,0 +1,90 @@
+package de.pushservice.server.service;
+
+import de.pushservice.client.ResponseReason;
+import de.pushservice.client.dto.MessageDto;
+import de.pushservice.client.dto.NotificationRequestDto;
+import de.pushservice.client.dto.SubscriptionDto;
+import de.pushservice.server.dba.SubscriptionRepository;
+import de.pushservice.server.decorator.SubscriptionDecorator;
+import de.pushservice.server.model.Subscription;
+import nl.martijndwars.webpush.Notification;
+import nl.martijndwars.webpush.Urgency;
+import org.apache.commons.lang3.StringUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+
+@Service
+public class PushService {
+ private static final Logger LOGGER = LoggerFactory.getLogger(PushService.class);
+
+ @Autowired
+ private SubscriptionRepository subscriptionRepository;
+
+ @Autowired
+ private ExternalPushServiceResponseHandler handler;
+
+ @Transactional(propagation = Propagation.REQUIRED)
+ public ResponseReason subscribe(SubscriptionDto subscriptionDto) {
+ if (this.subscriptionRepository
+ .getForScopeAndEndpoint(subscriptionDto.getScope(), subscriptionDto.getEndpoint()).isPresent()) {
+ return ResponseReason.OK;
+ }
+
+ Subscription subscription = new Subscription();
+
+ subscription.setAuth(subscriptionDto.getAuth());
+ subscription.setEndpoint(subscriptionDto.getEndpoint());
+ subscription.setKey(subscriptionDto.getKey());
+ subscription.setErrorCount(0);
+ subscription.setScope(subscriptionDto.getScope());
+ subscription.setInsertDate(LocalDateTime.now());
+ subscription.setLastSeen(LocalDateTime.now());
+
+ this.subscriptionRepository.save(subscription);
+
+ return ResponseReason.OK;
+ }
+
+ public ResponseReason notify(NotificationRequestDto notificationRequestDto) {
+ if (StringUtils.isEmpty(notificationRequestDto.getScope())) {
+ return ResponseReason.EMPTY_SCOPE;
+ }
+
+ final Iterable subscriptions = this.subscriptionRepository
+ .getAllForScope(notificationRequestDto.getScope());
+ final nl.martijndwars.webpush.PushService extPushService = new nl.martijndwars.webpush.PushService();
+
+ for (Subscription subscription : subscriptions) {
+ try {
+ SubscriptionDecorator subscriptionDecorator = SubscriptionDecorator.from(subscription);
+ Notification notification = Notification.builder()
+ .endpoint(subscriptionDecorator.getEndpoint())
+ .userPublicKey(subscriptionDecorator.getUserPublicKey())
+ .userAuth(subscriptionDecorator.getAuthAsBytes())
+ .payload(notificationRequestDto.getMessageDto().getPayload())
+ .urgency(getExtUrgency(notificationRequestDto.getMessageDto()))
+ .topic(notificationRequestDto.getMessageDto().getTopic())
+ .build();
+
+ this.handler.handleResponse(extPushService.sendAsync(notification), subscription); // Async
+ } catch (Exception e) {
+ LOGGER.error(String.format("Error while sending push notification for endpoint %s", subscription
+ .getEndpoint()), e);
+
+ this.handler.handleError(e, subscription); // Async
+ }
+ }
+
+ return ResponseReason.OK;
+ }
+
+ private Urgency getExtUrgency(MessageDto messageDto) {
+ return Urgency.valueOf(messageDto.getUrgency().name());
+ }
+}
diff --git a/push-service-server/src/main/resources/config/application-hsqldb.properties b/push-service-server/src/main/resources/config/application-hsqldb.properties
new file mode 100644
index 0000000..7eebefb
--- /dev/null
+++ b/push-service-server/src/main/resources/config/application-hsqldb.properties
@@ -0,0 +1,6 @@
+spring.flyway.locations=classpath:/database/hsqldb
+
+# DataSource
+#spring.datasource.url=jdbc:hsqldb:file:/tmp/push_service
+spring.datasource.url=jdbc:hsqldb:mem:.
+spring.datasource.username=sa
\ No newline at end of file
diff --git a/push-service-server/src/main/resources/config/application-mk.properties b/push-service-server/src/main/resources/config/application-mk.properties
new file mode 100644
index 0000000..e9e2a59
--- /dev/null
+++ b/push-service-server/src/main/resources/config/application-mk.properties
@@ -0,0 +1,3 @@
+spring.datasource.url=jdbc:postgresql://localhost/push_service_mk
+spring.datasource.username=push_service_mk
+spring.datasource.password=push_service_mk
\ No newline at end of file
diff --git a/push-service-server/src/main/resources/config/application-postgres.properties b/push-service-server/src/main/resources/config/application-postgres.properties
new file mode 100644
index 0000000..138f259
--- /dev/null
+++ b/push-service-server/src/main/resources/config/application-postgres.properties
@@ -0,0 +1,8 @@
+spring.flyway.locations=classpath:/database/postgres
+
+spring.datasource.url=jdbc:postgresql://localhost/push_service
+spring.datasource.username=push_service
+spring.datasource.password=push_service
+
+# See https://github.com/spring-projects/spring-boot/issues/12007
+spring.jpa.properties.hibernate.jdbc.lob.non_contextual_creation=true
diff --git a/push-service-server/src/main/resources/config/application.properties b/push-service-server/src/main/resources/config/application.properties
index 4a7a510..27a4649 100644
--- a/push-service-server/src/main/resources/config/application.properties
+++ b/push-service-server/src/main/resources/config/application.properties
@@ -1,3 +1,5 @@
+spring.profiles.active=@activeProfiles@
+
server.servlet.context-path=/push-service-server
server.port=8077
@@ -10,4 +12,10 @@ info.build.version=@project.version@
logging.level.de.pushservice=DEBUG
logging.file=push-service-server.log
logging.file.max-history=7
-logging.file.max-size=50MB
\ No newline at end of file
+logging.file.max-size=50MB
+
+# Disable JMX as we don't need it and it blocks parallel deployment on Tomcat
+# because the connection pool cannot shutdown properly
+spring.jmx.enabled=false
+
+spring.jpa.hibernate.ddl-auto=validate
\ No newline at end of file
diff --git a/push-service-server/src/main/resources/database/hsqldb/V1_0_0__init.sql b/push-service-server/src/main/resources/database/hsqldb/V1_0_0__init.sql
new file mode 100644
index 0000000..b2e163f
--- /dev/null
+++ b/push-service-server/src/main/resources/database/hsqldb/V1_0_0__init.sql
@@ -0,0 +1,12 @@
+CREATE TABLE subscription (
+ id BIGINT NOT NULL PRIMARY KEY IDENTITY,
+ scope VARCHAR(2000) NOT NULL,
+ auth VARCHAR(255) NOT NULL,
+ "key" VARCHAR(255) NOT NULL,
+ endpoint VARCHAR(2000) NOT NULL,
+ insert_date DATETIME NOT NULL,
+ last_seen DATETIME NOT NULL,
+ error_count INTEGER NOT NULL,
+
+ CONSTRAINT un_subscription_scope_endpoint UNIQUE (scope, endpoint)
+);
\ No newline at end of file
diff --git a/push-service-server/src/main/resources/database/postgres/V1_0_0__init.sql b/push-service-server/src/main/resources/database/postgres/V1_0_0__init.sql
new file mode 100644
index 0000000..f92f0e0
--- /dev/null
+++ b/push-service-server/src/main/resources/database/postgres/V1_0_0__init.sql
@@ -0,0 +1,12 @@
+CREATE TABLE subscription (
+ id BIGINT NOT NULL PRIMARY KEY GENERATED ALWAYS AS IDENTITY,
+ scope VARCHAR(2000) NOT NULL,
+ auth VARCHAR(255) NOT NULL,
+ "key" VARCHAR(255) NOT NULL,
+ endpoint VARCHAR(2000) NOT NULL,
+ insert_date TIMESTAMP NOT NULL,
+ last_seen TIMESTAMP NOT NULL,
+ error_count INTEGER NOT NULL,
+
+ CONSTRAINT un_subscription_scope_endpoint UNIQUE (scope, endpoint)
+);
\ No newline at end of file
diff --git a/push-service-server/src/test/java/de/pushservice/server/PushServiceServerApplicationBootTest.java b/push-service-server/src/test/java/de/pushservice/server/PushServiceServerApplicationBootTest.java
new file mode 100644
index 0000000..0b0c93a
--- /dev/null
+++ b/push-service-server/src/test/java/de/pushservice/server/PushServiceServerApplicationBootTest.java
@@ -0,0 +1,31 @@
+package de.pushservice.server;
+
+import org.junit.Assert;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.TestPropertySource;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+
+@RunWith(SpringRunner.class)
+@SpringBootTest(classes = PushServiceApplication.class)
+@AutoConfigureMockMvc
+@TestPropertySource(
+ locations = "classpath:application-integrationtest.properties")
+public class PushServiceServerApplicationBootTest {
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Test
+ public void test_appBoots() {
+ // Nothing to do in this test as we just want to startup the app
+ // to make sure that spring, flyway and hibernate all work
+ // as expected even after changes
+ // While this slightly increases build time it's an easy and safe
+ // way to ensure that the app can start
+ Assert.assertTrue(true);
+ }
+}
diff --git a/push-service-server/src/test/resources/application-integrationtest.properties b/push-service-server/src/test/resources/application-integrationtest.properties
new file mode 100644
index 0000000..234493f
--- /dev/null
+++ b/push-service-server/src/test/resources/application-integrationtest.properties
@@ -0,0 +1,5 @@
+spring.profiles.active=hsqldb
+
+spring.datasource.url=jdbc:hsqldb:mem:.
+spring.datasource.username=sa
+spring.flyway.locations=classpath:/database/hsqldb