diff --git a/apps/accounts/migrations/0007_user_xc_allowed_ips.py b/apps/accounts/migrations/0007_user_xc_allowed_ips.py
deleted file mode 100644
index 962dc639..00000000
--- a/apps/accounts/migrations/0007_user_xc_allowed_ips.py
+++ /dev/null
@@ -1,16 +0,0 @@
-from django.db import migrations, models
-
-
-class Migration(migrations.Migration):
-
- dependencies = [
- ('accounts', '0006_user_stream_limit'),
- ]
-
- operations = [
- migrations.AddField(
- model_name='user',
- name='xc_allowed_ips',
- field=models.TextField(blank=True, default=''),
- ),
- ]
diff --git a/apps/accounts/models.py b/apps/accounts/models.py
index 155bfc15..2a32f95a 100644
--- a/apps/accounts/models.py
+++ b/apps/accounts/models.py
@@ -31,7 +31,6 @@ class User(AbstractUser):
custom_properties = models.JSONField(default=dict, blank=True, null=True)
api_key = models.CharField(max_length=200, blank=True, null=True, db_index=True)
stream_limit = models.IntegerField(default=0)
- xc_allowed_ips = models.TextField(blank=True, default='')
def __str__(self):
return self.username
diff --git a/apps/accounts/permissions.py b/apps/accounts/permissions.py
index 62673038..f564f68f 100644
--- a/apps/accounts/permissions.py
+++ b/apps/accounts/permissions.py
@@ -6,7 +6,8 @@ from dispatcharr.utils import network_access_allowed
class Authenticated(IsAuthenticated):
def has_permission(self, request, view):
is_authenticated = super().has_permission(request, view)
- network_allowed = network_access_allowed(request, "UI")
+ user = request.user if hasattr(request, 'user') and request.user.is_authenticated else None
+ network_allowed = network_access_allowed(request, "UI", user)
return is_authenticated and network_allowed
diff --git a/apps/accounts/serializers.py b/apps/accounts/serializers.py
index 067f6b45..8a9f609e 100644
--- a/apps/accounts/serializers.py
+++ b/apps/accounts/serializers.py
@@ -1,5 +1,4 @@
import json
-import ipaddress
from rest_framework import serializers
from django.contrib.auth.models import Group, Permission
@@ -67,7 +66,6 @@ class UserSerializer(serializers.ModelSerializer):
"custom_properties",
"avatar_config",
"stream_limit",
- "xc_allowed_ips",
"is_staff",
"is_superuser",
"last_login",
@@ -76,19 +74,6 @@ class UserSerializer(serializers.ModelSerializer):
"last_name",
]
- def validate_xc_allowed_ips(self, value):
- if not value or not value.strip():
- return value
- for cidr in value.split(','):
- cidr = cidr.strip()
- if not cidr:
- continue
- try:
- ipaddress.ip_network(cidr, strict=False)
- except ValueError:
- raise serializers.ValidationError(f"'{cidr}' is not a valid CIDR range")
- return value
-
def validate_custom_properties(self, value):
"""Validate custom_properties structure and size."""
if value is None:
diff --git a/apps/output/views.py b/apps/output/views.py
index f10eff42..958dd3be 100644
--- a/apps/output/views.py
+++ b/apps/output/views.py
@@ -7,7 +7,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods
from apps.epg.models import ProgramData
from apps.accounts.models import User
-from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
+from dispatcharr.utils import network_access_allowed
from django.utils import timezone as django_timezone
from django.shortcuts import get_object_or_404
from datetime import datetime, timedelta
@@ -1926,7 +1926,7 @@ def xc_get_user(request):
if custom_properties["xc_password"] != password:
return None
- if not user_xc_ip_allowed(request, user):
+ if not network_access_allowed(request, 'XC_API', user):
return None
return user
diff --git a/apps/proxy/ts_proxy/views.py b/apps/proxy/ts_proxy/views.py
index 900d218e..cb926890 100644
--- a/apps/proxy/ts_proxy/views.py
+++ b/apps/proxy/ts_proxy/views.py
@@ -40,7 +40,7 @@ from .url_utils import (
from .utils import get_logger
from uuid import UUID
import gevent
-from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
+from dispatcharr.utils import network_access_allowed
from apps.proxy.utils import check_user_stream_limits
logger = get_logger()
@@ -572,7 +572,7 @@ def stream_xc(request, username, password, channel_id):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
- if not user_xc_ip_allowed(request, user):
+ if not network_access_allowed(request, 'STREAMS', user):
return Response({"error": "Invalid credentials"}, status=401)
if user.user_level < 10:
diff --git a/apps/proxy/vod_proxy/views.py b/apps/proxy/vod_proxy/views.py
index fa77fa0f..c5d39fb9 100644
--- a/apps/proxy/vod_proxy/views.py
+++ b/apps/proxy/vod_proxy/views.py
@@ -19,7 +19,7 @@ from rest_framework.permissions import AllowAny
from apps.accounts.models import User
from apps.accounts.permissions import IsAdmin
from apps.proxy.utils import check_user_stream_limits
-from dispatcharr.utils import network_access_allowed, user_xc_ip_allowed
+from dispatcharr.utils import network_access_allowed
logger = logging.getLogger(__name__)
@@ -1031,7 +1031,7 @@ def stream_xc_movie(request, username, password, stream_id, extension):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
- if not user_xc_ip_allowed(request, user):
+ if not network_access_allowed(request, 'STREAMS', user):
return Response({"error": "Invalid credentials"}, status=401)
# All authenticated users get access to VOD from all active M3U accounts
@@ -1068,7 +1068,7 @@ def stream_xc_episode(request, username, password, stream_id, extension):
if custom_properties["xc_password"] != password:
return Response({"error": "Invalid credentials"}, status=401)
- if not user_xc_ip_allowed(request, user):
+ if not network_access_allowed(request, 'STREAMS', user):
return Response({"error": "Invalid credentials"}, status=401)
# All authenticated users get access to series/episodes from all active M3U accounts
diff --git a/core/management/commands/reset_user_network.py b/core/management/commands/reset_user_network.py
new file mode 100644
index 00000000..a6a43243
--- /dev/null
+++ b/core/management/commands/reset_user_network.py
@@ -0,0 +1,22 @@
+from django.core.management.base import BaseCommand
+from apps.accounts.models import User
+
+
+class Command(BaseCommand):
+ help = "Reset per-user network access restrictions"
+
+ def add_arguments(self, parser):
+ parser.add_argument('--user', type=str, help='Username to reset (omit to reset all users)')
+
+ def handle(self, *args, **options):
+ username = options.get('user')
+ qs = User.objects.filter(username=username) if username else User.objects.all()
+ count = 0
+ for user in qs:
+ props = user.custom_properties or {}
+ if 'allowed_networks' in props:
+ del props['allowed_networks']
+ user.custom_properties = props
+ user.save(update_fields=['custom_properties'])
+ count += 1
+ self.stdout.write(f"Reset allowed_networks for {count} user(s).")
diff --git a/dispatcharr/utils.py b/dispatcharr/utils.py
index f96cd539..02fe7853 100644
--- a/dispatcharr/utils.py
+++ b/dispatcharr/utils.py
@@ -38,27 +38,7 @@ def get_client_ip(request):
return ip
-def user_xc_ip_allowed(request, user):
- """Check request IP against per-user XC allowed ranges. Empty = allow all (0.0.0.0/0)."""
- allowed_ips = getattr(user, 'xc_allowed_ips', '') or ''
- if not allowed_ips.strip():
- return True
-
- cidrs = [c.strip() for c in allowed_ips.split(',') if c.strip()]
- if not cidrs:
- return True
-
- client_ip = ipaddress.ip_address(get_client_ip(request))
- for cidr in cidrs:
- try:
- if client_ip in ipaddress.ip_network(cidr, strict=False):
- return True
- except ValueError:
- continue
- return False
-
-
-def network_access_allowed(request, settings_key):
+def network_access_allowed(request, settings_key, user=None):
try:
network_access = CoreSettings.objects.get(key=NETWORK_ACCESS_KEY).value
except CoreSettings.DoesNotExist:
@@ -86,4 +66,19 @@ def network_access_allowed(request, settings_key):
network_allowed = True
break
- return network_allowed
+ if not network_allowed:
+ return False
+
+ if user is not None:
+ user_networks = (getattr(user, 'custom_properties', None) or {}).get('allowed_networks', {})
+ raw = user_networks.get(settings_key, '')
+ if raw:
+ for cidr in (c.strip() for c in raw.split(',') if c.strip()):
+ try:
+ if client_ip in ipaddress.ip_network(cidr, strict=False):
+ return True
+ except ValueError:
+ continue
+ return False
+
+ return True
diff --git a/frontend/src/components/forms/User.jsx b/frontend/src/components/forms/User.jsx
index cb3a8c23..a849e066 100644
--- a/frontend/src/components/forms/User.jsx
+++ b/frontend/src/components/forms/User.jsx
@@ -21,10 +21,18 @@ import { RotateCcwKey, X } from 'lucide-react';
import { Copy, Key } from 'lucide-react';
import { useForm } from '@mantine/form';
import useChannelsStore from '../../store/channels';
-import { USER_LEVELS, USER_LEVEL_LABELS } from '../../constants';
+import { USER_LEVELS, USER_LEVEL_LABELS, NETWORK_ACCESS_OPTIONS } from '../../constants';
import useAuthStore from '../../store/auth';
import { copyToClipboard } from '../../utils';
-import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX, IPV4_REGEX, IPV6_REGEX } from '../../utils/networkUtils';
+import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../utils/networkUtils';
+
+const isValidNetworkEntry = (entry) =>
+ entry.match(IPV4_CIDR_REGEX) ||
+ entry.match(IPV6_CIDR_REGEX) ||
+ (entry + '/32').match(IPV4_CIDR_REGEX) ||
+ (entry + '/128').match(IPV6_CIDR_REGEX);
+
+const NETWORK_KEYS = Object.keys(NETWORK_ACCESS_OPTIONS);
const User = ({ user = null, isOpen, onClose }) => {
const profiles = useChannelsStore((s) => s.profiles);
@@ -50,11 +58,11 @@ const User = ({ user = null, isOpen, onClose }) => {
stream_limit: 0,
password: '',
xc_password: '',
- xc_allowed_ips: [],
channel_profiles: [],
hide_adult_content: false,
epg_days: 0,
epg_prev_days: 0,
+ allowed_ips: [],
},
validate: (values) => ({
@@ -66,20 +74,14 @@ const User = ({ user = null, isOpen, onClose }) => {
: null,
password:
!user && !values.password && values.user_level != USER_LEVELS.STREAMER
- ? 'Password is requried'
+ ? 'Password is required'
: null,
xc_password:
values.xc_password && !values.xc_password.match(/^[a-z0-9]+$/i)
? 'XC password must be alphanumeric'
: null,
- xc_allowed_ips: values.xc_allowed_ips.some(
- (entry) =>
- !entry.match(IPV4_CIDR_REGEX) &&
- !entry.match(IPV6_CIDR_REGEX) &&
- !entry.match(IPV4_REGEX) &&
- !entry.match(IPV6_REGEX)
- )
- ? 'Each entry must be a valid IP address or CIDR range (e.g. 192.168.1.1 or 192.168.1.0/24)'
+ allowed_ips: (values.allowed_ips || []).some((t) => !isValidNetworkEntry(t))
+ ? 'Invalid IP address or CIDR range'
: null,
}),
});
@@ -102,18 +104,12 @@ const User = ({ user = null, isOpen, onClose }) => {
const customProps = user?.custom_properties || {};
- // Always save xc_password, even if it's empty (to allow clearing)
customProps.xc_password = values.xc_password || '';
delete values.xc_password;
- // Serialize xc_allowed_ips array to comma-separated string for the backend
- values.xc_allowed_ips = (values.xc_allowed_ips || []).join(',');
-
- // Save hide_adult_content in custom_properties
customProps.hide_adult_content = values.hide_adult_content || false;
delete values.hide_adult_content;
- // Save EPG defaults in custom_properties
customProps.epg_days = values.epg_days || 0;
delete values.epg_days;
customProps.epg_prev_days = values.epg_prev_days || 0;
@@ -121,13 +117,18 @@ const User = ({ user = null, isOpen, onClose }) => {
values.custom_properties = customProps;
- // If 'All' is included, clear this and we assume access to all channels
+ // Serialize per-user network restrictions into custom_properties (same list for all types)
+ const joined = (values.allowed_ips || []).join(',');
+ delete values.allowed_ips;
+ const allowed_networks = {};
+ if (joined) NETWORK_KEYS.forEach((key) => { allowed_networks[key] = joined; });
+ customProps.allowed_networks = allowed_networks;
+
if (values.channel_profiles.includes('0')) {
values.channel_profiles = [];
}
if (!user && values.user_level == USER_LEVELS.STREAMER) {
- // Generate random password - they can't log in, but user can't be created without a password
values.password = Math.random().toString(36).slice(2);
}
@@ -157,6 +158,7 @@ const User = ({ user = null, isOpen, onClose }) => {
useEffect(() => {
if (user?.id) {
const customProps = user.custom_properties || {};
+ const networks = customProps.allowed_networks || {};
form.setValues({
username: user.username,
@@ -170,12 +172,14 @@ const User = ({ user = null, isOpen, onClose }) => {
? user.channel_profiles.map((id) => `${id}`)
: ['0'],
xc_password: customProps.xc_password || '',
- xc_allowed_ips: user.xc_allowed_ips
- ? user.xc_allowed_ips.split(',').filter(Boolean)
- : [],
hide_adult_content: customProps.hide_adult_content || false,
epg_days: customProps.epg_days || 0,
epg_prev_days: customProps.epg_prev_days || 0,
+ allowed_ips: [...new Set(
+ NETWORK_KEYS.flatMap((key) =>
+ networks[key] ? networks[key].split(',').filter(Boolean) : []
+ )
+ )],
});
if (customProps.xc_password) {
@@ -241,12 +245,10 @@ const User = ({ user = null, isOpen, onClose }) => {
}
const resp = await API.revokeApiKey(payload);
- // backend returns { success: true } - clear local state
if (resp && resp.success) {
setGeneratedKey(null);
setUserAPIKey(null);
- // If we're revoking the current authenticated user's key, update auth store
if (user?.id && authUser?.id === user.id) {
setUser({ ...authUser, api_key: null });
}
@@ -404,12 +406,12 @@ const User = ({ user = null, isOpen, onClose }) => {
/>
{isAdmin && (