166 lines
7.7 KiB
Python
166 lines
7.7 KiB
Python
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/<size>/`
|
|
# 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('<int:size>/<path:hash_path>/', thumbnail_urls),
|
|
path('<path:hash_path>', media_urls),
|
|
]
|