Implement browser push notifications

This commit is contained in:
2025-09-20 20:13:35 +03:00
Unverified
parent 2f30399607
commit 9a25bdcf62
13 changed files with 590 additions and 6 deletions
+2 -1
View File
@@ -1,7 +1,7 @@
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from routes import account, messaging, profile from routes import account, messaging, profile, push
# Инициализация FastAPI # Инициализация FastAPI
app = FastAPI(title="PixelChat") app = FastAPI(title="PixelChat")
@@ -19,3 +19,4 @@ app.add_middleware(
app.include_router(account.router) app.include_router(account.router)
app.include_router(messaging.router) app.include_router(messaging.router)
app.include_router(profile.router) app.include_router(profile.router)
app.include_router(push.router, prefix="/push")
+73
View File
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""
Generate VAPID keys for push notifications
Run this script to generate new VAPID keys for your application
"""
from pywebpush import WebPushException
import base64
import json
def generate_vapid_keys():
"""Generate VAPID keys for push notifications"""
try:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.backends import default_backend
# Generate private key
private_key = ec.generate_private_key(ec.SECP256R1(), default_backend())
# Get public key
public_key = private_key.public_key()
# Serialize keys
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo
)
# Convert to base64 for web push
private_key_b64 = base64.urlsafe_b64encode(
private_key.private_bytes(
encoding=serialization.Encoding.DER,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption()
)
).decode('utf-8').rstrip('=')
# Get the raw uncompressed public key point (65 bytes: 0x04 + 32 bytes x + 32 bytes y)
public_numbers = public_key.public_numbers()
x_bytes = public_numbers.x.to_bytes(32, 'big')
y_bytes = public_numbers.y.to_bytes(32, 'big')
public_key_raw = b'\x04' + x_bytes + y_bytes
public_key_b64 = base64.urlsafe_b64encode(public_key_raw).decode('utf-8').rstrip('=')
print("VAPID Keys Generated:")
print("=" * 50)
print(f"Private Key: {private_key_b64}")
print(f"Public Key: {public_key_b64}")
print("=" * 50)
print("\nAdd these to your environment variables:")
print(f"VAPID_PRIVATE_KEY={private_key_b64}")
print(f"VAPID_PUBLIC_KEY={public_key_b64}")
return private_key_b64, public_key_b64
except ImportError:
print("Error: cryptography library not found.")
print("Install it with: pip install cryptography")
return None, None
except Exception as e:
print(f"Error generating VAPID keys: {e}")
return None, None
if __name__ == "__main__":
generate_vapid_keys()
+17
View File
@@ -68,6 +68,18 @@ class DMEnvelope(Base):
timestamp = Column(DateTime, default=datetime.now) timestamp = Column(DateTime, default=datetime.now)
class PushSubscription(Base):
__tablename__ = "push_subscription"
id = Column(Integer, primary_key=True, index=True)
user_id = Column(Integer, ForeignKey("user.id"), nullable=False)
endpoint = Column(Text, nullable=False)
p256dh_key = Column(Text, nullable=False)
auth_key = Column(Text, nullable=False)
created_at = Column(DateTime, default=datetime.now)
updated_at = Column(DateTime, default=datetime.now, onupdate=datetime.now)
# Pydantic модели # Pydantic модели
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
username: str username: str
@@ -97,6 +109,11 @@ class UpdateBioRequest(BaseModel):
bio: str bio: str
class PushSubscriptionRequest(BaseModel):
endpoint: str
keys: dict
class UserProfileResponse(BaseModel): class UserProfileResponse(BaseModel):
id: int id: int
username: str username: str
+149
View File
@@ -0,0 +1,149 @@
import json
import logging
import os
from typing import List, Optional
from sqlalchemy.orm import Session
from pywebpush import webpush, WebPushException
from models import PushSubscription, User, Message, DMEnvelope
logger = logging.getLogger("uvicorn.error")
class PushNotificationService:
def __init__(self):
# VAPID keys - load from environment variables
self.vapid_private_key = os.getenv("VAPID_PRIVATE_KEY", "MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQghg2CSKiq0KsXXXImE75Z8UAphGBjkpYjUE87zPBmGqKhRANCAATxbNBGMhNl6gLmPL0PAf2YIJCVYX_TZrSqkj7SCqsu5VNMhnDOan6Qc9hEkcTZgvwj286C24SnxfH5CghVMCI6")
self.vapid_public_key = os.getenv("VAPID_PUBLIC_KEY", "BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo")
self.vapid_claims = {
"sub": "mailto:admin@fromchat.com",
"aud": "https://fcm.googleapis.com"
}
async def subscribe_user(self, db: Session, user_id: int, endpoint: str, p256dh_key: str, auth_key: str) -> bool:
"""Subscribe a user to push notifications"""
try:
# Check if user already has a subscription
existing_sub = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
if existing_sub:
# Update existing subscription
existing_sub.endpoint = endpoint
existing_sub.p256dh_key = p256dh_key
existing_sub.auth_key = auth_key
else:
# Create new subscription
new_sub = PushSubscription(
user_id=user_id,
endpoint=endpoint,
p256dh_key=p256dh_key,
auth_key=auth_key
)
db.add(new_sub)
db.commit()
logger.info(f"Push subscription saved for user {user_id}")
return True
except Exception as e:
logger.error(f"Failed to save push subscription for user {user_id}: {e}")
db.rollback()
return False
async def send_public_message_notification(self, db: Session, message: Message, exclude_user_id: Optional[int] = None):
"""Send push notification for a new public chat message"""
try:
# Get all users except the sender
users = db.query(User).filter(User.id != message.user_id)
if exclude_user_id:
users = users.filter(User.id != exclude_user_id)
for user in users:
# Check if user has push subscription before trying to send
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user.id).first()
if not subscription:
continue
await self._send_notification_to_user(
db, user.id,
f"New message from {message.author.username}",
message.content[:100] + ("..." if len(message.content) > 100 else ""),
message.author.profile_picture,
{
"type": "public_message",
"message_id": message.id,
"sender_id": message.user_id,
"sender_username": message.author.username
}
)
except Exception as e:
logger.error(f"Failed to send public message notifications: {e}")
async def send_dm_notification(self, db: Session, dm_envelope: DMEnvelope, sender: User):
"""Send push notification for a new DM"""
try:
await self._send_notification_to_user(
db, dm_envelope.recipient_id,
f"New message from {sender.username}",
"You have a new direct message",
sender.profile_picture,
{
"type": "dm",
"dm_id": dm_envelope.id,
"sender_id": sender.id,
"sender_username": sender.username
}
)
except Exception as e:
logger.error(f"Failed to send DM notification: {e}")
async def _send_notification_to_user(self, db: Session, user_id: int, title: str, body: str, icon: Optional[str], data: dict):
"""Send a push notification to a specific user"""
try:
subscription = db.query(PushSubscription).filter(PushSubscription.user_id == user_id).first()
if not subscription:
return
payload = {
"title": title,
"body": body,
"icon": icon or "/logo.png",
"tag": f"message_{user_id}",
"data": data
}
subscription_info = {
"endpoint": subscription.endpoint,
"keys": {
"p256dh": subscription.p256dh_key,
"auth": subscription.auth_key
}
}
webpush(
subscription_info=subscription_info,
data=json.dumps(payload),
vapid_private_key=self.vapid_private_key,
vapid_claims=self.vapid_claims
)
except WebPushException as e:
logger.error(f"WebPush error for user {user_id}: {e}")
# If the subscription is invalid, remove it
if hasattr(e, 'response') and e.response and e.response.status_code in [410, 404]:
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
db.commit()
except Exception as e:
logger.error(f"Failed to send push notification to user {user_id}: {e}")
async def unsubscribe_user(self, db: Session, user_id: int) -> bool:
"""Unsubscribe a user from push notifications"""
try:
db.query(PushSubscription).filter(PushSubscription.user_id == user_id).delete()
db.commit()
logger.info(f"Push subscription removed for user {user_id}")
return True
except Exception as e:
logger.error(f"Failed to remove push subscription for user {user_id}: {e}")
db.rollback()
return False
# Global instance
push_service = PushNotificationService()
+2
View File
@@ -6,3 +6,5 @@ bcrypt>=4.3.0
websockets>=15.0.1 websockets>=15.0.1
Pillow>=10.0.0 Pillow>=10.0.0
python-multipart>=0.0.6 python-multipart>=0.0.6
pywebpush>=1.14.0
cryptography>=41.0.0
+20
View File
@@ -6,6 +6,7 @@ from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db from dependencies import get_current_user, get_db
from constants import OWNER_USERNAME from constants import OWNER_USERNAME
from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope
from push_service import push_service
router = APIRouter() router = APIRouter()
logger = logging.getLogger("uvicorn.error") logger = logging.getLogger("uvicorn.error")
@@ -58,6 +59,12 @@ async def send_message(
db.commit() db.commit()
db.refresh(new_message) db.refresh(new_message)
# Send push notifications for public messages
try:
await push_service.send_public_message_notification(db, new_message, exclude_user_id=current_user.id)
except Exception as e:
logger.error(f"Failed to send push notification for message {new_message.id}: {e}")
return {"status": "success", "message": convert_message(new_message)} return {"status": "success", "message": convert_message(new_message)}
@@ -93,6 +100,13 @@ async def dm_send(payload: dict, current_user: User = Depends(get_current_user),
db.add(env) db.add(env)
db.commit() db.commit()
db.refresh(env) db.refresh(env)
# Send push notification for DM
try:
await push_service.send_dm_notification(db, env, current_user)
except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
return {"status": "ok", "id": env.id} return {"status": "ok", "id": env.id}
@@ -298,6 +312,12 @@ class MessaggingSocketManager:
} }
} }
# Send push notification for DM
try:
await push_service.send_dm_notification(db, env, current_user)
except Exception as e:
logger.error(f"Failed to send push notification for DM {env.id}: {e}")
await self.send_to_user(env.recipient_id, payload); await self.send_to_user(env.recipient_id, payload);
await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); await websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}});
await self.send_to_user(env.sender_id, payload); await self.send_to_user(env.sender_id, payload);
+46
View File
@@ -0,0 +1,46 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from dependencies import get_current_user, get_db
from models import User, PushSubscriptionRequest
from push_service import push_service
router = APIRouter()
@router.post("/subscribe")
async def subscribe_to_push_notifications(
request: PushSubscriptionRequest,
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Subscribe user to push notifications"""
try:
success = await push_service.subscribe_user(
db=db,
user_id=current_user.id,
endpoint=request.endpoint,
p256dh_key=request.keys["p256dh"],
auth_key=request.keys["auth"]
)
if success:
return {"status": "success", "message": "Push notifications enabled"}
else:
raise HTTPException(status_code=500, detail="Failed to enable push notifications")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/unsubscribe")
async def unsubscribe_from_push_notifications(
current_user: User = Depends(get_current_user),
db: Session = Depends(get_db)
):
"""Unsubscribe user from push notifications"""
try:
success = await push_service.unsubscribe_user(db=db, user_id=current_user.id)
if success:
return {"status": "success", "message": "Push notifications disabled"}
else:
raise HTTPException(status_code=500, detail="Failed to disable push notifications")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+57
View File
@@ -0,0 +1,57 @@
// Service Worker for Push Notifications
self.addEventListener("push", function(event) {
if (event.data) {
const data = event.data.json();
const options = {
body: data.body,
icon: data.icon || "/logo.png",
badge: "/logo.png",
image: data.image,
tag: data.tag || "message",
data: data.data,
actions: [
{
action: "open",
title: "Open Chat"
},
{
action: "close",
title: "Close"
}
],
requireInteraction: true,
silent: false
};
event.waitUntil(
self.registration.showNotification(data.title, options)
);
}
});
self.addEventListener("notificationclick", function(event) {
event.notification.close();
if (event.action === "open" || !event.action) {
event.waitUntil(
clients.matchAll({ type: "window" }).then(function(clientList) {
// If there's already a window open, focus it
for (let i = 0; i < clientList.length; i++) {
const client = clientList[i];
if (client.url === self.location.origin && "focus" in client) {
return client.focus();
}
}
// Otherwise, open a new window
if (clients.openWindow) {
return clients.openWindow(self.location.origin);
}
})
);
}
});
self.addEventListener("notificationclose", function(event) {
// Handle notification close if needed
});
@@ -1,15 +1,56 @@
import { useState } from "react"; import { useState, useEffect } from "react";
import { PRODUCT_NAME } from "../../../core/config"; import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config";
import type { DialogProps } from "../../../core/types"; import type { DialogProps } from "../../../core/types";
import { MaterialDialog } from "../core/Dialog"; import { MaterialDialog } from "../core/Dialog";
import { pushNotificationManager } from "../../../utils/pushNotifications";
import { useAppState } from "../../state";
import type { Switch } from "mdui/components/switch";
import { getAuthHeaders } from "../../../auth/api";
export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) { export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
const [activePanel, setActivePanel] = useState("notifications-settings"); const [activePanel, setActivePanel] = useState("notifications-settings");
const [pushNotificationsEnabled, setPushNotificationsEnabled] = useState(false);
const [pushSupported, setPushSupported] = useState(false);
const user = useAppState(state => state.user);
useEffect(() => {
setPushSupported(pushNotificationManager.isSupported());
setPushNotificationsEnabled(!!pushNotificationManager.getSubscription());
}, []);
const handlePanelChange = (panelId: string) => { const handlePanelChange = (panelId: string) => {
setActivePanel(panelId); setActivePanel(panelId);
}; };
const handlePushNotificationToggle = async (enabled: boolean) => {
if (!user.authToken) return;
try {
if (enabled) {
await pushNotificationManager.initialize();
const permission = await pushNotificationManager.requestPermission();
if (permission === "granted") {
const subscription = await pushNotificationManager.subscribe();
if (subscription) {
await pushNotificationManager.sendSubscriptionToServer(user.authToken);
setPushNotificationsEnabled(true);
}
}
} else {
await pushNotificationManager.unsubscribe();
// Call API to unsubscribe on server
await fetch(`${API_BASE_URL}/push/unsubscribe`, {
method: "DELETE",
headers: getAuthHeaders(user.authToken)
});
setPushNotificationsEnabled(false);
}
} catch (error) {
console.error("Failed to toggle push notifications:", error);
}
};
return ( return (
<MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog"> <MaterialDialog close-on-overlay-click close-on-esc fullscreen open={isOpen} onOpenChange={onOpenChange} id="settings-dialog">
<div className="fullscreen-wrapper"> <div className="fullscreen-wrapper">
@@ -87,6 +128,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {
<div className="screen"> <div className="screen">
<div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}> <div id="notifications-settings" className={`settings-panel ${activePanel === "notifications-settings" ? "active" : ""}`}>
<h3>Уведомления</h3> <h3>Уведомления</h3>
{pushSupported && (
<mdui-switch
checked={pushNotificationsEnabled}
onInput={(e) => handlePushNotificationToggle((e.target as Switch).checked)}
>
Push уведомления
</mdui-switch>
)}
<mdui-switch checked>Новые сообщения</mdui-switch> <mdui-switch checked>Новые сообщения</mdui-switch>
<mdui-switch checked>Звуковые уведомления</mdui-switch> <mdui-switch checked>Звуковые уведомления</mdui-switch>
<mdui-switch>Уведомления о статусе</mdui-switch> <mdui-switch>Уведомления о статусе</mdui-switch>
+21
View File
@@ -8,6 +8,7 @@ import { useRef } from "react";
import type { TextField } from "mdui/components/text-field"; import type { TextField } from "mdui/components/text-field";
import { useAppState } from "../state"; import { useAppState } from "../state";
import { MaterialTextField } from "../components/core/TextField"; import { MaterialTextField } from "../components/core/TextField";
import { pushNotificationManager } from "../../utils/pushNotifications";
export default function LoginScreen() { export default function LoginScreen() {
const [alerts, updateAlerts] = useImmer<Alert[]>([]); const [alerts, updateAlerts] = useImmer<Alert[]>([]);
@@ -65,6 +66,26 @@ export default function LoginScreen() {
console.error("Key setup failed:", e); console.error("Key setup failed:", e);
} }
// Initialize push notifications
try {
if (pushNotificationManager.isSupported()) {
await pushNotificationManager.initialize();
const permission = await pushNotificationManager.requestPermission();
if (permission === "granted") {
const subscription = await pushNotificationManager.subscribe();
if (subscription) {
await pushNotificationManager.sendSubscriptionToServer(data.token);
console.log("Push notifications enabled");
}
} else {
console.log("Push notification permission denied");
}
}
} catch (e) {
console.error("Push notification setup failed:", e);
}
setCurrentPage("chat"); setCurrentPage("chat");
// initializeProfile(); // Initialize profile after login // initializeProfile(); // Initialize profile after login
} else { } else {
+1
View File
@@ -19,6 +19,7 @@ import 'mdui/components/text-field';
import 'mdui/components/button-icon'; import 'mdui/components/button-icon';
import 'mdui/components/top-app-bar'; import 'mdui/components/top-app-bar';
import 'mdui/components/top-app-bar-title'; import 'mdui/components/top-app-bar-title';
import 'mdui/components/switch';
import { setColorScheme } from 'mdui/functions/setColorScheme.js'; import { setColorScheme } from 'mdui/functions/setColorScheme.js';
+148
View File
@@ -0,0 +1,148 @@
import { API_BASE_URL } from "../core/config";
export interface PushSubscriptionData {
endpoint: string;
keys: {
p256dh: string;
auth: string;
};
}
export interface NotificationPayload {
title: string;
body: string;
icon?: string;
image?: string;
tag?: string;
data?: any;
}
class PushNotificationManager {
private registration: ServiceWorkerRegistration | null = null;
private subscription: PushSubscription | null = null;
async initialize(): Promise<boolean> {
if (!("serviceWorker" in navigator) || !("PushManager" in window)) {
console.log("Push messaging is not supported");
return false;
}
try {
this.registration = await navigator.serviceWorker.register("/sw.js");
console.log("Service Worker registered successfully");
return true;
} catch (error) {
console.error("Service Worker registration failed:", error);
return false;
}
}
async requestPermission(): Promise<NotificationPermission> {
if (!this.registration) {
throw new Error("Service Worker not initialized");
}
const permission = await Notification.requestPermission();
return permission;
}
async subscribe(): Promise<PushSubscription | null> {
if (!this.registration) {
throw new Error("Service Worker not initialized");
}
try {
this.subscription = await this.registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: this.urlBase64ToUint8Array(
"BPFs0EYyE2XqAuY8vQ8B_ZggkJVhf9NmtKqSPtIKqy7lU0yGcM5qfpBz2ESRxNmC_CPbzoLbhKfF8fkKCFUwIjo"
).slice().buffer
});
console.log("Push subscription successful");
return this.subscription;
} catch (error) {
console.error("Push subscription failed:", error);
return null;
}
}
async sendSubscriptionToServer(token: string): Promise<boolean> {
if (!this.subscription) {
throw new Error("No push subscription available");
}
const subscriptionData: PushSubscriptionData = {
endpoint: this.subscription.endpoint,
keys: {
p256dh: this.arrayBufferToBase64(this.subscription.getKey("p256dh")!),
auth: this.arrayBufferToBase64(this.subscription.getKey("auth")!)
}
};
try {
const response = await fetch(`${API_BASE_URL}/push/subscribe`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify(subscriptionData)
});
return response.ok;
} catch (error) {
console.error("Failed to send subscription to server:", error);
return false;
}
}
async unsubscribe(): Promise<boolean> {
if (!this.subscription) {
return true;
}
try {
const result = await this.subscription.unsubscribe();
this.subscription = null;
return result;
} catch (error) {
console.error("Failed to unsubscribe:", error);
return false;
}
}
private urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = "=".repeat((4 - base64String.length % 4) % 4);
const base64 = (base64String + padding)
.replace(/-/g, "+")
.replace(/_/g, "/");
const rawData = window.atob(base64);
const outputArray = new Uint8Array(rawData.length);
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i);
}
return outputArray;
}
private arrayBufferToBase64(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = "";
for (let i = 0; i < bytes.byteLength; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
getSubscription(): PushSubscription | null {
return this.subscription;
}
isSupported(): boolean {
return "serviceWorker" in navigator && "PushManager" in window;
}
}
export const pushNotificationManager = new PushNotificationManager();
+1 -1
View File
@@ -16,7 +16,7 @@
"frontend:typecheck": "tsc --project frontend", "frontend:typecheck": "tsc --project frontend",
"frontend:build": "npm run frontend:typecheck && vite build frontend", "frontend:build": "npm run frontend:typecheck && vite build frontend",
"frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev", "frontend:electron:dev": "VITE_ELECTRON=true npm run frontend:dev",
"frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force", "frontend:electron:build": "VITE_ELECTRON=true npm run frontend:build && rm -rf out && electron-forge make --force --arch arm64,x64",
"frontend:preview": "vite preview frontend", "frontend:preview": "vite preview frontend",
"frontend:dependencies": "npm install --ignore-scripts", "frontend:dependencies": "npm install --ignore-scripts",
"frontend:clean": "rm -rf frontend/dist", "frontend:clean": "rm -rf frontend/dist",