Files
offline-db/users/views.py
25beb6ede8 feat: Implement comprehensive input validation (XSS, MIME, URLs) (#330)
- Added `bio` field to User model and migrations
- Created `UserProfileUpdateSerializer` with RegexValidator (XSS) and Bleach (HTML sanitization)
- Updated `ExternalSourceSerializer` to require `https://` or `magnet:` schemes
- Replaced insecure extension checking with true MIME validation via `python-magic` in serializers (Subtitles, Images, Banners)
- Fixed NextUI floating label overlap bug on magnet link input with `labelPlacement="outside"` and a placeholder.

Co-authored-by: google-labs-jules[bot] <161369871+google-labs-jules[bot]@users.noreply.github.com>
Co-authored-by: Senpai-YoloBot <41898282+github-actions[bot]@users.noreply.github.com>
2026-03-22 13:05:46 +00:00

147 lines
6.0 KiB
Python

from rest_framework import generics, permissions, status, viewsets, mixins
from rest_framework.views import APIView
from rest_framework.response import Response
from rest_framework.pagination import PageNumberPagination
from rest_framework.throttling import ScopedRateThrottle, UserRateThrottle, AnonRateThrottle
from rest_framework.decorators import action
from rest_framework_simplejwt.views import TokenObtainPairView
from django.shortcuts import get_object_or_404
from .models import Notification, UserBadge, WatchLog, Badge
from .serializers import NotificationSerializer, UserBadgeSerializer, WatchLogSerializer, UserProfileUpdateSerializer
class LoginThrottle(AnonRateThrottle):
scope = 'login'
class CustomTokenObtainPairView(TokenObtainPairView):
throttle_classes = [LoginThrottle]
class WatchLogCreateThrottle(UserRateThrottle):
scope = 'watchlog'
def allow_request(self, request, view):
if request.method != 'POST':
return True
return super().allow_request(request, view)
class StandardResultsSetPagination(PageNumberPagination):
page_size = 20
page_size_query_param = 'page_size'
max_page_size = 100
class NotificationViewSet(mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
serializer_class = NotificationSerializer
permission_classes = [permissions.IsAuthenticated]
pagination_class = StandardResultsSetPagination
throttle_classes = [ScopedRateThrottle]
throttle_scope = 'notifications'
def get_queryset(self):
queryset = Notification.objects.filter(user=self.request.user).order_by('-created_at')
is_read_param = self.request.query_params.get('is_read')
if is_read_param is not None:
if is_read_param.lower() in ['true', '1', 'yes']:
queryset = queryset.filter(is_read=True)
elif is_read_param.lower() in ['false', '0', 'no']:
queryset = queryset.filter(is_read=False)
return queryset
@action(detail=False, methods=['get'])
def unread_count(self, request):
count = Notification.objects.filter(user=request.user, is_read=False).count()
return Response({'count': count})
@action(detail=False, methods=['post'])
def mark_all_read(self, request):
Notification.objects.filter(user=request.user, is_read=False).update(is_read=True)
return Response({'status': 'all marked as read'})
@action(detail=True, methods=['post'])
def mark_read(self, request, pk=None):
notification = self.get_object()
notification.is_read = True
notification.save()
return Response({'status': 'marked as read'})
@action(detail=False, methods=['post'], url_path='bulk-update')
def bulk_update_status(self, request):
"""
Bulk update is_read status for a list of notification IDs.
Expected payload:
{
"notification_ids": [1, 2, 3],
"is_read": true
}
"""
notification_ids = request.data.get('notification_ids', [])
is_read = request.data.get('is_read')
if not isinstance(notification_ids, list) or is_read is None:
return Response(
{"error": "Invalid payload. 'notification_ids' (list) and 'is_read' (boolean) are required."},
status=status.HTTP_400_BAD_REQUEST
)
if not notification_ids:
return Response({"status": "no notifications updated", "updated_count": 0})
# Ensure users only update their own notifications
updated_count = Notification.objects.filter(
user=request.user,
id__in=notification_ids
).update(is_read=is_read)
return Response({
"status": "success",
"updated_count": updated_count
})
class UserBadgeViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = UserBadgeSerializer
permission_classes = [permissions.IsAuthenticated]
def get_queryset(self):
return UserBadge.objects.filter(user=self.request.user).select_related('badge')
class WatchLogViewSet(mixins.CreateModelMixin, mixins.ListModelMixin, mixins.RetrieveModelMixin, viewsets.GenericViewSet):
serializer_class = WatchLogSerializer
permission_classes = [permissions.IsAuthenticated]
throttle_classes = [WatchLogCreateThrottle]
def get_queryset(self):
return WatchLog.objects.filter(user=self.request.user).order_by('-watched_at')
def perform_create(self, serializer):
serializer.save(user=self.request.user)
# Note: Badge checks are handled automatically via post_save signal in users.signals
class UserProfileAPIView(APIView):
permission_classes = [permissions.IsAuthenticated]
def get(self, request):
user = request.user
badges = UserBadge.objects.filter(user=user).select_related('badge')
# Optimization: WatchLogSerializer only serializes the 'episode' field (ID representation),
# so select_related('episode__season__anime') causes an unnecessary DB join
history = WatchLog.objects.filter(user=user).order_by('-watched_at')[:10]
# Note: In a real app, create a ProfileSerializer.
# Here constructing ad-hoc response for speed as per migration plan.
return Response({
'id': user.id,
'username': user.username,
'email': user.email,
'bio': getattr(user, 'bio', ''),
'is_premium': getattr(user, 'is_premium', False),
'date_joined': user.date_joined,
'badges': UserBadgeSerializer(badges, many=True).data,
'recent_history': WatchLogSerializer(history, many=True).data
})
def patch(self, request):
user = request.user
serializer = UserProfileUpdateSerializer(user, data=request.data, partial=True)
if serializer.is_valid():
serializer.save()
return Response(serializer.data)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)