Initial commit - SoundWave v1.0
- Full PWA support with offline capabilities - Comprehensive search across songs, playlists, and channels - Offline playlist manager with download tracking - Pre-built frontend for zero-build deployment - Docker-based deployment with docker compose - Material-UI dark theme interface - YouTube audio download and management - Multi-user authentication support
This commit is contained in:
commit
51679d1943
254 changed files with 37281 additions and 0 deletions
591
backend/user/views.py
Normal file
591
backend/user/views.py
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
"""User API views"""
|
||||
|
||||
import os
|
||||
import mimetypes
|
||||
from pathlib import Path
|
||||
from django.contrib.auth import authenticate, login, logout
|
||||
from django.http import HttpResponse, FileResponse
|
||||
from rest_framework import status
|
||||
from rest_framework.authtoken.models import Token
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.parsers import MultiPartParser, FormParser
|
||||
from user.models import Account
|
||||
from user.serializers import (
|
||||
AccountSerializer,
|
||||
LoginSerializer,
|
||||
UserConfigSerializer,
|
||||
TwoFactorSetupSerializer,
|
||||
TwoFactorVerifySerializer,
|
||||
TwoFactorStatusSerializer,
|
||||
)
|
||||
from user.two_factor import (
|
||||
generate_totp_secret,
|
||||
get_totp_uri,
|
||||
generate_qr_code,
|
||||
verify_totp,
|
||||
generate_backup_codes,
|
||||
generate_backup_codes_pdf,
|
||||
)
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class UserAccountView(APIView):
|
||||
"""User account endpoint"""
|
||||
|
||||
def get(self, request):
|
||||
"""Get current user account"""
|
||||
user = request.user
|
||||
# Calculate current storage usage
|
||||
user.calculate_storage_usage()
|
||||
serializer = AccountSerializer(user)
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class UserProfileView(APIView):
|
||||
"""User profile management"""
|
||||
|
||||
def patch(self, request):
|
||||
"""Update user profile (username, email, first_name, last_name)"""
|
||||
user = request.user
|
||||
username = request.data.get('username')
|
||||
email = request.data.get('email')
|
||||
first_name = request.data.get('first_name')
|
||||
last_name = request.data.get('last_name')
|
||||
current_password = request.data.get('current_password', '').strip()
|
||||
|
||||
# At least one field must be provided
|
||||
if not username and not email and first_name is None and last_name is None:
|
||||
return Response(
|
||||
{'error': 'At least one field (username, email, first_name, last_name) must be provided'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Password is required to change username or email (security critical fields)
|
||||
if (username or email) and not current_password:
|
||||
return Response(
|
||||
{'error': 'Current password is required to change username or email'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Verify current password only if it's provided (for username/email changes)
|
||||
if current_password and not user.check_password(current_password):
|
||||
return Response(
|
||||
{'error': 'Current password is incorrect'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# Validate username
|
||||
if username:
|
||||
username = username.strip()
|
||||
if len(username) < 3:
|
||||
return Response(
|
||||
{'error': 'Username must be at least 3 characters long'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
if not username.isalnum() and '_' not in username:
|
||||
return Response(
|
||||
{'error': 'Username can only contain letters, numbers, and underscores'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
# Check if username is already taken
|
||||
if Account.objects.exclude(id=user.id).filter(username=username).exists():
|
||||
return Response(
|
||||
{'error': 'Username already taken'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Check if email is already taken
|
||||
if email and Account.objects.exclude(id=user.id).filter(email=email).exists():
|
||||
return Response(
|
||||
{'error': 'Email already in use'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Update fields
|
||||
updated_fields = []
|
||||
if username:
|
||||
user.username = username
|
||||
updated_fields.append('username')
|
||||
if email:
|
||||
user.email = email
|
||||
updated_fields.append('email')
|
||||
if first_name is not None:
|
||||
user.first_name = first_name
|
||||
updated_fields.append('name')
|
||||
if last_name is not None:
|
||||
user.last_name = last_name
|
||||
if 'name' not in updated_fields:
|
||||
updated_fields.append('name')
|
||||
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'message': f'{" and ".join(updated_fields).capitalize()} updated successfully',
|
||||
'user': AccountSerializer(user).data
|
||||
})
|
||||
|
||||
|
||||
class ChangePasswordView(APIView):
|
||||
"""Change user password"""
|
||||
|
||||
def post(self, request):
|
||||
"""Change password"""
|
||||
user = request.user
|
||||
current_password = request.data.get('current_password')
|
||||
new_password = request.data.get('new_password')
|
||||
|
||||
if not current_password or not new_password:
|
||||
return Response(
|
||||
{'error': 'Current and new password are required'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Verify current password
|
||||
if not user.check_password(current_password):
|
||||
return Response(
|
||||
{'error': 'Current password is incorrect'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# Validate new password length
|
||||
if len(new_password) < 8:
|
||||
return Response(
|
||||
{'error': 'Password must be at least 8 characters long'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Set new password
|
||||
user.set_password(new_password)
|
||||
user.save()
|
||||
|
||||
# Delete old token and create new one for security
|
||||
Token.objects.filter(user=user).delete()
|
||||
new_token = Token.objects.create(user=user)
|
||||
|
||||
return Response({
|
||||
'message': 'Password changed successfully',
|
||||
'token': new_token.key # Return new token so user stays logged in
|
||||
})
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
"""Login endpoint"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
"""Authenticate user"""
|
||||
serializer = LoginSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = authenticate(
|
||||
username=serializer.validated_data['username'],
|
||||
password=serializer.validated_data['password']
|
||||
)
|
||||
|
||||
if not user:
|
||||
return Response(
|
||||
{'error': 'Invalid credentials'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
# Check if 2FA is enabled
|
||||
if user.two_factor_enabled:
|
||||
two_factor_code = serializer.validated_data.get('two_factor_code')
|
||||
|
||||
if not two_factor_code:
|
||||
return Response({
|
||||
'requires_2fa': True,
|
||||
'message': 'Two-factor authentication required'
|
||||
}, status=status.HTTP_200_OK)
|
||||
|
||||
# Verify TOTP code
|
||||
if user.two_factor_secret and verify_totp(user.two_factor_secret, two_factor_code):
|
||||
pass # Code is valid, continue login
|
||||
# Check backup codes
|
||||
elif two_factor_code in user.backup_codes:
|
||||
# Remove used backup code
|
||||
user.backup_codes.remove(two_factor_code)
|
||||
user.save()
|
||||
else:
|
||||
return Response(
|
||||
{'error': 'Invalid two-factor code'},
|
||||
status=status.HTTP_401_UNAUTHORIZED
|
||||
)
|
||||
|
||||
login(request, user)
|
||||
token, _ = Token.objects.get_or_create(user=user)
|
||||
return Response({
|
||||
'token': token.key,
|
||||
'user': AccountSerializer(user).data
|
||||
})
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
"""Logout endpoint"""
|
||||
|
||||
def post(self, request):
|
||||
"""Logout user and delete token"""
|
||||
# Delete the user's token for security
|
||||
if request.user.is_authenticated:
|
||||
try:
|
||||
Token.objects.filter(user=request.user).delete()
|
||||
except Token.DoesNotExist:
|
||||
pass
|
||||
|
||||
logout(request)
|
||||
return Response({'message': 'Logged out successfully'})
|
||||
|
||||
|
||||
class RegisterView(APIView):
|
||||
"""
|
||||
Registration endpoint - DISABLED
|
||||
Public registration is not allowed. Only admins can create new users.
|
||||
"""
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
"""Block public registration"""
|
||||
from config.user_settings import ALLOW_PUBLIC_REGISTRATION
|
||||
|
||||
if not ALLOW_PUBLIC_REGISTRATION:
|
||||
return Response(
|
||||
{
|
||||
'error': 'Public registration is disabled',
|
||||
'message': 'New users can only be created by administrators. Please contact your system administrator for account creation.'
|
||||
},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
# If registration is enabled in settings, this would handle it
|
||||
# This code is kept for potential future use
|
||||
return Response(
|
||||
{'error': 'Registration endpoint not implemented'},
|
||||
status=status.HTTP_501_NOT_IMPLEMENTED
|
||||
)
|
||||
|
||||
|
||||
class UserConfigView(APIView):
|
||||
"""User configuration endpoint"""
|
||||
|
||||
def get(self, request):
|
||||
"""Get user configuration"""
|
||||
# TODO: Implement user config storage
|
||||
config = {
|
||||
'theme': 'dark',
|
||||
'items_per_page': 50,
|
||||
'audio_quality': 'best'
|
||||
}
|
||||
serializer = UserConfigSerializer(config)
|
||||
return Response(serializer.data)
|
||||
|
||||
def post(self, request):
|
||||
"""Update user configuration"""
|
||||
serializer = UserConfigSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
# TODO: Store user config
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TwoFactorStatusView(APIView):
|
||||
"""Get 2FA status"""
|
||||
|
||||
def get(self, request):
|
||||
"""Get 2FA status for current user"""
|
||||
user = request.user
|
||||
serializer = TwoFactorStatusSerializer({
|
||||
'enabled': user.two_factor_enabled,
|
||||
'backup_codes_count': len(user.backup_codes) if user.backup_codes else 0
|
||||
})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TwoFactorSetupView(APIView):
|
||||
"""Setup 2FA"""
|
||||
|
||||
def post(self, request):
|
||||
"""Generate 2FA secret and QR code"""
|
||||
user = request.user
|
||||
|
||||
# Generate new secret
|
||||
secret = generate_totp_secret()
|
||||
uri = get_totp_uri(secret, user.username)
|
||||
qr_code = generate_qr_code(uri)
|
||||
|
||||
# Generate backup codes
|
||||
backup_codes = generate_backup_codes()
|
||||
|
||||
# Store secret temporarily (not enabled yet)
|
||||
user.two_factor_secret = secret
|
||||
user.backup_codes = backup_codes
|
||||
user.save()
|
||||
|
||||
serializer = TwoFactorSetupSerializer({
|
||||
'secret': secret,
|
||||
'qr_code': qr_code,
|
||||
'backup_codes': backup_codes
|
||||
})
|
||||
return Response(serializer.data)
|
||||
|
||||
|
||||
class TwoFactorVerifyView(APIView):
|
||||
"""Verify and enable 2FA"""
|
||||
|
||||
def post(self, request):
|
||||
"""Verify 2FA code and enable"""
|
||||
serializer = TwoFactorVerifySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = request.user
|
||||
code = serializer.validated_data['code']
|
||||
|
||||
if not user.two_factor_secret:
|
||||
return Response(
|
||||
{'error': 'No 2FA setup found. Please setup 2FA first.'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
if verify_totp(user.two_factor_secret, code):
|
||||
user.two_factor_enabled = True
|
||||
user.save()
|
||||
return Response({
|
||||
'message': 'Two-factor authentication enabled successfully',
|
||||
'enabled': True
|
||||
})
|
||||
|
||||
return Response(
|
||||
{'error': 'Invalid verification code'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
class TwoFactorDisableView(APIView):
|
||||
"""Disable 2FA"""
|
||||
|
||||
def post(self, request):
|
||||
"""Disable 2FA for user"""
|
||||
serializer = TwoFactorVerifySerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
user = request.user
|
||||
code = serializer.validated_data['code']
|
||||
|
||||
if not user.two_factor_enabled:
|
||||
return Response(
|
||||
{'error': 'Two-factor authentication is not enabled'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Verify code before disabling
|
||||
if verify_totp(user.two_factor_secret, code) or code in user.backup_codes:
|
||||
user.two_factor_enabled = False
|
||||
user.two_factor_secret = None
|
||||
user.backup_codes = []
|
||||
user.save()
|
||||
return Response({
|
||||
'message': 'Two-factor authentication disabled successfully',
|
||||
'enabled': False
|
||||
})
|
||||
|
||||
return Response(
|
||||
{'error': 'Invalid verification code'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
|
||||
class TwoFactorRegenerateCodesView(APIView):
|
||||
"""Regenerate backup codes"""
|
||||
|
||||
def post(self, request):
|
||||
"""Generate new backup codes"""
|
||||
user = request.user
|
||||
|
||||
if not user.two_factor_enabled:
|
||||
return Response(
|
||||
{'error': 'Two-factor authentication is not enabled'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Generate new backup codes
|
||||
backup_codes = generate_backup_codes()
|
||||
user.backup_codes = backup_codes
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'backup_codes': backup_codes,
|
||||
'message': 'Backup codes regenerated successfully'
|
||||
})
|
||||
|
||||
|
||||
class TwoFactorDownloadCodesView(APIView):
|
||||
"""Download backup codes as PDF"""
|
||||
|
||||
def get(self, request):
|
||||
"""Generate and download backup codes PDF"""
|
||||
user = request.user
|
||||
|
||||
if not user.two_factor_enabled or not user.backup_codes:
|
||||
return Response(
|
||||
{'error': 'No backup codes available'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Generate PDF
|
||||
pdf_buffer = generate_backup_codes_pdf(user.username, user.backup_codes)
|
||||
|
||||
# Create filename: username_SoundWave_BackupCodes_YYYY-MM-DD.pdf
|
||||
filename = f"{user.username}_SoundWave_BackupCodes_{datetime.now().strftime('%Y-%m-%d')}.pdf"
|
||||
|
||||
response = HttpResponse(pdf_buffer, content_type='application/pdf')
|
||||
response['Content-Disposition'] = f'attachment; filename="{filename}"'
|
||||
|
||||
return response
|
||||
|
||||
|
||||
class AvatarUploadView(APIView):
|
||||
"""Upload user avatar"""
|
||||
parser_classes = [MultiPartParser, FormParser]
|
||||
|
||||
# Avatar directory - persistent storage
|
||||
AVATAR_DIR = Path('/app/data/avatars')
|
||||
MAX_SIZE = 20 * 1024 * 1024 # 20MB
|
||||
ALLOWED_TYPES = {'image/jpeg', 'image/png', 'image/gif', 'image/webp'}
|
||||
|
||||
def post(self, request):
|
||||
"""Upload custom avatar image"""
|
||||
if 'avatar' not in request.FILES:
|
||||
return Response(
|
||||
{'error': 'No avatar file provided'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
avatar_file = request.FILES['avatar']
|
||||
|
||||
# Validate file size
|
||||
if avatar_file.size > self.MAX_SIZE:
|
||||
return Response(
|
||||
{'error': f'File too large. Maximum size is {self.MAX_SIZE // (1024*1024)}MB'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Validate content type
|
||||
content_type = avatar_file.content_type
|
||||
if content_type not in self.ALLOWED_TYPES:
|
||||
return Response(
|
||||
{'error': f'Invalid file type. Allowed types: {", ".join(self.ALLOWED_TYPES)}'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Create avatars directory if it doesn't exist
|
||||
self.AVATAR_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Generate safe filename: username_timestamp.ext
|
||||
ext = Path(avatar_file.name).suffix or '.jpg'
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"{request.user.username}_{timestamp}{ext}"
|
||||
filepath = self.AVATAR_DIR / filename
|
||||
|
||||
# Remove old avatar file if it exists and is not a preset
|
||||
if request.user.avatar and not request.user.avatar.startswith('preset_'):
|
||||
old_path = self.AVATAR_DIR / request.user.avatar.split('/')[-1]
|
||||
if old_path.exists():
|
||||
old_path.unlink()
|
||||
|
||||
# Save file
|
||||
with open(filepath, 'wb+') as destination:
|
||||
for chunk in avatar_file.chunks():
|
||||
destination.write(chunk)
|
||||
|
||||
# Update user model
|
||||
request.user.avatar = f"avatars/{filename}"
|
||||
request.user.save()
|
||||
|
||||
return Response({
|
||||
'message': 'Avatar uploaded successfully',
|
||||
'avatar': request.user.avatar
|
||||
})
|
||||
|
||||
def delete(self, request):
|
||||
"""Remove custom avatar and reset to default"""
|
||||
user = request.user
|
||||
|
||||
# Remove file if it exists and is not a preset
|
||||
if user.avatar and not user.avatar.startswith('preset_'):
|
||||
filepath = self.AVATAR_DIR / user.avatar.split('/')[-1]
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
|
||||
user.avatar = None
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'message': 'Avatar removed successfully'
|
||||
})
|
||||
|
||||
|
||||
class AvatarPresetView(APIView):
|
||||
"""Set preset avatar"""
|
||||
|
||||
def post(self, request):
|
||||
"""Set preset avatar (1-5)"""
|
||||
preset = request.data.get('preset')
|
||||
|
||||
if not preset or not str(preset).isdigit():
|
||||
return Response(
|
||||
{'error': 'Invalid preset number'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
preset_num = int(preset)
|
||||
if preset_num < 1 or preset_num > 5:
|
||||
return Response(
|
||||
{'error': 'Preset must be between 1 and 5'},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
# Remove old custom avatar file if exists
|
||||
user = request.user
|
||||
if user.avatar and not user.avatar.startswith('preset_'):
|
||||
avatar_dir = Path('/app/data/avatars')
|
||||
filepath = avatar_dir / user.avatar.split('/')[-1]
|
||||
if filepath.exists():
|
||||
filepath.unlink()
|
||||
|
||||
# Set preset
|
||||
user.avatar = f"preset_{preset_num}"
|
||||
user.save()
|
||||
|
||||
return Response({
|
||||
'message': 'Preset avatar set successfully',
|
||||
'avatar': user.avatar
|
||||
})
|
||||
|
||||
|
||||
class AvatarFileView(APIView):
|
||||
"""Serve avatar files"""
|
||||
|
||||
def get(self, request, filename):
|
||||
"""Serve avatar file"""
|
||||
avatar_dir = Path('/app/data/avatars')
|
||||
filepath = avatar_dir / filename
|
||||
|
||||
# Security: validate path
|
||||
if not filepath.resolve().is_relative_to(avatar_dir.resolve()):
|
||||
return Response(
|
||||
{'error': 'Invalid path'},
|
||||
status=status.HTTP_403_FORBIDDEN
|
||||
)
|
||||
|
||||
if not filepath.exists():
|
||||
return Response(
|
||||
{'error': 'Avatar not found'},
|
||||
status=status.HTTP_404_NOT_FOUND
|
||||
)
|
||||
|
||||
# Determine content type
|
||||
content_type, _ = mimetypes.guess_type(str(filepath))
|
||||
if not content_type:
|
||||
content_type = 'application/octet-stream'
|
||||
|
||||
return FileResponse(open(filepath, 'rb'), content_type=content_type)
|
||||
Loading…
Add table
Add a link
Reference in a new issue