1
0

Rework push server so it holds the subscriptions in a DB

This commit is contained in:
2020-07-27 23:22:32 +02:00
parent f3c6227dce
commit a8b2a43216
26 changed files with 554 additions and 121 deletions

10
pom.xml
View File

@@ -66,6 +66,16 @@
<artifactId>bcprov-jdk15on</artifactId> <artifactId>bcprov-jdk15on</artifactId>
<version>1.64</version> <version>1.64</version>
</dependency> </dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
<version>4.3</version>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<version>4.12</version>
</dependency>
</dependencies> </dependencies>
</dependencyManagement> </dependencyManagement>
</project> </project>

View File

@@ -16,10 +16,6 @@
<name>push-service-client-lib</name> <name>push-service-client-lib</name>
<dependencies> <dependencies>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework</groupId> <groupId>org.springframework</groupId>
<artifactId>spring-web</artifactId> <artifactId>spring-web</artifactId>
@@ -28,6 +24,16 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<!-- Misc dependencies -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>

View File

@@ -5,7 +5,7 @@
2. How to use the client-lib in a client application 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 - 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")' - Enable component scan in app configuration via '@ComponentScan("de.pushservice.client")'
- Obtain an instance of SubscriptionService via '@Autowired' - Obtain an instance of SubscriptionService via '@Autowired'
- Add a JavaScript file with content 'registerServiceWorker();' to register the service worker and subscribing at the - Add a JavaScript file with content 'registerServiceWorker();' to register the service worker and subscribing at the

View File

@@ -5,6 +5,7 @@ import org.springframework.http.ResponseEntity;
public enum ResponseReason { public enum ResponseReason {
OK(HttpStatus.OK), OK(HttpStatus.OK),
EMPTY_SCOPE(HttpStatus.BAD_REQUEST),
UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR); UNKNOWN_ERROR(HttpStatus.INTERNAL_SERVER_ERROR);
private final HttpStatus httpStatus; private final HttpStatus httpStatus;

View File

@@ -1,6 +1,5 @@
package de.pushservice.client.controller; package de.pushservice.client.controller;
import de.pushservice.client.ResponseReason;
import de.pushservice.client.dto.SubscriptionDto; import de.pushservice.client.dto.SubscriptionDto;
import de.pushservice.client.service.SubscriptionService; import de.pushservice.client.service.SubscriptionService;
import org.slf4j.Logger; import org.slf4j.Logger;
@@ -22,8 +21,6 @@ public class SubscriptionController {
public ResponseEntity subscribe(@RequestBody SubscriptionDto subscriptionDto) { public ResponseEntity subscribe(@RequestBody SubscriptionDto subscriptionDto) {
LOGGER.debug(String.format("Received subscription for endpoint %s", subscriptionDto.getEndpoint())); LOGGER.debug(String.format("Received subscription for endpoint %s", subscriptionDto.getEndpoint()));
this.subscriptionService.subscribe(subscriptionDto); return this.subscriptionService.subscribe(subscriptionDto).toResponseEntity();
return ResponseReason.OK.toResponseEntity();
} }
} }

View File

@@ -1,20 +1,20 @@
package de.pushservice.client.dto; package de.pushservice.client.dto;
public class NotificationRequestDto { public class NotificationRequestDto {
private SubscriptionDto subscriptionDto; private String scope;
private MessageDto messageDto; private MessageDto messageDto;
public NotificationRequestDto(SubscriptionDto subscriptionDto, MessageDto messageDto) { public NotificationRequestDto(String scope, MessageDto messageDto) {
this.subscriptionDto = subscriptionDto; this.scope = scope;
this.messageDto = messageDto; this.messageDto = messageDto;
} }
public SubscriptionDto getSubscriptionDto() { public String getScope() {
return subscriptionDto; return scope;
} }
public void setSubscriptionDto(SubscriptionDto subscriptionDto) { public void setScope(String scope) {
this.subscriptionDto = subscriptionDto; this.scope = scope;
} }
public MessageDto getMessageDto() { public MessageDto getMessageDto() {

View File

@@ -4,6 +4,7 @@ public class SubscriptionDto {
private String auth; private String auth;
private String endpoint; private String endpoint;
private String key; private String key;
private String scope;
public void setAuth(String auth) { public void setAuth(String auth) {
this.auth = auth; this.auth = auth;
@@ -28,4 +29,12 @@ public class SubscriptionDto {
public String getEndpoint() { public String getEndpoint() {
return endpoint; return endpoint;
} }
public String getScope() {
return scope;
}
public void setScope(String scope) {
this.scope = scope;
}
} }

View File

@@ -9,6 +9,7 @@ import de.pushservice.client.dto.NotificationRequestDto;
import de.pushservice.client.dto.PayloadDto; import de.pushservice.client.dto.PayloadDto;
import de.pushservice.client.dto.SubscriptionDto; import de.pushservice.client.dto.SubscriptionDto;
import de.pushservice.client.model.Urgency; import de.pushservice.client.model.Urgency;
import org.apache.commons.lang3.StringUtils;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@@ -30,37 +31,41 @@ public class SubscriptionService {
@Autowired @Autowired
private ObjectMapper objectMapper; private ObjectMapper objectMapper;
private Set<SubscriptionDto> subscriptions = new HashSet<>(); private String scope;
public void subscribe(SubscriptionDto subscriptionDto) { public boolean isInitialized() {
this.subscriptions.add(subscriptionDto); return StringUtils.isNotEmpty(this.scope);
} }
public int notifyAll(PayloadDto payload, String topic, Urgency urgency) throws JsonProcessingException { public ResponseReason subscribe(SubscriptionDto subscriptionDto) {
int notificationsSend = 0; if (StringUtils.isEmpty(this.scope)) {
final Urgency tmpUrgency = Optional.ofNullable(urgency).orElse(Urgency.NORMAL); this.scope = subscriptionDto.getScope();
}
final MessageDto messageDto = new MessageDto(objectMapper.writeValueAsString(payload), topic, tmpUrgency); else {
if(!this.scope.equals(subscriptionDto.getScope())) {
for (SubscriptionDto subscription : this.subscriptions) { // Should not happen since the scope is the host + context path of the hosting app
final NotificationRequestDto requestDto = new NotificationRequestDto(subscription, messageDto); LOGGER.warn(String.format("Scope changed! Old: %s, new %s", this.scope, subscriptionDto.getScope()));
LOGGER.debug(String.format("Sending notification for endpoint %s", subscription.getEndpoint()));
final ResponseEntity<String> 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);
} }
} }
return notificationsSend; final ResponseEntity<String> 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<String> 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));
}
} }
} }

View File

@@ -5,7 +5,7 @@ function registerServiceWorker() {
// Registration was successful // Registration was successful
console.log('ServiceWorker registration successful with scope: ', registration.scope); console.log('ServiceWorker registration successful with scope: ', registration.scope);
initializeState(); initializeState(registration.scope);
}, function(err) { }, function(err) {
// registration failed :( // registration failed :(
console.log('ServiceWorker registration failed: ', err); console.log('ServiceWorker registration failed: ', err);
@@ -14,7 +14,7 @@ function registerServiceWorker() {
} }
} }
function initializeState() { function initializeState(registrationScope) {
if (!('showNotification' in ServiceWorkerRegistration.prototype)) { if (!('showNotification' in ServiceWorkerRegistration.prototype)) {
console.warn('Notifications aren\'t supported.'); console.warn('Notifications aren\'t supported.');
return; return;
@@ -33,11 +33,11 @@ function initializeState() {
navigator.serviceWorker.ready.then(function (serviceWorkerRegistration) { navigator.serviceWorker.ready.then(function (serviceWorkerRegistration) {
serviceWorkerRegistration.pushManager.getSubscription().then(function (subscription) { serviceWorkerRegistration.pushManager.getSubscription().then(function (subscription) {
if (!subscription) { if (!subscription) {
subscribe(); subscribe(registrationScope);
return; return;
} }
sendSubscriptionToServer(subscription); sendSubscriptionToServer(subscription, registrationScope);
}) })
.catch(function(err) { .catch(function(err) {
console.warn('Error during getSubscription()', err); console.warn('Error during getSubscription()', err);
@@ -45,10 +45,10 @@ function initializeState() {
}); });
} }
function subscribe() { function subscribe(registrationScope) {
navigator.serviceWorker.ready.then(function (serviceWorkerRegistration) { navigator.serviceWorker.ready.then(function (serviceWorkerRegistration) {
serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true}).then(function (subscription) { serviceWorkerRegistration.pushManager.subscribe({userVisibleOnly: true}).then(function (subscription) {
return sendSubscriptionToServer(subscription); return sendSubscriptionToServer(subscription, registrationScope);
}) })
.catch(function (e) { .catch(function (e) {
if (Notification.permission === 'denied') { 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 key = subscription.getKey ? subscription.getKey('p256dh') : '';
var auth = subscription.getKey ? subscription.getKey('auth') : ''; 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 // Take byte[] and turn it into a base64 encoded string suitable for
// POSTing to a server over HTTP // POSTing to a server over HTTP
key: key ? btoa(String.fromCharCode.apply(null, new Uint8Array(key))) : '', 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
}) })
}); });
} }

View File

@@ -17,21 +17,77 @@
<properties> <properties>
<packaging.type>jar</packaging.type> <packaging.type>jar</packaging.type>
<activeProfiles>postgres</activeProfiles>
<deploymentProfile>mk</deploymentProfile>
</properties> </properties>
<dependencies> <dependencies>
<dependency> <!-- Glue dependencies -->
<groupId>nl.martijndwars</groupId>
<artifactId>web-push</artifactId>
</dependency>
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId> <artifactId>spring-boot-starter-web</artifactId>
</dependency> </dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Misc dependencies -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-collections4</artifactId>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<!-- Domain dependencies -->
<dependency>
<groupId>nl.martijndwars</groupId>
<artifactId>web-push</artifactId>
</dependency>
<dependency> <dependency>
<groupId>de.77zzcx7.push-service</groupId> <groupId>de.77zzcx7.push-service</groupId>
<artifactId>push-service-client-lib</artifactId> <artifactId>push-service-client-lib</artifactId>
<version>${version}</version> <version>${project.version}</version>
</dependency>
<!-- Runtime dependencies -->
<dependency>
<groupId>org.hsqldb</groupId>
<artifactId>hsqldb</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.restdocs</groupId>
<artifactId>spring-restdocs-mockmvc</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>junit</groupId>
<artifactId>junit</artifactId>
<scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>
@@ -40,9 +96,10 @@
<id>build-war</id> <id>build-war</id>
<properties> <properties>
<packaging.type>war</packaging.type> <packaging.type>war</packaging.type>
<activeProfiles>postgres,${deploymentProfile}</activeProfiles>
</properties> </properties>
<build> <build>
<finalName>${project.artifactId}##${parallelDeploymentVersion}</finalName> <finalName>${project.artifactId}-${deploymentProfile}##${parallelDeploymentVersion}</finalName>
</build> </build>
<dependencies> <dependencies>
<dependency> <dependency>

View File

@@ -4,11 +4,13 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder; import org.springframework.boot.builder.SpringApplicationBuilder;
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; 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 * This whole application is basically just a thin layer around the webpush-java lib
*/ */
@SpringBootApplication @SpringBootApplication
@EnableAsync
public class PushServiceApplication extends SpringBootServletInitializer { public class PushServiceApplication extends SpringBootServletInitializer {
public static void main(String[] args) { public static void main(String[] args) {
SpringApplication.run(PushServiceApplication.class); SpringApplication.run(PushServiceApplication.class);

View File

@@ -1,14 +1,12 @@
package de.pushservice.server.controller; package de.pushservice.server.controller;
import de.pushservice.client.ResponseReason;
import de.pushservice.client.dto.NotificationRequestDto; import de.pushservice.client.dto.NotificationRequestDto;
import de.pushservice.server.decorator.MessageDecorator; import de.pushservice.client.dto.SubscriptionDto;
import de.pushservice.server.decorator.SubscriptionDecorator; import de.pushservice.server.service.PushService;
import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestBody;
@@ -20,27 +18,20 @@ import java.security.Security;
public class PushServiceController { public class PushServiceController {
private static final Logger LOGGER = LoggerFactory.getLogger(PushServiceController.class); private static final Logger LOGGER = LoggerFactory.getLogger(PushServiceController.class);
@Autowired
private PushService pushService;
static { static {
Security.addProvider(new BouncyCastleProvider()); Security.addProvider(new BouncyCastleProvider());
} }
@PostMapping @PostMapping("/notify")
public ResponseEntity notify(@RequestBody NotificationRequestDto notificationRequestDto) { public ResponseEntity notify(@RequestBody NotificationRequestDto notificationRequestDto) {
try { return this.pushService.notify(notificationRequestDto).toResponseEntity();
final SubscriptionDecorator subscription = SubscriptionDecorator }
.fromDto(notificationRequestDto.getSubscriptionDto());
final Notification notification = MessageDecorator.fromDto(notificationRequestDto.getMessageDto())
.toNotification(subscription);
final PushService extPushService = new PushService();
extPushService.send(notification); @PostMapping("/subscribe")
public ResponseEntity subscribe(@RequestBody SubscriptionDto subscriptionDto) {
return ResponseReason.OK.toResponseEntity(); return this.pushService.subscribe(subscriptionDto).toResponseEntity();
}
catch(Exception e) {
LOGGER.error("Error while sending notification!", e);
return ResponseReason.UNKNOWN_ERROR.toResponseEntity();
}
} }
} }

View File

@@ -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<Subscription, Long> {
@Query("SELECT s FROM Subscription s WHERE s.scope = :scope")
Iterable<Subscription> getAllForScope(String scope);
@Query("SELECT s FROM Subscription s WHERE s.scope = :scope AND s.endpoint = :endpoint")
Optional<Subscription> getForScopeAndEndpoint(String scope, String endpoint);
}

View File

@@ -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());
}
}

View File

@@ -1,6 +1,6 @@
package de.pushservice.server.decorator; 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.ECNamedCurveTable;
import org.bouncycastle.jce.provider.BouncyCastleProvider; import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec; import org.bouncycastle.jce.spec.ECNamedCurveParameterSpec;
@@ -15,17 +15,17 @@ import java.security.spec.InvalidKeySpecException;
import java.util.Base64; import java.util.Base64;
public class SubscriptionDecorator { public class SubscriptionDecorator {
private SubscriptionDto dto; private Subscription dto;
private SubscriptionDecorator(SubscriptionDto dto) { private SubscriptionDecorator(Subscription dto) {
this.dto = dto; this.dto = dto;
} }
public static final SubscriptionDecorator fromDto(SubscriptionDto dto) { public static final SubscriptionDecorator from(Subscription dto) {
return new SubscriptionDecorator(dto); return new SubscriptionDecorator(dto);
} }
byte[] getAuthAsBytes() { public byte[] getAuthAsBytes() {
return Base64.getDecoder().decode(dto.getAuth()); return Base64.getDecoder().decode(dto.getAuth());
} }
@@ -33,7 +33,7 @@ public class SubscriptionDecorator {
return Base64.getDecoder().decode(dto.getKey()); 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); KeyFactory kf = KeyFactory.getInstance("ECDH", BouncyCastleProvider.PROVIDER_NAME);
ECNamedCurveParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256r1"); ECNamedCurveParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256r1");
ECPoint point = ecSpec.getCurve().decodePoint(getKeyAsBytes()); ECPoint point = ecSpec.getCurve().decodePoint(getKeyAsBytes());
@@ -42,7 +42,7 @@ public class SubscriptionDecorator {
return kf.generatePublic(pubSpec); return kf.generatePublic(pubSpec);
} }
String getEndpoint() { public String getEndpoint() {
return dto.getEndpoint(); return dto.getEndpoint();
} }
} }

View File

@@ -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;
}
}

View File

@@ -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 <a href="https://developers.google.com/web/fundamentals/push-notifications/web-push-protocol#response_from_push_service">Google
* developer documentation on response codes from third party push service</a>
*/
public void handleResponse(Future<HttpResponse> 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);
}
}

View File

@@ -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<Subscription> 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());
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -1,3 +1,5 @@
spring.profiles.active=@activeProfiles@
server.servlet.context-path=/push-service-server server.servlet.context-path=/push-service-server
server.port=8077 server.port=8077
@@ -11,3 +13,9 @@ logging.level.de.pushservice=DEBUG
logging.file=push-service-server.log logging.file=push-service-server.log
logging.file.max-history=7 logging.file.max-history=7
logging.file.max-size=50MB 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

View File

@@ -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)
);

View File

@@ -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)
);

View File

@@ -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);
}
}

View File

@@ -0,0 +1,5 @@
spring.profiles.active=hsqldb
spring.datasource.url=jdbc:hsqldb:mem:.
spring.datasource.username=sa
spring.flyway.locations=classpath:/database/hsqldb