This commit is contained in:
september
2025-09-03 06:51:24 -07:00
commit 54a81cbdcf
95 changed files with 17637 additions and 0 deletions

37
backend/Dockerfile Normal file
View File

@@ -0,0 +1,37 @@
FROM python:3.11-slim
WORKDIR /app
# Установка системных зависимостей
RUN apt-get update && apt-get install -y \
libpq-dev \
gcc \
gettext \
vim \
&& rm -rf /var/lib/apt/lists/*
# Копирование и установка Python зависимостей
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Дополнительно устанавливаем daphne если его нет в requirements.txt
RUN pip install daphne
# Создание непривилегированного пользователя с определенным UID/GID
RUN groupadd -r appuser -g 1000 && useradd -r -g appuser -u 1000 appuser
# Копирование исходного кода
COPY . .
# Создание директорий для статических файлов, медиа и логов с правильными правами
RUN mkdir -p /app/staticfiles /app/media /app/logs && \
chown -R appuser:appuser /app && \
chmod -R 755 /app/logs
# Переключение на непривилегированного пользователя
USER appuser
EXPOSE 8000
# Команда по умолчанию - запуск через daphne для поддержки WebSocket
CMD ["daphne", "-b", "0.0.0.0", "-p", "8000", "videocall_app.asgi:application"]

0
backend/apps/__init__.py Normal file
View File

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AuthenticationConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.authentication'

View File

@@ -0,0 +1,3 @@
from django.db import models
# Create your models here.

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,11 @@
# authentication/urls.py - Authentication URL patterns
from django.urls import path
from . import views
app_name = 'authentication'
urlpatterns = [
path('login/', views.login_view, name='login'),
path('logout/', views.logout_view, name='logout'),
path('check/', views.check_auth_view, name='check'),
]

View File

@@ -0,0 +1,127 @@
# authentication/views.py - Authentication API views
import hashlib
from django.contrib.auth import login, logout
from django.contrib.auth.models import User
from django.contrib.sessions.models import Session
from django.views.decorators.csrf import csrf_exempt
from django.utils.decorators import method_decorator
from django.utils import timezone
from datetime import timedelta
from rest_framework import status
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from django_ratelimit.decorators import ratelimit
from apps.core.models import SystemSettings
import logging
logger = logging.getLogger(__name__)
def get_client_ip(request):
"""Extract client IP address from request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR')
return ip
@api_view(['POST'])
@permission_classes([AllowAny])
@csrf_exempt
@ratelimit(key='ip', rate='10/min', method='POST', block=True)
def login_view(request):
"""
Authenticate user with system password.
Rate limited to prevent brute force attacks.
"""
try:
password = request.data.get('password')
if not password:
return Response(
{'error': 'Password is required'},
status=status.HTTP_400_BAD_REQUEST
)
settings_obj = SystemSettings.get_settings()
if settings_obj.check_password(password):
# Create or get session
if not request.session.session_key:
request.session.create()
# Store authentication in session
request.session['authenticated'] = True
request.session['auth_timestamp'] = timezone.now().isoformat()
request.session['client_ip'] = get_client_ip(request)
request.session.save()
logger.info(f"Successful login from IP: {get_client_ip(request)}")
return Response({
'success': True,
'message': 'Authentication successful',
'session_key': request.session.session_key
})
else:
logger.warning(f"Failed login attempt from IP: {get_client_ip(request)}")
return Response(
{'error': 'Invalid password'},
status=status.HTTP_401_UNAUTHORIZED
)
except Exception as e:
logger.error(f"Login failed: {e}")
return Response(
{'error': 'Authentication failed'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
@permission_classes([AllowAny])
@csrf_exempt
def logout_view(request):
"""Clear user session and log out"""
try:
request.session.flush()
logger.info("User logged out successfully")
return Response({
'success': True,
'message': 'Logged out successfully'
})
except Exception as e:
logger.error(f"Logout failed: {e}")
return Response(
{'error': 'Logout failed'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['GET'])
@permission_classes([AllowAny])
@csrf_exempt
def check_auth_view(request):
"""Check if user is authenticated"""
is_authenticated = request.session.get('authenticated', False)
if is_authenticated:
# Optional: Check session age for additional security
auth_timestamp = request.session.get('auth_timestamp')
if auth_timestamp:
try:
auth_time = timezone.datetime.fromisoformat(auth_timestamp)
if timezone.now() - auth_time > timedelta(hours=24):
request.session.flush()
is_authenticated = False
except (ValueError, TypeError):
request.session.flush()
is_authenticated = False
return Response({
'authenticated': is_authenticated,
'session_key': request.session.session_key if is_authenticated else None
})

View File

180
backend/apps/core/admin.py Normal file
View File

@@ -0,0 +1,180 @@
# apps/core/admin.py - Админка для управления системными настройками
from django import forms
from django.contrib import admin
from django.contrib import messages
from django.contrib.auth.hashers import make_password
from django.utils.html import format_html
from django.urls import reverse
from django.utils.safestring import mark_safe
from django.core.exceptions import ValidationError
from .models import SystemSettings, RoomActivityLog
class SystemSettingsForm(forms.ModelForm):
"""Кастомная форма для системных настроек"""
new_password = forms.CharField(
widget=forms.PasswordInput,
required=False,
label='Новый пароль',
help_text='Введите новый пароль для входа в систему (минимум 6 символов)'
)
confirm_password = forms.CharField(
widget=forms.PasswordInput,
required=False,
label='Подтвердите пароль',
help_text='Повторите новый пароль'
)
class Meta:
model = SystemSettings
fields = ['is_active']
def clean(self):
"""Валидация формы"""
cleaned_data = super().clean()
new_password = cleaned_data.get('new_password')
confirm_password = cleaned_data.get('confirm_password')
if new_password or confirm_password:
if not new_password:
raise ValidationError('Введите новый пароль')
if not confirm_password:
raise ValidationError('Подтвердите пароль')
if new_password != confirm_password:
raise ValidationError('Пароли не совпадают')
if len(new_password) < 6:
raise ValidationError('Пароль должен содержать минимум 6 символов')
return cleaned_data
@admin.register(SystemSettings)
class SystemSettingsAdmin(admin.ModelAdmin):
"""
Админ-интерфейс для управления системными настройками
"""
form = SystemSettingsForm
list_display = ('id', 'is_active', 'created_at', 'updated_at', 'password_status')
list_filter = ('is_active', 'created_at', 'updated_at')
readonly_fields = ('created_at', 'updated_at', 'password_hash_preview')
fieldsets = (
('Основные настройки', {
'fields': ('is_active',)
}),
('Безопасность', {
'fields': ('new_password', 'confirm_password', 'password_hash_preview'),
'description': 'Установите новый пароль для доступа к системе'
}),
('Информация', {
'fields': ('created_at', 'updated_at'),
'classes': ('collapse',)
})
)
def save_model(self, request, obj, form, change):
"""Обработка сохранения с проверкой пароля"""
new_password = form.cleaned_data.get('new_password')
if new_password:
obj.set_password(new_password)
self.message_user(
request,
'Пароль успешно изменен',
level=messages.SUCCESS
)
super().save_model(request, obj, form, change)
def password_status(self, obj):
"""Статус пароля"""
if obj.access_password_hash:
return format_html(
'<span style="color: green;">✓ Установлен</span>'
)
return format_html(
'<span style="color: red;">✗ Не установлен</span>'
)
password_status.short_description = 'Статус пароля'
def password_hash_preview(self, obj):
"""Превью хеша пароля"""
if obj.access_password_hash:
hash_preview = obj.access_password_hash[:20] + '...' if len(obj.access_password_hash) > 20 else obj.access_password_hash
return format_html(
'<code style="background: #f8f9fa; padding: 2px 6px; border-radius: 3px;">{}</code>',
hash_preview
)
return 'Пароль не установлен'
password_hash_preview.short_description = 'Хеш пароля'
def has_add_permission(self, request):
"""Ограничиваем создание - должен быть только один экземпляр настроек"""
if SystemSettings.objects.exists():
return False
return True
def has_delete_permission(self, request, obj=None):
"""Запрещаем удаление системных настроек"""
return False
def get_queryset(self, request):
"""Возвращаем queryset"""
return super().get_queryset(request)
class Media:
css = {
'all': ('admin/css/custom_admin.css',)
}
@admin.register(RoomActivityLog)
class RoomActivityLogAdmin(admin.ModelAdmin):
"""
Админ-интерфейс для просмотра логов активности комнат
"""
list_display = ('room_id_short', 'action', 'participant_count', 'timestamp', 'ip_address')
list_filter = ('action', 'timestamp')
search_fields = ('room_id',)
readonly_fields = ('room_id', 'action', 'timestamp', 'participant_count', 'ip_address', 'user_agent_hash')
date_hierarchy = 'timestamp'
ordering = ('-timestamp',)
def room_id_short(self, obj):
"""Короткое отображение ID комнаты"""
if obj.room_id:
return f"{obj.room_id[:8]}..."
return 'N/A'
room_id_short.short_description = 'Room ID'
def has_add_permission(self, request):
"""Запрещаем ручное добавление логов"""
return False
def has_change_permission(self, request, obj=None):
"""Запрещаем изменение логов"""
return False
def has_delete_permission(self, request, obj=None):
"""Разрешаем удаление только суперпользователям"""
return request.user.is_superuser
# Дополнительные действия
actions = ['delete_old_logs']
def delete_old_logs(self, request, queryset):
"""Удаление старых логов"""
from datetime import datetime, timedelta
old_date = datetime.now() - timedelta(days=30)
count = RoomActivityLog.objects.filter(timestamp__lt=old_date).delete()[0]
self.message_user(request, f'Удалено {count} старых записей (старше 30 дней)')
delete_old_logs.short_description = 'Удалить логи старше 30 дней'
# Кастомизация админки
admin.site.site_header = 'Video Call Administration'
admin.site.site_title = 'Video Call Admin'
admin.site.index_title = 'Управление системой видеозвонков'

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class CoreConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.core'

View File

@@ -0,0 +1,44 @@
# Generated by Django 5.2.5 on 2025-09-01 07:37
from django.db import migrations, models
class Migration(migrations.Migration):
initial = True
dependencies = [
]
operations = [
migrations.CreateModel(
name='SystemSettings',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('access_password_hash', models.CharField(help_text='Hashed password for system access', max_length=255)),
('created_at', models.DateTimeField(auto_now_add=True)),
('updated_at', models.DateTimeField(auto_now=True)),
('is_active', models.BooleanField(default=True)),
],
options={
'verbose_name': 'System Settings',
'verbose_name_plural': 'System Settings',
},
),
migrations.CreateModel(
name='RoomActivityLog',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('room_id', models.CharField(db_index=True, max_length=36)),
('action', models.CharField(choices=[('created', 'Room Created'), ('joined', 'User Joined'), ('left', 'User Left'), ('expired', 'Room Expired'), ('deleted', 'Room Deleted')], max_length=20)),
('timestamp', models.DateTimeField(auto_now_add=True)),
('participant_count', models.PositiveIntegerField(default=0)),
('ip_address', models.GenericIPAddressField(blank=True, null=True)),
('user_agent_hash', models.CharField(blank=True, help_text='Hashed user agent for basic analytics', max_length=64, null=True)),
],
options={
'ordering': ['-timestamp'],
'indexes': [models.Index(fields=['room_id', '-timestamp'], name='core_roomac_room_id_3e3535_idx'), models.Index(fields=['action', '-timestamp'], name='core_roomac_action_9fbe2f_idx')],
},
),
]

View File

@@ -0,0 +1,17 @@
# Generated by Django 5.2.5 on 2025-09-01 08:30
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('core', '0001_initial'),
]
operations = [
migrations.AlterModelOptions(
name='roomactivitylog',
options={'ordering': ['-timestamp'], 'verbose_name': 'Room Activity Log', 'verbose_name_plural': 'Room Activity Logs'},
),
]

View File

View File

@@ -0,0 +1,92 @@
# core/models.py - Core system models for video call application
from django.db import models
from django.contrib.auth.hashers import make_password, check_password
from django.utils import timezone
class SystemSettings(models.Model):
"""
Model for storing system-wide settings like access password.
Follows singleton pattern - only one instance should exist.
"""
access_password_hash = models.CharField(
max_length=255,
help_text="Hashed password for system access"
)
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
is_active = models.BooleanField(default=True)
class Meta:
verbose_name = "System Settings"
verbose_name_plural = "System Settings"
def set_password(self, raw_password):
"""Set and hash the access password"""
self.access_password_hash = make_password(raw_password)
def check_password(self, raw_password):
"""Check if provided password matches stored hash"""
return check_password(raw_password, self.access_password_hash)
@classmethod
def get_settings(cls):
"""Get or create system settings instance"""
settings, created = cls.objects.get_or_create(
pk=1,
defaults={
'access_password_hash': make_password('admin123'),
'is_active': True
}
)
return settings
def save(self, *args, **kwargs):
"""Ensure only one settings instance exists"""
self.pk = 1
super().save(*args, **kwargs)
def delete(self, *args, **kwargs):
"""Prevent deletion of system settings"""
pass
def __str__(self):
return f"System Settings (Updated: {self.updated_at.strftime('%Y-%m-%d %H:%M')})"
class RoomActivityLog(models.Model):
"""
Model for logging room activities without storing personal data.
Used for analytics and system monitoring.
"""
ACTION_CHOICES = [
('created', 'Room Created'),
('joined', 'User Joined'),
('left', 'User Left'),
('expired', 'Room Expired'),
('deleted', 'Room Deleted'),
]
room_id = models.CharField(max_length=36, db_index=True) # UUID
action = models.CharField(max_length=20, choices=ACTION_CHOICES)
timestamp = models.DateTimeField(auto_now_add=True)
participant_count = models.PositiveIntegerField(default=0)
ip_address = models.GenericIPAddressField(null=True, blank=True)
user_agent_hash = models.CharField(
max_length=64,
null=True,
blank=True,
help_text="Hashed user agent for basic analytics"
)
class Meta:
ordering = ['-timestamp']
indexes = [
models.Index(fields=['room_id', '-timestamp']),
models.Index(fields=['action', '-timestamp']),
]
verbose_name = "Room Activity Log"
verbose_name_plural = "Room Activity Logs"
def __str__(self):
return f"{self.get_action_display()} - {self.room_id[:8]}... at {self.timestamp.strftime('%Y-%m-%d %H:%M:%S')}"

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

10
backend/apps/core/urls.py Normal file
View File

@@ -0,0 +1,10 @@
# core/urls.py - Core application URL patterns
from django.urls import path
from . import views
app_name = 'core'
urlpatterns = [
path('health/', views.health_check, name='health'),
path('csrf/', views.get_csrf_token, name='csrf'),
]

243
backend/apps/core/views.py Normal file
View File

@@ -0,0 +1,243 @@
# apps/core/views.py - Core application views including health check
from django.http import JsonResponse
from django.views.decorators.http import require_http_methods
from django.views.decorators.csrf import csrf_exempt, ensure_csrf_cookie
from django.utils import timezone
from django.db import connection
from django.core.cache import cache
from django.conf import settings
from django.middleware.csrf import get_token
import logging
import sys
logger = logging.getLogger(__name__)
@require_http_methods(["GET"])
@csrf_exempt
def health_check(request):
"""
Health check endpoint for monitoring system status.
Returns detailed health information about various components.
"""
try:
health_data = {
'status': 'healthy',
'timestamp': timezone.now().isoformat(),
'version': getattr(settings, 'VERSION', '1.0.0'),
'environment': 'production' if not settings.DEBUG else 'development',
'checks': {}
}
overall_status = True
# Database health check
try:
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
cursor.fetchone()
health_data['checks']['database'] = {
'status': 'healthy',
'message': 'Database connection successful'
}
except Exception as e:
logger.error(f"Database health check failed: {e}")
health_data['checks']['database'] = {
'status': 'unhealthy',
'message': f'Database connection failed: {str(e)}'
}
overall_status = False
# Redis/Cache health check
try:
cache.set('health_check', 'test', 30)
cached_value = cache.get('health_check')
if cached_value == 'test':
health_data['checks']['cache'] = {
'status': 'healthy',
'message': 'Cache (Redis) connection successful'
}
else:
raise Exception("Cache test failed")
except Exception as e:
logger.error(f"Cache health check failed: {e}")
health_data['checks']['cache'] = {
'status': 'unhealthy',
'message': f'Cache connection failed: {str(e)}'
}
overall_status = False
# System resources check
try:
import psutil
memory_usage = psutil.virtual_memory().percent
cpu_usage = psutil.cpu_percent(interval=1)
disk_usage = psutil.disk_usage('/').percent
health_data['checks']['system'] = {
'status': 'healthy' if memory_usage < 90 and cpu_usage < 90 and disk_usage < 90 else 'warning',
'memory_usage': f"{memory_usage}%",
'cpu_usage': f"{cpu_usage}%",
'disk_usage': f"{disk_usage}%"
}
if memory_usage > 95 or cpu_usage > 95 or disk_usage > 95:
overall_status = False
except ImportError:
health_data['checks']['system'] = {
'status': 'unavailable',
'message': 'psutil not installed - system metrics unavailable'
}
except Exception as e:
logger.error(f"System health check failed: {e}")
health_data['checks']['system'] = {
'status': 'unhealthy',
'message': f'System check failed: {str(e)}'
}
# WebSocket/Channels check
try:
from channels.layers import get_channel_layer
channel_layer = get_channel_layer()
if channel_layer:
health_data['checks']['websocket'] = {
'status': 'healthy',
'message': 'WebSocket layer available'
}
else:
raise Exception("Channel layer not configured")
except Exception as e:
logger.error(f"WebSocket health check failed: {e}")
health_data['checks']['websocket'] = {
'status': 'unhealthy',
'message': f'WebSocket check failed: {str(e)}'
}
overall_status = False
# Update overall status
health_data['status'] = 'healthy' if overall_status else 'unhealthy'
# Return appropriate HTTP status code
status_code = 200 if overall_status else 503
return JsonResponse(health_data, status=status_code)
except Exception as e:
logger.error(f"Health check endpoint failed: {e}")
return JsonResponse({
'status': 'error',
'message': 'Health check failed',
'error': str(e),
'timestamp': timezone.now().isoformat()
}, status=500)
@require_http_methods(["GET"])
@csrf_exempt
def system_info(request):
"""
System information endpoint for monitoring and debugging.
Only available in DEBUG mode or to authenticated users.
"""
# Security check - only allow in development or for authenticated requests
if not settings.DEBUG and not request.session.get('authenticated'):
return JsonResponse({'error': 'Access denied'}, status=403)
try:
system_data = {
'python_version': sys.version,
'django_version': __import__('django').get_version(),
'debug_mode': settings.DEBUG,
'timezone': str(timezone.get_current_timezone()),
'database_engine': settings.DATABASES['default']['ENGINE'],
'installed_apps': list(settings.INSTALLED_APPS),
'middleware': list(settings.MIDDLEWARE),
}
# Add more detailed info in DEBUG mode
if settings.DEBUG:
system_data.update({
'secret_key_length': len(settings.SECRET_KEY),
'allowed_hosts': settings.ALLOWED_HOSTS,
'static_url': settings.STATIC_URL,
'media_url': settings.MEDIA_URL,
})
return JsonResponse(system_data)
except Exception as e:
logger.error(f"System info endpoint failed: {e}")
return JsonResponse({
'error': 'Failed to retrieve system information',
'message': str(e)
}, status=500)
@require_http_methods(["GET"])
@csrf_exempt
def metrics(request):
"""
Basic metrics endpoint for monitoring.
"""
try:
from apps.core.models import RoomActivityLog
from datetime import timedelta
# Calculate basic metrics
now = timezone.now()
last_hour = now - timedelta(hours=1)
last_day = now - timedelta(days=1)
metrics_data = {
'timestamp': now.isoformat(),
'rooms': {
'created_last_hour': RoomActivityLog.objects.filter(
action='created',
timestamp__gte=last_hour
).count(),
'created_last_day': RoomActivityLog.objects.filter(
action='created',
timestamp__gte=last_day
).count(),
'total_created': RoomActivityLog.objects.filter(
action='created'
).count(),
},
'users': {
'joined_last_hour': RoomActivityLog.objects.filter(
action='joined',
timestamp__gte=last_hour
).count(),
'joined_last_day': RoomActivityLog.objects.filter(
action='joined',
timestamp__gte=last_day
).count(),
}
}
return JsonResponse(metrics_data)
except Exception as e:
logger.error(f"Metrics endpoint failed: {e}")
return JsonResponse({
'error': 'Failed to retrieve metrics',
'message': str(e)
}, status=500)
@ensure_csrf_cookie
@require_http_methods(["GET"])
def get_csrf_token(request):
"""Get CSRF token for API requests"""
token = get_token(request)
return JsonResponse({'csrfToken': token})
def get_client_ip(request):
"""Extract client IP address from request"""
x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
if x_forwarded_for:
ip = x_forwarded_for.split(',')[0].strip()
else:
ip = request.META.get('REMOTE_ADDR')
return ip

View File

View File

@@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class RoomsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'apps.rooms'

View File

@@ -0,0 +1,319 @@
# rooms/consumers.py - WebSocket consumer for WebRTC signaling
import json
import logging
from channels.generic.websocket import AsyncWebsocketConsumer
from channels.db import database_sync_to_async
from django.utils import timezone
from apps.rooms.models import RoomManager
logger = logging.getLogger(__name__)
class VideoCallConsumer(AsyncWebsocketConsumer):
"""
WebSocket consumer for handling WebRTC signaling and room management.
Implements secure peer-to-peer connection establishment.
"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.room_id = None
self.room_group_name = None
self.participant_id = None
async def connect(self):
"""Handle WebSocket connection"""
try:
# Extract room ID from URL
self.room_id = self.scope['url_route']['kwargs']['room_id']
self.room_group_name = f'room_{self.room_id}'
# Get participant ID from session or generate one
session = self.scope.get('session', {})
self.participant_id = session.get('session_key') or f'temp_{timezone.now().timestamp()}'
# Verify room exists and user can join
room_data = await self.get_room_data(self.room_id)
if not room_data:
await self.close(code=4004) # Room not found
return
# Check room capacity
participants = room_data.get('participants', [])
max_participants = room_data.get('max_participants', 2)
if len(participants) >= max_participants and self.participant_id not in participants:
await self.close(code=4003) # Room is full
return
# Join the room group
await self.channel_layer.group_add(
self.room_group_name,
self.channel_name
)
# Accept the WebSocket connection
await self.accept()
# Notify other participants about new user
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'user_joined',
'participant_id': self.participant_id,
'timestamp': timezone.now().isoformat()
}
)
logger.info(f"User {self.participant_id} connected to room {self.room_id}")
except Exception as e:
logger.error(f"WebSocket connection error: {e}")
await self.close(code=4000)
async def disconnect(self, close_code):
"""Handle WebSocket disconnection"""
try:
if self.room_group_name and self.participant_id:
# Notify other participants about user leaving
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'user_left',
'participant_id': self.participant_id,
'timestamp': timezone.now().isoformat()
}
)
# Remove user from room group
await self.channel_layer.group_discard(
self.room_group_name,
self.channel_name
)
# Update room data
await self.leave_room(self.room_id, self.participant_id)
logger.info(f"User {self.participant_id} disconnected from room {self.room_id}")
except Exception as e:
logger.error(f"WebSocket disconnect error: {e}")
async def receive(self, text_data):
"""Handle incoming WebSocket messages"""
try:
data = json.loads(text_data)
message_type = data.get('type')
# Validate message structure
if not message_type:
await self.send_error('Message type is required')
return
# Handle different message types
if message_type == 'offer':
await self.handle_webrtc_offer(data)
elif message_type == 'answer':
await self.handle_webrtc_answer(data)
elif message_type == 'ice_candidate':
await self.handle_ice_candidate(data)
elif message_type == 'ping':
await self.handle_ping()
elif message_type == 'media_state':
await self.handle_media_state(data)
else:
await self.send_error(f'Unknown message type: {message_type}')
except json.JSONDecodeError:
await self.send_error('Invalid JSON format')
except Exception as e:
logger.error(f"WebSocket receive error: {e}")
await self.send_error('Message processing failed')
async def handle_webrtc_offer(self, data):
"""Handle WebRTC offer from peer"""
try:
target_participant = data.get('target')
offer = data.get('offer')
if not offer:
await self.send_error('Offer data is required')
return
# Forward offer to target participant or broadcast to room
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'webrtc_offer',
'offer': offer,
'sender': self.participant_id,
'target': target_participant,
'timestamp': timezone.now().isoformat()
}
)
except Exception as e:
logger.error(f"WebRTC offer handling error: {e}")
await self.send_error('Failed to process offer')
async def handle_webrtc_answer(self, data):
"""Handle WebRTC answer from peer"""
try:
target_participant = data.get('target')
answer = data.get('answer')
if not answer:
await self.send_error('Answer data is required')
return
# Forward answer to target participant
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'webrtc_answer',
'answer': answer,
'sender': self.participant_id,
'target': target_participant,
'timestamp': timezone.now().isoformat()
}
)
except Exception as e:
logger.error(f"WebRTC answer handling error: {e}")
await self.send_error('Failed to process answer')
async def handle_ice_candidate(self, data):
"""Handle ICE candidate exchange"""
try:
target_participant = data.get('target')
candidate = data.get('candidate')
if not candidate:
await self.send_error('ICE candidate data is required')
return
# Forward ICE candidate to target participant
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'ice_candidate',
'candidate': candidate,
'sender': self.participant_id,
'target': target_participant,
'timestamp': timezone.now().isoformat()
}
)
except Exception as e:
logger.error(f"ICE candidate handling error: {e}")
await self.send_error('Failed to process ICE candidate')
async def handle_ping(self):
"""Handle ping message for connection health check"""
await self.send(text_data=json.dumps({
'type': 'pong',
'timestamp': timezone.now().isoformat()
}))
async def handle_media_state(self, data):
"""Handle media state changes (mute/unmute, video on/off)"""
try:
media_state = data.get('state', {})
# Broadcast media state to other participants
await self.channel_layer.group_send(
self.room_group_name,
{
'type': 'media_state_update',
'participant_id': self.participant_id,
'state': media_state,
'timestamp': timezone.now().isoformat()
}
)
except Exception as e:
logger.error(f"Media state handling error: {e}")
await self.send_error('Failed to process media state')
# Group message handlers
async def user_joined(self, event):
"""Send user joined notification"""
if event['participant_id'] != self.participant_id:
await self.send(text_data=json.dumps({
'type': 'user_joined',
'participant_id': event['participant_id'],
'timestamp': event['timestamp']
}))
async def user_left(self, event):
"""Send user left notification"""
if event['participant_id'] != self.participant_id:
await self.send(text_data=json.dumps({
'type': 'user_left',
'participant_id': event['participant_id'],
'timestamp': event['timestamp']
}))
async def webrtc_offer(self, event):
"""Forward WebRTC offer to client"""
# Only send to target participant or broadcast if no target specified
if not event.get('target') or event['target'] == self.participant_id:
if event['sender'] != self.participant_id:
await self.send(text_data=json.dumps({
'type': 'webrtc_offer',
'offer': event['offer'],
'sender': event['sender'],
'timestamp': event['timestamp']
}))
async def webrtc_answer(self, event):
"""Forward WebRTC answer to client"""
if event.get('target') == self.participant_id:
await self.send(text_data=json.dumps({
'type': 'webrtc_answer',
'answer': event['answer'],
'sender': event['sender'],
'timestamp': event['timestamp']
}))
async def ice_candidate(self, event):
"""Forward ICE candidate to client"""
# Only send to target participant or broadcast if no target specified
if not event.get('target') or event['target'] == self.participant_id:
if event['sender'] != self.participant_id:
await self.send(text_data=json.dumps({
'type': 'ice_candidate',
'candidate': event['candidate'],
'sender': event['sender'],
'timestamp': event['timestamp']
}))
async def media_state_update(self, event):
"""Forward media state update to client"""
if event['participant_id'] != self.participant_id:
await self.send(text_data=json.dumps({
'type': 'media_state_update',
'participant_id': event['participant_id'],
'state': event['state'],
'timestamp': event['timestamp']
}))
# Helper methods
async def send_error(self, error_message):
"""Send error message to client"""
await self.send(text_data=json.dumps({
'type': 'error',
'message': error_message,
'timestamp': timezone.now().isoformat()
}))
@database_sync_to_async
def get_room_data(self, room_id):
"""Get room data from Redis"""
return RoomManager.get_room_by_id(room_id)
@database_sync_to_async
def leave_room(self, room_id, participant_id):
"""Remove participant from room"""
return RoomManager.leave_room(room_id, participant_id)

View File

@@ -0,0 +1,223 @@
# rooms/models.py - Room management models
import uuid
import string
import secrets
from datetime import timedelta
from django.conf import settings
from django.utils import timezone
class RoomManager:
"""
Manager class for room operations using Redis for temporary storage.
Implements all CRUD operations for video call rooms.
"""
@staticmethod
def _get_redis_client():
"""Get Redis client instance"""
from django.core.cache import cache
return cache
@classmethod
def generate_short_code(cls, length=None):
"""Generate a unique short code for room access"""
length = length or getattr(settings, 'SHORT_CODE_LENGTH', 6)
characters = string.ascii_uppercase + string.digits
# Ensure uniqueness by checking existing codes
cache = cls._get_redis_client()
max_attempts = 100
for _ in range(max_attempts):
code = ''.join(secrets.choice(characters) for _ in range(length))
if not cache.get(f'room_code_{code}'):
return code
raise ValueError("Unable to generate unique short code")
@classmethod
def create_room(cls, creator_ip=None):
"""Create a new video call room"""
cache = cls._get_redis_client()
room_data = {
'room_id': str(uuid.uuid4()),
'short_code': cls.generate_short_code(),
'created_at': timezone.now().isoformat(),
'participants': [],
'is_active': True,
'expires_at': (
timezone.now() +
timedelta(hours=getattr(settings, 'ROOM_EXPIRY_HOURS', 24))
).isoformat(),
'creator_ip': creator_ip,
'max_participants': getattr(settings, 'MAX_PARTICIPANTS_PER_ROOM', 2)
}
# Store room data with expiration
cache.set(
f'room_{room_data["room_id"]}',
room_data,
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
)
# Create code mapping for easy lookup
cache.set(
f'room_code_{room_data["short_code"]}',
room_data["room_id"],
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
)
# Log room creation
from apps.core.models import RoomActivityLog
RoomActivityLog.objects.create(
room_id=room_data["room_id"],
action='created',
ip_address=creator_ip
)
return room_data
@classmethod
def get_room_by_id(cls, room_id):
"""Retrieve room data by room ID"""
cache = cls._get_redis_client()
return cache.get(f'room_{room_id}')
@classmethod
def get_room_by_code(cls, short_code):
"""Retrieve room data by short code"""
cache = cls._get_redis_client()
room_id = cache.get(f'room_code_{short_code}')
if room_id:
return cls.get_room_by_id(room_id)
return None
@classmethod
def join_room(cls, room_identifier, participant_id, participant_ip=None):
"""
Add participant to room.
room_identifier can be either room_id or short_code
"""
cache = cls._get_redis_client()
# Try to get room by ID first, then by code
room_data = cls.get_room_by_id(room_identifier)
if not room_data:
room_data = cls.get_room_by_code(room_identifier)
if not room_data:
return None, "Room not found"
# Check if room is active and not expired
if not room_data.get('is_active', False):
return None, "Room is not active"
expires_at = timezone.datetime.fromisoformat(
room_data['expires_at'].replace('Z', '+00:00')
)
if timezone.now() > expires_at:
cls.delete_room(room_data['room_id'])
return None, "Room has expired"
# Check participant limit
current_participants = room_data.get('participants', [])
max_participants = room_data.get('max_participants', 2)
if len(current_participants) >= max_participants:
return None, "Room is full"
# Add participant if not already in room
if participant_id not in current_participants:
current_participants.append(participant_id)
room_data['participants'] = current_participants
# Update room data
cache.set(
f'room_{room_data["room_id"]}',
room_data,
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
)
# Log participant join
from apps.core.models import RoomActivityLog
RoomActivityLog.objects.create(
room_id=room_data["room_id"],
action='joined',
participant_count=len(current_participants),
ip_address=participant_ip
)
return room_data, "Successfully joined room"
@classmethod
def leave_room(cls, room_id, participant_id):
"""Remove participant from room"""
cache = cls._get_redis_client()
room_data = cls.get_room_by_id(room_id)
if not room_data:
return False
participants = room_data.get('participants', [])
if participant_id in participants:
participants.remove(participant_id)
room_data['participants'] = participants
# Update room data
cache.set(
f'room_{room_id}',
room_data,
timeout=getattr(settings, 'ROOM_EXPIRY_HOURS', 24) * 3600
)
# Log participant leave
from apps.core.models import RoomActivityLog
RoomActivityLog.objects.create(
room_id=room_id,
action='left',
participant_count=len(participants)
)
# Delete room if no participants left
if not participants:
cls.delete_room(room_id)
return True
return False
@classmethod
def delete_room(cls, room_id):
"""Delete room and clean up all associated data"""
cache = cls._get_redis_client()
room_data = cls.get_room_by_id(room_id)
if room_data:
# Remove code mapping
short_code = room_data.get('short_code')
if short_code:
cache.delete(f'room_code_{short_code}')
# Remove room data
cache.delete(f'room_{room_id}')
# Log room deletion
from apps.core.models import RoomActivityLog
RoomActivityLog.objects.create(
room_id=room_id,
action='deleted'
)
return True
return False
@classmethod
def cleanup_expired_rooms(cls):
"""Clean up expired rooms (to be called by scheduled task)"""
# This would typically be implemented as a management command
# or scheduled task using Django-Q or Celery
pass

View File

@@ -0,0 +1,7 @@
# rooms/routing.py - WebSocket URL routing
from django.urls import path
from apps.rooms import consumers
websocket_urlpatterns = [
path('ws/room/<str:room_id>/', consumers.VideoCallConsumer.as_asgi()),
]

View File

@@ -0,0 +1,3 @@
from django.test import TestCase
# Create your tests here.

View File

@@ -0,0 +1,14 @@
# rooms/urls.py - Room management URL patterns
from django.urls import path
from . import views
app_name = 'rooms'
urlpatterns = [
path('create/', views.create_room, name='create'),
path('join/', views.join_room, name='join'),
path('<str:room_id>/', views.get_room, name='get'),
path('<str:room_id>/leave/', views.leave_room, name='leave'),
path('<str:room_id>/delete/', views.delete_room, name='delete'),
]

293
backend/apps/rooms/views.py Normal file
View File

@@ -0,0 +1,293 @@
# rooms/views.py - Room management API views
from django.utils import timezone
from django.views.decorators.csrf import csrf_exempt
from rest_framework.decorators import api_view, permission_classes
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework import status
from django_ratelimit.decorators import ratelimit
from apps.rooms.models import RoomManager
from apps.core.views import get_client_ip
import qrcode
import io
import base64
import logging
logger = logging.getLogger(__name__)
def require_auth(view_func):
"""Decorator to require authentication for views"""
def wrapper(request, *args, **kwargs):
if not request.session.get('authenticated'):
logger.warning(f"Unauthenticated access attempt to {request.path}")
return Response(
{'error': 'Authentication required'},
status=status.HTTP_401_UNAUTHORIZED
)
return view_func(request, *args, **kwargs)
return wrapper
@api_view(['POST'])
@permission_classes([AllowAny])
@csrf_exempt
@require_auth
@ratelimit(key='ip', rate='30/min', method='POST', block=True)
def create_room(request):
"""Create a new video call room"""
try:
client_ip = get_client_ip(request)
logger.info(f"Creating room for IP: {client_ip}")
room_data = RoomManager.create_room(creator_ip=client_ip)
# Generate QR code for the room
room_url = f"{request.build_absolute_uri('/')}join/{room_data['short_code']}"
qr = qrcode.QRCode(version=1, box_size=10, border=5)
qr.add_data(room_url)
qr.make(fit=True)
qr_image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
qr_image.save(buffer, format='PNG')
qr_code_data = base64.b64encode(buffer.getvalue()).decode()
response_data = {
'room_id': room_data['room_id'],
'short_code': room_data['short_code'],
'room_url': room_url,
'qr_code': f"data:image/png;base64,{qr_code_data}",
'expires_at': room_data['expires_at'],
'max_participants': room_data['max_participants']
}
logger.info(f"Room created successfully: {room_data['room_id']}")
return Response(response_data, status=status.HTTP_201_CREATED)
except Exception as e:
logger.error(f"Room creation failed: {e}")
return Response(
{'error': 'Failed to create room'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['GET'])
@permission_classes([AllowAny])
@csrf_exempt
@require_auth
def get_room(request, room_id):
"""Get room information by room ID"""
try:
logger.info(f"Getting room info for: {room_id}")
room_data = RoomManager.get_room_by_id(room_id)
if not room_data:
logger.warning(f"Room not found: {room_id}")
return Response(
{'error': 'Room not found'},
status=status.HTTP_404_NOT_FOUND
)
# Check if room has expired
expires_at = timezone.datetime.fromisoformat(
room_data['expires_at'].replace('Z', '+00:00')
)
if timezone.now() > expires_at:
logger.info(f"Room expired, deleting: {room_id}")
RoomManager.delete_room(room_id)
return Response(
{'error': 'Room has expired'},
status=status.HTTP_404_NOT_FOUND
)
response_data = {
'room_id': room_data['room_id'],
'short_code': room_data['short_code'],
'is_active': room_data['is_active'],
'participant_count': len(room_data.get('participants', [])),
'max_participants': room_data.get('max_participants', 2),
'expires_at': room_data['expires_at']
}
logger.info(f"Room info retrieved: {room_id}, participants: {len(room_data.get('participants', []))}")
return Response(response_data)
except Exception as e:
logger.error(f"Failed to retrieve room {room_id}: {e}")
return Response(
{'error': 'Failed to retrieve room'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
@permission_classes([AllowAny])
@csrf_exempt
@require_auth
@ratelimit(key='ip', rate='60/min', method='POST', block=True)
def join_room(request):
"""Join a room by room ID or short code"""
try:
room_identifier = request.data.get('room_identifier')
# Ensure we have a session
if not request.session.session_key:
request.session.create()
participant_id = request.session.session_key
if not room_identifier:
return Response(
{'error': 'Room identifier is required'},
status=status.HTTP_400_BAD_REQUEST
)
logger.info(f"Joining room: {room_identifier} with participant: {participant_id}")
client_ip = get_client_ip(request)
room_data, message = RoomManager.join_room(
room_identifier,
participant_id,
client_ip
)
if not room_data:
logger.warning(f"Failed to join room: {room_identifier}, reason: {message}")
return Response(
{'error': message},
status=status.HTTP_400_BAD_REQUEST
)
response_data = {
'success': True,
'message': message,
'room_id': room_data['room_id'],
'short_code': room_data['short_code'],
'participant_count': len(room_data.get('participants', [])),
'participant_id': participant_id
}
logger.info(f"User joined room: {room_data['room_id']}, participant: {participant_id}")
return Response(response_data)
except Exception as e:
logger.error(f"Failed to join room: {e}")
return Response(
{'error': 'Failed to join room'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['POST'])
@permission_classes([AllowAny])
@csrf_exempt
@require_auth
def leave_room(request, room_id):
"""Leave a room"""
try:
# Ensure we have a session
if not request.session.session_key:
logger.warning(f"No session key when trying to leave room: {room_id}")
return Response(
{'success': True, # Return success even if no session, as user wasn't in room anyway
'message': 'No active session found'})
participant_id = request.session.session_key
logger.info(f"Leaving room: {room_id} with participant: {participant_id}")
# Check if room exists first
room_data = RoomManager.get_room_by_id(room_id)
if not room_data:
logger.info(f"Room {room_id} not found when trying to leave")
return Response({
'success': True, # Return success as room doesn't exist anyway
'message': 'Room not found'
})
# Check if participant is actually in the room
participants = room_data.get('participants', [])
if participant_id not in participants:
logger.info(f"Participant {participant_id} not in room {room_id}")
return Response({
'success': True, # Return success as user wasn't in room anyway
'message': 'Not in room'
})
success = RoomManager.leave_room(room_id, participant_id)
if success:
logger.info(f"User left room: {room_id}, participant: {participant_id}")
return Response({
'success': True,
'message': 'Left room successfully'
})
else:
logger.warning(f"Failed to leave room: {room_id}, participant: {participant_id}")
return Response({
'success': True, # Still return success to avoid client errors
'message': 'Room leave processed'
})
except Exception as e:
logger.error(f"Failed to leave room {room_id}: {e}")
return Response(
{'error': 'Failed to leave room'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['DELETE'])
@permission_classes([AllowAny])
@csrf_exempt
@require_auth
def delete_room(request, room_id):
"""Delete a room (only creator or admin can delete)"""
try:
logger.info(f"Deleting room: {room_id}")
# Check if room exists
room_data = RoomManager.get_room_by_id(room_id)
if not room_data:
return Response(
{'error': 'Room not found'},
status=status.HTTP_404_NOT_FOUND
)
# Additional permission check could be added here
success = RoomManager.delete_room(room_id)
if success:
logger.info(f"Room deleted: {room_id}")
return Response({
'success': True,
'message': 'Room deleted successfully'
})
else:
return Response(
{'error': 'Failed to delete room'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
except Exception as e:
logger.error(f"Failed to delete room {room_id}: {e}")
return Response(
{'error': 'Failed to delete room'},
status=status.HTTP_500_INTERNAL_SERVER_ERROR
)
@api_view(['GET'])
@permission_classes([AllowAny])
@csrf_exempt
@require_auth
def health_check(request):
"""API health check endpoint"""
return Response({
'status': 'healthy',
'timestamp': timezone.now().isoformat(),
'authenticated': True,
'session_key': request.session.session_key
})

22
backend/manage.py Executable file
View File

@@ -0,0 +1,22 @@
#!/usr/bin/env python
"""Django's command-line utility for administrative tasks."""
import os
import sys
def main():
"""Run administrative tasks."""
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
try:
from django.core.management import execute_from_command_line
except ImportError as exc:
raise ImportError(
"Couldn't import Django. Are you sure it's installed and "
"available on your PYTHONPATH environment variable? Did you "
"forget to activate a virtual environment?"
) from exc
execute_from_command_line(sys.argv)
if __name__ == '__main__':
main()

23
backend/requirements.txt Normal file
View File

@@ -0,0 +1,23 @@
asgiref==3.9.1
cffi==1.17.1
channels==4.0.0
channels-redis==4.1.0
cryptography==41.0.7
Django==5.2.5
django-cors-headers==4.3.1
django-ratelimit==4.1.0
django-redis==5.4.0
djangorestframework==3.14.0
msgpack==1.1.1
pillow==11.3.0
psycopg2-binary==2.9.10
pycparser==2.22
pypng==0.20220715.0
python-decouple==3.8
pytz==2025.2
qrcode==7.4.2
redis==5.0.1
sqlparse==0.5.3
typing_extensions==4.15.0
uuid==1.30
websockets==12.0

72
backend/run_server.py Normal file
View File

@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Server startup script with WebSocket support
Use this instead of 'python manage.py runserver' for development
"""
import os
import sys
import django
from pathlib import Path
# Add the project directory to Python path
BASE_DIR = Path(__file__).resolve().parent
sys.path.insert(0, str(BASE_DIR))
# Set Django settings module
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
# Setup Django
django.setup()
def check_daphne_installed():
"""Check if Daphne is installed"""
try:
import daphne
return True
except ImportError:
return False
def run_with_daphne():
"""Run server with Daphne (supports WebSocket)"""
print("🚀 Starting server with Daphne (WebSocket support enabled)")
print(" Backend: http://localhost:8000")
print(" WebSocket: ws://localhost:8000/ws/")
print(" Press Ctrl+C to stop")
print()
os.system('daphne -b 0.0.0.0 -p 8000 videocall_app.asgi:application')
def run_with_runserver():
"""Run server with standard runserver (no WebSocket support)"""
print("⚠️ Running with standard Django runserver")
print(" WebSocket connections will NOT work!")
print(" Install Daphne for WebSocket support: pip install daphne")
print(" Backend: http://localhost:8000")
print(" Press Ctrl+C to stop")
print()
os.system('python manage.py runserver')
def main():
"""Main function"""
print("🔌 Video Call Application Server")
print("=" * 40)
if check_daphne_installed():
run_with_daphne()
else:
print("❌ Daphne not found - WebSocket support disabled")
print(" Install it with: pip install daphne")
print(" Or install from requirements: pip install -r requirements.txt")
print()
response = input("Continue with standard runserver? (y/N): ")
if response.lower() == 'y':
run_with_runserver()
else:
print("Please install Daphne and try again.")
sys.exit(1)
if __name__ == "__main__":
main()

View File

View File

@@ -0,0 +1,20 @@
# videocall_app/asgi.py - ASGI configuration for WebSocket support
import os
from channels.auth import AuthMiddlewareStack
from channels.routing import ProtocolTypeRouter, URLRouter
from channels.security.websocket import AllowedHostsOriginValidator
from django.core.asgi import get_asgi_application
from apps.rooms.routing import websocket_urlpatterns
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
django_asgi_app = get_asgi_application()
application = ProtocolTypeRouter({
"http": django_asgi_app,
"websocket": AllowedHostsOriginValidator(
AuthMiddlewareStack(
URLRouter(websocket_urlpatterns)
)
),
})

View File

@@ -0,0 +1,303 @@
# videocall_app/settings.py - Django main settings configuration
import os
from decouple import config
from pathlib import Path
BASE_DIR = Path(__file__).resolve().parent.parent
# Security settings
SECRET_KEY = config('SECRET_KEY', default='your-secret-key-here-change-in-production')
DEBUG = config('DEBUG', default=False, cast=bool)
# ALLOWED_HOSTS - только ваши настоящие домены
allowed_hosts_default = 'yourdomain.com,www.yourdomain.com,localhost,127.0.0.1'
ALLOWED_HOSTS = config('ALLOWED_HOSTS', default=allowed_hosts_default).split(',')
# Убираем пустые строки и пробелы
ALLOWED_HOSTS = [host.strip() for host in ALLOWED_HOSTS if host.strip()]
# В DEBUG режиме разрешаем localhost для разработки
if DEBUG:
ALLOWED_HOSTS.extend(['localhost', '127.0.0.1', '0.0.0.0'])
# Application definition
DJANGO_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
]
THIRD_PARTY_APPS = [
'rest_framework',
'corsheaders',
'channels',
'django_ratelimit',
]
LOCAL_APPS = [
'apps.core',
'apps.rooms',
'apps.authentication',
]
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
MIDDLEWARE = [
'corsheaders.middleware.CorsMiddleware',
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
]
ROOT_URLCONF = 'videocall_app.urls'
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
],
},
},
]
WSGI_APPLICATION = 'videocall_app.wsgi.application'
ASGI_APPLICATION = 'videocall_app.asgi.application'
# Database configuration
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.postgresql',
'NAME': config('DB_NAME', default='videocall_db'),
'USER': config('DB_USER', default='postgres'),
'PASSWORD': config('DB_PASSWORD', default=''),
'HOST': config('DB_HOST', default='localhost'),
'PORT': config('DB_PORT', default='5432'),
}
}
# Redis configuration
REDIS_URL = config('REDIS_URL', default='redis://localhost:6379/0')
# Channels configuration for WebSockets
CHANNEL_LAYERS = {
'default': {
'BACKEND': 'channels_redis.core.RedisChannelLayer',
'CONFIG': {
'hosts': [REDIS_URL],
},
},
}
# Cache configuration
CACHES = {
'default': {
'BACKEND': 'django_redis.cache.RedisCache',
'LOCATION': REDIS_URL,
'OPTIONS': {
'CLIENT_CLASS': 'django_redis.client.DefaultClient',
}
}
}
# Session configuration
SESSION_ENGINE = 'django.contrib.sessions.backends.cache'
SESSION_CACHE_ALIAS = 'default'
SESSION_COOKIE_AGE = 86400 # 24 hours
SESSION_EXPIRE_AT_BROWSER_CLOSE = True
SESSION_SAVE_EVERY_REQUEST = True
SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SECURE = not DEBUG # Use secure cookies in production
SESSION_COOKIE_SAMESITE = 'Lax'
# CSRF Configuration
CSRF_COOKIE_HTTPONLY = False # Allow JavaScript to read CSRF token
CSRF_COOKIE_SECURE = not DEBUG # Use secure cookies in production
CSRF_COOKIE_SAMESITE = 'Lax'
CSRF_TRUSTED_ORIGINS = [
'http://localhost:3000',
'http://127.0.0.1:3000',
]
if not DEBUG:
# Add your production domains
CSRF_TRUSTED_ORIGINS.extend([
'https://yourdomain.com',
'https://www.yourdomain.com',
])
# REST Framework configuration
REST_FRAMEWORK = {
'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework.authentication.SessionAuthentication',
],
'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny', # Changed to allow custom auth
],
'DEFAULT_THROTTLE_CLASSES': [
'rest_framework.throttling.AnonRateThrottle',
'rest_framework.throttling.UserRateThrottle'
],
'DEFAULT_THROTTLE_RATES': {
'anon': '100/hour',
'user': '1000/hour'
}
}
# CORS settings
CORS_ALLOWED_ORIGINS = config(
'CORS_ALLOWED_ORIGINS',
default='http://localhost:3000,http://127.0.0.1:3000'
).split(',')
CORS_ALLOW_CREDENTIALS = True
CORS_ALLOW_ALL_ORIGINS = DEBUG # Only in development
# WebSocket origins for Channels
ALLOWED_HOSTS_INCLUDE_WEBSOCKET = True
# Additional CORS headers for development
if DEBUG:
CORS_ALLOW_HEADERS = [
'accept',
'accept-encoding',
'authorization',
'content-type',
'dnt',
'origin',
'user-agent',
'x-csrftoken',
'x-requested-with',
]
# Django Ratelimit settings
RATELIMIT_USE_CACHE = 'default'
RATELIMIT_ENABLE = True
# В файле backend/videocall_app/settings.py замените секцию LOGGING на:
# Logging configuration
import os
# Создаем директорию для логов если её нет
LOGS_DIR = BASE_DIR / 'logs'
LOGS_DIR.mkdir(exist_ok=True)
LOGGING = {
'version': 1,
'disable_existing_loggers': False,
'formatters': {
'verbose': {
'format': '[{levelname}] {asctime} {name}: {message}',
'style': '{',
},
'simple': {
'format': '{levelname} {message}',
'style': '{',
},
},
'handlers': {
'console': {
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
'file': {
'class': 'logging.FileHandler',
'filename': LOGS_DIR / 'django.log',
'formatter': 'verbose',
} if os.access(LOGS_DIR, os.W_OK) else {
# Fallback to console if can't write to file
'class': 'logging.StreamHandler',
'formatter': 'verbose',
},
},
'root': {
'handlers': ['console'],
'level': 'INFO',
},
'loggers': {
'django': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
'apps.authentication': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
'apps.rooms': {
'handlers': ['console'],
'level': 'INFO',
'propagate': False,
},
},
}
# Internationalization
LANGUAGE_CODE = 'en-us'
TIME_ZONE = 'UTC'
USE_I18N = True
USE_TZ = True
# Static files (CSS, JavaScript, Images)
STATIC_URL = '/static/'
STATIC_ROOT = BASE_DIR / 'staticfiles'
# Additional locations of static files
STATICFILES_DIRS = []
if (BASE_DIR / 'static').exists():
STATICFILES_DIRS.append(BASE_DIR / 'static')
# Static files finders
STATICFILES_FINDERS = [
'django.contrib.staticfiles.finders.FileSystemFinder',
'django.contrib.staticfiles.finders.AppDirectoriesFinder',
]
# Media files
MEDIA_URL = '/media/'
MEDIA_ROOT = BASE_DIR / 'media'
# Application-specific settings
ROOM_EXPIRY_HOURS = 24
MAX_PARTICIPANTS_PER_ROOM = 2
SHORT_CODE_LENGTH = 6
# Default primary key field type
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
# Security settings for production
if not DEBUG:
# Trust proxy headers from nginx
USE_X_FORWARDED_HOST = True
USE_X_FORWARDED_PORT = True
SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
# ВАЖНО: НЕ ВКЛЮЧАЕМ принудительное перенаправление на HTTPS
# так как это делает nginx
SECURE_SSL_REDIRECT = False
# Остальные настройки безопасности
SECURE_BROWSER_XSS_FILTER = True
SECURE_CONTENT_TYPE_NOSNIFF = True
SECURE_HSTS_INCLUDE_SUBDOMAINS = True
SECURE_HSTS_SECONDS = 31536000
SESSION_COOKIE_SECURE = True
CSRF_COOKIE_SECURE = True
# Дополнительные заголовки безопасности
SECURE_REFERRER_POLICY = 'same-origin'

View File

@@ -0,0 +1,17 @@
# videocall_app/urls.py - Main URL configuration
from django.contrib import admin
from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
urlpatterns = [
path('admin/', admin.site.urls),
path('api/auth/', include('apps.authentication.urls')),
path('api/rooms/', include('apps.rooms.urls')),
path('api/', include('apps.core.urls')),
]
# Serve media files in development
if settings.DEBUG:
urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)
urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT)

View File

@@ -0,0 +1,16 @@
"""
WSGI config for videocall_app project.
It exposes the WSGI callable as a module-level variable named ``application``.
For more information on this file, see
https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
"""
import os
from django.core.wsgi import get_wsgi_application
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'videocall_app.settings')
application = get_wsgi_application()