mirror of
https://github.com/Dispatcharr/Dispatcharr.git
synced 2026-07-17 16:50:53 +00:00
Bug #954: The initialize_superuser endpoint only checked is_superuser=True, missing admin accounts created via the API (user_level=10). This caused users to intermittently see the "Create Super User" page instead of login. Feature #1004: Admins can now disable/enable user accounts. Disabled users are blocked from JWT login, token refresh, XC API access, and all authenticated endpoints. The last active admin cannot be disabled. Files changed: Backend: - apps/accounts/api_views.py — Superuser check uses user_level>=10; token refresh blocks disabled users - apps/accounts/models.py — CustomUserManager ensures create_superuser() always sets user_level=10 - apps/accounts/serializers.py — Last-admin protection guard; respect is_active on user creation - apps/accounts/tests.py — new tests covering superuser detection, token refresh blocking, last-admin protection, disabled user login/access - apps/backups/api_views.py — Switched from DRF's IsAdminUser (checks is_staff) to app's IsAdmin (checks user_level) on all 8 endpoints - apps/backups/tests.py — new tests verifying user_level-based admin permission on backup endpoints - apps/output/views.py — is_active check on xc_get_user(), xc_movie_stream(), xc_series_stream() - apps/proxy/ts_proxy/views.py — is_active check on stream_xc() - core/api_views.py — Admin notification filter uses user_level>=10 - core/developer_notifications.py — Admin checks use user_level>=10 Frontend: - frontend/src/App.jsx — Null-safe superuser existence check - frontend/src/components/forms/User.jsx — Account Enabled switch with self-disable prevention and tooltip - frontend/src/components/tables/UsersTable.jsx — Status column (Active/Disabled badge)
412 lines
15 KiB
Python
412 lines
15 KiB
Python
"""
|
|
Developer Notification Sync Service
|
|
|
|
Handles syncing developer-defined notifications from the JSON file to the database.
|
|
This ensures users receive important notifications from the development team
|
|
about recommended settings, security updates, and other announcements.
|
|
|
|
JSON Schema (see fixtures/developer_notifications.json):
|
|
{
|
|
"id": str, # REQUIRED - Unique identifier (notification_key)
|
|
"title": str, # REQUIRED - Notification heading
|
|
"message": str, # REQUIRED - Notification body text
|
|
|
|
"notification_type": str, # OPTIONAL - 'version_update', 'setting_recommendation', 'announcement', 'warning', 'info' (default: 'info')
|
|
"priority": str, # OPTIONAL - 'low', 'normal', 'high', 'critical' (default: 'normal')
|
|
"min_version": str | null, # OPTIONAL - Minimum version (inclusive), e.g., "0.17.0" (default: null)
|
|
"max_version": str | null, # OPTIONAL - Maximum version (inclusive), e.g., "0.18.1" (default: null)
|
|
"created_at": str, # OPTIONAL - ISO timestamp for tracking
|
|
"expires_at": str | null, # OPTIONAL - ISO timestamp when notification expires (default: null)
|
|
"condition": list[str], # OPTIONAL - List of condition check names, AND logic (default: [])
|
|
"user_level": str, # OPTIONAL - 'all' or 'admin' (default: 'all')
|
|
"action_url": str | null, # OPTIONAL - Internal navigation URL (e.g., "/settings#network-access")
|
|
"action_text": str | null, # OPTIONAL - Text for action button (required if action_url is set)
|
|
}
|
|
|
|
Condition Checks:
|
|
Conditions are function names from CONDITION_CHECKS registry that evaluate
|
|
whether a notification should be shown. All conditions must pass (AND logic).
|
|
|
|
Available conditions:
|
|
- 'm3u_epg_network_insecure': M3U/EPG endpoint allows access from anywhere
|
|
|
|
To add new conditions:
|
|
1. Define a function: check_your_condition(user) -> bool
|
|
2. Add to CONDITION_CHECKS registry
|
|
3. Reference in JSON: "condition": ["your_condition"]
|
|
|
|
Sync Behavior:
|
|
- Runs on startup (see apps.py)
|
|
- Runs when relevant settings change (see signals.py)
|
|
- Adds new notifications if in version range and not expired
|
|
- Updates existing notifications with latest data
|
|
- Removes notifications that are:
|
|
* No longer in JSON file
|
|
* Out of current version range
|
|
* Past expiration date
|
|
- Sends websocket event to refresh frontend
|
|
- Cache invalidated when triggering settings change
|
|
"""
|
|
import json
|
|
import logging
|
|
from datetime import datetime
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from django.conf import settings
|
|
from django.db import models
|
|
from django.utils import timezone
|
|
from packaging import version
|
|
|
|
from version import __version__
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Path to developer notifications JSON file
|
|
NOTIFICATIONS_FILE = Path(__file__).parent / 'fixtures' / 'developer_notifications.json'
|
|
|
|
|
|
# ─────────────────────────────
|
|
# Condition Checks
|
|
# ─────────────────────────────
|
|
# Each condition function receives (user) and returns True if the notification should show
|
|
|
|
def check_network_access_is_default(user, endpoint: str = 'M3U_EPG') -> bool:
|
|
"""
|
|
Check if network access settings for a specific endpoint are insecure (allow all).
|
|
|
|
Args:
|
|
user: The user object (unused but required for condition check signature)
|
|
endpoint: The endpoint to check (e.g., 'M3U_EPG', 'XC_API')
|
|
|
|
Returns:
|
|
True if the notification should show (insecure settings detected)
|
|
"""
|
|
from core.models import CoreSettings, NETWORK_ACCESS_KEY
|
|
|
|
try:
|
|
network_settings = CoreSettings._get_group(NETWORK_ACCESS_KEY, {})
|
|
|
|
# Empty settings are secure (defaults to local network only)
|
|
if not network_settings:
|
|
return False
|
|
|
|
# Get the specific endpoint's allowed networks (stored as comma-separated string)
|
|
allowed_networks_str = network_settings.get(endpoint, '')
|
|
if not allowed_networks_str:
|
|
return False
|
|
|
|
# Parse comma-separated network addresses
|
|
allowed_networks = [net.strip() for net in allowed_networks_str.split(',')]
|
|
|
|
# Check if settings allow access from anywhere (insecure)
|
|
if '0.0.0.0/0' in allowed_networks or '::/0' in allowed_networks:
|
|
return True
|
|
|
|
return False
|
|
except Exception as e:
|
|
logger.warning(f"Error checking network_access_is_default condition for {endpoint}: {e}")
|
|
return False
|
|
|
|
|
|
# Registry of all available condition checks
|
|
CONDITION_CHECKS: dict[str, Callable] = {
|
|
'm3u_epg_network_insecure': lambda user: check_network_access_is_default(user, 'M3U_EPG'),
|
|
# Add more conditions here as needed
|
|
# 'transcode_not_configured': check_transcode_not_configured,
|
|
# 'no_backup_configured': check_no_backup_configured,
|
|
}
|
|
|
|
|
|
# ─────────────────────────────
|
|
# Version Utilities
|
|
# ─────────────────────────────
|
|
|
|
def parse_version(version_str: str | None) -> version.Version | None:
|
|
"""Parse a version string, returning None if invalid or empty."""
|
|
if not version_str:
|
|
return None
|
|
try:
|
|
return version.parse(version_str)
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def is_version_in_range(
|
|
current_version: str,
|
|
min_version: str | None,
|
|
max_version: str | None
|
|
) -> bool:
|
|
"""Check if current version is within the specified range."""
|
|
current = parse_version(current_version)
|
|
if not current:
|
|
return True # If we can't parse version, show notification
|
|
|
|
min_ver = parse_version(min_version)
|
|
max_ver = parse_version(max_version)
|
|
|
|
if min_ver and current < min_ver:
|
|
return False
|
|
if max_ver and current > max_ver:
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
# ─────────────────────────────
|
|
# Notification Evaluation
|
|
# ─────────────────────────────
|
|
|
|
def evaluate_conditions(conditions: list[str] | str | None, user) -> bool:
|
|
"""
|
|
Evaluate notification conditions for a user.
|
|
All conditions must pass (AND logic).
|
|
"""
|
|
if not conditions:
|
|
return True
|
|
|
|
# Normalize to list
|
|
if isinstance(conditions, str):
|
|
conditions = [conditions]
|
|
|
|
for condition in conditions:
|
|
if condition not in CONDITION_CHECKS:
|
|
logger.warning(f"Unknown condition: {condition}")
|
|
continue
|
|
|
|
try:
|
|
if not CONDITION_CHECKS[condition](user):
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"Error evaluating condition {condition}: {e}")
|
|
# On error, skip this condition (fail open)
|
|
continue
|
|
|
|
return True
|
|
|
|
|
|
def should_show_notification(notification_data: dict, user) -> bool:
|
|
"""
|
|
Determine if a notification should be shown to a specific user.
|
|
Checks version range, user level, and conditions.
|
|
"""
|
|
# Check version range
|
|
if not is_version_in_range(
|
|
__version__,
|
|
notification_data.get('min_version'),
|
|
notification_data.get('max_version')
|
|
):
|
|
return False
|
|
|
|
# Check user level
|
|
user_level = notification_data.get('user_level', 'all')
|
|
if user_level == 'admin' and getattr(user, 'user_level', 0) < 10:
|
|
return False
|
|
|
|
# Check conditions
|
|
conditions = notification_data.get('condition', [])
|
|
if not evaluate_conditions(conditions, user):
|
|
return False
|
|
|
|
return True
|
|
|
|
|
|
# ─────────────────────────────
|
|
# Sync Service
|
|
# ─────────────────────────────
|
|
|
|
def load_developer_notifications() -> list[dict]:
|
|
"""Load notifications from the JSON file."""
|
|
if not NOTIFICATIONS_FILE.exists():
|
|
logger.warning(f"Developer notifications file not found: {NOTIFICATIONS_FILE}")
|
|
return []
|
|
|
|
try:
|
|
with open(NOTIFICATIONS_FILE, 'r', encoding='utf-8') as f:
|
|
data = json.load(f)
|
|
return data.get('notifications', [])
|
|
except json.JSONDecodeError as e:
|
|
logger.error(f"Error parsing developer notifications JSON: {e}")
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Error loading developer notifications: {e}")
|
|
return []
|
|
|
|
|
|
def sync_developer_notifications() -> dict[str, int]:
|
|
"""
|
|
Sync developer notifications from JSON file to database.
|
|
|
|
- Adds new notifications that don't exist in the DB
|
|
- Removes DB notifications that are no longer in the JSON file
|
|
- Updates existing notifications if they've changed
|
|
|
|
Returns a dict with counts of added, updated, and removed notifications.
|
|
"""
|
|
from core.models import SystemNotification
|
|
|
|
results = {'added': 0, 'updated': 0, 'removed': 0, 'skipped': 0}
|
|
|
|
notifications = load_developer_notifications()
|
|
json_notification_keys = set()
|
|
notifications_to_remove = set() # Track notifications to remove (out of range or expired)
|
|
|
|
for notif_data in notifications:
|
|
notification_id = notif_data.get('id')
|
|
if not notification_id:
|
|
logger.warning("Notification missing 'id' field, skipping")
|
|
results['skipped'] += 1
|
|
continue
|
|
|
|
json_notification_keys.add(notification_id)
|
|
|
|
# Check version constraints (only add if current version is in range)
|
|
if not is_version_in_range(
|
|
__version__,
|
|
notif_data.get('min_version'),
|
|
notif_data.get('max_version')
|
|
):
|
|
logger.debug(f"Notification {notification_id} not in version range, marking for removal")
|
|
results['skipped'] += 1
|
|
notifications_to_remove.add(notification_id)
|
|
continue
|
|
|
|
# Parse expires_at if provided
|
|
expires_at = None
|
|
if notif_data.get('expires_at'):
|
|
try:
|
|
expires_at = datetime.fromisoformat(
|
|
notif_data['expires_at'].replace('Z', '+00:00')
|
|
)
|
|
# Skip if already expired and mark for removal
|
|
if expires_at < timezone.now():
|
|
logger.debug(f"Notification {notification_id} has expired, marking for removal")
|
|
results['skipped'] += 1
|
|
notifications_to_remove.add(notification_id)
|
|
continue
|
|
except (ValueError, TypeError) as e:
|
|
logger.warning(f"Invalid expires_at for {notification_id}: {e}")
|
|
|
|
# Map notification_type from JSON to model choices
|
|
type_mapping = {
|
|
'version_update': SystemNotification.NotificationType.VERSION_UPDATE,
|
|
'setting_recommendation': SystemNotification.NotificationType.SETTING_RECOMMENDATION,
|
|
'announcement': SystemNotification.NotificationType.ANNOUNCEMENT,
|
|
'warning': SystemNotification.NotificationType.WARNING,
|
|
'info': SystemNotification.NotificationType.INFO,
|
|
}
|
|
notification_type = type_mapping.get(
|
|
notif_data.get('notification_type', 'info'),
|
|
SystemNotification.NotificationType.INFO
|
|
)
|
|
|
|
# Map priority
|
|
priority_mapping = {
|
|
'low': SystemNotification.Priority.LOW,
|
|
'normal': SystemNotification.Priority.NORMAL,
|
|
'high': SystemNotification.Priority.HIGH,
|
|
'critical': SystemNotification.Priority.CRITICAL,
|
|
}
|
|
priority = priority_mapping.get(
|
|
notif_data.get('priority', 'normal'),
|
|
SystemNotification.Priority.NORMAL
|
|
)
|
|
|
|
# Prepare action_data
|
|
action_data = {
|
|
'action_url': notif_data.get('action_url'),
|
|
'action_text': notif_data.get('action_text'),
|
|
'condition': notif_data.get('condition', []),
|
|
'min_version': notif_data.get('min_version'),
|
|
'max_version': notif_data.get('max_version'),
|
|
'user_level': notif_data.get('user_level', 'all'),
|
|
}
|
|
|
|
# Determine if admin-only based on user_level
|
|
admin_only = notif_data.get('user_level', 'all') == 'admin'
|
|
|
|
# Create or update the notification
|
|
notification, created = SystemNotification.objects.update_or_create(
|
|
notification_key=notification_id,
|
|
defaults={
|
|
'notification_type': notification_type,
|
|
'priority': priority,
|
|
'source': SystemNotification.Source.DEVELOPER,
|
|
'title': notif_data.get('title', 'Notification'),
|
|
'message': notif_data.get('message', ''),
|
|
'action_data': action_data,
|
|
'is_active': True,
|
|
'admin_only': admin_only,
|
|
'expires_at': expires_at,
|
|
}
|
|
)
|
|
|
|
if created:
|
|
logger.info(f"Added developer notification: {notification_id}")
|
|
results['added'] += 1
|
|
else:
|
|
logger.debug(f"Updated developer notification: {notification_id}")
|
|
results['updated'] += 1
|
|
|
|
# Remove developer notifications that are:
|
|
# - No longer in the JSON file, OR
|
|
# - Out of version range for the current version, OR
|
|
# - Expired
|
|
removed_count, _ = SystemNotification.objects.filter(
|
|
source=SystemNotification.Source.DEVELOPER
|
|
).filter(
|
|
models.Q(notification_key__in=notifications_to_remove) |
|
|
~models.Q(notification_key__in=json_notification_keys)
|
|
).delete()
|
|
|
|
if removed_count:
|
|
logger.info(f"Removed {removed_count} obsolete/expired/out-of-range developer notification(s)")
|
|
results['removed'] = removed_count
|
|
|
|
logger.info(
|
|
f"Developer notification sync complete: "
|
|
f"{results['added']} added, {results['updated']} updated, "
|
|
f"{results['removed']} removed, {results['skipped']} skipped"
|
|
)
|
|
|
|
# Send websocket notification to frontend to refresh notifications
|
|
try:
|
|
from core.utils import send_websocket_update
|
|
send_websocket_update('updates', 'update', {
|
|
'type': 'notifications_cleared',
|
|
})
|
|
logger.debug("Sent websocket notification for notifications refresh")
|
|
except Exception as e:
|
|
logger.warning(f"Failed to send websocket update: {e}")
|
|
|
|
return results
|
|
|
|
|
|
def get_user_developer_notifications(user) -> list:
|
|
"""
|
|
Get all developer notifications that should be shown to a specific user.
|
|
Evaluates conditions and user_level for each notification.
|
|
"""
|
|
from core.models import SystemNotification
|
|
|
|
# Get all active developer notifications
|
|
notifications = SystemNotification.objects.filter(
|
|
source=SystemNotification.Source.DEVELOPER,
|
|
is_active=True
|
|
)
|
|
|
|
# Filter by admin_only based on user
|
|
if getattr(user, 'user_level', 0) < 10:
|
|
notifications = notifications.filter(admin_only=False)
|
|
|
|
# Filter by conditions
|
|
result = []
|
|
for notification in notifications:
|
|
action_data = notification.action_data or {}
|
|
|
|
# Evaluate conditions
|
|
conditions = action_data.get('condition', [])
|
|
if evaluate_conditions(conditions, user):
|
|
result.append(notification)
|
|
|
|
return result
|