mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
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
This commit is contained in:
parent
1d07b26a01
commit
f403004769
4 changed files with 74 additions and 36 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}`
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
|||
<Stack gap="sm" style={{ width: '100%' }}>
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
environment.ip_lookup_pending && (
|
||||
<Box>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Skeleton height={36} radius="sm" />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!collapsed &&
|
||||
environment.ip_lookup_enabled !== false &&
|
||||
!environment.ip_lookup_pending &&
|
||||
environment.public_ip &&
|
||||
!environment.public_ip.startsWith('Error') && (
|
||||
<Box
|
||||
onMouseEnter={() => setIpRevealed(true)}
|
||||
onMouseLeave={() => setIpRevealed(false)}
|
||||
onClick={() => setIpRevealed((v) => !v)}
|
||||
style={{ cursor: 'pointer' }}
|
||||
>
|
||||
<Text size="sm" fw={500} mb={4}>Public IP</Text>
|
||||
<Text size="sm" fw={500} mb={4}>
|
||||
Public IP
|
||||
</Text>
|
||||
<Box
|
||||
style={{
|
||||
display: 'flex',
|
||||
|
|
@ -307,14 +322,20 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
height: '36px',
|
||||
paddingLeft: '10px',
|
||||
gap: '8px',
|
||||
cursor: 'default',
|
||||
}}
|
||||
>
|
||||
{environment.country_code && (
|
||||
<img
|
||||
src={`https://flagcdn.com/16x12/${environment.country_code.toLowerCase()}.png`}
|
||||
alt={environment.country_name || environment.country_code}
|
||||
title={[environment.country_name || environment.country_code, environment.city].filter(Boolean).join(', ')}
|
||||
alt={
|
||||
environment.country_name || environment.country_code
|
||||
}
|
||||
title={[
|
||||
environment.country_name || environment.country_code,
|
||||
environment.city,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(', ')}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
)}
|
||||
|
|
@ -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}
|
||||
<span style={{
|
||||
filter: ipRevealed ? 'none' : 'blur(5px)',
|
||||
transition: 'filter 0.15s',
|
||||
userSelect: ipRevealed ? 'text' : 'none',
|
||||
}}>
|
||||
<span
|
||||
style={{
|
||||
filter: ipRevealed ? 'none' : 'blur(5px)',
|
||||
transition: 'filter 0.15s',
|
||||
userSelect: ipRevealed ? 'text' : 'none',
|
||||
}}
|
||||
>
|
||||
{hidden}
|
||||
</span>
|
||||
</>
|
||||
|
|
@ -353,7 +377,10 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray.9"
|
||||
onClick={copyPublicIP}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
copyPublicIP();
|
||||
}}
|
||||
style={{ flexShrink: 0 }}
|
||||
>
|
||||
<Copy />
|
||||
|
|
@ -379,7 +406,7 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => {
|
|||
</Group>
|
||||
)}
|
||||
{collapsed && (
|
||||
<Group gap="xs">
|
||||
<Group justify="center" style={{ width: '100%' }}>
|
||||
<Avatar src="" radius="xl" />
|
||||
</Group>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue