mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
code review & pivot
This commit is contained in:
parent
0eb99381ac
commit
a01814069a
15 changed files with 174 additions and 208 deletions
|
|
@ -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=''),
|
||||
),
|
||||
]
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
22
core/management/commands/reset_user_network.py
Normal file
22
core/management/commands/reset_user_network.py
Normal file
|
|
@ -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).")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
<TagsInput
|
||||
label="XC Allowed IP Ranges"
|
||||
description="Restrict XC access to these IP addresses or CIDR ranges. Leave empty to allow all (0.0.0.0/0)."
|
||||
label="Allowed IPs"
|
||||
description="Restrict all access for this user by IP. Leave empty to inherit global settings."
|
||||
placeholder="e.g. 192.168.1.1 or 192.168.1.0/24"
|
||||
splitChars={[',', ' ']}
|
||||
{...form.getInputProps('xc_allowed_ips')}
|
||||
key={form.key('xc_allowed_ips')}
|
||||
{...form.getInputProps('allowed_ips')}
|
||||
key={form.key('allowed_ips')}
|
||||
/>
|
||||
)}
|
||||
{canGenerateKey && (
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import {
|
|||
checkSetting,
|
||||
updateSetting,
|
||||
} from '../../../utils/pages/SettingsUtils.js';
|
||||
import { Alert, Button, Flex, Stack, Text, TextInput } from '@mantine/core';
|
||||
import { Alert, Button, Flex, Stack, TagsInput, Text } from '@mantine/core';
|
||||
import ConfirmationDialog from '../../ConfirmationDialog.jsx';
|
||||
import {
|
||||
getNetworkAccessFormInitialValues,
|
||||
|
|
@ -14,6 +14,9 @@ import {
|
|||
getNetworkAccessDefaults,
|
||||
} from '../../../utils/forms/settings/NetworkAccessFormUtils.js';
|
||||
|
||||
const toTags = (str) => (str ? str.split(',').map((s) => s.trim()).filter(Boolean) : []);
|
||||
const toStr = (tags) => (tags || []).join(',');
|
||||
|
||||
const NetworkAccessForm = React.memo(({ active }) => {
|
||||
const settings = useSettingsStore((s) => s.settings);
|
||||
|
||||
|
|
@ -43,14 +46,12 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
|
||||
useEffect(() => {
|
||||
const networkAccessSettings = settings['network_access']?.value || {};
|
||||
// M3U/EPG endpoints default to local networks only
|
||||
const m3uEpgDefaults =
|
||||
'127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
|
||||
const defaults = getNetworkAccessDefaults();
|
||||
networkAccessForm.setValues(
|
||||
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||
const defaultValue =
|
||||
key === 'M3U_EPG' ? m3uEpgDefaults : '0.0.0.0/0,::/0';
|
||||
acc[key] = networkAccessSettings[key] || defaultValue;
|
||||
acc[key] = networkAccessSettings[key]
|
||||
? toTags(networkAccessSettings[key])
|
||||
: defaults[key];
|
||||
return acc;
|
||||
}, {})
|
||||
);
|
||||
|
|
@ -65,24 +66,28 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
setNetworkAccessError(null);
|
||||
setRestoredDefaults([]);
|
||||
|
||||
// Check for blank fields and substitute defaults before saving
|
||||
const currentValues = networkAccessForm.getValues();
|
||||
const defaults = getNetworkAccessDefaults();
|
||||
const restoredLabels = [];
|
||||
const submitValues = { ...currentValues };
|
||||
const tagValues = { ...currentValues };
|
||||
|
||||
Object.keys(currentValues).forEach((key) => {
|
||||
if (!currentValues[key] || currentValues[key].trim() === '') {
|
||||
submitValues[key] = defaults[key];
|
||||
if (!currentValues[key] || currentValues[key].length === 0) {
|
||||
tagValues[key] = defaults[key];
|
||||
restoredLabels.push(NETWORK_ACCESS_OPTIONS[key]?.label || key);
|
||||
}
|
||||
});
|
||||
|
||||
if (restoredLabels.length > 0) {
|
||||
networkAccessForm.setValues(submitValues);
|
||||
networkAccessForm.setValues(tagValues);
|
||||
setRestoredDefaults(restoredLabels);
|
||||
}
|
||||
|
||||
// Backend expects comma-separated strings
|
||||
const submitValues = Object.fromEntries(
|
||||
Object.entries(tagValues).map(([k, v]) => [k, toStr(v)])
|
||||
);
|
||||
|
||||
pendingSaveValuesRef.current = submitValues;
|
||||
|
||||
const check = await checkSetting({
|
||||
|
|
@ -111,8 +116,9 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
const saveNetworkAccess = async () => {
|
||||
setSaved(false);
|
||||
setSaving(true);
|
||||
const values =
|
||||
pendingSaveValuesRef.current || networkAccessForm.getValues();
|
||||
const values = pendingSaveValuesRef.current || Object.fromEntries(
|
||||
Object.entries(networkAccessForm.getValues()).map(([k, v]) => [k, toStr(v)])
|
||||
);
|
||||
try {
|
||||
await updateSetting({
|
||||
...settings['network_access'],
|
||||
|
|
@ -136,11 +142,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
<form onSubmit={networkAccessForm.onSubmit(onNetworkAccessSubmit)}>
|
||||
<Stack gap="sm">
|
||||
{saved && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="green"
|
||||
title="Saved Successfully"
|
||||
></Alert>
|
||||
<Alert variant="light" color="green" title="Saved Successfully" />
|
||||
)}
|
||||
{restoredDefaults.length > 0 && (
|
||||
<Alert variant="light" color="yellow" title="Defaults Restored">
|
||||
|
|
@ -149,35 +151,25 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
</Alert>
|
||||
)}
|
||||
{networkAccessError && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="red"
|
||||
title={networkAccessError}
|
||||
></Alert>
|
||||
<Alert variant="light" color="red" title={networkAccessError} />
|
||||
)}
|
||||
|
||||
{Object.entries(NETWORK_ACCESS_OPTIONS).map(([key, config]) => (
|
||||
<TextInput
|
||||
<TagsInput
|
||||
label={config.label}
|
||||
description={config.description}
|
||||
placeholder="e.g. 192.168.1.1 or 192.168.1.0/24"
|
||||
splitChars={[',', ' ']}
|
||||
{...networkAccessForm.getInputProps(key)}
|
||||
key={networkAccessForm.key(key)}
|
||||
description={config.description}
|
||||
/>
|
||||
))}
|
||||
|
||||
<Flex mih={50} gap="xs" justify="space-between" align="flex-end">
|
||||
<Button
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={resetNetworkAccessToDefaults}
|
||||
>
|
||||
<Button variant="subtle" color="gray" onClick={resetNetworkAccessToDefaults}>
|
||||
Reset to Defaults
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={networkAccessForm.submitting}
|
||||
variant="default"
|
||||
>
|
||||
<Button type="submit" disabled={networkAccessForm.submitting} variant="default">
|
||||
Save
|
||||
</Button>
|
||||
</Flex>
|
||||
|
|
@ -188,7 +180,7 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
opened={networkAccessConfirmOpen}
|
||||
onClose={() => setNetworkAccessConfirmOpen(false)}
|
||||
onConfirm={saveNetworkAccess}
|
||||
title={`Confirm Network Access Blocks`}
|
||||
title="Confirm Network Access Blocks"
|
||||
loading={saving}
|
||||
message={
|
||||
<>
|
||||
|
|
@ -197,10 +189,9 @@ const NetworkAccessForm = React.memo(({ active }) => {
|
|||
included in the allowed networks for the web UI. Are you sure you
|
||||
want to proceed?
|
||||
</Text>
|
||||
|
||||
<ul>
|
||||
{netNetworkAccessConfirmCIDRs.map((cidr) => (
|
||||
<li>{cidr}</li>
|
||||
<li key={cidr}>{cidr}</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -4,11 +4,12 @@ import NetworkAccessForm from '../NetworkAccessForm';
|
|||
|
||||
// ── Constants mock ─────────────────────────────────────────────────────────────
|
||||
vi.mock('../../../../constants.js', () => ({
|
||||
NETWORK_ACCESS_OPTIONS: [
|
||||
{ value: 'all', label: 'All' },
|
||||
{ value: 'local', label: 'Local Only' },
|
||||
{ value: 'custom', label: 'Custom' },
|
||||
],
|
||||
NETWORK_ACCESS_OPTIONS: {
|
||||
M3U_EPG: { label: 'M3U / EPG Endpoints', description: 'Limit M3U/EPG access' },
|
||||
STREAMS: { label: 'Stream Endpoints', description: 'Limit stream access' },
|
||||
XC_API: { label: 'XC API', description: 'Limit XC API access' },
|
||||
UI: { label: 'UI', description: 'Limit UI access' },
|
||||
},
|
||||
}));
|
||||
|
||||
// ── Store mock ─────────────────────────────────────────────────────────────────
|
||||
|
|
@ -82,6 +83,12 @@ vi.mock('@mantine/core', () => ({
|
|||
{error && <span data-testid={`${id}-error`}>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
TagsInput: ({ label, placeholder, error, ...rest }) => (
|
||||
<div>
|
||||
<input aria-label={label} placeholder={placeholder} data-error={error} readOnly />
|
||||
{error && <span>{error}</span>}
|
||||
</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -103,12 +110,10 @@ import {
|
|||
// Helpers
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
const mockInitialValues = {
|
||||
m3u_access: 'local',
|
||||
epg_access: 'local',
|
||||
recordings_access: 'all',
|
||||
m3u_custom_cidrs: '',
|
||||
epg_custom_cidrs: '',
|
||||
recordings_custom_cidrs: '',
|
||||
M3U_EPG: ['127.0.0.0/8', '192.168.0.0/16'],
|
||||
STREAMS: ['0.0.0.0/0', '::/0'],
|
||||
XC_API: ['0.0.0.0/0', '::/0'],
|
||||
UI: ['0.0.0.0/0', '::/0'],
|
||||
};
|
||||
|
||||
const makeFormMock = (overrides = {}) => ({
|
||||
|
|
@ -134,9 +139,10 @@ const makeSettings = (overrides = {}) => ({
|
|||
network_access: {
|
||||
key: 'network_access',
|
||||
value: {
|
||||
m3u_access: 'local',
|
||||
epg_access: 'local',
|
||||
recordings_access: 'all',
|
||||
M3U_EPG: '127.0.0.0/8,192.168.0.0/16',
|
||||
STREAMS: '0.0.0.0/0,::/0',
|
||||
XC_API: '0.0.0.0/0,::/0',
|
||||
UI: '0.0.0.0/0,::/0',
|
||||
},
|
||||
},
|
||||
...overrides,
|
||||
|
|
|
|||
|
|
@ -2,46 +2,34 @@ import { NETWORK_ACCESS_OPTIONS } from '../../../constants.js';
|
|||
import { IPV4_CIDR_REGEX, IPV6_CIDR_REGEX } from '../../networkUtils.js';
|
||||
|
||||
// Default CIDR ranges for M3U/EPG endpoints (local networks only)
|
||||
const M3U_EPG_DEFAULTS =
|
||||
'127.0.0.0/8,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,::1/128,fc00::/7,fe80::/10';
|
||||
const M3U_EPG_DEFAULTS = ['127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16', '::1/128', 'fc00::/7', 'fe80::/10'];
|
||||
const OPEN_DEFAULTS = ['0.0.0.0/0', '::/0'];
|
||||
|
||||
export const getNetworkAccessFormInitialValues = () => {
|
||||
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||
const isValidEntry = (entry) =>
|
||||
entry.match(IPV4_CIDR_REGEX) ||
|
||||
entry.match(IPV6_CIDR_REGEX) ||
|
||||
(entry + '/32').match(IPV4_CIDR_REGEX) ||
|
||||
(entry + '/128').match(IPV6_CIDR_REGEX);
|
||||
|
||||
export const getNetworkAccessFormInitialValues = () =>
|
||||
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||
// M3U/EPG endpoints default to local networks only
|
||||
acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : '0.0.0.0/0,::/0';
|
||||
acc[key] = key === 'M3U_EPG' ? M3U_EPG_DEFAULTS : OPEN_DEFAULTS;
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getNetworkAccessFormValidation = () => {
|
||||
return Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||
acc[key] = (value) => {
|
||||
if (!value || value.trim() === '') {
|
||||
return null; // Empty values will be replaced with defaults on submit
|
||||
}
|
||||
|
||||
if (
|
||||
value
|
||||
.split(',')
|
||||
.some(
|
||||
(cidr) =>
|
||||
!(cidr.match(IPV4_CIDR_REGEX) || cidr.match(IPV6_CIDR_REGEX))
|
||||
)
|
||||
) {
|
||||
return 'Invalid CIDR range';
|
||||
}
|
||||
|
||||
return null;
|
||||
export const getNetworkAccessFormValidation = () =>
|
||||
Object.keys(NETWORK_ACCESS_OPTIONS).reduce((acc, key) => {
|
||||
acc[key] = (tags) => {
|
||||
if (!tags || tags.length === 0) return null;
|
||||
return tags.some((t) => !isValidEntry(t)) ? 'Invalid IP address or CIDR range' : null;
|
||||
};
|
||||
return acc;
|
||||
}, {});
|
||||
};
|
||||
|
||||
export const getNetworkAccessDefaults = () => {
|
||||
return {
|
||||
M3U_EPG: M3U_EPG_DEFAULTS,
|
||||
STREAMS: '0.0.0.0/0,::/0',
|
||||
XC_API: '0.0.0.0/0,::/0',
|
||||
UI: '0.0.0.0/0,::/0',
|
||||
};
|
||||
};
|
||||
export const getNetworkAccessDefaults = () => ({
|
||||
M3U_EPG: M3U_EPG_DEFAULTS,
|
||||
STREAMS: OPEN_DEFAULTS,
|
||||
XC_API: OPEN_DEFAULTS,
|
||||
UI: OPEN_DEFAULTS,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,9 +27,9 @@ describe('NetworkAccessFormUtils', () => {
|
|||
const result = NetworkAccessFormUtils.getNetworkAccessFormInitialValues();
|
||||
|
||||
expect(result).toEqual({
|
||||
'network-access-admin': '0.0.0.0/0,::/0',
|
||||
'network-access-api': '0.0.0.0/0,::/0',
|
||||
'network-access-streaming': '0.0.0.0/0,::/0',
|
||||
'network-access-admin': ['0.0.0.0/0', '::/0'],
|
||||
'network-access-api': ['0.0.0.0/0', '::/0'],
|
||||
'network-access-streaming': ['0.0.0.0/0', '::/0'],
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -80,9 +80,9 @@ describe('NetworkAccessFormUtils', () => {
|
|||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.0/24')).toBeNull();
|
||||
expect(validator('10.0.0.0/8')).toBeNull();
|
||||
expect(validator('0.0.0.0/0')).toBeNull();
|
||||
expect(validator(['192.168.1.0/24'])).toBeNull();
|
||||
expect(validator(['10.0.0.0/8'])).toBeNull();
|
||||
expect(validator(['0.0.0.0/0'])).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate valid IPv6 CIDR ranges', () => {
|
||||
|
|
@ -90,18 +90,18 @@ describe('NetworkAccessFormUtils', () => {
|
|||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('2001:db8::/32')).toBeNull();
|
||||
expect(validator('::/0')).toBeNull();
|
||||
expect(validator(['2001:db8::/32'])).toBeNull();
|
||||
expect(validator(['::/0'])).toBeNull();
|
||||
});
|
||||
|
||||
it('should validate multiple CIDR ranges separated by commas', () => {
|
||||
it('should validate multiple CIDR entries', () => {
|
||||
const validation =
|
||||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.0/24,10.0.0.0/8')).toBeNull();
|
||||
expect(validator('0.0.0.0/0,::/0')).toBeNull();
|
||||
expect(validator('192.168.1.0/24,2001:db8::/32')).toBeNull();
|
||||
expect(validator(['192.168.1.0/24', '10.0.0.0/8'])).toBeNull();
|
||||
expect(validator(['0.0.0.0/0', '::/0'])).toBeNull();
|
||||
expect(validator(['192.168.1.0/24', '2001:db8::/32'])).toBeNull();
|
||||
});
|
||||
|
||||
it('should return error for invalid IPv4 CIDR ranges', () => {
|
||||
|
|
@ -109,30 +109,31 @@ describe('NetworkAccessFormUtils', () => {
|
|||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.256.1/24')).toBe('Invalid CIDR range');
|
||||
expect(validator('invalid')).toBe('Invalid CIDR range');
|
||||
expect(validator('192.168.1.0/256')).toBe('Invalid CIDR range');
|
||||
expect(validator(['192.168.1.256.1/24'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['invalid'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['192.168.1.0/256'])).toBe('Invalid IP address or CIDR range');
|
||||
});
|
||||
|
||||
it('should return error when any CIDR in comma-separated list is invalid', () => {
|
||||
it('should return error when any entry in the list is invalid', () => {
|
||||
const validation =
|
||||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
expect(validator('192.168.1.0/24,invalid')).toBe('Invalid CIDR range');
|
||||
expect(validator('invalid,192.168.1.0/24')).toBe('Invalid CIDR range');
|
||||
expect(validator('192.168.1.0/24,10.0.0.0/8,invalid')).toBe(
|
||||
'Invalid CIDR range'
|
||||
expect(validator(['192.168.1.0/24', 'invalid'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['invalid', '192.168.1.0/24'])).toBe('Invalid IP address or CIDR range');
|
||||
expect(validator(['192.168.1.0/24', '10.0.0.0/8', 'invalid'])).toBe(
|
||||
'Invalid IP address or CIDR range'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle empty strings', () => {
|
||||
it('should handle empty arrays', () => {
|
||||
const validation =
|
||||
NetworkAccessFormUtils.getNetworkAccessFormValidation();
|
||||
const validator = validation['network-access-admin'];
|
||||
|
||||
// Empty values are allowed — defaults are substituted on submit
|
||||
expect(validator('')).toBe(null);
|
||||
expect(validator([])).toBe(null);
|
||||
expect(validator(null)).toBe(null);
|
||||
});
|
||||
|
||||
it('should return empty object when NETWORK_ACCESS_OPTIONS is empty', () => {
|
||||
|
|
|
|||
|
|
@ -1,11 +1,3 @@
|
|||
// Plain IPv4 address regex
|
||||
export const IPV4_REGEX =
|
||||
/^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$/;
|
||||
|
||||
// Plain IPv6 address regex
|
||||
export const IPV6_REGEX =
|
||||
/^(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))$/;
|
||||
|
||||
// IPv4 CIDR regex - validates IP address and prefix length (0-32)
|
||||
export const IPV4_CIDR_REGEX =
|
||||
/^((25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.){3}(25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\/(3[0-2]|[12]?[0-9])$/;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue