diff --git a/.dockerignore b/.dockerignore index 5073af60..c79ca7b4 100755 --- a/.dockerignore +++ b/.dockerignore @@ -11,6 +11,10 @@ **/.toolstarget **/.vs **/.vscode +**/.history +**/media +**/models +**/static **/*.*proj.user **/*.dbmdl **/*.jfm @@ -26,3 +30,4 @@ **/values.dev.yaml LICENSE README.md +data/ diff --git a/apps/m3u/serializers.py b/apps/m3u/serializers.py index 038af628..7394f00b 100644 --- a/apps/m3u/serializers.py +++ b/apps/m3u/serializers.py @@ -3,33 +3,45 @@ from rest_framework.response import Response from .models import M3UAccount, M3UFilter, ServerGroup, M3UAccountProfile from core.models import UserAgent from apps.channels.models import ChannelGroup, ChannelGroupM3UAccount -from apps.channels.serializers import ChannelGroupM3UAccountSerializer, ChannelGroupSerializer +from apps.channels.serializers import ( + ChannelGroupM3UAccountSerializer, + ChannelGroupSerializer, +) import logging logger = logging.getLogger(__name__) + class M3UFilterSerializer(serializers.ModelSerializer): """Serializer for M3U Filters""" - channel_groups = ChannelGroupM3UAccountSerializer(source='m3u_account', many=True) + + channel_groups = ChannelGroupM3UAccountSerializer(source="m3u_account", many=True) class Meta: model = M3UFilter - fields = ['id', 'filter_type', 'regex_pattern', 'exclude', 'channel_groups'] + fields = ["id", "filter_type", "regex_pattern", "exclude", "channel_groups"] -from rest_framework import serializers -from .models import M3UAccountProfile class M3UAccountProfileSerializer(serializers.ModelSerializer): class Meta: model = M3UAccountProfile - fields = ['id', 'name', 'max_streams', 'is_active', 'is_default', 'current_viewers', 'search_pattern', 'replace_pattern'] - read_only_fields = ['id'] + fields = [ + "id", + "name", + "max_streams", + "is_active", + "is_default", + "current_viewers", + "search_pattern", + "replace_pattern", + ] + read_only_fields = ["id"] def create(self, validated_data): - m3u_account = self.context.get('m3u_account') + m3u_account = self.context.get("m3u_account") # Use the m3u_account when creating the profile - validated_data['m3u_account_id'] = m3u_account.id + validated_data["m3u_account_id"] = m3u_account.id return super().create(validated_data) @@ -43,12 +55,14 @@ class M3UAccountProfileSerializer(serializers.ModelSerializer): if instance.is_default: return Response( {"error": "Default profiles cannot be deleted."}, - status=status.HTTP_400_BAD_REQUEST + status=status.HTTP_400_BAD_REQUEST, ) return super().destroy(request, *args, **kwargs) + class M3UAccountSerializer(serializers.ModelSerializer): """Serializer for M3U Account""" + filters = M3UFilterSerializer(many=True, read_only=True) # Include user_agent as a mandatory field using its primary key. user_agent = serializers.PrimaryKeyRelatedField( @@ -57,28 +71,48 @@ class M3UAccountSerializer(serializers.ModelSerializer): allow_null=True, ) profiles = M3UAccountProfileSerializer(many=True, read_only=True) - read_only_fields = ['locked', 'created_at', 'updated_at'] + read_only_fields = ["locked", "created_at", "updated_at"] # channel_groups = serializers.SerializerMethodField() - channel_groups = ChannelGroupM3UAccountSerializer(source='channel_group', many=True, required=False) + channel_groups = ChannelGroupM3UAccountSerializer( + source="channel_group", many=True, required=False + ) class Meta: model = M3UAccount fields = [ - 'id', 'name', 'server_url', 'file_path', 'server_group', - 'max_streams', 'is_active', 'created_at', 'updated_at', 'filters', 'user_agent', 'profiles', 'locked', - 'channel_groups', 'refresh_interval', 'custom_properties', 'account_type', 'username', 'password', 'stale_stream_days', - 'status', 'last_message', + "id", + "name", + "server_url", + "file_path", + "server_group", + "max_streams", + "is_active", + "created_at", + "updated_at", + "filters", + "user_agent", + "profiles", + "locked", + "channel_groups", + "refresh_interval", + "custom_properties", + "account_type", + "username", + "password", + "stale_stream_days", + "status", + "last_message", ] extra_kwargs = { - 'password': { - 'required': False, - 'allow_blank': True, + "password": { + "required": False, + "allow_blank": True, }, } def update(self, instance, validated_data): # Pop out channel group memberships so we can handle them manually - channel_group_data = validated_data.pop('channel_group', []) + channel_group_data = validated_data.pop("channel_group", []) # First, update the M3UAccount itself for attr, value in validated_data.items(): @@ -88,13 +122,12 @@ class M3UAccountSerializer(serializers.ModelSerializer): # Prepare a list of memberships to update memberships_to_update = [] for group_data in channel_group_data: - group = group_data.get('channel_group') - enabled = group_data.get('enabled') + group = group_data.get("channel_group") + enabled = group_data.get("enabled") try: membership = ChannelGroupM3UAccount.objects.get( - m3u_account=instance, - channel_group=group + m3u_account=instance, channel_group=group ) membership.enabled = enabled memberships_to_update.append(membership) @@ -103,13 +136,16 @@ class M3UAccountSerializer(serializers.ModelSerializer): # Perform the bulk update if memberships_to_update: - ChannelGroupM3UAccount.objects.bulk_update(memberships_to_update, ['enabled']) + ChannelGroupM3UAccount.objects.bulk_update( + memberships_to_update, ["enabled"] + ) return instance + class ServerGroupSerializer(serializers.ModelSerializer): """Serializer for Server Group""" class Meta: model = ServerGroup - fields = ['id', 'name'] + fields = ["id", "name"] diff --git a/core/api_views.py b/core/api_views.py index b3e0c1bb..bf6ee2ba 100644 --- a/core/api_views.py +++ b/core/api_views.py @@ -1,5 +1,7 @@ # core/api_views.py +import json +import ipaddress from rest_framework import viewsets, status from rest_framework.response import Response from django.shortcuts import get_object_or_404 @@ -9,7 +11,7 @@ from .serializers import ( StreamProfileSerializer, CoreSettingsSerializer, ) -from rest_framework.decorators import api_view, permission_classes +from rest_framework.decorators import api_view, permission_classes, action from drf_yasg.utils import swagger_auto_schema import socket import requests @@ -18,6 +20,7 @@ from core.tasks import rehash_streams from apps.accounts.permissions import ( Authenticated, ) +from dispatcharr.utils import get_client_ip class UserAgentViewSet(viewsets.ModelViewSet): @@ -56,6 +59,23 @@ class CoreSettingsViewSet(viewsets.ModelViewSet): return response + @action(detail=False, methods=["post"], url_path="check") + def check(self, request, *args, **kwargs): + data = request.data + + client_ip = ipaddress.ip_address(get_client_ip(request)) + in_network = [] + key = data.get("key") + value = json.loads(data.get("value", "{}")) + for key, val in value.items(): + cidrs = val.split(",") + for cidr in cidrs: + network = ipaddress.ip_network(cidr) + if client_ip not in network: + in_network.append(cidr) + + return Response(in_network, status=status.HTTP_200_OK) + @swagger_auto_schema( method="get", diff --git a/frontend/src/api.js b/frontend/src/api.js index 488f0f31..17c38b90 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -1085,6 +1085,21 @@ export default class API { } } + static async checkSetting(values) { + const { id, ...payload } = values; + + try { + const response = await request(`${host}/api/core/settings/check/`, { + method: 'POST', + body: payload, + }); + + return response; + } catch (e) { + errorNotification('Failed to update settings', e); + } + } + static async updateSetting(values) { const { id, ...payload } = values; diff --git a/frontend/src/pages/Settings.jsx b/frontend/src/pages/Settings.jsx index 6606f977..b4fc37cc 100644 --- a/frontend/src/pages/Settings.jsx +++ b/frontend/src/pages/Settings.jsx @@ -24,6 +24,7 @@ import StreamProfilesTable from '../components/tables/StreamProfilesTable'; import useLocalStorage from '../hooks/useLocalStorage'; import useAuthStore from '../store/auth'; import { USER_LEVELS, NETWORK_ACCESS_OPTIONS } from '../constants'; +import ConfirmationDialog from '../components/ConfirmationDialog'; const SettingsPage = () => { const settings = useSettingsStore((s) => s.settings); @@ -33,6 +34,10 @@ const SettingsPage = () => { const [accordianValue, setAccordianValue] = useState(null); const [networkAccessSaved, setNetworkAccessSaved] = useState(false); + const [networkAccessConfirmOpen, setNetworkAccessConfirmOpen] = + useState(false); + const [netNetworkAccessConfirmCIDRs, setNetNetworkAccessConfirmCIDRs] = + useState([]); // UI / local storage settings const [tableSize, setTableSize] = useLocalStorage('table-size', 'default'); @@ -315,7 +320,6 @@ const SettingsPage = () => { useEffect(() => { if (settings) { - console.log(settings); const formValues = Object.entries(settings).reduce( (acc, [key, value]) => { // Modify each value based on its own properties @@ -378,7 +382,21 @@ const SettingsPage = () => { }; const onNetworkAccessSubmit = async () => { - let result = null; + setNetworkAccessSaved(false); + const check = await API.checkSetting({ + ...settings['network-access'], + value: JSON.stringify(networkAccessForm.getValues()), + }); + + if (check.length == 0) { + return saveNetworkAccess(); + } + + setNetNetworkAccessConfirmCIDRs(check); + setNetworkAccessConfirmOpen(true); + }; + + const saveNetworkAccess = async () => { setNetworkAccessSaved(false); try { await API.updateSetting({ @@ -386,6 +404,7 @@ const SettingsPage = () => { value: JSON.stringify(networkAccessForm.getValues()), }); setNetworkAccessSaved(true); + setNetworkAccessConfirmOpen(false); } catch (e) { const errors = {}; for (const key in e.body.value) { @@ -644,6 +663,30 @@ const SettingsPage = () => { )} + + setNetworkAccessConfirmOpen(false)} + onConfirm={saveNetworkAccess} + title={`Confirm Network Access Blocks`} + message={ + <> + + Your client is included in the following CIDRs and could block + access Are you sure you want to proceed? + + + + + } + confirmLabel="Save" + cancelLabel="Cancel" + size="md" + /> ); };