""" HTTP Range request support for media file streaming Enables seeking in audio/video files by supporting partial content delivery Security Features: - Path normalization to prevent directory traversal - User authentication (handled by Django middleware) - File validation - Content-Type header enforcement - Symlink attack prevention Note: Authentication is handled by Django's authentication middleware before this view is reached. All media files are considered protected and require an authenticated user session. """ import os import re import logging from django.http import StreamingHttpResponse, HttpResponse, Http404 from django.utils.http import http_date from pathlib import Path from wsgiref.util import FileWrapper logger = logging.getLogger(__name__) def range_file_iterator(file_obj, offset=0, chunk_size=8192, length=None): """ Iterator for serving file in chunks with range support Efficiently streams large files without loading entire file into memory Args: file_obj: Open file object offset: Starting byte position chunk_size: Size of each chunk to read length: Total bytes to read (None = read to end) """ file_obj.seek(offset) remaining = length while True: if remaining is not None: chunk_size = min(chunk_size, remaining) if chunk_size == 0: break data = file_obj.read(chunk_size) if not data: break if remaining is not None: remaining -= len(data) yield data def serve_media_with_range(request, path, document_root): """ Serve static media files with HTTP Range request support This enables seeking in audio/video files Security considerations: 1. Authentication: Assumes authentication is handled by Django middleware 2. Path Traversal: Prevents access to files outside document_root 3. File Validation: Only serves existing files within allowed directory 4. No Directory Listing: Returns 404 for directories Args: request: Django request object (user must be authenticated) path: Relative path to file (validated for security) document_root: Absolute path to media root directory Returns: StreamingHttpResponse with proper Range headers for seeking support HTTP Status Codes: 200: Full content served 206: Partial content served (range request) 416: Range Not Satisfiable 404: File not found or access denied """ # Security: Normalize path and prevent directory traversal attacks # Remove any path components that try to navigate up the directory tree path = Path(path).as_posix() if '..' in path or path.startswith('/') or '\\' in path: logger.warning(f"Blocked directory traversal attempt: {path}") raise Http404("Invalid path") # Build full file path full_path = Path(document_root) / path # Security: Verify the resolved path is still within document_root # This prevents symlink attacks and ensures files are in allowed directory try: full_path = full_path.resolve() document_root = Path(document_root).resolve() full_path.relative_to(document_root) except (ValueError, OSError) as e: logger.warning(f"Access denied for path: {path} - {e}") raise Http404("Access denied") # Check if file exists and is a file (not directory) if not full_path.exists() or not full_path.is_file(): logger.debug(f"Media file not found: {path}") raise Http404(f"Media file not found: {path}") # Get file size file_size = full_path.stat().st_size # Get Range header range_header = request.META.get('HTTP_RANGE', '').strip() range_match = re.match(r'bytes=(\d+)-(\d*)', range_header) # Determine content type content_type = 'application/octet-stream' ext = full_path.suffix.lower() content_types = { '.mp3': 'audio/mpeg', '.mp4': 'video/mp4', '.m4a': 'audio/mp4', '.webm': 'video/webm', '.ogg': 'audio/ogg', '.wav': 'audio/wav', '.flac': 'audio/flac', '.aac': 'audio/aac', '.opus': 'audio/opus', } content_type = content_types.get(ext, content_type) # Open file file_obj = open(full_path, 'rb') # Handle Range request (for seeking) if range_match: start = int(range_match.group(1)) end = range_match.group(2) end = int(end) if end else file_size - 1 # Validate range if start >= file_size or end >= file_size or start > end: file_obj.close() response = HttpResponse(status=416) # Range Not Satisfiable response['Content-Range'] = f'bytes */{file_size}' return response # Calculate content length for this range length = end - start + 1 # Create streaming response with partial content response = StreamingHttpResponse( range_file_iterator(file_obj, offset=start, length=length), status=206, # Partial Content content_type=content_type, ) response['Content-Length'] = str(length) response['Content-Range'] = f'bytes {start}-{end}/{file_size}' response['Accept-Ranges'] = 'bytes' else: # Serve entire file response = StreamingHttpResponse( FileWrapper(file_obj), content_type=content_type, ) response['Content-Length'] = str(file_size) response['Accept-Ranges'] = 'bytes' # Add caching headers for better performance response['Cache-Control'] = 'public, max-age=3600' response['Last-Modified'] = http_date(full_path.stat().st_mtime) # Add Content-Disposition for download fallback response['Content-Disposition'] = f'inline; filename="{full_path.name}"' return response