From 3367762e6a5b1be93decc471939a9350d9919844 Mon Sep 17 00:00:00 2001 From: mohsentaba Date: Sat, 11 Apr 2026 10:57:34 +0330 Subject: [PATCH] update webhooks for handling online class recordings --- apps/course/views/webhook.py | 907 +++++++++-------------------------- 1 file changed, 222 insertions(+), 685 deletions(-) diff --git a/apps/course/views/webhook.py b/apps/course/views/webhook.py index 366d5c4..de20185 100644 --- a/apps/course/views/webhook.py +++ b/apps/course/views/webhook.py @@ -1,721 +1,92 @@ -# 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 +import os +import tempfile +import subprocess +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 django.core.files.base import ContentFile + +from rest_framework import status from rest_framework.views import APIView from rest_framework.response import Response from rest_framework.permissions import AllowAny -from rest_framework import status +from rest_framework.parsers import BaseParser +from drf_yasg.utils import swagger_auto_schema +from drf_yasg import openapi + +from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant, LiveSessionRecording +from apps.account.models import User +from apps.course.services.plugnmeet import PlugNMeetClient, PlugNMeetError from utils.exceptions import AppAPIException logger = logging.getLogger(__name__) -# 1. Custom Parser to preserve the raw body for HMAC verification class RawJSONParser(BaseParser): + """ + Parser that preserves the raw body bytes for HMAC signature verification. + """ 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 + """ + Webhook endpoint to receive and process events from the PlugNMeet server. + + Handles: + - room_finished: Closes the live session record. + - participant_joined: Tracks student entry. + - participant_left: Tracks student exit. + - end_recording: Downloads and saves session recordings. + """ authentication_classes = [] permission_classes = [AllowAny] - - # 3. Use the raw parser to prevent DRF from consuming the stream prematurely parser_classes = [RawJSONParser] + @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"), + 403: openapi.Response(description="Invalid signature"), + 400: openapi.Response(description="Invalid payload") + } + ) def post(self, request, *args, **kwargs): + logger.info("⚡ [PlugNMeet Webhook] Request received") - logger.info("⚡ [PlugNMeet Webhook] Request Hit the View!") - - # --- 1. ROBUST HEADER EXTRACTION --- - # Try standard way - hash_token = request.headers.get('Hash-Token') + # 1. Extract Signature + hash_token = request.headers.get('Hash-Token') or request.META.get('HTTP_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("-----------------------------") - + logger.error("❌ [PlugNMeet Webhook] Missing Hash-Token header") 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}") + # 2. Verify Signature + if not self._verify_webhook_signature(request, hash_token): return Response({'message': 'Invalid webhook signature'}, status=403) - # --- PARSING --- + # 3. Parse Payload try: - # Now we manually parse the JSON since we used a RawParser + body_bytes = request.data # RawJSONParser puts bytes here payload = json.loads(body_bytes.decode('utf-8')) except Exception as e: - logger.error(f"❌ [PlugNMeet Webhook] JSON Error: {e}") + logger.error(f"❌ [PlugNMeet Webhook] Parsing Error: {e}") return Response({'message': 'Invalid JSON'}, status=400) - # --- EVENT ROUTING --- event = payload.get('event') - logger.info(f"✅ [PlugNMeet Webhook] Processing Event: {event}") + logger.info(f"✅ [PlugNMeet Webhook] Event: {event}") + # 4. Route Event handler_map = { 'room_finished': self._handle_room_finished, 'participant_joined': self._handle_participant_joined, @@ -725,18 +96,184 @@ class PlugNMeetWebhookAPIView(APIView): handler = handler_map.get(event) if not handler: + logger.info(f"ℹ️ [PlugNMeet Webhook] Event {event} ignored") 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) + logger.error(f"❌ [PlugNMeet Webhook] Error in {event}: {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 \ No newline at end of file + def _verify_webhook_signature(self, request, hash_token: str) -> bool: + api_secret = getattr(settings, 'PLUGNMEET_API_SECRET', None) + if not api_secret: + logger.error("❌ [PlugNMeet Webhook] PLUGNMEET_API_SECRET not configured") + return False + + body_bytes = request.data + 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] Signature mismatch! \nReceived: {hash_token[:10]}...\nExpected: {expected_signature[:10]}...") + return False + return True + + def _handle_room_finished(self, payload: Dict[str, Any]) -> Dict[str, Any]: + room_data = payload.get('room', {}) + room_id = room_data.get('identity') + + if not room_id: + return {'message': 'Missing room identity'} + + try: + session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) + now = timezone.now() + session.ended_at = now + session.save(update_fields=['ended_at', 'updated_at']) + + # Close 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] Session {session.id} ended. Users disconnected: {updated_count}") + return {'session_id': session.id, 'closed_users': updated_count} + except CourseLiveSession.DoesNotExist: + return {'message': 'No active session found for this room'} + + def _handle_participant_joined(self, payload: Dict[str, Any]) -> Dict[str, Any]: + room_data = payload.get('room', {}) + participant_data = payload.get('participant', {}) + room_id = room_data.get('identity') + user_id = participant_data.get('identity') + + if not room_id or not user_id: + return {'message': 'Missing required metadata'} + + try: + session = CourseLiveSession.objects.get(room_id=room_id, ended_at__isnull=True) + user = User.objects.get(id=int(user_id)) + + role = 'moderator' if user.can_manage_course(session.course) else 'participant' + + session_user, created = LiveSessionUser.objects.update_or_create( + session=session, + user=user, + defaults={ + 'role': role, + 'is_online': True, + 'exited_at': None, + 'entered_at': timezone.now() + } + ) + logger.info(f"👤 [PlugNMeet Webhook] User {user.id} joined session {session.id}") + return {'session_user_id': session_user.id, 'created': created} + except Exception as e: + return {'error': str(e)} + + def _handle_participant_left(self, payload: Dict[str, Any]) -> Dict[str, Any]: + room_data = payload.get('room', {}) + participant_data = payload.get('participant', {}) + room_id = room_data.get('identity') + user_id = participant_data.get('identity') + + try: + session = CourseLiveSession.objects.get(room_id=room_id) + user = User.objects.get(id=int(user_id)) + + updated = LiveSessionUser.objects.filter( + session=session, user=user, is_online=True + ).update(is_online=False, exited_at=timezone.now(), updated_at=timezone.now()) + + logger.info(f"🚪 [PlugNMeet Webhook] User {user.id} left session {session.id}") + return {'updated': bool(updated)} + except Exception as e: + return {'error': str(e)} + + def _handle_end_recording(self, payload: Dict[str, Any]) -> Dict[str, Any]: + 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: + return {'message': 'Missing recording metadata'} + + try: + session = CourseLiveSession.objects.get(room_id=room_id) + client = PlugNMeetClient() + + # 1. Fetch download token + token_response = client.get_recording_download_token(recording_id) + if not token_response.get('status'): + return {'error': 'Failed to get download token'} + + download_token = token_response.get('token') + download_path = f"/download/recording/{download_token}" + + # 2. Download to 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 {recording_id}...") + client.download_file(download_path, tmp_file_path) + + # 3. Save to Database + with open(tmp_file_path, 'rb') as f: + content = f.read() + + recording = LiveSessionRecording.objects.create( + session=session, + title=f"{session.subject} - Recording", + file_time=timezone.timedelta(seconds=duration) if duration > 0 else None, + recording_type='video' if file_name.lower().endswith('.mp4') else 'voice' + ) + recording.file.save(file_name, ContentFile(content), save=True) + + # 4. Generate thumbnail (Optional) + self._generate_video_thumbnail(tmp_file_path, recording) + + logger.info(f"💾 [PlugNMeet Webhook] Recording saved successfully: {recording.id}") + return {'recording_id': recording.id, 'file': file_name} + + finally: + if os.path.exists(tmp_file_path): + os.unlink(tmp_file_path) + + except Exception as e: + logger.error(f"❌ [PlugNMeet Webhook] End Recording Error: {e}", exc_info=True) + return {'error': str(e)} + + def _generate_video_thumbnail(self, video_path: str, recording: LiveSessionRecording) -> bool: + try: + with tempfile.NamedTemporaryFile(delete=False, suffix='.jpg') as tmp_thumb: + thumbnail_path = tmp_thumb.name + + cmd = [ + 'ffmpeg', '-ss', '1', '-i', video_path, '-frames:v', '1', + '-q:v', '2', '-vf', 'scale=640:-1', '-y', thumbnail_path + ] + + result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=30) + + if result.returncode == 0 and os.path.exists(thumbnail_path) and os.path.getsize(thumbnail_path) > 0: + with open(thumbnail_path, 'rb') as f: + recording.thumbnail.save(f"thumb_{recording.id}.jpg", ContentFile(f.read()), save=True) + os.unlink(thumbnail_path) + return True + return False + except Exception as e: + logger.warning(f"⚠️ [PlugNMeet Webhook] Thumbnail failed: {e}") + return False \ No newline at end of file