diff --git a/apps/course/views/webhook.py b/apps/course/views/webhook.py index 3b72852..366d5c4 100644 --- a/apps/course/views/webhook.py +++ b/apps/course/views/webhook.py @@ -1,618 +1,742 @@ -import json -import hmac -import hashlib -import logging -from typing import Dict, Any +# 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 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 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 +# from apps.course.models import CourseLiveSession, LiveSessionUser, Course, Participant +# from apps.account.models import User +# from utils.exceptions import AppAPIException -logger = logging.getLogger(__name__) +# 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] +# @method_decorator(csrf_exempt, name='dispatch') +# class PlugNMeetWebhookAPIView(APIView): +# """ +# Webhook endpoint to receive events from PlugNMeet server. - @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:]}") +# 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] - # 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.") +# @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 +# ) - 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}") +# # 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 - is_valid = hmac.compare_digest(hash_token, expected_signature) - - if not is_valid: - logger.error("[PlugNMeet Webhook] Signature MISMATCH") - - 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:]}") - 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') +# # Calculate expected signature +# body = request.body - 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 - } +# # 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.") - 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 - } +# 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}") - 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') +# is_valid = hmac.compare_digest(hash_token, expected_signature) - 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'} +# if not is_valid: +# logger.error("[PlugNMeet Webhook] Signature MISMATCH") - 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 - } +# return is_valid - 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() +# 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) +# # 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')} +# 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) +# # 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')} +# 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'} +# 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}" +# # 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 +# # 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) +# 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") +# # 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' +# # 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() +# # 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, - ) +# # 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}") +# # 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)}") +# # 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 - } +# 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)}'} +# 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. +# 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 +# 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 +# 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 +# ] - # 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 +# ) - 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 - 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 - # 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() - # 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) - 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) - # 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.") - return True + # 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}") - 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 + # 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] 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 + 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 \ No newline at end of file