Rework push server so it holds the subscriptions in a DB
This commit is contained in:
10
pom.xml
10
pom.xml
@@ -66,6 +66,16 @@
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
<version>1.64</version>
|
||||
</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>
|
||||
</dependencyManagement>
|
||||
</project>
|
||||
@@ -16,10 +16,6 @@
|
||||
<name>push-service-client-lib</name>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>org.bouncycastle</groupId>
|
||||
<artifactId>bcprov-jdk15on</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework</groupId>
|
||||
<artifactId>spring-web</artifactId>
|
||||
@@ -28,6 +24,16 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</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>
|
||||
</dependencies>
|
||||
|
||||
<build>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<SubscriptionDto> 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()));
|
||||
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()));
|
||||
}
|
||||
}
|
||||
|
||||
final ResponseEntity<String> responseEntity = new RestTemplate()
|
||||
.exchange(this.config.getServerUrl(), HttpMethod.POST, new HttpEntity<>(requestDto), String.class);
|
||||
.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) {
|
||||
notificationsSend++;
|
||||
}
|
||||
else {
|
||||
// Well, nothing we can do about it
|
||||
LOGGER.error("Sending notification to endpoint failed! %s", responseReason);
|
||||
if (ResponseReason.OK != responseReason) {
|
||||
LOGGER.error(String.format("Could not send notification! %s", responseReason));
|
||||
}
|
||||
}
|
||||
|
||||
return notificationsSend;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
});
|
||||
}
|
||||
@@ -17,21 +17,77 @@
|
||||
|
||||
<properties>
|
||||
<packaging.type>jar</packaging.type>
|
||||
<activeProfiles>postgres</activeProfiles>
|
||||
<deploymentProfile>mk</deploymentProfile>
|
||||
</properties>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>nl.martijndwars</groupId>
|
||||
<artifactId>web-push</artifactId>
|
||||
</dependency>
|
||||
<!-- Glue dependencies -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-web</artifactId>
|
||||
</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>
|
||||
<groupId>de.77zzcx7.push-service</groupId>
|
||||
<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>
|
||||
</dependencies>
|
||||
|
||||
@@ -40,9 +96,10 @@
|
||||
<id>build-war</id>
|
||||
<properties>
|
||||
<packaging.type>war</packaging.type>
|
||||
<activeProfiles>postgres,${deploymentProfile}</activeProfiles>
|
||||
</properties>
|
||||
<build>
|
||||
<finalName>${project.artifactId}##${parallelDeploymentVersion}</finalName>
|
||||
<finalName>${project.artifactId}-${deploymentProfile}##${parallelDeploymentVersion}</finalName>
|
||||
</build>
|
||||
<dependencies>
|
||||
<dependency>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,3 +1,5 @@
|
||||
spring.profiles.active=@activeProfiles@
|
||||
|
||||
server.servlet.context-path=/push-service-server
|
||||
server.port=8077
|
||||
|
||||
@@ -11,3 +13,9 @@ logging.level.de.pushservice=DEBUG
|
||||
logging.file=push-service-server.log
|
||||
logging.file.max-history=7
|
||||
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
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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)
|
||||
);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
spring.profiles.active=hsqldb
|
||||
|
||||
spring.datasource.url=jdbc:hsqldb:mem:.
|
||||
spring.datasource.username=sa
|
||||
spring.flyway.locations=classpath:/database/hsqldb
|
||||
Reference in New Issue
Block a user