180 lines
6.8 KiB
Python
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])
|