"""API du CMS, lecture publique et écriture réservée aux administrateurs. Authentification par session Django / protection CSRF native DRF. """ import re import uuid from pathlib import Path from django.conf import settings from django.http import FileResponse, Http404 from django.shortcuts import get_object_or_404 from rest_framework import permissions, serializers, status from rest_framework.parsers import FormParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView from .models import SiteContent, SitePage, SiteBlock KEY_PATTERN = re.compile(r'^[a-z][a-z0-9._-]{1,99}$') MEDIA_PATTERN = re.compile(r'^[a-f0-9]{32}\.(png|jpg|webp)$') IMAGE_TYPES = {'image/png': 'png', 'image/jpeg': 'jpg', 'image/webp': 'webp'} FORBIDDEN_PAGE_SLUGS = {'admin', 'api', 'static', 'media', 'home'} def is_admin(request): return request.user.is_authenticated and request.user.is_active and request.user.is_staff def allowed_link(value): return not value or (value.startswith('/') and not value.startswith('//') and not value.startswith('/\\')) or value.startswith('https://') or bool(re.fullmatch(r'tel:[+0-9 ()-]{3,40}', value)) or bool(re.fullmatch(r'mailto:[a-zA-Z0-9._+%-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}', value)) class ContentView(APIView): permission_classes = [permissions.AllowAny] def get(self, request): return Response({row.key: row.value for row in SiteContent.objects.all()}) class ContentEntryView(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) > 12000: return Response({'detail': 'Texte invalide (maximum 12 000 caractères).'}, status=400) if key.endswith(('.url', '.href', '.image', '.logo')) and not allowed_link(value): return Response({'detail': 'Utilisez un chemin interne, une URL HTTPS, mailto: ou tel:.'}, status=400) if key.startswith('theme.') and not re.fullmatch(r'#[a-fA-F0-9]{6}', value): return Response({'detail': 'Couleur hexadécimale attendue (#RRGGBB).'}, status=400) obj, _ = SiteContent.objects.update_or_create(key=key, defaults={'value': value}) return Response({'key': obj.key, 'value': obj.value}) def delete(self, request, key): SiteContent.objects.filter(key=key).delete() return Response(status=204) class PageSerializer(serializers.ModelSerializer): class Meta: model = SitePage fields = ('id', 'slug', 'title', 'description', 'nav_label', 'position', 'published', 'show_in_nav') def validate_slug(self, value): if value in FORBIDDEN_PAGE_SLUGS or value in getattr(settings, 'CMS_RESERVED_SLUGS', set()): raise serializers.ValidationError('Ce chemin est réservé.') return value class BlockSerializer(serializers.ModelSerializer): class Meta: model = SiteBlock fields = ('id', 'page_slug', 'kind', 'title', 'body', 'image', 'url', 'position', 'published') def validate(self, attrs): for field in ('url', 'image'): value = attrs.get(field, getattr(self.instance, field, '')) if not allowed_link(value): raise serializers.ValidationError({field: 'Adresse non autorisée : URL HTTPS ou chemin interne uniquement.'}) if len(attrs.get('body', '')) > 12000: raise serializers.ValidationError({'body': 'Texte trop long.'}) return attrs class CollectionView(APIView): permission_classes = [permissions.AllowAny] model = None serializer_class = None def get(self, request): items = self.model.objects.all() if not is_admin(request): items = items.filter(published=True) slug = request.query_params.get('page_slug') if slug and self.model is SiteBlock: items = items.filter(page_slug=slug) return Response(self.serializer_class(items, many=True).data) def post(self, request): if not is_admin(request): return Response({'detail': 'Accès administrateur requis.'}, status=403) serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data, status=201) class ItemView(APIView): permission_classes = [permissions.IsAdminUser] model = None serializer_class = None def patch(self, request, pk): instance = get_object_or_404(self.model, pk=pk) serializer = self.serializer_class(instance, data=request.data, partial=True) serializer.is_valid(raise_exception=True) serializer.save() return Response(serializer.data) def delete(self, request, pk): get_object_or_404(self.model, pk=pk).delete() return Response(status=204) class PagesView(CollectionView): model = SitePage serializer_class = PageSerializer class PageView(ItemView): model = SitePage serializer_class = PageSerializer class BlocksView(CollectionView): model = SiteBlock serializer_class = BlockSerializer class BlockView(ItemView): model = SiteBlock serializer_class = BlockSerializer class UploadView(APIView): permission_classes = [permissions.IsAdminUser] parser_classes = [FormParser, MultiPartParser] def post(self, request): image = request.FILES.get('image') if not image or image.size > 5 * 1024 * 1024: return Response({'detail': 'Image absente ou supérieure à 5 Mo.'}, status=400) kind = IMAGE_TYPES.get(image.content_type) if not kind: return Response({'detail': 'Formats : PNG, JPEG, WebP.'}, status=400) header = image.read(16) image.seek(0) valid = {'png': header.startswith(b'\x89PNG\r\n\x1a\n'), 'jpg': header.startswith(b'\xff\xd8\xff'), 'webp': header.startswith(b'RIFF') and header[8:12] == b'WEBP'} if not valid[kind]: return Response({'detail': 'Fichier image invalide.'}, status=400) filename = f'{uuid.uuid4().hex}.{kind}' folder = Path(settings.MEDIA_ROOT) / 'cms' folder.mkdir(parents=True, exist_ok=True) with (folder / filename).open('wb') as dest: for chunk in image.chunks(): dest.write(chunk) return Response({'url': f'/api/cms/media/{filename}'}, status=201) def serve_image(request, filename): if not MEDIA_PATTERN.fullmatch(filename): raise Http404() file = Path(settings.MEDIA_ROOT) / 'cms' / filename if not file.is_file(): raise Http404() extension = file.suffix return FileResponse(file.open('rb'), content_type={'.png': 'image/png', '.jpg': 'image/jpeg', '.webp': 'image/webp'}[extension])