mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-19 13:30:55 +08:00
feat: add browser push notifications
This commit is contained in:
@@ -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` 环境变量
|
||||
|
||||
## 🏘️ 社区
|
||||
|
||||
|
||||
9
open-isle-cli/public/notifications-sw.js
Normal file
9
open-isle-cli/public/notifications-sw.js
Normal 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'
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
48
open-isle-cli/src/utils/push.js
Normal file
48
open-isle-cli/src/utils/push.js
Normal 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
10
pom.xml
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
40
src/main/java/com/openisle/model/PushSubscription.java
Normal file
40
src/main/java/com/openisle/model/PushSubscription.java
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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:}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user