This commit is contained in:
j3d1 2026-08-24 15:57:17 +02:00
parent 8d96bc97c4
commit ed04d98bf1
54 changed files with 661 additions and 1214 deletions

View file

@ -24,9 +24,8 @@ 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.
# Shared by media_urls and thumbnail_urls: a file is visible if the requester is
# friends-or-self with whatever references it (item, 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()) |
@ -35,8 +34,7 @@ def _accessible_files(request):
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.
# Content is hash-addressed and can never change under a given URL, so it's cacheable forever.
return {
'ETag': etag,
'Cache-Control': 'max-age=31536000, private, immutable',
@ -49,23 +47,16 @@ def _cache_headers(etag):
@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.
# CORS is added automatically by middleware, except via X-Accel-Redirect, where nginx's
# /redirect_media/ block must set it 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.
# Looked up by the derived storage path, not the raw hash, to match FileSerializer.name
# (used for AuthenticatedImage's `src`) and the existing test suite (MediaUrlTestCase).
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.
# Must run before this check, else a bare hash + If-None-Match would let anyone probe
# file existence for files they can't see.
if request.META.get('HTTP_IF_NONE_MATCH') == file.hash:
return HttpResponse(status=status.HTTP_304_NOT_MODIFIED)
@ -79,9 +70,7 @@ def media_urls(request, 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.
# Reads via FieldFile.open() (not file.file.path) since tests swap in an in-memory storage backend.
with file.file.open('rb') as fh:
content = fh.read()
return HttpResponse(status=status.HTTP_200_OK,
@ -94,9 +83,7 @@ def media_urls(request, hash_path):
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.
# Mirrors hash_upload()'s sharding under thumbnails/<size>/, reachable via the same nginx alias as originals.
return os.path.join('thumbnails', str(size), file_hash[:2], file_hash[2:4], file_hash[4:6],
file_hash[6:] + '.jpg')
@ -116,22 +103,15 @@ def thumbnail_urls(request, size, hash_path):
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.
# Read/write via default_storage, not a hand-rolled path, to work with both real-disk
# and in-memory test storage.
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.
# Always re-encoded as JPEG regardless of original format - simpler than preserving 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.
# Flatten through RGBA before dropping to RGB. See docs/implementation.md#rgba-flattening-avoids-revealing-black-under-transparent-pixels.
rgba = image.convert('RGBA')
flattened = Image.new('RGB', rgba.size, (255, 255, 255))
flattened.paste(rgba, mask=rgba.getchannel('A'))