From 92cc78d9ffc3412444fa9b8b6827a84e79fc60ab Mon Sep 17 00:00:00 2001 From: denis0001-dev Date: Tue, 18 Nov 2025 17:58:29 +0300 Subject: [PATCH] Clean up and fix issues --- backend/dependencies.py | 5 +- backend/push_service.py | 2 +- backend/routes/account.py | 4 + backend/routes/devices.py | 3 + backend/routes/messaging.py | 38 ++- backend/routes/profile.py | 6 + backend/utils.py | 2 +- frontend/index.html | 2 +- .../push-notifications/push-notifications.ts | 3 +- .../core/push-notifications/service-worker.ts | 6 +- frontend/src/core/websocket.ts | 279 +++++++++++++----- frontend/src/css/_components.scss | 2 +- frontend/src/css/_material.scss | 5 - .../src/pages/chat/css/left-panel.module.scss | 1 + 14 files changed, 259 insertions(+), 99 deletions(-) diff --git a/backend/dependencies.py b/backend/dependencies.py index 5bdfcab..c19adee 100644 --- a/backend/dependencies.py +++ b/backend/dependencies.py @@ -1,8 +1,9 @@ +from datetime import datetime from fastapi import Depends, HTTPException, Request, status from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer from sqlalchemy.orm import Session -from utils import * -from models import * +from utils import verify_token +from models import User, DeviceSession from db import SessionLocal security = HTTPBearer() diff --git a/backend/push_service.py b/backend/push_service.py index ee48ded..a06c36d 100644 --- a/backend/push_service.py +++ b/backend/push_service.py @@ -107,7 +107,7 @@ class PushNotificationService: payload = { "title": title, "body": body, - "icon": icon or "/logo.png", + "icon": icon or "about:blank", "tag": f"message_{user_id}", "data": data } diff --git a/backend/routes/account.py b/backend/routes/account.py index eb90569..445930e 100644 --- a/backend/routes/account.py +++ b/backend/routes/account.py @@ -313,6 +313,8 @@ def set_public_key(payload: dict, current_user: User = Depends(get_current_user) pk = payload.get("publicKey") if not pk: raise HTTPException(status_code=400, detail="publicKey required") + if not isinstance(pk, str) or len(pk) > 10000 or len(pk) < 10: + raise HTTPException(status_code=400, detail="Invalid publicKey format") row = db.query(CryptoPublicKey).filter(CryptoPublicKey.user_id == current_user.id).first() if row: row.public_key_b64 = pk @@ -334,6 +336,8 @@ def set_backup(payload: dict, current_user: User = Depends(get_current_user), db blob = payload.get("blob") if not blob: raise HTTPException(status_code=400, detail="blob required") + if not isinstance(blob, str) or len(blob) > 1000000: # 1MB limit + raise HTTPException(status_code=400, detail="Invalid blob format or size exceeds 1MB") row = db.query(CryptoBackup).filter(CryptoBackup.user_id == current_user.id).first() if row: row.blob_json = blob diff --git a/backend/routes/devices.py b/backend/routes/devices.py index 550c5f9..7cf41b9 100644 --- a/backend/routes/devices.py +++ b/backend/routes/devices.py @@ -60,6 +60,9 @@ def revoke_device( current_user: User = Depends(get_current_user), db: Session = Depends(get_db) ): + if not session_id or len(session_id) > 64 or len(session_id) < 1: + raise HTTPException(status_code=400, detail="Invalid session ID") + s = ( db.query(DeviceSession) .filter(DeviceSession.user_id == current_user.id, DeviceSession.session_id == session_id) diff --git a/backend/routes/messaging.py b/backend/routes/messaging.py index 7f321f2..bd75081 100644 --- a/backend/routes/messaging.py +++ b/backend/routes/messaging.py @@ -198,7 +198,7 @@ def convert_message(msg: Message) -> dict: } -def convert_dm_envelope(envelope: DMEnvelope) -> dict: +def convert_dm_envelope(db: Session, envelope: DMEnvelope) -> dict: # Group reactions by emoji reactions_dict = {} if envelope.reactions: @@ -217,9 +217,6 @@ def convert_dm_envelope(envelope: DMEnvelope) -> dict: }) # Get sender info for verified status - from models import User - from dependencies import get_db - db = next(get_db()) sender = db.query(User).filter(User.id == envelope.sender_id).first() # Handle deleted or suspended users @@ -454,9 +451,25 @@ async def dm_send( if key not in payload: raise HTTPException(status_code=400, detail=f"Missing {key}") + try: + recipient_id = int(payload["recipientId"]) + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="Invalid recipientId") + + if recipient_id <= 0: + raise HTTPException(status_code=400, detail="Invalid recipientId") + + if recipient_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot send DM to yourself") + + # Verify recipient exists + recipient = db.query(User).filter(User.id == recipient_id).first() + if not recipient or recipient.deleted or recipient.suspended: + raise HTTPException(status_code=404, detail="Recipient not found") + env = DMEnvelope( sender_id=current_user.id, - recipient_id=int(payload["recipientId"]), + recipient_id=recipient_id, iv_b64=payload["iv"], ciphertext_b64=payload["ciphertext"], salt_b64=payload["salt"], @@ -590,6 +603,17 @@ async def dm_fetch(request: Request, since: int | None = None, current_user: Use @router.get("/dm/history/{other_user_id}") @rate_limit_per_ip("60/minute") # Per-IP limit to prevent abuse async def dm_history(request: Request, other_user_id: int, current_user: User = Depends(get_current_user), db: Session = Depends(get_db)): + if other_user_id <= 0: + raise HTTPException(status_code=400, detail="Invalid user ID") + + if other_user_id == current_user.id: + raise HTTPException(status_code=400, detail="Cannot get history with yourself") + + # Verify other user exists + other_user = db.query(User).filter(User.id == other_user_id).first() + if not other_user or other_user.deleted or other_user.suspended: + raise HTTPException(status_code=404, detail="User not found") + return convert_envelopes( db.query(DMEnvelope) .filter( @@ -631,7 +655,7 @@ async def get_dm_conversations(request: Request, current_user: User = Depends(ge result.append({ "user": convert_user(other_user), - "lastMessage": convert_dm_envelope(latest_message), + "lastMessage": convert_dm_envelope(db, latest_message), "unreadCount": unread_count }) @@ -833,7 +857,7 @@ async def add_dm_reaction( # Refresh envelope to get updated reactions db.refresh(envelope) - envelope_data = convert_dm_envelope(envelope) + envelope_data = convert_dm_envelope(db, envelope) # Broadcast reaction update to both participants try: diff --git a/backend/routes/profile.py b/backend/routes/profile.py index 04a38ea..31d1794 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -275,6 +275,9 @@ async def get_user_by_username( """ Get user profile by username """ + if not username or not is_valid_username(username): + raise HTTPException(status_code=400, detail="Invalid username format") + user = db.query(User).filter(User.username == username).first() if not user: @@ -322,6 +325,9 @@ async def get_user_by_id( """ Get user profile by user ID """ + if user_id <= 0: + raise HTTPException(status_code=400, detail="Invalid user ID") + user = db.query(User).filter(User.id == user_id).first() if not user: diff --git a/backend/utils.py b/backend/utils.py index 2d294dd..660b475 100644 --- a/backend/utils.py +++ b/backend/utils.py @@ -4,7 +4,7 @@ import jwt from typing import Optional, Any import bcrypt -from constants import * +from constants import ACCESS_TOKEN_EXPIRE_HOURS, JWT_SECRET_KEY, JWT_ALGORITHM # JWT Helper Functions def create_token(user_id: int, username: str, session_id: str) -> str: diff --git a/frontend/index.html b/frontend/index.html index 9038e1e..e0e69ad 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ Loading... - +
diff --git a/frontend/src/core/push-notifications/push-notifications.ts b/frontend/src/core/push-notifications/push-notifications.ts index bef62c9..a0760dd 100644 --- a/frontend/src/core/push-notifications/push-notifications.ts +++ b/frontend/src/core/push-notifications/push-notifications.ts @@ -3,6 +3,7 @@ import { isElectron } from "@/core/electron/electron"; import { websocket } from "@/core/websocket"; import type { NewMessageWebSocketMessage, WebSocketMessage } from "@/core/types"; import serviceWorker from "./service-worker?worker&url"; +import logo from "@/images/logo.svg"; export interface PushSubscriptionData { endpoint: string; @@ -111,7 +112,7 @@ async function showMessageNotification(message: any): Promise { body: message.content.length > 100 ? message.content.substring(0, 100) + "..." : message.content, - icon: message.profile_picture || "/logo.png", + icon: message.profile_picture || logo, tag: `message_${message.id}`, data: { type: "public_message", diff --git a/frontend/src/core/push-notifications/service-worker.ts b/frontend/src/core/push-notifications/service-worker.ts index ff40735..b111c16 100644 --- a/frontend/src/core/push-notifications/service-worker.ts +++ b/frontend/src/core/push-notifications/service-worker.ts @@ -1,5 +1,7 @@ /// +import logo from "@/images/logo.svg"; + declare const self: ServiceWorkerGlobalScope; interface NotificationPayload { @@ -36,8 +38,8 @@ self.addEventListener("push", function(event: ExtendableEvent) { const options: NotificationOptions = { body: data.body, - icon: data.icon || "/logo.png", - badge: "/logo.png", + icon: data.icon || logo, + badge: logo, image: data.image, tag: data.tag || "message", data: data.data, diff --git a/frontend/src/core/websocket.ts b/frontend/src/core/websocket.ts index 435a113..d4400c1 100644 --- a/frontend/src/core/websocket.ts +++ b/frontend/src/core/websocket.ts @@ -44,6 +44,18 @@ let globalMessageHandler: ((response: WebSocketMessage) => void) | null = n */ let callSignalingHandler: CallSignalingHandler | null = null; +/** + * Reconnection state + */ +let reconnectAttempts = 0; +const MAX_RECONNECT_DELAY = 30000; // 30 seconds max delay +const INITIAL_RECONNECT_DELAY = 1000; // Start with 1 second +let isReconnecting = false; +let messageHandler: ((e: MessageEvent) => void) | null = null; +let errorHandler: ((e: Event) => void) | null = null; +let closeHandler: ((e: CloseEvent) => void) | null = null; +let openHandler: ((e: Event) => void) | null = null; + /** * Set the global WebSocket message handler * @param handler - Function to handle WebSocket messages @@ -60,97 +72,208 @@ export function setCallSignalingHandler(handler: CallSignalingHandler | null): v callSignalingHandler = handler; } -export function request(payload: WebSocketMessage): Promise> { - console.log("WebSocket request:", payload); - return new Promise((resolve, reject) => { - function requestInner() { - let listener: ((e: MessageEvent) => void) | null = null; - listener = (e) => { - resolve(JSON.parse(e.data)); - websocket.removeEventListener("message", listener!); +/** + * Clean up all event listeners from the current WebSocket instance + * @private + */ +function cleanupWebSocket(): void { + if (websocket) { + if (messageHandler) { + websocket.removeEventListener("message", messageHandler); + } + if (errorHandler) { + websocket.removeEventListener("error", errorHandler); + } + if (closeHandler) { + websocket.removeEventListener("close", closeHandler); + } + if (openHandler) { + websocket.removeEventListener("open", openHandler); + } + + // Close if still connected + if (websocket.readyState === WebSocket.OPEN || websocket.readyState === WebSocket.CONNECTING) { + try { + websocket.close(); + } catch (e) { + // Ignore errors during cleanup } - websocket.addEventListener("message", listener); - websocket.send(JSON.stringify(payload)) - - setTimeout(() => reject("Request timed out"), 10000); } - - if (websocket.readyState == 0) { - websocket.addEventListener("open", requestInner); - setTimeout(() => reject("Request timed out"), 10000); - } else { - requestInner(); - } - }) + } } /** - * This function will wait 3 seconds and them attempts to reconnect the WebSocket. - * If it fails, tries again in an endless loop until the connection is established - * again. - * + * Calculate exponential backoff delay + * @param attempt - Current reconnection attempt number + * @returns Delay in milliseconds * @private */ -async function onError() { - console.warn("WebSocket disconnected, retrying in 3 seconds..."); - await delay(3000); - websocket = create(); +function getReconnectDelay(attempt: number): number { + const delay = INITIAL_RECONNECT_DELAY * Math.pow(2, attempt); + return Math.min(delay, MAX_RECONNECT_DELAY); +} - let listener: () => void | null; - listener = () => { - console.log("WebSocket successfully reconnected!"); - websocket.removeEventListener("open", listener); +/** + * Handle WebSocket reconnection with exponential backoff + * @private + */ +async function reconnect(): Promise { + if (isReconnecting) { + return; } - websocket.addEventListener("open", listener); - websocket.addEventListener("error", onError); + isReconnecting = true; + + // Clean up old connection + cleanupWebSocket(); + + const delayMs = getReconnectDelay(reconnectAttempts); + reconnectAttempts++; + + await delay(delayMs); + + try { + websocket = create(); + setupEventHandlers(); + } catch (error) { + // If creation fails, try again + isReconnecting = false; + reconnect(); + } +} + +/** + * Setup event handlers for the WebSocket connection + * @private + */ +function setupEventHandlers(): void { + // Message handler + messageHandler = (e: MessageEvent) => { + try { + const response: WebSocketMessage = JSON.parse(e.data); + + // Handle call signaling messages + if (callSignalingHandler && response.type === "call_signaling" && response.data) { + callSignalingHandler.handleWebSocketMessage(response.data); + } + + // Handle status and typing messages + if (response.type === "statusUpdate") { + onlineStatusManager.handleStatusUpdate(response as any); + } else if (response.type === "typing") { + typingManager.handleTyping(response as any); + } else if (response.type === "stopTyping") { + typingManager.handleStopTyping(response as any); + } else if (response.type === "dmTyping") { + typingManager.handleDmTyping(response as any); + } else if (response.type === "stopDmTyping") { + typingManager.handleStopDmTyping(response as any); + } else if (response.type === "suspended") { + // Handle account suspension + const { setSuspended } = useAppState.getState(); + const reason = response.data?.reason || "No reason provided"; + setSuspended(reason); + // Close WebSocket connection + websocket.close(); + } else if (response.type === "account_deleted") { + // Handle account deletion - silent logout + const { logout } = useAppState.getState(); + logout(); + // Close WebSocket connection + websocket.close(); + } + + // Route message to global handler if set + if (globalMessageHandler) { + globalMessageHandler(response); + } + } catch (error) { + console.error("Error parsing WebSocket message:", error); + } + }; + websocket.addEventListener("message", messageHandler); + + // Open handler + openHandler = () => { + reconnectAttempts = 0; // Reset on successful connection + isReconnecting = false; + }; + websocket.addEventListener("open", openHandler); + + // Error handler + errorHandler = () => { + // Don't reconnect immediately on error - let close handler handle it + // This prevents double reconnection attempts + }; + websocket.addEventListener("error", errorHandler); + + // Close handler + closeHandler = (e: CloseEvent) => { + // Don't reconnect if it was a clean close (e.g., logout, suspension) + if (e.code === 1000 || e.code === 1001) { + return; + } + + // Reconnect for unexpected closes + if (!isReconnecting) { + reconnect(); + } + }; + websocket.addEventListener("close", closeHandler); +} + +export function request(payload: WebSocketMessage): Promise> { + console.log("WebSocket request:", payload); + return new Promise((resolve, reject) => { + const timeoutId = setTimeout(() => { + reject(new Error("Request timed out")); + }, 10000); + + function requestInner() { + if (websocket.readyState !== WebSocket.OPEN) { + clearTimeout(timeoutId); + reject(new Error("WebSocket is not open")); + return; + } + + const listener = (e: MessageEvent) => { + clearTimeout(timeoutId); + try { + resolve(JSON.parse(e.data)); + } catch (error) { + reject(error); + } + websocket.removeEventListener("message", listener); + }; + + websocket.addEventListener("message", listener); + + try { + websocket.send(JSON.stringify(payload)); + } catch (error) { + clearTimeout(timeoutId); + websocket.removeEventListener("message", listener); + reject(error); + } + } + + if (websocket.readyState === WebSocket.CONNECTING) { + const openListener = () => { + websocket.removeEventListener("open", openListener); + requestInner(); + }; + websocket.addEventListener("open", openListener); + } else if (websocket.readyState === WebSocket.OPEN) { + requestInner(); + } else { + clearTimeout(timeoutId); + reject(new Error("WebSocket is closed")); + } + }); } // -------------- // Initialization // -------------- -websocket.addEventListener("message", (e) => { - try { - const response: WebSocketMessage = JSON.parse(e.data); - - // Handle call signaling messages - if (callSignalingHandler && response.type === "call_signaling" && response.data) { - callSignalingHandler.handleWebSocketMessage(response.data); - } - - // Handle status and typing messages - if (response.type === "statusUpdate") { - onlineStatusManager.handleStatusUpdate(response as any); - } else if (response.type === "typing") { - typingManager.handleTyping(response as any); - } else if (response.type === "stopTyping") { - typingManager.handleStopTyping(response as any); - } else if (response.type === "dmTyping") { - typingManager.handleDmTyping(response as any); - } else if (response.type === "stopDmTyping") { - typingManager.handleStopDmTyping(response as any); - } else if (response.type === "suspended") { - // Handle account suspension - const { setSuspended } = useAppState.getState(); - const reason = response.data?.reason || "No reason provided"; - setSuspended(reason); - // Close WebSocket connection - websocket.close(); - } else if (response.type === "account_deleted") { - // Handle account deletion - silent logout - const { logout } = useAppState.getState(); - logout(); - // Close WebSocket connection - websocket.close(); - } - - // Route message to global handler if set - if (globalMessageHandler) { - globalMessageHandler(response); - } - } catch (error) { - console.error("Error parsing WebSocket message:", error); - } -}); -websocket.addEventListener("error", onError); \ No newline at end of file +setupEventHandlers(); \ No newline at end of file diff --git a/frontend/src/css/_components.scss b/frontend/src/css/_components.scss index 454d843..0f066b9 100644 --- a/frontend/src/css/_components.scss +++ b/frontend/src/css/_components.scss @@ -53,7 +53,7 @@ button, input { } &.warning { - color: #ff9800; // Orange color for warnings + color: $color-dark-tertiary; // Purple-themed warning color } &.small mdui-icon { diff --git a/frontend/src/css/_material.scss b/frontend/src/css/_material.scss index 281337f..b9c53c2 100644 --- a/frontend/src/css/_material.scss +++ b/frontend/src/css/_material.scss @@ -54,11 +54,6 @@ $color-dark-surface-container-highest: rgb(55 51 57); $color-dark-surface-primary-container-lightened: color.adjust($color-dark-primary-container, $lightness: 5%); $color-dark-surface-container-lightened: color.adjust($color-dark-surface-container, $lightness: 5%); -// custom colors -$color-1: rgb(82, 109, 246); -$color-2: rgb(65, 11, 113); -$color-4: rgb(95, 26, 198); -$color-3: rgb(49, 71, 179); // Light $color-light-primary: rgb(31 101 134); $color-light-surface-tint: rgb(31 101 134); diff --git a/frontend/src/pages/chat/css/left-panel.module.scss b/frontend/src/pages/chat/css/left-panel.module.scss index 375f340..932a64f 100644 --- a/frontend/src/pages/chat/css/left-panel.module.scss +++ b/frontend/src/pages/chat/css/left-panel.module.scss @@ -25,6 +25,7 @@ justify-content: center; align-items: center; padding: 16px; + user-select: none; .logo { $size: 35px;