import io import os from datetime import timedelta from django.core.files.base import ContentFile from django.core.files.storage import default_storage from django.http import HttpResponse from django.urls import path from django.db.models import Q from django.conf import settings from django.utils.http import http_date from django.utils.timezone import now from drf_yasg.utils import swagger_auto_schema from PIL import Image from rest_framework import status from rest_framework.decorators import api_view, permission_classes, authentication_classes from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from authentication.signature_auth import SignatureAuthentication from files.models import File THUMBNAIL_SIZES = (32, 64, 256) def _accessible_files(request): # Shared by media_urls and thumbnail_urls so both endpoints always agree on who can see # what - a file is visible if the requester is friends-or-self with whatever currently # references it (an inventory item, a profile picture) or it's their own staged photo. return File.objects.filter( Q(connected_items__owner__in=request.user.friends_or_self()) | Q(profile_picture_users__in=request.user.friends_or_self()) | Q(staged_by_workflows__owner__in=request.user.user.all()) ).distinct() def _cache_headers(etag): # Content is addressed by its own hash and can never change under a given URL, so caches # (and the conditional-GET checks in both views below) can treat it as immutable forever. return { 'ETag': etag, 'Cache-Control': 'max-age=31536000, private, immutable', 'Expires': http_date((now() + timedelta(days=365)).timestamp()), } @swagger_auto_schema(method='GET', auto_schema=None) @api_view(['GET']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def media_urls(request, hash_path): # Note: CORS headers are NOT set here - django-cors-headers (CorsMiddleware, # configured in settings.py) adds them to every Django response automatically, so # setting them manually on these responses would just be redundant. The one exception # is the SERVE_X_ACCEL_REDIRECT path: nginx replaces this response entirely when it # follows the X-Accel-Redirect and serves the file itself, so the CORS header for that # case has to be configured in nginx's `location /redirect_media/` block instead. # # Looked up by the derived storage path (not by raw hash) because FileSerializer.name # (files/serializers.py) - used everywhere a file URL is handed to the frontend, e.g. # AuthenticatedImage's `src` - already returns this path via Django's FileField.url, and # the existing test suite (files/tests.py MediaUrlTestCase) exercises it this way too. try: file = _accessible_files(request).get(file=hash_path) # The access-control lookup above must happen before this check - otherwise a bare # hash + If-None-Match would let anyone probe "does a file with this hash exist" for # files they can't actually see. if request.META.get('HTTP_IF_NONE_MATCH') == file.hash: return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) cache_headers = _cache_headers(file.hash) if settings.SERVE_X_ACCEL_REDIRECT: return HttpResponse(status=status.HTTP_200_OK, content_type=file.mime_type, headers={ 'X-Accel-Redirect': f'/redirect_media/{hash_path}', **cache_headers, }) else: # Read via the FieldFile itself (works against whatever storage backend is # actually configured) rather than assuming file.file.path is a real filesystem # path - the test suite swaps in an in-memory backend where that isn't true. with file.file.open('rb') as fh: content = fh.read() return HttpResponse(status=status.HTTP_200_OK, content_type=file.mime_type, headers=cache_headers, content=content) except File.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) def _thumbnail_rel_path(file_hash, size): # Mirrors files/models.py's hash_upload() sharding, under its own `thumbnails//` # subtree - reachable through the same nginx `/redirect_media/` alias as originals when # served from real disk, no separate nginx location needed. return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6], file_hash[6:] + '.jpg') @swagger_auto_schema(method='GET', auto_schema=None) @api_view(['GET']) @permission_classes([IsAuthenticated]) @authentication_classes([SignatureAuthentication]) def thumbnail_urls(request, size, hash_path): if size not in THUMBNAIL_SIZES: return Response(status=status.HTTP_404_NOT_FOUND) try: file = _accessible_files(request).get(file=hash_path) etag = f'{file.hash}_{size}' if request.META.get('HTTP_IF_NONE_MATCH') == etag: return HttpResponse(status=status.HTTP_304_NOT_MODIFIED) # Read/write through the default storage backend, same as File.file itself, rather # than a hand-rolled filesystem path - correct regardless of storage backend (real # disk in production, in-memory under the test runner) and keeps the cache in the # same place originals live. rel_path = _thumbnail_rel_path(file.hash, size) if not default_storage.exists(rel_path): # Thumbnails are always re-encoded as JPEG regardless of the original format - # smaller and simpler than preserving e.g. PNG transparency at this scale. with file.file.open('rb') as fh: image = Image.open(fh) image.thumbnail((size, size)) # Flatten through RGBA before dropping to RGB - some modes (grayscale+alpha, # palette-with-transparency, RGBA) store meaningless color/luminance data under # fully transparent pixels (often zeroed out, i.e. black). Converting straight # to RGB reveals that instead of "nothing there"; compositing onto an opaque # background first shows what the image is actually supposed to look like. rgba = image.convert('RGBA') flattened = Image.new('RGB', rgba.size, (255, 255, 255)) flattened.paste(rgba, mask=rgba.getchannel('A')) buffer = io.BytesIO() flattened.save(buffer, 'JPEG', quality=90) default_storage.save(rel_path, ContentFile(buffer.getvalue())) cache_headers = _cache_headers(etag) if settings.SERVE_X_ACCEL_REDIRECT: return HttpResponse(status=status.HTTP_200_OK, content_type='image/jpeg', headers={ 'X-Accel-Redirect': f'/redirect_media/{rel_path}', **cache_headers, }) else: with default_storage.open(rel_path, 'rb') as fh: content = fh.read() return HttpResponse(status=status.HTTP_200_OK, content_type='image/jpeg', headers=cache_headers, content=content) except File.DoesNotExist: return Response(status=status.HTTP_404_NOT_FOUND) urlpatterns = [ path('//', thumbnail_urls), path('', media_urls), ]