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