From dccf8f9d0cb4ba29abd1952fdb104dc965d40eaf Mon Sep 17 00:00:00 2001 From: Tim <135014430+nagisa77@users.noreply.github.com> Date: Wed, 30 Jul 2025 10:48:02 +0800 Subject: [PATCH] feat: add browser push notifications --- README.md | 2 + open-isle-cli/public/notifications-sw.js | 9 ++++ open-isle-cli/src/main.js | 2 + open-isle-cli/src/utils/push.js | 48 +++++++++++++++++++ pom.xml | 10 ++++ .../PushSubscriptionController.java | 41 ++++++++++++++++ .../com/openisle/model/PushSubscription.java | 40 ++++++++++++++++ .../PushSubscriptionRepository.java | 12 +++++ .../openisle/service/NotificationService.java | 5 +- .../service/PushNotificationService.java | 42 ++++++++++++++++ .../service/PushSubscriptionService.java | 33 +++++++++++++ src/main/resources/application.properties | 4 ++ .../PushSubscriptionControllerTest.java | 36 ++++++++++++++ 13 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 open-isle-cli/public/notifications-sw.js create mode 100644 open-isle-cli/src/utils/push.js create mode 100644 src/main/java/com/openisle/controller/PushSubscriptionController.java create mode 100644 src/main/java/com/openisle/model/PushSubscription.java create mode 100644 src/main/java/com/openisle/repository/PushSubscriptionRepository.java create mode 100644 src/main/java/com/openisle/service/PushNotificationService.java create mode 100644 src/main/java/com/openisle/service/PushSubscriptionService.java create mode 100644 src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java diff --git a/README.md b/README.md index 31b460d24..f21bc7bd9 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台 - 集成 OpenAI 提供的 Markdown 格式化功能 - 通过环境变量可调整密码强度、登录方式、保护码等多种配置 - 支持图片上传,默认使用腾讯云 COS 扩展 +- 浏览器推送通知,离开网站也能及时收到提醒 ## 🌟 项目优势 - 全面开源,便于二次开发和自定义扩展 @@ -39,6 +40,7 @@ OpenIsle 是一个使用 Spring Boot 和 Vue 3 构建的全栈开源社区平台 - 模块化设计,代码结构清晰,维护成本低 - REST API 可接入任意前端框架,兼容多端平台 - 配置简单,通过环境变量快速调整和部署 +- 如需推送通知,请设置 `WEBPUSH_PUBLIC_KEY` 和 `WEBPUSH_PRIVATE_KEY` 环境变量 ## 🏘️ 社区 diff --git a/open-isle-cli/public/notifications-sw.js b/open-isle-cli/public/notifications-sw.js new file mode 100644 index 000000000..bc6378131 --- /dev/null +++ b/open-isle-cli/public/notifications-sw.js @@ -0,0 +1,9 @@ +self.addEventListener('push', function(event) { + const data = event.data ? event.data.text() : 'New notification'; + event.waitUntil( + self.registration.showNotification('OpenIsle', { + body: data, + icon: '/favicon.ico' + }) + ); +}); diff --git a/open-isle-cli/src/main.js b/open-isle-cli/src/main.js index 3642b4dac..c7c82f1fe 100644 --- a/open-isle-cli/src/main.js +++ b/open-isle-cli/src/main.js @@ -11,6 +11,7 @@ import { useToast } from 'vue-toastification' import { checkToken, clearToken, isLogin } from './utils/auth' import { initTheme } from './utils/theme' import { loginWithGoogle } from './utils/google' +import { registerPush } from './utils/push' // Configurable API domain and port // export const API_DOMAIN = 'http://127.0.0.1' @@ -43,6 +44,7 @@ app.use( ) app.mount('#app') +registerPush() checkToken().then(valid => { if (!valid) { diff --git a/open-isle-cli/src/utils/push.js b/open-isle-cli/src/utils/push.js new file mode 100644 index 000000000..201b8cc60 --- /dev/null +++ b/open-isle-cli/src/utils/push.js @@ -0,0 +1,48 @@ +import { API_BASE_URL } from '../main' +import { getToken } from './auth' + +function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - base64String.length % 4) % 4) + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/') + const rawData = atob(base64) + const outputArray = new Uint8Array(rawData.length) + for (let i = 0; i < rawData.length; ++i) { + outputArray[i] = rawData.charCodeAt(i) + } + return outputArray +} + +function arrayBufferToBase64(buffer) { + const bytes = new Uint8Array(buffer) + let binary = '' + for (const b of bytes) binary += String.fromCharCode(b) + return btoa(binary) +} + +export async function registerPush() { + if (!('serviceWorker' in navigator) || !('PushManager' in window)) return + try { + const reg = await navigator.serviceWorker.register('/notifications-sw.js') + const res = await fetch(`${API_BASE_URL}/api/push/public-key`) + if (!res.ok) return + const { key } = await res.json() + const sub = await reg.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(key) + }) + await fetch(`${API_BASE_URL}/api/push/subscribe`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getToken()}` + }, + body: JSON.stringify({ + endpoint: sub.endpoint, + p256dh: arrayBufferToBase64(sub.getKey('p256dh')), + auth: arrayBufferToBase64(sub.getKey('auth')) + }) + }) + } catch (e) { + // ignore + } +} diff --git a/pom.xml b/pom.xml index cc82b1772..445fa4058 100644 --- a/pom.xml +++ b/pom.xml @@ -90,6 +90,16 @@ spring-boot-starter-test test + + nl.martijndwars + web-push + 5.1.1 + + + org.bouncycastle + bcprov-jdk15on + 1.70 + diff --git a/src/main/java/com/openisle/controller/PushSubscriptionController.java b/src/main/java/com/openisle/controller/PushSubscriptionController.java new file mode 100644 index 000000000..800287b40 --- /dev/null +++ b/src/main/java/com/openisle/controller/PushSubscriptionController.java @@ -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; + } +} diff --git a/src/main/java/com/openisle/model/PushSubscription.java b/src/main/java/com/openisle/model/PushSubscription.java new file mode 100644 index 000000000..34888513e --- /dev/null +++ b/src/main/java/com/openisle/model/PushSubscription.java @@ -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; +} diff --git a/src/main/java/com/openisle/repository/PushSubscriptionRepository.java b/src/main/java/com/openisle/repository/PushSubscriptionRepository.java new file mode 100644 index 000000000..28268d203 --- /dev/null +++ b/src/main/java/com/openisle/repository/PushSubscriptionRepository.java @@ -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 { + List findByUser(User user); + void deleteByUserAndEndpoint(User user, String endpoint); +} diff --git a/src/main/java/com/openisle/service/NotificationService.java b/src/main/java/com/openisle/service/NotificationService.java index 73ef61929..e4daa73f2 100644 --- a/src/main/java/com/openisle/service/NotificationService.java +++ b/src/main/java/com/openisle/service/NotificationService.java @@ -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; } /** diff --git a/src/main/java/com/openisle/service/PushNotificationService.java b/src/main/java/com/openisle/service/PushNotificationService.java new file mode 100644 index 000000000..862fb9904 --- /dev/null +++ b/src/main/java/com/openisle/service/PushNotificationService.java @@ -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 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 + } + } + } +} diff --git a/src/main/java/com/openisle/service/PushSubscriptionService.java b/src/main/java/com/openisle/service/PushSubscriptionService.java new file mode 100644 index 000000000..6d76c2b85 --- /dev/null +++ b/src/main/java/com/openisle/service/PushSubscriptionService.java @@ -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 listByUser(User user) { + return subscriptionRepository.findByUser(user); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 7d74a929c..f1e2b2b30 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -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:} diff --git a/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java b/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java new file mode 100644 index 000000000..fbb31b718 --- /dev/null +++ b/src/test/java/com/openisle/controller/PushSubscriptionControllerTest.java @@ -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"); + } +}