blur public IP in sidebar, add ip lookup toggle and env var, make lookup non-blocking

This commit is contained in:
Seth Van Niekerk 2026-05-30 13:07:07 -04:00
parent effa03b2a5
commit 16cb9edcbd
No known key found for this signature in database
GPG key ID: E86ACA677312A675
12 changed files with 186 additions and 89 deletions

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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 && (
<Stack gap="sm">
{!collapsed && (
<TextInput
label="Public IP"
ref={publicIPRef}
value={environment.public_ip}
readOnly={true}
leftSection={
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
}
/>
)
}
rightSection={
<ActionIcon
variant="transparent"
color="gray.9"
onClick={copyPublicIP}
<Stack gap="sm" style={{ width: '100%' }}>
{!collapsed &&
environment.ip_lookup_enabled !== false &&
environment.public_ip &&
!environment.public_ip.startsWith('Error') && (
<Box
onMouseEnter={() => setIpRevealed(true)}
onMouseLeave={() => setIpRevealed(false)}
>
<Text size="sm" fw={500} mb={4}>Public IP</Text>
<Box
style={{
display: 'flex',
alignItems: 'center',
border: '1px solid var(--mantine-color-default-border)',
borderRadius: 'var(--mantine-radius-sm)',
backgroundColor: 'var(--mantine-color-dark-6)',
height: '36px',
paddingLeft: '10px',
gap: '8px',
cursor: 'default',
}}
>
<Copy />
</ActionIcon>
}
/>
)}
{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}
style={{ flexShrink: 0 }}
/>
)}
<Box style={{ flex: 1, overflow: 'hidden', userSelect: ipRevealed ? 'text' : 'none' }}>
<span
style={{
display: 'block',
whiteSpace: 'nowrap',
fontSize: 'var(--mantine-font-size-sm)',
color: 'var(--mantine-color-text)',
filter: ipRevealed ? 'none' : 'blur(5px)',
transition: 'filter 0.15s',
}}
>
{environment.public_ip}
</span>
</Box>
<ActionIcon
variant="transparent"
color="gray.9"
onClick={copyPublicIP}
style={{ flexShrink: 0 }}
>
<Copy />
</ActionIcon>
</Box>
</Box>
)}
{!collapsed && authUser && (
<Group

View file

@ -96,14 +96,6 @@ vi.mock('@mantine/core', async () => {
</Component>
);
},
TextInput: ({ value, onChange, leftSection, rightSection, label }) => (
<div>
{label && <label>{label}</label>}
{leftSection}
<input value={value} onChange={onChange} />
{rightSection}
</div>
),
ActionIcon: ({ children, onClick, ...props }) => (
<button onClick={onClick} {...props}>
{children}
@ -290,7 +282,7 @@ describe('Sidebar', () => {
it('should render public IP with country flag', () => {
renderSidebar();
const ipInput = screen.getByDisplayValue('192.168.1.1');
const ipInput = screen.getByText('192.168.1.1');
expect(ipInput).toBeInTheDocument();
const flag = screen.getByAltText('United States');
@ -492,7 +484,7 @@ describe('Sidebar', () => {
});
renderSidebar();
expect(screen.getByDisplayValue('192.168.1.1')).toBeInTheDocument();
expect(screen.getByText('192.168.1.1')).toBeInTheDocument();
expect(
screen.queryByRole('img', { name: /flag/i })
).not.toBeInTheDocument();

View file

@ -26,6 +26,9 @@ const SystemSettingsForm = React.memo(({ active }) => {
const settings = useSettingsStore((s) => s.settings);
const isModular =
useSettingsStore((s) => s.environment.env_mode) === 'modular';
const ipLookupEnvDisabled = useSettingsStore(
(s) => s.environment.ip_lookup_env_disabled
);
const [saved, setSaved] = useState(false);
@ -108,6 +111,23 @@ const SystemSettingsForm = React.memo(({ active }) => {
id="auto_import_mapped_files"
/>
</Group>
{!ipLookupEnvDisabled && (
<Group justify="space-between" pt={5}>
<div>
<Text size="sm" fw={500}>
Enable IP Lookup
</Text>
<Text size="xs" c="dimmed">
Fetch and display the instance's public IP and country flag in the
sidebar.
</Text>
</div>
<Switch
{...form.getInputProps('enable_ip_lookup', { type: 'checkbox' })}
id="enable_ip_lookup"
/>
</Group>
)}
{isModular && (
<>
<Divider my="md" label="Connection Security" labelPosition="left" />

View file

@ -88,6 +88,9 @@ describe('useSettingsStore', () => {
country_code: '',
country_name: '',
env_mode: 'aio',
ip_lookup_enabled: true,
ip_lookup_env_disabled: false,
ip_lookup_pending: false,
});
});

View file

@ -9,6 +9,9 @@ const useSettingsStore = create((set, get) => ({
country_code: '',
country_name: '',
env_mode: 'aio',
ip_lookup_enabled: true,
ip_lookup_env_disabled: false,
ip_lookup_pending: false,
},
version: {
version: '',
@ -41,6 +44,9 @@ const useSettingsStore = create((set, get) => ({
country_code: '',
country_name: '',
env_mode: 'aio',
ip_lookup_enabled: true,
ip_lookup_env_disabled: false,
ip_lookup_pending: false,
},
};
@ -53,6 +59,18 @@ 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 });
}

View file

@ -3,5 +3,6 @@ export const getSystemSettingsFormInitialValues = () => {
max_system_events: 100,
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
};
};

View file

@ -11,6 +11,7 @@ describe('SystemSettingsFormUtils', () => {
max_system_events: 100,
preferred_region: '',
auto_import_mapped_files: true,
enable_ip_lookup: true,
});
});

View file

@ -67,6 +67,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'max_system_events',
'preferred_region',
'auto_import_mapped_files',
'enable_ip_lookup',
];
for (const formKey in changedSettings) {
@ -133,6 +134,7 @@ export const saveChangedSettings = async (settings, changedSettings) => {
'comskip_enabled',
'schedule_enabled',
'auto_import_mapped_files',
'enable_ip_lookup',
];
if (booleanFields.includes(formKey) && value != null) {
value = typeof value === 'boolean' ? value : Boolean(value);
@ -358,6 +360,10 @@ export const parseSettings = (settings) => {
typeof systemSettings.auto_import_mapped_files === 'boolean'
? systemSettings.auto_import_mapped_files
: true;
parsed.enable_ip_lookup =
typeof systemSettings.enable_ip_lookup === 'boolean'
? systemSettings.enable_ip_lookup
: true;
}
// Proxy and network access are already grouped objects