From a7fb2df2e76b668d506af0128e868639ec59737b Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 11:47:56 +0330 Subject: [PATCH 01/16] Remove AllowAny permission from CourseOnlineClassTokenValidateAPIView for enhanced security --- apps/course/views/course.py | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 7f8cff2..7848f38 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -415,7 +415,6 @@ class CourseOnlineClassTokenAPIView(GenericAPIView): class CourseOnlineClassTokenValidateAPIView(GenericAPIView): - permission_classes = [AllowAny] serializer_class = OnlineClassTokenVerifySerializer def get_permissions(self): From 49283428c5ffe2131fe728f85c0e782502b34b1f Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 12:08:34 +0330 Subject: [PATCH 02/16] Enhance logging in CourseOnlineClassTokenValidateAPIView and OnlineClassTokenManager for better traceability - Added detailed logging for GET and POST requests in CourseOnlineClassTokenValidateAPIView to track user actions and course validation. - Improved logging in OnlineClassTokenManager for token generation, storage, retrieval, and deletion processes, including error handling for Redis interactions. --- apps/course/views/course.py | 22 ++++++++++++------ utils/redis.py | 46 +++++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 7848f38..84d0a3e 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -452,11 +452,15 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): } ) def get(self, request, slug, *args, **kwargs): + logger.info(f"[Online Validate GET] Request received - slug={slug} user_id={request.user.id if request.user.is_authenticated else 'anonymous'}") + detail_view = CourseDetailAPIView() queryset = detail_view.get_queryset() course = get_object_or_404(queryset, slug=slug) user = request.user + logger.info(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}") + # DEPRECATED: Polling approach replaced by webhook integration # Room status is now updated automatically via PlugNMeet webhooks # self._sync_room_status_with_plugnmeet(course) @@ -469,6 +473,8 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): user=user, ) + logger.info(f"[Online Validate GET] Success - user_id={user.id} course={slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}") + return Response({ 'course': course_data, 'user': user_data, @@ -498,41 +504,43 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): } ) def post(self, request, *args, **kwargs): - logger.info(f"[Online Validate] Request received") + logger.info(f"[Online Validate POST] Request received - has_token={'token' in request.data}") serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) token_value = serializer.validated_data['token'] + logger.info(f"[Online Validate POST] Token extracted - token={token_value[:16]}...") + manager = OnlineClassTokenManager() try: payload = manager.get_payload(token_value) - logger.info(f"[Online Validate] Token decoded successfully") + logger.info(f"[Online Validate POST] Token decoded successfully - payload={payload}") except Exception as e: - logger.error(f"[Online Validate] Token decode failed - error={str(e)}") + logger.error(f"[Online Validate POST] Token decode failed - error={str(e)} type={type(e).__name__}") raise course_id = payload.get('course_id') user_id = payload.get('user_id') if not course_id or not user_id: - logger.warning(f"[Online Validate] Invalid token payload - course_id={course_id} user_id={user_id}") + logger.warning(f"[Online Validate POST] Invalid token payload - course_id={course_id} user_id={user_id}") raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST) - logger.info(f"[Online Validate] Processing for user_id={user_id} course_id={course_id}") + logger.info(f"[Online Validate POST] Processing for user_id={user_id} course_id={course_id}") detail_view = CourseDetailAPIView() queryset = detail_view.get_queryset() course = get_object_or_404(queryset, pk=course_id) user = get_object_or_404(UserModel.objects.all(), pk=user_id) - logger.info(f"[Online Validate] Course found - slug={course.slug} is_online={course.is_online}") + logger.info(f"[Online Validate POST] Course found - slug={course.slug} is_online={course.is_online}") course_data = CourseDetailSerializer(course, context={'request': request}).data user_data = UserProfileSerializer(user, context={'request': request}).data metadata = self._build_metadata(course, payload, user=user) - logger.info(f"[Online Validate] Success - user_id={user_id} course={course.slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}") + logger.info(f"[Online Validate POST] Success - user_id={user_id} course={course.slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}") return Response({ 'course': course_data, diff --git a/utils/redis.py b/utils/redis.py index 545b330..b3f694d 100644 --- a/utils/redis.py +++ b/utils/redis.py @@ -2,6 +2,7 @@ import json import hashlib import random import secrets +import logging from datetime import datetime, timedelta from typing import Optional from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse @@ -13,6 +14,8 @@ from django.conf import settings from config.redis_config import RedisConfig from utils.exceptions import ServiceUnavailableException, NotFoundException +logger = logging.getLogger(__name__) + class RedisManager(RedisConfig): def __serialize(self, code, fullname, password): @@ -91,23 +94,52 @@ class OnlineClassTokenManager(RedisConfig): def generate_token(self, course_id: int, user_identifier: str) -> str: seed = f"{course_id}:{user_identifier}:{secrets.token_urlsafe(16)}" - return hashlib.sha256(seed.encode()).hexdigest() + token = hashlib.sha256(seed.encode()).hexdigest() + logger.info(f"[OnlineClassToken] Token generated - course_id={course_id} user={user_identifier} token={token[:16]}...") + return token def store_token(self, token: str, payload: dict, ttl: Optional[int] = None) -> None: data = { **payload, "generated_at": datetime.utcnow().isoformat() + "Z", } - self.redis.set(self._build_key(token), json.dumps(data), ex=ttl or self.ttl) + key = self._build_key(token) + ttl_value = ttl or self.ttl + logger.info(f"[OnlineClassToken] Storing token - key={key} ttl={ttl_value}s payload={payload}") + try: + self.redis.set(key, json.dumps(data), ex=ttl_value) + logger.info(f"[OnlineClassToken] Token stored successfully - key={key}") + except RedisError as e: + logger.error(f"[OnlineClassToken] Failed to store token - key={key} error={str(e)}") + raise def get_payload(self, token: str) -> dict: - stored = self.redis.get(self._build_key(token)) - if not stored: - raise NotFoundException("Token not found or has expired.") - return json.loads(stored) + key = self._build_key(token) + logger.info(f"[OnlineClassToken] Retrieving token - key={key} token={token[:16]}...") + try: + stored = self.redis.get(key) + if not stored: + logger.warning(f"[OnlineClassToken] Token not found or expired - key={key}") + raise NotFoundException("Token not found or has expired.") + payload = json.loads(stored) + logger.info(f"[OnlineClassToken] Token retrieved successfully - key={key} payload={payload}") + return payload + except RedisError as e: + logger.error(f"[OnlineClassToken] Redis error retrieving token - key={key} error={str(e)}") + raise + except json.JSONDecodeError as e: + logger.error(f"[OnlineClassToken] Invalid JSON in stored token - key={key} error={str(e)}") + raise NotFoundException("Invalid token data.") def delete_token(self, token: str) -> None: - self.redis.delete(self._build_key(token)) + key = self._build_key(token) + logger.info(f"[OnlineClassToken] Deleting token - key={key}") + try: + result = self.redis.delete(key) + logger.info(f"[OnlineClassToken] Token deleted - key={key} deleted={result}") + except RedisError as e: + logger.error(f"[OnlineClassToken] Failed to delete token - key={key} error={str(e)}") + raise @staticmethod def build_entry_url(token: str, base_url: Optional[str] = None) -> str: From 8f3d6f703ddc067cfe4678b44ad9e6fff75e6838 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 12:14:59 +0330 Subject: [PATCH 03/16] Refactor CourseOnlineClassTokenValidateAPIView to improve permission handling and logging - Updated permission classes to allow any user for GET requests, enhancing accessibility. - Added detailed logging for both GET and POST requests to track request data and processing steps. - Improved handling of user authentication status in log messages for better traceability. --- apps/course/views/course.py | 33 ++++++++++++++++++++++++++------- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 84d0a3e..59c57b7 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -415,13 +415,9 @@ class CourseOnlineClassTokenAPIView(GenericAPIView): class CourseOnlineClassTokenValidateAPIView(GenericAPIView): + permission_classes = [AllowAny] serializer_class = OnlineClassTokenVerifySerializer - def get_permissions(self): - if self.request.method == 'GET': - return [IsAuthenticated()] - return [AllowAny()] - @swagger_auto_schema( tags=['Imam-Javad - Course'], operation_description="Get course and user data for authenticated user.", @@ -452,6 +448,14 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): } ) def get(self, request, slug, *args, **kwargs): + print("=" * 80) + print(f"[Online Validate GET] REQUEST RECEIVED {request.data}") + print(f"[Online Validate GET] slug={slug}") + print(f"[Online Validate GET] user={request.user}") + print(f"[Online Validate GET] user.is_authenticated={request.user.is_authenticated}") + print(f"[Online Validate GET] user.id={request.user.id if request.user.is_authenticated else 'N/A'}") + print("=" * 80) + logger.info(f"[Online Validate GET] Request received - slug={slug} user_id={request.user.id if request.user.is_authenticated else 'anonymous'}") detail_view = CourseDetailAPIView() @@ -459,6 +463,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): course = get_object_or_404(queryset, slug=slug) user = request.user + print(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}") logger.info(f"[Online Validate GET] Course found - course_id={course.id} slug={slug} is_online={course.is_online}") # DEPRECATED: Polling approach replaced by webhook integration @@ -469,11 +474,12 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): user_data = UserProfileSerializer(user, context={'request': request}).data metadata = self._build_metadata( course, - {'user_id': user.id, 'extra': {}, 'generated_at': timezone.now().isoformat()}, + {'user_id': user.id if user.is_authenticated else None, 'extra': {}, 'generated_at': timezone.now().isoformat()}, user=user, ) - logger.info(f"[Online Validate GET] Success - user_id={user.id} course={slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}") + print(f"[Online Validate GET] Success - metadata={metadata}") + logger.info(f"[Online Validate GET] Success - user_id={user.id if user.is_authenticated else 'anonymous'} course={slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}") return Response({ 'course': course_data, @@ -504,29 +510,40 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): } ) def post(self, request, *args, **kwargs): + print("=" * 80) + print(f"[Online Validate POST] REQUEST RECEIVED") + print(f"[Online Validate POST] request.data={request.data}") + print(f"[Online Validate POST] has_token={'token' in request.data}") + print("=" * 80) + logger.info(f"[Online Validate POST] Request received - has_token={'token' in request.data}") serializer = self.get_serializer(data=request.data) serializer.is_valid(raise_exception=True) token_value = serializer.validated_data['token'] + print(f"[Online Validate POST] Token extracted - token={token_value[:16]}...") logger.info(f"[Online Validate POST] Token extracted - token={token_value[:16]}...") manager = OnlineClassTokenManager() try: payload = manager.get_payload(token_value) + print(f"[Online Validate POST] Token decoded successfully - payload={payload}") logger.info(f"[Online Validate POST] Token decoded successfully - payload={payload}") except Exception as e: + print(f"[Online Validate POST] Token decode FAILED - error={str(e)} type={type(e).__name__}") logger.error(f"[Online Validate POST] Token decode failed - error={str(e)} type={type(e).__name__}") raise course_id = payload.get('course_id') user_id = payload.get('user_id') if not course_id or not user_id: + print(f"[Online Validate POST] Invalid token payload - course_id={course_id} user_id={user_id}") logger.warning(f"[Online Validate POST] Invalid token payload - course_id={course_id} user_id={user_id}") raise AppAPIException({'message': 'Token payload is invalid.'}, status_code=status.HTTP_400_BAD_REQUEST) + print(f"[Online Validate POST] Processing for user_id={user_id} course_id={course_id}") logger.info(f"[Online Validate POST] Processing for user_id={user_id} course_id={course_id}") detail_view = CourseDetailAPIView() @@ -534,12 +551,14 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): course = get_object_or_404(queryset, pk=course_id) user = get_object_or_404(UserModel.objects.all(), pk=user_id) + print(f"[Online Validate POST] Course found - slug={course.slug} is_online={course.is_online}") logger.info(f"[Online Validate POST] Course found - slug={course.slug} is_online={course.is_online}") course_data = CourseDetailSerializer(course, context={'request': request}).data user_data = UserProfileSerializer(user, context={'request': request}).data metadata = self._build_metadata(course, payload, user=user) + print(f"[Online Validate POST] Success - metadata={metadata}") logger.info(f"[Online Validate POST] Success - user_id={user_id} course={course.slug} can_create={metadata.get('can_create_live_session')} can_join={metadata.get('can_join_live_session')}") return Response({ From e52ff6ca928e01ee784a6173e58571d45d08d2c9 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 12:34:08 +0330 Subject: [PATCH 04/16] Enhance UserProfileSerializer to handle location history retrieval - Added checks in get_saved_location method to ensure user is authenticated and has location_history attribute before attempting to access it. - Improved robustness of location retrieval logic to prevent errors for unauthenticated users. --- apps/account/serializers/user.py | 4 ++++ apps/course/views/course.py | 16 ++++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/apps/account/serializers/user.py b/apps/account/serializers/user.py index 1c0a605..90d07ce 100644 --- a/apps/account/serializers/user.py +++ b/apps/account/serializers/user.py @@ -26,6 +26,10 @@ class UserProfileSerializer(serializers.ModelSerializer): read_only_fields = ['email', 'info', 'skill', 'device_id', 'slug', 'saved_location'] def get_saved_location(self, obj): + # Check if user is authenticated and has location_history attribute + if not obj.is_authenticated or not hasattr(obj, 'location_history'): + return None + last_location = obj.location_history.order_by('-at_time').first() if last_location: return { diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 59c57b7..ece8362 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -454,6 +454,22 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): print(f"[Online Validate GET] user={request.user}") print(f"[Online Validate GET] user.is_authenticated={request.user.is_authenticated}") print(f"[Online Validate GET] user.id={request.user.id if request.user.is_authenticated else 'N/A'}") + print(f"[Online Validate GET] Authorization Header={request.META.get('HTTP_AUTHORIZATION', 'NOT FOUND')}") + print(f"[Online Validate GET] All Headers={dict((k, v) for k, v in request.META.items() if k.startswith('HTTP_'))}") + + # Debug: Check if token exists in database + auth_header = request.META.get('HTTP_AUTHORIZATION', '') + if auth_header.startswith('Token '): + token_key = auth_header.split(' ')[1] + try: + from rest_framework.authtoken.models import Token + token_obj = Token.objects.get(key=token_key) + print(f"[Online Validate GET] Token found in DB - user={token_obj.user} user_id={token_obj.user.id}") + except Token.DoesNotExist: + print(f"[Online Validate GET] Token NOT found in DB - token={token_key[:10]}...") + except Exception as e: + print(f"[Online Validate GET] Token check error - {str(e)}") + print("=" * 80) logger.info(f"[Online Validate GET] Request received - slug={slug} user_id={request.user.id if request.user.is_authenticated else 'anonymous'}") From bfe7a0a4c20f2911404f47a673d7c97252424f30 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 12:49:54 +0330 Subject: [PATCH 05/16] Implement Token Authentication in CourseOnlineClassTokenValidateAPIView - Added TokenAuthentication to allow users to authenticate with a token while still permitting access without authentication. - Removed redundant debug print statements related to token validation to streamline the code and improve readability. --- apps/course/views/course.py | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/apps/course/views/course.py b/apps/course/views/course.py index ece8362..e9e8b92 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -9,6 +9,7 @@ from django.utils import timezone from drf_yasg import openapi from drf_yasg.utils import swagger_auto_schema from rest_framework import status +from rest_framework.authentication import TokenAuthentication from rest_framework.authtoken.models import Token from rest_framework.exceptions import NotFound from rest_framework.filters import SearchFilter @@ -415,6 +416,9 @@ class CourseOnlineClassTokenAPIView(GenericAPIView): class CourseOnlineClassTokenValidateAPIView(GenericAPIView): + # Changed from AllowAny to enable DRF authentication + # Users can still access without auth, but if token is provided, it will be authenticated + authentication_classes = [TokenAuthentication] permission_classes = [AllowAny] serializer_class = OnlineClassTokenVerifySerializer @@ -454,22 +458,6 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): print(f"[Online Validate GET] user={request.user}") print(f"[Online Validate GET] user.is_authenticated={request.user.is_authenticated}") print(f"[Online Validate GET] user.id={request.user.id if request.user.is_authenticated else 'N/A'}") - print(f"[Online Validate GET] Authorization Header={request.META.get('HTTP_AUTHORIZATION', 'NOT FOUND')}") - print(f"[Online Validate GET] All Headers={dict((k, v) for k, v in request.META.items() if k.startswith('HTTP_'))}") - - # Debug: Check if token exists in database - auth_header = request.META.get('HTTP_AUTHORIZATION', '') - if auth_header.startswith('Token '): - token_key = auth_header.split(' ')[1] - try: - from rest_framework.authtoken.models import Token - token_obj = Token.objects.get(key=token_key) - print(f"[Online Validate GET] Token found in DB - user={token_obj.user} user_id={token_obj.user.id}") - except Token.DoesNotExist: - print(f"[Online Validate GET] Token NOT found in DB - token={token_key[:10]}...") - except Exception as e: - print(f"[Online Validate GET] Token check error - {str(e)}") - print("=" * 80) logger.info(f"[Online Validate GET] Request received - slug={slug} user_id={request.user.id if request.user.is_authenticated else 'anonymous'}") From 10a3a1c51cf36265562a4223d05c091a5ae024a2 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 13:03:32 +0330 Subject: [PATCH 06/16] Add Plugnmeet Server API Documentation and Enhance Live Session Verification --- apps/course/services/api.md | 1660 +++++++++++++++++++++++++++++ apps/course/views/course.py | 87 +- apps/course/views/live_session.py | 65 +- 3 files changed, 1808 insertions(+), 4 deletions(-) create mode 100644 apps/course/services/api.md diff --git a/apps/course/services/api.md b/apps/course/services/api.md new file mode 100644 index 0000000..53b2c6b --- /dev/null +++ b/apps/course/services/api.md @@ -0,0 +1,1660 @@ +# 📚 Plugnmeet Server API Documentation + +> **مستندات کامل API سرور کنفرانس ویدیویی Plugnmeet** + +این مستند راهنمای کامل API های Plugnmeet Server را شامل می‌شود که بر پایه LiveKit ساخته شده است. + +--- + +## 📖 فهرست مطالب + +### 🚀 شروع سریع +- [بررسی فعال بودن روم](#-quick-check-room-status) +- [قابلیت‌های کلیدی](#-core-features) + +### 🔐 احراز هویت و امنیت +- [نحوه احراز هویت](#-authentication) + - [روش `/auth` (HMAC + JSON)](#1-auth-endpoints-hmac--json) + - [روش `/api` (Bearer Token + Protobuf)](#2-api-endpoints-bearer-token--protobuf) + - [روش‌های خاص (LTI & BBB)](#3-special-authentication-methods) + +### 🎯 API Reference +- [**Room Management** - مدیریت اتاق‌ها](#-room-management-api) +- [**Recording Management** - مدیریت ضبط‌ها](#-recording-management-api) +- [**Analytics** - آنالیتیکس و گزارش‌گیری](#-analytics-api) +- [**In-Meeting Controls** - کنترل‌های داخل جلسه](#-in-meeting-controls-api) +- [**Advanced Features** - امکانات پیشرفته](#-advanced-features) + +### 🔧 سایر سرویس‌ها +- [Webhook, Health Check, Downloads](#-other-services) +- [BBB & LTI Compatibility](#-compatibility-apis) + +--- + +## 🚀 Quick Check: Room Status + +ساده‌ترین روش برای بررسی فعال بودن یک روم: + +### Endpoint +```http +POST /auth/room/isRoomActive +``` +d +### Request +```json +{ + "roomId": "your-room-id" +} +``` + +### Response +```json +{ + "status": true, + "msg": "room is active", + "isActive": true +} +``` + +### cURL Example +```bash +#!/bin/bash +API_KEY="your-api-key" +SECRET="your-secret-key" +BODY='{"roomId":"algebra-1402"}' + +# محاسبه HMAC-SHA256 +SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') + +# ارسال درخواست +curl -X POST 'https://your-domain.com/auth/room/isRoomActive' \ + -H "API-KEY: $API_KEY" \ + -H "HASH-SIGNATURE: $SIG" \ + -H 'Content-Type: application/json' \ + -d "$BODY" +``` + +--- + +## ⭐ Core Features + +Plugnmeet Server مجموعه کاملی از قابلیت‌های حرفه‌ای برای برگزاری کنفرانس‌های آنلاین را فراهم می‌کند: + +### 🎥 Video Conferencing +- ✅ HD Audio/Video با کیفیت بالا +- ✅ Screen Sharing - اشتراک‌گذاری صفحه +- ✅ Virtual Backgrounds - پس‌زمینه مجازی +- ✅ Adaptive Streaming (Simulcast & Dynacast) + +### 📊 Collaboration Tools +- ✅ Interactive Whiteboard با پشتیبانی از فایل‌های PDF/Office +- ✅ Shared Notepad - یادداشت مشترک +- ✅ Live Polls - نظرسنجی زنده +- ✅ Breakout Rooms - اتاق‌های گروهی + +### 🎬 Recording & Streaming +- ✅ Cloud Recording - ضبط ابری با فرمت MP4 +- ✅ RTMP Streaming - پخش زنده +- ✅ Ingress Support (RTMP/WHIP) + +### 🛡️ Security & Control +- ✅ Waiting Room - اتاق انتظار +- ✅ Lock Settings - قفل کردن قابلیت‌ها +- ✅ User Management - مدیریت کاربران +- ✅ End-to-End Encryption + +### 📈 Analytics & Monitoring +- ✅ Session Analytics - آنالیتیکس جلسات +- ✅ Participant Reports - گزارش شرکت‌کنندگان +- ✅ Real-time Monitoring + +### ♿ Accessibility +- ✅ Speech-to-Text - گفتار به متن +- ✅ Real-time Translation (Azure) + +--- + +## 🔐 Authentication + +Plugnmeet از سه روش احراز هویت مختلف استفاده می‌کند: + +### 1. `/auth` Endpoints (HMAC + JSON) + +برای عملیات مدیریتی و ساخت توکن‌ها استفاده می‌شود. + +#### Headers +```http +API-KEY: your_api_key +HASH-SIGNATURE: hmac_sha256_hex_signature +Content-Type: application/json +``` + +#### محاسبه HMAC Signature + +**Bash/Shell:** +```bash +SECRET="your-secret-key" +BODY='{"roomId":"test-room"}' +SIG=$(echo -n "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}') +``` + +**Python:** +```python +import hmac +import hashlib +import json + +secret = "your-secret-key" +body = {"roomId": "test-room"} +body_json = json.dumps(body) + +signature = hmac.new( + secret.encode('utf-8'), + body_json.encode('utf-8'), + hashlib.sha256 +).hexdigest() +``` + +**Node.js:** +```javascript +const crypto = require('crypto'); + +const secret = 'your-secret-key'; +const body = JSON.stringify({ roomId: 'test-room' }); + +const signature = crypto + .createHmac('sha256', secret) + .update(body) + .digest('hex'); +``` + +--- + +### 2. `/api` Endpoints (Bearer Token + Protobuf) + +برای کنترل‌های داخل جلسه استفاده می‌شود. + +#### Headers +```http +Authorization: +Content-Type: application/octet-stream +``` + +> **توکن دسترسی** از طریق `/auth/room/getJoinToken` دریافت می‌شود. + +#### Request/Response Format +- **بدنه درخواست**: Binary Protobuf (استفاده از SDK توصیه می‌شود) +- **پاسخ**: Binary Protobuf + +#### cURL Example با Protobuf +```bash +# ساخت فایل باینری با SDK +# سپس ارسال با curl +curl -X POST 'https://your-domain.com/api/recording' \ + -H "Authorization: $TOKEN" \ + -H 'Content-Type: application/octet-stream' \ + --data-binary @recording_req.bin \ + -o response.bin +``` + +> **نکته**: برخی endpoint های `/api` مانند `convertWhiteboardFile` و `fileUpload` از JSON استفاده می‌کنند. + +--- + +### 3. Special Authentication Methods + +#### LTI (Learning Tools Interoperability) +```http +Authorization: +``` +مسیرها: `/lti/v1/...` + +#### BigBlueButton Compatibility +نیازمند `checksum` محاسبه شده مطابق استاندارد BBB +مسیرها: `/:apiKey/bigbluebutton/api/...` + +--- + +## 📋 Room Management API + +### 🏗️ Create Room + +اتاق جلسه جدید ایجاد می‌کند. + +#### Endpoint +```http +POST /auth/room/create +``` + +#### Request Body +```json +{ + "roomId": "algebra-class-1402", + "maxParticipants": 50, + "emptyTimeout": 300, + "metadata": { + "roomTitle": "کلاس جبر خطی", + "welcomeMessage": "به کلاس جبر خوش آمدید", + "defaultLockSettings": { + "lockMicrophone": false, + "lockWebcam": false, + "lockScreenSharing": true, + "lockChat": false, + "lockChatSendMessage": false, + "lockChatFileShare": false, + "lockPrivateChat": false, + "lockWhiteboard": true, + "lockSharedNotepad": false + }, + "roomFeatures": { + "allowWebcams": true, + "muteOnStart": false, + "allowScreenSharing": true, + "allowRecording": true, + "allowRtmp": true, + "allowViewOtherWebcams": true, + "allowViewOtherParticipantsList": true, + "adminOnlyWebcams": false, + "allowPolls": true, + "roomDuration": 0, + "recordingFeatures": { + "isAllow": true, + "isAllowCloud": true, + "enableAutoCloudRecording": false + }, + "chatFeatures": { + "allowChat": true, + "allowFileUpload": true + }, + "sharedNotePadFeatures": { + "allowedSharedNotePad": true + }, + "whiteboardFeatures": { + "allowedWhiteboard": true, + "preloadFile": "" + }, + "breakoutRoomFeatures": { + "isAllow": true, + "allowedNumberRooms": 6 + }, + "displayExternalLinkFeatures": { + "isAllow": true + }, + "ingressFeatures": { + "isAllow": false + }, + "speechToTextTranslationFeatures": { + "isAllow": true, + "isAllowTranslation": true + } + }, + "webhookUrl": "https://your-domain.com/webhook", + "isBreakoutRoom": false, + "parentRoomId": "" + } +} +``` + +#### Response +```json +{ + "status": true, + "msg": "room created successfully", + "roomId": "algebra-class-1402" +} +``` + +--- + +### 🎫 Generate Join Token + +توکن ورود کاربر به جلسه را ایجاد می‌کند. + +#### Endpoint +```http +POST /auth/room/getJoinToken +``` + +#### Request Body +```json +{ + "roomId": "algebra-class-1402", + "userInfo": { + "userId": "student-123", + "name": "علی احمدی", + "isAdmin": false, + "isHidden": false, + "userMetadata": { + "profilePic": "https://example.com/avatar.jpg", + "lockSettings": { + "lockMicrophone": false, + "lockWebcam": false, + "lockScreenSharing": true, + "lockChat": false + } + } + } +} +``` + +#### Response +```json +{ + "status": true, + "msg": "token generated", + "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +#### Integration Example +```html + + + + Join Meeting + + + + + +``` + +--- + +### ✅ Check Room Status + +#### Endpoint +```http +POST /auth/room/isRoomActive +``` + +#### Request +```json +{ + "roomId": "algebra-class-1402" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "room is active", + "isActive": true +} +``` + +--- + +### 📊 Get Active Room Info + +اطلاعات کامل یک روم فعال و لیست شرکت‌کنندگان آن را برمی‌گرداند. + +#### Endpoint +```http +POST /auth/room/getActiveRoomInfo +``` + +#### Request +```json +{ + "roomId": "algebra-class-1402" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "room": { + "roomInfo": { + "sid": "RM_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "name": "algebra-class-1402", + "emptyTimeout": 300, + "maxParticipants": 50, + "creationTime": "1699123456", + "metadata": "{...}" + }, + "participantsInfo": [ + { + "sid": "PA_xxxxxxxxxxxx", + "identity": "student-123", + "name": "علی احمدی", + "state": 0, + "joinedAt": "1699123500" + } + ] + } +} +``` + +--- + +### 📋 List All Active Rooms + +لیست تمام روم‌های فعال را برمی‌گرداند. + +#### Endpoint +```http +POST /auth/room/getActiveRoomsInfo +``` + +#### Request +```json +{} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "rooms": [ + { + "roomId": "algebra-class-1402", + "sid": "RM_xxxxxxxxxxxx", + "numParticipants": 15, + "creationTime": "1699123456" + }, + { + "roomId": "physics-class-1402", + "sid": "RM_yyyyyyyyyyyy", + "numParticipants": 8, + "creationTime": "1699123789" + } + ] +} +``` + +--- + +### 🛑 End Room + +جلسه را به پایان می‌رساند و تمام شرکت‌کنندگان را خارج می‌کند. + +#### Endpoint +```http +POST /auth/room/endRoom +``` + +#### Request +```json +{ + "roomId": "algebra-class-1402" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "room ended successfully" +} +``` + +--- + +### 📜 Fetch Past Rooms + +لیست روم‌های گذشته را با امکان فیلتر و صفحه‌بندی برمی‌گرداند. + +#### Endpoint +```http +POST /auth/room/fetchPastRooms +``` + +#### Request +```json +{ + "roomIds": ["algebra-class-1402", "physics-class-1402"], + "from": 0, + "limit": 20, + "orderBy": "DESC" +} +``` + +#### Request Parameters + +| Parameter | Type | Description | +|-----------|------|-------------| +| `roomIds` | `string[]` | لیست شناسه‌های روم (اختیاری) | +| `from` | `number` | شروع صفحه‌بندی | +| `limit` | `number` | تعداد نتایج (حداکثر 100) | +| `orderBy` | `string` | ترتیب: `ASC` یا `DESC` | + +#### Response +```json +{ + "status": true, + "msg": "success", + "result": { + "totalRooms": 45, + "from": 0, + "limit": 20, + "orderBy": "DESC", + "roomsList": [ + { + "roomId": "algebra-class-1402", + "sid": "RM_xxxxxxxxxxxx", + "roomTitle": "کلاس جبر خطی", + "creationTime": "1699123456", + "ended": "1699127056", + "roomDuration": 3600 + } + ] + } +} +``` + +--- + +## 🎬 Recording Management API + +### 📋 Fetch Recordings + +لیست ضبط‌های انجام شده را دریافت می‌کند. + +#### Endpoint +```http +POST /auth/recording/fetch +``` + +#### Request +```json +{ + "roomIds": ["algebra-class-1402"], + "from": 0, + "limit": 20, + "orderBy": "DESC" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "result": { + "totalRecordings": 5, + "from": 0, + "limit": 20, + "orderBy": "DESC", + "recordings": [ + { + "recordId": "rec_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "roomSid": "RM_xxxxxxxxxxxx", + "filePath": "/recordings/algebra-class-1402_20231105.mp4", + "fileSize": 524288000, + "creationTime": "1699123456", + "roomCreationTime": "1699120000", + "recordingDuration": 3600 + } + ] + } +} +``` + +--- + +### 📄 Get Recording Info + +اطلاعات کامل یک ضبط را برمی‌گرداند. + +#### Endpoint +```http +POST /auth/recording/recordingInfo +``` + +#### Request +```json +{ + "recordId": "rec_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "recordingInfo": { + "recordId": "rec_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "filePath": "/recordings/algebra-class-1402_20231105.mp4", + "fileSize": 524288000, + "creationTime": "1699123456" + } +} +``` + +--- + +### 🗑️ Delete Recording + +یک ضبط را حذف می‌کند. + +#### Endpoint +```http +POST /auth/recording/delete +``` + +#### Request +```json +{ + "recordId": "rec_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "recording deleted successfully" +} +``` + +--- + +### 🔗 Get Download Token + +توکن موقت برای دانلود فایل ضبط شده ایجاد می‌کند. + +#### Endpoint +```http +POST /auth/recording/getDownloadToken +``` + +#### Request +```json +{ + "recordId": "rec_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "token generated", + "token": "download_token_xxxxxxxxxxxx" +} +``` + +#### Download File +```bash +# دانلود فایل با توکن +curl -o recording.mp4 \ + "https://your-domain.com/download/recording/download_token_xxxxxxxxxxxx" +``` + +--- + +## 📈 Analytics API + +### 📋 Fetch Analytics + +لیست آنالیتیکس جلسات را دریافت می‌کند. + +#### Endpoint +```http +POST /auth/analytics/fetch +``` + +#### Request +```json +{ + "roomIds": ["algebra-class-1402"], + "from": 0, + "limit": 20 +} +``` + +#### Response +```json +{ + "status": true, + "msg": "success", + "result": { + "totalAnalytics": 10, + "analyticsList": [ + { + "analyticsId": "ana_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "roomSid": "RM_xxxxxxxxxxxx", + "fileId": "file_xxxxxxxxxxxx", + "fileName": "analytics_algebra-class-1402_20231105.json", + "filePath": "/analytics/algebra-class-1402_20231105.json", + "fileSize": 102400, + "creationTime": "1699127056" + } + ] + } +} +``` + +--- + +### 🗑️ Delete Analytics + +#### Endpoint +```http +POST /auth/analytics/delete +``` + +#### Request +```json +{ + "analyticsId": "ana_xxxxxxxxxxxx" +} +``` + +--- + +### 🔗 Get Download Token + +#### Endpoint +```http +POST /auth/analytics/getDownloadToken +``` + +#### Request +```json +{ + "analyticsId": "ana_xxxxxxxxxxxx" +} +``` + +#### Response +```json +{ + "status": true, + "msg": "token generated", + "token": "analytics_token_xxxxxxxxxxxx" +} +``` + +#### Download Analytics File +```bash +curl -o analytics.json \ + "https://your-domain.com/download/analytics/analytics_token_xxxxxxxxxxxx" +``` + +--- + +## 🎮 In-Meeting Controls API + +> **نکته مهم**: تمام endpoint های این بخش نیازمند **Bearer Token** در هدر `Authorization` هستند و از **Binary Protobuf** استفاده می‌کنند (مگر در موارد خاص که JSON ذکر شده باشد). + +### 🔐 Verify Token + +توکن کاربر را تایید کرده و اطلاعات اتصال را برمی‌گرداند. + +#### Endpoint +```http +POST /api/verifyToken +``` + +#### Request (Protobuf) +```protobuf +message VerifyTokenReq {} +``` + +#### Response (Protobuf) +```protobuf +message VerifyTokenRes { + bool status = 1; + string msg = 2; + string roomId = 3; + string userId = 4; + string roomSid = 5; + repeated string natsWsUrls = 6; + string natsSubject = 7; + string serverVersion = 8; +} +``` + +--- + +### 🎬 Recording & RTMP Control + +#### Start/Stop Recording + +**Endpoint:** +```http +POST /api/recording +``` + +**Request (Protobuf):** +```protobuf +message RecordingReq { + string sid = 1; // Room SID + RecordingTasks task = 2; // START_RECORDING | STOP_RECORDING + string rtmpUrl = 3; // برای RTMP +} + +enum RecordingTasks { + START_RECORDING = 0; + STOP_RECORDING = 1; + START_RTMP = 2; + STOP_RTMP = 3; +} +``` + +**Response (Protobuf):** +```protobuf +message RecordingRes { + bool status = 1; + string msg = 2; +} +``` + +--- + +### 🛑 End Room + +اتاق را از داخل جلسه به پایان می‌رساند (فقط ادمین). + +#### Endpoint +```http +POST /api/endRoom +``` + +#### Request (Protobuf) +```protobuf +message RoomEndReq { + string roomId = 1; +} +``` + +--- + +### 🔒 Update Lock Settings + +تنظیمات قفل کاربران را تغییر می‌دهد (فقط ادمین). + +#### Endpoint +```http +POST /api/updateLockSettings +``` + +#### Request (Protobuf) +```protobuf +message UpdateUserLockSettingsReq { + string roomSid = 1; + string roomId = 2; + string userId = 3; // "all" برای همه | شناسه کاربر خاص + string service = 4; // mic | webcam | screenShare | chat | etc. + string direction = 5; // "lock" | "unlock" +} +``` + +#### Available Services +- `mic` - میکروفون +- `webcam` - وب‌کم +- `screenShare` - اشتراک‌گذاری صفحه +- `chat` - چت +- `sendChatMsg` - ارسال پیام در چت +- `chatFile` - ارسال فایل در چت +- `privateChat` - چت خصوصی +- `whiteboard` - وایت‌برد +- `sharedNotepad` - یادداشت مشترک + +--- + +### 🔇 Mute/Unmute Track + +میکروفون یک یا تمام کاربران را قطع یا وصل می‌کند (فقط ادمین). + +#### Endpoint +```http +POST /api/muteUnmuteTrack +``` + +#### Request (Protobuf) +```protobuf +message MuteUnMuteTrackReq { + string sid = 1; // Room SID + string roomId = 2; + string userId = 3; // "all" برای همه | شناسه کاربر + string trackSid = 4; // اختیاری + bool muted = 5; // true = mute | false = unmute +} +``` + +--- + +### 👤 Remove Participant + +کاربر را از جلسه حذف می‌کند (فقط ادمین). + +#### Endpoint +```http +POST /api/removeParticipant +``` + +#### Request (Protobuf) +```protobuf +message RemoveParticipantReq { + string sid = 1; + string roomId = 2; + string userId = 3; + string msg = 4; // پیام برای کاربر + bool blockUser = 5; // مسدود کردن دائمی +} +``` + +--- + +### 🎤 Switch Presenter + +نقش ارائه‌دهنده را به کاربر می‌دهد یا می‌گیرد (فقط ادمین). + +#### Endpoint +```http +POST /api/switchPresenter +``` + +#### Request (Protobuf) +```protobuf +message SwitchPresenterReq { + string userId = 1; + SwitchPresenterTask task = 2; // PROMOTE | DEMOTE +} + +enum SwitchPresenterTask { + PROMOTE = 0; + DEMOTE = 1; +} +``` + +--- + +## 🎨 Advanced Features + +### 🔗 External Display Link + +لینک خارجی را برای تمام شرکت‌کنندگان نمایش می‌دهد (فقط ادمین). + +#### Endpoint +```http +POST /api/externalDisplayLink +``` + +#### Request (Protobuf) +```protobuf +message ExternalDisplayLinkReq { + ExternalDisplayLinkTask task = 1; // START_EXTERNAL_LINK | STOP_EXTERNAL_LINK + string url = 2; +} +``` + +--- + +### 🎵 External Media Player + +ویدیو یا صدای خارجی را پخش می‌کند (فقط ادمین). + +#### Endpoint +```http +POST /api/externalMediaPlayer +``` + +#### Request (Protobuf) +```protobuf +message ExternalMediaPlayerReq { + ExternalMediaPlayerTask task = 1; // START_PLAYBACK | STOP_PLAYBACK + string url = 2; + bool isPresentation = 3; +} +``` + +> **نکته**: می‌توانید فایل را با `/api/fileUpload` آپلود کرده و لینک `/download/uploadedFile/...` را استفاده کنید. + +--- + +### 🚪 Waiting Room + +#### Approve Users + +کاربران در اتاق انتظار را تایید می‌کند (فقط ادمین). + +**Endpoint:** +```http +POST /api/waitingRoom/approveUsers +``` + +**Request (Protobuf):** +```protobuf +message ApproveWaitingUsersReq { + repeated string userIds = 1; +} +``` + +#### Update Waiting Room Message + +**Endpoint:** +```http +POST /api/waitingRoom/updateMsg +``` + +**Request (Protobuf):** +```protobuf +message UpdateWaitingRoomMessageReq { + string message = 1; +} +``` + +--- + +### 📊 Polls (نظرسنجی) + +#### Create Poll + +نظرسنجی جدید ایجاد می‌کند (فقط ادمین). + +**Endpoint:** +```http +POST /api/polls/create +``` + +**Request (Protobuf):** +```protobuf +message CreatePollReq { + string question = 1; + repeated PollOption options = 2; + bool isAnonymous = 3; + bool allowMultipleVotes = 4; +} + +message PollOption { + uint64 id = 1; + string text = 2; +} +``` + +--- + +#### List Polls + +**Endpoint:** +```http +GET /api/polls/listPolls +``` + +**Response:** Binary Protobuf + +--- + +#### Submit Poll Response + +**Endpoint:** +```http +POST /api/polls/submitResponse +``` + +**Request (Protobuf):** +```protobuf +message SubmitPollResponseReq { + string pollId = 1; + repeated uint64 selectedOptionIds = 2; +} +``` + +--- + +#### Get Poll Results + +**Endpoint:** +```http +GET /api/polls/pollResponsesResult/:pollId +``` + +**Response:** Binary Protobuf با نتایج نظرسنجی + +--- + +### 🏢 Breakout Rooms (اتاق‌های گروهی) + +#### Create Breakout Rooms + +**Endpoint:** +```http +POST /api/breakoutRoom/create +``` + +**Request (Protobuf):** +```protobuf +message CreateBreakoutRoomsReq { + uint64 duration = 1; + repeated BreakoutRoom rooms = 2; +} + +message BreakoutRoom { + string id = 1; + string title = 2; + repeated string userIds = 3; +} +``` + +--- + +#### Join Breakout Room + +**Endpoint:** +```http +POST /api/breakoutRoom/join +``` + +**Request (Protobuf):** +```protobuf +message JoinBreakoutRoomReq { + string breakoutRoomId = 1; +} +``` + +--- + +#### List Breakout Rooms + +**Endpoint:** +```http +GET /api/breakoutRoom/listRooms +``` + +--- + +#### End Breakout Room + +**Endpoint:** +```http +POST /api/breakoutRoom/endRoom +``` + +**Request (Protobuf):** +```protobuf +message EndBreakoutRoomReq { + string breakoutRoomId = 1; +} +``` + +--- + +#### End All Breakout Rooms + +**Endpoint:** +```http +POST /api/breakoutRoom/endAllRooms +``` + +--- + +### 📡 Ingress (RTMP/WHIP Input) + +ورودی استریم خارجی ایجاد می‌کند. + +#### Endpoint +```http +POST /api/ingress/create +``` + +#### Request (Protobuf) +```protobuf +message CreateIngressReq { + IngressInput inputType = 1; // RTMP_INPUT | WHIP_INPUT + string participantName = 2; + string roomId = 3; +} + +enum IngressInput { + RTMP_INPUT = 0; + WHIP_INPUT = 1; +} +``` + +#### Response (Protobuf) +```protobuf +message CreateIngressRes { + bool status = 1; + string msg = 2; + string ingressId = 3; + string url = 4; + string streamKey = 5; +} +``` + +#### Usage Example +پس از دریافت `url` و `streamKey`: +```bash +# استریم با FFmpeg +ffmpeg -re -i input.mp4 \ + -c:v libx264 -c:a aac \ + -f flv "rtmp://url/stream_key" +``` + +--- + +### 🗣️ Speech Services (Azure) + +#### Enable/Disable Speech Service + +**Endpoint:** +```http +POST /api/speechServices/serviceStatus +``` + +**Request (Protobuf):** +```protobuf +message SpeechToTextTranslationReq { + bool enabled = 1; +} +``` + +--- + +#### Get Azure Token + +**Endpoint:** +```http +POST /api/speechServices/azureToken +``` + +**Request (Protobuf):** +```protobuf +message GenerateAzureTokenReq { + string userSid = 1; +} +``` + +--- + +### 📁 File Upload & Whiteboard + +#### Upload File (Resumable) + +برای آپلود فایل‌های بزرگ به صورت chunk به chunk. + +**Endpoint:** +```http +POST /api/fileUpload?resumable=true&roomSid=xxx&roomId=xxx&userId=xxx +``` + +**Headers:** +```http +Authorization: +Content-Type: multipart/form-data +``` + +**Response:** `part_uploaded` or error + +--- + +#### Merge Uploaded Chunks + +**Endpoint:** +```http +POST /api/uploadedFileMerge +``` + +**Request (JSON):** +```json +{ + "roomSid": "RM_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "resumableIdentifier": "unique-file-id", + "resumableFilename": "document.pdf", + "resumableTotalChunks": 10 +} +``` + +**Response (JSON):** +```json +{ + "status": true, + "msg": "file merged successfully", + "filePath": "/uploads/document.pdf", + "fileName": "document.pdf", + "fileExtension": "pdf" +} +``` + +--- + +#### Convert Whiteboard File + +فایل‌های PDF/Office را به تصاویر برای وایت‌برد تبدیل می‌کند. + +> **پیش‌نیاز**: `libreoffice` و `mupdf-tools` (mutool) باید روی سرور نصب باشند. + +**Endpoint:** +```http +POST /api/convertWhiteboardFile +``` + +**Request (JSON):** +```json +{ + "roomSid": "RM_xxxxxxxxxxxx", + "roomId": "algebra-class-1402", + "filePath": "/uploads/document.pdf", + "userId": "teacher-123" +} +``` + +**Response (JSON):** +```json +{ + "status": true, + "msg": "file converted successfully", + "fileName": "document", + "fileId": "file_xxxxxxxxxxxx", + "filePath": "/whiteboard/document/", + "totalPages": 15 +} +``` + +--- + +## 🔧 Other Services + +### 🔔 Webhook + +برای دریافت رویدادهای LiveKit. + +**Endpoint:** +```http +POST /webhook +``` + +**Headers:** +```http +Authorization: +``` + +**Webhook Events:** +- `room_started` - شروع روم +- `room_finished` - پایان روم +- `participant_joined` - ورود کاربر +- `participant_left` - خروج کاربر +- `track_published` - انتشار track +- `track_unpublished` - حذف track +- `recording_started` - شروع ضبط +- `recording_finished` - پایان ضبط +- و بیشتر... + +--- + +### ❤️ Health Check + +وضعیت سلامت سرور را بررسی می‌کند. + +**Endpoint:** +```http +GET /healthCheck +``` + +**Response:** +``` +Healthy +``` + +سرویس‌های بررسی شده: +- ✅ Database (MySQL/MariaDB) +- ✅ Redis +- ✅ NATS + +--- + +### 📥 Download Services + +#### Download Uploaded File +```http +GET /download/uploadedFile/:sid/* +``` + +#### Download Recording +```http +GET /download/recording/:token +``` + +#### Download Analytics +```http +GET /download/analytics/:token +``` + +--- + +## 🔄 Compatibility APIs + +### 🟦 BigBlueButton (BBB) Compatibility + +Plugnmeet از API های BigBlueButton پشتیبانی می‌کند. + +**Base Path:** +``` +/:apiKey/bigbluebutton/api +``` + +**Available Endpoints:** +- `GET/POST /create` - ایجاد جلسه +- `GET/POST /join` - ورود به جلسه +- `GET/POST /isMeetingRunning` - بررسی فعال بودن +- `GET/POST /getMeetingInfo` - اطلاعات جلسه +- `GET/POST /getMeetings` - لیست جلسات +- `GET/POST /end` - پایان جلسه +- `GET/POST /getRecordings` - لیست ضبط‌ها +- `GET/POST /deleteRecordings` - حذف ضبط +- `GET/POST /publishRecordings` - انتشار ضبط +- `GET/POST /updateRecordings` - به‌روزرسانی ضبط + +**Authentication:** نیازمند `checksum` مطابق استاندارد BBB + +#### Example (BBB Join) +```bash +API_KEY="your-api-key" +SECRET="your-secret" +MEETING_ID="test-meeting" +USER_NAME="Ali" + +# ساخت query string +QUERY="meetingID=${MEETING_ID}&fullName=${USER_NAME}" + +# محاسبه checksum +CHECKSUM=$(echo -n "join${QUERY}${SECRET}" | sha1sum | awk '{print $1}') + +# URL نهایی +URL="https://your-domain.com/${API_KEY}/bigbluebutton/api/join?${QUERY}&checksum=${CHECKSUM}" + +echo "Join URL: $URL" +``` + +--- + +### 🎓 LTI (Learning Tools Interoperability) + +برای یکپارچگی با سیستم‌های LMS. + +**Base Path:** +``` +/lti/v1 +``` + +#### LTI Landing +```http +POST /lti/v1 +``` + +#### LTI API Endpoints + +نیازمند هدر `Authorization` خاص LTI: + +- `POST /lti/v1/api/room/join` - ورود به روم +- `POST /lti/v1/api/room/isActive` - بررسی فعال بودن +- `POST /lti/v1/api/room/end` - پایان روم +- `POST /lti/v1/api/recording/fetch` - لیست ضبط‌ها +- `POST /lti/v1/api/recording/download` - دانلود ضبط +- `POST /lti/v1/api/recording/delete` - حذف ضبط + +--- + +## 🛠️ SDKs & Tools + +### Official SDKs + +#### PHP SDK +```bash +composer require mynaparrot/plugnmeet-sdk-php +``` + +```php +roomId = 'test-room'; +$params->metadata->roomTitle = 'کلاس آزمایشی'; + +$result = $plugnmeet->room->create($params); +``` + +--- + +#### JavaScript/Node.js SDK +```bash +npm install plugnmeet-sdk-js +``` + +```javascript +const { PlugNmeet } = require('plugnmeet-sdk-js'); + +const plugnmeet = new PlugNmeet({ + host: 'https://your-domain.com', + apiKey: 'your-api-key', + apiSecret: 'your-secret' +}); + +// ایجاد روم +const result = await plugnmeet.room.create({ + roomId: 'test-room', + metadata: { + roomTitle: 'کلاس آزمایشی' + } +}); + +// تولید توکن ورود +const token = await plugnmeet.room.getJoinToken({ + roomId: 'test-room', + userInfo: { + userId: 'user-123', + name: 'علی احمدی', + isAdmin: false + } +}); +``` + +--- + +### Docker Deployment + +```bash +docker run -d \ + --name plugnmeet-server \ + -p 8080:8080 \ + -v $PWD/config.yaml:/config.yaml \ + mynaparrot/plugnmeet-server \ + --config /config.yaml +``` + +--- + +## 📚 Additional Resources + +### Documentation +- 🌐 **Official Website**: https://www.plugnmeet.org +- 📖 **Full Documentation**: https://www.plugnmeet.org/docs +- 🔧 **Installation Guide**: https://www.plugnmeet.org/docs/installation +- 👨‍💻 **Developer Guide**: https://www.plugnmeet.org/docs/developer-guide + +### Community & Support +- 💬 **Discord**: https://discord.gg/2X2ZaCHu4C +- 🐛 **GitHub Issues**: https://github.com/mynaparrot/plugNmeet-server/issues +- 📧 **Email Support**: support@plugnmeet.com + +### Source Code +- 🖥️ **Server**: https://github.com/mynaparrot/plugNmeet-server +- 🎨 **Client**: https://github.com/mynaparrot/plugNmeet-client +- 🎬 **Recorder**: https://github.com/mynaparrot/plugNmeet-recorder + +--- + +## 📝 Notes & Best Practices + +### Performance Tips +1. ✅ از Redis برای caching استفاده کنید +2. ✅ برای مقیاس‌پذیری از Load Balancer استفاده کنید +3. ✅ ضبط‌ها را در storage خارجی (S3, MinIO) ذخیره کنید +4. ✅ از CDN برای سرویس‌دهی فایل‌های استاتیک استفاده کنید + +### Security Best Practices +1. 🔒 HTTPS را فعال کنید (الزامی) +2. 🔒 `apiKey` و `secret` را محرمانه نگه دارید +3. 🔒 از CORS Policy مناسب استفاده کنید +4. 🔒 توکن‌ها را با expiration time محدود تولید کنید +5. 🔒 Webhook signature را همیشه تایید کنید + +### Rate Limiting +- `/auth` endpoints: 100 req/min per IP +- `/api` endpoints: 1000 req/min per token +- File uploads: 10 MB/s per user + +--- + +## 🎯 Quick Start Checklist + +- [ ] LiveKit Server راه‌اندازی شده +- [ ] Redis نصب و پیکربندی شده +- [ ] MySQL/MariaDB آماده است +- [ ] فایل `config.yaml` تنظیم شده +- [ ] Plugnmeet Server در حال اجراست +- [ ] Client UI در دسترس است +- [ ] Test meeting ایجاد و تست شده +- [ ] Webhook تنظیم شده (اختیاری) +- [ ] Recording تست شده (اختیاری) + +--- + +## 🎉 نسخه و تاریخچه تغییرات + +**نسخه فعلی مستند**: 2.0.0 +**آخرین به‌روزرسانی**: نوامبر 2024 + +برای مشاهده تاریخچه کامل تغییرات به فایل [CHANGELOG.md](./CHANGELOG.md) مراجعه کنید. + +--- + +
+ +**ساخته شده با ❤️ توسط [MynaParrot](https://www.mynaparrot.com)** + +[Website](https://www.plugnmeet.org) • [GitHub](https://github.com/mynaparrot/plugNmeet-server) • [Discord](https://discord.gg/2X2ZaCHu4C) + +
diff --git a/apps/course/views/course.py b/apps/course/views/course.py index e9e8b92..4fa5e1c 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -602,6 +602,15 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): return metadata def _build_live_session_context(self, course: Course) -> dict: + """ + Build live session context with real-time PlugNMeet verification. + + This method: + 1. Finds the latest session for the course + 2. Verifies with PlugNMeet if the room is actually active + 3. Auto-closes sessions if PlugNMeet reports room is inactive + 4. Returns accurate session state independent of webhook delays + """ latest_session = ( CourseLiveSession.objects.filter(course=course) .order_by('-started_at', '-id') @@ -609,6 +618,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): ) if not latest_session: + logger.debug(f"[Live Session Context] No session found for course={course.slug}") return { 'is_online': False, 'live_session': None, @@ -620,6 +630,13 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): started_at = latest_session.started_at ended_at = latest_session.ended_at is_online = bool(started_at and not ended_at) + + # CRITICAL: Verify room status with PlugNMeet if session appears online + # This ensures we don't rely solely on webhooks which may fail or be delayed + if is_online and latest_session.room_id: + is_online = self._verify_and_sync_room_status(latest_session) + # Refresh ended_at in case session was closed + ended_at = latest_session.ended_at live_session_data = { 'id': latest_session.id, @@ -629,13 +646,78 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): 'ended_at': self._format_datetime(ended_at), } - return { + context = { 'is_online': is_online, 'live_session': live_session_data, 'active_room_id': live_session_data['room_id'] if is_online and live_session_data['room_id'] else None, 'livesession_started_at': live_session_data['started_at'], 'livesession_ended_at': live_session_data['ended_at'], } + + logger.debug(f"[Live Session Context] course={course.slug} is_online={is_online} room_id={live_session_data['room_id']}") + return context + + def _verify_and_sync_room_status(self, session: CourseLiveSession) -> bool: + """ + Verify room status with PlugNMeet and sync local database. + + Args: + session: The CourseLiveSession to verify + + Returns: + bool: True if room is active, False if inactive or verification failed + + Side effects: + - Closes session in database if PlugNMeet reports room is inactive + - Updates LiveSessionUser records accordingly + """ + if not session.room_id: + logger.warning(f"[Room Sync] Session has no room_id - session_id={session.id}") + return False + + try: + client = PlugNMeetClient() + response = client.is_room_active(session.room_id) + + # PlugNMeet returns: {"status": true, "msg": "...", "isActive": true/false} + is_active = response.get('isActive', False) + response_msg = response.get('msg', 'unknown') + + if is_active: + logger.debug(f"[Room Sync] ✓ Room verified active - room_id={session.room_id} session_id={session.id} msg={response_msg}") + return True + else: + # Room is not active in PlugNMeet but active in our database + # This happens when: + # 1. Webhook failed to fire + # 2. Room was ended externally + # 3. Room crashed or timed out + logger.warning(f"[Room Sync] ✗ Room inactive in PlugNMeet - auto-closing session_id={session.id} room_id={session.room_id} msg={response_msg}") + self._close_live_session(session) + return False + + except PlugNMeetError as e: + # PlugNMeet API returned an error + error_msg = str(e) + logger.error(f"[Room Sync] PlugNMeet API error - room_id={session.room_id} session_id={session.id} error={error_msg}") + + # Check if error message indicates room doesn't exist + if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower(): + logger.warning(f"[Room Sync] Room not found in PlugNMeet - closing session_id={session.id}") + self._close_live_session(session) + return False + + # For other API errors, assume room might still be active (fail-safe) + logger.warning(f"[Room Sync] Cannot verify room status, assuming inactive for safety - room_id={session.room_id}") + return False + + except Exception as e: + # Network error or unexpected exception + logger.error(f"[Room Sync] Unexpected error verifying room - room_id={session.room_id} session_id={session.id} error={type(e).__name__}: {str(e)}") + # For network errors, fail-safe: assume room might still be active + # but log a warning for monitoring + logger.warning(f"[Room Sync] Network/system error, assuming room inactive for safety") + return False @staticmethod def _user_can_join_live_session(user, course: Course) -> bool: @@ -686,8 +768,7 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): # except (PlugNMeetError, Exception) as e: # logger.warning(f"[Room Sync] Failed to check room status - room_id={active_session.room_id} error={str(e)}") - @staticmethod - def _close_live_session(session: CourseLiveSession): + def _close_live_session(self, session: CourseLiveSession): """ Close a live session and all related user entries. Sets ended_at for session and exited_at/is_online for users. diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index ee9b51d..732a303 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -295,12 +295,21 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): course=course, ended_at__isnull=True ) - logger.info(f"[LiveSession Token] Active session found - session_id={session.id} room_id={session.room_id} course={course_slug}") + logger.info(f"[LiveSession Token] Active session found in DB - session_id={session.id} room_id={session.room_id} course={course_slug}") except CourseLiveSession.DoesNotExist: logger.warning(f"[LiveSession Token] No active session found - course={course_slug} user_id={user.id}") raise AppAPIException({'message': 'No active live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND) + # CRITICAL: Verify the room is actually active in PlugNMeet before issuing token + # This prevents issuing tokens for rooms that have crashed or ended without webhook notification room_id = session.room_id + if not self._verify_room_is_active(session): + logger.error(f"[LiveSession Token] Room not active in PlugNMeet - refusing token - room_id={room_id} session_id={session.id}") + raise AppAPIException({ + 'status': 'False', + 'message': 'room is not active. create room first', + 'msg': 'room is not active. create room first' + }, status_code=status.HTTP_400_BAD_REQUEST) is_admin = user.can_manage_course(course) user_role = "professor" if is_admin else "student" @@ -368,6 +377,60 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): 'plugnmeet': plugnmeet_response, }) + @staticmethod + def _verify_room_is_active(session: CourseLiveSession) -> bool: + """ + Verify that the room is actually active in PlugNMeet. + + Args: + session: The CourseLiveSession to verify + + Returns: + bool: True if room is active in PlugNMeet, False otherwise + + Side effects: + - Closes session in database if PlugNMeet reports room is inactive + """ + if not session.room_id: + logger.warning(f"[Room Verify] Session has no room_id - session_id={session.id}") + return False + + try: + client = PlugNMeetClient() + response = client.is_room_active(session.room_id) + + is_active = response.get('isActive', False) + response_msg = response.get('msg', 'unknown') + + if is_active: + logger.debug(f"[Room Verify] ✓ Room is active - room_id={session.room_id} session_id={session.id}") + return True + else: + logger.warning(f"[Room Verify] ✗ Room is NOT active - room_id={session.room_id} session_id={session.id} msg={response_msg}") + # Auto-close the session since room is not active + now = timezone.now() + session.ended_at = now + session.save(update_fields=['ended_at', 'updated_at']) + logger.info(f"[Room Verify] Session auto-closed - session_id={session.id} room_id={session.room_id}") + return False + + except PlugNMeetError as e: + error_msg = str(e) + logger.error(f"[Room Verify] PlugNMeet API error - room_id={session.room_id} error={error_msg}") + + # If room not found, close the session + if 'not found' in error_msg.lower() or 'does not exist' in error_msg.lower(): + now = timezone.now() + session.ended_at = now + session.save(update_fields=['ended_at', 'updated_at']) + logger.warning(f"[Room Verify] Room not found - session closed - session_id={session.id}") + + return False + + except Exception as e: + logger.error(f"[Room Verify] Unexpected error - room_id={session.room_id} error={type(e).__name__}: {str(e)}") + return False + @staticmethod def _build_profile_url(request, user): avatar = getattr(user, 'avatar', None) From c11bc44c9521cf21f6bdbb3e36658d45b5d2b4b7 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 13:11:40 +0330 Subject: [PATCH 07/16] Enhance Live Session Token Handling and Room Verification - Improved room verification logic in CourseLiveSessionTokenAPIView to handle both boolean and string responses for room activity status. - Added functionality to automatically recreate inactive rooms for professors while denying token issuance for students if the room is inactive. - Enhanced logging for better traceability of room status and actions taken during token requests. - Introduced a new method to handle room recreation in PlugNMeet, ensuring sessions can be reactivated when necessary. --- apps/course/views/course.py | 12 +++- apps/course/views/live_session.py | 94 +++++++++++++++++++++++++++---- 2 files changed, 93 insertions(+), 13 deletions(-) diff --git a/apps/course/views/course.py b/apps/course/views/course.py index 4fa5e1c..e036cff 100644 --- a/apps/course/views/course.py +++ b/apps/course/views/course.py @@ -679,9 +679,19 @@ class CourseOnlineClassTokenValidateAPIView(GenericAPIView): client = PlugNMeetClient() response = client.is_room_active(session.room_id) + # Debug: Log full response to understand structure + logger.debug(f"[Room Sync] PlugNMeet response - room_id={session.room_id} response={response}") + # PlugNMeet returns: {"status": true, "msg": "...", "isActive": true/false} - is_active = response.get('isActive', False) + # Note: isActive might be boolean or string, handle both + is_active_raw = response.get('isActive', False) + is_active = is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true' response_msg = response.get('msg', 'unknown') + response_status = response.get('status', False) + + # Additional check: if status is true and msg says "active", trust that + if response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower(): + is_active = True if is_active: logger.debug(f"[Room Sync] ✓ Room verified active - room_id={session.room_id} session_id={session.id} msg={response_msg}") diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index 732a303..5dc5c46 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -300,20 +300,39 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): logger.warning(f"[LiveSession Token] No active session found - course={course_slug} user_id={user.id}") raise AppAPIException({'message': 'No active live session found for this course.'}, status_code=status.HTTP_404_NOT_FOUND) - # CRITICAL: Verify the room is actually active in PlugNMeet before issuing token - # This prevents issuing tokens for rooms that have crashed or ended without webhook notification - room_id = session.room_id - if not self._verify_room_is_active(session): - logger.error(f"[LiveSession Token] Room not active in PlugNMeet - refusing token - room_id={room_id} session_id={session.id}") - raise AppAPIException({ - 'status': 'False', - 'message': 'room is not active. create room first', - 'msg': 'room is not active. create room first' - }, status_code=status.HTTP_400_BAD_REQUEST) - + # Check user role first to determine permissions is_admin = user.can_manage_course(course) user_role = "professor" if is_admin else "student" logger.info(f"[LiveSession Token] User role determined - user_id={user.id} role={user_role} course={course_slug}") + + # CRITICAL: Verify the room is actually active in PlugNMeet before issuing token + # This prevents issuing tokens for rooms that have crashed or ended without webhook notification + room_id = session.room_id + room_is_active = self._verify_room_is_active(session) + + if not room_is_active: + # Room is not active in PlugNMeet but we have a session record + if is_admin: + # For professors: Auto-recreate the room in PlugNMeet + logger.info(f"[LiveSession Token] Room inactive but professor requesting - recreating room - room_id={room_id} session_id={session.id}") + try: + self._recreate_room_in_plugnmeet(course, session) + logger.info(f"[LiveSession Token] Room recreated successfully - room_id={room_id}") + except Exception as e: + logger.error(f"[LiveSession Token] Failed to recreate room - room_id={room_id} error={str(e)}") + raise AppAPIException({ + 'status': 'False', + 'message': f'Failed to recreate room: {str(e)}', + 'msg': f'Failed to recreate room: {str(e)}' + }, status_code=status.HTTP_500_INTERNAL_SERVER_ERROR) + else: + # For students: Refuse token - they cannot create rooms + logger.error(f"[LiveSession Token] Room not active and user is student - refusing token - room_id={room_id} user_id={user.id}") + raise AppAPIException({ + 'status': 'False', + 'message': 'room is not active. Please wait for the professor to start the class.', + 'msg': 'room is not active. Please wait for the professor to start the class.' + }, status_code=status.HTTP_400_BAD_REQUEST) if not is_admin and not Participant.objects.filter(course=course, student_id=user.id, is_active=True).exists(): logger.warning(f"[LiveSession Token] Access denied - user_id={user.id} not enrolled in course={course_slug}") @@ -399,8 +418,18 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): client = PlugNMeetClient() response = client.is_room_active(session.room_id) - is_active = response.get('isActive', False) + # Debug: Log full response + logger.debug(f"[Room Verify] PlugNMeet response - room_id={session.room_id} response={response}") + + # Handle isActive as boolean or string + is_active_raw = response.get('isActive', False) + is_active = is_active_raw if isinstance(is_active_raw, bool) else str(is_active_raw).lower() == 'true' response_msg = response.get('msg', 'unknown') + response_status = response.get('status', False) + + # Trust status and msg if they indicate active room + if response_status and 'active' in response_msg.lower() and 'not' not in response_msg.lower(): + is_active = True if is_active: logger.debug(f"[Room Verify] ✓ Room is active - room_id={session.room_id} session_id={session.id}") @@ -431,6 +460,47 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): logger.error(f"[Room Verify] Unexpected error - room_id={session.room_id} error={type(e).__name__}: {str(e)}") return False + def _recreate_room_in_plugnmeet(self, course: Course, session: CourseLiveSession) -> None: + """ + Recreate a room in PlugNMeet when session exists but room is inactive. + + This happens when: + - Webhook failed to notify us of room closure + - PlugNMeet server restarted + - Room was manually ended + + Args: + course: The course for which to recreate the room + session: The existing session to reactivate + + Raises: + PlugNMeetError: If room creation fails + """ + subject = session.subject or f"{course.title} Live Session" + room_id = session.room_id + metadata = self._build_metadata(subject) + + payload = { + 'room_id': room_id, + 'metadata': metadata, + } + + logger.info(f"[Room Recreate] Recreating room in PlugNMeet - room_id={room_id} session_id={session.id}") + + try: + client = PlugNMeetClient() + plugnmeet_response = client.create_room(payload) + logger.info(f"[Room Recreate] Room recreated successfully - room_id={room_id} response={plugnmeet_response}") + + # Reset session ended_at to mark it as active again + session.ended_at = None + session.save(update_fields=['ended_at', 'updated_at']) + logger.info(f"[Room Recreate] Session reactivated - session_id={session.id}") + + except PlugNMeetError as exc: + logger.error(f"[Room Recreate] Failed to recreate room - room_id={room_id} error={str(exc)}") + raise + @staticmethod def _build_profile_url(request, user): avatar = getattr(user, 'avatar', None) From f62387d108df7d903693d8e0829f6f66dec06dd1 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 13:21:25 +0330 Subject: [PATCH 08/16] Implement critical fix for session handling and add centralized metadata structure - Updated CourseLiveSessionRoomCreateAPIView to filter out ended sessions by adding `ended_at__isnull=True` in the session creation logic. - Introduced a new method in CourseLiveSessionTokenAPIView to build centralized metadata for room settings, ensuring client overrides are not permitted and defining default lock settings and room features. --- apps/course/views/live_session.py | 56 ++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index 5dc5c46..50e85cf 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -97,8 +97,9 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView): raise AppAPIException({'message': str(exc)}, status_code=500) # 5. Database Logic + # CRITICAL FIX: Also filter by ended_at__isnull=True to avoid reusing ended sessions session, created = CourseLiveSession.objects.get_or_create( - course=course, room_id=room_id, + course=course, room_id=room_id, ended_at__isnull=True, defaults={'subject': subject, 'started_at': timezone.now()} ) @@ -396,6 +397,59 @@ class CourseLiveSessionTokenAPIView(GenericAPIView): 'plugnmeet': plugnmeet_response, }) + def _build_metadata(self, subject: str) -> dict: + # Build secured, centralized metadata. Client overrides are NOT allowed. + return { + 'room_title': subject, + 'default_lock_settings': { + 'lock_microphone': True, + 'lock_webcam': True, + 'lock_screen_sharing': True, + 'lock_whiteboard': False, + 'lock_shared_notepad': False, + 'lock_chat': False, + 'lock_chat_send_message': False, + 'lock_chat_file_share': False, + 'lock_private_chat': False, + }, + 'room_features': { + 'allow_webcams': True, + 'mute_on_start': True, + 'allow_screen_sharing': True, + 'allow_recording': True, + 'allow_rtmp': False, + 'allow_view_other_webcams': True, + 'allow_view_other_participants_list': True, + 'admin_only_webcams': False, + 'allow_polls': True, + 'room_duration': 0, + 'chat_features': { + 'allow_chat': True, + 'allow_file_upload': True, + }, + 'shared_note_pad_features': { + 'allowed_shared_note_pad': True, + }, + 'whiteboard_features': { + 'allowed_whiteboard': True, + }, + 'breakout_room_features': { + 'is_allow': True, + 'allowed_number_rooms': 6, + }, + 'waiting_room_features': { + 'is_active': False, + }, + 'recording_features': { + 'is_allow': True, + 'is_allow_cloud': True, + 'is_allow_local': True, + 'enable_auto_cloud_recording': False, + 'only_record_admin_webcams': False, + }, + }, + } + @staticmethod def _verify_room_is_active(session: CourseLiveSession) -> bool: """ From 1c2c1de141eae012c7fa47410d137284fd35cd27 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 13:25:19 +0330 Subject: [PATCH 09/16] Implement critical fix for session handling and add centralized metadata structure - Updated CourseLiveSessionRoomCreateAPIView to filter out ended sessions by adding `ended_at__isnull=True` in the session creation logic. - Introduced a new method in CourseLiveSessionTokenAPIView to build centralized metadata for room settings, ensuring client overrides are not permitted and enhancing session configuration consistency. --- apps/course/views/live_session.py | 39 +++++++++++++++++++++++++++---- 1 file changed, 34 insertions(+), 5 deletions(-) diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index 50e85cf..6aad5f0 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -97,11 +97,40 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView): raise AppAPIException({'message': str(exc)}, status_code=500) # 5. Database Logic - # CRITICAL FIX: Also filter by ended_at__isnull=True to avoid reusing ended sessions - session, created = CourseLiveSession.objects.get_or_create( - course=course, room_id=room_id, ended_at__isnull=True, - defaults={'subject': subject, 'started_at': timezone.now()} - ) + # Strategy: + # 1. Try to find active session (ended_at is NULL) + # 2. If not found, try to find ended session with same room_id and reactivate it + # 3. If not found, create new session + try: + # Try to get active session first + session = CourseLiveSession.objects.get( + course=course, room_id=room_id, ended_at__isnull=True + ) + created = False + logger.info(f"[LiveSession Create] Reusing active session - session_id={session.id} room_id={room_id}") + except CourseLiveSession.DoesNotExist: + # No active session, check if there's an old one with same room_id + try: + session = CourseLiveSession.objects.get( + course=course, room_id=room_id + ) + # Reactivate the old session + session.ended_at = None + session.started_at = timezone.now() + session.subject = subject + session.save(update_fields=['ended_at', 'started_at', 'subject', 'updated_at']) + created = False + logger.info(f"[LiveSession Create] Reactivated ended session - session_id={session.id} room_id={room_id}") + except CourseLiveSession.DoesNotExist: + # No session exists at all, create new one + session = CourseLiveSession.objects.create( + course=course, + room_id=room_id, + subject=subject, + started_at=timezone.now() + ) + created = True + logger.info(f"[LiveSession Create] Created new session - session_id={session.id} room_id={room_id}") return Response({ 'success': True, From 69595847998fef4aee98cf910c36b43cbea49a70 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Thu, 5 Feb 2026 13:35:32 +0330 Subject: [PATCH 10/16] Refactor CourseLiveSessionRoomCreateAPIView for improved session management and room creation logic - Enhanced session handling by checking for active sessions before creating a new room in PlugNMeet, reducing unnecessary API calls. - Implemented logic to reactivate ended sessions and log relevant actions for better traceability. - Added conditional room creation to avoid duplicate rooms, improving overall efficiency and user experience. --- apps/course/views/live_session.py | 107 ++++++++++++++++-------------- 1 file changed, 56 insertions(+), 51 deletions(-) diff --git a/apps/course/views/live_session.py b/apps/course/views/live_session.py index 6aad5f0..52dfd68 100644 --- a/apps/course/views/live_session.py +++ b/apps/course/views/live_session.py @@ -53,89 +53,94 @@ class CourseLiveSessionRoomCreateAPIView(GenericAPIView): # 2. Setup ID and Metadata room_id = f"room-{course.id}-imamjavad" subject = f"{course.title} Live Session" - metadata = self._build_metadata(subject) - - # 3. Use your CLEAN PlugNMeetClient - try: - client = PlugNMeetClient() # Loads keys from settings automatically - - # This uses the keys internally to talk to the server - plugnmeet_response = client.create_room({ - 'room_id': room_id, - 'metadata': metadata, - }) - - # 4. Generate the JOIN TOKEN (The Entry Ticket) - # Users CANNOT enter without this. - token_payload = { - "room_id": room_id, - "user_info": { - "name": f"{request.user.first_name} {request.user.last_name}", - "user_id": str(request.user.id), - "is_admin": True, - "is_hidden": False - } - } - - # Check if your client has a method for this, otherwise use manual: - # access_token = client.get_join_token(token_payload) - # If not, we do it manually here using the SAME secret from settings: - - pnm_token = jwt.encode( - { - "iss": settings.PLUGNMEET_API_KEY, - "exp": int(time.time()) + 3600, - "sub": str(request.user.id), - **token_payload - }, - settings.PLUGNMEET_API_SECRET, - algorithm="HS256" - ) - - except Exception as exc: - logger.error(f"PlugNMeet Error: {exc}") - raise AppAPIException({'message': str(exc)}, status_code=500) - - # 5. Database Logic + + # 3. Database Logic - Check FIRST before calling PlugNMeet # Strategy: # 1. Try to find active session (ended_at is NULL) # 2. If not found, try to find ended session with same room_id and reactivate it # 3. If not found, create new session + session = None + needs_room_creation = False + try: # Try to get active session first session = CourseLiveSession.objects.get( course=course, room_id=room_id, ended_at__isnull=True ) - created = False - logger.info(f"[LiveSession Create] Reusing active session - session_id={session.id} room_id={room_id}") + needs_room_creation = False + logger.info(f"[LiveSession Create] Found active session - session_id={session.id} room_id={room_id}") except CourseLiveSession.DoesNotExist: # No active session, check if there's an old one with same room_id try: session = CourseLiveSession.objects.get( course=course, room_id=room_id ) - # Reactivate the old session + # Reactivate the old session and mark for room recreation session.ended_at = None session.started_at = timezone.now() session.subject = subject session.save(update_fields=['ended_at', 'started_at', 'subject', 'updated_at']) - created = False + needs_room_creation = True logger.info(f"[LiveSession Create] Reactivated ended session - session_id={session.id} room_id={room_id}") except CourseLiveSession.DoesNotExist: - # No session exists at all, create new one + # No session exists at all, create new one and mark for room creation session = CourseLiveSession.objects.create( course=course, room_id=room_id, subject=subject, started_at=timezone.now() ) - created = True + needs_room_creation = True logger.info(f"[LiveSession Create] Created new session - session_id={session.id} room_id={room_id}") + # 4. Create room in PlugNMeet ONLY if needed + if needs_room_creation: + metadata = self._build_metadata(subject) + try: + client = PlugNMeetClient() + plugnmeet_response = client.create_room({ + 'room_id': room_id, + 'metadata': metadata, + }) + logger.info(f"[LiveSession Create] Room created in PlugNMeet - room_id={room_id}") + except Exception as exc: + logger.error(f"[LiveSession Create] PlugNMeet Error: {exc}") + # If room creation fails, revert the session changes + if session.ended_at is None: + session.ended_at = timezone.now() + session.save(update_fields=['ended_at', 'updated_at']) + raise AppAPIException({'message': f'Failed to create room: {str(exc)}'}, status_code=500) + else: + logger.info(f"[LiveSession Create] Skipping room creation - room already exists - room_id={room_id}") + + # 5. Generate the JOIN TOKEN (The Entry Ticket) + token_payload = { + "room_id": room_id, + "user_info": { + "name": f"{request.user.first_name} {request.user.last_name}", + "user_id": str(request.user.id), + "is_admin": True, + "is_hidden": False + } + } + + pnm_token = jwt.encode( + { + "iss": settings.PLUGNMEET_API_KEY, + "exp": int(time.time()) + 3600, + "sub": str(request.user.id), + **token_payload + }, + settings.PLUGNMEET_API_SECRET, + algorithm="HS256" + ) + + logger.info(f"[LiveSession Create] Success - session_id={session.id} room_id={room_id} user_id={request.user.id}") + return Response({ 'success': True, 'session': {'id': session.id, 'room_id': session.room_id}, - 'access_token': pnm_token # <--- REQUIRED for frontend + 'access_token': pnm_token }, status=201) From 01ee802f9c3c5c52114bc3475dd67d3fd208ca1c Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 8 Feb 2026 13:28:59 +0330 Subject: [PATCH 11/16] Add domain-specific URL configurations and middleware for Dovoodi and Imam Javad - Introduced `urls_dovoodi.py` and `urls_imamjavad.py` for handling URL patterns specific to each domain. - Implemented `SiteMiddleware` to route requests based on the request domain, ensuring clean admin URLs. - Updated `base.py` settings to include the new middleware for domain-based routing. - Modified Nginx configurations for both domains to support the new routing logic and SSL settings. --- config/middleware/site_middleware.py | 32 ++++ config/settings/base.py | 1 + config/urls.py | 21 +-- config/urls_dovoodi.py | 26 +++ config/urls_imamjavad.py | 26 +++ nginx/dovodi.conf | 19 +-- nginx/imamjavad.conf | 231 +++++++++++++-------------- 7 files changed, 208 insertions(+), 148 deletions(-) create mode 100644 config/middleware/site_middleware.py create mode 100644 config/urls_dovoodi.py create mode 100644 config/urls_imamjavad.py diff --git a/config/middleware/site_middleware.py b/config/middleware/site_middleware.py new file mode 100644 index 0000000..9e3a169 --- /dev/null +++ b/config/middleware/site_middleware.py @@ -0,0 +1,32 @@ +""" +Domain-based URL Configuration Middleware + +This middleware detects the request domain and routes to the appropriate +URLconf (URL configuration) for each site: +- Dovoodi domains → config.urls_dovoodi +- Imam Javad domains → config.urls_imamjavad +""" + + +class SiteMiddleware: + """ + Middleware to route requests to different URL configurations based on domain. + + This allows each domain to have clean /admin/ URLs instead of path-based + differentiation (/imam-javad/admin vs /dovoodi/admin). + """ + + def __init__(self, get_response): + self.get_response = get_response + + def __call__(self, request): + host = request.get_host() + + # Check if the request is from Dovoodi domain + if 'dovodi' in host or 'dovoodi' in host: + request.urlconf = 'config.urls_dovoodi' + # Otherwise, use Imam Javad configuration (default) + else: + request.urlconf = 'config.urls_imamjavad' + + return self.get_response(request) diff --git a/config/settings/base.py b/config/settings/base.py index ff262cf..07a0be2 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -116,6 +116,7 @@ PHONENUMBER_DEFAULT_FORMAT = 'INTERNATIONAL' AUTH_USER_MODEL = "account.User" MIDDLEWARE = [ + 'config.middleware.site_middleware.SiteMiddleware', # Must be first to route by domain 'django.middleware.security.SecurityMiddleware', "whitenoise.middleware.WhiteNoiseMiddleware", 'django.contrib.sessions.middleware.SessionMiddleware', diff --git a/config/urls.py b/config/urls.py index e78de5c..8b6c43b 100644 --- a/config/urls.py +++ b/config/urls.py @@ -93,19 +93,17 @@ api_patterns = [ ] +# Base URL patterns (common to all domains) +# These patterns are shared by both Imam Javad and Dovoodi sites urlpatterns = [ - path("admin/", HomeView.as_view(), name="home"), path("i18n/", include("django.conf.urls.i18n")), - - # path('admin/', admin.site.urls), path('api/', include(api_patterns)), - # path('test/', include('apps.api.urls')) path('oneapi-translation/', oneapi_translate), path('admin/filer/', include('filer.urls')), - path('filer/', include('filer.urls')), - + path('filer/', include('filer.urls')), ] -# Protected swagger URL patterns + +# Protected swagger URL patterns (to be used in domain-specific configs) swagger_urlpatterns = [ path('swagger-auth/', SwaggerTokenAuthView.as_view(), name='swagger-token-auth'), path('swagger-auth/clear/', clear_swagger_auth, name='clear-swagger-auth'), @@ -118,15 +116,6 @@ swagger_urlpatterns = [ name='schema-redoc'), ] -urlpatterns+= i18n_patterns( - path("imam-javad/admin/", project_admin_site.urls), - path("dovoodi/admin/", dovoodi_admin_site.urls ), - path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), - *swagger_urlpatterns, - path('admin/filer/', include('filer.urls')), - path('filer/', include('filer.urls')), -) - if settings.DEBUG: urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT) diff --git a/config/urls_dovoodi.py b/config/urls_dovoodi.py new file mode 100644 index 0000000..8fb77a6 --- /dev/null +++ b/config/urls_dovoodi.py @@ -0,0 +1,26 @@ +""" +URL configuration for Dovoodi domain +This configuration is loaded when accessing from dovodi.* or dovoodi.* domains +""" +from django.urls import path, include +from django.conf.urls.i18n import i18n_patterns +from config.urls import urlpatterns as base_urlpatterns, swagger_urlpatterns +from utils.admin import dovoodi_admin_site +from apps.api.views import CustomAPIDocumentationView + + +# Combine base patterns with Dovoodi specific admin +urlpatterns = base_urlpatterns + i18n_patterns( + # Admin panel accessible at /admin/ (Django will redirect to /en/admin/ or /fa/admin/) + path("admin/", dovoodi_admin_site.urls), + + # API documentation + path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), + + # Swagger and API documentation + *swagger_urlpatterns, + + # Filer (Django file manager) + path('admin/filer/', include('filer.urls')), + path('filer/', include('filer.urls')), +) diff --git a/config/urls_imamjavad.py b/config/urls_imamjavad.py new file mode 100644 index 0000000..e5bface --- /dev/null +++ b/config/urls_imamjavad.py @@ -0,0 +1,26 @@ +""" +URL configuration for Imam Javad domain +This configuration is loaded when accessing from imamjavad.* domains +""" +from django.urls import path, include +from django.conf.urls.i18n import i18n_patterns +from config.urls import urlpatterns as base_urlpatterns, swagger_urlpatterns +from utils.admin import project_admin_site +from apps.api.views import CustomAPIDocumentationView + + +# Combine base patterns with Imam Javad specific admin +urlpatterns = base_urlpatterns + i18n_patterns( + # Admin panel accessible at /admin/ (Django will redirect to /en/admin/ or /fa/admin/) + path("admin/", project_admin_site.urls), + + # API documentation + path('docs/', CustomAPIDocumentationView.as_view(), name='docs-index'), + + # Swagger and API documentation + *swagger_urlpatterns, + + # Filer (Django file manager) + path('admin/filer/', include('filer.urls')), + path('filer/', include('filer.urls')), +) diff --git a/nginx/dovodi.conf b/nginx/dovodi.conf index 481b381..be2fecf 100644 --- a/nginx/dovodi.conf +++ b/nginx/dovodi.conf @@ -1,10 +1,10 @@ server { listen 80; listen 443 ssl; - server_name dovodi.nwhco.ir; - # ssl_certificate /etc/nginx/certs/nwhco.pem; - # ssl_certificate_key /etc/nginx/certs/nwhco.key; - # include /etc/nginx/options-ssl-nginx.conf; + server_name dovodi.nwhco.ir dovodi.newhorizonco.uk; + ssl_certificate /etc/nginx/certs/nwhco.pem; + ssl_certificate_key /etc/nginx/certs/nwhco.key; + include /etc/nginx/options-ssl-nginx.conf; client_max_body_size 500M; client_body_timeout 600s; @@ -14,17 +14,6 @@ server { proxy_read_timeout 600s; # ========== Django Admin Paths (باید قبل از location / باشند) ========== - - # Add this inside the 'server' block of the existing config - - location /agent/ { - proxy_pass http://88.99.212.243:8098; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } - # با prefix زبانی location /en/dovoodi/ { diff --git a/nginx/imamjavad.conf b/nginx/imamjavad.conf index 81501bf..6f98fac 100644 --- a/nginx/imamjavad.conf +++ b/nginx/imamjavad.conf @@ -1,15 +1,38 @@ +# ======================================== +# Imam Javad Platform - Nginx Configuration +# Single Domain - Clean Architecture +# ======================================== + +# ========== Upstream Definitions ========== +upstream django_backend { + server 88.99.212.243:8010; + keepalive 32; +} + +upstream nextjs_frontend { + server 88.99.212.243:7226; + keepalive 32; +} + +upstream chat_service { + server 88.99.212.243:8020; + keepalive 32; +} + +# ========== Proxy Settings (Reusable) ========== +# Include this file or copy these settings +# /etc/nginx/snippets/proxy-params.conf + server { listen 80; -<<<<<<< HEAD - # listen 443 ssl; -======= listen 443 ssl; ->>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services) - server_name imamjavad.nwhco.ir imamjavad.newhorizonco.uk; + server_name imamjavad.nwhco.ir imamjavad.newhorizonco.uk; + # ssl_certificate /etc/nginx/certs/nwhco.pem; # ssl_certificate_key /etc/nginx/certs/nwhco.key; # include /etc/nginx/options-ssl-nginx.conf; - + + # ========== Global Settings ========== client_max_body_size 500M; client_body_timeout 600s; client_header_timeout 60s; @@ -17,167 +40,141 @@ server { proxy_send_timeout 600s; proxy_read_timeout 600s; - # ========== Django Admin Paths (باید قبل از location / باشند) ========== - - # با prefix زبانی - location /en/imam-javad/ { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # ========== 1. Static Files (Highest Priority) ========== + location /static/ { + alias /home/app/web/imam_javad_staticfiles/; + expires 30d; + add_header Cache-Control "public, immutable"; + access_log off; + } + + location /media/ { + alias /home/app/web/imam_javad_mediafiles/; + expires 30d; + add_header Cache-Control "public, immutable"; + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, OPTIONS"; + access_log off; + } + + # ========== 2. Next.js Assets ========== + location /_next/ { + proxy_pass http://nextjs_frontend; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - client_max_body_size 1200M; + expires 1y; + add_header Cache-Control "public, immutable"; + access_log off; } - location /fa/imam-javad/ { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # ========== 3. API Endpoints ========== + location /api/ { + proxy_pass http://django_backend; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Authorization $http_authorization; + proxy_pass_header Authorization; client_max_body_size 1200M; } - # بدون prefix زبانی (fallback) - location /imam-javad/ { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # ========== 4. Chat Service (WebSocket) ========== + location /chat { + proxy_pass http://chat_service; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; client_max_body_size 1200M; + + # WebSocket timeouts + proxy_read_timeout 3600s; + proxy_send_timeout 3600s; } - # ========== Admin Entry Points ========== - - location = /admin { - return 301 /admin/; - } - - location /admin/ { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # ========== 5. i18n Language Switcher ========== + location /i18n/ { + proxy_pass http://django_backend; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - client_max_body_size 1200M; } - location = /admin/logout { - return 301 /en/imam-javad/admin/logout/; - } - - # مسیر عمومی admin (سازگاری با config قدیمی) - location /en/admin { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # ========== 6. Django Admin Panel (Single Pattern) ========== + # Matches: /en/imam-javad/admin/, /fa/imam-javad/admin/, /imam-javad/admin/ + location ~ ^/(en|fa)?/?imam-javad/ { + proxy_pass http://django_backend; + proxy_http_version 1.1; proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; client_max_body_size 1200M; } - # ========== Django Services ========== - - location /api { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # ========== 7. Dovoodi Admin Panel (Single Pattern) ========== + # Matches: /en/dovoodi/admin/, /fa/dovoodi/admin/, /dovoodi/admin/ + location ~ ^/(en|fa)?/?dovoodi/ { + proxy_pass http://django_backend; + proxy_http_version 1.1; proxy_set_header Host $host; - proxy_method $request_method; proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; - -<<<<<<< HEAD - # 🔥 CRITICAL: Forward Authorization header for Token authentication -======= ->>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services) - proxy_set_header Authorization $http_authorization; - proxy_pass_header Authorization; - client_max_body_size 1200M; } - location /en/swagger { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + # ========== 8. Swagger & API Documentation ========== + # Matches: /en/swagger/, /fa/swagger/, /en/redoc/, /fa/redoc/, /en/docs/ + location ~ ^/(en|fa)/(swagger|redoc|docs) { + proxy_pass http://django_backend; + proxy_http_version 1.1; proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header Authorization $http_authorization; proxy_pass_header Authorization; } - location /en/redoc { - proxy_pass http://88.99.212.243:8010; - proxy_set_header Authorization $http_authorization; - proxy_pass_header Authorization; - } - - location /i18n/ { - proxy_pass http://88.99.212.243:8010; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header Host $host; - } - - # ========== Other Services ========== - - location /chat { - proxy_pass http://88.99.212.243:8020; - client_max_body_size 1200M; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection "upgrade"; + # ========== 9. Filer (Django File Manager) ========== + location /filer/ { + proxy_pass http://django_backend; + proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 1200M; } -<<<<<<< HEAD - location /static/ { - alias /home/app/web/imam_javad_static/static/; - } - - location /media/ { - alias /home/app/web/imam_javad_static/media/; -======= - - location /static/ { - alias /home/app/web/imam_javad_staticfiles/; - expires 30d; - add_header Cache-Control "public, immutable"; - } - - location /media/{ - alias /home/app/web/imam_javad_mediafiles/; - - expires 30d; - add_header Cache-Control "public, immutable"; - - # Enable CORS for media files - add_header Access-Control-Allow-Origin *; - add_header Access-Control-Allow-Methods "GET, OPTIONS"; - add_header Access-Control-Allow-Headers "Origin, X-Requested-With, Content-Type, Accept"; ->>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services) + # ========== 10. Admin Entry Point Redirect ========== + # /admin → /en/imam-javad/admin/ + location = /admin { + return 301 /en/imam-javad/admin/; } - - # ========== Next.js Frontend ========== - location /_next/ { - proxy_pass http://88.99.212.243:7226/_next/; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - expires 1y; - add_header Cache-Control "public, immutable"; + location = /admin/ { + return 301 /en/imam-javad/admin/; } -<<<<<<< HEAD - # ⚠️ این باید آخرین location باشد (fallback) -======= ->>>>>>> a1b3ddf (Add Nginx configuration files for dovoodi and imamjavad services) + # ========== 11. Next.js Frontend (Catch-all - Lowest Priority) ========== location / { - proxy_pass http://88.99.212.243:7226; - client_max_body_size 1200M; + proxy_pass http://nextjs_frontend; + proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; } } From dc103d1f08bdb6b36cfddc632893772131afa1a3 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 8 Feb 2026 13:33:40 +0330 Subject: [PATCH 12/16] Add admin URL redirection and update admin links for Dovoodi and Imam Javad - Introduced a new URL pattern to redirect "/admin/" to the appropriate language-prefixed admin URL. - Updated admin links for both Dovoodi and Imam Javad to remove language-specific paths, simplifying access. - Refactored HomeView to streamline the redirection logic based on the current language. --- config/urls.py | 1 + utils/admin.py | 21 +++++++++------------ 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/config/urls.py b/config/urls.py index 8b6c43b..4c69d44 100644 --- a/config/urls.py +++ b/config/urls.py @@ -96,6 +96,7 @@ api_patterns = [ # Base URL patterns (common to all domains) # These patterns are shared by both Imam Javad and Dovoodi sites urlpatterns = [ + path("admin/", HomeView.as_view(), name="home"), # Redirect to appropriate admin based on domain path("i18n/", include("django.conf.urls.i18n")), path('api/', include(api_patterns)), path('oneapi-translation/', oneapi_translate), diff --git a/utils/admin.py b/utils/admin.py index d09141e..5990e82 100644 --- a/utils/admin.py +++ b/utils/admin.py @@ -130,7 +130,7 @@ class FormulaAdminSite(UnfoldAdminSite): }, { "title": _("Dovoodi Admin"), - "link": "https://dovodi.newhorizonco.uk/en/dovoodi/admin/", + "link": "https://dovodi.newhorizonco.uk/admin/", "icon": "diamond", } ] @@ -236,7 +236,7 @@ class DovoodiAdminSite(UnfoldAdminSite): }, { "title": _("Imam Javad Admin"), - "link": "https://imamjavad.newhorizonco.uk/en/imam-javad/admin/", + "link": "https://imamjavad.newhorizonco.uk/admin/", "icon": "diamond", } ] @@ -464,20 +464,17 @@ def replace_placeholders_with_real_sites(): # This ensures proper CSS loading for admin templates class HomeView(RedirectView): + """ + Redirects /admin/ to the language-prefixed admin URL. + The domain-based routing middleware will handle which admin site to use. + """ def get_redirect_url(self, *args, **kwargs): - host = self.request.get_host() - # دریافت زبان فعلی (پیش‌فرض: en) language = get_language() or 'en' - # دامنه‌های داوودی - dovoodi_domains = ['dovodi.newhorizonco.uk', 'dovoodi.newhorizonco.uk'] - - # تصمیم‌گیری بر اساس دامنه و برگرداندن URL با prefix زبانی - if any(domain in host for domain in dovoodi_domains): - return f'/{language}/dovoodi/admin/' - else: - return f'/{language}/imam-javad/admin/' + # Now we simply redirect to /language/admin/ + # The SiteMiddleware will route to the correct admin based on domain + return f'/{language}/admin/' # --------------------------------------------------------- # 4. Dummy Data for Dashboard Charts From 16460c3da775d7908e500a84141af4955719c510 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 8 Feb 2026 13:36:26 +0330 Subject: [PATCH 13/16] Add admin namespace determination for domain-based redirection - Introduced a new function `get_admin_namespace` to determine the appropriate admin namespace based on the request domain (Dovoodi or Imam Javad). - Updated `AdminAccessMiddleware` and `enhanced_auth_middleware` to utilize the new function for redirecting users to the correct admin login page, enhancing access control based on the domain. - Improved code readability and maintainability by centralizing the logic for admin namespace resolution. --- apps/account/middleware/admin_access.py | 21 +++++++++++++++++++-- config/enhanced_auth_middleware.py | 18 +++++++++++++++++- 2 files changed, 36 insertions(+), 3 deletions(-) diff --git a/apps/account/middleware/admin_access.py b/apps/account/middleware/admin_access.py index ff99fec..00d09dd 100644 --- a/apps/account/middleware/admin_access.py +++ b/apps/account/middleware/admin_access.py @@ -7,6 +7,20 @@ from django.contrib import messages from django.utils.translation import gettext_lazy as _ +def get_admin_namespace(request): + """ + Determine the admin namespace based on the request domain. + Returns the appropriate admin namespace for use in reverse() calls. + """ + host = request.get_host() + + # Check if the request is from Dovoodi domain + if 'dovodi' in host or 'dovoodi' in host: + return 'dovoodi_admin' + else: + return 'imam_javad_admin' + + class AdminAccessMiddleware: """Middleware برای کنترل دسترسی به admin panel""" @@ -93,8 +107,11 @@ class AdminAccessMiddleware: def handle_restricted_access(self, request): """مدیریت دسترسی محدود شده""" + # Get the correct admin namespace based on domain + admin_namespace = get_admin_namespace(request) + if not request.user.is_authenticated: - return redirect('admin:login') + return redirect(f'{admin_namespace}:login') # اگر کاربر استاد است، در همان admin panel می‌ماند if request.user.is_authenticated and request.user.has_role('professor'): @@ -111,4 +128,4 @@ class AdminAccessMiddleware: request, _('You do not have permission to access this page.') ) - return redirect('admin:login') + return redirect(f'{admin_namespace}:login') diff --git a/config/enhanced_auth_middleware.py b/config/enhanced_auth_middleware.py index b64a952..9129b42 100644 --- a/config/enhanced_auth_middleware.py +++ b/config/enhanced_auth_middleware.py @@ -6,6 +6,21 @@ from django.contrib import messages User = get_user_model() + +def get_admin_namespace(request): + """ + Determine the admin namespace based on the request domain. + Returns the appropriate admin namespace for use in reverse() calls. + """ + host = request.get_host() + + # Check if the request is from Dovoodi domain + if 'dovodi' in host or 'dovoodi' in host: + return 'dovoodi_admin' + else: + return 'imam_javad_admin' + + def enhanced_auth_middleware(get_response): """ Enhanced middleware for API authentication with admin restriction @@ -49,8 +64,9 @@ def enhanced_auth_middleware(get_response): # For swagger-auth paths, allow access (they handle their own auth) if '/swagger-auth/' not in request.path: # Redirect to admin login for other protected paths + admin_namespace = get_admin_namespace(request) messages.warning(request, 'You must be logged in as a staff member to access API documentation.') - return redirect(f"{reverse('admin:login')}?next={request.path}") + return redirect(f"{reverse(f'{admin_namespace}:login')}?next={request.path}") # For non-protected API paths, handle normal authentication elif "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: From 8ffeb504bb67402231647f34102816b92a1a8f83 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 8 Feb 2026 13:36:43 +0330 Subject: [PATCH 14/16] Refactor admin namespace logic into middleware - Moved the `get_admin_namespace` function from `admin_access.py` to a new `config.middleware` module for better organization and reusability. - Updated `AdminAccessMiddleware` to utilize the centralized `get_admin_namespace` function, enhancing code maintainability. - Improved documentation for the `get_admin_namespace` function to clarify its usage and purpose. --- apps/account/middleware/admin_access.py | 15 +-------------- config/middleware/__init__.py | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 14 deletions(-) create mode 100644 config/middleware/__init__.py diff --git a/apps/account/middleware/admin_access.py b/apps/account/middleware/admin_access.py index 00d09dd..73c2817 100644 --- a/apps/account/middleware/admin_access.py +++ b/apps/account/middleware/admin_access.py @@ -5,20 +5,7 @@ from django.shortcuts import redirect from django.urls import reverse from django.contrib import messages from django.utils.translation import gettext_lazy as _ - - -def get_admin_namespace(request): - """ - Determine the admin namespace based on the request domain. - Returns the appropriate admin namespace for use in reverse() calls. - """ - host = request.get_host() - - # Check if the request is from Dovoodi domain - if 'dovodi' in host or 'dovoodi' in host: - return 'dovoodi_admin' - else: - return 'imam_javad_admin' +from config.middleware import get_admin_namespace class AdminAccessMiddleware: diff --git a/config/middleware/__init__.py b/config/middleware/__init__.py new file mode 100644 index 0000000..977b954 --- /dev/null +++ b/config/middleware/__init__.py @@ -0,0 +1,22 @@ +""" +Middleware utilities and helpers +""" + + +def get_admin_namespace(request): + """ + Determine the admin namespace based on the request domain. + Returns the appropriate admin namespace for use in reverse() calls. + + Usage: + from config.middleware import get_admin_namespace + admin_ns = get_admin_namespace(request) + url = reverse(f'{admin_ns}:model_changelist') + """ + host = request.get_host() + + # Check if the request is from Dovoodi domain + if 'dovodi' in host or 'dovoodi' in host: + return 'dovoodi_admin' + else: + return 'imam_javad_admin' From 4b325008889bd8da0d432509127f21a0b046fd20 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 8 Feb 2026 13:38:41 +0330 Subject: [PATCH 15/16] Refactor: Remove inline `get_admin_namespace` function from `enhanced_auth_middleware.py` - Eliminated the inline `get_admin_namespace` function to streamline the code and improve readability. - The function is now imported from the centralized `config.middleware` module, enhancing code organization and reusability. --- config/enhanced_auth_middleware.py | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/config/enhanced_auth_middleware.py b/config/enhanced_auth_middleware.py index 9129b42..c2da2bc 100644 --- a/config/enhanced_auth_middleware.py +++ b/config/enhanced_auth_middleware.py @@ -3,24 +3,11 @@ from django.contrib.auth import get_user_model from django.shortcuts import redirect from django.urls import reverse from django.contrib import messages +from config.middleware import get_admin_namespace User = get_user_model() -def get_admin_namespace(request): - """ - Determine the admin namespace based on the request domain. - Returns the appropriate admin namespace for use in reverse() calls. - """ - host = request.get_host() - - # Check if the request is from Dovoodi domain - if 'dovodi' in host or 'dovoodi' in host: - return 'dovoodi_admin' - else: - return 'imam_javad_admin' - - def enhanced_auth_middleware(get_response): """ Enhanced middleware for API authentication with admin restriction From cabbfc06ab4ab3be1005e50d9ca2260e6f34c167 Mon Sep 17 00:00:00 2001 From: mortezaei Date: Sun, 8 Feb 2026 13:41:02 +0330 Subject: [PATCH 16/16] Refactor admin access redirection logic in middleware - Removed dependency on `get_admin_namespace` and replaced it with direct language-based URL redirection for admin login in both `AdminAccessMiddleware` and `enhanced_auth_middleware`. - Improved code clarity by utilizing Django's `get_language` to dynamically construct the redirect URL based on the user's language preference. - Enhanced user experience by ensuring consistent access to the admin login page across different languages. --- apps/account/middleware/admin_access.py | 14 ++++++++------ config/enhanced_auth_middleware.py | 6 +++--- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/apps/account/middleware/admin_access.py b/apps/account/middleware/admin_access.py index 73c2817..6eb8440 100644 --- a/apps/account/middleware/admin_access.py +++ b/apps/account/middleware/admin_access.py @@ -5,7 +5,6 @@ from django.shortcuts import redirect from django.urls import reverse from django.contrib import messages from django.utils.translation import gettext_lazy as _ -from config.middleware import get_admin_namespace class AdminAccessMiddleware: @@ -94,11 +93,11 @@ class AdminAccessMiddleware: def handle_restricted_access(self, request): """مدیریت دسترسی محدود شده""" - # Get the correct admin namespace based on domain - admin_namespace = get_admin_namespace(request) - if not request.user.is_authenticated: - return redirect(f'{admin_namespace}:login') + # Redirect to admin login page using direct URL path + from django.utils.translation import get_language + language = get_language() or 'en' + return redirect(f'/{language}/admin/login/?next={request.path}') # اگر کاربر استاد است، در همان admin panel می‌ماند if request.user.is_authenticated and request.user.has_role('professor'): @@ -115,4 +114,7 @@ class AdminAccessMiddleware: request, _('You do not have permission to access this page.') ) - return redirect(f'{admin_namespace}:login') + # Redirect to admin login page using direct URL path + from django.utils.translation import get_language + language = get_language() or 'en' + return redirect(f'/{language}/admin/login/?next={request.path}') diff --git a/config/enhanced_auth_middleware.py b/config/enhanced_auth_middleware.py index c2da2bc..bd82db4 100644 --- a/config/enhanced_auth_middleware.py +++ b/config/enhanced_auth_middleware.py @@ -3,7 +3,6 @@ from django.contrib.auth import get_user_model from django.shortcuts import redirect from django.urls import reverse from django.contrib import messages -from config.middleware import get_admin_namespace User = get_user_model() @@ -51,9 +50,10 @@ def enhanced_auth_middleware(get_response): # For swagger-auth paths, allow access (they handle their own auth) if '/swagger-auth/' not in request.path: # Redirect to admin login for other protected paths - admin_namespace = get_admin_namespace(request) messages.warning(request, 'You must be logged in as a staff member to access API documentation.') - return redirect(f"{reverse(f'{admin_namespace}:login')}?next={request.path}") + from django.utils.translation import get_language + language = get_language() or 'en' + return redirect(f"/{language}/admin/login/?next={request.path}") # For non-protected API paths, handle normal authentication elif "/admin/" not in request.path and request.META.get('HTTP_AUTHORIZATION') is None: