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");
+ }
+}