"""Edition du site public via une interface React protégée par JWT.""" import re import uuid from pathlib import Path from django.conf import settings from django.contrib.auth import authenticate from django.http import FileResponse, Http404 from rest_framework import permissions, serializers, status, throttling from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from rest_framework_simplejwt.tokens import RefreshToken from .models import Announcement, ContactMessage, Resource, SiteContent, Testimonial KEY_PATTERN = re.compile(r'^[a-z][a-z0-9._-]{1,99}$') IMAGE_EXTENSIONS = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'} class EditorLoginThrottle(throttling.AnonRateThrottle): scope = 'editor_login' class EditorLoginView(APIView): authentication_classes = [] permission_classes = [permissions.AllowAny] throttle_classes = [EditorLoginThrottle] def post(self, request): username = str(request.data.get('username', ''))[:150] password = request.data.get('password', '') user = authenticate(request, username=username, password=password) if not user or not user.is_active or not user.is_staff: return Response({'detail': 'Identifiants incorrects ou accès administrateur refusé.'}, status=401) token = RefreshToken.for_user(user) return Response({'access': str(token.access_token), 'username': user.username}) class EditorMeView(APIView): permission_classes = [permissions.IsAdminUser] def get(self, request): return Response({'username': request.user.username, 'is_staff': True}) class SiteContentView(APIView): authentication_classes = [] permission_classes = [permissions.AllowAny] def get(self, request): return Response({record.key: record.value for record in SiteContent.objects.all()}) class SiteContentEntryView(APIView): permission_classes = [permissions.IsAdminUser] def put(self, request, key): if not KEY_PATTERN.fullmatch(key): return Response({'detail': 'Clé de contenu invalide.'}, status=400) value = request.data.get('value') if not isinstance(value, str) or len(value) > 4000: return Response({'detail': 'La valeur doit être un texte de 4000 caractères maximum.'}, status=400) # Les liens ne peuvent pas contenir de protocole actif ou de URL relative suspecte. if key.endswith('.url') or key.endswith('.href') or key.endswith('.image'): if value and not ((value.startswith('/') and not value.startswith('//')) or value.startswith('https://') or (key.endswith('.href') and re.fullmatch(r'tel:[+0-9 ().-]{3,40}', value))): return Response({'detail': 'Utilisez une route interne (/) ou une URL HTTPS.'}, status=400) entry, _ = SiteContent.objects.update_or_create(key=key, defaults={'value': value}) return Response({'key': entry.key, 'value': entry.value}) def delete(self, request, key): SiteContent.objects.filter(key=key).delete() return Response(status=status.HTTP_204_NO_CONTENT) class EditorImageUploadView(APIView): permission_classes = [permissions.IsAdminUser] parser_classes = [MultiPartParser, FormParser] def post(self, request): file = request.FILES.get('image') if not file or file.size > 5 * 1024 * 1024: return Response({'detail': 'Image absente ou supérieure à 5 Mo.'}, status=400) content_type = file.content_type or '' if content_type not in IMAGE_EXTENSIONS: return Response({'detail': 'Formats acceptés : PNG, JPEG, WebP.'}, status=400) header = file.read(16) file.seek(0) valid = { 'image/png': header.startswith(b'\x89PNG\r\n\x1a\n'), 'image/jpeg': header.startswith(b'\xff\xd8\xff'), 'image/webp': header.startswith(b'RIFF') and header[8:12] == b'WEBP', } if not valid[content_type]: return Response({'detail': 'Le contenu du fichier ne correspond pas à son format.'}, status=400) filename = f'{uuid.uuid4().hex}.{IMAGE_EXTENSIONS[content_type]}' directory = Path(settings.MEDIA_ROOT) / 'editor' directory.mkdir(parents=True, exist_ok=True) with (directory / filename).open('wb') as target: for chunk in file.chunks(): target.write(chunk) return Response({'url': f'/api/education/editor-images/{filename}'}, status=201) def serve_editor_image(request, filename): # Noms générés aléatoirement uniquement ; aucune traversée de dossier possible. if not re.fullmatch(r'[a-f0-9]{32}\.(png|jpg|webp)', filename): raise Http404() image_path = Path(settings.MEDIA_ROOT) / 'editor' / filename if not image_path.is_file(): raise Http404() ext = image_path.suffix.lower() content_type = {'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp'}[ext] return FileResponse(image_path.open('rb'), content_type=content_type) RESOURCE_FIELDS = ('title', 'description', 'category', 'kind', 'url', 'published', 'position') REVIEW_FIELDS = ('author', 'description', 'quote', 'published', 'position') ANNOUNCEMENT_FIELDS = ('title', 'body', 'cta_label', 'active', 'starts_at', 'ends_at') class EditorResourceSerializer(serializers.ModelSerializer): class Meta: model = Resource fields = ('id',) + RESOURCE_FIELDS class EditorReviewSerializer(serializers.ModelSerializer): class Meta: model = Testimonial fields = ('id',) + REVIEW_FIELDS class EditorAnnouncementSerializer(serializers.ModelSerializer): class Meta: model = Announcement fields = ('id',) + ANNOUNCEMENT_FIELDS class EditorDataView(APIView): permission_classes = [permissions.IsAdminUser] MODELS = { 'resources': (Resource, EditorResourceSerializer), 'reviews': (Testimonial, EditorReviewSerializer), 'announcements': (Announcement, EditorAnnouncementSerializer), } def get(self, request, category): if category not in (*self.MODELS.keys(), 'messages'): raise Http404() if category == 'messages': return Response(list(ContactMessage.objects.values('id', 'name', 'email', 'phone', 'level', 'message', 'read', 'created_at').order_by('-created_at')[:300])) model, serializer = self.MODELS[category] return Response(serializer(model.objects.all(), many=True).data) def post(self, request, category): if category not in (*self.MODELS.keys(), 'messages'): raise Http404() if category == 'messages': return Response({'detail': 'Action non autorisée.'}, status=405) _, serializer_class = self.MODELS[category] serializer = serializer_class(data=request.data) serializer.is_valid(raise_exception=True) instance = serializer.save() return Response(serializer_class(instance).data, status=201) class EditorDataDetailView(APIView): permission_classes = [permissions.IsAdminUser] MODELS = EditorDataView.MODELS def _get(self, category, pk): if category not in (*self.MODELS.keys(), 'messages'): raise Http404() from django.shortcuts import get_object_or_404 if category == 'messages': return get_object_or_404(ContactMessage, pk=pk) return get_object_or_404(self.MODELS[category][0], pk=pk) def patch(self, request, category, pk): instance = self._get(category, pk) if category == 'messages': if not isinstance(request.data.get('read'), bool): return Response({'detail': 'Champ read invalide.'}, status=400) instance.read = request.data['read'] instance.save(update_fields=['read']) return Response({'id': instance.id, 'read': instance.read}) serializer = self.MODELS[category][1](instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) def delete(self, request, category, pk): instance = self._get(category, pk) instance.delete() return Response(status=204)