mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
blur public IP in sidebar, add ip lookup toggle and env var, make lookup non-blocking
This commit is contained in:
parent
effa03b2a5
commit
16cb9edcbd
12 changed files with 186 additions and 89 deletions
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,5 +3,6 @@ export const getSystemSettingsFormInitialValues = () => {
|
|||
max_system_events: 100,
|
||||
preferred_region: '',
|
||||
auto_import_mapped_files: true,
|
||||
enable_ip_lookup: true,
|
||||
};
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ describe('SystemSettingsFormUtils', () => {
|
|||
max_system_events: 100,
|
||||
preferred_region: '',
|
||||
auto_import_mapped_files: true,
|
||||
enable_ip_lookup: true,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue