diff --git a/core/api_views.py b/core/api_views.py index f79c2396..a6b8cca2 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -32,8 +32,10 @@ from .serializers import ( ) import socket +import threading import requests import os +from django.core.cache import cache from core.tasks import rehash_streams from apps.accounts.permissions import ( Authenticated, @@ -287,6 +289,58 @@ class ProxySettingsViewSet(viewsets.ViewSet): +_IP_CACHE_KEY = "dispatcharr:ip_lookup_result" +_IP_CACHE_TTL = 3600 # 1 hour +_IP_LOCK_KEY = "dispatcharr:ip_lookup_lock" + + +def _perform_ip_lookup(): + """Run IP and geolocation lookups in a background thread and cache the result.""" + public_ip = None + local_ip = None + country_code = None + country_name = None + + try: + r = requests.get("https://api64.ipify.org?format=json", timeout=5) + r.raise_for_status() + public_ip = r.json().get("ip") + except requests.RequestException: + pass + + try: + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(("8.8.8.8", 80)) + local_ip = s.getsockname()[0] + s.close() + except Exception: + pass + + if public_ip: + try: + r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5) + if r.status_code == requests.codes.ok: + geo = r.json() + country_code = geo.get("country_code") + country_name = geo.get("country_name") + else: + r = requests.get("http://ip-api.com/json/", timeout=5) + if r.status_code == requests.codes.ok: + geo = r.json() + country_code = geo.get("countryCode") + country_name = geo.get("country") + except Exception as e: + logger.error(f"Error during geo lookup: {e}") + + cache.set(_IP_CACHE_KEY, { + "public_ip": public_ip, + "local_ip": local_ip, + "country_code": country_code, + "country_name": country_name, + }, _IP_CACHE_TTL) + cache.delete(_IP_LOCK_KEY) + + @extend_schema( description="Endpoint for environment details", ) @@ -297,55 +351,26 @@ def environment(request): local_ip = None country_code = None country_name = None + ip_lookup_pending = False - # 1) Get the public IP from ipify.org API - try: - r = requests.get("https://api64.ipify.org?format=json", timeout=5) - r.raise_for_status() - public_ip = r.json().get("ip") - except requests.RequestException as e: - public_ip = f"Error: {e}" + ip_lookup_env_disabled = not getattr(django_settings, "ENABLE_IP_LOOKUP", True) + ip_lookup_db_enabled = CoreSettings.get_system_settings().get("enable_ip_lookup", True) + ip_lookup_enabled = not ip_lookup_env_disabled and ip_lookup_db_enabled - # 2) Get the local IP by connecting to a public DNS server - try: - s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) - # connect to a "public" address so the OS can determine our local interface - s.connect(("8.8.8.8", 80)) - local_ip = s.getsockname()[0] - s.close() - except Exception as e: - local_ip = f"Error: {e}" + if ip_lookup_enabled: + cached = cache.get(_IP_CACHE_KEY) + if cached is not None: + public_ip = cached.get("public_ip") + local_ip = cached.get("local_ip") + country_code = cached.get("country_code") + country_name = cached.get("country_name") + 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 - # 3) Get geolocation data from ipapi.co or ip-api.com - if public_ip and "Error" not in public_ip: - try: - # Attempt to get geo information from ipapi.co first - r = requests.get(f"https://ipapi.co/{public_ip}/json/", timeout=5) - - if r.status_code == requests.codes.ok: - geo = r.json() - country_code = geo.get("country_code") # e.g. "US" - country_name = geo.get("country_name") # e.g. "United States" - - else: - # If ipapi.co fails, fallback to ip-api.com - # only supports http requests for free tier - r = requests.get("http://ip-api.com/json/", timeout=5) - - if r.status_code == requests.codes.ok: - geo = r.json() - country_code = geo.get("countryCode") # e.g. "US" - country_name = geo.get("country") # e.g. "United States" - - else: - raise Exception("Geo lookup failed with both services") - - except Exception as e: - logger.error(f"Error during geo lookup: {e}") - country_code = None - country_name = None - - # 4) Get environment mode and TLS status from settings + # Get environment mode and TLS status from settings postgres_ssl = getattr(django_settings, "POSTGRES_SSL", False) return Response( @@ -355,6 +380,9 @@ def environment(request): "local_ip": local_ip, "country_code": country_code, "country_name": country_name, + "ip_lookup_enabled": ip_lookup_enabled, + "ip_lookup_env_disabled": ip_lookup_env_disabled, + "ip_lookup_pending": ip_lookup_pending, "env_mode": os.getenv("DISPATCHARR_ENV", "aio"), "redis_tls": { "enabled": getattr(django_settings, "REDIS_SSL", False), diff --git a/core/models.py b/core/models.py index 0ec4efda..60be2f0e 100644 --- a/core/models.py +++ b/core/models.py @@ -407,6 +407,7 @@ class CoreSettings(models.Model): "max_system_events": 100, "preferred_region": None, "auto_import_mapped_files": True, + "enable_ip_lookup": True, }) @classmethod diff --git a/dispatcharr/settings.py b/dispatcharr/settings.py index 6a6856db..51d5bc49 100644 --- a/dispatcharr/settings.py +++ b/dispatcharr/settings.py @@ -59,6 +59,8 @@ if REDIS_SSL: else: print("Redis TLS: disabled") +ENABLE_IP_LOOKUP = os.environ.get("DISPATCHARR_ENABLE_IP_LOOKUP", "true").lower() == "true" + # Set DEBUG to True for development, False for production if os.environ.get("DISPATCHARR_DEBUG", "False").lower() == "true": DEBUG = True diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index e5433650..505b64b5 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -177,7 +177,7 @@ export POSTGRES_DIR=/data/db variables=( PATH VIRTUAL_ENV DJANGO_SETTINGS_MODULE PYTHONUNBUFFERED PYTHONDONTWRITEBYTECODE POSTGRES_DB POSTGRES_USER POSTGRES_PASSWORD POSTGRES_HOST POSTGRES_PORT - DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL + DISPATCHARR_ENV DISPATCHARR_DEBUG DISPATCHARR_LOG_LEVEL DISPATCHARR_ENABLE_IP_LOOKUP REDIS_HOST REDIS_PORT REDIS_DB REDIS_PASSWORD REDIS_USER POSTGRES_DIR DISPATCHARR_PORT DISPATCHARR_VERSION DISPATCHARR_TIMESTAMP LIBVA_DRIVERS_PATH LIBVA_DRIVER_NAME LD_LIBRARY_PATH CELERY_NICE_LEVEL UWSGI_NICE_LEVEL DJANGO_SECRET_KEY diff --git a/frontend/src/components/Sidebar.jsx b/frontend/src/components/Sidebar.jsx index c17f02e4..cc0de7ce 100644 --- a/frontend/src/components/Sidebar.jsx +++ b/frontend/src/components/Sidebar.jsx @@ -1,4 +1,4 @@ -import React, { useRef, useState, useMemo } from 'react'; +import React, { useState, useMemo } from 'react'; import { Link, useLocation } from 'react-router-dom'; import { copyToClipboard } from '../utils'; import { @@ -18,7 +18,6 @@ import { Box, Text, UnstyledButton, - TextInput, ActionIcon, AppShellNavbar, ScrollArea, @@ -161,10 +160,9 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { const getNavOrder = useAuthStore((s) => s.getNavOrder); const getHiddenNav = useAuthStore((s) => s.getHiddenNav); - const publicIPRef = useRef(null); - const [userFormOpen, setUserFormOpen] = useState(false); const [aboutOpen, setAboutOpen] = useState(false); + const [ipRevealed, setIpRevealed] = useState(false); const closeUserForm = () => setUserFormOpen(false); @@ -289,35 +287,62 @@ const Sidebar = ({ collapsed, toggleDrawer, drawerWidth, miniDrawerWidth }) => { }} > {isAuthenticated && ( - - {!collapsed && ( - - ) - } - rightSection={ - + {!collapsed && + environment.ip_lookup_enabled !== false && + environment.public_ip && + !environment.public_ip.startsWith('Error') && ( + setIpRevealed(true)} + onMouseLeave={() => setIpRevealed(false)} + > + Public IP + - - - } - /> - )} + {environment.country_code && ( + {environment.country_name + )} + + + {environment.public_ip} + + + + + + + + )} {!collapsed && authUser && ( { ); }, - TextInput: ({ value, onChange, leftSection, rightSection, label }) => ( -
- {label && } - {leftSection} - - {rightSection} -
- ), ActionIcon: ({ children, onClick, ...props }) => (