# import json # import hmac # import hashlib # import logging # from typing import Dict, Any # from django.conf import settings # from django.core.exceptions import ImproperlyConfigured # from django.utils import timezone # from django.utils.decorators import method_decorator # from django.views.decorators.csrf import csrf_exempt # from rest_framework import status # from rest_framework.views import APIView # from rest_framework.response import Response # from rest_framework.permissions import AllowAny # from drf_yasg.utils import swagger_auto_schema # from drf_yasg import openapi # from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant # from apps.account.models import User # from utils.exceptions import AppAPIException # logger = logging.getLogger(__name__) # @method_decorator(csrf_exempt, name='dispatch') # class PlugNMeetWebhookAPIView(APIView): # """ # Webhook endpoint to receive events from PlugNMeet server. # Events handled: # - room_finished: Close the live session # - participant_joined: Create LiveSessionUser entry # - participant_left: Mark LiveSessionUser as offline/exited # - end_recording: (Future implementation) # """ # permission_classes = [AllowAny] # @swagger_auto_schema( # operation_description="Handle webhook events from PlugNMeet server for live sessions", # tags=["Imam-Javad - Course"], # responses={ # 200: openapi.Response(description="Webhook processed successfully"), # 400: openapi.Response(description="Invalid webhook signature or data"), # 500: openapi.Response(description="Internal server error") # } # ) # def post(self, request, *args, **kwargs): # logger.info(f"[PlugNMeet Webhook] Received webhook request") # # Verify webhook signature # if not self._verify_webhook_signature(request): # logger.warning(f"[PlugNMeet Webhook] Invalid signature") # raise AppAPIException( # {'message': 'Invalid webhook signature'}, # status_code=status.HTTP_403_FORBIDDEN # ) # try: # payload = json.loads(request.body.decode('utf-8')) # except (json.JSONDecodeError, UnicodeDecodeError) as e: # logger.error(f"[PlugNMeet Webhook] Invalid JSON payload - error={str(e)}") # raise AppAPIException( # {'message': 'Invalid JSON payload'}, # status_code=status.HTTP_400_BAD_REQUEST # ) # event = payload.get('event') # if not event: # logger.warning(f"[PlugNMeet Webhook] Missing event field") # raise AppAPIException( # {'message': 'Missing event field'}, # status_code=status.HTTP_400_BAD_REQUEST # ) # logger.info(f"[PlugNMeet Webhook] Processing event={event}") # # Route to appropriate handler # handler_map = { # 'room_finished': self._handle_room_finished, # 'participant_joined': self._handle_participant_joined, # 'participant_left': self._handle_participant_left, # 'end_recording': self._handle_end_recording, # } # handler = handler_map.get(event) # if not handler: # logger.info(f"[PlugNMeet Webhook] Unhandled event={event}, ignoring") # return Response({'status': 'ok', 'message': f'Event {event} ignored'}) # try: # result = handler(payload) # logger.info(f"[PlugNMeet Webhook] Event processed successfully - event={event}") # return Response({'status': 'ok', **result}) # except Exception as e: # logger.error(f"[PlugNMeet Webhook] Error processing event={event} - error={str(e)}", exc_info=True) # return Response( # {'status': 'error', 'message': str(e)}, # status=status.HTTP_500_INTERNAL_SERVER_ERROR # ) # # def _verify_webhook_signature(self, request) -> bool: # # """ # # Verify webhook signature using SHA256 HMAC. # # Expects Hash-Token header with SHA256 signature of request body. # # """ # # hash_token = request.headers.get('Hash-Token') # # if not hash_token: # # logger.warning(f"[PlugNMeet Webhook] Missing Hash-Token header") # # return False # # # Get API secret from settings # # api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) # # if not api_secret: # # logger.error(f"[PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured") # # raise ImproperlyConfigured("PLUGNMEET_API_SECRET is not configured") # # # Calculate expected signature # # body = request.body # # expected_signature = hmac.new( # # api_secret.encode('utf-8'), # # body, # # hashlib.sha256 # # ).hexdigest() # # # Compare signatures (constant time comparison) # # is_valid = hmac.compare_digest(hash_token, expected_signature) # # if not is_valid: # # logger.warning(f"[PlugNMeet Webhook] Signature mismatch - expected={expected_signature[:10]}... got={hash_token[:10]}...") # # return is_valid # def _verify_webhook_signature(self, request) -> bool: # """ # DEBUG VERSION: Prints details to find the mismatch. # """ # hash_token = request.headers.get('Hash-Token') # if not hash_token: # logger.error("[PlugNMeet Webhook] MISSING Hash-Token header") # return False # # Get API secret from settings # api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) # if not api_secret: # logger.error("[PlugNMeet Webhook] PLUGNMEET_API_SECRET is missing in settings.py") # raise ImproperlyConfigured("PLUGNMEET_API_SECRET is not configured") # # DEBUG: Print the first/last few chars of the secret to ensure it's loaded correctly # # DO NOT log the whole secret in production! # logger.info(f"[DEBUG] Secret in Django: {api_secret[:4]}...{api_secret[-4:]}") # # Calculate expected signature # body = request.body # # DEBUG: Check if body is empty (common middleware issue) # if len(body) == 0: # logger.error("[DEBUG] Request Body is EMPTY! Middleware might have consumed it.") # expected_signature = hmac.new( # api_secret.encode('utf-8'), # body, # hashlib.sha256 # ).hexdigest() # # DEBUG: Compare them visually in logs # logger.info(f"[DEBUG] Received Token: {hash_token}") # logger.info(f"[DEBUG] Calculated: {expected_signature}") # is_valid = hmac.compare_digest(hash_token, expected_signature) # if not is_valid: # logger.error("[PlugNMeet Webhook] Signature MISMATCH") # return is_valid # def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]: # """ # Handle room_finished event: Close the live session and all user sessions. # Payload structure: # { # "event": "room_finished", # "room": { # "sid": "room-123456", # "identity": "course-slug-20240101120000", # "name": "کلاس جبر", # "duration": 3600 # } # } # """ # room_data = payload.get('room', {}) # room_id = room_data.get('identity') # if not room_id: # logger.warning(f"[PlugNMeet Webhook] room_finished: Missing room identity") # return {'message': 'Missing room identity'} # logger.info(f"[PlugNMeet Webhook] room_finished - room_id={room_id}") # try: # session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) # except CourseLiveSession.DoesNotExist: # logger.warning(f"[PlugNMeet Webhook] room_finished: Session not found or already ended - room_id={room_id}") # return {'message': 'Session not found or already ended'} # # Close the session # now = timezone.now() # session.ended_at = now # session.save(update_fields=['ended_at', 'updated_at']) # logger.info(f"[PlugNMeet Webhook] Session closed - session_id={session.id} room_id={room_id}") # # Close all active user sessions # updated_count = LiveSessionUser.objects.filter( # session=session, # is_online=True, # exited_at__isnull=True # ).update( # is_online=False, # exited_at=now, # updated_at=now # ) # logger.info(f"[PlugNMeet Webhook] User sessions closed - session_id={session.id} count={updated_count}") # return { # 'message': 'Room finished', # 'session_id': session.id, # 'users_disconnected': updated_count # } # def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]: # """ # Handle participant_joined event: Create or update LiveSessionUser entry. # Payload structure: # { # "event": "participant_joined", # "room": { # "sid": "room-123456", # "identity": "course-slug-20240101120000" # }, # "participant": { # "sid": "participant-user-27", # "identity": "27", # "name": "دانشجو نمونه", # "metadata": "{\"is_admin\": false}", # "joinedAt": 1697497300 # } # } # """ # room_data = payload.get('room', {}) # participant_data = payload.get('participant', {}) # room_id = room_data.get('identity') # user_identity = participant_data.get('identity') # joined_at_timestamp = participant_data.get('joinedAt') # if not room_id or not user_identity: # logger.warning(f"[PlugNMeet Webhook] participant_joined: Missing room_id or user_identity") # return {'message': 'Missing required fields'} # logger.info(f"[PlugNMeet Webhook] participant_joined - room_id={room_id} user_identity={user_identity}") # # Get session # try: # session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) # except CourseLiveSession.DoesNotExist: # logger.warning(f"[PlugNMeet Webhook] participant_joined: Active session not found - room_id={room_id}") # return {'message': 'Active session not found'} # # Get user # try: # user = User.objects.get(id=int(user_identity)) # except (User.DoesNotExist, ValueError): # logger.warning(f"[PlugNMeet Webhook] participant_joined: User not found - user_identity={user_identity}") # return {'message': 'User not found'} # # Determine user role # is_admin = user.can_manage_course(session.course) # role = 'moderator' if is_admin else 'participant' # # Parse joined_at timestamp # if joined_at_timestamp: # entered_at = timezone.datetime.fromtimestamp(joined_at_timestamp, tz=timezone.utc) # else: # entered_at = timezone.now() # # Check if user has an existing session entry that was marked as offline # # If so, reactivate it instead of creating a new one # existing_session = LiveSessionUser.objects.filter( # session=session, # user=user, # is_online=False # ).order_by('-entered_at').first() # if existing_session: # # Reactivate existing session # existing_session.is_online = True # existing_session.exited_at = None # existing_session.save(update_fields=['is_online', 'exited_at', 'updated_at']) # logger.info(f"[PlugNMeet Webhook] User rejoined (reactivated) - session_user_id={existing_session.id} user_id={user.id}") # return { # 'message': 'Participant rejoined', # 'session_user_id': existing_session.id, # 'created': False # } # # Create new LiveSessionUser entry # try: # session_user = LiveSessionUser.objects.create( # session=session, # user=user, # role=role, # entered_at=entered_at, # is_online=True, # ) # logger.info(f"[PlugNMeet Webhook] User joined - session_user_id={session_user.id} user_id={user.id} role={role}") # except Exception as e: # logger.error(f"[PlugNMeet Webhook] Failed to create session user - error={str(e)}") # return {'message': f'Failed to create session user: {str(e)}'} # return { # 'message': 'Participant joined', # 'session_user_id': session_user.id, # 'created': created # } # def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]: # """ # Handle participant_left event: Mark LiveSessionUser as offline/exited. # Payload structure: # { # "event": "participant_left", # "room": { # "sid": "room-123456", # "identity": "course-slug-20240101120000" # }, # "participant": { # "sid": "participant-user-27", # "identity": "27", # "state": "DISCONNECTED", # "joinedAt": 1697497300, # "duration": 1800 # } # } # """ # room_data = payload.get('room', {}) # participant_data = payload.get('participant', {}) # room_id = room_data.get('identity') # user_identity = participant_data.get('identity') # if not room_id or not user_identity: # logger.warning(f"[PlugNMeet Webhook] participant_left: Missing room_id or user_identity") # return {'message': 'Missing required fields'} # logger.info(f"[PlugNMeet Webhook] participant_left - room_id={room_id} user_identity={user_identity}") # # Get session # try: # session = CourseLiveSession.objects.get(room_id=room_id) # except CourseLiveSession.DoesNotExist: # logger.warning(f"[PlugNMeet Webhook] participant_left: Session not found - room_id={room_id}") # return {'message': 'Session not found'} # # Get user # try: # user = User.objects.get(id=int(user_identity)) # except (User.DoesNotExist, ValueError): # logger.warning(f"[PlugNMeet Webhook] participant_left: User not found - user_identity={user_identity}") # return {'message': 'User not found'} # # Update LiveSessionUser # now = timezone.now() # updated_count = LiveSessionUser.objects.filter( # session=session, # user=user, # is_online=True, # exited_at__isnull=True # ).update( # is_online=False, # exited_at=now, # updated_at=now # ) # if updated_count > 0: # logger.info(f"[PlugNMeet Webhook] User left - session_id={session.id} user_id={user.id}") # else: # logger.warning(f"[PlugNMeet Webhook] participant_left: No active session found for user - session_id={session.id} user_id={user.id}") # return { # 'message': 'Participant left', # 'updated': updated_count > 0 # } # def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]: # """ # Handle end_recording event: Download and save recording file. # Payload structure: # { # "event": "end_recording", # "room": { # "sid": "room-123456", # "identity": "course-slug-20240101120000" # }, # "recording_info": { # "recordingId": "rec-123456", # "roomId": "course-slug-20240101120000", # "recordingType": "COMPOSITE", # "fileName": "algebra-1402-20231016.mp4", # "duration": 3600, # "status": "FINISHED" # } # } # """ # from django.core.files.base import ContentFile # from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError # from apps.course.models import LiveSessionRecording # import os # import tempfile # import subprocess # room_data = payload.get('room', {}) # recording_info = payload.get('recording_info', {}) # room_id = room_data.get('identity') # recording_id = recording_info.get('recordingId') # file_name = recording_info.get('fileName', 'recording.mp4') # duration = recording_info.get('duration', 0) # if not room_id or not recording_id: # logger.warning(f"[PlugNMeet Webhook] end_recording: Missing room_id or recording_id") # return {'message': 'Missing required fields'} # logger.info(f"[PlugNMeet Webhook] end_recording - room_id={room_id} recording_id={recording_id}") # # Get session # try: # session = CourseLiveSession.objects.get(room_id=room_id) # except CourseLiveSession.DoesNotExist: # logger.warning(f"[PlugNMeet Webhook] end_recording: Session not found - room_id={room_id}") # return {'message': 'Session not found'} # try: # client = PlugNMeetClient() # # Step 1: Get recording info # logger.info(f"[PlugNMeet Webhook] Fetching recording info - recording_id={recording_id}") # recording_data = client.get_recording_info(recording_id) # if not recording_data.get('status'): # logger.error(f"[PlugNMeet Webhook] Failed to get recording info - recording_id={recording_id}") # return {'message': 'Failed to get recording info', 'error': recording_data.get('msg')} # # Step 2: Get download token # logger.info(f"[PlugNMeet Webhook] Getting download token - recording_id={recording_id}") # token_response = client.get_recording_download_token(recording_id) # if not token_response.get('status'): # logger.error(f"[PlugNMeet Webhook] Failed to get download token - recording_id={recording_id}") # return {'message': 'Failed to get download token', 'error': token_response.get('msg')} # download_token = token_response.get('token') # if not download_token: # logger.error(f"[PlugNMeet Webhook] No download token in response - recording_id={recording_id}") # return {'message': 'No download token received'} # # Step 3: Download file # download_path = f"/download/recording/{download_token}" # # Create temporary file # with tempfile.NamedTemporaryFile(delete=False, suffix='.mp4') as tmp_file: # tmp_file_path = tmp_file.name # try: # logger.info(f"[PlugNMeet Webhook] Downloading recording file - recording_id={recording_id}") # client.download_file(download_path, tmp_file_path) # # Get file size # file_size = os.path.getsize(tmp_file_path) # logger.info(f"[PlugNMeet Webhook] File downloaded - size={file_size} bytes") # # Determine recording type (video or audio) # is_video = file_name.lower().endswith(('.mp4', '.webm', '.mkv')) # recording_type = 'video' if is_video else 'voice' # # Read file content # with open(tmp_file_path, 'rb') as f: # file_content = f.read() # # Create LiveSessionRecording entry # recording = LiveSessionRecording.objects.create( # session=session, # title=f"{session.subject} - Recording", # file_time=timezone.timedelta(seconds=duration) if duration > 0 else None, # recording_type=recording_type, # ) # # Save file to Django FileField # recording.file.save(file_name, ContentFile(file_content), save=True) # logger.info(f"[PlugNMeet Webhook] Recording saved - recording_id={recording.id} file={file_name}") # # Generate thumbnail for video recordings (if ffmpeg is available) # thumbnail_generated = False # if is_video and file_size > 0: # try: # thumbnail_generated = self._generate_video_thumbnail(tmp_file_path, recording) # if thumbnail_generated: # logger.info(f"[PlugNMeet Webhook] Thumbnail generated - recording_id={recording.id}") # except Exception as e: # logger.warning(f"[PlugNMeet Webhook] Thumbnail generation skipped - error={str(e)}") # return { # 'message': 'Recording downloaded and saved successfully', # 'recording_id': recording.id, # 'file_name': file_name, # 'file_size': file_size, # 'thumbnail_generated': thumbnail_generated # } # finally: # # Clean up temporary file # if os.path.exists(tmp_file_path): # os.unlink(tmp_file_path) # logger.debug(f"[PlugNMeet Webhook] Temporary file deleted - path={tmp_file_path}") # except PlugNMeetError as e: # logger.error(f"[PlugNMeet Webhook] PlugNMeet API error - recording_id={recording_id} error={str(e)}") # return {'message': f'PlugNMeet API error: {str(e)}'} # except Exception as e: # logger.error(f"[PlugNMeet Webhook] Unexpected error - recording_id={recording_id} error={str(e)}", exc_info=True) # return {'message': f'Unexpected error: {str(e)}'} # def _generate_video_thumbnail(self, video_path: str, recording: 'LiveSessionRecording') -> bool: # """ # Generate thumbnail from video file using ffmpeg. # Args: # video_path: Path to the video file # recording: LiveSessionRecording instance # Returns: # True if thumbnail generated successfully, False otherwise # """ # from django.core.files.base import ContentFile # import subprocess # import tempfile # import os # try: # # Create temporary file for thumbnail # with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb: # thumbnail_path = tmp_thumb.name # # Extract frame at 1 second using ffmpeg # # -ss 1: seek to 1 second # # -i: input file # # -frames:v 1: extract 1 frame # # -q:v 2: quality (2 is high quality) # cmd = [ # 'ffmpeg', # '-ss', '1', # '-i', video_path, # '-frames:v', '1', # '-q:v', '2', # '-vf', 'scale=640:-1', # scale to width 640, maintain aspect ratio # '-y', # overwrite output file # thumbnail_path # ] # result = subprocess.run( # cmd, # stdout=subprocess.PIPE, # stderr=subprocess.PIPE, # timeout=30 # ) # if result.returncode != 0: # logger.warning(f"[PlugNMeet Webhook] ffmpeg failed - return_code={result.returncode}") # return False # # Check if thumbnail was created # if not os.path.exists(thumbnail_path) or os.path.getsize(thumbnail_path) == 0: # logger.warning(f"[PlugNMeet Webhook] Thumbnail file not created or empty") # return False # # Save thumbnail to recording # with open(thumbnail_path, 'rb') as f: # thumbnail_content = f.read() # thumbnail_filename = f"thumb_{recording.id}.jpg" # recording.thumbnail.save(thumbnail_filename, ContentFile(thumbnail_content), save=True) # # Clean up temporary file # os.unlink(thumbnail_path) # return True # except subprocess.TimeoutExpired: # logger.error(f"[PlugNMeet Webhook] ffmpeg timeout during thumbnail generation") # return False # except FileNotFoundError: # logger.error(f"[PlugNMeet Webhook] ffmpeg not found - please install ffmpeg") # return False # except Exception as e: # logger.error(f"[PlugNMeet Webhook] Thumbnail generation error - error={str(e)}", exc_info=True) # return False # finally: # # Clean up temporary file if it still exists # if 'thumbnail_path' in locals() and os.path.exists(thumbnail_path): # try: # os.unlink(thumbnail_path) # except: # pass from rest_framework.parsers import BaseParser import json import hmac import hashlib import logging from django.conf import settings from django.utils.decorators import method_decorator from django.views.decorators.csrf import csrf_exempt from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny from rest_framework import status from utils.exceptions import AppAPIException logger = logging.getLogger(__name__) # 1. Custom Parser to preserve the raw body for HMAC verification class RawJSONParser(BaseParser): media_type = 'application/json' def parse(self, stream, media_type=None, parser_context=None): # We return the raw bytes so we can verify the signature first return stream.read() @method_decorator(csrf_exempt, name='dispatch') class PlugNMeetWebhookAPIView(APIView): # 2. CRITICAL: Disable Global Authentication logic for this specific endpoint authentication_classes = [] permission_classes = [AllowAny] # 3. Use the raw parser to prevent DRF from consuming the stream prematurely parser_classes = [RawJSONParser] def post(self, request, *args, **kwargs): logger.info("⚡ [PlugNMeet Webhook] Request Hit the View!") # --- 1. ROBUST HEADER EXTRACTION --- # Try standard way hash_token = request.headers.get('Hash-Token') # Try Django internal way (Fall back to META) # Django converts "Hash-Token" -> "HTTP_HASH_TOKEN" if not hash_token: hash_token = request.META.get('HTTP_HASH_TOKEN') # --- 2. DEBUG IF MISSING --- if not hash_token: logger.error("❌ [PlugNMeet Webhook] MISSING Hash-Token.") # Print ALL headers to see if it's there under a different name # This is critical for debugging Nginx stripping issues logger.info("--- DUMPING ALL HEADERS ---") for key, value in request.headers.items(): logger.info(f"Header: {key} = {value}") # Also dump META keys starting with HTTP meta_headers = {k: v for k, v in request.META.items() if k.startswith('HTTP_')} logger.info(f"META Headers: {meta_headers}") logger.info("-----------------------------") return Response({'message': 'Missing Hash-Token header'}, status=403) # # --- SIGNATURE VERIFICATION --- # hash_token = request.headers.get('Hash-Token') # if not hash_token: # logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token") # return Response({'message': 'Missing Hash-Token'}, status=403) api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) # We use request.data because our RawJSONParser returned the bytes into it body_bytes = request.data # Debug Logs (Remove in production) logger.info(f"🔍 [DEBUG] Secret: {api_secret[:5]}...") logger.info(f"🔍 [DEBUG] Body Bytes Len: {len(body_bytes)}") expected_signature = hmac.new( api_secret.encode('utf-8'), body_bytes, hashlib.sha256 ).hexdigest() if not hmac.compare_digest(hash_token, expected_signature): logger.error(f"❌ [PlugNMeet Webhook] Mismatch! \nExpected: {expected_signature}\nReceived: {hash_token}") return Response({'message': 'Invalid webhook signature'}, status=403) # --- PARSING --- try: # Now we manually parse the JSON since we used a RawParser payload = json.loads(body_bytes.decode('utf-8')) except Exception as e: logger.error(f"❌ [PlugNMeet Webhook] JSON Error: {e}") return Response({'message': 'Invalid JSON'}, status=400) # --- EVENT ROUTING --- event = payload.get('event') logger.info(f"✅ [PlugNMeet Webhook] Processing Event: {event}") handler_map = { 'room_finished': self._handle_room_finished, 'participant_joined': self._handle_participant_joined, 'participant_left': self._handle_participant_left, 'end_recording': self._handle_end_recording, } handler = handler_map.get(event) if not handler: return Response({'status': 'ok', 'message': f'Event {event} ignored'}) try: result = handler(payload) return Response({'status': 'ok', **result}) except Exception as e: logger.error(f"❌ [PlugNMeet Webhook] Logic Error: {e}", exc_info=True) return Response({'status': 'error', 'message': str(e)}, status=500) # ... (Keep your existing _handle methods exactly as they were) ... # Copy _handle_room_finished, _handle_participant_joined, etc. here def _handle_room_finished(self, payload): # ... your existing code ... return {'status': 'ok'} # Placeholder