diff --git a/backend/app.py b/backend/app.py index 9b7a6ae..dd2ee15 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from routes import account, messaging, profile +from routes import account, messaging, profile, push # Инициализация FastAPI app = FastAPI(title="PixelChat") @@ -18,4 +18,5 @@ app.add_middleware( # Routes app.include_router(account.router) app.include_router(messaging.router) -app.include_router(profile.router) \ No newline at end of file +app.include_router(profile.router) +app.include_router(push.router, prefix="/push") \ No newline at end of file diff --git a/backend/generate_vapid_keys.py b/backend/generate_vapid_keys.py new file mode 100644 index 0000000..8e3a074 --- /dev/null +++ b/backend/generate_vapid_keys.py @@ -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() diff --git a/backend/models.py b/backend/models.py index e3625c5..d9e2757 100644 --- a/backend/models.py +++ b/backend/models.py @@ -68,6 +68,18 @@ class DMEnvelope(Base): 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 модели class LoginRequest(BaseModel): username: str @@ -97,6 +109,11 @@ class UpdateBioRequest(BaseModel): bio: str +class PushSubscriptionRequest(BaseModel): + endpoint: str + keys: dict + + class UserProfileResponse(BaseModel): id: int username: str diff --git a/backend/push_service.py b/backend/push_service.py new file mode 100644 index 0000000..d8b9b1c --- /dev/null +++ b/backend/push_service.py @@ -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() diff --git a/backend/requirements.txt b/backend/requirements.txt index 326b790..966e1b9 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,4 +5,6 @@ sqlalchemy>=2.0.43 bcrypt>=4.3.0 websockets>=15.0.1 Pillow>=10.0.0 -python-multipart>=0.0.6 \ No newline at end of file +python-multipart>=0.0.6 +pywebpush>=1.14.0 +cryptography>=41.0.0 \ No newline at end of file diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 927aac3..a5b527b 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -6,6 +6,7 @@ from sqlalchemy.orm import Session from dependencies import get_current_user, get_db from constants import OWNER_USERNAME from models import Message, SendMessageRequest, EditMessageRequest, User, DMEnvelope +from push_service import push_service router = APIRouter() logger = logging.getLogger("uvicorn.error") @@ -58,6 +59,12 @@ async def send_message( db.commit() 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)} @@ -93,6 +100,13 @@ async def dm_send(payload: dict, current_user: User = Depends(get_current_user), db.add(env) db.commit() 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} @@ -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 websocket.send_json({"type": type, "data": {"status": "ok", "id": env.id}}); await self.send_to_user(env.sender_id, payload); diff --git a/backend/routes/push.py b/backend/routes/push.py new file mode 100644 index 0000000..d9799ed --- /dev/null +++ b/backend/routes/push.py @@ -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)) diff --git a/frontend/public/sw.js b/frontend/public/sw.js new file mode 100644 index 0000000..62b3e16 --- /dev/null +++ b/frontend/public/sw.js @@ -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 +}); diff --git a/frontend/src/ui/components/settings/SettingsDialog.tsx b/frontend/src/ui/components/settings/SettingsDialog.tsx index 786ce55..033ee78 100644 --- a/frontend/src/ui/components/settings/SettingsDialog.tsx +++ b/frontend/src/ui/components/settings/SettingsDialog.tsx @@ -1,15 +1,56 @@ -import { useState } from "react"; -import { PRODUCT_NAME } from "../../../core/config"; +import { useState, useEffect } from "react"; +import { PRODUCT_NAME, API_BASE_URL } from "../../../core/config"; import type { DialogProps } from "../../../core/types"; 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) { 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) => { 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 (
@@ -87,6 +128,14 @@ export function SettingsDialog({ isOpen, onOpenChange }: DialogProps) {

Уведомления

+ {pushSupported && ( + handlePushNotificationToggle((e.target as Switch).checked)} + > + Push уведомления + + )} Новые сообщения Звуковые уведомления Уведомления о статусе diff --git a/frontend/src/ui/screen/LoginScreen.tsx b/frontend/src/ui/screen/LoginScreen.tsx index 24a95f2..b5d11b1 100644 --- a/frontend/src/ui/screen/LoginScreen.tsx +++ b/frontend/src/ui/screen/LoginScreen.tsx @@ -8,6 +8,7 @@ import { useRef } from "react"; import type { TextField } from "mdui/components/text-field"; import { useAppState } from "../state"; import { MaterialTextField } from "../components/core/TextField"; +import { pushNotificationManager } from "../../utils/pushNotifications"; export default function LoginScreen() { const [alerts, updateAlerts] = useImmer([]); @@ -65,6 +66,26 @@ export default function LoginScreen() { 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"); // initializeProfile(); // Initialize profile after login } else { diff --git a/frontend/src/utils/material.ts b/frontend/src/utils/material.ts index 265d42b..34574a5 100644 --- a/frontend/src/utils/material.ts +++ b/frontend/src/utils/material.ts @@ -19,6 +19,7 @@ import 'mdui/components/text-field'; import 'mdui/components/button-icon'; import 'mdui/components/top-app-bar'; import 'mdui/components/top-app-bar-title'; +import 'mdui/components/switch'; import { setColorScheme } from 'mdui/functions/setColorScheme.js'; diff --git a/frontend/src/utils/pushNotifications.ts b/frontend/src/utils/pushNotifications.ts new file mode 100644 index 0000000..115520b --- /dev/null +++ b/frontend/src/utils/pushNotifications.ts @@ -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 { + 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 { + if (!this.registration) { + throw new Error("Service Worker not initialized"); + } + + const permission = await Notification.requestPermission(); + return permission; + } + + async subscribe(): Promise { + 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 { + 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 { + 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(); diff --git a/package.json b/package.json index 13a3c9b..ba01af0 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "frontend:typecheck": "tsc --project frontend", "frontend:build": "npm run frontend:typecheck && vite build frontend", "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:dependencies": "npm install --ignore-scripts", "frontend:clean": "rm -rf frontend/dist",