From f403004769a55e3051ab8de48ffb3672f44423c6 Mon Sep 17 00:00:00 2001 From: SergeantPanda Date: Sun, 31 May 2026 17:56:52 -0500 Subject: [PATCH] fix(environment): harden IP lookup, add WebSocket push, improve sidebar UX - Fix socket leak in local IP detection using try/finally; replace 8.8.8.8 with RFC 5737 test address 203.0.113.1 - Validate ipify response with ipaddress.ip_address() before using in URL - Push ip_lookup_complete WebSocket event when background lookup finishes, eliminating frontend polling entirely - Show Skeleton placeholder in sidebar while IP lookup is pending - Replace hover-to-reveal blur with click-to-toggle for mobile support - Fix copy button propagating click to blur toggle via stopPropagation - Add missing city field to null-env fallback in settings store - Add setEnvironmentFields() store action for WebSocket-driven updates - Remove unused imports from api_views.py --- core/api_views.py | 27 ++++++++------ frontend/src/WebSocket.jsx | 6 ++++ frontend/src/components/Sidebar.jsx | 55 +++++++++++++++++++++-------- frontend/src/store/settings.jsx | 22 ++++++------ 4 files changed, 74 insertions(+), 36 deletions(-) diff --git a/core/api_views.py b/core/api_views.py index a4f0c1ed..ec66d69a 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -1,6 +1,5 @@ # core/api_views.py -import json import ipaddress import logging from django.conf import settings as django_settings @@ -8,11 +7,9 @@ from django.db import models from rest_framework import viewsets, status from rest_framework.response import Response from rest_framework.views import APIView -from django.shortcuts import get_object_or_404 from rest_framework.permissions import IsAuthenticated, AllowAny from rest_framework.decorators import api_view, permission_classes, action -from drf_spectacular.utils import extend_schema, OpenApiParameter -from drf_spectacular.types import OpenApiTypes +from drf_spectacular.utils import extend_schema from .models import ( UserAgent, StreamProfile, @@ -311,12 +308,19 @@ def _perform_ip_lookup(): try: s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() + try: + s.connect(("203.0.113.1", 80)) + local_ip = s.getsockname()[0] + finally: + s.close() except Exception: pass + try: + ipaddress.ip_address(public_ip) + except (ValueError, TypeError): + public_ip = None + if public_ip: try: r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5) @@ -335,15 +339,19 @@ def _perform_ip_lookup(): except Exception as e: logger.error(f"Error during geo lookup: {e}") - cache.set(_IP_CACHE_KEY, { + result = { "public_ip": public_ip, "local_ip": local_ip, "country_code": country_code, "country_name": country_name, "city": city, - }, _IP_CACHE_TTL) + } + cache.set(_IP_CACHE_KEY, result, _IP_CACHE_TTL) cache.delete(_IP_LOCK_KEY) + from core.utils import send_websocket_update + send_websocket_update("updates", "update", {"type": "ip_lookup_complete", **result}) + @extend_schema( description="Endpoint for environment details", @@ -371,7 +379,6 @@ def environment(request): country_name = cached.get("country_name") city = cached.get("city") else: - # cache.add() is atomic — only one worker starts the background thread if cache.add(_IP_LOCK_KEY, True, 30): threading.Thread(target=_perform_ip_lookup, daemon=True).start() ip_lookup_pending = True diff --git a/frontend/src/WebSocket.jsx b/frontend/src/WebSocket.jsx index 31ad1fc5..aa079830 100644 --- a/frontend/src/WebSocket.jsx +++ b/frontend/src/WebSocket.jsx @@ -965,6 +965,12 @@ export const WebsocketProvider = ({ children }) => { break; } + case 'ip_lookup_complete': { + const { type: _t, ...ipData } = parsedEvent.data; + useSettingsStore.getState().setEnvironmentFields(ipData); + break; + } + default: console.error( `Unknown websocket event type: ${parsedEvent.data?.type}` diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index b3f3494d..636db2e3 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -21,6 +21,7 @@ import { ActionIcon, AppShellNavbar, ScrollArea, + Skeleton, Tooltip, } from '@mantine/core'; import logo from '../images/logo.png'; @@ -290,13 +291,27 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { {!collapsed && environment.ip_lookup_enabled !== false && + environment.ip_lookup_pending && ( + + + Public IP + + + + )} + + {!collapsed && + environment.ip_lookup_enabled !== false && + !environment.ip_lookup_pending && environment.public_ip && !environment.public_ip.startsWith('Error') && ( setIpRevealed(true)} - onMouseLeave={() => setIpRevealed(false)} + onClick={() => setIpRevealed((v) => !v)} + style={{ cursor: 'pointer' }} > - Public IP + + Public IP + { height: '36px', paddingLeft: '10px', gap: '8px', - cursor: 'default', }} > {environment.country_code && ( {environment.country_name )} @@ -333,16 +354,19 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const sep = isIPv6 ? ':' : '.'; const parts = ip.split(sep); const splitAt = isIPv6 ? 4 : 2; - const visible = parts.slice(0, splitAt).join(sep) + sep; + const visible = + parts.slice(0, splitAt).join(sep) + sep; const hidden = parts.slice(splitAt).join(sep); return ( <> {visible} - + {hidden} @@ -353,7 +377,10 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { { + e.stopPropagation(); + copyPublicIP(); + }} style={{ flexShrink: 0 }} > @@ -379,7 +406,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { )} {collapsed && ( - + )} diff --git a/frontend/src/store/settings.jsx b/frontend/src/store/settings.jsx index 20f00ea4..abcdb98e 100644 --- a/frontend/src/store/settings.jsx +++ b/frontend/src/store/settings.jsx @@ -44,6 +44,7 @@ const useSettingsStore = create((set, get) => ({ public_ip: '', country_code: '', country_name: '', + city: '', env_mode: 'aio', ip_lookup_enabled: true, ip_lookup_env_disabled: false, @@ -60,18 +61,6 @@ const useSettingsStore = create((set, get) => ({ } set(newState); - - // If the IP lookup was still running when we fetched, retry once it should be done - if (env?.ip_lookup_pending) { - setTimeout(async () => { - try { - const freshEnv = await api.getEnvironmentSettings(); - if (freshEnv && !freshEnv.ip_lookup_pending) { - set({ environment: freshEnv }); - } - } catch {} - }, 7000); - } } catch (error) { set({ error: 'Failed to load settings.', isLoading: false }); } @@ -97,6 +86,15 @@ const useSettingsStore = create((set, get) => ({ } }, + setEnvironmentFields: (fields) => + set((state) => ({ + environment: { + ...state.environment, + ...fields, + ip_lookup_pending: false, + }, + })), + updateSetting: (setting) => set((state) => ({ settings: { ...state.settings, [setting.key]: setting },