Louis Mazin 4fd1c14d1d test
2026-10-15 16:45:02 +02:00

180 lines
6.8 KiB
Python

"""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])