feat: add browser push notifications

This commit is contained in:
Tim
2025-07-30 10:48:02 +08:00
parent df8c5376f6
commit dccf8f9d0c
13 changed files with 283 additions and 1 deletions

View File

@@ -0,0 +1,41 @@
package com.openisle.controller;
import com.openisle.service.PushSubscriptionService;
import lombok.Data;
import lombok.RequiredArgsConstructor;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/push")
@RequiredArgsConstructor
public class PushSubscriptionController {
private final PushSubscriptionService pushSubscriptionService;
@Value("${app.webpush.public-key}")
private String publicKey;
@GetMapping("/public-key")
public PublicKeyResponse getPublicKey() {
PublicKeyResponse r = new PublicKeyResponse();
r.setKey(publicKey);
return r;
}
@PostMapping("/subscribe")
public void subscribe(@RequestBody SubscriptionRequest req, Authentication auth) {
pushSubscriptionService.saveSubscription(auth.getName(), req.getEndpoint(), req.getP256dh(), req.getAuth());
}
@Data
private static class PublicKeyResponse {
private String key;
}
@Data
private static class SubscriptionRequest {
private String endpoint;
private String p256dh;
private String auth;
}
}

View File

@@ -0,0 +1,40 @@
package com.openisle.model;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
/**
* Entity storing a browser push subscription for a user.
*/
@Entity
@Getter
@Setter
@NoArgsConstructor
@Table(name = "push_subscriptions")
public class PushSubscription {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id")
private User user;
@Column(nullable = false, length = 512)
private String endpoint;
@Column(nullable = false, length = 256)
private String p256dh;
@Column(nullable = false, length = 256)
private String auth;
@CreationTimestamp
@Column(nullable = false, updatable = false)
private LocalDateTime createdAt;
}

View File

@@ -0,0 +1,12 @@
package com.openisle.repository;
import com.openisle.model.PushSubscription;
import com.openisle.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PushSubscriptionRepository extends JpaRepository<PushSubscription, Long> {
List<PushSubscription> findByUser(User user);
void deleteByUserAndEndpoint(User user, String endpoint);
}

View File

@@ -14,6 +14,7 @@ import java.util.List;
public class NotificationService {
private final NotificationRepository notificationRepository;
private final UserRepository userRepository;
private final PushNotificationService pushNotificationService;
public Notification createNotification(User user, NotificationType type, Post post, Comment comment, Boolean approved) {
return createNotification(user, type, post, comment, approved, null, null, null);
@@ -30,7 +31,9 @@ public class NotificationService {
n.setFromUser(fromUser);
n.setReactionType(reactionType);
n.setContent(content);
return notificationRepository.save(n);
Notification saved = notificationRepository.save(n);
pushNotificationService.sendNotification(user, "You have a new notification");
return saved;
}
/**

View File

@@ -0,0 +1,42 @@
package com.openisle.service;
import com.openisle.model.PushSubscription;
import com.openisle.model.User;
import com.openisle.repository.PushSubscriptionRepository;
import nl.martijndwars.webpush.Notification;
import nl.martijndwars.webpush.PushService;
import org.bouncycastle.jce.provider.BouncyCastleProvider;
import org.jose4j.lang.JoseException;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.security.Security;
import java.util.List;
@Service
public class PushNotificationService {
private final PushSubscriptionRepository subscriptionRepository;
private final PushService pushService;
public PushNotificationService(PushSubscriptionRepository subscriptionRepository,
@Value("${app.webpush.public-key}") String publicKey,
@Value("${app.webpush.private-key}") String privateKey) throws GeneralSecurityException {
this.subscriptionRepository = subscriptionRepository;
Security.addProvider(new BouncyCastleProvider());
this.pushService = new PushService(publicKey, privateKey);
}
public void sendNotification(User user, String payload) {
List<PushSubscription> subs = subscriptionRepository.findByUser(user);
for (PushSubscription sub : subs) {
try {
Notification notification = new Notification(sub.getEndpoint(), sub.getP256dh(), sub.getAuth(), payload);
pushService.send(notification);
} catch (GeneralSecurityException | IOException | JoseException | InterruptedException | java.util.concurrent.ExecutionException e) {
// ignore
}
}
}
}

View File

@@ -0,0 +1,33 @@
package com.openisle.service;
import com.openisle.model.PushSubscription;
import com.openisle.model.User;
import com.openisle.repository.PushSubscriptionRepository;
import com.openisle.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.List;
@Service
@RequiredArgsConstructor
public class PushSubscriptionService {
private final PushSubscriptionRepository subscriptionRepository;
private final UserRepository userRepository;
public void saveSubscription(String username, String endpoint, String p256dh, String auth) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found"));
subscriptionRepository.deleteByUserAndEndpoint(user, endpoint);
PushSubscription sub = new PushSubscription();
sub.setUser(user);
sub.setEndpoint(endpoint);
sub.setP256dh(p256dh);
sub.setAuth(auth);
subscriptionRepository.save(sub);
}
public List<PushSubscription> listByUser(User user) {
return subscriptionRepository.findByUser(user);
}
}

View File

@@ -70,3 +70,7 @@ app.ai.format-limit=${AI_FORMAT_LIMIT:3}
# Website URL for emails and redirects
app.website-url=${WEBSITE_URL:https://www.open-isle.com}
# Web push configuration
app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:}
app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:}

View File

@@ -0,0 +1,36 @@
package com.openisle.controller;
import com.openisle.service.PushSubscriptionService;
import org.junit.jupiter.api.Test;
import org.mockito.Mockito;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.mockito.MockBean;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.test.web.servlet.MockMvc;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
@WebMvcTest(PushSubscriptionController.class)
@AutoConfigureMockMvc(addFilters = false)
class PushSubscriptionControllerTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private PushSubscriptionService pushSubscriptionService;
@Test
void subscribeEndpoint() throws Exception {
mockMvc.perform(post("/api/push/subscribe")
.contentType(MediaType.APPLICATION_JSON)
.content("{\"endpoint\":\"e\",\"p256dh\":\"p\",\"auth\":\"a\"}")
.principal(new UsernamePasswordAuthenticationToken("u","p")))
.andExpect(status().isOk());
Mockito.verify(pushSubscriptionService).saveSubscription("u","e","p","a");
}
}