Make media URLs stable and cacheable, add real image thumbnails
Signed media URLs embedded the current second (TimestampSigner), so every API response re-minted every raw/thumbnail/staged URL and the browser re-downloaded each file on every poll or navigation. Responses also carried no cache headers at all. - sign with a plain Signer plus a bucket-quantized exp (7d TTL, 24h bucket), so a URL is byte-identical across responses and rotates once a day; legacy TimestampSigner URLs stay accepted for one release - add a v=<md5> version parameter to library media URLs so replacing a file under the same J-ID (the optimize flow) busts caches exactly when needed - serve_file now sends ETag/Last-Modified and a private Cache-Control and answers conditional requests with 304; library media gets max-age 6d + immutable, staged/similarity files 1h - build cached 480px JPEG thumbnails for images (Pillow, keyed by MD5 under MEDIA_ROOT/thumbs) instead of serving full-size originals through the thumbnail endpoint; the library grid uses thumbnail_url for images too
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
"""Signed media URLs must be stable, versioned and cacheable.
|
||||
|
||||
Regression: signatures embedded the current second, so every API response
|
||||
re-minted every URL and browsers re-downloaded each image on every poll; the
|
||||
file responses also carried no cache headers at all.
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import shutil
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import Client, TestCase, override_settings
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from rest_framework.authtoken.models import Token
|
||||
|
||||
from apps.library import services
|
||||
from apps.library.models import MediaItem, MediaLocation, TempUpload
|
||||
from apps.library.signing_urls import sign_payload
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
TINY_PNG = base64.b64decode(
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
|
||||
def png_bytes(width=1200, height=800, color=(20, 120, 200)):
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (width, height), color).save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
class MediaCacheTests(TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super().setUpClass()
|
||||
cls._tmp = tempfile.mkdtemp(prefix="j621-cache-")
|
||||
cls._media = Path(cls._tmp) / "media"
|
||||
cls._watched = cls._media / "library"
|
||||
cls._watched.mkdir(parents=True, exist_ok=True)
|
||||
cls._settings = override_settings(
|
||||
MEDIA_ROOT=str(cls._media), WATCHED_FOLDER=str(cls._watched)
|
||||
)
|
||||
cls._settings.enable()
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls):
|
||||
cls._settings.disable()
|
||||
shutil.rmtree(cls._tmp, ignore_errors=True)
|
||||
super().tearDownClass()
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="cache-uploader", password="cache-pass-123456"
|
||||
)
|
||||
self.user.role = "uploader"
|
||||
self.user.save(update_fields=["role"])
|
||||
self.token = Token.objects.create(user=self.user).key
|
||||
payload = png_bytes()
|
||||
path = self._watched / "cache-image.png"
|
||||
path.write_bytes(payload)
|
||||
self.item = MediaItem.objects.create(
|
||||
md5=hashlib.md5(payload).hexdigest(), size=len(payload)
|
||||
)
|
||||
MediaLocation.objects.create(
|
||||
item=self.item, path=str(path), rel_path=path.name, mtime=time.time()
|
||||
)
|
||||
self.client = Client()
|
||||
|
||||
def signed(self, action):
|
||||
return {
|
||||
"sig": sign_payload(
|
||||
{"item": self.item.id, "user": self.user.id, "action": action},
|
||||
services.MEDIA_FILE_SALT,
|
||||
)
|
||||
}
|
||||
|
||||
def test_media_response_carries_cache_headers(self):
|
||||
response = self.client.get(
|
||||
f"/api/files/J-{self.item.id}/raw/", self.signed("raw")
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn("private", response["Cache-Control"])
|
||||
self.assertIn(
|
||||
f"max-age={services.MEDIA_CACHE_SECONDS}", response["Cache-Control"]
|
||||
)
|
||||
self.assertIn("immutable", response["Cache-Control"])
|
||||
self.assertTrue(response["ETag"])
|
||||
self.assertTrue(response["Last-Modified"])
|
||||
|
||||
def test_media_revalidation_returns_304(self):
|
||||
url = f"/api/files/J-{self.item.id}/raw/"
|
||||
first = self.client.get(url, self.signed("raw"))
|
||||
second = self.client.get(
|
||||
url, self.signed("raw"), HTTP_IF_NONE_MATCH=first["ETag"]
|
||||
)
|
||||
self.assertEqual(second.status_code, 304)
|
||||
self.assertEqual(second.content, b"")
|
||||
|
||||
def test_image_thumbnail_is_generated_and_reused(self):
|
||||
url = f"/api/files/J-{self.item.id}/thumbnail/"
|
||||
response = self.client.get(url, self.signed("thumbnail"))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response["Content-Type"], "image/jpeg")
|
||||
thumb = self._media / "thumbs" / f"{self.item.md5}.jpg"
|
||||
self.assertTrue(thumb.exists())
|
||||
with Image.open(thumb) as image:
|
||||
self.assertLessEqual(max(image.size), 480)
|
||||
before = thumb.stat().st_mtime_ns
|
||||
self.client.get(url, self.signed("thumbnail"))
|
||||
self.assertEqual(thumb.stat().st_mtime_ns, before)
|
||||
|
||||
def test_staged_files_cache_briefly(self):
|
||||
temp = TempUpload.objects.create(
|
||||
user=self.user,
|
||||
file=SimpleUploadedFile("staged.png", TINY_PNG, content_type="image/png"),
|
||||
original_filename="staged.png",
|
||||
md5=hashlib.md5(b"staged").hexdigest(),
|
||||
size=len(TINY_PNG),
|
||||
)
|
||||
signature = sign_payload(
|
||||
{"temp": str(temp.id), "user": self.user.id}, services.UPLOAD_FILE_SALT
|
||||
)
|
||||
response = self.client.get(
|
||||
f"/api/uploads/{temp.id}/file/", {"sig": signature}
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertIn(
|
||||
f"max-age={services.TEMP_CACHE_SECONDS}", response["Cache-Control"]
|
||||
)
|
||||
self.assertNotIn("immutable", response["Cache-Control"])
|
||||
Reference in New Issue
Block a user