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

@@ -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` 环境变量
## 🏘️ 社区

View File

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

View File

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

View File

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

10
pom.xml
View File

@@ -90,6 +90,16 @@
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>nl.martijndwars</groupId>
<artifactId>web-push</artifactId>
<version>5.1.1</version>
</dependency>
<dependency>
<groupId>org.bouncycastle</groupId>
<artifactId>bcprov-jdk15on</artifactId>
<version>1.70</version>
</dependency>
</dependencies>
<build>

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